Compare commits
32 commits
feat/T3-we
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ede416a3b | |||
| 20e14800ed | |||
|
|
0741a8b717 | ||
|
|
aa858bd2c1 | ||
|
|
9be1fd991b | ||
|
|
ba51a00f2b | ||
|
|
71a16d35ed | ||
|
|
641f70b68c | ||
|
|
62b8af2ab8 | ||
|
|
d8967f29f0 | ||
|
|
b3a1f588ef | ||
|
|
59ea645125 | ||
|
|
7bd9e5bfc6 | ||
|
|
8dceedca2b | ||
|
|
31a3509bd3 | ||
|
|
069ac454e5 | ||
|
|
05ba99cb4b | ||
|
|
6c38de5fed | ||
|
|
5e539ba713 | ||
|
|
c69fb128bd | ||
|
|
1e55c11dc9 | ||
|
|
f43ede9e07 | ||
|
|
e9b3b37e0d | ||
|
|
115fea39f2 | ||
|
|
9ee5449acb | ||
|
|
3035e4eac9 | ||
|
|
0e13ee5d51 | ||
|
|
d1753bb70a | ||
|
|
7560bdb4c9 | ||
|
|
17759ef564 | ||
|
|
8eb8400bad | ||
|
|
8d8a863300 |
114 changed files with 15438 additions and 80 deletions
38
.env.example
Normal file
38
.env.example
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# ---- Database ----
|
||||||
|
# Used by apps/api to connect to the postgres service defined in docker-compose.yml.
|
||||||
|
# Postgres is not published to the host; all services run inside the compose network.
|
||||||
|
DATABASE_URL=postgresql://jobhunt:***@postgres:5432/jobhunt
|
||||||
|
|
||||||
|
# ---- LLM Gateway ----
|
||||||
|
# Primary provider (default: GLM-5.2 via ollama-cloud).
|
||||||
|
LLM_PRIMARY_BASE_URL=https://api.ollama-cloud.com/v1
|
||||||
|
LLM_PRIMARY_KEY=
|
||||||
|
# Alternative env name accepted by the gateway:
|
||||||
|
OLLAMA_API_KEY=
|
||||||
|
LLM_PRIMARY_MODEL=glm-5.2
|
||||||
|
|
||||||
|
# Optional fallback provider (same shape). Leave empty to disable fallback.
|
||||||
|
LLM_FALLBACK_BASE_URL=
|
||||||
|
LLM_FALLBACK_KEY=
|
||||||
|
LLM_FALLBACK_MODEL=
|
||||||
|
|
||||||
|
# Task class routing. Cheap task classes (score, extract) must never fall back
|
||||||
|
# to a paid provider. Cheap models are expected here.
|
||||||
|
LLM_CHEAP_MODEL=glm-5.2
|
||||||
|
LLM_STRONG_MODEL=glm-5.2
|
||||||
|
|
||||||
|
# Per-task token budgets (max output tokens). Over-budget raises before the call.
|
||||||
|
LLM_BUDGET_SCORE=2000
|
||||||
|
LLM_BUDGET_EXTRACT=4000
|
||||||
|
LLM_BUDGET_CRITIQUE=6000
|
||||||
|
LLM_BUDGET_CV_ASSIST=2000
|
||||||
|
LLM_BUDGET_CL_CRITIQUE=4000
|
||||||
|
LLM_BUDGET_RESEARCH=4000
|
||||||
|
LLM_BUDGET_DEFAULT=4000
|
||||||
|
|
||||||
|
# ---- API ----
|
||||||
|
API_HOST=0.0.0.0
|
||||||
|
API_PORT=8000
|
||||||
|
|
||||||
|
# ---- Web ----
|
||||||
|
VITE_API_BASE=http://localhost:8000/api
|
||||||
98
.forgejo/workflows/ci.yml
Normal file
98
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["*"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
api-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
run: |
|
||||||
|
git init
|
||||||
|
git remote add origin https://x-access-token:${FORGEJO_TOKEN}@srvr.nu/git/hermes/jobhunt-platform.git
|
||||||
|
git fetch --depth 1 origin ${GITHUB_SHA}
|
||||||
|
git checkout FETCH_HEAD
|
||||||
|
- name: Set up Docker
|
||||||
|
run: |
|
||||||
|
docker --version
|
||||||
|
docker compose version || docker-compose --version
|
||||||
|
- name: Build and run API tests
|
||||||
|
run: |
|
||||||
|
docker compose build api-test
|
||||||
|
docker compose run --rm api-test
|
||||||
|
|
||||||
|
package-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
run: |
|
||||||
|
git init
|
||||||
|
git remote add origin https://x-access-token:${FORGEJO_TOKEN}@srvr.nu/git/hermes/jobhunt-platform.git
|
||||||
|
git fetch --depth 1 origin ${GITHUB_SHA}
|
||||||
|
git checkout FETCH_HEAD
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
- name: Install uv
|
||||||
|
run: |
|
||||||
|
pip install uv
|
||||||
|
- name: Run connectors tests
|
||||||
|
run: |
|
||||||
|
cd packages/connectors
|
||||||
|
uv venv
|
||||||
|
. .venv/bin/activate
|
||||||
|
uv pip install -e ".[dev]"
|
||||||
|
pytest -q
|
||||||
|
- name: Run llm-gateway tests
|
||||||
|
run: |
|
||||||
|
cd packages/llm-gateway
|
||||||
|
uv venv
|
||||||
|
. .venv/bin/activate
|
||||||
|
uv pip install -e ".[dev]"
|
||||||
|
pytest -q
|
||||||
|
- name: Run artifacts tests
|
||||||
|
run: |
|
||||||
|
cd packages/artifacts
|
||||||
|
uv venv
|
||||||
|
. .venv/bin/activate
|
||||||
|
uv pip install -e ".[dev]"
|
||||||
|
pytest -q
|
||||||
|
- name: Run matching tests
|
||||||
|
run: |
|
||||||
|
cd packages/matching
|
||||||
|
uv venv
|
||||||
|
. .venv/bin/activate
|
||||||
|
uv pip install -e ".[dev]"
|
||||||
|
pytest -q
|
||||||
|
|
||||||
|
web-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
run: |
|
||||||
|
git init
|
||||||
|
git remote add origin https://x-access-token:${FORGEJO_TOKEN}@srvr.nu/git/hermes/jobhunt-platform.git
|
||||||
|
git fetch --depth 1 origin ${GITHUB_SHA}
|
||||||
|
git checkout FETCH_HEAD
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
cd apps/web
|
||||||
|
npm ci
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
cd apps/web
|
||||||
|
npm run build
|
||||||
|
- name: Test
|
||||||
|
run: |
|
||||||
|
cd apps/web
|
||||||
|
npm test
|
||||||
145
.forgejo/workflows/deploy.yml
Normal file
145
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
name: Deploy to Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Leave as "auto" to bump from latest git tag, or enter a specific version (e.g. v0.1.2)'
|
||||||
|
required: false
|
||||||
|
default: 'auto'
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Build and deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
run: |
|
||||||
|
git init
|
||||||
|
git remote add origin https://x-access-token:${FORGEJO_TOKEN}@srvr.nu/git/hermes/jobhunt-platform.git
|
||||||
|
git fetch --depth 1 origin ${GITHUB_SHA}
|
||||||
|
git checkout FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Resolve version
|
||||||
|
run: |
|
||||||
|
INPUT_VERSION="${{ github.event.inputs.version }}"
|
||||||
|
if [ -z "$INPUT_VERSION" ] || [ "$INPUT_VERSION" = "auto" ]; then
|
||||||
|
git fetch --tags origin
|
||||||
|
LATEST=$(git tag --list 'v*' --sort=-v:refname | head -1)
|
||||||
|
if [ -z "$LATEST" ]; then LATEST="v0.0.0"; fi
|
||||||
|
BASE="${LATEST#v}"
|
||||||
|
MAJOR=$(echo "$BASE" | cut -d. -f1)
|
||||||
|
MINOR=$(echo "$BASE" | cut -d. -f2)
|
||||||
|
PATCH=$(echo "$BASE" | cut -d. -f3)
|
||||||
|
PATCH=$(( ${PATCH:-0} + 1 ))
|
||||||
|
VERSION="v${MAJOR:-0}.${MINOR:-0}.${PATCH}"
|
||||||
|
echo "Latest tag: $LATEST → auto-bumped to $VERSION"
|
||||||
|
else
|
||||||
|
VERSION="$INPUT_VERSION"
|
||||||
|
echo "Using manual version: $VERSION"
|
||||||
|
fi
|
||||||
|
if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
echo "ERROR: resolved version '$VERSION' is not valid semver (expected vX.Y.Z)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Tag version
|
||||||
|
run: |
|
||||||
|
git tag -d ${{ env.VERSION }} 2>/dev/null || true
|
||||||
|
git push origin --delete ${{ env.VERSION }} 2>/dev/null || true
|
||||||
|
git tag ${{ env.VERSION }}
|
||||||
|
git push origin ${{ env.VERSION }}
|
||||||
|
|
||||||
|
- name: Write production .env
|
||||||
|
env:
|
||||||
|
LLM_PRIMARY_KEY: ${{ secrets.LLM_PRIMARY_KEY }}
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
printf 'DATABASE_URL=%s\n' 'postgresql://jobhunt:jobhunt@postgres:5432/jobhunt'
|
||||||
|
printf 'LLM_PRIMARY_BASE_URL=%s\n' 'https://ollama.com/v1'
|
||||||
|
printf 'LLM_PRIMARY_KEY=%s\n' "$LLM_PRIMARY_KEY"
|
||||||
|
printf 'LLM_PRIMARY_MODEL=%s\n' 'glm-5.2'
|
||||||
|
printf 'LLM_CHEAP_MODEL=%s\n' 'glm-5.2'
|
||||||
|
printf 'LLM_STRONG_MODEL=%s\n' 'glm-5.2'
|
||||||
|
printf 'VITE_API_BASE=%s\n' '/api'
|
||||||
|
} > .env
|
||||||
|
|
||||||
|
- name: Build and start production stack
|
||||||
|
run: |
|
||||||
|
docker compose -p jobhunt -f docker-compose.prod.yml down
|
||||||
|
docker compose -p jobhunt -f docker-compose.prod.yml up --build -d
|
||||||
|
|
||||||
|
- name: Health checks with rollback
|
||||||
|
run: |
|
||||||
|
echo "Waiting for services to start..."
|
||||||
|
sleep 15
|
||||||
|
|
||||||
|
API_OK=false
|
||||||
|
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
if docker run --rm --network jobhunt_default curlimages/curl:8.5.0 \
|
||||||
|
-sf http://jobhunt-api:8000/api/health > /dev/null; then
|
||||||
|
echo "API is healthy"
|
||||||
|
API_OK=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "API check attempt $i failed, retrying in 5s..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
WEB_OK=false
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
if docker run --rm --network jobhunt_default curlimages/curl:8.5.0 \
|
||||||
|
-sf http://jobhunt-web/ > /dev/null; then
|
||||||
|
echo "Frontend is serving"
|
||||||
|
WEB_OK=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Frontend check attempt $i failed, retrying in 5s..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$API_OK" != "true" ] || [ "$WEB_OK" != "true" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════════════════"
|
||||||
|
echo " HEALTH CHECK FAILED — DIAGNOSTICS"
|
||||||
|
echo "═══════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
docker compose -p jobhunt -f docker-compose.prod.yml ps
|
||||||
|
echo ""
|
||||||
|
echo "--- API logs ---"
|
||||||
|
docker logs jobhunt-api 2>&1 | tail -80 || true
|
||||||
|
echo ""
|
||||||
|
echo "--- Postgres logs ---"
|
||||||
|
docker logs jobhunt-postgres 2>&1 | tail -30 || true
|
||||||
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════════════════"
|
||||||
|
echo " ROLLING BACK DEPLOYMENT"
|
||||||
|
echo "═══════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
docker compose -p jobhunt -f docker-compose.prod.yml down
|
||||||
|
echo ""
|
||||||
|
echo "Rolled back. Containers stopped. DB volume preserved."
|
||||||
|
echo "Read API logs above to find the root cause before redeploying."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Seed demo data (idempotent)
|
||||||
|
run: |
|
||||||
|
docker run --rm --network jobhunt_default curlimages/curl:8.5.0 \
|
||||||
|
-sf -X POST http://jobhunt-api:8000/api/concierge/seed-demo || \
|
||||||
|
echo "WARN: demo seed failed (non-fatal)"
|
||||||
|
|
||||||
|
- name: Print deploy status
|
||||||
|
run: |
|
||||||
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════════════════"
|
||||||
|
echo " Deployed ${{ env.VERSION }} to production"
|
||||||
|
echo "═══════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
docker compose -p jobhunt -f docker-compose.prod.yml ps
|
||||||
|
echo ""
|
||||||
|
echo "Web UI: http://tocke:8085"
|
||||||
|
echo "API: http://tocke:8000/api/health"
|
||||||
|
echo ""
|
||||||
36
README.md
36
README.md
|
|
@ -35,14 +35,38 @@ External comms are only possible from `approved`/`drafting` states, and only wit
|
||||||
## Quick start (POC)
|
## Quick start (POC)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # add LLM provider keys
|
cp .env.example .env # add LLM provider keys (no key = mock mode, deterministic)
|
||||||
docker compose up -d postgres
|
|
||||||
cd apps/api && uv venv .venv && . .venv/bin/activate && uv pip install -e .
|
# Backend tests + live API (DinD-safe: everything runs inside the compose network)
|
||||||
pytest # backend tests
|
docker compose run --rm api-test # 47 pytest tests
|
||||||
uvicorn app.main:app --reload
|
docker rm -f jobhunt-api-poc 2>/dev/null; \
|
||||||
cd apps/web && npm install && npm run dev
|
docker compose run -d --name jobhunt-api-poc \
|
||||||
|
--entrypoint "uvicorn app.main:app --host 0.0.0.0 --port 8000" api-test
|
||||||
|
docker exec jobhunt-api-poc curl -s localhost:8000/api/health # {"status":"ok"}
|
||||||
|
|
||||||
|
# Frontend (host): Vue dev server proxies to the API
|
||||||
|
cd apps/web && npm install && VITE_API_BASE=http://localhost:8000/api npm run dev
|
||||||
|
|
||||||
|
# Packages (host, no DB needed): artifacts + llm-gateway unit tests
|
||||||
|
cd packages/artifacts && uv venv && . .venv/bin/activate && uv pip install -e ".[dev]" && pytest -q
|
||||||
|
cd packages/llm-gateway && uv venv && . .venv/bin/activate && uv pip install -e ".[dev]" && pytest -q
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note: in sandboxed Docker-in-Docker environments host port publishing may not work; run curl/docker exec against the `jobhunt-api-poc` container directly, as above.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
POC scaffolding in progress. See `docs/` for the design.
|
POC scaffolding in progress. See `docs/` for the design.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
Screenshots will be added here as the UI stabilizes.
|
||||||
|
|
||||||
|
| View | Description | Screenshot |
|
||||||
|
|------|-------------|------------|
|
||||||
|
| Today | Daily digest with top matches, nudge cards, and cost summary | _placeholder_ |
|
||||||
|
| Onboarding Wizard | Welcome, CV import, postings fetch, done steps | _placeholder_ |
|
||||||
|
| CV Editor | Profile form, sections list, AI assist, PDF render | _placeholder_ |
|
||||||
|
| Research | Postings table with fetch form, scam column, and scoring | _placeholder_ |
|
||||||
|
| Applications Kanban | Drag-and-drop board with red-flag badges and nudge dots | _placeholder_ |
|
||||||
|
| Application Detail | Posting info, interview prep modal, cover letter, approval gate | _placeholder_ |
|
||||||
|
|
|
||||||
26
apps/api/Dockerfile.test
Normal file
26
apps/api/Dockerfile.test
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Test/POC runner image for apps/api
|
||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Copy packages from build context root
|
||||||
|
COPY packages ./packages
|
||||||
|
|
||||||
|
# Copy api app
|
||||||
|
COPY apps/api/pyproject.toml ./apps/api/
|
||||||
|
COPY apps/api/app ./apps/api/app
|
||||||
|
COPY apps/api/schema.sql ./apps/api/schema.sql
|
||||||
|
COPY apps/api/migrations ./apps/api/migrations
|
||||||
|
COPY apps/api/tests ./apps/api/tests
|
||||||
|
|
||||||
|
WORKDIR /app/apps/api
|
||||||
|
|
||||||
|
# Install the api package with dev deps, plus the local packages
|
||||||
|
RUN pip install --no-cache-dir -e ".[dev]" \
|
||||||
|
&& pip install --no-cache-dir -e /app/packages/llm-gateway \
|
||||||
|
&& pip install --no-cache-dir -e /app/packages/artifacts \
|
||||||
|
&& pip install --no-cache-dir -e /app/packages/matching \
|
||||||
|
&& pip install --no-cache-dir pypdf python-docx apscheduler
|
||||||
|
|
||||||
|
CMD ["pytest", "-q"]
|
||||||
1
apps/api/app/__init__.py
Normal file
1
apps/api/app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""jobhunt API package."""
|
||||||
16
apps/api/app/config.py
Normal file
16
apps/api/app/config.py
Normal 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"
|
||||||
80
apps/api/app/db/__init__.py
Normal file
80
apps/api/app/db/__init__.py
Normal 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)
|
||||||
82
apps/api/app/db/migrate.py
Normal file
82
apps/api/app/db/migrate.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
"""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,
|
||||||
|
email_suggestion, notification_log,
|
||||||
|
application, job_posting, cv_section, profile, schema_migrations
|
||||||
|
CASCADE
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
run_migrations(url)
|
||||||
537
apps/api/app/db/repo_app.py
Normal file
537
apps/api/app/db/repo_app.py
Normal file
|
|
@ -0,0 +1,537 @@
|
||||||
|
"""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 update_posting_cluster_id(posting_id: str, cluster_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Set the cluster_id on a job posting."""
|
||||||
|
row = execute(
|
||||||
|
"UPDATE job_posting SET cluster_id = %s WHERE id = %s RETURNING *",
|
||||||
|
(cluster_id, posting_id),
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return _normalize_posting(row)
|
||||||
|
|
||||||
|
|
||||||
|
def update_posting_apply_by(posting_id: str, apply_by: Any) -> dict[str, Any] | None:
|
||||||
|
"""Set the apply_by date on a job posting."""
|
||||||
|
row = execute(
|
||||||
|
"UPDATE job_posting SET apply_by = %s WHERE id = %s RETURNING *",
|
||||||
|
(apply_by, 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,
|
||||||
|
"cluster_id": row.get("cluster_id"),
|
||||||
|
"apply_by": row.get("apply_by").isoformat() if row.get("apply_by") 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(), last_activity_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"),
|
||||||
|
"follow_up_after_days": row.get("follow_up_after_days", 7),
|
||||||
|
"last_activity_at": row["last_activity_at"].isoformat() if row.get("last_activity_at") is not None else None,
|
||||||
|
"follow_up_snoozed_until": row.get("follow_up_snoozed_until").isoformat() if row.get("follow_up_snoozed_until") else None,
|
||||||
|
"interview_prep_artifact_id": str(row["interview_prep_artifact_id"]) if row.get("interview_prep_artifact_id") else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Follow-up nudges ---
|
||||||
|
|
||||||
|
def get_nudge_applications() -> list[dict[str, Any]]:
|
||||||
|
"""Return applications in 'sent' state past follow_up_after_days and not snoozed."""
|
||||||
|
rows = fetch_all(
|
||||||
|
"""
|
||||||
|
SELECT a.*, j.company, j.title, j.location,
|
||||||
|
(now() - a.last_activity_at) AS elapsed
|
||||||
|
FROM application a
|
||||||
|
JOIN job_posting j ON a.job_posting_id = j.id
|
||||||
|
WHERE a.state = 'sent'
|
||||||
|
AND EXTRACT(day FROM now() - a.last_activity_at) > a.follow_up_after_days
|
||||||
|
AND (a.follow_up_snoozed_until IS NULL OR a.follow_up_snoozed_until < CURRENT_DATE)
|
||||||
|
ORDER BY a.last_activity_at ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
app = _normalize_application(row)
|
||||||
|
elapsed = row.get("elapsed")
|
||||||
|
days = None
|
||||||
|
if elapsed is not None:
|
||||||
|
days = abs(int(elapsed.days))
|
||||||
|
app["days_since_sent"] = days
|
||||||
|
results.append(app)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def snooze_follow_up(app_id: str, until_date: Any) -> dict[str, Any] | None:
|
||||||
|
"""Snooze follow-up nudge for an application until a given date."""
|
||||||
|
row = execute(
|
||||||
|
"UPDATE application SET follow_up_snoozed_until = %s WHERE id = %s RETURNING *",
|
||||||
|
(until_date, app_id),
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return _normalize_application(row)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Interview prep ---
|
||||||
|
|
||||||
|
def set_interview_prep_artifact(app_id: str, artifact_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Link an interview prep artifact to the application."""
|
||||||
|
row = execute(
|
||||||
|
"""
|
||||||
|
UPDATE application
|
||||||
|
SET interview_prep_artifact_id = %s, last_activity_at = now()
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(artifact_id, app_id),
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return _normalize_application(row)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Pending approvals count ---
|
||||||
|
|
||||||
|
def count_pending_approvals() -> int:
|
||||||
|
"""Count approvals that are not yet confirmed and not expired."""
|
||||||
|
row = fetch_one(
|
||||||
|
"""
|
||||||
|
SELECT count(*) AS cnt
|
||||||
|
FROM approval
|
||||||
|
WHERE confirmed_by_user = false
|
||||||
|
AND expires_at > now()
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return 0
|
||||||
|
return int(row["cnt"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Digest (scored applications, top by score) ---
|
||||||
|
|
||||||
|
def get_digest(limit: int = 20) -> list[dict[str, Any]]:
|
||||||
|
"""Return scored applications ordered by score descending, with posting info."""
|
||||||
|
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
|
||||||
|
WHERE a.score IS NOT NULL
|
||||||
|
ORDER BY a.score DESC NULLS LAST
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
return [_normalize_application(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_upcoming_deadlines(days: int = 7) -> list[dict[str, Any]]:
|
||||||
|
"""Return applications whose job_posting has apply_by within the next *days* days.
|
||||||
|
|
||||||
|
Returns list of dicts: {application_id, title, company, apply_by}.
|
||||||
|
"""
|
||||||
|
rows = fetch_all(
|
||||||
|
"""
|
||||||
|
SELECT a.id AS application_id, j.title, j.company, j.apply_by
|
||||||
|
FROM application a
|
||||||
|
JOIN job_posting j ON a.job_posting_id = j.id
|
||||||
|
WHERE j.apply_by IS NOT NULL
|
||||||
|
AND j.apply_by >= CURRENT_DATE
|
||||||
|
AND j.apply_by <= CURRENT_DATE + %s * INTERVAL '1 day'
|
||||||
|
ORDER BY j.apply_by ASC
|
||||||
|
""",
|
||||||
|
(days,),
|
||||||
|
)
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
result.append({
|
||||||
|
"application_id": str(row["application_id"]),
|
||||||
|
"title": row["title"],
|
||||||
|
"company": row["company"],
|
||||||
|
"apply_by": row["apply_by"].isoformat() if row.get("apply_by") else None,
|
||||||
|
})
|
||||||
|
return result
|
||||||
157
apps/api/app/db/repo_profile.py
Normal file
157
apps/api/app/db/repo_profile.py
Normal 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),
|
||||||
|
}
|
||||||
468
apps/api/app/imap_watch.py
Normal file
468
apps/api/app/imap_watch.py
Normal file
|
|
@ -0,0 +1,468 @@
|
||||||
|
"""IMAP email watch: polls UNSEEN messages and classifies them.
|
||||||
|
|
||||||
|
Uses stdlib imaplib (SSL). Enabled only when EMAIL_WATCH_ENABLED=true.
|
||||||
|
Config: IMAP_HOST, IMAP_PORT, IMAP_USER, IMAP_PASS.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Connect via IMAP SSL.
|
||||||
|
2. Fetch UNSEEN messages since last poll.
|
||||||
|
3. For each message, match sender domain + subject/body keywords to
|
||||||
|
open applications (status in sent/interviewing).
|
||||||
|
4. Classify via LLM task 'email_classify' -> {classification, state_proposal, reason}.
|
||||||
|
5. Insert email_suggestion rows (skip noise and dedupe by from+subject+day).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import email
|
||||||
|
import email.utils
|
||||||
|
import hashlib
|
||||||
|
import imaplib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Sequence
|
||||||
|
|
||||||
|
from app.db import execute, fetch_all, fetch_one
|
||||||
|
from app import llm
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VALID_CLASSIFICATIONS = frozenset({
|
||||||
|
"interview_invite",
|
||||||
|
"rejection",
|
||||||
|
"question",
|
||||||
|
"noise",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Keywords for cheap pre-matching before LLM classify
|
||||||
|
INTERVIEW_KEYWORDS = ("interview", "invite", "meeting", "schedule", "call")
|
||||||
|
REJECTION_KEYWORDS = ("regret", "unfortunately", "not moving", "rejection", "position has been filled")
|
||||||
|
QUESTION_KEYWORDS = ("question", "clarif", "additional", "could you", "please provide")
|
||||||
|
NOISE_KEYWORDS = ("newsletter", "unsubscribe", "promotion", "advert", "offer")
|
||||||
|
|
||||||
|
|
||||||
|
def is_email_watch_enabled() -> bool:
|
||||||
|
"""Check if email watch is enabled."""
|
||||||
|
return os.environ.get("EMAIL_WATCH_ENABLED", "false").lower() in (
|
||||||
|
"true",
|
||||||
|
"1",
|
||||||
|
"yes",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_sender_domain(from_addr: str) -> str:
|
||||||
|
"""Extract the domain from an email From header."""
|
||||||
|
parsed = email.utils.parseaddr(from_addr)
|
||||||
|
addr = parsed[1] or from_addr
|
||||||
|
parts = addr.split("@")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
return parts[-1].lower().strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_snippet(body: str, max_len: int = 300) -> str:
|
||||||
|
"""Truncate body to a snippet."""
|
||||||
|
body = body.replace("\r", " ").replace("\n", " ").strip()
|
||||||
|
if len(body) > max_len:
|
||||||
|
return body[:max_len] + "..."
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_email_message(raw_bytes: bytes) -> dict[str, str]:
|
||||||
|
"""Parse raw email bytes into a dict with from, subject, body."""
|
||||||
|
msg = email.message_from_bytes(raw_bytes)
|
||||||
|
from_addr = msg.get("From", "")
|
||||||
|
subject = msg.get("Subject", "")
|
||||||
|
date_str = msg.get("Date", "")
|
||||||
|
|
||||||
|
# Extract body (prefer plain text)
|
||||||
|
body = ""
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
ct = part.get_content_type()
|
||||||
|
if ct == "text/plain":
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
body = payload.decode("utf-8", errors="replace")
|
||||||
|
break
|
||||||
|
if not body:
|
||||||
|
for part in msg.walk():
|
||||||
|
if part.get_content_type().startswith("text/"):
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
body = payload.decode("utf-8", errors="replace")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
payload = msg.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
body = payload.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"from": from_addr,
|
||||||
|
"subject": subject,
|
||||||
|
"body": body,
|
||||||
|
"date": date_str,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(date_str: str) -> datetime:
|
||||||
|
"""Parse RFC 2822 date string to UTC datetime. Falls back to now()."""
|
||||||
|
if date_str:
|
||||||
|
try:
|
||||||
|
parsed = email.utils.parsedate_to_datetime(date_str)
|
||||||
|
if parsed is not None:
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def match_application(
|
||||||
|
from_addr: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
applications: Sequence[dict[str, Any]],
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Match an email to an open application.
|
||||||
|
|
||||||
|
Match by:
|
||||||
|
1. Company name in subject/body
|
||||||
|
2. Sender domain in posting URL
|
||||||
|
|
||||||
|
Only matches applications with state in ('sent', 'interviewing').
|
||||||
|
"""
|
||||||
|
sender_domain = _extract_sender_domain(from_addr)
|
||||||
|
text_lower = (subject + " " + body).lower()
|
||||||
|
|
||||||
|
for app_row in applications:
|
||||||
|
state = app_row.get("state", "")
|
||||||
|
if state not in ("sent", "interviewing"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
company = (app_row.get("company") or "").lower()
|
||||||
|
title = (app_row.get("title") or "").lower()
|
||||||
|
posting_url = app_row.get("url") or ""
|
||||||
|
|
||||||
|
# Match 1: company name in subject/body
|
||||||
|
if company and len(company) > 2 and company in text_lower:
|
||||||
|
return app_row
|
||||||
|
|
||||||
|
# Match 2: sender domain in posting URL
|
||||||
|
if sender_domain and sender_domain in (posting_url or "").lower():
|
||||||
|
return app_row
|
||||||
|
|
||||||
|
# Match 3: title keywords in subject (looser)
|
||||||
|
if title and len(title) > 3:
|
||||||
|
title_words = [w for w in title.split() if len(w) > 3]
|
||||||
|
matches = sum(1 for w in title_words if w in text_lower)
|
||||||
|
if matches >= 2 and len(title_words) >= 2:
|
||||||
|
return app_row
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def classify_email(subject: str, body: str) -> dict[str, Any]:
|
||||||
|
"""Classify an email via LLM task 'email_classify'.
|
||||||
|
|
||||||
|
Returns {classification, state_proposal, reason}.
|
||||||
|
Falls back to inline keyword heuristics if LLM fails.
|
||||||
|
"""
|
||||||
|
text_lower = (subject + " " + body).lower()
|
||||||
|
|
||||||
|
result = llm.run_task(
|
||||||
|
"email_classify",
|
||||||
|
f"Subject: {subject}\nBody: {body[:1000]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
classification = result.get("classification", "noise")
|
||||||
|
state_proposal = result.get("state_proposal")
|
||||||
|
reason = result.get("reason", "")
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
if classification not in VALID_CLASSIFICATIONS:
|
||||||
|
classification = "noise"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"classification": classification,
|
||||||
|
"state_proposal": state_proposal,
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_duplicate(from_addr: str, subject: str, received_at: datetime) -> bool:
|
||||||
|
"""Check if a similar email_suggestion already exists (same from+subject+day)."""
|
||||||
|
day_start = received_at.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
row = fetch_one(
|
||||||
|
"""
|
||||||
|
SELECT id FROM email_suggestion
|
||||||
|
WHERE mailbox_from = %s AND subject = %s
|
||||||
|
AND received_at >= %s AND received_at < %s + interval '1 day'
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(from_addr, subject, day_start, day_start),
|
||||||
|
)
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
def create_email_suggestion(
|
||||||
|
application_id: str | None,
|
||||||
|
mailbox_from: str,
|
||||||
|
subject: str,
|
||||||
|
snippet: str,
|
||||||
|
classification: str,
|
||||||
|
state_proposal: str | None,
|
||||||
|
received_at: datetime,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Insert an email_suggestion row."""
|
||||||
|
row = execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO email_suggestion
|
||||||
|
(application_id, mailbox_from, subject, snippet, classification, state_proposal, status, received_at)
|
||||||
|
VALUES
|
||||||
|
(%s, %s, %s, %s, %s, %s, 'pending', %s)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(application_id, mailbox_from, subject, snippet, classification, state_proposal, received_at),
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise RuntimeError("insert email_suggestion failed")
|
||||||
|
return _normalize_suggestion(row)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_suggestion(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"application_id": str(row["application_id"]) if row.get("application_id") else None,
|
||||||
|
"mailbox_from": row["mailbox_from"],
|
||||||
|
"subject": row["subject"],
|
||||||
|
"snippet": row["snippet"],
|
||||||
|
"classification": row["classification"],
|
||||||
|
"state_proposal": row.get("state_proposal"),
|
||||||
|
"status": row["status"],
|
||||||
|
"received_at": row["received_at"].isoformat() if row.get("received_at") else None,
|
||||||
|
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_pending_suggestions() -> list[dict[str, Any]]:
|
||||||
|
"""Return all pending email_suggestions, newest first."""
|
||||||
|
rows = fetch_all(
|
||||||
|
"""
|
||||||
|
SELECT * FROM email_suggestion
|
||||||
|
WHERE status = 'pending'
|
||||||
|
ORDER BY received_at DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return [_normalize_suggestion(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_suggestion(suggestion_id: str) -> dict[str, Any] | None:
|
||||||
|
row = fetch_one("SELECT * FROM email_suggestion WHERE id = %s", (suggestion_id,))
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return _normalize_suggestion(row)
|
||||||
|
|
||||||
|
|
||||||
|
def update_suggestion_status(
|
||||||
|
suggestion_id: str,
|
||||||
|
status: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
row = execute(
|
||||||
|
"UPDATE email_suggestion SET status = %s WHERE id = %s RETURNING *",
|
||||||
|
(status, suggestion_id),
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return _normalize_suggestion(row)
|
||||||
|
|
||||||
|
|
||||||
|
def get_open_applications() -> list[dict[str, Any]]:
|
||||||
|
"""Return applications in sent/interviewing state for matching."""
|
||||||
|
rows = fetch_all(
|
||||||
|
"""
|
||||||
|
SELECT a.*, j.company, j.title, j.url
|
||||||
|
FROM application a
|
||||||
|
JOIN job_posting j ON a.job_posting_id = j.id
|
||||||
|
WHERE a.state IN ('sent', 'interviewing')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
result.append({
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"state": row["state"],
|
||||||
|
"company": row.get("company", ""),
|
||||||
|
"title": row.get("title", ""),
|
||||||
|
"url": row.get("url", ""),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# --- IMAP poll ---
|
||||||
|
|
||||||
|
class FakeImap:
|
||||||
|
"""Test double for imaplib IMAP4_SSL. No network.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
fake = FakeImap(messages=[(uid1, raw1), (uid2, raw2)])
|
||||||
|
poll_inbox(fake) # uses fake instead of real connection
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, messages: list[tuple[bytes, bytes]] | None = None) -> None:
|
||||||
|
# messages: list of (uid, raw_email_bytes)
|
||||||
|
self._messages = messages or []
|
||||||
|
self._seen_uids: set[bytes] = set()
|
||||||
|
self.selected = False
|
||||||
|
|
||||||
|
def select(self, mailbox: str = "INBOX") -> tuple[str, list[bytes]]:
|
||||||
|
self.selected = True
|
||||||
|
count = len(self._messages)
|
||||||
|
return ("OK", [str(count).encode()])
|
||||||
|
|
||||||
|
def search(self, charset: str | None, *criteria: str) -> tuple[str, list[bytes]]:
|
||||||
|
# Return uids of messages matching criteria (we keep it simple)
|
||||||
|
uids = [uid for uid, _ in self._messages]
|
||||||
|
return ("OK", [b" ".join(uids)])
|
||||||
|
|
||||||
|
def fetch(self, uid: bytes, parts: str) -> tuple[str, list[tuple[bytes, bytes]]]:
|
||||||
|
for msg_uid, raw in self._messages:
|
||||||
|
if msg_uid == uid:
|
||||||
|
return ("OK", [(uid, raw)])
|
||||||
|
return ("OK", [])
|
||||||
|
|
||||||
|
def store(self, uid: bytes, flags: str, flag_set: str) -> tuple[str, list[bytes]]:
|
||||||
|
self._seen_uids.add(uid)
|
||||||
|
return ("OK", [uid])
|
||||||
|
|
||||||
|
def close(self) -> tuple[str, list[bytes]]:
|
||||||
|
self.selected = False
|
||||||
|
return ("OK", [b""])
|
||||||
|
|
||||||
|
def logout(self) -> tuple[str, list[bytes]]:
|
||||||
|
return ("OK", [b"BYE"])
|
||||||
|
|
||||||
|
|
||||||
|
def _connect_imap() -> Any:
|
||||||
|
"""Connect to IMAP server using env config."""
|
||||||
|
host = os.environ.get("IMAP_HOST", "")
|
||||||
|
port = int(os.environ.get("IMAP_PORT", "993"))
|
||||||
|
user = os.environ.get("IMAP_USER", "")
|
||||||
|
password = os.environ.get("IMAP_PASS", "")
|
||||||
|
|
||||||
|
conn = imaplib.IMAP4_SSL(host, port)
|
||||||
|
conn.login(user, password)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def poll_inbox(conn: Any = None) -> list[dict[str, Any]]:
|
||||||
|
"""Poll the inbox for UNSEEN messages, classify, and create suggestions.
|
||||||
|
|
||||||
|
If conn is provided (e.g. FakeImap for tests), uses it instead of connecting.
|
||||||
|
Returns list of created suggestions.
|
||||||
|
|
||||||
|
No network is used when conn is a FakeImap.
|
||||||
|
"""
|
||||||
|
created: list[dict[str, Any]] = []
|
||||||
|
own_conn = False
|
||||||
|
|
||||||
|
if conn is None:
|
||||||
|
conn = _connect_imap()
|
||||||
|
own_conn = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.select("INBOX")
|
||||||
|
status, data = conn.search(None, "UNSEEN")
|
||||||
|
if status != "OK":
|
||||||
|
logger.warning("imap_watch: search failed: %s", status)
|
||||||
|
return created
|
||||||
|
|
||||||
|
uids = []
|
||||||
|
if data and data[0]:
|
||||||
|
uids = data[0].split()
|
||||||
|
|
||||||
|
if not uids:
|
||||||
|
return created
|
||||||
|
|
||||||
|
# Get open applications for matching
|
||||||
|
applications = get_open_applications()
|
||||||
|
|
||||||
|
for uid in uids:
|
||||||
|
status, fetch_data = conn.fetch(uid, "(RFC822)")
|
||||||
|
if status != "OK" or not fetch_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_bytes = b""
|
||||||
|
for item in fetch_data:
|
||||||
|
if isinstance(item, tuple) and len(item) >= 2:
|
||||||
|
raw_bytes = item[1]
|
||||||
|
break
|
||||||
|
|
||||||
|
if not raw_bytes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parsed = _parse_email_message(raw_bytes)
|
||||||
|
from_addr = parsed["from"]
|
||||||
|
subject = parsed["subject"]
|
||||||
|
body = parsed["body"]
|
||||||
|
received_at = _parse_date(parsed["date"])
|
||||||
|
snippet = _build_snippet(body)
|
||||||
|
|
||||||
|
# Dedupe: same from+subject+day
|
||||||
|
if is_duplicate(from_addr, subject, received_at):
|
||||||
|
logger.debug("imap_watch: dedupe skip: %s / %s", from_addr, subject)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Match to application
|
||||||
|
app_row = match_application(from_addr, subject, body, applications)
|
||||||
|
|
||||||
|
# Classify
|
||||||
|
classification_result = classify_email(subject, body)
|
||||||
|
classification = classification_result["classification"]
|
||||||
|
state_proposal = classification_result["state_proposal"]
|
||||||
|
|
||||||
|
# Skip pure noise (don't create suggestion rows)
|
||||||
|
if classification == "noise":
|
||||||
|
continue
|
||||||
|
|
||||||
|
suggestion = create_email_suggestion(
|
||||||
|
application_id=app_row["id"] if app_row else None,
|
||||||
|
mailbox_from=from_addr,
|
||||||
|
subject=subject,
|
||||||
|
snippet=snippet,
|
||||||
|
classification=classification,
|
||||||
|
state_proposal=state_proposal,
|
||||||
|
received_at=received_at,
|
||||||
|
)
|
||||||
|
created.append(suggestion)
|
||||||
|
|
||||||
|
# Send notification for interview invites
|
||||||
|
if classification == "interview_invite" and app_row:
|
||||||
|
from app.notify import send_notification
|
||||||
|
send_notification(
|
||||||
|
"email_suggestion",
|
||||||
|
f"Interview invite from {app_row.get('company', 'unknown')}",
|
||||||
|
{
|
||||||
|
"suggestion_id": suggestion["id"],
|
||||||
|
"application_id": app_row["id"],
|
||||||
|
"classification": classification,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark as seen
|
||||||
|
try:
|
||||||
|
conn.store(uid, "+FLAGS", "\\Seen")
|
||||||
|
except Exception:
|
||||||
|
logger.debug("imap_watch: store failed for uid %s", uid)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if own_conn:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
conn.logout()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return created
|
||||||
222
apps/api/app/llm.py
Normal file
222
apps/api/app/llm.py
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
"""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},
|
||||||
|
},
|
||||||
|
"red_flags": [],
|
||||||
|
},
|
||||||
|
"cv_extract": {
|
||||||
|
"drafts": [
|
||||||
|
{
|
||||||
|
"kind": "experience",
|
||||||
|
"title": "Software Engineer",
|
||||||
|
"org": "Extracted Company",
|
||||||
|
"location": "Malmo",
|
||||||
|
"start_date": "2020-01",
|
||||||
|
"end_date": None,
|
||||||
|
"bullets": ["Developed web applications", "Led team of 3"],
|
||||||
|
"tags": ["python", "javascript"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "education",
|
||||||
|
"title": "MSc Computer Science",
|
||||||
|
"org": "Lund University",
|
||||||
|
"location": "Lund",
|
||||||
|
"start_date": "2016-09",
|
||||||
|
"end_date": "2018-06",
|
||||||
|
"bullets": ["Specialized in distributed systems"],
|
||||||
|
"tags": ["algorithms", "distributed systems"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"interview_prep": {
|
||||||
|
"content": (
|
||||||
|
"# Interview Prep\n\n"
|
||||||
|
"## Q1: Tell us about yourself\n"
|
||||||
|
"**Suggested angle:** Highlight your experience with Python and FastAPI, "
|
||||||
|
"and your ability to deliver features on time.\n\n"
|
||||||
|
"## Q2: Why are you interested in this role?\n"
|
||||||
|
"**Suggested angle:** Reference the specific technologies mentioned in the "
|
||||||
|
"posting and your experience with similar stacks.\n\n"
|
||||||
|
"## Q3: Describe a challenging project\n"
|
||||||
|
"**Suggested angle:** Use the STAR method. Reference your experience building "
|
||||||
|
"distributed systems at Lund University.\n\n"
|
||||||
|
"## Q4: How do you handle tight deadlines?\n"
|
||||||
|
"**Suggested angle:** Mention your track record of delivering 2 weeks ahead "
|
||||||
|
"of schedule and your automation-first approach.\n\n"
|
||||||
|
"## Q5: What are your salary expectations?\n"
|
||||||
|
"**Suggested angle:** Research market rates for the Skane region. "
|
||||||
|
"Be prepared to give a range.\n\n"
|
||||||
|
"## Q6: Tell us about a time you failed\n"
|
||||||
|
"**Suggested angle:** Pick something real but not catastrophic. Show what "
|
||||||
|
"you learned and how you changed your approach.\n\n"
|
||||||
|
"## Q7: How do you stay current with technology?\n"
|
||||||
|
"**Suggested angle:** Mention your tags: python, javascript, distributed "
|
||||||
|
"systems. Talk about hands-on side projects.\n\n"
|
||||||
|
"## Q8: Describe your ideal work environment\n"
|
||||||
|
"**Suggested angle:** Be honest but flexible. Mention collaboration and "
|
||||||
|
"autonomy.\n\n"
|
||||||
|
"## Q9: What questions do you have for us?\n"
|
||||||
|
"**Suggested angle:** Ask about team structure, current projects, and "
|
||||||
|
"growth opportunities.\n\n"
|
||||||
|
"## Q10: Why should we hire you?\n"
|
||||||
|
"**Suggested angle:** Summarize your top 3 qualifications matching the "
|
||||||
|
"posting requirements. Be specific."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"email_classify": {
|
||||||
|
"classification": "interview_invite",
|
||||||
|
"state_proposal": "interviewing",
|
||||||
|
"reason": "The email mentions an interview invitation.",
|
||||||
|
},
|
||||||
|
"cv_tailor": {
|
||||||
|
"tailored_cv": {
|
||||||
|
"summary": "Senior Python Developer with 6+ years building scalable backend systems.",
|
||||||
|
"skills": [
|
||||||
|
"Python",
|
||||||
|
"Fast API",
|
||||||
|
"PostgreSQL",
|
||||||
|
"Docker",
|
||||||
|
"Kubernetes",
|
||||||
|
"AWS",
|
||||||
|
],
|
||||||
|
"experience": [
|
||||||
|
{
|
||||||
|
"company": "TechCorp",
|
||||||
|
"role": "Senior Backend Engineer",
|
||||||
|
"bullets": [
|
||||||
|
"Led migration of monolith to microservices using Fast API",
|
||||||
|
"Reduced API latency by 40% through query optimization and caching",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"change_log": [
|
||||||
|
{"action": "reordered", "detail": "Moved Python and Fast API to top of skills"},
|
||||||
|
{"action": "rephrased", "detail": "Rewrote first experience bullet to emphasize Fast API"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"deadline_extract": {
|
||||||
|
"apply_by": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_cv_tailor(prompt: str) -> dict[str, Any]:
|
||||||
|
"""Prompt-aware mock tailor: extracts source bullets from the prompt and
|
||||||
|
rephrases them deterministically, so the result always passes the
|
||||||
|
hallucination guard (which requires traceable source overlap)."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
bullets = re.findall(r'"([A-ZÅÄÖ][^"]{20,300})"', prompt)
|
||||||
|
bullets = [b for b in bullets if "{" not in b and ":" not in b][:4]
|
||||||
|
if not bullets:
|
||||||
|
bullets = ["Experienced backend developer focused on reliability"]
|
||||||
|
tailored = []
|
||||||
|
change_log = []
|
||||||
|
for b in bullets[:2]:
|
||||||
|
tailored.append(f"{b} (tailored for this posting)")
|
||||||
|
change_log.append({"action": "rephrased", "detail": f"Emphasized relevance of {b[:60]}"})
|
||||||
|
for b in bullets[2:]:
|
||||||
|
tailored.append(b)
|
||||||
|
change_log.append({"action": "kept", "detail": f"Retained as-is {b[:60]}"})
|
||||||
|
return {
|
||||||
|
"tailored_cv": {"summary": bullets[0][:160], "bullets": tailored},
|
||||||
|
"change_log": change_log,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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"})
|
||||||
|
if task == "cv_tailor":
|
||||||
|
result = _mock_cv_tailor(prompt)
|
||||||
|
|
||||||
|
# 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
|
||||||
1732
apps/api/app/main.py
Normal file
1732
apps/api/app/main.py
Normal file
File diff suppressed because it is too large
Load diff
183
apps/api/app/notify.py
Normal file
183
apps/api/app/notify.py
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
"""Notification channels: LogChannel + WebhookChannel.
|
||||||
|
|
||||||
|
Protocol-based: NotificationChannel defines send(kind, text, data) -> bool.
|
||||||
|
LogChannel writes to notification_log (delivered=true always).
|
||||||
|
WebhookChannel POSTs to NOTIFY_WEBHOOK_URL (2xx=delivered, else error row).
|
||||||
|
|
||||||
|
send_notification(kind, text, data) is the public entry point used by
|
||||||
|
the scheduler and email watch modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from app.db import execute, fetch_all
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationChannel(Protocol):
|
||||||
|
"""Protocol for notification delivery channels."""
|
||||||
|
|
||||||
|
def send(self, kind: str, text: str, data: dict[str, Any]) -> bool:
|
||||||
|
"""Send a notification. Returns True if delivered, False otherwise."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_log_row(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"]),
|
||||||
|
"channel": row["channel"],
|
||||||
|
"kind": row["kind"],
|
||||||
|
"payload": payload or {},
|
||||||
|
"delivered": row["delivered"],
|
||||||
|
"error": row.get("error"),
|
||||||
|
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LogChannel:
|
||||||
|
"""Writes notification entries to notification_log with delivered=true."""
|
||||||
|
|
||||||
|
channel_name: str = "log"
|
||||||
|
|
||||||
|
def send(self, kind: str, text: str, data: dict[str, Any]) -> bool:
|
||||||
|
row = execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO notification_log (channel, kind, payload, delivered, error)
|
||||||
|
VALUES (%s, %s, %s, true, NULL)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(self.channel_name, kind, json.dumps({"text": text, **data})),
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
logger.error("LogChannel: insert notification_log failed")
|
||||||
|
return False
|
||||||
|
logger.info("LogChannel: delivered kind=%s", kind)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookChannel:
|
||||||
|
"""POSTs notification payload to NOTIFY_WEBHOOK_URL.
|
||||||
|
|
||||||
|
On 2xx response: writes notification_log with delivered=true.
|
||||||
|
On non-2xx or exception: writes notification_log with delivered=false and error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
channel_name: str = "webhook"
|
||||||
|
|
||||||
|
def __init__(self, url: str | None = None) -> None:
|
||||||
|
self.url = url or os.environ.get("NOTIFY_WEBHOOK_URL", "")
|
||||||
|
|
||||||
|
def send(self, kind: str, text: str, data: dict[str, Any]) -> bool:
|
||||||
|
payload: dict[str, Any] = {"kind": kind, "text": text, "data": data}
|
||||||
|
error: str | None = None
|
||||||
|
delivered = False
|
||||||
|
|
||||||
|
if not self.url:
|
||||||
|
error = "NOTIFY_WEBHOOK_URL not configured"
|
||||||
|
logger.warning("WebhookChannel: %s", error)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
resp = httpx.post(self.url, json=payload, timeout=10)
|
||||||
|
if 200 <= resp.status_code < 300:
|
||||||
|
delivered = True
|
||||||
|
else:
|
||||||
|
error = f"HTTP {resp.status_code}: {resp.text[:200]}"
|
||||||
|
logger.warning("WebhookChannel: %s", error)
|
||||||
|
except Exception as exc:
|
||||||
|
error = str(exc)
|
||||||
|
logger.warning("WebhookChannel: exception: %s", error)
|
||||||
|
|
||||||
|
row = execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO notification_log (channel, kind, payload, delivered, error)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
self.channel_name,
|
||||||
|
kind,
|
||||||
|
json.dumps(payload),
|
||||||
|
delivered,
|
||||||
|
error,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
logger.error("WebhookChannel: insert notification_log failed")
|
||||||
|
return delivered
|
||||||
|
|
||||||
|
|
||||||
|
# --- Channel registry ---
|
||||||
|
|
||||||
|
_channels: list[NotificationChannel] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_channels() -> list[NotificationChannel]:
|
||||||
|
"""Return the list of active notification channels.
|
||||||
|
|
||||||
|
LogChannel is always included.
|
||||||
|
WebhookChannel is included when NOTIFY_WEBHOOK_URL is set.
|
||||||
|
"""
|
||||||
|
global _channels
|
||||||
|
if _channels is not None:
|
||||||
|
return _channels
|
||||||
|
|
||||||
|
channels: list[NotificationChannel] = [LogChannel()]
|
||||||
|
webhook_url = os.environ.get("NOTIFY_WEBHOOK_URL", "").strip()
|
||||||
|
if webhook_url:
|
||||||
|
channels.append(WebhookChannel())
|
||||||
|
_channels = channels
|
||||||
|
return channels
|
||||||
|
|
||||||
|
|
||||||
|
def set_channels(channels: list[NotificationChannel] | None) -> None:
|
||||||
|
"""Override channel list (for testing)."""
|
||||||
|
global _channels
|
||||||
|
_channels = channels
|
||||||
|
|
||||||
|
|
||||||
|
def reset_channels() -> None:
|
||||||
|
"""Reset to default (for testing)."""
|
||||||
|
global _channels
|
||||||
|
_channels = None
|
||||||
|
|
||||||
|
|
||||||
|
def send_notification(kind: str, text: str, data: dict[str, Any] | None = None) -> None:
|
||||||
|
"""Send a notification via all active channels.
|
||||||
|
|
||||||
|
kind: e.g. 'daily_digest', 'email_suggestion'
|
||||||
|
text: human-readable notification text
|
||||||
|
data: structured payload dict
|
||||||
|
"""
|
||||||
|
chs = get_channels()
|
||||||
|
payload = data or {}
|
||||||
|
for ch in chs:
|
||||||
|
try:
|
||||||
|
ch.send(kind, text, payload)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("send_notification: channel %s failed", type(ch).__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Repository helpers ---
|
||||||
|
|
||||||
|
def list_notification_log(limit: int = 50) -> list[dict[str, Any]]:
|
||||||
|
"""Return recent notification_log rows, newest first."""
|
||||||
|
rows = fetch_all(
|
||||||
|
"""
|
||||||
|
SELECT * FROM notification_log
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
return [_normalize_log_row(r) for r in rows]
|
||||||
178
apps/api/app/scheduler.py
Normal file
178
apps/api/app/scheduler.py
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
"""APScheduler integration: daily fetch + batch score + imap poll + digest.
|
||||||
|
|
||||||
|
Starts during app lifespan when SCHEDULER_ENABLED=true (default false).
|
||||||
|
Jobs:
|
||||||
|
- daily_fetch_score at 07:00: fetch postings + batch score
|
||||||
|
- daily_digest at 07:30: send daily digest notification
|
||||||
|
- imap_poll every 15 min: poll inbox for new emails (gated by EMAIL_WATCH_ENABLED)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_scheduler = None
|
||||||
|
|
||||||
|
|
||||||
|
def is_scheduler_enabled() -> bool:
|
||||||
|
"""Check if the scheduler is enabled via env."""
|
||||||
|
return os.environ.get("SCHEDULER_ENABLED", "false").lower() in (
|
||||||
|
"true",
|
||||||
|
"1",
|
||||||
|
"yes",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_email_watch_enabled() -> bool:
|
||||||
|
"""Check if email watch is enabled via env."""
|
||||||
|
return os.environ.get("EMAIL_WATCH_ENABLED", "false").lower() in (
|
||||||
|
"true",
|
||||||
|
"1",
|
||||||
|
"yes",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _daily_fetch_and_score() -> None:
|
||||||
|
"""Daily job: fetch postings and batch-score pending applications."""
|
||||||
|
logger.info("Scheduler: running daily fetch + batch score")
|
||||||
|
try:
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from app.main import _fetch_and_create_postings, _batch_score_internal
|
||||||
|
|
||||||
|
# Fetch default query
|
||||||
|
fetch_result = _fetch_and_create_postings(
|
||||||
|
query="developer",
|
||||||
|
region="Skane lan",
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Scheduler: fetched %s new, %s dupes",
|
||||||
|
fetch_result.get("new", 0),
|
||||||
|
fetch_result.get("dupes", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Batch score all discovered applications
|
||||||
|
from app.db import repo_app
|
||||||
|
apps = repo_app.list_applications()
|
||||||
|
discovered_ids = [
|
||||||
|
a["id"] for a in apps if a["state"] == "discovered"
|
||||||
|
]
|
||||||
|
if discovered_ids:
|
||||||
|
results = _batch_score_internal(discovered_ids)
|
||||||
|
logger.info(
|
||||||
|
"Scheduler: batch-scored %s applications", len(results)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Scheduler: daily job failed")
|
||||||
|
|
||||||
|
|
||||||
|
async def _daily_digest() -> None:
|
||||||
|
"""Daily digest: build /today payload text and send notification."""
|
||||||
|
logger.info("Scheduler: running daily digest")
|
||||||
|
try:
|
||||||
|
from app.notify import send_notification
|
||||||
|
from app.db import repo_app
|
||||||
|
|
||||||
|
digest_apps = repo_app.get_digest(limit=20)
|
||||||
|
nudge_apps = repo_app.get_nudge_applications()
|
||||||
|
pending = repo_app.count_pending_approvals()
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append("Daily Digest")
|
||||||
|
lines.append(f"Scored applications: {len(digest_apps)}")
|
||||||
|
if digest_apps:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Top opportunities:")
|
||||||
|
for item in digest_apps[:5]:
|
||||||
|
score = item.get("score")
|
||||||
|
score_str = f" (score: {int(score)})" if score else ""
|
||||||
|
lines.append(
|
||||||
|
f" - {item.get('title', '?')} at {item.get('company', '?')}{score_str}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if nudge_apps:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Follow-up nudges: {len(nudge_apps)}")
|
||||||
|
for n in nudge_apps[:5]:
|
||||||
|
lines.append(
|
||||||
|
f" - {n.get('title', '?')} at {n.get('company', '?')}"
|
||||||
|
f" ({n.get('days_since_sent', 0)} days since sent)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if pending:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Pending approvals: {pending}")
|
||||||
|
|
||||||
|
text = "\n".join(lines)
|
||||||
|
send_notification("daily_digest", text, {
|
||||||
|
"digest_count": len(digest_apps),
|
||||||
|
"nudge_count": len(nudge_apps),
|
||||||
|
"pending_approvals": pending,
|
||||||
|
})
|
||||||
|
logger.info("Scheduler: daily digest sent")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Scheduler: daily digest failed")
|
||||||
|
|
||||||
|
|
||||||
|
async def _imap_poll() -> None:
|
||||||
|
"""IMAP poll job: check for new emails and create suggestions."""
|
||||||
|
if not is_email_watch_enabled():
|
||||||
|
return
|
||||||
|
logger.info("Scheduler: running imap poll")
|
||||||
|
try:
|
||||||
|
from app.imap_watch import poll_inbox
|
||||||
|
created = poll_inbox()
|
||||||
|
logger.info("Scheduler: imap poll created %s suggestions", len(created))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Scheduler: imap poll failed")
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler() -> None:
|
||||||
|
"""Start the APScheduler if enabled."""
|
||||||
|
global _scheduler
|
||||||
|
if not is_scheduler_enabled():
|
||||||
|
logger.info("Scheduler disabled (SCHEDULER_ENABLED != true)")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
except ImportError:
|
||||||
|
logger.warning(
|
||||||
|
"APScheduler not installed; scheduler will not start."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
_scheduler = AsyncIOScheduler()
|
||||||
|
_scheduler.add_job(
|
||||||
|
_daily_fetch_and_score,
|
||||||
|
CronTrigger(hour=7, minute=0),
|
||||||
|
id="daily_fetch_score",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
_scheduler.add_job(
|
||||||
|
_daily_digest,
|
||||||
|
CronTrigger(hour=7, minute=30),
|
||||||
|
id="daily_digest",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
_scheduler.add_job(
|
||||||
|
_imap_poll,
|
||||||
|
IntervalTrigger(minutes=15),
|
||||||
|
id="imap_poll",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
_scheduler.start()
|
||||||
|
logger.info("Scheduler started: daily fetch+score at 07:00, digest at 07:30, imap poll every 15 min")
|
||||||
|
|
||||||
|
|
||||||
|
def stop_scheduler() -> None:
|
||||||
|
"""Stop the scheduler if running."""
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler is not None:
|
||||||
|
_scheduler.shutdown(wait=False)
|
||||||
|
_scheduler = None
|
||||||
|
logger.info("Scheduler stopped")
|
||||||
391
apps/api/app/schemas.py
Normal file
391
apps/api/app/schemas.py
Normal file
|
|
@ -0,0 +1,391 @@
|
||||||
|
"""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
|
||||||
|
cluster_id: str | None = None
|
||||||
|
apply_by: 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
|
||||||
|
# v1 follow-up fields
|
||||||
|
follow_up_after_days: int = 7
|
||||||
|
last_activity_at: str | None = None
|
||||||
|
follow_up_snoozed_until: str | None = None
|
||||||
|
interview_prep_artifact_id: 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]
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1: CV Import ---
|
||||||
|
|
||||||
|
class CvImportRequest(BaseModel):
|
||||||
|
filename: str
|
||||||
|
content_base64: str
|
||||||
|
|
||||||
|
|
||||||
|
class CvDraft(BaseModel):
|
||||||
|
kind: str = "experience"
|
||||||
|
title: str = ""
|
||||||
|
org: str | None = None
|
||||||
|
location: str | None = None
|
||||||
|
start_date: str | None = None
|
||||||
|
end_date: str | None = None
|
||||||
|
bullets: list[str] = Field(default_factory=list)
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CvImportResponse(BaseModel):
|
||||||
|
drafts: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class CvImportConfirmRequest(BaseModel):
|
||||||
|
drafts: list[CvDraft]
|
||||||
|
|
||||||
|
|
||||||
|
class CvImportConfirmResponse(BaseModel):
|
||||||
|
created: int
|
||||||
|
sections: list[CvSectionOut]
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1: Postings Fetch ---
|
||||||
|
|
||||||
|
class PostingsFetchRequest(BaseModel):
|
||||||
|
query: str
|
||||||
|
region: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PostingsFetchResponse(BaseModel):
|
||||||
|
new: int
|
||||||
|
dupes: int
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1: Batch Scoring ---
|
||||||
|
|
||||||
|
class BatchScoreRequest(BaseModel):
|
||||||
|
application_ids: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class BatchScoreResult(BaseModel):
|
||||||
|
application_id: str
|
||||||
|
score: float
|
||||||
|
rationale: dict[str, Any]
|
||||||
|
red_flags: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class BatchScoreResponse(BaseModel):
|
||||||
|
results: list[BatchScoreResult]
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1: Today ---
|
||||||
|
|
||||||
|
class DigestItem(BaseModel):
|
||||||
|
application_id: str
|
||||||
|
title: str
|
||||||
|
company: str
|
||||||
|
score: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class NudgeItem(BaseModel):
|
||||||
|
application_id: str
|
||||||
|
days_since_sent: int
|
||||||
|
suggestion: str
|
||||||
|
|
||||||
|
|
||||||
|
class TodayResponse(BaseModel):
|
||||||
|
digest: list[DigestItem]
|
||||||
|
nudges: list[NudgeItem]
|
||||||
|
pending_approvals: int
|
||||||
|
deadlines: list[DeadlineItem] = []
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1: Interview Prep ---
|
||||||
|
|
||||||
|
class InterviewPrepResponse(BaseModel):
|
||||||
|
artifact_id: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1: Concierge Seed ---
|
||||||
|
|
||||||
|
class SeedDemoResponse(BaseModel):
|
||||||
|
profile: str
|
||||||
|
postings: int
|
||||||
|
applications: int
|
||||||
|
sections: int
|
||||||
|
clusters: int = 0
|
||||||
|
deadlines: int = 0
|
||||||
|
suggestions: int = 0
|
||||||
|
notifications: int = 0
|
||||||
|
task_runs: int = 0
|
||||||
|
cv_artifacts: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1.1: Email Suggestions ---
|
||||||
|
|
||||||
|
class EmailSuggestionOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
application_id: str | None = None
|
||||||
|
mailbox_from: str
|
||||||
|
subject: str
|
||||||
|
snippet: str
|
||||||
|
classification: str
|
||||||
|
state_proposal: str | None = None
|
||||||
|
status: str
|
||||||
|
received_at: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1.1: Notification Log ---
|
||||||
|
|
||||||
|
class NotificationLogOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
channel: str
|
||||||
|
kind: str
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
delivered: bool
|
||||||
|
error: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1.1: Clusters ---
|
||||||
|
|
||||||
|
class ClusterPostingOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
company: str
|
||||||
|
source: str
|
||||||
|
url: str
|
||||||
|
score: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ClusterOut(BaseModel):
|
||||||
|
cluster_id: str
|
||||||
|
postings: list[ClusterPostingOut] = []
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1.1: Tailor CV ---
|
||||||
|
|
||||||
|
class TailorCvResponse(BaseModel):
|
||||||
|
artifact_id: str
|
||||||
|
change_log: list[dict[str, Any]]
|
||||||
|
keyword_coverage: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1.1: Deadlines ---
|
||||||
|
|
||||||
|
class DeadlineItem(BaseModel):
|
||||||
|
application_id: str
|
||||||
|
title: str
|
||||||
|
company: str
|
||||||
|
apply_by: str | None = None
|
||||||
105
apps/api/app/statemachine.py
Normal file
105
apps/api/app/statemachine.py
Normal 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}",
|
||||||
|
)
|
||||||
158
apps/api/app/transport.py
Normal file
158
apps/api/app/transport.py
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
"""Send transport interface.
|
||||||
|
|
||||||
|
Pluggable Transport interface for the outbox send operation.
|
||||||
|
Selection order:
|
||||||
|
1. SMTP configured (SMTP_HOST set) -> SmtpTransport
|
||||||
|
2. Else -> ClipboardTransport (marks sent + stores payload for UI copy)
|
||||||
|
|
||||||
|
The approval gate checks (confirmed, unexpired, hash match) are UNCHANGED
|
||||||
|
and enforced in the API layer before transport.send() is called.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ClipboardTransport:
|
||||||
|
"""Fallback transport: marks sent and stores payload for UI copy/paste.
|
||||||
|
|
||||||
|
No real email is sent. The payload is stored so the UI can show it
|
||||||
|
to the user for manual copy-paste into their email client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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(),
|
||||||
|
"transport": "clipboard",
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpTransport:
|
||||||
|
"""SMTP transport: sends real email via SMTP.
|
||||||
|
|
||||||
|
Configuration from env:
|
||||||
|
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM
|
||||||
|
SSL on port 465, STARTTLS otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str | None = None,
|
||||||
|
port: int | None = None,
|
||||||
|
user: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
from_addr: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.host = host or os.environ.get("SMTP_HOST", "")
|
||||||
|
self.port = int(port or os.environ.get("SMTP_PORT", "587"))
|
||||||
|
self.user = user or os.environ.get("SMTP_USER", "")
|
||||||
|
self.password = password or os.environ.get("SMTP_PASS", "")
|
||||||
|
self.from_addr = from_addr or os.environ.get("SMTP_FROM", self.user)
|
||||||
|
|
||||||
|
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
to = payload.get("to", "")
|
||||||
|
subject = payload.get("subject", "(no subject)")
|
||||||
|
body = payload.get("body", "")
|
||||||
|
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
msg["From"] = self.from_addr
|
||||||
|
msg["To"] = to
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg.attach(MIMEText(body, "plain"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.port == 465:
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
with smtplib.SMTP_SSL(self.host, self.port, context=context) as server:
|
||||||
|
if self.user and self.password:
|
||||||
|
server.login(self.user, self.password)
|
||||||
|
server.sendmail(self.from_addr, [to], msg.as_string())
|
||||||
|
else:
|
||||||
|
with smtplib.SMTP(self.host, self.port) as server:
|
||||||
|
server.ehlo()
|
||||||
|
if self.user and self.password:
|
||||||
|
server.starttls()
|
||||||
|
server.ehlo()
|
||||||
|
server.login(self.user, self.password)
|
||||||
|
server.sendmail(self.from_addr, [to], msg.as_string())
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"sent_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"transport": "smtp",
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"sent_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"transport": "smtp",
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_smtp_configured() -> bool:
|
||||||
|
"""Check if SMTP is configured (SMTP_HOST is set)."""
|
||||||
|
return bool(os.environ.get("SMTP_HOST", "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
_default_transport: Transport | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_transport() -> Transport:
|
||||||
|
"""Get the transport. Selects SMTP if configured, else Clipboard."""
|
||||||
|
global _default_transport
|
||||||
|
if _default_transport is None:
|
||||||
|
if is_smtp_configured():
|
||||||
|
_default_transport = SmtpTransport()
|
||||||
|
else:
|
||||||
|
_default_transport = ClipboardTransport()
|
||||||
|
return _default_transport
|
||||||
|
|
||||||
|
|
||||||
|
def set_transport(t: Transport) -> None:
|
||||||
|
"""Override the transport (for testing)."""
|
||||||
|
global _default_transport
|
||||||
|
_default_transport = t
|
||||||
|
|
||||||
|
|
||||||
|
def reset_transport() -> None:
|
||||||
|
"""Reset to default (for testing)."""
|
||||||
|
global _default_transport
|
||||||
|
_default_transport = None
|
||||||
106
apps/api/migrations/001_initial.sql
Normal file
106
apps/api/migrations/001_initial.sql
Normal 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()
|
||||||
|
);
|
||||||
12
apps/api/migrations/002_followups.sql
Normal file
12
apps/api/migrations/002_followups.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- 002_followups.sql -- follow-up rules + interview prep on application
|
||||||
|
|
||||||
|
ALTER TABLE application
|
||||||
|
ADD COLUMN IF NOT EXISTS follow_up_after_days int NOT NULL DEFAULT 7,
|
||||||
|
ADD COLUMN IF NOT EXISTS last_activity_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
ADD COLUMN IF NOT EXISTS follow_up_snoozed_until date,
|
||||||
|
ADD COLUMN IF NOT EXISTS interview_prep_artifact_id uuid REFERENCES artifact(id);
|
||||||
|
|
||||||
|
-- last_activity_at defaults to created_at for existing rows
|
||||||
|
UPDATE application
|
||||||
|
SET last_activity_at = created_at
|
||||||
|
WHERE last_activity_at IS NULL;
|
||||||
24
apps/api/migrations/003_email_notify.sql
Normal file
24
apps/api/migrations/003_email_notify.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
-- 003_email_notify.sql -- email suggestions + notification log
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS email_suggestion (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
application_id uuid REFERENCES application(id) ON DELETE SET NULL,
|
||||||
|
mailbox_from text NOT NULL,
|
||||||
|
subject text NOT NULL,
|
||||||
|
snippet text NOT NULL,
|
||||||
|
classification text NOT NULL CHECK (classification IN ('interview_invite','rejection','question','noise')),
|
||||||
|
state_proposal text,
|
||||||
|
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','accepted','dismissed')),
|
||||||
|
received_at timestamptz NOT NULL,
|
||||||
|
created_at timestamptz DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_log (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
channel text NOT NULL,
|
||||||
|
kind text NOT NULL,
|
||||||
|
payload jsonb NOT NULL,
|
||||||
|
delivered boolean NOT NULL,
|
||||||
|
error text,
|
||||||
|
created_at timestamptz DEFAULT now()
|
||||||
|
);
|
||||||
4
apps/api/migrations/004_dedupe_deadline.sql
Normal file
4
apps/api/migrations/004_dedupe_deadline.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- 004_dedupe_deadline.sql -- cluster_id for dedupe + apply_by deadline
|
||||||
|
|
||||||
|
ALTER TABLE job_posting ADD COLUMN IF NOT EXISTS cluster_id text;
|
||||||
|
ALTER TABLE job_posting ADD COLUMN IF NOT EXISTS apply_by date;
|
||||||
32
apps/api/pyproject.toml
Normal file
32
apps/api/pyproject.toml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
[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",
|
||||||
|
"pypdf>=4.0",
|
||||||
|
"python-docx>=1.1",
|
||||||
|
"apscheduler>=3.10",
|
||||||
|
]
|
||||||
|
|
||||||
|
[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
106
apps/api/schema.sql
Normal 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()
|
||||||
|
);
|
||||||
1
apps/api/tests/__init__.py
Normal file
1
apps/api/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Test package init."""
|
||||||
47
apps/api/tests/conftest.py
Normal file
47
apps/api/tests/conftest.py
Normal 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 notification_log, email_suggestion, task_run, outbox, approval, artifact,
|
||||||
|
application, job_posting, cv_section, profile
|
||||||
|
RESTART IDENTITY CASCADE
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
yield
|
||||||
265
apps/api/tests/test_api.py
Normal file
265
apps/api/tests/test_api.py
Normal 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"
|
||||||
216
apps/api/tests/test_approval.py
Normal file
216
apps/api/tests/test_approval.py
Normal 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()
|
||||||
162
apps/api/tests/test_cv_import.py
Normal file
162
apps/api/tests/test_cv_import.py
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
"""Tests for v1 CV import endpoints (mock LLM mode)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCvImport:
|
||||||
|
def test_import_txt_file(self, client):
|
||||||
|
"""POST /cv/import with a .txt file returns drafts."""
|
||||||
|
content = b"John Doe\nSoftware Engineer at TechCorp\n5 years experience with Python"
|
||||||
|
resp = client.post(
|
||||||
|
"/api/cv/import",
|
||||||
|
json={
|
||||||
|
"filename": "my_cv.txt",
|
||||||
|
"content_base64": base64.b64encode(content).decode(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "drafts" in data
|
||||||
|
assert len(data["drafts"]) > 0
|
||||||
|
|
||||||
|
def test_import_empty_file_422(self, client):
|
||||||
|
"""Empty file -> 422."""
|
||||||
|
content = b""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/cv/import",
|
||||||
|
json={
|
||||||
|
"filename": "empty.txt",
|
||||||
|
"content_base64": base64.b64encode(content).decode(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "empty" in resp.text.lower()
|
||||||
|
|
||||||
|
def test_import_whitespace_only_file_422(self, client):
|
||||||
|
"""File with only whitespace -> 422."""
|
||||||
|
content = b" \n\n\t "
|
||||||
|
resp = client.post(
|
||||||
|
"/api/cv/import",
|
||||||
|
json={
|
||||||
|
"filename": "blank.txt",
|
||||||
|
"content_base64": base64.b64encode(content).decode(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "empty" in resp.text.lower()
|
||||||
|
|
||||||
|
def test_import_unsupported_format_422(self, client):
|
||||||
|
"""Unsupported file format -> 422."""
|
||||||
|
content = b"some data"
|
||||||
|
resp = client.post(
|
||||||
|
"/api/cv/import",
|
||||||
|
json={
|
||||||
|
"filename": "file.xyz",
|
||||||
|
"content_base64": base64.b64encode(content).decode(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "unsupported" in resp.text.lower()
|
||||||
|
|
||||||
|
def test_import_invalid_base64_422(self, client):
|
||||||
|
"""Invalid base64 -> 422."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/cv/import",
|
||||||
|
json={
|
||||||
|
"filename": "file.txt",
|
||||||
|
"content_base64": "!!!not-base64!!!",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_import_md_file(self, client):
|
||||||
|
"""POST /cv/import with a .md file returns drafts."""
|
||||||
|
content = b"# Jane Doe\n\n## Experience\nSenior Developer at Acme"
|
||||||
|
resp = client.post(
|
||||||
|
"/api/cv/import",
|
||||||
|
json={
|
||||||
|
"filename": "cv.md",
|
||||||
|
"content_base64": base64.b64encode(content).decode(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "drafts" in resp.json()
|
||||||
|
|
||||||
|
def test_import_creates_telemetry(self, client):
|
||||||
|
"""CV import should create a task_run entry."""
|
||||||
|
content = b"Some CV text with experience"
|
||||||
|
client.post(
|
||||||
|
"/api/cv/import",
|
||||||
|
json={
|
||||||
|
"filename": "cv.txt",
|
||||||
|
"content_base64": base64.b64encode(content).decode(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp = client.get("/api/telemetry/tasks")
|
||||||
|
tasks = resp.json()
|
||||||
|
assert any(t["task"] == "cv_extract" for t in tasks)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCvImportConfirm:
|
||||||
|
def test_confirm_creates_sections(self, client):
|
||||||
|
"""POST /cv/import/confirm creates cv_section rows."""
|
||||||
|
client.get("/api/profile")
|
||||||
|
resp = client.post(
|
||||||
|
"/api/cv/import/confirm",
|
||||||
|
json={
|
||||||
|
"drafts": [
|
||||||
|
{
|
||||||
|
"kind": "experience",
|
||||||
|
"title": "Dev",
|
||||||
|
"org": "Corp",
|
||||||
|
"bullets": ["did stuff"],
|
||||||
|
"tags": ["python"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "education",
|
||||||
|
"title": "MSc",
|
||||||
|
"org": "Uni",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert data["created"] == 2
|
||||||
|
assert len(data["sections"]) == 2
|
||||||
|
|
||||||
|
def test_confirm_empty_drafts(self, client):
|
||||||
|
"""Empty drafts list creates zero sections."""
|
||||||
|
client.get("/api/profile")
|
||||||
|
resp = client.post(
|
||||||
|
"/api/cv/import/confirm",
|
||||||
|
json={"drafts": []},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["created"] == 0
|
||||||
|
|
||||||
|
def test_confirm_sections_appear_in_list(self, client):
|
||||||
|
"""Confirmed sections appear in GET /profile/sections."""
|
||||||
|
client.get("/api/profile")
|
||||||
|
client.post(
|
||||||
|
"/api/cv/import/confirm",
|
||||||
|
json={
|
||||||
|
"drafts": [
|
||||||
|
{"kind": "skills", "title": "Python Dev", "bullets": ["FastAPI"]},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp = client.get("/api/profile/sections")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert any(s["title"] == "Python Dev" for s in resp.json())
|
||||||
137
apps/api/tests/test_postings_fetch.py
Normal file
137
apps/api/tests/test_postings_fetch.py
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
"""Tests for v1 postings fetch endpoint (connector stubbed)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRawPosting:
|
||||||
|
"""Minimal raw posting dict for testing."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fake_postings():
|
||||||
|
"""Return fake raw postings as dicts."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"source": "arbetsformedlingen",
|
||||||
|
"external_id": "af-001",
|
||||||
|
"url": "https://arbetsformedlingen.se/job/001",
|
||||||
|
"company": "Skane Tech",
|
||||||
|
"title": "Python Developer",
|
||||||
|
"location": "Malmo",
|
||||||
|
"description": "Great Python job in Malmo.",
|
||||||
|
"raw": {"id": "af-001"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "arbetsformedlingen",
|
||||||
|
"external_id": "af-002",
|
||||||
|
"url": "https://arbetsformedlingen.se/job/002",
|
||||||
|
"company": "Lund Systems",
|
||||||
|
"title": "Backend Engineer",
|
||||||
|
"location": "Lund",
|
||||||
|
"description": "Backend engineer at Lund.",
|
||||||
|
"raw": {"id": "af-002"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConnector:
|
||||||
|
"""Fake connector that returns predefined postings."""
|
||||||
|
def fetch(self, query):
|
||||||
|
# Accept both SearchQuery objects and dicts
|
||||||
|
return _make_fake_postings()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPostingsFetch:
|
||||||
|
def test_fetch_disabled_503(self, client, monkeypatch):
|
||||||
|
"""CONNECTORS_ENABLED=false -> 503."""
|
||||||
|
monkeypatch.setenv("CONNECTORS_ENABLED", "false")
|
||||||
|
resp = client.post(
|
||||||
|
"/api/postings/fetch",
|
||||||
|
json={"query": "python developer"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 503
|
||||||
|
assert "connectors_disabled" in resp.text
|
||||||
|
|
||||||
|
def test_fetch_enabled_with_stubbed_connector(self, client, monkeypatch):
|
||||||
|
"""Fetch with monkeypatched connector creates new applications."""
|
||||||
|
monkeypatch.setenv("CONNECTORS_ENABLED", "true")
|
||||||
|
# Monkeypatch the connector lookup
|
||||||
|
import app.main as main_mod
|
||||||
|
monkeypatch.setattr(main_mod, "CONNECTORS_AVAILABLE", True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main_mod,
|
||||||
|
"_get_arbetsformedlingen_connector",
|
||||||
|
lambda: FakeConnector(),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/postings/fetch",
|
||||||
|
json={"query": "python developer", "region": "Skane lan"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["new"] == 2
|
||||||
|
assert data["dupes"] == 0
|
||||||
|
|
||||||
|
def test_fetch_dedupes_existing_postings(self, client, monkeypatch):
|
||||||
|
"""Second fetch of same postings counts as dupes."""
|
||||||
|
monkeypatch.setenv("CONNECTORS_ENABLED", "true")
|
||||||
|
import app.main as main_mod
|
||||||
|
monkeypatch.setattr(main_mod, "CONNECTORS_AVAILABLE", True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main_mod,
|
||||||
|
"_get_arbetsformedlingen_connector",
|
||||||
|
lambda: FakeConnector(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# First fetch
|
||||||
|
resp1 = client.post(
|
||||||
|
"/api/postings/fetch",
|
||||||
|
json={"query": "python"},
|
||||||
|
)
|
||||||
|
assert resp1.status_code == 200
|
||||||
|
assert resp1.json()["new"] == 2
|
||||||
|
|
||||||
|
# Second fetch: same postings should be dupes
|
||||||
|
resp2 = client.post(
|
||||||
|
"/api/postings/fetch",
|
||||||
|
json={"query": "python"},
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
assert resp2.json()["new"] == 0
|
||||||
|
assert resp2.json()["dupes"] == 2
|
||||||
|
|
||||||
|
def test_fetch_empty_results(self, client, monkeypatch):
|
||||||
|
"""Connector returning no postings -> new=0, dupes=0."""
|
||||||
|
monkeypatch.setenv("CONNECTORS_ENABLED", "true")
|
||||||
|
import app.main as main_mod
|
||||||
|
monkeypatch.setattr(main_mod, "CONNECTORS_AVAILABLE", True)
|
||||||
|
|
||||||
|
class EmptyConnector:
|
||||||
|
def fetch(self, query):
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main_mod,
|
||||||
|
"_get_arbetsformedlingen_connector",
|
||||||
|
lambda: EmptyConnector(),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/postings/fetch",
|
||||||
|
json={"query": "rare keyword"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["new"] == 0
|
||||||
|
assert resp.json()["dupes"] == 0
|
||||||
161
apps/api/tests/test_statemachine.py
Normal file
161
apps/api/tests/test_statemachine.py
Normal 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")
|
||||||
682
apps/api/tests/test_v11_email_notify.py
Normal file
682
apps/api/tests/test_v11_email_notify.py
Normal file
|
|
@ -0,0 +1,682 @@
|
||||||
|
"""Tests for v1.1: email watch, notifications, suggestions endpoints.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- IMAP matching logic (company in subject, sender domain in URL, title keywords)
|
||||||
|
- Classifier -> suggestion row
|
||||||
|
- Noise dedupe (same from+subject+day)
|
||||||
|
- Accept applies transition through guard
|
||||||
|
- Dismiss marks suggestion
|
||||||
|
- Webhook success/failure notification_log rows
|
||||||
|
- LogChannel writes delivered=true
|
||||||
|
- Daily digest payload shape
|
||||||
|
- GET /suggestions, POST accept, POST dismiss, GET /notifications/log
|
||||||
|
- FakeImap end-to-end poll
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import email as email_mod
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.config import DATABASE_URL
|
||||||
|
from app.db import repo_app
|
||||||
|
from app.imap_watch import (
|
||||||
|
FakeImap,
|
||||||
|
_build_snippet,
|
||||||
|
_extract_sender_domain,
|
||||||
|
_parse_email_message,
|
||||||
|
classify_email,
|
||||||
|
create_email_suggestion,
|
||||||
|
is_duplicate,
|
||||||
|
match_application,
|
||||||
|
poll_inbox,
|
||||||
|
)
|
||||||
|
from app.notify import (
|
||||||
|
LogChannel,
|
||||||
|
WebhookChannel,
|
||||||
|
get_channels,
|
||||||
|
list_notification_log,
|
||||||
|
reset_channels,
|
||||||
|
send_notification,
|
||||||
|
set_channels,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Helpers ---
|
||||||
|
|
||||||
|
def _make_email(raw_from: str, subject: str, body: str, date: str = "") -> bytes:
|
||||||
|
"""Build raw email bytes for FakeImap."""
|
||||||
|
msg = email_mod.message_from_string(
|
||||||
|
f"From: {raw_from}\r\n"
|
||||||
|
f"Subject: {subject}\r\n"
|
||||||
|
f"Date: {date or 'Mon, 01 Jul 2026 10:00:00 +0000'}\r\n"
|
||||||
|
f"\r\n"
|
||||||
|
f"{body}"
|
||||||
|
)
|
||||||
|
return msg.as_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def _create_app_in_state(state: str = "sent", company: str = "TechCorp", url: str = "https://techcorp.com/jobs/1") -> dict:
|
||||||
|
"""Create a posting + application, force state via SQL."""
|
||||||
|
posting = repo_app.create_job_posting(
|
||||||
|
source="manual_url",
|
||||||
|
url=url,
|
||||||
|
company=company,
|
||||||
|
title="Senior Python Developer",
|
||||||
|
location="Malmo",
|
||||||
|
description="",
|
||||||
|
raw={},
|
||||||
|
)
|
||||||
|
app_row = repo_app.create_application(posting["id"])
|
||||||
|
if state != "discovered":
|
||||||
|
repo_app.update_application_score(app_row["id"], 80, {"factors": {}})
|
||||||
|
if state in ("approved", "rejected"):
|
||||||
|
repo_app.update_application_state(app_row["id"], "scored")
|
||||||
|
repo_app.update_application_state(app_row["id"], state)
|
||||||
|
elif state == "sent":
|
||||||
|
repo_app.update_application_state(app_row["id"], "scored")
|
||||||
|
repo_app.update_application_state(app_row["id"], "approved")
|
||||||
|
repo_app.update_application_state(app_row["id"], "drafting")
|
||||||
|
# Bypass guard for test
|
||||||
|
from app.db import execute
|
||||||
|
execute(
|
||||||
|
"UPDATE application SET state = 'sent', last_activity_at = now() WHERE id = %s",
|
||||||
|
(app_row["id"],),
|
||||||
|
)
|
||||||
|
elif state == "interviewing":
|
||||||
|
repo_app.update_application_state(app_row["id"], "scored")
|
||||||
|
repo_app.update_application_state(app_row["id"], "approved")
|
||||||
|
repo_app.update_application_state(app_row["id"], "drafting")
|
||||||
|
from app.db import execute
|
||||||
|
execute(
|
||||||
|
"UPDATE application SET state = 'sent' WHERE id = %s",
|
||||||
|
(app_row["id"],),
|
||||||
|
)
|
||||||
|
execute(
|
||||||
|
"UPDATE application SET state = 'interviewing' WHERE id = %s",
|
||||||
|
(app_row["id"],),
|
||||||
|
)
|
||||||
|
return app_row
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# IMAP matching logic (3 tests)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
class TestImapMatching:
|
||||||
|
def test_match_by_company_name_in_subject(self):
|
||||||
|
"""Email subject contains company name -> match."""
|
||||||
|
apps = [
|
||||||
|
{"id": "app1", "state": "sent", "company": "TechCorp", "title": "Python Dev", "url": "https://example.com"},
|
||||||
|
]
|
||||||
|
result = match_application(
|
||||||
|
"recruiter@gmail.com",
|
||||||
|
"Interview at TechCorp",
|
||||||
|
"Please come for an interview.",
|
||||||
|
apps,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert result["id"] == "app1"
|
||||||
|
|
||||||
|
def test_match_by_sender_domain_in_url(self):
|
||||||
|
"""Sender domain matches the posting URL -> match."""
|
||||||
|
apps = [
|
||||||
|
{"id": "app2", "state": "sent", "company": "Unknown", "title": "Dev", "url": "https://techcorp.com/careers/1"},
|
||||||
|
]
|
||||||
|
result = match_application(
|
||||||
|
"hr@techcorp.com",
|
||||||
|
"Your application",
|
||||||
|
"We reviewed your application.",
|
||||||
|
apps,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert result["id"] == "app2"
|
||||||
|
|
||||||
|
def test_match_by_title_keywords(self):
|
||||||
|
"""Email subject contains 2+ title words -> match."""
|
||||||
|
apps = [
|
||||||
|
{"id": "app3", "state": "interviewing", "company": "SomeCompany", "title": "Senior Python Developer", "url": "https://other.com"},
|
||||||
|
]
|
||||||
|
result = match_application(
|
||||||
|
"someone@other.com",
|
||||||
|
"Senior Python position update",
|
||||||
|
"Regarding the developer role.",
|
||||||
|
apps,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert result["id"] == "app3"
|
||||||
|
|
||||||
|
def test_no_match_wrong_state(self):
|
||||||
|
"""Applications in discovered state are not matched."""
|
||||||
|
apps = [
|
||||||
|
{"id": "app4", "state": "discovered", "company": "TechCorp", "title": "Dev", "url": "https://techcorp.com"},
|
||||||
|
]
|
||||||
|
result = match_application(
|
||||||
|
"hr@techcorp.com",
|
||||||
|
"Interview at TechCorp",
|
||||||
|
"Come for an interview.",
|
||||||
|
apps,
|
||||||
|
)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_no_match_unrelated_email(self):
|
||||||
|
"""Email unrelated to any application -> no match."""
|
||||||
|
apps = [
|
||||||
|
{"id": "app5", "state": "sent", "company": "TechCorp", "title": "Dev", "url": "https://techcorp.com"},
|
||||||
|
]
|
||||||
|
result = match_application(
|
||||||
|
"newsletter@spam.com",
|
||||||
|
"Buy now!",
|
||||||
|
"Special offer for you.",
|
||||||
|
apps,
|
||||||
|
)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Classifier -> row (3 tests)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
class TestClassifierToRow:
|
||||||
|
def test_classify_returns_interview_invite(self):
|
||||||
|
"""classify_email returns interview_invite from mock."""
|
||||||
|
result = classify_email("Interview invitation", "Please come for an interview next week.")
|
||||||
|
assert result["classification"] == "interview_invite"
|
||||||
|
assert result["state_proposal"] == "interviewing"
|
||||||
|
assert "reason" in result
|
||||||
|
|
||||||
|
def test_classify_falls_back_on_invalid_classification(self):
|
||||||
|
"""Invalid classification from LLM falls back to noise."""
|
||||||
|
import app.llm as llm_mod
|
||||||
|
original = llm_mod.MOCK_OUTPUTS.get("email_classify", {}).copy()
|
||||||
|
try:
|
||||||
|
llm_mod.MOCK_OUTPUTS["email_classify"] = {"classification": "bogus", "state_proposal": None, "reason": "test"}
|
||||||
|
result = classify_email("test", "test")
|
||||||
|
assert result["classification"] == "noise"
|
||||||
|
finally:
|
||||||
|
llm_mod.MOCK_OUTPUTS["email_classify"] = original
|
||||||
|
|
||||||
|
def test_create_email_suggestion_row(self):
|
||||||
|
"""create_email_suggestion inserts a row correctly."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
suggestion = create_email_suggestion(
|
||||||
|
application_id=None,
|
||||||
|
mailbox_from="hr@example.com",
|
||||||
|
subject="Interview invite",
|
||||||
|
snippet="Please come for an interview.",
|
||||||
|
classification="interview_invite",
|
||||||
|
state_proposal="interviewing",
|
||||||
|
received_at=now,
|
||||||
|
)
|
||||||
|
assert suggestion["id"] is not None
|
||||||
|
assert suggestion["mailbox_from"] == "hr@example.com"
|
||||||
|
assert suggestion["classification"] == "interview_invite"
|
||||||
|
assert suggestion["status"] == "pending"
|
||||||
|
assert suggestion["state_proposal"] == "interviewing"
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Noise dedupe (3 tests)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
class TestNoiseDedupe:
|
||||||
|
def test_duplicate_detected_same_day(self):
|
||||||
|
"""Same from+subject+day is flagged as duplicate."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
create_email_suggestion(
|
||||||
|
application_id=None,
|
||||||
|
mailbox_from="hr@example.com",
|
||||||
|
subject="Interview",
|
||||||
|
snippet="Come for an interview.",
|
||||||
|
classification="interview_invite",
|
||||||
|
state_proposal="interviewing",
|
||||||
|
received_at=now,
|
||||||
|
)
|
||||||
|
assert is_duplicate("hr@example.com", "Interview", now)
|
||||||
|
|
||||||
|
def test_different_subject_not_duplicate(self):
|
||||||
|
"""Different subject -> not duplicate."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
create_email_suggestion(
|
||||||
|
application_id=None,
|
||||||
|
mailbox_from="hr@example.com",
|
||||||
|
subject="Interview",
|
||||||
|
snippet="Come.",
|
||||||
|
classification="interview_invite",
|
||||||
|
state_proposal="interviewing",
|
||||||
|
received_at=now,
|
||||||
|
)
|
||||||
|
assert not is_duplicate("hr@example.com", "Different Subject", now)
|
||||||
|
|
||||||
|
def test_different_sender_not_duplicate(self):
|
||||||
|
"""Different sender -> not duplicate."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
create_email_suggestion(
|
||||||
|
application_id=None,
|
||||||
|
mailbox_from="hr@example.com",
|
||||||
|
subject="Interview",
|
||||||
|
snippet="Come.",
|
||||||
|
classification="interview_invite",
|
||||||
|
state_proposal="interviewing",
|
||||||
|
received_at=now,
|
||||||
|
)
|
||||||
|
assert not is_duplicate("other@example.com", "Interview", now)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Accept applies transition through guard (3 tests)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
class TestAcceptSuggestion:
|
||||||
|
def test_accept_applies_transition(self, client):
|
||||||
|
"""POST /suggestions/{id}/accept transitions app from sent to interviewing."""
|
||||||
|
app_row = _create_app_in_state("sent", company="TechCorp")
|
||||||
|
suggestion = create_email_suggestion(
|
||||||
|
application_id=app_row["id"],
|
||||||
|
mailbox_from="hr@techcorp.com",
|
||||||
|
subject="Interview at TechCorp",
|
||||||
|
snippet="Please come in for an interview.",
|
||||||
|
classification="interview_invite",
|
||||||
|
state_proposal="interviewing",
|
||||||
|
received_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(f"/api/suggestions/{suggestion['id']}/accept")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "accepted"
|
||||||
|
|
||||||
|
# Verify application state changed
|
||||||
|
updated_app = repo_app.get_application(app_row["id"])
|
||||||
|
assert updated_app["state"] == "interviewing"
|
||||||
|
|
||||||
|
def test_accept_invalid_transition_409(self, client):
|
||||||
|
"""Accept with invalid transition (e.g. discovered -> interviewing) returns 409."""
|
||||||
|
posting = repo_app.create_job_posting(
|
||||||
|
source="manual_url", url="https://example.com/bad/1",
|
||||||
|
company="X", title="X", location=None, description="", raw={},
|
||||||
|
)
|
||||||
|
app_row = repo_app.create_application(posting["id"])
|
||||||
|
|
||||||
|
suggestion = create_email_suggestion(
|
||||||
|
application_id=app_row["id"],
|
||||||
|
mailbox_from="hr@example.com",
|
||||||
|
subject="Test",
|
||||||
|
snippet="Test",
|
||||||
|
classification="interview_invite",
|
||||||
|
state_proposal="interviewing",
|
||||||
|
received_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(f"/api/suggestions/{suggestion['id']}/accept")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert "invalid_transition" in str(resp.json()["detail"])
|
||||||
|
|
||||||
|
def test_accept_404_nonexistent(self, client):
|
||||||
|
"""Accept on nonexistent suggestion -> 404."""
|
||||||
|
resp = client.post("/api/suggestions/00000000-0000-0000-0000-000000000000/accept")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_accept_already_accepted_409(self, client):
|
||||||
|
"""Accept on already accepted suggestion -> 409."""
|
||||||
|
app_row = _create_app_in_state("sent")
|
||||||
|
suggestion = create_email_suggestion(
|
||||||
|
application_id=app_row["id"],
|
||||||
|
mailbox_from="hr@example.com",
|
||||||
|
subject="Test",
|
||||||
|
snippet="Test",
|
||||||
|
classification="interview_invite",
|
||||||
|
state_proposal="interviewing",
|
||||||
|
received_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
client.post(f"/api/suggestions/{suggestion['id']}/accept")
|
||||||
|
resp = client.post(f"/api/suggestions/{suggestion['id']}/accept")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Dismiss suggestion (2 tests)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
class TestDismissSuggestion:
|
||||||
|
def test_dismiss_marks_as_dismissed(self, client):
|
||||||
|
"""POST /suggestions/{id}/dismiss marks as dismissed."""
|
||||||
|
suggestion = create_email_suggestion(
|
||||||
|
application_id=None,
|
||||||
|
mailbox_from="hr@example.com",
|
||||||
|
subject="Test",
|
||||||
|
snippet="Test",
|
||||||
|
classification="question",
|
||||||
|
state_proposal=None,
|
||||||
|
received_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(f"/api/suggestions/{suggestion['id']}/dismiss")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "dismissed"
|
||||||
|
|
||||||
|
def test_dismiss_404_nonexistent(self, client):
|
||||||
|
"""Dismiss nonexistent -> 404."""
|
||||||
|
resp = client.post("/api/suggestions/00000000-0000-0000-0000-000000000000/dismiss")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# GET /suggestions (2 tests)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
class TestGetSuggestions:
|
||||||
|
def test_get_suggestions_returns_pending(self, client):
|
||||||
|
"""GET /suggestions returns only pending suggestions."""
|
||||||
|
create_email_suggestion(
|
||||||
|
application_id=None,
|
||||||
|
mailbox_from="a@example.com",
|
||||||
|
subject="Subject A",
|
||||||
|
snippet="Snippet A",
|
||||||
|
classification="interview_invite",
|
||||||
|
state_proposal="interviewing",
|
||||||
|
received_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
create_email_suggestion(
|
||||||
|
application_id=None,
|
||||||
|
mailbox_from="b@example.com",
|
||||||
|
subject="Subject B",
|
||||||
|
snippet="Snippet B",
|
||||||
|
classification="rejection",
|
||||||
|
state_proposal=None,
|
||||||
|
received_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get("/api/suggestions")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 2
|
||||||
|
assert all(s["status"] == "pending" for s in data)
|
||||||
|
|
||||||
|
def test_get_suggestions_empty(self, client):
|
||||||
|
"""GET /suggestions returns empty list when no suggestions."""
|
||||||
|
resp = client.get("/api/suggestions")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Notification channels (6 tests)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
class TestLogChannel:
|
||||||
|
def test_log_channel_writes_delivered_true(self):
|
||||||
|
"""LogChannel writes notification_log with delivered=true."""
|
||||||
|
reset_channels()
|
||||||
|
ch = LogChannel()
|
||||||
|
result = ch.send("daily_digest", "Test digest", {"count": 5})
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
logs = list_notification_log(limit=10)
|
||||||
|
assert len(logs) >= 1
|
||||||
|
latest = logs[0]
|
||||||
|
assert latest["channel"] == "log"
|
||||||
|
assert latest["kind"] == "daily_digest"
|
||||||
|
assert latest["delivered"] is True
|
||||||
|
assert latest["error"] is None
|
||||||
|
|
||||||
|
def test_send_notification_log_channel(self):
|
||||||
|
"""send_notification via LogChannel creates a log entry."""
|
||||||
|
reset_channels()
|
||||||
|
set_channels([LogChannel()])
|
||||||
|
send_notification("email_suggestion", "Interview invite from TechCorp", {"id": "test"})
|
||||||
|
logs = list_notification_log(limit=10)
|
||||||
|
assert len(logs) >= 1
|
||||||
|
assert logs[0]["kind"] == "email_suggestion"
|
||||||
|
assert logs[0]["delivered"] is True
|
||||||
|
reset_channels()
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebhookChannel:
|
||||||
|
def test_webhook_success_2xx(self, monkeypatch):
|
||||||
|
"""WebhookChannel with 2xx response writes delivered=true."""
|
||||||
|
class FakeResponse:
|
||||||
|
status_code = 200
|
||||||
|
text = "OK"
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
@staticmethod
|
||||||
|
def post(url, json=None, timeout=None):
|
||||||
|
assert url == "https://hook.example.com/notify"
|
||||||
|
return FakeResponse()
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
monkeypatch.setattr(httpx, "post", FakeClient.post)
|
||||||
|
|
||||||
|
reset_channels()
|
||||||
|
ch = WebhookChannel(url="https://hook.example.com/notify")
|
||||||
|
result = ch.send("daily_digest", "Digest", {"count": 3})
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
logs = list_notification_log(limit=10)
|
||||||
|
latest = logs[0]
|
||||||
|
assert latest["channel"] == "webhook"
|
||||||
|
assert latest["delivered"] is True
|
||||||
|
assert latest["error"] is None
|
||||||
|
reset_channels()
|
||||||
|
|
||||||
|
def test_webhook_failure_non_2xx(self, monkeypatch):
|
||||||
|
"""WebhookChannel with non-2xx writes delivered=false with error."""
|
||||||
|
class FakeResponse:
|
||||||
|
status_code = 500
|
||||||
|
text = "Internal Server Error"
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
@staticmethod
|
||||||
|
def post(url, json=None, timeout=None):
|
||||||
|
return FakeResponse()
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
monkeypatch.setattr(httpx, "post", FakeClient.post)
|
||||||
|
|
||||||
|
reset_channels()
|
||||||
|
ch = WebhookChannel(url="https://hook.example.com/notify")
|
||||||
|
result = ch.send("daily_digest", "Digest", {"count": 3})
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
logs = list_notification_log(limit=10)
|
||||||
|
latest = logs[0]
|
||||||
|
assert latest["channel"] == "webhook"
|
||||||
|
assert latest["delivered"] is False
|
||||||
|
assert latest["error"] is not None
|
||||||
|
assert "500" in latest["error"]
|
||||||
|
reset_channels()
|
||||||
|
|
||||||
|
def test_webhook_exception_writes_error(self, monkeypatch):
|
||||||
|
"""WebhookChannel with connection exception writes delivered=false."""
|
||||||
|
def raise_exc(url, json=None, timeout=None):
|
||||||
|
raise ConnectionError("Connection refused")
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
monkeypatch.setattr(httpx, "post", raise_exc)
|
||||||
|
|
||||||
|
reset_channels()
|
||||||
|
ch = WebhookChannel(url="https://hook.example.com/notify")
|
||||||
|
result = ch.send("daily_digest", "Digest", {"count": 1})
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
logs = list_notification_log(limit=10)
|
||||||
|
latest = logs[0]
|
||||||
|
assert latest["delivered"] is False
|
||||||
|
assert "Connection refused" in (latest["error"] or "")
|
||||||
|
reset_channels()
|
||||||
|
|
||||||
|
def test_webhook_no_url_writes_error(self):
|
||||||
|
"""WebhookChannel with no URL writes delivered=false with config error."""
|
||||||
|
reset_channels()
|
||||||
|
ch = WebhookChannel(url="")
|
||||||
|
result = ch.send("test", "test", {})
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
logs = list_notification_log(limit=10)
|
||||||
|
latest = logs[0]
|
||||||
|
assert latest["delivered"] is False
|
||||||
|
assert "not configured" in (latest["error"] or "")
|
||||||
|
reset_channels()
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Notification log endpoint (2 tests)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
class TestNotificationLogEndpoint:
|
||||||
|
def test_get_notifications_log(self, client):
|
||||||
|
"""GET /notifications/log returns entries."""
|
||||||
|
reset_channels()
|
||||||
|
set_channels([LogChannel()])
|
||||||
|
send_notification("daily_digest", "Test", {"count": 1})
|
||||||
|
reset_channels()
|
||||||
|
|
||||||
|
resp = client.get("/api/notifications/log")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) >= 1
|
||||||
|
assert "channel" in data[0]
|
||||||
|
assert "kind" in data[0]
|
||||||
|
assert "delivered" in data[0]
|
||||||
|
|
||||||
|
def test_get_notifications_log_empty(self, client):
|
||||||
|
"""GET /notifications/log returns empty when no entries."""
|
||||||
|
resp = client.get("/api/notifications/log")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Digest payload shape (2 tests)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
class TestDigestPayload:
|
||||||
|
def test_daily_digest_notification_text(self):
|
||||||
|
"""Daily digest notification contains expected text fields."""
|
||||||
|
reset_channels()
|
||||||
|
set_channels([LogChannel()])
|
||||||
|
send_notification("daily_digest", "Daily Digest\nScored applications: 5", {
|
||||||
|
"digest_count": 5,
|
||||||
|
"nudge_count": 2,
|
||||||
|
"pending_approvals": 1,
|
||||||
|
})
|
||||||
|
logs = list_notification_log(limit=10)
|
||||||
|
entry = logs[0]
|
||||||
|
assert entry["kind"] == "daily_digest"
|
||||||
|
payload = entry["payload"]
|
||||||
|
assert "text" in payload
|
||||||
|
assert "Daily Digest" in payload["text"]
|
||||||
|
assert payload.get("digest_count") == 5
|
||||||
|
reset_channels()
|
||||||
|
|
||||||
|
def test_daily_digest_payload_has_counts(self):
|
||||||
|
"""Digest payload includes digest_count, nudge_count, pending_approvals."""
|
||||||
|
reset_channels()
|
||||||
|
set_channels([LogChannel()])
|
||||||
|
send_notification("daily_digest", "text", {
|
||||||
|
"digest_count": 3,
|
||||||
|
"nudge_count": 1,
|
||||||
|
"pending_approvals": 0,
|
||||||
|
})
|
||||||
|
logs = list_notification_log(limit=10)
|
||||||
|
payload = logs[0]["payload"]
|
||||||
|
assert payload["digest_count"] == 3
|
||||||
|
assert payload["nudge_count"] == 1
|
||||||
|
assert payload["pending_approvals"] == 0
|
||||||
|
reset_channels()
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# FakeImap end-to-end poll (3 tests)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
class TestFakeImapPoll:
|
||||||
|
def test_poll_creates_suggestion_for_matching_email(self):
|
||||||
|
"""FakeImap poll creates a suggestion when email matches an application."""
|
||||||
|
app_row = _create_app_in_state("sent", company="TechCorp", url="https://techcorp.com/jobs/1")
|
||||||
|
|
||||||
|
raw_email = _make_email(
|
||||||
|
"hr@techcorp.com",
|
||||||
|
"Interview at TechCorp",
|
||||||
|
"Please come for an interview next Tuesday.",
|
||||||
|
)
|
||||||
|
fake = FakeImap(messages=[(b"1", raw_email)])
|
||||||
|
created = poll_inbox(fake)
|
||||||
|
assert len(created) == 1
|
||||||
|
assert created[0]["classification"] == "interview_invite"
|
||||||
|
assert created[0]["mailbox_from"] == "hr@techcorp.com"
|
||||||
|
assert created[0]["application_id"] == app_row["id"]
|
||||||
|
|
||||||
|
def test_poll_skips_noise_emails(self):
|
||||||
|
"""FakeImap poll skips noise classification (no suggestion created)."""
|
||||||
|
_create_app_in_state("sent", company="TechCorp")
|
||||||
|
|
||||||
|
# Mock email_classify to return noise
|
||||||
|
import app.llm as llm_mod
|
||||||
|
original = llm_mod.MOCK_OUTPUTS.get("email_classify", {}).copy()
|
||||||
|
try:
|
||||||
|
llm_mod.MOCK_OUTPUTS["email_classify"] = {
|
||||||
|
"classification": "noise",
|
||||||
|
"state_proposal": None,
|
||||||
|
"reason": "spam",
|
||||||
|
}
|
||||||
|
raw_email = _make_email(
|
||||||
|
"newsletter@spam.com",
|
||||||
|
"Buy our product",
|
||||||
|
"Special offer just for you!",
|
||||||
|
)
|
||||||
|
fake = FakeImap(messages=[(b"1", raw_email)])
|
||||||
|
created = poll_inbox(fake)
|
||||||
|
assert len(created) == 0
|
||||||
|
finally:
|
||||||
|
llm_mod.MOCK_OUTPUTS["email_classify"] = original
|
||||||
|
|
||||||
|
def test_poll_dedupe_skips_same_from_subject_day(self):
|
||||||
|
"""FakeImap poll dedupes same from+subject+day."""
|
||||||
|
raw_email = _make_email(
|
||||||
|
"hr@example.com",
|
||||||
|
"Same Subject",
|
||||||
|
"Same body content.",
|
||||||
|
)
|
||||||
|
# First poll
|
||||||
|
fake1 = FakeImap(messages=[(b"1", raw_email)])
|
||||||
|
created1 = poll_inbox(fake1)
|
||||||
|
assert len(created1) == 1
|
||||||
|
|
||||||
|
# Second poll with same message -> dedupe
|
||||||
|
fake2 = FakeImap(messages=[(b"1", raw_email)])
|
||||||
|
created2 = poll_inbox(fake2)
|
||||||
|
assert len(created2) == 0 # deduped
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Email parsing helpers (2 tests)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
class TestEmailParsing:
|
||||||
|
def test_extract_sender_domain(self):
|
||||||
|
"""Extract domain from From header."""
|
||||||
|
assert _extract_sender_domain("John Doe <hr@techcorp.com>") == "techcorp.com"
|
||||||
|
assert _extract_sender_domain("noreply@example.org") == "example.org"
|
||||||
|
assert _extract_sender_domain("") == ""
|
||||||
|
|
||||||
|
def test_build_snippet_truncates(self):
|
||||||
|
"""Snippet is truncated to max_len."""
|
||||||
|
long_body = "A" * 500
|
||||||
|
snippet = _build_snippet(long_body, max_len=50)
|
||||||
|
assert len(snippet) <= 53 # 50 + "..."
|
||||||
|
assert snippet.endswith("...")
|
||||||
|
|
||||||
|
def test_build_snippet_short_body(self):
|
||||||
|
"""Short body is not truncated."""
|
||||||
|
snippet = _build_snippet("Hello", max_len=300)
|
||||||
|
assert snippet == "Hello"
|
||||||
737
apps/api/tests/test_v11b_wb1.py
Normal file
737
apps/api/tests/test_v11b_wb1.py
Normal file
|
|
@ -0,0 +1,737 @@
|
||||||
|
"""Tests for v1.1 wave B WB1: dedupe + tailor + deadline integration.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Cluster assignment on posting creation (manual POST /postings)
|
||||||
|
- Cluster stability across re-imports (same posting URL -> same cluster_id)
|
||||||
|
- GET /clusters endpoint shape (cluster_id, postings with id/title/company/source/url/score)
|
||||||
|
- Tailor CV happy path (artifact created, change_log, keyword_coverage)
|
||||||
|
- Tailor CV hallucination rejection (fabricated mock returning unmapped bullet -> 502)
|
||||||
|
- Keyword coverage numbers vs fixture
|
||||||
|
- Deadline persisted on scoring (single + batch)
|
||||||
|
- /today deadlines filter window (next 7 days)
|
||||||
|
|
||||||
|
Total: >= 20 new tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.db import execute, fetch_one, repo_app, repo_profile
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Helpers ---
|
||||||
|
|
||||||
|
def _create_posting_direct(
|
||||||
|
company: str = "TechCorp",
|
||||||
|
title: str = "Senior Python Developer",
|
||||||
|
url: str = "https://example.com/1",
|
||||||
|
description: str = "We need a Python developer with FastAPI experience.",
|
||||||
|
source: str = "manual_url",
|
||||||
|
) -> dict:
|
||||||
|
"""Create a posting directly via repo."""
|
||||||
|
posting = repo_app.create_job_posting(
|
||||||
|
source=source,
|
||||||
|
url=url,
|
||||||
|
company=company,
|
||||||
|
title=title,
|
||||||
|
location="Malmo",
|
||||||
|
description=description,
|
||||||
|
raw={},
|
||||||
|
)
|
||||||
|
repo_app.create_application(posting["id"])
|
||||||
|
return posting
|
||||||
|
|
||||||
|
|
||||||
|
def _create_app_with_profile_and_sections(
|
||||||
|
client,
|
||||||
|
company: str = "TechCorp",
|
||||||
|
title: str = "Senior Python Developer",
|
||||||
|
url: str = "https://example.com/tc1",
|
||||||
|
description: str = "Python FastAPI PostgreSQL Docker Kubernetes AWS",
|
||||||
|
) -> str:
|
||||||
|
"""Create a profile with sections + posting + application. Returns app_id."""
|
||||||
|
# Create profile
|
||||||
|
client.get("/api/profile")
|
||||||
|
client.put("/api/profile", json={
|
||||||
|
"full_name": "Test User",
|
||||||
|
"email": "test@test.com",
|
||||||
|
"headline": "Backend Developer",
|
||||||
|
"summary": "Experienced backend developer.",
|
||||||
|
})
|
||||||
|
# Create sections
|
||||||
|
client.post("/api/profile/sections", json={
|
||||||
|
"kind": "experience",
|
||||||
|
"title": "Backend Developer",
|
||||||
|
"org": "TechCorp",
|
||||||
|
"bullets": [
|
||||||
|
"Led migration of monolith to microservices using Fast API",
|
||||||
|
"Reduced API latency by 40% through query optimization and caching",
|
||||||
|
],
|
||||||
|
"tags": ["python", "fastapi"],
|
||||||
|
})
|
||||||
|
client.post("/api/profile/sections", json={
|
||||||
|
"kind": "skills",
|
||||||
|
"title": "Technical Skills",
|
||||||
|
"bullets": ["Python", "PostgreSQL", "Docker", "FastAPI"],
|
||||||
|
"tags": ["python", "docker"],
|
||||||
|
})
|
||||||
|
# Create posting + application
|
||||||
|
posting = _create_posting_direct(company=company, title=title, url=url, description=description)
|
||||||
|
# Re-fetch to get the app
|
||||||
|
apps = repo_app.list_applications()
|
||||||
|
for a in apps:
|
||||||
|
if a["job_posting_id"] == posting["id"]:
|
||||||
|
return a["id"]
|
||||||
|
raise RuntimeError("Application not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_posting_dict(posting_id: str, company: str, title: str, description: str) -> dict:
|
||||||
|
"""Build a posting dict suitable for cluster()."""
|
||||||
|
return {
|
||||||
|
"id": posting_id,
|
||||||
|
"employer": company,
|
||||||
|
"title": title,
|
||||||
|
"description": description,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Cluster assignment on create (4 tests)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
class TestClusterAssignmentOnCreate:
|
||||||
|
def test_single_posting_gets_cluster_id(self, client):
|
||||||
|
"""A posting created via POST /postings gets a cluster_id assigned."""
|
||||||
|
resp = client.post("/api/postings", json={"url": "https://example.com/cluster/1"})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
postings = client.get("/api/postings").json()
|
||||||
|
assert len(postings) >= 1
|
||||||
|
# The first posting might get c1 or no cluster (if it's the only one, cluster() gives it c1)
|
||||||
|
# With matching available, even 1 posting gets cluster c1
|
||||||
|
if len(postings) == 1:
|
||||||
|
# Single posting: cluster() returns {"c1": [id]}
|
||||||
|
assert postings[0]["cluster_id"] is not None
|
||||||
|
|
||||||
|
def test_duplicate_postings_same_cluster(self, client):
|
||||||
|
"""Two identical postings (same company, title, description) get same cluster_id."""
|
||||||
|
_create_posting_direct(
|
||||||
|
company="Acme Corp",
|
||||||
|
title="Software Engineer",
|
||||||
|
url="https://example.com/dup/1",
|
||||||
|
description="We need a Python developer with Docker experience.",
|
||||||
|
)
|
||||||
|
_create_posting_direct(
|
||||||
|
company="Acme Corp",
|
||||||
|
title="Software Engineer",
|
||||||
|
url="https://example.com/dup/2",
|
||||||
|
description="We need a Python developer with Docker experience.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run cluster assignment manually
|
||||||
|
from app.main import _assign_cluster_id
|
||||||
|
from app.db import repo_app
|
||||||
|
all_postings = repo_app.list_postings()
|
||||||
|
for p in all_postings:
|
||||||
|
_assign_cluster_id(p["id"])
|
||||||
|
|
||||||
|
postings = repo_app.list_postings()
|
||||||
|
cluster_ids = [p["cluster_id"] for p in postings if p["cluster_id"]]
|
||||||
|
# Both should have the same cluster_id
|
||||||
|
assert len(cluster_ids) >= 2
|
||||||
|
assert len(set(cluster_ids)) == 1
|
||||||
|
|
||||||
|
def test_different_postings_different_clusters(self, client):
|
||||||
|
"""Completely different postings get different cluster_ids."""
|
||||||
|
_create_posting_direct(
|
||||||
|
company="CompanyA",
|
||||||
|
title="Chef",
|
||||||
|
url="https://example.com/diff/1",
|
||||||
|
description="Looking for an experienced chef.",
|
||||||
|
)
|
||||||
|
_create_posting_direct(
|
||||||
|
company="CompanyB",
|
||||||
|
title="Pilot",
|
||||||
|
url="https://example.com/diff/2",
|
||||||
|
description="Commercial airline pilot needed.",
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.main import _assign_cluster_id
|
||||||
|
from app.db import repo_app
|
||||||
|
all_postings = repo_app.list_postings()
|
||||||
|
for p in all_postings:
|
||||||
|
_assign_cluster_id(p["id"])
|
||||||
|
|
||||||
|
postings = repo_app.list_postings()
|
||||||
|
cluster_ids = [p["cluster_id"] for p in postings if p["cluster_id"]]
|
||||||
|
if len(cluster_ids) >= 2:
|
||||||
|
assert len(set(cluster_ids)) >= 2
|
||||||
|
|
||||||
|
def test_cluster_id_in_get_postings(self, client):
|
||||||
|
"""GET /postings returns cluster_id field."""
|
||||||
|
_create_posting_direct(
|
||||||
|
company="TestCo",
|
||||||
|
title="Dev",
|
||||||
|
url="https://example.com/field/1",
|
||||||
|
description="Test description",
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get("/api/postings")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) >= 1
|
||||||
|
assert "cluster_id" in data[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Cluster stability across re-imports (3 tests)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
class TestClusterStability:
|
||||||
|
def test_reimport_preserves_cluster_id(self, client):
|
||||||
|
"""Re-importing a posting (same URL) keeps the cluster_id stable."""
|
||||||
|
# First import
|
||||||
|
posting1 = _create_posting_direct(
|
||||||
|
company="StableCorp",
|
||||||
|
title="Engineer",
|
||||||
|
url="https://example.com/stable/1",
|
||||||
|
description="Stable description for engineer role.",
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.main import _assign_cluster_id
|
||||||
|
_assign_cluster_id(posting1["id"])
|
||||||
|
|
||||||
|
p1 = repo_app.get_job_posting(posting1["id"])
|
||||||
|
original_cluster_id = p1.get("cluster_id")
|
||||||
|
|
||||||
|
# Re-import: same URL -> ON CONFLICT DO UPDATE, returns same row
|
||||||
|
posting2 = repo_app.create_job_posting(
|
||||||
|
source="manual_url",
|
||||||
|
url="https://example.com/stable/1",
|
||||||
|
company="StableCorp",
|
||||||
|
title="Engineer",
|
||||||
|
description="Stable description for engineer role.",
|
||||||
|
raw={},
|
||||||
|
)
|
||||||
|
assert posting2["id"] == posting1["id"]
|
||||||
|
|
||||||
|
p2 = repo_app.get_job_posting(posting2["id"])
|
||||||
|
assert p2.get("cluster_id") == original_cluster_id
|
||||||
|
|
||||||
|
def test_new_duplicate_joins_existing_cluster(self, client):
|
||||||
|
"""A new posting that's a duplicate of an existing one joins its cluster_id."""
|
||||||
|
# First posting
|
||||||
|
p1 = _create_posting_direct(
|
||||||
|
company="JoinCorp",
|
||||||
|
title="Backend Developer",
|
||||||
|
url="https://example.com/join/1",
|
||||||
|
description="Python developer with PostgreSQL and Docker.",
|
||||||
|
)
|
||||||
|
from app.main import _assign_cluster_id
|
||||||
|
_assign_cluster_id(p1["id"])
|
||||||
|
|
||||||
|
p1_row = repo_app.get_job_posting(p1["id"])
|
||||||
|
original_cluster = p1_row.get("cluster_id")
|
||||||
|
|
||||||
|
# Second posting (duplicate)
|
||||||
|
p2 = _create_posting_direct(
|
||||||
|
company="JoinCorp",
|
||||||
|
title="Backend Developer",
|
||||||
|
url="https://example.com/join/2",
|
||||||
|
description="Python developer with PostgreSQL and Docker.",
|
||||||
|
)
|
||||||
|
_assign_cluster_id(p2["id"])
|
||||||
|
|
||||||
|
p2_row = repo_app.get_job_posting(p2["id"])
|
||||||
|
assert p2_row.get("cluster_id") == original_cluster
|
||||||
|
|
||||||
|
def test_third_duplicate_extends_cluster(self, client):
|
||||||
|
"""Third duplicate posting joins the same cluster as the first two."""
|
||||||
|
desc = "Senior Python developer with FastAPI and PostgreSQL experience."
|
||||||
|
p1 = _create_posting_direct(
|
||||||
|
company="ExtCorp", title="Senior Python Developer",
|
||||||
|
url="https://example.com/ext/1", description=desc,
|
||||||
|
)
|
||||||
|
from app.main import _assign_cluster_id
|
||||||
|
_assign_cluster_id(p1["id"])
|
||||||
|
c1 = repo_app.get_job_posting(p1["id"]).get("cluster_id")
|
||||||
|
|
||||||
|
p2 = _create_posting_direct(
|
||||||
|
company="ExtCorp", title="Senior Python Developer",
|
||||||
|
url="https://example.com/ext/2", description=desc,
|
||||||
|
)
|
||||||
|
_assign_cluster_id(p2["id"])
|
||||||
|
|
||||||
|
p3 = _create_posting_direct(
|
||||||
|
company="ExtCorp", title="Senior Python Developer",
|
||||||
|
url="https://example.com/ext/3", description=desc,
|
||||||
|
)
|
||||||
|
_assign_cluster_id(p3["id"])
|
||||||
|
|
||||||
|
c2 = repo_app.get_job_posting(p2["id"]).get("cluster_id")
|
||||||
|
c3 = repo_app.get_job_posting(p3["id"]).get("cluster_id")
|
||||||
|
assert c1 == c2 == c3
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# GET /clusters endpoint shape (3 tests)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
class TestClustersEndpoint:
|
||||||
|
def test_clusters_returns_list(self, client):
|
||||||
|
"""GET /clusters returns a list of cluster objects."""
|
||||||
|
resp = client.get("/api/clusters")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert isinstance(resp.json(), list)
|
||||||
|
|
||||||
|
def test_clusters_shape(self, client):
|
||||||
|
"""Each cluster has cluster_id and postings with required fields."""
|
||||||
|
# Create two duplicate postings
|
||||||
|
desc = "Full stack developer with React and Node.js experience needed."
|
||||||
|
p1 = _create_posting_direct(
|
||||||
|
company="ShapeCorp", title="Full Stack Developer",
|
||||||
|
url="https://example.com/shape/1", description=desc,
|
||||||
|
)
|
||||||
|
from app.main import _assign_cluster_id
|
||||||
|
_assign_cluster_id(p1["id"])
|
||||||
|
|
||||||
|
p2 = _create_posting_direct(
|
||||||
|
company="ShapeCorp", title="Full Stack Developer",
|
||||||
|
url="https://example.com/shape/2", description=desc,
|
||||||
|
)
|
||||||
|
_assign_cluster_id(p2["id"])
|
||||||
|
|
||||||
|
resp = client.get("/api/clusters")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) >= 1
|
||||||
|
|
||||||
|
cluster = data[0]
|
||||||
|
assert "cluster_id" in cluster
|
||||||
|
assert "postings" in cluster
|
||||||
|
assert isinstance(cluster["postings"], list)
|
||||||
|
assert len(cluster["postings"]) >= 2
|
||||||
|
|
||||||
|
p = cluster["postings"][0]
|
||||||
|
assert "id" in p
|
||||||
|
assert "title" in p
|
||||||
|
assert "company" in p
|
||||||
|
assert "source" in p
|
||||||
|
assert "url" in p
|
||||||
|
assert "score" in p
|
||||||
|
|
||||||
|
def test_clusters_empty_when_no_cluster_ids(self, client):
|
||||||
|
"""GET /clusters returns empty list when no postings have cluster_ids."""
|
||||||
|
# Create a posting but don't assign cluster_id
|
||||||
|
_create_posting_direct(
|
||||||
|
company="NoCluster", title="Dev",
|
||||||
|
url="https://example.com/nocluster/1", description="Something unique.",
|
||||||
|
)
|
||||||
|
# Don't call _assign_cluster_id
|
||||||
|
|
||||||
|
resp = client.get("/api/clusters")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
# Should be empty since no cluster_ids assigned
|
||||||
|
assert len(data) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Tailor CV happy path (3 tests)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
class TestTailorCvHappyPath:
|
||||||
|
def test_tailor_cv_returns_artifact_and_coverage(self, client):
|
||||||
|
"""POST /applications/{id}/tailor-cv returns artifact_id, change_log, keyword_coverage."""
|
||||||
|
app_id = _create_app_with_profile_and_sections(client)
|
||||||
|
|
||||||
|
resp = client.post(f"/api/applications/{app_id}/tailor-cv")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
assert "artifact_id" in data
|
||||||
|
assert data["artifact_id"] is not None
|
||||||
|
assert "change_log" in data
|
||||||
|
assert isinstance(data["change_log"], list)
|
||||||
|
assert len(data["change_log"]) >= 1
|
||||||
|
assert "keyword_coverage" in data
|
||||||
|
kc = data["keyword_coverage"]
|
||||||
|
assert "matched" in kc
|
||||||
|
assert "missing" in kc
|
||||||
|
assert "ratio" in kc
|
||||||
|
|
||||||
|
def test_tailor_cv_artifact_stored(self, client):
|
||||||
|
"""The tailored CV artifact appears in the application's artifacts list."""
|
||||||
|
app_id = _create_app_with_profile_and_sections(client)
|
||||||
|
|
||||||
|
resp = client.post(f"/api/applications/{app_id}/tailor-cv")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
artifact_id = resp.json()["artifact_id"]
|
||||||
|
|
||||||
|
artifacts = client.get(f"/api/applications/{app_id}/artifacts").json()
|
||||||
|
cv_artifacts = [a for a in artifacts if a["kind"] == "cv"]
|
||||||
|
assert len(cv_artifacts) >= 1
|
||||||
|
assert any(a["id"] == artifact_id for a in cv_artifacts)
|
||||||
|
ai_artifact = [a for a in cv_artifacts if a["id"] == artifact_id][0]
|
||||||
|
assert ai_artifact["origin"] == "ai_drafted"
|
||||||
|
|
||||||
|
def test_tailor_cv_404_nonexistent(self, client):
|
||||||
|
"""Tailor CV on nonexistent application returns 404."""
|
||||||
|
resp = client.post("/api/applications/00000000-0000-0000-0000-000000000000/tailor-cv")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Tailor CV hallucination rejection (3 tests)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
class TestTailorCvHallucinationGuard:
|
||||||
|
def test_hallucination_rejection_502(self, client, monkeypatch):
|
||||||
|
"""When the tailor output has bullets with no source mapping, return 502."""
|
||||||
|
import app.llm as llm_mod
|
||||||
|
|
||||||
|
app_id = _create_app_with_profile_and_sections(client)
|
||||||
|
fabricated = {
|
||||||
|
"tailored_cv": {
|
||||||
|
"summary": "Developer",
|
||||||
|
"skills": ["Python"],
|
||||||
|
"experience": [
|
||||||
|
{
|
||||||
|
"company": "FakeCorp",
|
||||||
|
"role": "Fake Role",
|
||||||
|
"bullets": [
|
||||||
|
"Completely fabricated achievement that has no overlap with any source bullet xyzqwerty",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"change_log": [{"action": "invented", "detail": "Made up a bullet"}],
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
llm_mod, "run_task",
|
||||||
|
lambda task, prompt, *a, **k: fabricated,
|
||||||
|
)
|
||||||
|
resp = client.post(f"/api/applications/{app_id}/tailor-cv")
|
||||||
|
assert resp.status_code == 502
|
||||||
|
detail = resp.json()["detail"]
|
||||||
|
assert "hallucination_guard" in str(detail)
|
||||||
|
|
||||||
|
def test_hallucination_rejection_with_empty_source_bullets(self, client):
|
||||||
|
"""When there are no source bullets, hallucination guard is not triggered (no source to map to)."""
|
||||||
|
# Create profile with no sections
|
||||||
|
client.get("/api/profile")
|
||||||
|
client.put("/api/profile", json={
|
||||||
|
"full_name": "Test User",
|
||||||
|
"email": "test@test.com",
|
||||||
|
})
|
||||||
|
# Create posting + app
|
||||||
|
posting = _create_posting_direct(
|
||||||
|
company="NoSourceCo",
|
||||||
|
title="Dev",
|
||||||
|
url="https://example.com/nosource/1",
|
||||||
|
description="Python developer",
|
||||||
|
)
|
||||||
|
apps = repo_app.list_applications()
|
||||||
|
app_id = None
|
||||||
|
for a in apps:
|
||||||
|
if a["job_posting_id"] == posting["id"]:
|
||||||
|
app_id = a["id"]
|
||||||
|
break
|
||||||
|
|
||||||
|
# When no source bullets exist, the guard doesn't trigger (source_bullets is empty)
|
||||||
|
resp = client.post(f"/api/applications/{app_id}/tailor-cv")
|
||||||
|
# Should succeed since source_bullets is empty -> guard not triggered
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_hallucination_rejection_preserves_existing_output(self, client, monkeypatch):
|
||||||
|
"""After a 502 hallucination rejection, a subsequent valid call works."""
|
||||||
|
import app.llm as llm_mod
|
||||||
|
|
||||||
|
app_id = _create_app_with_profile_and_sections(client)
|
||||||
|
fabricated = {
|
||||||
|
"tailored_cv": {
|
||||||
|
"summary": "Dev",
|
||||||
|
"skills": ["Python"],
|
||||||
|
"experience": [
|
||||||
|
{
|
||||||
|
"company": "X",
|
||||||
|
"role": "X",
|
||||||
|
"bullets": ["Fabricated xyzqwerty zzz new content"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"change_log": [],
|
||||||
|
}
|
||||||
|
with monkeypatch.context() as mp:
|
||||||
|
mp.setattr(
|
||||||
|
llm_mod, "run_task",
|
||||||
|
lambda task, prompt, *a, **k: fabricated,
|
||||||
|
)
|
||||||
|
resp1 = client.post(f"/api/applications/{app_id}/tailor-cv")
|
||||||
|
assert resp1.status_code == 502
|
||||||
|
|
||||||
|
# Default prompt-aware mock is guard-safe -> succeeds
|
||||||
|
resp2 = client.post(f"/api/applications/{app_id}/tailor-cv")
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Keyword coverage numbers vs fixture (2 tests)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
class TestKeywordCoverage:
|
||||||
|
def test_keyword_coverage_has_matched_and_missing(self, client):
|
||||||
|
"""Keyword coverage from tailor-cv contains matched and missing keywords."""
|
||||||
|
app_id = _create_app_with_profile_and_sections(
|
||||||
|
client,
|
||||||
|
description="Python FastAPI PostgreSQL Docker Kubernetes AWS Java Spring",
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(f"/api/applications/{app_id}/tailor-cv")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
kc = resp.json()["keyword_coverage"]
|
||||||
|
|
||||||
|
assert "matched" in kc
|
||||||
|
assert "missing" in kc
|
||||||
|
assert "ratio" in kc
|
||||||
|
assert isinstance(kc["matched"], list)
|
||||||
|
assert isinstance(kc["missing"], list)
|
||||||
|
assert isinstance(kc["ratio"], (int, float))
|
||||||
|
assert 0.0 <= kc["ratio"] <= 1.0
|
||||||
|
|
||||||
|
def test_keyword_coverage_ratio_is_reasonable(self, client):
|
||||||
|
"""With matching CV keywords, coverage ratio should be > 0."""
|
||||||
|
app_id = _create_app_with_profile_and_sections(
|
||||||
|
client,
|
||||||
|
description="Python FastAPI PostgreSQL Docker Kubernetes AWS",
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(f"/api/applications/{app_id}/tailor-cv")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
kc = resp.json()["keyword_coverage"]
|
||||||
|
|
||||||
|
# The mock CV has Python, Fast API, PostgreSQL, Docker, Kubernetes, AWS
|
||||||
|
# which should match most posting keywords
|
||||||
|
assert kc["ratio"] > 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Deadline persisted on scoring (3 tests)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
class TestDeadlinePersisted:
|
||||||
|
def test_deadline_extracted_on_single_score(self, client):
|
||||||
|
"""Scoring a posting also runs deadline_extract and persists apply_by."""
|
||||||
|
posting = _create_posting_direct(
|
||||||
|
company="DeadlineCo",
|
||||||
|
title="Dev",
|
||||||
|
url="https://example.com/deadline/1",
|
||||||
|
description="Apply by 2026-12-31.",
|
||||||
|
)
|
||||||
|
apps = repo_app.list_applications()
|
||||||
|
app_id = None
|
||||||
|
for a in apps:
|
||||||
|
if a["job_posting_id"] == posting["id"]:
|
||||||
|
app_id = a["id"]
|
||||||
|
break
|
||||||
|
|
||||||
|
# Mock deadline_extract to return a date
|
||||||
|
import app.llm as llm_mod
|
||||||
|
original = llm_mod.MOCK_OUTPUTS.get("deadline_extract", {}).copy()
|
||||||
|
try:
|
||||||
|
llm_mod.MOCK_OUTPUTS["deadline_extract"] = {"apply_by": "2026-12-31"}
|
||||||
|
resp = client.post(f"/api/postings/{posting['id']}/score")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Verify apply_by was persisted
|
||||||
|
p = repo_app.get_job_posting(posting["id"])
|
||||||
|
assert p["apply_by"] == "2026-12-31"
|
||||||
|
finally:
|
||||||
|
llm_mod.MOCK_OUTPUTS["deadline_extract"] = original
|
||||||
|
|
||||||
|
def test_deadline_null_does_not_persist(self, client):
|
||||||
|
"""When deadline_extract returns null apply_by, nothing is persisted."""
|
||||||
|
posting = _create_posting_direct(
|
||||||
|
company="NoDeadlineCo",
|
||||||
|
title="Dev",
|
||||||
|
url="https://example.com/nodeadline/1",
|
||||||
|
description="No deadline mentioned.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default mock returns apply_by=None
|
||||||
|
resp = client.post(f"/api/postings/{posting['id']}/score")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
p = repo_app.get_job_posting(posting["id"])
|
||||||
|
assert p["apply_by"] is None
|
||||||
|
|
||||||
|
def test_deadline_extracted_on_batch_score(self, client):
|
||||||
|
"""Batch scoring also runs deadline_extract and persists apply_by."""
|
||||||
|
posting = _create_posting_direct(
|
||||||
|
company="BatchDeadlineCo",
|
||||||
|
title="Dev",
|
||||||
|
url="https://example.com/batchdeadline/1",
|
||||||
|
description="Apply by 2026-11-15.",
|
||||||
|
)
|
||||||
|
apps = repo_app.list_applications()
|
||||||
|
app_id = None
|
||||||
|
for a in apps:
|
||||||
|
if a["job_posting_id"] == posting["id"]:
|
||||||
|
app_id = a["id"]
|
||||||
|
break
|
||||||
|
|
||||||
|
import app.llm as llm_mod
|
||||||
|
original = llm_mod.MOCK_OUTPUTS.get("deadline_extract", {}).copy()
|
||||||
|
try:
|
||||||
|
llm_mod.MOCK_OUTPUTS["deadline_extract"] = {"apply_by": "2026-11-15"}
|
||||||
|
resp = client.post("/api/scoring/batch", json={"application_ids": [app_id]})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
p = repo_app.get_job_posting(posting["id"])
|
||||||
|
assert p["apply_by"] == "2026-11-15"
|
||||||
|
finally:
|
||||||
|
llm_mod.MOCK_OUTPUTS["deadline_extract"] = original
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# /today deadlines filter window (3 tests)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
class TestTodayDeadlines:
|
||||||
|
def test_today_returns_deadlines_field(self, client):
|
||||||
|
"""/today response includes deadlines field."""
|
||||||
|
resp = client.get("/api/today")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "deadlines" in data
|
||||||
|
assert isinstance(data["deadlines"], list)
|
||||||
|
|
||||||
|
def test_today_deadlines_within_7_days(self, client):
|
||||||
|
"""Deadlines within next 7 days appear in /today."""
|
||||||
|
# Create posting with apply_by in 3 days
|
||||||
|
posting = _create_posting_direct(
|
||||||
|
company="WeekCo",
|
||||||
|
title="Dev",
|
||||||
|
url="https://example.com/week/1",
|
||||||
|
description="Dev role.",
|
||||||
|
)
|
||||||
|
apps = repo_app.list_applications()
|
||||||
|
app_id = None
|
||||||
|
for a in apps:
|
||||||
|
if a["job_posting_id"] == posting["id"]:
|
||||||
|
app_id = a["id"]
|
||||||
|
break
|
||||||
|
|
||||||
|
future_date = date.today() + timedelta(days=3)
|
||||||
|
repo_app.update_posting_apply_by(posting["id"], future_date)
|
||||||
|
|
||||||
|
resp = client.get("/api/today")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
deadlines = resp.json()["deadlines"]
|
||||||
|
assert len(deadlines) >= 1
|
||||||
|
|
||||||
|
dl = [d for d in deadlines if d["application_id"] == app_id]
|
||||||
|
assert len(dl) == 1
|
||||||
|
assert dl[0]["title"] == "Dev"
|
||||||
|
assert dl[0]["company"] == "WeekCo"
|
||||||
|
assert dl[0]["apply_by"] == future_date.isoformat()
|
||||||
|
|
||||||
|
def test_today_deadlines_excludes_beyond_7_days(self, client):
|
||||||
|
"""Deadlines beyond 7 days do NOT appear in /today."""
|
||||||
|
posting = _create_posting_direct(
|
||||||
|
company="FarCo",
|
||||||
|
title="Dev",
|
||||||
|
url="https://example.com/far/1",
|
||||||
|
description="Dev role.",
|
||||||
|
)
|
||||||
|
apps = repo_app.list_applications()
|
||||||
|
app_id = None
|
||||||
|
for a in apps:
|
||||||
|
if a["job_posting_id"] == posting["id"]:
|
||||||
|
app_id = a["id"]
|
||||||
|
break
|
||||||
|
|
||||||
|
far_date = date.today() + timedelta(days=30)
|
||||||
|
repo_app.update_posting_apply_by(posting["id"], far_date)
|
||||||
|
|
||||||
|
resp = client.get("/api/today")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
deadlines = resp.json()["deadlines"]
|
||||||
|
far_deadlines = [d for d in deadlines if d["application_id"] == app_id]
|
||||||
|
assert len(far_deadlines) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Extra integration tests (2 tests)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
class TestExtraIntegration:
|
||||||
|
def test_get_postings_has_apply_by_field(self, client):
|
||||||
|
"""GET /postings includes apply_by field."""
|
||||||
|
posting = _create_posting_direct(
|
||||||
|
company="ApplyByCo",
|
||||||
|
title="Dev",
|
||||||
|
url="https://example.com/applyby/1",
|
||||||
|
description="Dev role.",
|
||||||
|
)
|
||||||
|
repo_app.update_posting_apply_by(posting["id"], date.today() + timedelta(days=5))
|
||||||
|
|
||||||
|
resp = client.get("/api/postings")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
p = [x for x in data if x["id"] == posting["id"]][0]
|
||||||
|
assert p["apply_by"] is not None
|
||||||
|
|
||||||
|
def test_clusters_sorted_by_best_score_desc(self, client):
|
||||||
|
"""Clusters are sorted by best score descending."""
|
||||||
|
# Create cluster 1 with a high-score posting
|
||||||
|
desc1 = "Python developer with PostgreSQL and Docker experience needed."
|
||||||
|
p1 = _create_posting_direct(
|
||||||
|
company="HighScoreCo", title="Python Developer",
|
||||||
|
url="https://example.com/sort/1", description=desc1,
|
||||||
|
)
|
||||||
|
from app.main import _assign_cluster_id
|
||||||
|
_assign_cluster_id(p1["id"])
|
||||||
|
|
||||||
|
# Score p1
|
||||||
|
apps = repo_app.list_applications()
|
||||||
|
for a in apps:
|
||||||
|
if a["job_posting_id"] == p1["id"]:
|
||||||
|
repo_app.update_application_score(a["id"], 90, {"factors": {}})
|
||||||
|
break
|
||||||
|
|
||||||
|
# Create cluster 2 with a low-score posting
|
||||||
|
desc2 = "Marketing specialist for social media campaigns and content creation."
|
||||||
|
p2 = _create_posting_direct(
|
||||||
|
company="LowScoreCo", title="Marketing Specialist",
|
||||||
|
url="https://example.com/sort/2", description=desc2,
|
||||||
|
)
|
||||||
|
_assign_cluster_id(p2["id"])
|
||||||
|
|
||||||
|
for a in apps:
|
||||||
|
if a["job_posting_id"] == p2["id"]:
|
||||||
|
repo_app.update_application_score(a["id"], 30, {"factors": {}})
|
||||||
|
break
|
||||||
|
|
||||||
|
resp = client.get("/api/clusters")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
if len(data) >= 2:
|
||||||
|
# Best scores should be descending
|
||||||
|
best_scores = []
|
||||||
|
for c in data:
|
||||||
|
scores = [p.get("score") or 0 for p in c["postings"]]
|
||||||
|
best_scores.append(max(scores) if scores else 0)
|
||||||
|
assert best_scores[0] >= best_scores[1]
|
||||||
510
apps/api/tests/test_v1_features.py
Normal file
510
apps/api/tests/test_v1_features.py
Normal file
|
|
@ -0,0 +1,510 @@
|
||||||
|
"""Tests for v1 batch scoring, today digest, interview prep, seed demo, and SMTP transport."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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
|
||||||
|
from app.transport import (
|
||||||
|
ClipboardTransport,
|
||||||
|
SmtpTransport,
|
||||||
|
get_transport,
|
||||||
|
is_smtp_configured,
|
||||||
|
reset_transport,
|
||||||
|
set_transport,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Batch Scoring ---
|
||||||
|
|
||||||
|
class TestBatchScoring:
|
||||||
|
def test_batch_score_multiple(self, client):
|
||||||
|
"""Batch score multiple applications."""
|
||||||
|
ids = []
|
||||||
|
for i in range(3):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/postings",
|
||||||
|
json={"url": f"https://example.com/batch/{i}"},
|
||||||
|
)
|
||||||
|
ids.append(resp.json()["id"])
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/scoring/batch",
|
||||||
|
json={"application_ids": ids},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data["results"]) == 3
|
||||||
|
for r in data["results"]:
|
||||||
|
assert "score" in r
|
||||||
|
assert "red_flags" in r
|
||||||
|
assert isinstance(r["red_flags"], list)
|
||||||
|
|
||||||
|
def test_batch_score_empty_list(self, client):
|
||||||
|
"""Empty application_ids list -> 200 with empty results."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/scoring/batch",
|
||||||
|
json={"application_ids": []},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["results"] == []
|
||||||
|
|
||||||
|
def test_batch_score_nonexistent_app_skipped(self, client):
|
||||||
|
"""Nonexistent application IDs are silently skipped."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/scoring/batch",
|
||||||
|
json={"application_ids": ["00000000-0000-0000-0000-000000000000"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["results"] == []
|
||||||
|
|
||||||
|
def test_batch_score_includes_red_flags(self, client):
|
||||||
|
"""Batch score results include red_flags field."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/postings",
|
||||||
|
json={"url": "https://example.com/redflag/test"},
|
||||||
|
)
|
||||||
|
app_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/scoring/batch",
|
||||||
|
json={"application_ids": [app_id]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
result = resp.json()["results"][0]
|
||||||
|
assert "red_flags" in result
|
||||||
|
assert isinstance(result["red_flags"], list)
|
||||||
|
|
||||||
|
def test_batch_score_updates_application_state(self, client):
|
||||||
|
"""After batch scoring, application state should be 'scored'."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/postings",
|
||||||
|
json={"url": "https://example.com/state/test"},
|
||||||
|
)
|
||||||
|
app_id = resp.json()["id"]
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
"/api/scoring/batch",
|
||||||
|
json={"application_ids": [app_id]},
|
||||||
|
)
|
||||||
|
|
||||||
|
apps = client.get("/api/applications").json()
|
||||||
|
scored = [a for a in apps if a["id"] == app_id]
|
||||||
|
assert len(scored) == 1
|
||||||
|
assert scored[0]["state"] == "scored"
|
||||||
|
assert scored[0]["score"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Today ---
|
||||||
|
|
||||||
|
class TestToday:
|
||||||
|
def test_today_empty(self, client):
|
||||||
|
"""Today endpoint with no data returns empty digest and zero pending."""
|
||||||
|
resp = client.get("/api/today")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["digest"] == []
|
||||||
|
assert data["nudges"] == []
|
||||||
|
assert data["pending_approvals"] == 0
|
||||||
|
|
||||||
|
def test_today_with_scored_applications(self, client):
|
||||||
|
"""Today digest includes scored applications ordered by score desc."""
|
||||||
|
# Create and score two applications
|
||||||
|
for url in ["https://example.com/today/1", "https://example.com/today/2"]:
|
||||||
|
client.post("/api/postings", json={"url": url})
|
||||||
|
|
||||||
|
apps = client.get("/api/applications").json()
|
||||||
|
client.post(
|
||||||
|
"/api/scoring/batch",
|
||||||
|
json={"application_ids": [a["id"] for a in apps]},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get("/api/today")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
digest = resp.json()["digest"]
|
||||||
|
assert len(digest) == 2
|
||||||
|
# Should be ordered by score desc
|
||||||
|
assert digest[0]["score"] >= digest[1]["score"]
|
||||||
|
|
||||||
|
def test_today_nudge_for_backdated_sent(self, client):
|
||||||
|
"""A sent application backdated 8 days should appear in nudges."""
|
||||||
|
# Create posting + application
|
||||||
|
resp = client.post("/api/postings", json={"url": "https://example.com/nudge/1"})
|
||||||
|
app_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# Score it, then move through states to sent
|
||||||
|
client.post("/api/scoring/batch", json={"application_ids": [app_id]})
|
||||||
|
client.post(f"/api/applications/{app_id}/transition", json={"to": "approved"})
|
||||||
|
client.post(f"/api/applications/{app_id}/transition", json={"to": "drafting"})
|
||||||
|
|
||||||
|
# Directly set state to sent (bypassing guard for test)
|
||||||
|
with psycopg.connect(DATABASE_URL) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE application SET state = 'sent', last_activity_at = %s WHERE id = %s",
|
||||||
|
(datetime.now(timezone.utc) - timedelta(days=8), app_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
resp = client.get("/api/today")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
nudges = resp.json()["nudges"]
|
||||||
|
assert len(nudges) == 1
|
||||||
|
assert nudges[0]["application_id"] == app_id
|
||||||
|
assert nudges[0]["days_since_sent"] >= 8
|
||||||
|
|
||||||
|
def test_today_nudge_snoozed_excluded(self, client):
|
||||||
|
"""A snoozed application should not appear in nudges."""
|
||||||
|
resp = client.post("/api/postings", json={"url": "https://example.com/nudge/2"})
|
||||||
|
app_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# Score and set to sent with backdated activity
|
||||||
|
client.post("/api/scoring/batch", json={"application_ids": [app_id]})
|
||||||
|
with psycopg.connect(DATABASE_URL) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE application SET state = 'sent', last_activity_at = %s WHERE id = %s",
|
||||||
|
(datetime.now(timezone.utc) - timedelta(days=10), app_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Verify it shows up first
|
||||||
|
resp = client.get("/api/today")
|
||||||
|
assert len(resp.json()["nudges"]) == 1
|
||||||
|
|
||||||
|
# Snooze it for a future date
|
||||||
|
future = (datetime.now(timezone.utc) + timedelta(days=7)).date()
|
||||||
|
with psycopg.connect(DATABASE_URL) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE application SET follow_up_snoozed_until = %s WHERE id = %s",
|
||||||
|
(future, app_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
resp = client.get("/api/today")
|
||||||
|
assert len(resp.json()["nudges"]) == 0
|
||||||
|
|
||||||
|
def test_today_pending_approvals_count(self, client):
|
||||||
|
"""Today endpoint counts pending (unconfirmed, unexpired) approvals."""
|
||||||
|
# Create posting + application + artifact
|
||||||
|
resp = client.post("/api/postings", json={"url": "https://example.com/today/3"})
|
||||||
|
app_id = resp.json()["id"]
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
f"/api/applications/{app_id}/artifacts",
|
||||||
|
json={"kind": "email", "content": "test content"},
|
||||||
|
)
|
||||||
|
artifacts = client.get(f"/api/applications/{app_id}/artifacts").json()
|
||||||
|
artifact_id = artifacts[0]["id"]
|
||||||
|
|
||||||
|
# Create approval (not confirmed)
|
||||||
|
client.post(
|
||||||
|
f"/api/applications/{app_id}/approvals",
|
||||||
|
json={"action": "send_email", "artifact_id": artifact_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get("/api/today")
|
||||||
|
assert resp.json()["pending_approvals"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- Interview Prep ---
|
||||||
|
|
||||||
|
class TestInterviewPrep:
|
||||||
|
def test_interview_prep_creates_artifact(self, client):
|
||||||
|
"""POST /applications/{id}/interview-prep creates an artifact."""
|
||||||
|
resp = client.post("/api/postings", json={"url": "https://example.com/iprep/1"})
|
||||||
|
app_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(f"/api/applications/{app_id}/interview-prep")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "artifact_id" in data
|
||||||
|
assert "content" in data
|
||||||
|
assert len(data["content"]) > 0
|
||||||
|
assert "Q1" in data["content"]
|
||||||
|
|
||||||
|
def test_interview_prep_artifact_in_list(self, client):
|
||||||
|
"""Interview prep artifact appears in GET artifacts list."""
|
||||||
|
resp = client.post("/api/postings", json={"url": "https://example.com/iprep/2"})
|
||||||
|
app_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(f"/api/applications/{app_id}/interview-prep")
|
||||||
|
artifact_id = resp.json()["artifact_id"]
|
||||||
|
|
||||||
|
resp = client.get(f"/api/applications/{app_id}/artifacts")
|
||||||
|
artifacts = resp.json()
|
||||||
|
assert any(a["id"] == artifact_id for a in artifacts)
|
||||||
|
|
||||||
|
def test_interview_prep_sets_artifact_id_on_app(self, client):
|
||||||
|
"""interview_prep_artifact_id is set on the application."""
|
||||||
|
resp = client.post("/api/postings", json={"url": "https://example.com/iprep/3"})
|
||||||
|
app_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(f"/api/applications/{app_id}/interview-prep")
|
||||||
|
artifact_id = resp.json()["artifact_id"]
|
||||||
|
|
||||||
|
apps = client.get("/api/applications").json()
|
||||||
|
app_row = [a for a in apps if a["id"] == app_id][0]
|
||||||
|
assert app_row["interview_prep_artifact_id"] == artifact_id
|
||||||
|
|
||||||
|
def test_interview_prep_404_nonexistent_app(self, client):
|
||||||
|
"""404 for nonexistent application."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/applications/00000000-0000-0000-0000-000000000000/interview-prep"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_interview_prep_creates_telemetry(self, client):
|
||||||
|
"""Interview prep creates a task_run entry."""
|
||||||
|
resp = client.post("/api/postings", json={"url": "https://example.com/iprep/4"})
|
||||||
|
app_id = resp.json()["id"]
|
||||||
|
|
||||||
|
client.post(f"/api/applications/{app_id}/interview-prep")
|
||||||
|
|
||||||
|
resp = client.get("/api/telemetry/tasks")
|
||||||
|
tasks = resp.json()
|
||||||
|
assert any(t["task"] == "interview_prep" for t in tasks)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Seed Demo ---
|
||||||
|
|
||||||
|
class TestSeedDemo:
|
||||||
|
def test_seed_demo_creates_data(self, client):
|
||||||
|
"""POST /concierge/seed-demo creates profile, postings, applications, and extended data."""
|
||||||
|
resp = client.post("/api/concierge/seed-demo")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["profile"] == "Demo Demosson"
|
||||||
|
assert data["sections"] == 4
|
||||||
|
# 6 standalone + 3 agency + 2 deadline + 1 redflag + 1 interviewing = 13 postings
|
||||||
|
assert data["postings"] == 13
|
||||||
|
# 6 standalone + 3 agency + 2 deadline + 1 redflag + 1 interviewing = 13 applications
|
||||||
|
assert data["applications"] == 13
|
||||||
|
assert data["clusters"] >= 1
|
||||||
|
assert data["deadlines"] >= 2
|
||||||
|
assert data["suggestions"] == 2
|
||||||
|
assert data["notifications"] == 3
|
||||||
|
assert data["task_runs"] == 6
|
||||||
|
assert data["cv_artifacts"] >= 1
|
||||||
|
|
||||||
|
def test_seed_demo_idempotent(self, client):
|
||||||
|
"""Running seed-demo twice returns the same counts."""
|
||||||
|
resp1 = client.post("/api/concierge/seed-demo")
|
||||||
|
assert resp1.status_code == 200
|
||||||
|
|
||||||
|
resp2 = client.post("/api/concierge/seed-demo")
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
assert resp2.json()["postings"] == resp1.json()["postings"]
|
||||||
|
assert resp2.json()["applications"] == resp1.json()["applications"]
|
||||||
|
assert resp2.json()["sections"] == resp1.json()["sections"]
|
||||||
|
assert resp2.json()["clusters"] == resp1.json()["clusters"]
|
||||||
|
assert resp2.json()["deadlines"] == resp1.json()["deadlines"]
|
||||||
|
assert resp2.json()["suggestions"] == resp1.json()["suggestions"]
|
||||||
|
assert resp2.json()["notifications"] == resp1.json()["notifications"]
|
||||||
|
assert resp2.json()["task_runs"] == resp1.json()["task_runs"]
|
||||||
|
assert resp2.json()["cv_artifacts"] == resp1.json()["cv_artifacts"]
|
||||||
|
|
||||||
|
def test_seed_demo_has_nudge_candidate(self, client):
|
||||||
|
"""After seeding, /today should show a nudge for the backdated sent app."""
|
||||||
|
client.post("/api/concierge/seed-demo")
|
||||||
|
resp = client.get("/api/today")
|
||||||
|
nudges = resp.json()["nudges"]
|
||||||
|
assert len(nudges) >= 1
|
||||||
|
|
||||||
|
def test_seed_demo_has_scored_digest(self, client):
|
||||||
|
"""After seeding, /today digest should have scored applications."""
|
||||||
|
client.post("/api/concierge/seed-demo")
|
||||||
|
resp = client.get("/api/today")
|
||||||
|
digest = resp.json()["digest"]
|
||||||
|
assert len(digest) >= 1
|
||||||
|
|
||||||
|
# --- WS1: Extended seed demo tests (8 new) ---
|
||||||
|
|
||||||
|
def test_seed_demo_agency_cluster_present(self, client):
|
||||||
|
"""Seed creates a 3-posting agency cluster with the same cluster_id."""
|
||||||
|
client.post("/api/concierge/seed-demo")
|
||||||
|
postings = client.get("/api/postings").json()
|
||||||
|
agency_names = {"Aderanto AB", "Wise IT", "TechTalent Nord"}
|
||||||
|
agency_postings = [p for p in postings if p.get("company") in agency_names]
|
||||||
|
assert len(agency_postings) == 3
|
||||||
|
cluster_ids = {p["cluster_id"] for p in agency_postings if p.get("cluster_id")}
|
||||||
|
assert len(cluster_ids) == 1, f"Expected 1 cluster_id, got {cluster_ids}"
|
||||||
|
|
||||||
|
def test_seed_demo_deadlines_populated(self, client):
|
||||||
|
"""Seed creates at least 2 postings with apply_by in the next 7 days."""
|
||||||
|
client.post("/api/concierge/seed-demo")
|
||||||
|
resp = client.get("/api/today")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
deadlines = resp.json().get("deadlines", [])
|
||||||
|
assert len(deadlines) >= 2
|
||||||
|
for d in deadlines:
|
||||||
|
assert d["apply_by"] is not None
|
||||||
|
|
||||||
|
def test_seed_demo_red_flag_rationale(self, client):
|
||||||
|
"""Seed creates an application with red_flags in its score_rationale."""
|
||||||
|
client.post("/api/concierge/seed-demo")
|
||||||
|
apps = client.get("/api/applications").json()
|
||||||
|
red_flag_apps = [
|
||||||
|
a for a in apps
|
||||||
|
if a.get("score_rationale") and isinstance(a["score_rationale"], dict)
|
||||||
|
and "red_flags" in a["score_rationale"]
|
||||||
|
]
|
||||||
|
assert len(red_flag_apps) >= 1
|
||||||
|
red_flags = red_flag_apps[0]["score_rationale"]["red_flags"]
|
||||||
|
assert isinstance(red_flags, list)
|
||||||
|
assert any("unpaid trial" in str(rf).lower() for rf in red_flags)
|
||||||
|
|
||||||
|
def test_seed_demo_has_interviewing_application(self, client):
|
||||||
|
"""Seed creates at least one application in interviewing state."""
|
||||||
|
client.post("/api/concierge/seed-demo")
|
||||||
|
apps = client.get("/api/applications").json()
|
||||||
|
interviewing = [a for a in apps if a["state"] == "interviewing"]
|
||||||
|
assert len(interviewing) >= 1
|
||||||
|
|
||||||
|
def test_seed_demo_has_cover_letter_artifact(self, client):
|
||||||
|
"""Seed creates a cover_letter artifact (origin user_drafted) with Swedish text on the approved app."""
|
||||||
|
client.post("/api/concierge/seed-demo")
|
||||||
|
apps = client.get("/api/applications").json()
|
||||||
|
for a in apps:
|
||||||
|
artifacts = client.get(f"/api/applications/{a['id']}/artifacts").json()
|
||||||
|
for art in artifacts:
|
||||||
|
if art["kind"] == "cover_letter" and art["origin"] == "user_drafted":
|
||||||
|
return
|
||||||
|
assert False, "No user_drafted cover_letter artifact found"
|
||||||
|
|
||||||
|
def test_seed_demo_suggestions_present(self, client):
|
||||||
|
"""Seed creates 2 pending email_suggestion rows with expected classifications."""
|
||||||
|
client.post("/api/concierge/seed-demo")
|
||||||
|
suggestions = client.get("/api/suggestions").json()
|
||||||
|
assert len(suggestions) == 2
|
||||||
|
classifications = {s["classification"] for s in suggestions}
|
||||||
|
assert "interview_invite" in classifications
|
||||||
|
assert "question" in classifications
|
||||||
|
# Verify the interview_invite comes from recruiter@festina-demo.se
|
||||||
|
interview_suggestion = [s for s in suggestions if s["classification"] == "interview_invite"][0]
|
||||||
|
assert interview_suggestion["mailbox_from"] == "recruiter@festina-demo.se"
|
||||||
|
|
||||||
|
def test_seed_demo_notification_log_present(self, client):
|
||||||
|
"""Seed creates 3 notification_log rows: 2 delivered, 1 webhook failed."""
|
||||||
|
client.post("/api/concierge/seed-demo")
|
||||||
|
resp = client.get("/api/notifications/log")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
logs = resp.json()
|
||||||
|
assert len(logs) == 3
|
||||||
|
# At least one delivered (daily_digest or email_suggestion)
|
||||||
|
delivered = [l for l in logs if l["delivered"] is True]
|
||||||
|
assert len(delivered) >= 2
|
||||||
|
# At least one webhook failed with error text
|
||||||
|
failed = [l for l in logs if l["delivered"] is False]
|
||||||
|
assert len(failed) >= 1
|
||||||
|
assert failed[0]["error"] is not None
|
||||||
|
assert len(failed[0]["error"]) > 0
|
||||||
|
|
||||||
|
def test_seed_demo_task_run_telemetry_variance(self, client):
|
||||||
|
"""Seed creates 6 task_run rows across multiple providers and models."""
|
||||||
|
client.post("/api/concierge/seed-demo")
|
||||||
|
resp = client.get("/api/telemetry/tasks")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
tasks = resp.json()
|
||||||
|
assert len(tasks) == 6
|
||||||
|
providers = {t["provider"] for t in tasks}
|
||||||
|
models = {t["model"] for t in tasks}
|
||||||
|
assert len(providers) >= 3, f"Expected >= 3 providers, got {providers}"
|
||||||
|
assert len(models) >= 4, f"Expected >= 4 models, got {models}"
|
||||||
|
# Verify cost variance for CostDisplay
|
||||||
|
costs = [t["cost_usd"] for t in tasks if t["cost_usd"] is not None]
|
||||||
|
assert len(costs) >= 2
|
||||||
|
assert max(costs) > min(costs)
|
||||||
|
|
||||||
|
|
||||||
|
# --- SMTP Transport ---
|
||||||
|
|
||||||
|
class TestSmtpTransportSelection:
|
||||||
|
def test_clipboard_when_no_smtp(self, monkeypatch):
|
||||||
|
"""Without SMTP_HOST, transport is ClipboardTransport."""
|
||||||
|
monkeypatch.delenv("SMTP_HOST", raising=False)
|
||||||
|
reset_transport()
|
||||||
|
t = get_transport()
|
||||||
|
assert isinstance(t, ClipboardTransport)
|
||||||
|
|
||||||
|
def test_smtp_when_configured(self, monkeypatch):
|
||||||
|
"""With SMTP_HOST set, transport is SmtpTransport."""
|
||||||
|
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
|
||||||
|
monkeypatch.setenv("SMTP_PORT", "587")
|
||||||
|
monkeypatch.setenv("SMTP_USER", "user@example.com")
|
||||||
|
monkeypatch.setenv("SMTP_PASS", "pass")
|
||||||
|
monkeypatch.setenv("SMTP_FROM", "from@example.com")
|
||||||
|
reset_transport()
|
||||||
|
t = get_transport()
|
||||||
|
assert isinstance(t, SmtpTransport)
|
||||||
|
assert t.host == "smtp.example.com"
|
||||||
|
assert t.port == 587
|
||||||
|
reset_transport()
|
||||||
|
|
||||||
|
def test_smtp_ssl_on_465(self, monkeypatch):
|
||||||
|
"""SMTP port 465 triggers SSL."""
|
||||||
|
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
|
||||||
|
monkeypatch.setenv("SMTP_PORT", "465")
|
||||||
|
reset_transport()
|
||||||
|
t = get_transport()
|
||||||
|
assert isinstance(t, SmtpTransport)
|
||||||
|
assert t.port == 465
|
||||||
|
reset_transport()
|
||||||
|
|
||||||
|
def test_clipboard_send_success(self):
|
||||||
|
"""ClipboardTransport.send returns success with payload."""
|
||||||
|
t = ClipboardTransport()
|
||||||
|
payload = {"to": "test@example.com", "subject": "Hi", "body": "Hello"}
|
||||||
|
result = t.send(payload)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["transport"] == "clipboard"
|
||||||
|
assert result["payload"] == payload
|
||||||
|
|
||||||
|
def test_set_transport_override(self):
|
||||||
|
"""set_transport overrides the default."""
|
||||||
|
custom = ClipboardTransport()
|
||||||
|
set_transport(custom)
|
||||||
|
assert get_transport() is custom
|
||||||
|
reset_transport()
|
||||||
|
|
||||||
|
def test_is_smtp_configured_false(self, monkeypatch):
|
||||||
|
"""is_smtp_configured returns False when no SMTP_HOST."""
|
||||||
|
monkeypatch.delenv("SMTP_HOST", raising=False)
|
||||||
|
assert not is_smtp_configured()
|
||||||
|
|
||||||
|
def test_is_smtp_configured_true(self, monkeypatch):
|
||||||
|
"""is_smtp_configured returns True when SMTP_HOST is set."""
|
||||||
|
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
|
||||||
|
assert is_smtp_configured()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Scheduler ---
|
||||||
|
|
||||||
|
class TestScheduler:
|
||||||
|
def test_scheduler_disabled_by_default(self, monkeypatch):
|
||||||
|
"""SCHEDULER_ENABLED defaults to false."""
|
||||||
|
from app.scheduler import is_scheduler_enabled
|
||||||
|
monkeypatch.delenv("SCHEDULER_ENABLED", raising=False)
|
||||||
|
assert not is_scheduler_enabled()
|
||||||
|
|
||||||
|
def test_scheduler_enabled_when_true(self, monkeypatch):
|
||||||
|
"""SCHEDULER_ENABLED=true enables scheduler."""
|
||||||
|
from app.scheduler import is_scheduler_enabled
|
||||||
|
monkeypatch.setenv("SCHEDULER_ENABLED", "true")
|
||||||
|
assert is_scheduler_enabled()
|
||||||
|
|
||||||
|
def test_start_scheduler_noop_when_disabled(self, monkeypatch):
|
||||||
|
"""start_scheduler does nothing when disabled."""
|
||||||
|
from app import scheduler
|
||||||
|
monkeypatch.setenv("SCHEDULER_ENABLED", "false")
|
||||||
|
scheduler.start_scheduler() # should not raise
|
||||||
|
scheduler.stop_scheduler()
|
||||||
14
apps/web/Dockerfile
Normal file
14
apps/web/Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Web production image: build the SPA, serve via nginx with SPA fallback
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /w
|
||||||
|
ARG VITE_API_BASE=http://api:8000/api
|
||||||
|
ENV VITE_API_BASE=$VITE_API_BASE
|
||||||
|
COPY apps/web/package.json apps/web/package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --no-fund
|
||||||
|
COPY apps/web ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /w/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
15
apps/web/nginx.conf
Normal file
15
apps/web/nginx.conf
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8000/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,37 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink, RouterView } from 'vue-router'
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
import { RouterLink, RouterView, useRouter } from 'vue-router'
|
||||||
import ToastHost from './components/ToastHost.vue'
|
import ToastHost from './components/ToastHost.vue'
|
||||||
|
import * as api from '@/api'
|
||||||
|
import type { Profile } from '@/types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const profile = ref<Profile | null>(null)
|
||||||
|
const profileChecked = ref(false)
|
||||||
|
|
||||||
|
async function checkProfile() {
|
||||||
|
try {
|
||||||
|
profile.value = await api.getProfile()
|
||||||
|
} catch {
|
||||||
|
// API not available, let normal routing proceed
|
||||||
|
} finally {
|
||||||
|
profileChecked.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkProfile()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Redirect to /welcome when profile.full_name is empty (onboarding wizard)
|
||||||
|
watch(profileChecked, (ready) => {
|
||||||
|
if (ready && profile.value && !profile.value.full_name) {
|
||||||
|
const currentRoute = router.currentRoute.value
|
||||||
|
if (currentRoute.name !== 'welcome') {
|
||||||
|
router.push('/welcome')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -9,6 +40,7 @@ import ToastHost from './components/ToastHost.vue'
|
||||||
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center gap-6">
|
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center gap-6">
|
||||||
<span class="text-lg font-bold text-indigo-700">Jobhunt</span>
|
<span class="text-lg font-bold text-indigo-700">Jobhunt</span>
|
||||||
<nav class="flex gap-4 text-sm">
|
<nav class="flex gap-4 text-sm">
|
||||||
|
<RouterLink to="/today" class="text-gray-600 hover:text-indigo-700">Today</RouterLink>
|
||||||
<RouterLink to="/cv" class="text-gray-600 hover:text-indigo-700">CV</RouterLink>
|
<RouterLink to="/cv" class="text-gray-600 hover:text-indigo-700">CV</RouterLink>
|
||||||
<RouterLink to="/research" class="text-gray-600 hover:text-indigo-700">Research</RouterLink>
|
<RouterLink to="/research" class="text-gray-600 hover:text-indigo-700">Research</RouterLink>
|
||||||
<RouterLink to="/applications" class="text-gray-600 hover:text-indigo-700">Applications</RouterLink>
|
<RouterLink to="/applications" class="text-gray-600 hover:text-indigo-700">Applications</RouterLink>
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,26 @@ import type {
|
||||||
Application,
|
Application,
|
||||||
Approval,
|
Approval,
|
||||||
Artifact,
|
Artifact,
|
||||||
|
BatchScoringResponse,
|
||||||
|
Cluster,
|
||||||
CoverLetterResponse,
|
CoverLetterResponse,
|
||||||
CritiqueComment,
|
CritiqueComment,
|
||||||
|
CvImportConfirmResponse,
|
||||||
|
CvImportResponse,
|
||||||
CvSection,
|
CvSection,
|
||||||
|
DemoSeedResponse,
|
||||||
|
EmailSuggestion,
|
||||||
|
InterviewPrepResponse,
|
||||||
JobPosting,
|
JobPosting,
|
||||||
|
NotificationLogEntry,
|
||||||
|
PostingsFetchResponse,
|
||||||
Profile,
|
Profile,
|
||||||
RenderCvResponse,
|
RenderCvResponse,
|
||||||
ScoreResponse
|
ScoreResponse,
|
||||||
|
TailorCvResponse,
|
||||||
|
TaskRun,
|
||||||
|
TodayResponse,
|
||||||
|
TodayResponseV11
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
const API_BASE: string =
|
const API_BASE: string =
|
||||||
|
|
@ -154,17 +167,110 @@ export function outboxSend(approvalId: string, payload: Record<string, unknown>)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Telemetry ---
|
||||||
|
|
||||||
|
export function getTelemetryTasks(): Promise<TaskRun[]> {
|
||||||
|
return request<TaskRun[]>('/telemetry/tasks')
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- v1.0 additions (api-contract-v2.md) ---
|
||||||
|
|
||||||
|
export function importCv(filename: string, contentBase64: string): Promise<CvImportResponse> {
|
||||||
|
return request<CvImportResponse>('/cv/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ filename, content_base64: contentBase64 })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmCvImport(drafts: CvImportResponse['drafts']): Promise<CvImportConfirmResponse> {
|
||||||
|
return request<CvImportConfirmResponse>('/cv/import/confirm', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ drafts })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchPostings(query: string, region?: string): Promise<PostingsFetchResponse> {
|
||||||
|
const body: Record<string, string> = { query }
|
||||||
|
if (region) body.region = region
|
||||||
|
return request<PostingsFetchResponse>('/postings/fetch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function batchScore(applicationIds: string[]): Promise<BatchScoringResponse> {
|
||||||
|
return request<BatchScoringResponse>('/scoring/batch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ application_ids: applicationIds })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToday(): Promise<TodayResponseV11> {
|
||||||
|
return request<TodayResponseV11>('/today')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function interviewPrep(applicationId: string): Promise<InterviewPrepResponse> {
|
||||||
|
return request<InterviewPrepResponse>(`/applications/${applicationId}/interview-prep`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seedDemo(): Promise<DemoSeedResponse> {
|
||||||
|
return request<DemoSeedResponse>('/concierge/seed-demo', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- v1.1 additions (wave A/B) ---
|
||||||
|
|
||||||
|
export function getSuggestions(): Promise<EmailSuggestion[]> {
|
||||||
|
return request<EmailSuggestion[]>('/suggestions')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function acceptSuggestion(id: string): Promise<EmailSuggestion> {
|
||||||
|
return request<EmailSuggestion>(`/suggestions/${id}/accept`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismissSuggestion(id: string): Promise<EmailSuggestion> {
|
||||||
|
return request<EmailSuggestion>(`/suggestions/${id}/dismiss`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotificationLog(): Promise<NotificationLogEntry[]> {
|
||||||
|
return request<NotificationLogEntry[]>('/notifications/log')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClusters(): Promise<Cluster[]> {
|
||||||
|
return request<Cluster[]>('/clusters')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tailorCv(applicationId: string): Promise<TailorCvResponse> {
|
||||||
|
return request<TailorCvResponse>(`/applications/${applicationId}/tailor-cv`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Re-export types for convenience
|
// Re-export types for convenience
|
||||||
export type {
|
export type {
|
||||||
AiAssistResponse,
|
AiAssistResponse,
|
||||||
Application,
|
Application,
|
||||||
Approval,
|
Approval,
|
||||||
Artifact,
|
Artifact,
|
||||||
|
BatchScoringResponse,
|
||||||
|
Cluster,
|
||||||
CoverLetterResponse,
|
CoverLetterResponse,
|
||||||
CritiqueComment,
|
CritiqueComment,
|
||||||
|
CvImportConfirmResponse,
|
||||||
|
CvImportResponse,
|
||||||
CvSection,
|
CvSection,
|
||||||
|
DemoSeedResponse,
|
||||||
|
EmailSuggestion,
|
||||||
|
InterviewPrepResponse,
|
||||||
JobPosting,
|
JobPosting,
|
||||||
|
NotificationLogEntry,
|
||||||
|
PostingsFetchResponse,
|
||||||
Profile,
|
Profile,
|
||||||
RenderCvResponse,
|
RenderCvResponse,
|
||||||
ScoreResponse
|
ScoreResponse,
|
||||||
|
TailorCvResponse,
|
||||||
|
TaskRun,
|
||||||
|
TodayResponse,
|
||||||
|
TodayResponseV11
|
||||||
}
|
}
|
||||||
97
apps/web/src/components/CostDisplay.vue
Normal file
97
apps/web/src/components/CostDisplay.vue
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, computed } from 'vue'
|
||||||
|
import * as api from '@/api'
|
||||||
|
import type { TaskRun } from '@/types'
|
||||||
|
|
||||||
|
const tasks = ref<TaskRun[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref(false)
|
||||||
|
|
||||||
|
const totalTokensIn = computed(() =>
|
||||||
|
tasks.value.reduce((sum, t) => sum + (t.tokens_in ?? 0), 0)
|
||||||
|
)
|
||||||
|
const totalTokensOut = computed(() =>
|
||||||
|
tasks.value.reduce((sum, t) => sum + (t.tokens_out ?? 0), 0)
|
||||||
|
)
|
||||||
|
const totalCost = computed(() =>
|
||||||
|
tasks.value.reduce((sum, t) => sum + (t.cost ?? 0), 0)
|
||||||
|
)
|
||||||
|
const hasCost = computed(() => tasks.value.some((t) => t.cost != null))
|
||||||
|
|
||||||
|
// Group task runs by model name (proxy for provider) and compute totals per group
|
||||||
|
const byProvider = computed(() => {
|
||||||
|
const map = new Map<string, { model: string; tokensIn: number; tokensOut: number; cost: number; count: number }>()
|
||||||
|
for (const t of tasks.value) {
|
||||||
|
const key = t.model || 'unknown'
|
||||||
|
if (!map.has(key)) {
|
||||||
|
map.set(key, { model: key, tokensIn: 0, tokensOut: 0, cost: 0, count: 0 })
|
||||||
|
}
|
||||||
|
const entry = map.get(key)!
|
||||||
|
entry.tokensIn += t.tokens_in ?? 0
|
||||||
|
entry.tokensOut += t.tokens_out ?? 0
|
||||||
|
entry.cost += t.cost ?? 0
|
||||||
|
entry.count += 1
|
||||||
|
}
|
||||||
|
return Array.from(map.values()).sort((a, b) => b.cost - a.cost)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadTasks() {
|
||||||
|
try {
|
||||||
|
tasks.value = await api.getTelemetryTasks()
|
||||||
|
} catch {
|
||||||
|
error.value = true
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadTasks)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 p-4">
|
||||||
|
<h3 class="font-semibold text-sm text-gray-700 mb-2">LLM Cost Summary</h3>
|
||||||
|
<div v-if="loading" class="text-gray-400 text-sm">Loading...</div>
|
||||||
|
<div v-else-if="error" class="text-red-600 text-sm">Failed to load cost data.</div>
|
||||||
|
<div v-else class="space-y-1 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600">Tokens in:</span>
|
||||||
|
<span class="font-medium">{{ totalTokensIn.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600">Tokens out:</span>
|
||||||
|
<span class="font-medium">{{ totalTokensOut.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="hasCost" class="flex justify-between border-t border-gray-100 pt-1">
|
||||||
|
<span class="text-gray-600">Total cost:</span>
|
||||||
|
<span class="font-medium">{{ totalCost.toFixed(4) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-400 mt-1">{{ tasks.length }} task runs</div>
|
||||||
|
|
||||||
|
<!-- Provider breakdown -->
|
||||||
|
<div v-if="byProvider.length > 0" class="mt-3 border-t border-gray-100 pt-2">
|
||||||
|
<div class="text-xs font-medium text-gray-500 mb-1">By Provider</div>
|
||||||
|
<table class="w-full text-xs" data-testid="provider-breakdown">
|
||||||
|
<thead class="text-left text-gray-400">
|
||||||
|
<tr>
|
||||||
|
<th class="py-1">Model</th>
|
||||||
|
<th class="py-1 text-right">In</th>
|
||||||
|
<th class="py-1 text-right">Out</th>
|
||||||
|
<th class="py-1 text-right">Cost</th>
|
||||||
|
<th class="py-1 text-right">Runs</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="p in byProvider" :key="p.model" class="border-t border-gray-50">
|
||||||
|
<td class="py-1">{{ p.model }}</td>
|
||||||
|
<td class="py-1 text-right">{{ p.tokensIn.toLocaleString() }}</td>
|
||||||
|
<td class="py-1 text-right">{{ p.tokensOut.toLocaleString() }}</td>
|
||||||
|
<td class="py-1 text-right">{{ p.cost.toFixed(4) }}</td>
|
||||||
|
<td class="py-1 text-right">{{ p.count }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
121
apps/web/src/components/InterviewPrepModal.vue
Normal file
121
apps/web/src/components/InterviewPrepModal.vue
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import * as api from '@/api'
|
||||||
|
import { HttpError } from '@/api'
|
||||||
|
import type { InterviewPrepResponse } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
applicationId: string
|
||||||
|
visible: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const toast = useToastStore()
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const prepContent = ref('')
|
||||||
|
const artifactId = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function generatePrep() {
|
||||||
|
loading.value = true
|
||||||
|
prepContent.value = ''
|
||||||
|
artifactId.value = null
|
||||||
|
try {
|
||||||
|
const res: InterviewPrepResponse = await api.interviewPrep(props.applicationId)
|
||||||
|
prepContent.value = res.content
|
||||||
|
artifactId.value = res.artifact_id
|
||||||
|
toast.push('Interview prep generated', 'success')
|
||||||
|
} catch (err) {
|
||||||
|
let msg = 'Failed to generate interview prep'
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
const body = err.body as { error?: { message?: string } } | null
|
||||||
|
msg = body?.error?.message ?? msg
|
||||||
|
}
|
||||||
|
toast.push(msg, 'error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePrep() {
|
||||||
|
if (!prepContent.value.trim()) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const res: InterviewPrepResponse = await api.interviewPrep(props.applicationId)
|
||||||
|
artifactId.value = res.artifact_id
|
||||||
|
toast.push('Interview prep saved as new version', 'success')
|
||||||
|
} catch {
|
||||||
|
toast.push('Failed to save interview prep', 'error')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
||||||
|
@click.self="close"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-lg shadow-xl max-w-3xl w-full mx-4 max-h-[80vh] flex flex-col">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3">
|
||||||
|
<h2 class="font-semibold text-lg">Interview Prep</h2>
|
||||||
|
<button @click="close" class="text-gray-400 hover:text-gray-700 text-xl leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
<div v-if="!prepContent && !loading" class="text-center py-8">
|
||||||
|
<p class="text-gray-500 mb-4">
|
||||||
|
Generate likely interview questions with suggested answers based on your profile and the job posting.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
@click="generatePrep"
|
||||||
|
class="bg-indigo-600 text-white px-4 py-2 rounded text-sm"
|
||||||
|
>
|
||||||
|
Generate Interview Prep
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-gray-500 text-center py-8">Generating interview prep...</div>
|
||||||
|
|
||||||
|
<div v-if="prepContent && !loading" class="space-y-3">
|
||||||
|
<textarea
|
||||||
|
v-model="prepContent"
|
||||||
|
rows="18"
|
||||||
|
class="w-full border rounded px-3 py-2 text-sm font-mono"
|
||||||
|
placeholder="Interview prep content..."
|
||||||
|
></textarea>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
@click="savePrep"
|
||||||
|
:disabled="saving"
|
||||||
|
class="bg-green-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ saving ? 'Saving...' : 'Save as New Version' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="generatePrep"
|
||||||
|
class="text-sm text-indigo-600 hover:underline"
|
||||||
|
>
|
||||||
|
Regenerate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="artifactId" class="text-xs text-gray-400">
|
||||||
|
Artifact ID: <code>{{ artifactId.slice(0, 8) }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -1,33 +1,51 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
import App from '@/App.vue'
|
import App from '@/App.vue'
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
getProfile: vi.fn().mockResolvedValue({ id: 'p1', full_name: 'Test User', email: '', phone: '', location: '', headline: '', summary: '', languages: [], hard_rules: {} }),
|
||||||
|
HttpError: class HttpError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
constructor(status: number, body: unknown, msg?: string) {
|
||||||
|
super(msg ?? `HTTP ${status}`)
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
function makeRouter() {
|
function makeRouter() {
|
||||||
return createRouter({
|
return createRouter({
|
||||||
history: createMemoryHistory(),
|
history: createMemoryHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', redirect: '/cv' },
|
{ path: '/', redirect: '/today' },
|
||||||
|
{ path: '/today', name: 'today', component: { template: '<div>Today</div>' } },
|
||||||
{ path: '/cv', name: 'cv', component: { template: '<div>CV</div>' } },
|
{ path: '/cv', name: 'cv', component: { template: '<div>CV</div>' } },
|
||||||
{ path: '/research', name: 'research', component: { template: '<div>Research</div>' } },
|
{ path: '/research', name: 'research', component: { template: '<div>Research</div>' } },
|
||||||
{ path: '/applications', name: 'applications', component: { template: '<div>Applications</div>' } },
|
{ path: '/applications', name: 'applications', component: { template: '<div>Applications</div>' } },
|
||||||
{ path: '/applications/:id', name: 'application-detail', component: { template: '<div>Detail</div>' } }
|
{ path: '/applications/:id', name: 'application-detail', component: { template: '<div>Detail</div>' } },
|
||||||
|
{ path: '/welcome', name: 'welcome', component: { template: '<div>Welcome</div>' } }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Router tabs', () => {
|
describe('Router tabs', () => {
|
||||||
it('renders all three tab links', async () => {
|
it('renders all four tab links', async () => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
const router = makeRouter()
|
const router = makeRouter()
|
||||||
await router.push('/cv')
|
await router.push('/today')
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
const wrapper = mount(App, { global: { plugins: [router] } })
|
const wrapper = mount(App, { global: { plugins: [router] } })
|
||||||
const links = wrapper.findAll('nav a')
|
await vi.waitFor(() => {
|
||||||
expect(links).toHaveLength(3)
|
const links = wrapper.findAll('nav a')
|
||||||
expect(links[0].text()).toBe('CV')
|
expect(links).toHaveLength(4)
|
||||||
expect(links[1].text()).toBe('Research')
|
expect(links[0].text()).toBe('Today')
|
||||||
expect(links[2].text()).toBe('Applications')
|
expect(links[1].text()).toBe('CV')
|
||||||
|
expect(links[2].text()).toBe('Research')
|
||||||
|
expect(links[3].text()).toBe('Applications')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -2,7 +2,17 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import type { RouteRecordRaw } from 'vue-router'
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{ path: '/', redirect: '/cv' },
|
{ path: '/', redirect: '/today' },
|
||||||
|
{
|
||||||
|
path: '/welcome',
|
||||||
|
name: 'welcome',
|
||||||
|
component: () => import('@/views/Welcome.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/today',
|
||||||
|
name: 'today',
|
||||||
|
component: () => import('@/views/TodayView.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/cv',
|
path: '/cv',
|
||||||
name: 'cv',
|
name: 'cv',
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,10 @@ export interface Application {
|
||||||
notes: string
|
notes: string
|
||||||
state_changed_at: string
|
state_changed_at: string
|
||||||
created_at: string
|
created_at: string
|
||||||
// joined posting info (from GET /applications)
|
// joined posting info (flat fields from GET /applications)
|
||||||
posting?: JobPosting
|
company?: string | null
|
||||||
|
title?: string | null
|
||||||
|
location?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ArtifactKind = 'cv' | 'cover_letter' | 'email' | 'other'
|
export type ArtifactKind = 'cv' | 'cover_letter' | 'email' | 'other'
|
||||||
|
|
@ -126,3 +128,157 @@ export interface AiAssistResponse {
|
||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
error: { code: string; message: string }
|
error: { code: string; message: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- v1.0 additions (api-contract-v2.md) ---
|
||||||
|
|
||||||
|
export interface CvDraft {
|
||||||
|
kind: CvSectionKind
|
||||||
|
title: string
|
||||||
|
org: string
|
||||||
|
location: string
|
||||||
|
start_date: string
|
||||||
|
end_date: string | null
|
||||||
|
bullets: string[]
|
||||||
|
tags: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CvImportResponse {
|
||||||
|
drafts: CvDraft[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CvImportConfirmResponse {
|
||||||
|
created: number
|
||||||
|
sections: CvSection[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostingsFetchResponse {
|
||||||
|
new: number
|
||||||
|
dupes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchScoringResult {
|
||||||
|
application_id: string
|
||||||
|
score: number
|
||||||
|
rationale: Record<string, unknown>
|
||||||
|
red_flags: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchScoringResponse {
|
||||||
|
results: BatchScoringResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TodayDigestItem {
|
||||||
|
application_id: string
|
||||||
|
title: string
|
||||||
|
company: string
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TodayNudge {
|
||||||
|
application_id: string
|
||||||
|
days_since_sent: number
|
||||||
|
suggestion: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TodayResponse {
|
||||||
|
digest: TodayDigestItem[]
|
||||||
|
nudges: TodayNudge[]
|
||||||
|
pending_approvals: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InterviewPrepResponse {
|
||||||
|
artifact_id: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DemoSeedResponse {
|
||||||
|
profile: string
|
||||||
|
postings: number
|
||||||
|
applications: number
|
||||||
|
sections: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskRun {
|
||||||
|
id: string
|
||||||
|
task_type: string
|
||||||
|
model: string
|
||||||
|
tokens_in: number
|
||||||
|
tokens_out: number
|
||||||
|
cost: number | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedFlagsMap {
|
||||||
|
[applicationId: string]: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- v1.1 additions (wave A/B) ---
|
||||||
|
|
||||||
|
export interface TodayDeadline {
|
||||||
|
application_id: string
|
||||||
|
title: string
|
||||||
|
company: string
|
||||||
|
apply_by: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TodayResponseV11 extends TodayResponse {
|
||||||
|
deadlines?: TodayDeadline[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SuggestionClassification =
|
||||||
|
| 'interview_invite'
|
||||||
|
| 'rejection'
|
||||||
|
| 'question'
|
||||||
|
| 'noise'
|
||||||
|
|
||||||
|
export interface EmailSuggestion {
|
||||||
|
id: string
|
||||||
|
application_id: string | null
|
||||||
|
from_address: string
|
||||||
|
subject: string
|
||||||
|
snippet: string
|
||||||
|
classification: SuggestionClassification
|
||||||
|
created_at: string
|
||||||
|
status: 'pending' | 'accepted' | 'dismissed'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationLogEntry {
|
||||||
|
id: string
|
||||||
|
channel: string
|
||||||
|
message: string
|
||||||
|
data: Record<string, unknown> | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClusterPosting {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
company: string
|
||||||
|
source: string
|
||||||
|
url: string
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Cluster {
|
||||||
|
cluster_id: string
|
||||||
|
postings: ClusterPosting[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TailorKeywordCoverage {
|
||||||
|
ratio: number
|
||||||
|
matched: string[]
|
||||||
|
missing: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TailorChangeLogEntry {
|
||||||
|
action?: string
|
||||||
|
section?: string
|
||||||
|
detail?: string
|
||||||
|
change?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TailorCvResponse {
|
||||||
|
artifact_id: string
|
||||||
|
change_log: TailorChangeLogEntry[]
|
||||||
|
keyword_coverage: TailorKeywordCoverage
|
||||||
|
}
|
||||||
135
apps/web/src/views/ApplicationDetail.tailor.test.ts
Normal file
135
apps/web/src/views/ApplicationDetail.tailor.test.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import type { Application, Artifact, TailorCvResponse } from '@/types'
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
getApplications: vi.fn(),
|
||||||
|
getArtifacts: vi.fn(),
|
||||||
|
createCoverLetter: vi.fn(),
|
||||||
|
createApproval: vi.fn(),
|
||||||
|
confirmApproval: vi.fn(),
|
||||||
|
rejectApproval: vi.fn(),
|
||||||
|
outboxSend: vi.fn(),
|
||||||
|
interviewPrep: vi.fn(),
|
||||||
|
tailorCv: vi.fn(),
|
||||||
|
HttpError: class HttpError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
constructor(status: number, body: unknown, msg?: string) {
|
||||||
|
super(msg ?? `HTTP ${status}`)
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
function makeApp(): Application {
|
||||||
|
return {
|
||||||
|
id: 'app-1',
|
||||||
|
job_posting_id: 'j-1',
|
||||||
|
state: 'drafting',
|
||||||
|
score: 90,
|
||||||
|
score_rationale: null,
|
||||||
|
notes: '',
|
||||||
|
state_changed_at: '2026-01-01T00:00:00Z',
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
company: 'Acme',
|
||||||
|
title: 'Engineer',
|
||||||
|
location: 'Remote'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeArtifact(): Artifact {
|
||||||
|
return {
|
||||||
|
id: 'art-1',
|
||||||
|
application_id: 'app-1',
|
||||||
|
kind: 'cover_letter',
|
||||||
|
filename: 'cover.pdf',
|
||||||
|
content_hash: 'abcdef0123456789',
|
||||||
|
storage_path: '/tmp/cover.pdf',
|
||||||
|
version: 1,
|
||||||
|
origin: 'user_drafted',
|
||||||
|
created_at: '2026-01-01T00:00:00Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTailorResult(): TailorCvResponse {
|
||||||
|
return {
|
||||||
|
artifact_id: 'art-tailor-1',
|
||||||
|
change_log: [
|
||||||
|
{ action: 'experience', detail: 'Reordered to highlight Python backend work' },
|
||||||
|
{ action: 'skills', detail: 'Moved Docker and Kubernetes higher' }
|
||||||
|
],
|
||||||
|
keyword_coverage: { ratio: 0.75, matched: ['python', 'docker'], missing: ['kubernetes'] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountDetail(app: Application, artifacts: Artifact[]) {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const api = await import('@/api')
|
||||||
|
;(api.getApplications as ReturnType<typeof vi.fn>).mockResolvedValue([app])
|
||||||
|
;(api.getArtifacts as ReturnType<typeof vi.fn>).mockResolvedValue(artifacts)
|
||||||
|
const ApplicationDetail = (await import('@/views/ApplicationDetail.vue')).default
|
||||||
|
const wrapper = mount(ApplicationDetail, { props: { id: 'app-1' } })
|
||||||
|
await flushPromises()
|
||||||
|
return { wrapper }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Tailor CV panel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders change log, coverage bar, and download link after tailoring', async () => {
|
||||||
|
const { wrapper } = await mountDetail(makeApp(), [makeArtifact()])
|
||||||
|
const api = await import('@/api')
|
||||||
|
|
||||||
|
// Panel should not be visible before clicking
|
||||||
|
expect(wrapper.find('[data-testid="tailor-panel"]').exists()).toBe(false)
|
||||||
|
|
||||||
|
// Mock tailorCv to return a result
|
||||||
|
;(api.tailorCv as ReturnType<typeof vi.fn>).mockResolvedValue(makeTailorResult())
|
||||||
|
// After tailoring, getArtifacts is called again to refresh
|
||||||
|
;(api.getArtifacts as ReturnType<typeof vi.fn>).mockResolvedValue([makeArtifact(), {
|
||||||
|
id: 'art-tailor-1',
|
||||||
|
application_id: 'app-1',
|
||||||
|
kind: 'cv',
|
||||||
|
filename: 'tailored_cv.pdf',
|
||||||
|
content_hash: 'deadbeef01234567',
|
||||||
|
storage_path: '/tmp/tailored_cv.pdf',
|
||||||
|
version: 1,
|
||||||
|
origin: 'ai_drafted',
|
||||||
|
created_at: '2026-07-30T00:00:00Z'
|
||||||
|
}])
|
||||||
|
|
||||||
|
// Click the Tailor CV button
|
||||||
|
const btn = wrapper.find('[data-testid="tailor-cv-btn"]')
|
||||||
|
expect(btn.exists()).toBe(true)
|
||||||
|
await btn.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Panel should now be visible
|
||||||
|
const panel = wrapper.find('[data-testid="tailor-panel"]')
|
||||||
|
expect(panel.exists()).toBe(true)
|
||||||
|
|
||||||
|
// Change log entries should be visible
|
||||||
|
expect(panel.text()).toContain('Reordered to highlight Python backend work')
|
||||||
|
expect(panel.text()).toContain('Moved Docker and Kubernetes higher')
|
||||||
|
|
||||||
|
// Coverage bar should be present with 75%
|
||||||
|
expect(panel.text()).toContain('Keyword Coverage')
|
||||||
|
expect(panel.text()).toContain('75%')
|
||||||
|
const bar = panel.find('[data-testid="coverage-bar"]')
|
||||||
|
expect(bar.exists()).toBe(true)
|
||||||
|
expect(bar.attributes('style')).toContain('width: 75%')
|
||||||
|
|
||||||
|
// Download link should be present
|
||||||
|
const dl = panel.find('[data-testid="download-link"]')
|
||||||
|
expect(dl.exists()).toBe(true)
|
||||||
|
expect(dl.text()).toContain('Download tailored CV')
|
||||||
|
|
||||||
|
// Tailored artifact should appear in artifacts list
|
||||||
|
expect(wrapper.text()).toContain('tailored_cv.pdf')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -12,6 +12,7 @@ vi.mock('@/api', () => ({
|
||||||
confirmApproval: vi.fn(),
|
confirmApproval: vi.fn(),
|
||||||
rejectApproval: vi.fn(),
|
rejectApproval: vi.fn(),
|
||||||
outboxSend: vi.fn(),
|
outboxSend: vi.fn(),
|
||||||
|
interviewPrep: vi.fn(),
|
||||||
HttpError: class HttpError extends Error {
|
HttpError: class HttpError extends Error {
|
||||||
status: number
|
status: number
|
||||||
body: unknown
|
body: unknown
|
||||||
|
|
@ -33,11 +34,9 @@ function makeApp(): Application {
|
||||||
notes: '',
|
notes: '',
|
||||||
state_changed_at: '2026-01-01T00:00:00Z',
|
state_changed_at: '2026-01-01T00:00:00Z',
|
||||||
created_at: '2026-01-01T00:00:00Z',
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
posting: {
|
company: 'Acme',
|
||||||
id: 'j-1', source: 'manual_url', external_id: null, url: 'http://x',
|
title: 'Engineer',
|
||||||
company: 'Acme', title: 'Engineer', location: 'Remote', description: '',
|
location: 'Remote'
|
||||||
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,15 @@ import { onMounted, ref, computed } from 'vue'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import * as api from '@/api'
|
import * as api from '@/api'
|
||||||
import { HttpError } from '@/api'
|
import { HttpError } from '@/api'
|
||||||
|
import InterviewPrepModal from '@/components/InterviewPrepModal.vue'
|
||||||
import type {
|
import type {
|
||||||
Application,
|
Application,
|
||||||
Artifact,
|
Artifact,
|
||||||
Approval,
|
Approval,
|
||||||
ApprovalAction,
|
ApprovalAction,
|
||||||
CoverLetterResponse,
|
CoverLetterResponse,
|
||||||
CritiqueComment
|
CritiqueComment,
|
||||||
|
TailorCvResponse
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
const props = defineProps<{ id: string }>()
|
const props = defineProps<{ id: string }>()
|
||||||
|
|
@ -32,6 +34,13 @@ const requestingApproval = ref(false)
|
||||||
const confirming = ref(false)
|
const confirming = ref(false)
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
|
|
||||||
|
// Interview prep modal
|
||||||
|
const showPrepModal = ref(false)
|
||||||
|
|
||||||
|
// Tailor CV
|
||||||
|
const tailoring = ref(false)
|
||||||
|
const tailorResult = ref<TailorCvResponse | null>(null)
|
||||||
|
|
||||||
const isConfirmed = computed(() => approval.value?.confirmed_by_user ?? false)
|
const isConfirmed = computed(() => approval.value?.confirmed_by_user ?? false)
|
||||||
const canSend = computed(() => isConfirmed.value && !sending.value)
|
const canSend = computed(() => isConfirmed.value && !sending.value)
|
||||||
|
|
||||||
|
|
@ -43,6 +52,29 @@ const severityClass: Record<string, string> = {
|
||||||
low: 'bg-blue-50 border-blue-200'
|
low: 'bg-blue-50 border-blue-200'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const coverageRatio = computed(() => {
|
||||||
|
if (!tailorResult.value) return 0
|
||||||
|
const kc = tailorResult.value.keyword_coverage
|
||||||
|
return typeof kc === 'object' && kc !== null ? (kc.ratio ?? 0) : Number(kc) || 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const coverageColor = computed(() => {
|
||||||
|
if (!tailorResult.value) return 'bg-gray-300'
|
||||||
|
const c = coverageRatio.value
|
||||||
|
if (c >= 0.7) return 'bg-green-500'
|
||||||
|
if (c >= 0.4) return 'bg-yellow-500'
|
||||||
|
return 'bg-red-500'
|
||||||
|
})
|
||||||
|
|
||||||
|
const coveragePercent = computed(() => {
|
||||||
|
return Math.round(coverageRatio.value * 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
const downloadUrl = computed(() => {
|
||||||
|
if (!tailorResult.value) return ''
|
||||||
|
return `${import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api'}/artifacts/${tailorResult.value.artifact_id}/download`
|
||||||
|
})
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
try {
|
try {
|
||||||
const apps = await api.getApplications()
|
const apps = await api.getApplications()
|
||||||
|
|
@ -113,7 +145,7 @@ async function sendOutbox() {
|
||||||
if (!approval.value || !canSend.value) return
|
if (!approval.value || !canSend.value) return
|
||||||
sending.value = true
|
sending.value = true
|
||||||
try {
|
try {
|
||||||
await api.outboxSend(approval.value.id, { to: application.value?.posting?.company ?? '' })
|
await api.outboxSend(approval.value.id, { to: application.value?.company ?? '' })
|
||||||
toast.push('Sent successfully', 'success')
|
toast.push('Sent successfully', 'success')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
let msg = 'Send failed'
|
let msg = 'Send failed'
|
||||||
|
|
@ -127,6 +159,35 @@ async function sendOutbox() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openInterviewPrep() {
|
||||||
|
showPrepModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeInterviewPrep() {
|
||||||
|
showPrepModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tailorCv() {
|
||||||
|
tailoring.value = true
|
||||||
|
tailorResult.value = null
|
||||||
|
try {
|
||||||
|
const res = await api.tailorCv(props.id)
|
||||||
|
tailorResult.value = res
|
||||||
|
// Refresh artifacts to show the new tailored CV variant
|
||||||
|
artifacts.value = await api.getArtifacts(props.id)
|
||||||
|
toast.push('CV tailored for this job', 'success')
|
||||||
|
} catch (err) {
|
||||||
|
let msg = 'Failed to tailor CV'
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
const body = err.body as { error?: { message?: string } } | null
|
||||||
|
msg = body?.error?.message ?? msg
|
||||||
|
}
|
||||||
|
toast.push(msg, 'error')
|
||||||
|
} finally {
|
||||||
|
tailoring.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadData)
|
onMounted(loadData)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -139,14 +200,91 @@ onMounted(loadData)
|
||||||
<template v-if="!loading && application">
|
<template v-if="!loading && application">
|
||||||
<!-- Posting info -->
|
<!-- Posting info -->
|
||||||
<section class="bg-white rounded-lg border border-gray-200 p-4">
|
<section class="bg-white rounded-lg border border-gray-200 p-4">
|
||||||
<div class="font-semibold text-lg">{{ application.posting?.company ?? 'Unknown' }}</div>
|
<div class="font-semibold text-lg">{{ application.company ?? 'Unknown' }}</div>
|
||||||
<div class="text-gray-600">{{ application.posting?.title ?? 'No title' }}</div>
|
<div class="text-gray-600">{{ application.title ?? 'No title' }}</div>
|
||||||
<div class="text-sm text-gray-500 mt-1">
|
<div class="text-sm text-gray-500 mt-1">
|
||||||
State: <span class="capitalize font-medium">{{ application.state }}</span>
|
State: <span class="capitalize font-medium">{{ application.state }}</span>
|
||||||
<span v-if="application.score != null" class="ml-3">Score: {{ application.score }}</span>
|
<span v-if="application.score != null" class="ml-3">Score: {{ application.score }}</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Tailor CV -->
|
||||||
|
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
||||||
|
<h2 class="font-semibold">Tailor CV for this Job</h2>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Generate a tailored CV variant that reorders and rephrases your existing sections toward this posting's keywords. Your facts are never invented, only rephrased.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
@click="tailorCv"
|
||||||
|
:disabled="tailoring"
|
||||||
|
class="bg-indigo-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
|
||||||
|
data-testid="tailor-cv-btn"
|
||||||
|
>
|
||||||
|
{{ tailoring ? 'Tailoring...' : 'Tailor My CV' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Tailor result panel -->
|
||||||
|
<div v-if="tailorResult" class="space-y-4 border-t border-gray-100 pt-3" data-testid="tailor-panel">
|
||||||
|
<!-- Keyword coverage bar -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between text-sm mb-1">
|
||||||
|
<span class="text-gray-600">Keyword Coverage</span>
|
||||||
|
<span class="font-medium">{{ coveragePercent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-3">
|
||||||
|
<div
|
||||||
|
class="h-3 rounded-full transition-all"
|
||||||
|
:class="coverageColor"
|
||||||
|
:style="{ width: coveragePercent + '%' }"
|
||||||
|
data-testid="coverage-bar"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Change log -->
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium text-sm mb-2">Changes Made</h3>
|
||||||
|
<ul class="text-sm space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="(entry, i) in tailorResult.change_log"
|
||||||
|
:key="i"
|
||||||
|
class="border-b border-gray-100 py-1"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-gray-700">{{ entry.action || entry.section || 'change' }}:</span>
|
||||||
|
<span class="text-gray-600 ml-1">{{ entry.detail || entry.change }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Download link -->
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
:href="downloadUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="text-sm text-indigo-600 hover:underline"
|
||||||
|
data-testid="download-link"
|
||||||
|
>
|
||||||
|
Download tailored CV (PDF)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Interview prep -->
|
||||||
|
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
||||||
|
<h2 class="font-semibold">Interview Prep</h2>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Generate likely interview questions with suggested answers based on your profile and the posting.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
@click="openInterviewPrep"
|
||||||
|
class="bg-purple-600 text-white px-4 py-2 rounded text-sm"
|
||||||
|
>
|
||||||
|
Open Interview Prep
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Artifacts list -->
|
<!-- Artifacts list -->
|
||||||
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-2">
|
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-2">
|
||||||
<h2 class="font-semibold">Artifacts</h2>
|
<h2 class="font-semibold">Artifacts</h2>
|
||||||
|
|
@ -158,7 +296,6 @@ onMounted(loadData)
|
||||||
</ul>
|
</ul>
|
||||||
<p v-else class="text-sm text-gray-400">No artifacts yet.</p>
|
<p v-else class="text-sm text-gray-400">No artifacts yet.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Cover letter editor + critique -->
|
<!-- Cover letter editor + critique -->
|
||||||
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
||||||
<h2 class="font-semibold">Cover Letter</h2>
|
<h2 class="font-semibold">Cover Letter</h2>
|
||||||
|
|
@ -247,5 +384,12 @@ onMounted(loadData)
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="!loading && !application" class="text-gray-500">Application not found.</div>
|
<div v-if="!loading && !application" class="text-gray-500">Application not found.</div>
|
||||||
|
|
||||||
|
<!-- Interview prep modal -->
|
||||||
|
<InterviewPrepModal
|
||||||
|
:application-id="id"
|
||||||
|
:visible="showPrepModal"
|
||||||
|
@close="closeInterviewPrep"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
110
apps/web/src/views/Applications.badges.test.ts
Normal file
110
apps/web/src/views/Applications.badges.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import Applications from '@/views/Applications.vue'
|
||||||
|
import type { Application } from '@/types'
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
getApplications: vi.fn(),
|
||||||
|
transitionApplication: vi.fn(),
|
||||||
|
batchScore: vi.fn(),
|
||||||
|
getToday: vi.fn(),
|
||||||
|
HttpError: class HttpError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
constructor(status: number, body: unknown, msg?: string) {
|
||||||
|
super(msg ?? `HTTP ${status}`)
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
function makeApp(id: string, state: string, company: string): Application {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
job_posting_id: 'j-' + id,
|
||||||
|
state: state as Application['state'],
|
||||||
|
score: 80,
|
||||||
|
score_rationale: null,
|
||||||
|
notes: '',
|
||||||
|
state_changed_at: '2026-01-01T00:00:00Z',
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
company,
|
||||||
|
title: 'Engineer',
|
||||||
|
location: 'Remote'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Applications kanban badges', () => {
|
||||||
|
it('renders nudge dot on cards that have a follow-up nudge', async () => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const api = await import('@/api')
|
||||||
|
|
||||||
|
const apps = [makeApp('app-1', 'sent', 'NudgeCorp'), makeApp('app-2', 'discovered', 'NoNudge')]
|
||||||
|
;(api.getApplications as ReturnType<typeof vi.fn>).mockResolvedValue(apps)
|
||||||
|
;(api.batchScore as ReturnType<typeof vi.fn>).mockResolvedValue({ results: [] })
|
||||||
|
;(api.getToday as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
digest: [],
|
||||||
|
nudges: [
|
||||||
|
{ application_id: 'app-1', days_since_sent: 9, suggestion: 'Send a follow-up email' }
|
||||||
|
],
|
||||||
|
pending_approvals: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(Applications)
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(wrapper.text()).toContain('NudgeCorp')
|
||||||
|
})
|
||||||
|
|
||||||
|
// The nudge dot should be rendered as an orange dot (span with bg-orange-500)
|
||||||
|
const dots = wrapper.findAll('.bg-orange-500')
|
||||||
|
expect(dots.length).toBeGreaterThanOrEqual(1)
|
||||||
|
|
||||||
|
// Verify the dot is in the card for NudgeCorp (the sent column)
|
||||||
|
const sentCol = wrapper.findAll('.font-semibold').find((el) => el.text() === 'sent')
|
||||||
|
expect(sentCol).toBeTruthy()
|
||||||
|
const sentColumn = sentCol!.element.parentElement!
|
||||||
|
expect(sentColumn.textContent).toContain('NudgeCorp')
|
||||||
|
// The dot should be inside this column
|
||||||
|
expect(sentColumn.querySelector('.bg-orange-500')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders red flag badge with tooltip text from batch scoring results', async () => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const api = await import('@/api')
|
||||||
|
|
||||||
|
const apps = [makeApp('app-1', 'discovered', 'ScamCorp')]
|
||||||
|
;(api.getApplications as ReturnType<typeof vi.fn>).mockResolvedValue(apps)
|
||||||
|
;(api.batchScore as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
application_id: 'app-1',
|
||||||
|
score: 30,
|
||||||
|
rationale: {},
|
||||||
|
red_flags: ['Unpaid trial period mentioned', 'Asks for bank details upfront']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
;(api.getToday as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
digest: [],
|
||||||
|
nudges: [],
|
||||||
|
pending_approvals: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(Applications)
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(wrapper.text()).toContain('ScamCorp')
|
||||||
|
})
|
||||||
|
|
||||||
|
// The warning symbol should be rendered in the card
|
||||||
|
const warningEl = wrapper.find('.text-red-600.font-bold')
|
||||||
|
expect(warningEl.exists()).toBe(true)
|
||||||
|
|
||||||
|
// The title attribute should contain the red flag text
|
||||||
|
const title = warningEl.attributes('title')
|
||||||
|
expect(title).toBeTruthy()
|
||||||
|
expect(title).toContain('Unpaid trial period mentioned')
|
||||||
|
expect(title).toContain('Asks for bank details upfront')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -7,6 +7,8 @@ import type { Application } from '@/types'
|
||||||
vi.mock('@/api', () => ({
|
vi.mock('@/api', () => ({
|
||||||
getApplications: vi.fn(),
|
getApplications: vi.fn(),
|
||||||
transitionApplication: vi.fn(),
|
transitionApplication: vi.fn(),
|
||||||
|
batchScore: vi.fn().mockResolvedValue({ results: [] }),
|
||||||
|
getToday: vi.fn().mockResolvedValue({ digest: [], nudges: [], pending_approvals: 0 }),
|
||||||
HttpError: class HttpError extends Error {
|
HttpError: class HttpError extends Error {
|
||||||
status: number
|
status: number
|
||||||
body: unknown
|
body: unknown
|
||||||
|
|
@ -29,11 +31,9 @@ function fixture(): Application[] {
|
||||||
notes: '',
|
notes: '',
|
||||||
state_changed_at: '2026-01-01T00:00:00Z',
|
state_changed_at: '2026-01-01T00:00:00Z',
|
||||||
created_at: '2026-01-01T00:00:00Z',
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
posting: {
|
company: 'Acme',
|
||||||
id: 'j-1', source: 'manual_url', external_id: null, url: 'http://x',
|
title: 'Engineer',
|
||||||
company: 'Acme', title: 'Engineer', location: 'Remote', description: '',
|
location: 'Remote'
|
||||||
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'app-2',
|
id: 'app-2',
|
||||||
|
|
@ -44,11 +44,9 @@ function fixture(): Application[] {
|
||||||
notes: '',
|
notes: '',
|
||||||
state_changed_at: '2026-01-01T00:00:00Z',
|
state_changed_at: '2026-01-01T00:00:00Z',
|
||||||
created_at: '2026-01-01T00:00:00Z',
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
posting: {
|
company: 'Globex',
|
||||||
id: 'j-2', source: 'linkedin', external_id: null, url: 'http://y',
|
title: 'Manager',
|
||||||
company: 'Globex', title: 'Manager', location: 'Malmo', description: '',
|
location: 'Malmo'
|
||||||
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import * as api from '@/api'
|
import * as api from '@/api'
|
||||||
import { HttpError } from '@/api'
|
import { HttpError } from '@/api'
|
||||||
import type { Application, ApplicationState } from '@/types'
|
import type { Application, ApplicationState, TodayNudge } from '@/types'
|
||||||
|
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -14,6 +14,10 @@ const loading = ref(true)
|
||||||
const draggingId = ref<string | null>(null)
|
const draggingId = ref<string | null>(null)
|
||||||
const draggingFrom = ref<ApplicationState | null>(null)
|
const draggingFrom = ref<ApplicationState | null>(null)
|
||||||
|
|
||||||
|
// Red flags and nudges
|
||||||
|
const redFlagsMap = ref<Record<string, string[]>>({})
|
||||||
|
const nudgeIds = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
const states: ApplicationState[] = [
|
const states: ApplicationState[] = [
|
||||||
'discovered',
|
'discovered',
|
||||||
'scored',
|
'scored',
|
||||||
|
|
@ -31,9 +35,46 @@ function appsInState(state: ApplicationState): Application[] {
|
||||||
return applications.value.filter((a) => a.state === state)
|
return applications.value.filter((a) => a.state === state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasRedFlags(app: Application): boolean {
|
||||||
|
const flags = redFlagsMap.value[app.id]
|
||||||
|
return flags != null && flags.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function redFlagsFor(app: Application): string[] {
|
||||||
|
const fromBatch = redFlagsMap.value[app.id]
|
||||||
|
if (fromBatch) return fromBatch
|
||||||
|
const stored = (app.score_rationale as { red_flags?: string[] } | null)?.red_flags
|
||||||
|
return stored ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasNudge(app: Application): boolean {
|
||||||
|
return nudgeIds.value.has(app.id)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadApplications() {
|
async function loadApplications() {
|
||||||
try {
|
try {
|
||||||
applications.value = await api.getApplications()
|
applications.value = await api.getApplications()
|
||||||
|
// Load red flags via batch scoring and nudges via today endpoint
|
||||||
|
const [batchResult, todayResult] = await Promise.allSettled([
|
||||||
|
api.batchScore(
|
||||||
|
applications.value
|
||||||
|
.filter((a) => a.state === 'discovered' || a.state === 'scored')
|
||||||
|
.map((a) => a.id)
|
||||||
|
),
|
||||||
|
api.getToday()
|
||||||
|
])
|
||||||
|
if (batchResult.status === 'fulfilled') {
|
||||||
|
const map: Record<string, string[]> = {}
|
||||||
|
for (const r of batchResult.value.results) {
|
||||||
|
if (r.red_flags && r.red_flags.length > 0) {
|
||||||
|
map[r.application_id] = r.red_flags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
redFlagsMap.value = map
|
||||||
|
}
|
||||||
|
if (todayResult.status === 'fulfilled') {
|
||||||
|
nudgeIds.value = new Set(todayResult.value.nudges.map((n: TodayNudge) => n.application_id))
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.push('Failed to load applications', 'error')
|
toast.push('Failed to load applications', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -117,8 +158,22 @@ onMounted(loadApplications)
|
||||||
@click="goToDetail(app)"
|
@click="goToDetail(app)"
|
||||||
class="bg-white rounded border border-gray-200 p-2 cursor-pointer hover:shadow-md transition-shadow"
|
class="bg-white rounded border border-gray-200 p-2 cursor-pointer hover:shadow-md transition-shadow"
|
||||||
>
|
>
|
||||||
<div class="font-medium text-sm truncate">{{ app.posting?.company ?? 'Unknown' }}</div>
|
<div class="flex items-center gap-1">
|
||||||
<div class="text-xs text-gray-500 truncate">{{ app.posting?.title ?? 'No title' }}</div>
|
<span
|
||||||
|
v-if="hasRedFlags(app)"
|
||||||
|
class="text-red-600 font-bold text-sm flex-shrink-0"
|
||||||
|
:title="redFlagsFor(app).join('; ')"
|
||||||
|
>
|
||||||
|
⚠
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="hasNudge(app)"
|
||||||
|
class="inline-block w-2 h-2 rounded-full bg-orange-500 flex-shrink-0"
|
||||||
|
title="Follow-up nudge pending"
|
||||||
|
></span>
|
||||||
|
<div class="font-medium text-sm truncate">{{ app.company ?? 'Unknown' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 truncate">{{ app.title ?? 'No title' }}</div>
|
||||||
<div v-if="app.score != null" class="text-xs text-green-700 mt-1">
|
<div v-if="app.score != null" class="text-xs text-green-700 mt-1">
|
||||||
Score: {{ app.score }}
|
Score: {{ app.score }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
103
apps/web/src/views/Research.clusters.test.ts
Normal file
103
apps/web/src/views/Research.clusters.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
getPostings: vi.fn(),
|
||||||
|
getClusters: vi.fn(),
|
||||||
|
createPosting: vi.fn(),
|
||||||
|
fetchPostings: vi.fn(),
|
||||||
|
scorePosting: vi.fn(),
|
||||||
|
batchScore: vi.fn().mockResolvedValue({ results: [] }),
|
||||||
|
HttpError: class HttpError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
constructor(status: number, body: unknown, msg?: string) {
|
||||||
|
super(msg ?? `HTTP ${status}`)
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('Research cluster alternates', () => {
|
||||||
|
it('renders cluster header with alternate count and expands to show alternates', async () => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const api = await import('@/api')
|
||||||
|
const Research = (await import('@/views/Research.vue')).default
|
||||||
|
|
||||||
|
// Two postings in the same cluster
|
||||||
|
const postings = [
|
||||||
|
{
|
||||||
|
id: 'p-1', source: 'manual_url', external_id: null, url: 'http://a',
|
||||||
|
company: 'Acme', title: 'Backend Dev', location: 'Malmo', description: '',
|
||||||
|
raw: {}, fetched_at: '2026-07-30T00:00:00Z', cluster_id: 'c-1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p-2', source: 'linkedin', external_id: null, url: 'http://b',
|
||||||
|
company: 'Acme', title: 'Backend Dev', location: 'Remote', description: '',
|
||||||
|
raw: {}, fetched_at: '2026-07-30T00:00:00Z', cluster_id: 'c-1'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Clusters endpoint returns the cluster with alternate postings
|
||||||
|
const clusters = [
|
||||||
|
{
|
||||||
|
cluster_id: 'c-1',
|
||||||
|
postings: [
|
||||||
|
{ id: 'p-1', title: 'Backend Dev', company: 'Acme', source: 'manual_url', url: 'http://a', score: 85 },
|
||||||
|
{ id: 'p-2', title: 'Backend Dev', company: 'Acme', source: 'linkedin', url: 'http://b', score: 80 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
;(api.getPostings as ReturnType<typeof vi.fn>).mockResolvedValue(postings)
|
||||||
|
;(api.getClusters as ReturnType<typeof vi.fn>).mockResolvedValue(clusters)
|
||||||
|
|
||||||
|
const wrapper = mount(Research)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Cluster header should show "Also via 1 more"
|
||||||
|
expect(wrapper.text()).toContain('Also via 1 more')
|
||||||
|
|
||||||
|
// Alternates should NOT be visible before expanding
|
||||||
|
const alternatesBefore = wrapper.find('[data-testid="cluster-alternates"]')
|
||||||
|
expect(alternatesBefore.exists()).toBe(false)
|
||||||
|
|
||||||
|
// Click to expand
|
||||||
|
const toggle = wrapper.find('[data-testid="cluster-alternates-toggle"]')
|
||||||
|
expect(toggle.exists()).toBe(true)
|
||||||
|
await toggle.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Alternates should now be visible
|
||||||
|
const alternatesAfter = wrapper.find('[data-testid="cluster-alternates"]')
|
||||||
|
expect(alternatesAfter.exists()).toBe(true)
|
||||||
|
// Should show the alternate source (linkedin) and link
|
||||||
|
expect(alternatesAfter.text()).toContain('linkedin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show cluster header for single postings without alternates', async () => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const api = await import('@/api')
|
||||||
|
const Research = (await import('@/views/Research.vue')).default
|
||||||
|
|
||||||
|
const postings = [
|
||||||
|
{
|
||||||
|
id: 'p-3', source: 'manual_url', external_id: null, url: 'http://c',
|
||||||
|
company: 'Globex', title: 'Manager', location: 'Stockholm', description: '',
|
||||||
|
raw: {}, fetched_at: '2026-07-30T00:00:00Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
;(api.getPostings as ReturnType<typeof vi.fn>).mockResolvedValue(postings)
|
||||||
|
;(api.getClusters as ReturnType<typeof vi.fn>).mockResolvedValue([])
|
||||||
|
|
||||||
|
const wrapper = mount(Research)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Should not show "Also via" since no alternates
|
||||||
|
expect(wrapper.text()).not.toContain('Also via')
|
||||||
|
expect(wrapper.text()).toContain('Globex')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,20 +1,87 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref, computed } from 'vue'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import * as api from '@/api'
|
import * as api from '@/api'
|
||||||
import type { JobPosting } from '@/types'
|
import { HttpError } from '@/api'
|
||||||
|
import type { JobPosting, Cluster } from '@/types'
|
||||||
|
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
|
|
||||||
const postings = ref<JobPosting[]>([])
|
const postings = ref<JobPosting[]>([])
|
||||||
|
const clusters = ref<Cluster[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const newUrl = ref('')
|
const newUrl = ref('')
|
||||||
const scoringId = ref<string | null>(null)
|
const scoringId = ref<string | null>(null)
|
||||||
const scoreMap = ref<Record<string, { score: number; rationale: Record<string, unknown> }>>({})
|
const scoreMap = ref<Record<string, { score: number; rationale: Record<string, unknown> }>>({})
|
||||||
|
const redFlagsMap = ref<Record<string, string[]>>({})
|
||||||
|
const expandedClusters = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// Fetch form
|
||||||
|
const fetchQuery = ref('')
|
||||||
|
const fetchRegion = ref('')
|
||||||
|
const fetching = ref(false)
|
||||||
|
const fetchResult = ref<{ new: number; dupes: number } | null>(null)
|
||||||
|
|
||||||
|
// Group postings by cluster_id from the posting data. Postings without cluster_id get unique singleton groups.
|
||||||
|
const groupedPostings = computed(() => {
|
||||||
|
const map = new Map<string, JobPosting[]>()
|
||||||
|
for (const p of postings.value) {
|
||||||
|
const cid = (p as JobPosting & { cluster_id?: string }).cluster_id ?? `solo-${p.id}`
|
||||||
|
if (!map.has(cid)) map.set(cid, [])
|
||||||
|
map.get(cid)!.push(p)
|
||||||
|
}
|
||||||
|
return Array.from(map.entries()).map(([cluster_id, items]) => ({ cluster_id, items }))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Alternates for a cluster (from GET /clusters endpoint)
|
||||||
|
function clusterAlternatives(clusterId: string): Cluster['postings'] {
|
||||||
|
const cluster = clusters.value.find((c) => c.cluster_id === clusterId)
|
||||||
|
if (!cluster) return []
|
||||||
|
// Return postings other than the first/best one
|
||||||
|
return cluster.postings.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpanded(clusterId: string): boolean {
|
||||||
|
return expandedClusters.value.has(clusterId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpand(clusterId: string) {
|
||||||
|
const next = new Set(expandedClusters.value)
|
||||||
|
if (next.has(clusterId)) {
|
||||||
|
next.delete(clusterId)
|
||||||
|
} else {
|
||||||
|
next.add(clusterId)
|
||||||
|
}
|
||||||
|
expandedClusters.value = next
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPostings() {
|
async function loadPostings() {
|
||||||
try {
|
try {
|
||||||
postings.value = await api.getPostings()
|
const [postingsRes, clustersRes] = await Promise.allSettled([
|
||||||
|
api.getPostings(),
|
||||||
|
api.getClusters()
|
||||||
|
])
|
||||||
|
if (postingsRes.status === 'fulfilled') {
|
||||||
|
postings.value = postingsRes.value
|
||||||
|
}
|
||||||
|
if (clustersRes.status === 'fulfilled') {
|
||||||
|
clusters.value = clustersRes.value
|
||||||
|
}
|
||||||
|
// Load red flags for existing postings via batch scoring
|
||||||
|
if (postings.value.length > 0) {
|
||||||
|
try {
|
||||||
|
const batch = await api.batchScore(postings.value.map((p) => p.id))
|
||||||
|
const map: Record<string, string[]> = {}
|
||||||
|
for (const r of batch.results) {
|
||||||
|
if (r.red_flags && r.red_flags.length > 0) {
|
||||||
|
map[r.application_id] = r.red_flags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
redFlagsMap.value = map
|
||||||
|
} catch {
|
||||||
|
// batch scoring is optional; ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.push('Failed to load postings', 'error')
|
toast.push('Failed to load postings', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -34,6 +101,27 @@ async function addPosting() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function doFetch() {
|
||||||
|
if (!fetchQuery.value.trim()) return
|
||||||
|
fetching.value = true
|
||||||
|
fetchResult.value = null
|
||||||
|
try {
|
||||||
|
fetchResult.value = await api.fetchPostings(fetchQuery.value.trim(), fetchRegion.value.trim() || undefined)
|
||||||
|
toast.push(`Fetched ${fetchResult.value.new} new postings`, 'success')
|
||||||
|
// Reload postings to show new ones
|
||||||
|
await loadPostings()
|
||||||
|
} catch (err) {
|
||||||
|
let msg = 'Fetch failed'
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
const body = err.body as { error?: { message?: string } } | null
|
||||||
|
msg = body?.error?.message ?? msg
|
||||||
|
}
|
||||||
|
toast.push(msg, 'error')
|
||||||
|
} finally {
|
||||||
|
fetching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function scorePosting(p: JobPosting) {
|
async function scorePosting(p: JobPosting) {
|
||||||
scoringId.value = p.id
|
scoringId.value = p.id
|
||||||
try {
|
try {
|
||||||
|
|
@ -47,6 +135,15 @@ async function scorePosting(p: JobPosting) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasScamFlag(p: JobPosting): boolean {
|
||||||
|
const flags = redFlagsMap.value[p.id]
|
||||||
|
return flags != null && flags.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function scamFlagsFor(p: JobPosting): string[] {
|
||||||
|
return redFlagsMap.value[p.id] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadPostings)
|
onMounted(loadPostings)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -54,6 +151,37 @@ onMounted(loadPostings)
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<h1 class="text-2xl font-bold">Research</h1>
|
<h1 class="text-2xl font-bold">Research</h1>
|
||||||
|
|
||||||
|
<!-- Fetch form (Arbetsformedlingen connector) -->
|
||||||
|
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
||||||
|
<h2 class="font-semibold">Fetch from Arbetsförmedlingen</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
v-model="fetchQuery"
|
||||||
|
placeholder="Search query (e.g. python developer)"
|
||||||
|
class="flex-1 border rounded px-2 py-1"
|
||||||
|
@keyup.enter="doFetch"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="fetchRegion"
|
||||||
|
placeholder="Region (optional)"
|
||||||
|
class="w-48 border rounded px-2 py-1"
|
||||||
|
@keyup.enter="doFetch"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="doFetch"
|
||||||
|
:disabled="fetching"
|
||||||
|
class="bg-indigo-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ fetching ? 'Fetching...' : 'Fetch Postings' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="fetchResult" class="text-sm">
|
||||||
|
<span class="text-green-700 font-medium">{{ fetchResult.new }} new</span>,
|
||||||
|
<span class="text-gray-500">{{ fetchResult.dupes }} duplicates</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Manual URL add -->
|
||||||
<section class="bg-white rounded-lg border border-gray-200 p-4 flex gap-2">
|
<section class="bg-white rounded-lg border border-gray-200 p-4 flex gap-2">
|
||||||
<input
|
<input
|
||||||
v-model="newUrl"
|
v-model="newUrl"
|
||||||
|
|
@ -66,38 +194,101 @@ onMounted(loadPostings)
|
||||||
|
|
||||||
<div v-if="loading" class="text-gray-500">Loading...</div>
|
<div v-if="loading" class="text-gray-500">Loading...</div>
|
||||||
|
|
||||||
<table v-if="!loading" class="w-full bg-white rounded-lg border border-gray-200 text-sm">
|
<!-- Cluster grouped postings -->
|
||||||
<thead class="bg-gray-50 text-left">
|
<div v-if="!loading" class="space-y-4">
|
||||||
<tr>
|
<div
|
||||||
<th class="px-3 py-2">Company</th>
|
v-for="group in groupedPostings"
|
||||||
<th class="px-3 py-2">Title</th>
|
:key="group.cluster_id"
|
||||||
<th class="px-3 py-2">Location</th>
|
class="bg-white rounded-lg border border-gray-200"
|
||||||
<th class="px-3 py-2">Source</th>
|
>
|
||||||
<th class="px-3 py-2">Fetched</th>
|
<!-- Cluster header -->
|
||||||
<th class="px-3 py-2">Actions</th>
|
<div
|
||||||
</tr>
|
v-if="clusterAlternatives(group.cluster_id).length > 0"
|
||||||
</thead>
|
class="flex items-center justify-between px-4 py-2 border-b border-gray-100 cursor-pointer hover:bg-gray-50"
|
||||||
<tbody>
|
@click="toggleExpand(group.cluster_id)"
|
||||||
<tr v-for="p in postings" :key="p.id" class="border-t border-gray-100">
|
>
|
||||||
<td class="px-3 py-2">{{ p.company }}</td>
|
<span class="text-sm font-medium text-gray-700">
|
||||||
<td class="px-3 py-2">{{ p.title }}</td>
|
{{ group.items[0]?.company ?? 'Unknown' }} - {{ group.items[0]?.title ?? 'No title' }}
|
||||||
<td class="px-3 py-2">{{ p.location }}</td>
|
</span>
|
||||||
<td class="px-3 py-2">{{ p.source }}</td>
|
<span class="text-xs text-gray-500" data-testid="cluster-alternates-toggle">
|
||||||
<td class="px-3 py-2 text-gray-500">{{ p.fetched_at?.slice(0, 10) }}</td>
|
Also via {{ clusterAlternatives(group.cluster_id).length }} more
|
||||||
<td class="px-3 py-2">
|
<span v-if="isExpanded(group.cluster_id)">▲</span>
|
||||||
<button
|
<span v-else>▼</span>
|
||||||
@click="scorePosting(p)"
|
</span>
|
||||||
:disabled="scoringId === p.id"
|
</div>
|
||||||
class="text-indigo-600 hover:underline text-sm"
|
|
||||||
|
<!-- Main posting table for this cluster -->
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 text-left">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2">Company</th>
|
||||||
|
<th class="px-3 py-2">Title</th>
|
||||||
|
<th class="px-3 py-2">Location</th>
|
||||||
|
<th class="px-3 py-2">Source</th>
|
||||||
|
<th class="px-3 py-2">Scam</th>
|
||||||
|
<th class="px-3 py-2">Fetched</th>
|
||||||
|
<th class="px-3 py-2">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="p in group.items" :key="p.id" class="border-t border-gray-100">
|
||||||
|
<td class="px-3 py-2">{{ p.company }}</td>
|
||||||
|
<td class="px-3 py-2">{{ p.title }}</td>
|
||||||
|
<td class="px-3 py-2">{{ p.location }}</td>
|
||||||
|
<td class="px-3 py-2">{{ p.source }}</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<span
|
||||||
|
v-if="hasScamFlag(p)"
|
||||||
|
class="text-red-600 font-bold"
|
||||||
|
:title="scamFlagsFor(p).join('; ')"
|
||||||
|
>
|
||||||
|
⚠
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-gray-400">-</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-gray-500">{{ p.fetched_at?.slice(0, 10) }}</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<button
|
||||||
|
@click="scorePosting(p)"
|
||||||
|
:disabled="scoringId === p.id"
|
||||||
|
class="text-indigo-600 hover:underline text-sm"
|
||||||
|
>
|
||||||
|
{{ scoringId === p.id ? 'Scoring...' : 'Score' }}
|
||||||
|
</button>
|
||||||
|
<span v-if="scoreMap[p.id]" class="ml-2 text-xs bg-green-100 text-green-800 rounded px-2 py-0.5">
|
||||||
|
{{ scoreMap[p.id].score }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Expandable alternates -->
|
||||||
|
<div
|
||||||
|
v-if="isExpanded(group.cluster_id) && clusterAlternatives(group.cluster_id).length > 0"
|
||||||
|
class="border-t border-gray-100 px-4 py-3 bg-gray-50"
|
||||||
|
data-testid="cluster-alternates"
|
||||||
|
>
|
||||||
|
<div class="text-xs font-medium text-gray-500 mb-2">Alternate sources for this role:</div>
|
||||||
|
<ul class="text-sm space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="alt in clusterAlternatives(group.cluster_id)"
|
||||||
|
:key="alt.id"
|
||||||
|
class="flex items-center justify-between"
|
||||||
>
|
>
|
||||||
{{ scoringId === p.id ? 'Scoring...' : 'Score' }}
|
<span>
|
||||||
</button>
|
<a :href="alt.url" target="_blank" rel="noopener" class="text-indigo-600 hover:underline">
|
||||||
<span v-if="scoreMap[p.id]" class="ml-2 text-xs bg-green-100 text-green-800 rounded px-2 py-0.5">
|
{{ alt.company }}
|
||||||
{{ scoreMap[p.id].score }}
|
</a>
|
||||||
</span>
|
<span class="text-gray-400 ml-2">({{ alt.source }})</span>
|
||||||
</td>
|
</span>
|
||||||
</tr>
|
<span v-if="alt.score" class="text-xs bg-green-100 text-green-800 rounded px-2 py-0.5">
|
||||||
</tbody>
|
{{ alt.score }}
|
||||||
</table>
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
79
apps/web/src/views/TodayView.deadlines.test.ts
Normal file
79
apps/web/src/views/TodayView.deadlines.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
getToday: vi.fn(),
|
||||||
|
getSuggestions: vi.fn().mockResolvedValue([]),
|
||||||
|
getNotificationLog: vi.fn().mockResolvedValue([]),
|
||||||
|
acceptSuggestion: vi.fn(),
|
||||||
|
dismissSuggestion: vi.fn(),
|
||||||
|
getTelemetryTasks: vi.fn().mockResolvedValue([]),
|
||||||
|
HttpError: class HttpError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
constructor(status: number, body: unknown, msg?: string) {
|
||||||
|
super(msg ?? `HTTP ${status}`)
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('TodayView deadlines strip', () => {
|
||||||
|
it('renders deadline cards with urgent styling when <= 2 days', async () => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const api = await import('@/api')
|
||||||
|
const TodayView = (await import('@/views/TodayView.vue')).default
|
||||||
|
|
||||||
|
// Build deadlines: one urgent (tomorrow) and one normal (5 days)
|
||||||
|
const tomorrow = new Date()
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||||
|
const fiveDays = new Date()
|
||||||
|
fiveDays.setDate(fiveDays.getDate() + 5)
|
||||||
|
|
||||||
|
;(api.getToday as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
digest: [],
|
||||||
|
nudges: [],
|
||||||
|
pending_approvals: 0,
|
||||||
|
deadlines: [
|
||||||
|
{ application_id: 'app-1', title: 'Backend Dev', company: 'Acme', apply_by: tomorrow.toISOString().slice(0, 10) },
|
||||||
|
{ application_id: 'app-2', title: 'Frontend Dev', company: 'Globex', apply_by: fiveDays.toISOString().slice(0, 10) }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(TodayView)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Section heading present
|
||||||
|
expect(wrapper.text()).toContain('Deadlines This Week')
|
||||||
|
|
||||||
|
// Both companies shown
|
||||||
|
expect(wrapper.text()).toContain('Acme')
|
||||||
|
expect(wrapper.text()).toContain('Globex')
|
||||||
|
|
||||||
|
// Urgent card has red background class
|
||||||
|
const urgentCard = wrapper.findAll('.bg-red-50')
|
||||||
|
expect(urgentCard.length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(urgentCard[0].text()).toContain('Backend Dev')
|
||||||
|
expect(urgentCard[0].text()).toContain('Acme')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render deadlines section when no deadlines', async () => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const api = await import('@/api')
|
||||||
|
const TodayView = (await import('@/views/TodayView.vue')).default
|
||||||
|
|
||||||
|
;(api.getToday as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
digest: [],
|
||||||
|
nudges: [],
|
||||||
|
pending_approvals: 0,
|
||||||
|
deadlines: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(TodayView)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('Deadlines This Week')
|
||||||
|
})
|
||||||
|
})
|
||||||
125
apps/web/src/views/TodayView.suggestions.test.ts
Normal file
125
apps/web/src/views/TodayView.suggestions.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
getToday: vi.fn(),
|
||||||
|
getSuggestions: vi.fn(),
|
||||||
|
getNotificationLog: vi.fn().mockResolvedValue([]),
|
||||||
|
acceptSuggestion: vi.fn(),
|
||||||
|
dismissSuggestion: vi.fn(),
|
||||||
|
getTelemetryTasks: vi.fn().mockResolvedValue([]),
|
||||||
|
HttpError: class HttpError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
constructor(status: number, body: unknown, msg?: string) {
|
||||||
|
super(msg ?? `HTTP ${status}`)
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('TodayView suggestions accept flow', () => {
|
||||||
|
it('calls acceptSuggestion API and removes card from pending list', async () => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const api = await import('@/api')
|
||||||
|
const TodayView = (await import('@/views/TodayView.vue')).default
|
||||||
|
|
||||||
|
const suggestions = [
|
||||||
|
{
|
||||||
|
id: 'sug-1',
|
||||||
|
application_id: 'app-1',
|
||||||
|
from_address: 'recruiter@acme.com',
|
||||||
|
subject: 'Interview Invitation',
|
||||||
|
snippet: 'We would like to invite you...',
|
||||||
|
classification: 'interview_invite',
|
||||||
|
created_at: '2026-07-30T10:00:00Z',
|
||||||
|
status: 'pending'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sug-2',
|
||||||
|
application_id: 'app-2',
|
||||||
|
from_address: 'noreply@globex.com',
|
||||||
|
subject: 'Application Update',
|
||||||
|
snippet: 'Thank you for applying...',
|
||||||
|
classification: 'rejection',
|
||||||
|
created_at: '2026-07-30T11:00:00Z',
|
||||||
|
status: 'pending'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
;(api.getToday as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
digest: [],
|
||||||
|
nudges: [],
|
||||||
|
pending_approvals: 0,
|
||||||
|
deadlines: []
|
||||||
|
})
|
||||||
|
;(api.getSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue(suggestions)
|
||||||
|
// Accept returns the updated suggestion with status 'accepted'
|
||||||
|
;(api.acceptSuggestion as ReturnType<typeof vi.fn>).mockResolvedValue({ ...suggestions[0], status: 'accepted' })
|
||||||
|
|
||||||
|
const wrapper = mount(TodayView)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Both suggestions visible initially
|
||||||
|
expect(wrapper.text()).toContain('Interview Invitation')
|
||||||
|
expect(wrapper.text()).toContain('Application Update')
|
||||||
|
expect(wrapper.text()).toContain('Interview Invite')
|
||||||
|
|
||||||
|
// Click Accept on first suggestion
|
||||||
|
const acceptBtns = wrapper.findAll('[data-testid="accept-suggestion"]')
|
||||||
|
expect(acceptBtns.length).toBe(2)
|
||||||
|
await acceptBtns[0].trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// API was called with the right id
|
||||||
|
expect(api.acceptSuggestion).toHaveBeenCalledWith('sug-1')
|
||||||
|
|
||||||
|
// The accepted suggestion should no longer appear in the pending list
|
||||||
|
expect(wrapper.text()).not.toContain('Interview Invitation')
|
||||||
|
// The other suggestion should still be present
|
||||||
|
expect(wrapper.text()).toContain('Application Update')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls dismissSuggestion API and removes card from pending list', async () => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const api = await import('@/api')
|
||||||
|
const TodayView = (await import('@/views/TodayView.vue')).default
|
||||||
|
|
||||||
|
const suggestions = [
|
||||||
|
{
|
||||||
|
id: 'sug-3',
|
||||||
|
application_id: null,
|
||||||
|
from_address: 'spam@noise.com',
|
||||||
|
subject: 'Some spam',
|
||||||
|
snippet: 'Buy our product...',
|
||||||
|
classification: 'noise',
|
||||||
|
created_at: '2026-07-30T12:00:00Z',
|
||||||
|
status: 'pending'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
;(api.getToday as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
digest: [],
|
||||||
|
nudges: [],
|
||||||
|
pending_approvals: 0,
|
||||||
|
deadlines: []
|
||||||
|
})
|
||||||
|
;(api.getSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue(suggestions)
|
||||||
|
;(api.dismissSuggestion as ReturnType<typeof vi.fn>).mockResolvedValue({ ...suggestions[0], status: 'dismissed' })
|
||||||
|
|
||||||
|
const wrapper = mount(TodayView)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Some spam')
|
||||||
|
|
||||||
|
const dismissBtn = wrapper.find('[data-testid="dismiss-suggestion"]')
|
||||||
|
expect(dismissBtn.exists()).toBe(true)
|
||||||
|
await dismissBtn.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(api.dismissSuggestion).toHaveBeenCalledWith('sug-3')
|
||||||
|
expect(wrapper.text()).not.toContain('Some spam')
|
||||||
|
})
|
||||||
|
})
|
||||||
288
apps/web/src/views/TodayView.vue
Normal file
288
apps/web/src/views/TodayView.vue
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import * as api from '@/api'
|
||||||
|
import CostDisplay from '@/components/CostDisplay.vue'
|
||||||
|
import type { TodayResponseV11, TodayDeadline, EmailSuggestion, NotificationLogEntry, SuggestionClassification } from '@/types'
|
||||||
|
|
||||||
|
const toast = useToastStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const today = ref<TodayResponseV11 | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const deadlines = ref<TodayDeadline[]>([])
|
||||||
|
const suggestions = ref<EmailSuggestion[]>([])
|
||||||
|
const notifications = ref<NotificationLogEntry[]>([])
|
||||||
|
const suggestionActioningId = ref<string | null>(null)
|
||||||
|
|
||||||
|
const nudgeIds = computed(() => new Set(today.value?.nudges.map((n) => n.application_id) ?? []))
|
||||||
|
|
||||||
|
const pendingSuggestions = computed(() =>
|
||||||
|
suggestions.value.filter((s) => s.status === 'pending')
|
||||||
|
)
|
||||||
|
|
||||||
|
const classificationChipClass: Record<SuggestionClassification, string> = {
|
||||||
|
interview_invite: 'bg-green-100 text-green-800',
|
||||||
|
rejection: 'bg-red-100 text-red-800',
|
||||||
|
question: 'bg-yellow-100 text-yellow-800',
|
||||||
|
noise: 'bg-gray-100 text-gray-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
const classificationLabel: Record<SuggestionClassification, string> = {
|
||||||
|
interview_invite: 'Interview Invite',
|
||||||
|
rejection: 'Rejection',
|
||||||
|
question: 'Question',
|
||||||
|
noise: 'Noise'
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysUntil(dateStr: string): number {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
const target = new Date(dateStr)
|
||||||
|
target.setHours(0, 0, 0, 0)
|
||||||
|
const diff = Math.round((target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUrgent(dateStr: string): boolean {
|
||||||
|
return daysUntil(dateStr) <= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadToday() {
|
||||||
|
try {
|
||||||
|
const [todayRes, suggestionsRes, notifRes] = await Promise.allSettled([
|
||||||
|
api.getToday(),
|
||||||
|
api.getSuggestions(),
|
||||||
|
api.getNotificationLog()
|
||||||
|
])
|
||||||
|
if (todayRes.status === 'fulfilled') {
|
||||||
|
today.value = todayRes.value
|
||||||
|
deadlines.value = todayRes.value.deadlines ?? []
|
||||||
|
}
|
||||||
|
if (suggestionsRes.status === 'fulfilled') {
|
||||||
|
suggestions.value = suggestionsRes.value
|
||||||
|
}
|
||||||
|
if (notifRes.status === 'fulfilled') {
|
||||||
|
notifications.value = notifRes.value.slice(0, 5)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.push('Failed to load today digest', 'error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acceptSuggestion(id: string) {
|
||||||
|
suggestionActioningId.value = id
|
||||||
|
try {
|
||||||
|
await api.acceptSuggestion(id)
|
||||||
|
suggestions.value = suggestions.value.map((s) =>
|
||||||
|
s.id === id ? { ...s, status: 'accepted' } : s
|
||||||
|
)
|
||||||
|
toast.push('Suggestion accepted', 'success')
|
||||||
|
} catch {
|
||||||
|
toast.push('Failed to accept suggestion', 'error')
|
||||||
|
} finally {
|
||||||
|
suggestionActioningId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismissSuggestion(id: string) {
|
||||||
|
suggestionActioningId.value = id
|
||||||
|
try {
|
||||||
|
await api.dismissSuggestion(id)
|
||||||
|
suggestions.value = suggestions.value.map((s) =>
|
||||||
|
s.id === id ? { ...s, status: 'dismissed' } : s
|
||||||
|
)
|
||||||
|
toast.push('Suggestion dismissed', 'success')
|
||||||
|
} catch {
|
||||||
|
toast.push('Failed to dismiss suggestion', 'error')
|
||||||
|
} finally {
|
||||||
|
suggestionActioningId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToApplication(id: string) {
|
||||||
|
router.push(`/applications/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyNudge(suggestion: string) {
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(suggestion).then(
|
||||||
|
() => toast.push('Follow-up draft copied to clipboard', 'success'),
|
||||||
|
() => toast.push('Copy failed', 'error')
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
toast.push('Clipboard not available', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadToday)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h1 class="text-2xl font-bold">Today</h1>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-gray-500">Loading...</div>
|
||||||
|
|
||||||
|
<template v-if="!loading && today">
|
||||||
|
<!-- Pending approvals banner -->
|
||||||
|
<div v-if="today.pending_approvals > 0" class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||||
|
<span class="font-medium text-yellow-800">
|
||||||
|
{{ today.pending_approvals }} pending approval{{ today.pending_approvals > 1 ? 's' : '' }} waiting for you.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deadlines this week strip -->
|
||||||
|
<section v-if="deadlines.length > 0">
|
||||||
|
<h2 class="font-semibold text-lg mb-3">Deadlines This Week</h2>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div
|
||||||
|
v-for="d in deadlines"
|
||||||
|
:key="d.application_id"
|
||||||
|
class="rounded-lg border p-4 cursor-pointer hover:shadow-md transition-shadow"
|
||||||
|
:class="isUrgent(d.apply_by) ? 'bg-red-50 border-red-300' : 'bg-white border-gray-200'"
|
||||||
|
@click="goToApplication(d.application_id)"
|
||||||
|
>
|
||||||
|
<div class="font-medium">{{ d.title }}</div>
|
||||||
|
<div class="text-sm text-gray-600">{{ d.company }}</div>
|
||||||
|
<div
|
||||||
|
class="mt-2 text-sm font-medium"
|
||||||
|
:class="isUrgent(d.apply_by) ? 'text-red-700' : 'text-gray-600'"
|
||||||
|
>
|
||||||
|
Apply by {{ formatDate(d.apply_by) }}
|
||||||
|
<span v-if="daysUntil(d.apply_by) === 0" class="ml-1">(today)</span>
|
||||||
|
<span v-else-if="daysUntil(d.apply_by) === 1" class="ml-1">(tomorrow)</span>
|
||||||
|
<span v-else class="ml-1">({{ daysUntil(d.apply_by) }} days)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Inbox insights strip -->
|
||||||
|
<section v-if="pendingSuggestions.length > 0">
|
||||||
|
<h2 class="font-semibold text-lg mb-3">Inbox Insights</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="s in pendingSuggestions"
|
||||||
|
:key="s.id"
|
||||||
|
class="bg-white rounded-lg border border-gray-200 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-medium text-gray-700">{{ s.from_address }}</span>
|
||||||
|
<span
|
||||||
|
class="text-xs rounded px-2 py-0.5 font-medium"
|
||||||
|
:class="classificationChipClass[s.classification]"
|
||||||
|
>
|
||||||
|
{{ classificationLabel[s.classification] }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="text-sm bg-green-600 text-white px-3 py-1 rounded hover:bg-green-700 disabled:opacity-50"
|
||||||
|
:disabled="suggestionActioningId === s.id"
|
||||||
|
data-testid="accept-suggestion"
|
||||||
|
@click.stop="acceptSuggestion(s.id)"
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="text-sm bg-gray-200 text-gray-700 px-3 py-1 rounded hover:bg-gray-300 disabled:opacity-50"
|
||||||
|
:disabled="suggestionActioningId === s.id"
|
||||||
|
data-testid="dismiss-suggestion"
|
||||||
|
@click.stop="dismissSuggestion(s.id)"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="font-medium text-sm mt-2">{{ s.subject }}</div>
|
||||||
|
<div class="text-sm text-gray-500 mt-1">{{ s.snippet }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Digest cards -->
|
||||||
|
<section>
|
||||||
|
<h2 class="font-semibold text-lg mb-3">Top Matches Today</h2>
|
||||||
|
<div v-if="today.digest.length === 0" class="text-gray-400 text-sm">No postings in your digest yet.</div>
|
||||||
|
<div v-else class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div
|
||||||
|
v-for="item in today.digest"
|
||||||
|
:key="item.application_id"
|
||||||
|
class="bg-white rounded-lg border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow"
|
||||||
|
@click="goToApplication(item.application_id)"
|
||||||
|
>
|
||||||
|
<div class="font-medium">{{ item.title }}</div>
|
||||||
|
<div class="text-sm text-gray-600">{{ item.company }}</div>
|
||||||
|
<div class="mt-2 flex items-center gap-2">
|
||||||
|
<span class="text-xs bg-green-100 text-green-800 rounded px-2 py-0.5 font-medium">
|
||||||
|
Score: {{ item.score }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="nudgeIds.has(item.application_id)"
|
||||||
|
class="inline-block w-2 h-2 rounded-full bg-orange-500"
|
||||||
|
title="Follow-up nudge pending"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Nudge cards -->
|
||||||
|
<section>
|
||||||
|
<h2 class="font-semibold text-lg mb-3">Follow-up Nudges</h2>
|
||||||
|
<div v-if="today.nudges.length === 0" class="text-gray-400 text-sm">No nudges. You are up to date.</div>
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="nudge in today.nudges"
|
||||||
|
:key="nudge.application_id"
|
||||||
|
class="bg-orange-50 border border-orange-200 rounded-lg p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-medium text-orange-900">
|
||||||
|
Sent {{ nudge.days_since_sent }} days ago
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="text-sm text-indigo-600 hover:underline"
|
||||||
|
@click="goToApplication(nudge.application_id)"
|
||||||
|
>
|
||||||
|
Open application
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-700 mt-2">{{ nudge.suggestion }}</p>
|
||||||
|
<button
|
||||||
|
class="mt-2 text-sm bg-indigo-600 text-white px-3 py-1 rounded hover:bg-indigo-700"
|
||||||
|
@click="copyNudge(nudge.suggestion)"
|
||||||
|
>
|
||||||
|
Copy follow-up draft
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Notification mini-log -->
|
||||||
|
<section v-if="notifications.length > 0">
|
||||||
|
<h2 class="font-semibold text-lg mb-3">Recent Notifications</h2>
|
||||||
|
<ul class="text-sm space-y-1 bg-white rounded-lg border border-gray-200 p-3">
|
||||||
|
<li v-for="n in notifications" :key="n.id" class="border-b border-gray-100 py-1 last:border-0">
|
||||||
|
<span class="text-gray-400 text-xs">{{ n.created_at?.slice(0, 16).replace('T', ' ') }}</span>
|
||||||
|
<span class="ml-2 text-gray-700">{{ n.message }}</span>
|
||||||
|
<span class="ml-2 text-xs text-gray-400">({{ n.channel }})</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Cost display -->
|
||||||
|
<CostDisplay />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
70
apps/web/src/views/Welcome.test.ts
Normal file
70
apps/web/src/views/Welcome.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import Welcome from '@/views/Welcome.vue'
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
importCv: vi.fn(),
|
||||||
|
confirmCvImport: vi.fn(),
|
||||||
|
fetchPostings: vi.fn(),
|
||||||
|
HttpError: class HttpError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
constructor(status: number, body: unknown, msg?: string) {
|
||||||
|
super(msg ?? `HTTP ${status}`)
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
function makeRouter() {
|
||||||
|
return createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/welcome', name: 'welcome', component: Welcome },
|
||||||
|
{ path: '/today', name: 'today', component: { template: '<div>Today</div>' } }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Onboarding wizard', () => {
|
||||||
|
it('shows welcome step and advances through steps to finish', async () => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const router = makeRouter()
|
||||||
|
await router.push('/welcome')
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mount(Welcome, { global: { plugins: [router] } })
|
||||||
|
|
||||||
|
// Step 0: Welcome
|
||||||
|
expect(wrapper.text()).toContain('Welcome to Jobhunt')
|
||||||
|
expect(wrapper.text()).toContain('Get Started')
|
||||||
|
|
||||||
|
// Advance to step 1 (Import CV)
|
||||||
|
const getStartedBtn = wrapper.find('button')
|
||||||
|
await getStartedBtn.trigger('click')
|
||||||
|
expect(wrapper.text()).toContain('Import Your CV')
|
||||||
|
|
||||||
|
// Skip import -> step 2 (Fetch Postings)
|
||||||
|
const skipLink = wrapper.findAll('button').find((b) => b.text().includes('Skip for now'))
|
||||||
|
expect(skipLink).toBeTruthy()
|
||||||
|
await skipLink!.trigger('click')
|
||||||
|
expect(wrapper.text()).toContain('Fetch Job Postings')
|
||||||
|
|
||||||
|
// Continue -> step 3 (Done)
|
||||||
|
const continueBtn = wrapper.findAll('button').find((b) => b.text().includes('Continue'))
|
||||||
|
expect(continueBtn).toBeTruthy()
|
||||||
|
await continueBtn!.trigger('click')
|
||||||
|
expect(wrapper.text()).toContain('You are all set')
|
||||||
|
|
||||||
|
// Finish -> navigates to /today
|
||||||
|
const finishBtn = wrapper.findAll('button').find((b) => b.text().includes('Go to Today'))
|
||||||
|
expect(finishBtn).toBeTruthy()
|
||||||
|
await finishBtn!.trigger('click')
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(router.currentRoute.value.path).toBe('/today')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
280
apps/web/src/views/Welcome.vue
Normal file
280
apps/web/src/views/Welcome.vue
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import * as api from '@/api'
|
||||||
|
import { HttpError } from '@/api'
|
||||||
|
import type { CvDraft, PostingsFetchResponse } from '@/types'
|
||||||
|
|
||||||
|
const toast = useToastStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const step = ref(0)
|
||||||
|
const steps = ['Welcome', 'Import CV', 'Fetch Postings', 'Done']
|
||||||
|
|
||||||
|
// Step 1: Import CV
|
||||||
|
const selectedFile = ref<File | null>(null)
|
||||||
|
const importing = ref(false)
|
||||||
|
const drafts = ref<CvDraft[]>([])
|
||||||
|
const importError = ref('')
|
||||||
|
|
||||||
|
// Step 2: Fetch Postings
|
||||||
|
const fetchQuery = ref('')
|
||||||
|
const fetchRegion = ref('')
|
||||||
|
const fetching = ref(false)
|
||||||
|
const fetchResult = ref<PostingsFetchResponse | null>(null)
|
||||||
|
|
||||||
|
function onFileChange(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
if (target.files && target.files.length > 0) {
|
||||||
|
selectedFile.value = target.files[0]
|
||||||
|
importError.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileToBase64(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result as string
|
||||||
|
const base64 = result.split(',')[1] ?? ''
|
||||||
|
resolve(base64)
|
||||||
|
}
|
||||||
|
reader.onerror = () => reject(new Error('Failed to read file'))
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doImport() {
|
||||||
|
if (!selectedFile.value) {
|
||||||
|
importError.value = 'Please select a file first.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
importing.value = true
|
||||||
|
importError.value = ''
|
||||||
|
try {
|
||||||
|
const base64 = await fileToBase64(selectedFile.value)
|
||||||
|
const res = await api.importCv(selectedFile.value.name, base64)
|
||||||
|
drafts.value = res.drafts
|
||||||
|
if (drafts.value.length === 0) {
|
||||||
|
importError.value = 'No sections were extracted from this file.'
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
const body = err.body as { error?: { message?: string } } | null
|
||||||
|
importError.value = body?.error?.message ?? 'Import failed'
|
||||||
|
} else {
|
||||||
|
importError.value = 'Import failed'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
importing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDrafts() {
|
||||||
|
importing.value = true
|
||||||
|
try {
|
||||||
|
await api.confirmCvImport(drafts.value)
|
||||||
|
toast.push('CV sections saved', 'success')
|
||||||
|
step.value = 2
|
||||||
|
} catch (err) {
|
||||||
|
let msg = 'Failed to save CV sections'
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
const body = err.body as { error?: { message?: string } } | null
|
||||||
|
msg = body?.error?.message ?? msg
|
||||||
|
}
|
||||||
|
toast.push(msg, 'error')
|
||||||
|
} finally {
|
||||||
|
importing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipImport() {
|
||||||
|
step.value = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doFetch() {
|
||||||
|
if (!fetchQuery.value.trim()) {
|
||||||
|
toast.push('Enter a search query', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fetching.value = true
|
||||||
|
fetchResult.value = null
|
||||||
|
try {
|
||||||
|
fetchResult.value = await api.fetchPostings(fetchQuery.value.trim(), fetchRegion.value.trim() || undefined)
|
||||||
|
toast.push(`Fetched ${fetchResult.value.new} new postings`, 'success')
|
||||||
|
} catch (err) {
|
||||||
|
let msg = 'Fetch failed'
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
const body = err.body as { error?: { message?: string } } | null
|
||||||
|
msg = body?.error?.message ?? msg
|
||||||
|
}
|
||||||
|
toast.push(msg, 'error')
|
||||||
|
} finally {
|
||||||
|
fetching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
router.push('/today')
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
if (step.value < steps.length - 1) step.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
function prev() {
|
||||||
|
if (step.value > 0) step.value--
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="max-w-2xl mx-auto space-y-6">
|
||||||
|
<h1 class="text-2xl font-bold">Welcome to Jobhunt</h1>
|
||||||
|
|
||||||
|
<!-- Step indicator -->
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span
|
||||||
|
v-for="(s, i) in steps"
|
||||||
|
:key="s"
|
||||||
|
:class="[
|
||||||
|
'px-3 py-1 rounded-full',
|
||||||
|
i === step ? 'bg-indigo-600 text-white' : i < step ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ i + 1 }}. {{ s }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 0: Welcome -->
|
||||||
|
<div v-if="step === 0" class="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
|
||||||
|
<p class="text-gray-700">
|
||||||
|
Jobhunt helps you discover jobs, score them against your profile, draft application material, and prepare for interviews.
|
||||||
|
You stay in control: nothing is sent without your explicit approval.
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-700">
|
||||||
|
Let's set up your profile in a few quick steps. You can skip any step and come back later.
|
||||||
|
</p>
|
||||||
|
<button @click="next" class="bg-indigo-600 text-white px-4 py-2 rounded text-sm">
|
||||||
|
Get Started
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: Import CV -->
|
||||||
|
<div v-if="step === 1" class="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
|
||||||
|
<h2 class="font-semibold text-lg">Import Your CV</h2>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Upload a PDF, DOCX, or plain text file. We will extract sections for you to review and confirm.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.docx,.txt"
|
||||||
|
@change="onFileChange"
|
||||||
|
class="block text-sm text-gray-700"
|
||||||
|
/>
|
||||||
|
<div v-if="importError" class="text-red-600 text-sm">{{ importError }}</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="drafts.length === 0"
|
||||||
|
@click="doImport"
|
||||||
|
:disabled="importing || !selectedFile"
|
||||||
|
class="bg-indigo-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ importing ? 'Importing...' : 'Extract Sections' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Drafts review -->
|
||||||
|
<div v-if="drafts.length > 0" class="space-y-3">
|
||||||
|
<h3 class="font-medium text-sm">Review extracted sections ({{ drafts.length }})</h3>
|
||||||
|
<div
|
||||||
|
v-for="(draft, i) in drafts"
|
||||||
|
:key="i"
|
||||||
|
class="border border-gray-200 rounded p-3 text-sm"
|
||||||
|
>
|
||||||
|
<div class="font-medium">{{ draft.title }} ({{ draft.kind }})</div>
|
||||||
|
<div class="text-gray-500">{{ draft.org }}{{ draft.location ? ' - ' + draft.location : '' }}</div>
|
||||||
|
<ul v-if="draft.bullets.length" class="list-disc ml-5 text-gray-600 mt-1">
|
||||||
|
<li v-for="(b, bi) in draft.bullets" :key="bi">{{ b }}</li>
|
||||||
|
</ul>
|
||||||
|
<div v-if="draft.tags.length" class="flex flex-wrap gap-1 mt-1">
|
||||||
|
<span v-for="t in draft.tags" :key="t" class="text-xs bg-gray-100 rounded px-2 py-0.5">{{ t }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
@click="confirmDrafts"
|
||||||
|
:disabled="importing"
|
||||||
|
class="bg-green-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ importing ? 'Saving...' : 'Confirm & Save Sections' }}
|
||||||
|
</button>
|
||||||
|
<button @click="skipImport" class="text-sm text-gray-500 hover:underline">
|
||||||
|
Skip for now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button v-if="drafts.length === 0" @click="skipImport" class="text-sm text-gray-500 hover:underline block">
|
||||||
|
Skip for now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Fetch Postings -->
|
||||||
|
<div v-if="step === 2" class="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
|
||||||
|
<h2 class="font-semibold text-lg">Fetch Job Postings</h2>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Search for job postings from the Arbetsförmedlingen connector. New postings will be added to your applications.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm text-gray-600">Search query</span>
|
||||||
|
<input
|
||||||
|
v-model="fetchQuery"
|
||||||
|
placeholder="e.g. python developer"
|
||||||
|
class="w-full border rounded px-2 py-1 mt-1"
|
||||||
|
@keyup.enter="doFetch"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm text-gray-600">Region (optional)</span>
|
||||||
|
<input
|
||||||
|
v-model="fetchRegion"
|
||||||
|
placeholder="e.g. Skane lan"
|
||||||
|
class="w-full border rounded px-2 py-1 mt-1"
|
||||||
|
@keyup.enter="doFetch"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="doFetch"
|
||||||
|
:disabled="fetching"
|
||||||
|
class="bg-indigo-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ fetching ? 'Fetching...' : 'Fetch Postings' }}
|
||||||
|
</button>
|
||||||
|
<div v-if="fetchResult" class="text-sm">
|
||||||
|
<span class="text-green-700 font-medium">{{ fetchResult.new }} new</span>
|
||||||
|
postings found, <span class="text-gray-500">{{ fetchResult.dupes }} duplicates</span> skipped.
|
||||||
|
</div>
|
||||||
|
<button @click="next" class="text-sm text-indigo-600 hover:underline block">
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Done -->
|
||||||
|
<div v-if="step === 3" class="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
|
||||||
|
<h2 class="font-semibold text-lg">You are all set!</h2>
|
||||||
|
<p class="text-gray-700">
|
||||||
|
Your profile is ready. Head to the Today page to see your daily digest, nudges, and pending approvals.
|
||||||
|
</p>
|
||||||
|
<button @click="finish" class="bg-indigo-600 text-white px-4 py-2 rounded text-sm">
|
||||||
|
Go to Today
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<div v-if="step > 0 && step < 3" class="flex gap-3">
|
||||||
|
<button @click="prev" class="text-sm text-gray-500 hover:underline">Back</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
57
docker-compose.prod.yml
Normal file
57
docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Production stack for jobhunt-platform.
|
||||||
|
# Built and started by .forgejo/workflows/deploy.yml on the host docker daemon.
|
||||||
|
# Web UI is published on http://<host>:8085, API on :8000. Postgres is internal only.
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: jobhunt-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: jobhunt
|
||||||
|
POSTGRES_PASSWORD: jobhunt
|
||||||
|
POSTGRES_DB: jobhunt
|
||||||
|
volumes:
|
||||||
|
- jobhunt_pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U jobhunt -d jobhunt"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/api/Dockerfile.test
|
||||||
|
image: jobhunt-api
|
||||||
|
container_name: jobhunt-api
|
||||||
|
entrypoint: uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://jobhunt:jobhunt@postgres:5432/jobhunt
|
||||||
|
working_dir: /app/apps/api
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/web/Dockerfile
|
||||||
|
args:
|
||||||
|
# Baked into the SPA at build time. Relative /api goes through the
|
||||||
|
# nginx proxy in apps/web/nginx.conf -> http://api:8000/api/
|
||||||
|
VITE_API_BASE: /api
|
||||||
|
image: jobhunt-web
|
||||||
|
container_name: jobhunt-web
|
||||||
|
ports:
|
||||||
|
- "8085:80"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
jobhunt_pgdata:
|
||||||
|
name: jobhunt_pgdata
|
||||||
70
docker-compose.yml
Normal file
70
docker-compose.yml
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# POC infrastructure for jobhunt-platform.
|
||||||
|
# Only the postgres service is defined here; app services are run locally for now.
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: jobhunt-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: jobhunt
|
||||||
|
POSTGRES_PASSWORD: jobhunt
|
||||||
|
POSTGRES_DB: jobhunt
|
||||||
|
# No host port publishing: CI/tests run inside the compose network, and on
|
||||||
|
# this host 5433 is already taken by bilhej-postgres-prod.
|
||||||
|
volumes:
|
||||||
|
- jobhunt_pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U jobhunt -d jobhunt"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
api-test:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/api/Dockerfile.test
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://jobhunt:jobhunt@postgres:5432/jobhunt
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/api/Dockerfile.test
|
||||||
|
image: jobhunt-platform-api-test
|
||||||
|
entrypoint: uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://jobhunt:jobhunt@postgres:5432/jobhunt
|
||||||
|
working_dir: /app/apps/api
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/web/Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_BASE: http://api:8000/api
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
shots:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: scripts/Dockerfile.shots
|
||||||
|
volumes:
|
||||||
|
- shots_out:/out
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
- web
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
jobhunt_pgdata:
|
||||||
|
name: jobhunt_pgdata
|
||||||
|
shots_out:
|
||||||
31
docs/adr/0002-v1-scope.md
Normal file
31
docs/adr/0002-v1-scope.md
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# ADR-0002: v1.0 scope (post perspective review)
|
||||||
|
|
||||||
|
Status: accepted (2026-07-30)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
POC proved the architecture (state machine, approval gate, budgets, mock mode).
|
||||||
|
`docs/user-research/desperate-jobseeker.md` drives v1.0 scope.
|
||||||
|
|
||||||
|
## v1.0 adds
|
||||||
|
|
||||||
|
1. **CV import** (PDF/DOCX/paste -> extracted section drafts -> confirm-to-merge). No import, no adoption.
|
||||||
|
2. **Arbetsförmedlingen connector** (Official Swedish public employment API, free, no key, no ToS risk) + generic URL ingestion with readability extraction. LinkedIn stays manual-paste (account-ban risk documented).
|
||||||
|
3. **Nightly digest scheduler**: fetch -> dedupe -> batch score (cheap class) -> `GET /today` digest (top matches + follow-up nudges).
|
||||||
|
4. **Follow-up rules**: days-since-sent -> suggested nudge with one-click draft.
|
||||||
|
5. **Interview prep generator** (strong class, per application, stored as artifact).
|
||||||
|
6. **Scam/red-flag check** inside scoring (cheap class, extends rubric output with `red_flags: []`).
|
||||||
|
7. **SMTP transport** (env-configured) behind the unchanged approval gate; `ClipboardTransport` default when unconfigured.
|
||||||
|
8. **Onboarding wizard** in web: welcome -> import -> digest -> first application.
|
||||||
|
9. **Forgejo CI**: pytest suites (api, artifacts, llm-gateway, connectors) + web build/test on push.
|
||||||
|
10. **Demo seed**: `POST /dev/seed-demo` inserts a demo profile + 6 sample postings so a new user sees the product in 60 seconds with zero keys.
|
||||||
|
|
||||||
|
## Not in v1.0
|
||||||
|
|
||||||
|
Multi-user auth, SaaS, auto-apply, LinkedIn write, mobile app, interview scheduling.
|
||||||
|
|
||||||
|
## Targets
|
||||||
|
|
||||||
|
- `docker compose up` to running app in < 15 min on a dev machine.
|
||||||
|
- All tests green in CI.
|
||||||
|
- No endpoint requires an LLM key; everything degrades to mock.
|
||||||
27
docs/adr/0003-v11-scope.md
Normal file
27
docs/adr/0003-v11-scope.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# ADR-0003: v1.1 scope (feel-alive features + killer demo)
|
||||||
|
|
||||||
|
Status: accepted (2026-07-30)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
1. **Email reply tracking (read-only)**: IMAP poll (stdlib imaplib, env `IMAP_HOST/PORT/USER/PASS`, flag `EMAIL_WATCH_ENABLED` default false) every 15 min via scheduler. New messages matched to applications by contact domain/company; cheap-LLM classify into `interview_invite | rejection | question | noise`. Result stored as `suggestion` rows; user confirms card moves (state transitions stay user-gated per ADR-0001, no auto-move in v1.1).
|
||||||
|
2. **Notifications**: `NotificationChannel` interface; v1.1 implementations: `LogChannel` (default), `WebhookChannel` (generic POST to user URL, documented Hermes-webhook example). Triggers: daily digest (07:30), interview-invite suggestion. Payload: text + data JSON.
|
||||||
|
3. **Agency duplicate detection**: deterministic similarity (packages/matching with rapidfuzz: normalized employer name match OR token_set_ratio(title)>=85 AND token_set_ratio(description)>=80 -> same cluster). No LLM. `cluster_id` groups postings; API surfaces alternates ("same role via 3 agencies").
|
||||||
|
4. **CV tailoring per posting**: strong-class task `cv_tailor` -> tailored CV variant JSON (reordered skills, rephrased bullets toward posting keywords, unchanged facts — hallucination guard: only reorder/rephrase existing content, never invent). Stored as artifact(kind=cv) variant linked to application. ATS keyword report: deterministic keyword coverage (tokenizer intersection, no LLM).
|
||||||
|
5. **Deadline radar**: cheap extraction task `deadline_extract` during scoring; nullable `apply_by date` on job_posting (migration 004); /today adds `deadlines` strip (next 7 days).
|
||||||
|
|
||||||
|
## Rules kept
|
||||||
|
|
||||||
|
- Approval gate untouched; email watch is read-only.
|
||||||
|
- Zero-config still works: everything above degrades to mock/log/no-op.
|
||||||
|
- No paid-provider fallback for cheap classes.
|
||||||
|
|
||||||
|
## v1.1 delivery shape
|
||||||
|
|
||||||
|
Wave A (parallel, no shared files):
|
||||||
|
- WA1 apps/api: email watch + notifications + suggestion endpoints (owns main.py/schemas.py/migrations this wave)
|
||||||
|
- WA2 packages/matching (new) + packages/llm-gateway (mock additions only)
|
||||||
|
|
||||||
|
Wave B (after merge):
|
||||||
|
- WB1 apps/api: dedupe integration + cv-tailor + deadline endpoints (owns main.py etc.)
|
||||||
|
- WB2 apps/web: v1.1 UI (suggestions inbox strip, cluster alternates, tailor button + variant viewer, deadlines strip, notification settings stub)
|
||||||
199
docs/api-contract-v2.md
Normal file
199
docs/api-contract-v2.md
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
# API contract v2 (v1.0 additions)
|
||||||
|
|
||||||
|
Base: `/api`. JSON everywhere. Errors as `{error: {code, message}}` with proper HTTP status.
|
||||||
|
|
||||||
|
This file documents the **new** endpoints added on top of `docs/api-contract.md` (POC).
|
||||||
|
All existing POC endpoints remain unchanged.
|
||||||
|
|
||||||
|
## CV Import
|
||||||
|
|
||||||
|
### `POST /cv/import`
|
||||||
|
|
||||||
|
Extract text from an uploaded file and generate draft CV sections via the LLM gateway (cheap class).
|
||||||
|
Does NOT write to `cv_section` -- returns drafts for user review.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filename": "my_cv.pdf",
|
||||||
|
"content_base64": "JVBERi0xLjQK..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"drafts": [
|
||||||
|
{
|
||||||
|
"kind": "experience",
|
||||||
|
"title": "Software Engineer",
|
||||||
|
"org": "TechCorp",
|
||||||
|
"location": "Malmo",
|
||||||
|
"start_date": "2022-01",
|
||||||
|
"end_date": null,
|
||||||
|
"bullets": ["Built feature X", "Improved performance by 20%"],
|
||||||
|
"tags": ["python", "fastapi"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
- 422 `{error: {code: "empty_file", message: "..."}}` when the file is empty or contains no extractable text.
|
||||||
|
- 422 `{error: {code: "unsupported_format", message: "..."}}` when the file type is not recognized.
|
||||||
|
|
||||||
|
### `POST /cv/import/confirm`
|
||||||
|
|
||||||
|
Create `cv_section` rows from the drafts returned by `/cv/import`.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"drafts": [
|
||||||
|
{
|
||||||
|
"kind": "experience",
|
||||||
|
"title": "Software Engineer",
|
||||||
|
"org": "TechCorp",
|
||||||
|
"bullets": ["Built feature X"],
|
||||||
|
"tags": ["python"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 201:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"created": 3,
|
||||||
|
"sections": [/* CvSectionOut[] */]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Postings Fetch (Arbetsformedlingen connector)
|
||||||
|
|
||||||
|
### `POST /postings/fetch`
|
||||||
|
|
||||||
|
Fetch job postings from the Arbetsformedlingen connector, create `job_posting` + `application(discovered)` for new postings, skip duplicates.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": "python developer",
|
||||||
|
"region": "Skane lan"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"new": 12,
|
||||||
|
"dupes": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
- 503 `{error: {code: "connectors_disabled", message: "Connectors are not enabled. Set CONNECTORS_ENABLED=true to enable."}}` when `CONNECTORS_ENABLED=false`.
|
||||||
|
|
||||||
|
## Batch Scoring
|
||||||
|
|
||||||
|
### `POST /scoring/batch`
|
||||||
|
|
||||||
|
Score multiple applications in one call (cheap class). Each response includes `red_flags` (scam/shield checks).
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"application_ids": ["uuid1", "uuid2"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"application_id": "uuid1",
|
||||||
|
"score": 72,
|
||||||
|
"rationale": {"match": 0.72, "factors": {"skills": 0.8}},
|
||||||
|
"red_flags": ["unpaid trial period mentioned"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Today Digest
|
||||||
|
|
||||||
|
### `GET /today`
|
||||||
|
|
||||||
|
Returns the daily digest: ranked postings, follow-up nudges, and pending approval count.
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"digest": [
|
||||||
|
{
|
||||||
|
"application_id": "uuid",
|
||||||
|
"title": "Backend Developer",
|
||||||
|
"company": "TechCorp",
|
||||||
|
"score": 85
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nudges": [
|
||||||
|
{
|
||||||
|
"application_id": "uuid",
|
||||||
|
"days_since_sent": 9,
|
||||||
|
"suggestion": "Consider sending a follow-up email asking about the status of your application."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pending_approvals": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nudge SQL: `state = 'sent' AND days_since(last_activity_at) > follow_up_after_days AND (follow_up_snoozed_until IS NULL OR follow_up_snoozed_until < today)`.
|
||||||
|
|
||||||
|
## Interview Prep
|
||||||
|
|
||||||
|
### `POST /applications/{id}/interview-prep`
|
||||||
|
|
||||||
|
Generate interview prep Q&A (strong LLM class), stored as an artifact of kind `other`.
|
||||||
|
Sets `interview_prep_artifact_id` on the application.
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"artifact_id": "uuid",
|
||||||
|
"content": "# Interview Prep\n\n## Q1: ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Mock mode returns deterministic 10-question Q&A markdown.
|
||||||
|
|
||||||
|
## Concierge / Demo Seed
|
||||||
|
|
||||||
|
### `POST /concierge/seed-demo`
|
||||||
|
|
||||||
|
Idempotent: seeds a demo profile ('Demo Demosson') with Swedish characters (a,a,o), 6 realistic Skane postings, varied application states (one scored high, one sent 8 days ago for nudge demo). Calling twice does not duplicate data.
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"profile": "Demo Demosson",
|
||||||
|
"postings": 6,
|
||||||
|
"applications": 6,
|
||||||
|
"sections": 4
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## SMTP Transport (no new endpoint)
|
||||||
|
|
||||||
|
The outbox `POST /outbox/send` now selects transport at call time:
|
||||||
|
|
||||||
|
1. If `SMTP_HOST` is set: `SmtpTransport` (ssl on port 465, starttls otherwise, auth with `SMTP_USER`/`SMTP_PASS`, from `SMTP_FROM`).
|
||||||
|
2. Else: `ClipboardTransport` (marks sent + stores payload for UI copy/paste).
|
||||||
|
|
||||||
|
The approval gate checks (confirmed, unexpired, hash match) are UNCHANGED.
|
||||||
|
|
||||||
|
## Scheduler (no new endpoint)
|
||||||
|
|
||||||
|
APScheduler `AsyncIOScheduler` starts during app lifespan when `SCHEDULER_ENABLED=true` (default false).
|
||||||
|
Runs a daily job at 07:00 that fetches postings and batch-scores pending applications.
|
||||||
148
docs/user-guide.md
Normal file
148
docs/user-guide.md
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
# User Guide
|
||||||
|
|
||||||
|
A practical guide for getting started with Jobhunt Platform. Written for first-time users who want to land a job quickly.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Docker and Docker Compose installed on your machine
|
||||||
|
- Node.js 22 (for the web frontend, only needed if running the dev server on the host)
|
||||||
|
|
||||||
|
### Quick start
|
||||||
|
|
||||||
|
1. Clone the repository and enter the project directory.
|
||||||
|
2. Copy the environment template: `cp .env.example .env`
|
||||||
|
3. Start the database and run the API tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm api-test
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Start the API server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker rm -f jobhunt-api 2>/dev/null
|
||||||
|
docker compose run -d --name jobhunt-api \
|
||||||
|
--entrypoint "uvicorn app.main:app --host 0.0.0.0 --port 8000" api-test
|
||||||
|
```
|
||||||
|
|
||||||
|
5. In a separate terminal, start the web frontend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/web
|
||||||
|
npm install
|
||||||
|
VITE_API_BASE=http://localhost:8000/api npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Open the web app in your browser (typically http://localhost:5173).
|
||||||
|
|
||||||
|
You do NOT need any LLM API keys. The system runs in mock mode by default, which produces deterministic outputs. Add a key to `.env` only when you want real AI responses.
|
||||||
|
|
||||||
|
## First run: the onboarding wizard
|
||||||
|
|
||||||
|
When you open the app for the first time, the onboarding wizard appears at `/welcome`. It has four steps:
|
||||||
|
|
||||||
|
1. **Welcome** - a short introduction to the platform.
|
||||||
|
2. **Import CV** - upload a PDF, DOCX, or plain text file of your existing CV. The system extracts sections (experience, education, skills, projects) for you to review. You can edit them before confirming. If you do not have a CV file handy, you can skip this step and add sections manually later in the CV tab.
|
||||||
|
3. **Fetch Postings** - search for job postings from the Arbetsformedlingen connector. Enter a search query (for example "python developer") and optionally a region. New postings are added to your applications as "discovered". You can skip this step too.
|
||||||
|
4. **Done** - you are ready to go. Click "Go to Today" to see your dashboard.
|
||||||
|
|
||||||
|
## The Today page
|
||||||
|
|
||||||
|
The Today page (`/today`) is your daily dashboard. It shows three things:
|
||||||
|
|
||||||
|
- **Top Matches** - ranked job postings scored against your profile. Click a card to open the application detail page.
|
||||||
|
- **Follow-up Nudges** - applications you sent more than 7 days ago with no reply. Each nudge includes a suggested follow-up message. Click "Copy follow-up draft" to copy the text to your clipboard, then paste it into your email client.
|
||||||
|
- **Pending Approvals** - a count of outgoing actions (emails, submissions) waiting for your confirmation.
|
||||||
|
|
||||||
|
At the bottom of the Today page, the **Cost Summary** shows total tokens used (input and output) and the total cost if pricing is configured. This helps you track your LLM spending.
|
||||||
|
|
||||||
|
## CV editor
|
||||||
|
|
||||||
|
The CV tab lets you manage your profile and CV sections. You can:
|
||||||
|
|
||||||
|
- Edit your name, email, phone, location, headline, and summary.
|
||||||
|
- Add, edit, and delete sections (experience, education, skills, projects, other).
|
||||||
|
- Use AI Assist to get suggestions for improving bullet points.
|
||||||
|
- Render your CV to a PDF for download.
|
||||||
|
|
||||||
|
## Research
|
||||||
|
|
||||||
|
The Research tab has two ways to find job postings:
|
||||||
|
|
||||||
|
1. **Fetch from Arbetsformedlingen** - enter a search query and optional region to pull postings from the official Swedish public employment service. New postings are created automatically.
|
||||||
|
2. **Add by URL** - paste any job posting URL to add it manually.
|
||||||
|
|
||||||
|
The postings table includes a **Scam** column. If the scoring system detects red flags (such as unpaid trial periods or requests for personal financial data), a warning symbol appears with a tooltip listing the specific concerns.
|
||||||
|
|
||||||
|
Click "Score" on any posting to run the scoring rubric against your profile. The score appears as a green badge.
|
||||||
|
|
||||||
|
## Applications (kanban)
|
||||||
|
|
||||||
|
The Applications tab shows all your job applications as cards on a kanban board, organized by state:
|
||||||
|
|
||||||
|
- discovered, scored, approved, drafting, sent, interviewing, offer, closed, rejected, expired
|
||||||
|
|
||||||
|
You can drag cards between columns to change their state. The board enforces valid transitions (some moves are not allowed and will be rejected).
|
||||||
|
|
||||||
|
Two visual indicators appear on cards:
|
||||||
|
|
||||||
|
- **Red flag badge** (warning symbol) - the scoring system detected potential scam or fraud indicators. Hover over the symbol to see the specific red flags.
|
||||||
|
- **Nudge dot** (orange dot) - this application has a follow-up nudge, meaning you sent it more than 7 days ago without a reply. Visit the Today page for the suggested follow-up message.
|
||||||
|
|
||||||
|
## Application detail
|
||||||
|
|
||||||
|
Click any application card to open its detail page. Here you can:
|
||||||
|
|
||||||
|
- View the posting information, current state, and score.
|
||||||
|
- **Interview Prep** - click "Open Interview Prep" to generate likely interview questions with suggested answers based on your profile and the job posting. The content is saved as an artifact. You can edit the text and save a new version, or regenerate it.
|
||||||
|
- View all artifacts (cover letters, CVs, interview prep, etc.).
|
||||||
|
- Write and save a cover letter. The system provides an AI critique with severity-tagged suggestions.
|
||||||
|
- Request approval, confirm it, and send. The approval gate ensures nothing is sent without your explicit confirmation. The system verifies the artifact hash before sending.
|
||||||
|
|
||||||
|
## Costs
|
||||||
|
|
||||||
|
Every LLM call (scoring, extraction, critique, interview prep) is tracked. The Cost Summary on the Today page shows:
|
||||||
|
|
||||||
|
- Total tokens consumed (input and output)
|
||||||
|
- Total cost (if pricing is configured)
|
||||||
|
- Number of task runs
|
||||||
|
|
||||||
|
This transparency helps you make informed decisions about when to use AI features, especially if you are counting kronor.
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Run without API keys first to explore the platform with mock data. Use `POST /concierge/seed-demo` (via curl or the API) to populate demo data instantly.
|
||||||
|
- Check the Today page daily for new nudges and digest items.
|
||||||
|
- Always review AI-generated content before sending. The system assists you, but you are the decision maker.
|
||||||
|
- Use the scam/red-flag indicators to avoid suspicious postings.
|
||||||
|
|
||||||
|
## v1.1: email radar, dedupe, tailor CV
|
||||||
|
|
||||||
|
Three new features help you move faster without missing anything.
|
||||||
|
|
||||||
|
### Email radar on the Today page
|
||||||
|
|
||||||
|
The Today page now has two extra strips above the digest:
|
||||||
|
|
||||||
|
- **Deadlines This Week** shows upcoming application deadlines as cards. Cards turn red when the deadline is within two days. Click a card to jump to the application.
|
||||||
|
- **Inbox Insights** lists classified email suggestions (interview invite, rejection, question, noise) pulled from your inbox monitoring. Each card has Accept and Dismiss buttons. Accepted suggestions stay on file; dismissed ones disappear. A **Recent Notifications** mini-log at the bottom shows the last five system events so you can see what happened recently.
|
||||||
|
|
||||||
|
### Dedupe in Research
|
||||||
|
|
||||||
|
The Research table now groups duplicate postings by cluster. When the same role appears through multiple agencies or sources, the cluster header shows "also via N more." Click the header to expand the alternates list and see all sources side by side with their scores. This saves you from applying to the same job three times.
|
||||||
|
|
||||||
|
### Tailor CV
|
||||||
|
|
||||||
|
On any application detail page, click **Tailor My CV** to generate a CV variant tuned to that specific posting. The panel shows:
|
||||||
|
|
||||||
|
- A keyword coverage bar indicating how well your CV matches the posting description.
|
||||||
|
- A change log listing every modification (reordered sections, rephrased bullets). No facts are invented; only rephrased and reordered.
|
||||||
|
- A download link for the tailored CV as a PDF.
|
||||||
|
|
||||||
|
The tailored CV appears in the artifacts list for that application, ready to use in the approval and send flow.
|
||||||
|
|
||||||
|
### Cost breakdown by provider
|
||||||
|
|
||||||
|
The Cost Summary on the Today page now includes a per-model breakdown table showing tokens in, tokens out, cost, and run count for each LLM model used. This helps you compare spending across providers at a glance.
|
||||||
42
docs/user-research/desperate-jobseeker.md
Normal file
42
docs/user-research/desperate-jobseeker.md
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Perspective review: the desperate job seeker
|
||||||
|
|
||||||
|
Who uses this at v1.0? Not a senior dev fielding recruiter InMails. Someone who is unemployed, stressed, maybe burned through savings, possibly on A-kassa, who needs to land a job in weeks. Reviewed against the POC, here is what they actually experience:
|
||||||
|
|
||||||
|
## What breaks for them today
|
||||||
|
|
||||||
|
1. **They don't have a structured CV to edit.** Their CV is a PDF or Word file, possibly years old, possibly bad. A tab that asks them to hand-type every section is dead on arrival. They need: drop in the old file, the system extracts sections, they fix mistakes. CV import is THE onboarding feature.
|
||||||
|
|
||||||
|
2. **They can't config an API key hunt.** "Add three provider keys to .env" filters out 90% of desperate job seekers. v1.0 must run fully with zero keys (mock LLM + demo data) and work with a single free key for real use. Cost must be visible per action ("this scoring costs ~1 cent") — someone counting kronor will not press a button marked "maybe $$$".
|
||||||
|
|
||||||
|
3. **Volume AND tailoring.** They might need 30 applications out in a week. One-at-a-time paste-URL flow too slow, mass-auto-apply gets them flagged as spam. Middle path: a real source connector that pulls relevant postings nightly (Sweden: Arbetsförmedlingen's official free API), batch-scores them cheap, and presents a ranked morning digest ("3 worth your time today, 2 borderline"). Their scarce resource is writing energy, not job links.
|
||||||
|
|
||||||
|
4. **Rejection management.** A kanban full of red columns crushes morale. They need follow-up tracking that acts FOR them: applied 7 days ago, no reply -> "nudge them, here's a 2-line follow-up draft". Small forward motion daily.
|
||||||
|
|
||||||
|
5. **The interview is the bottleneck.** Weeks of silence, then "can you do Tuesday?". They panic. Interview prep must be one click per application: likely questions from the posting + their profile, draft answers in THEIR voice (they edit, system critiques).
|
||||||
|
|
||||||
|
6. **They can be scammed.** Desperation attracts fraud postings (fake employers harvesting personal data, pay-to-apply schemes). Scoring should include cheap red-flag checks as a visible "⚠" on postings.
|
||||||
|
|
||||||
|
7. **LinkedIn gray zone.** Mass-automation gets accounts banned; a desperate person losing their LinkedIn account is catastrophic. Official APIs (Arbetsförmedlingen) and read-paste flows only at v1.0.
|
||||||
|
|
||||||
|
8. **Swedish AND English.** Many will write applications in both. Artifacts and critique must handle both without mangling å/ä/ö (proven in POC) and the UI must not shame imperfect Swedish.
|
||||||
|
|
||||||
|
## What they do NOT need at v1.0
|
||||||
|
|
||||||
|
- Multi-user, auth, SaaS, billing. Single-user self-hosted.
|
||||||
|
- Auto-apply anything. Ever.
|
||||||
|
- Mobile app. Responsive web is enough.
|
||||||
|
- Interview scheduling integrations.
|
||||||
|
|
||||||
|
## Consequences for design (v1.0 deltas over POC)
|
||||||
|
|
||||||
|
| Need | Feature | Owner component |
|
||||||
|
|---|---|---|
|
||||||
|
| Import old CV | `POST /cv/import` (PDF/DOCX text -> LLM extract -> section drafts -> user confirms) | api + llm-gateway |
|
||||||
|
| Zero-config start | demo seed data + mock mode already default; first-run wizard | web + api seed |
|
||||||
|
| Volume | Arbetsförmedlingen connector (official, free, keyless), nightly fetch + batch score + digest endpoint | connectors + scheduler |
|
||||||
|
| Burn transparency | `/telemetry/cost-estimate` returning per-task token+cost totals | api |
|
||||||
|
| Follow-up engine | `follow_up_rule` on application; `GET /today` returns nudges + digest + open nudges count | api |
|
||||||
|
| Interview prep | `POST /applications/{id}/interview-prep` -> markdown Q&A in user's languages, saved as artifact | api |
|
||||||
|
| Scam shield | scoring task v2 adds red_flags array (unpaid trial, asks for money, harvesters) | llm-gateway + api |
|
||||||
|
| Real send | SMTP transport behind existing approval gate (unchanged gate semantics); clipboard fallback transport when SMTP unconfigured | api |
|
||||||
|
| Onboarding | wizard: welcome -> import CV -> first digest -> first approval | web |
|
||||||
52
docs/worker-tasks/v1-tasks.md
Normal file
52
docs/worker-tasks/v1-tasks.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
# v1.0 worker dispatch cards
|
||||||
|
|
||||||
|
Global rules for ALL workers (same as POC, still binding):
|
||||||
|
|
||||||
|
- Work only inside your assigned paths. Never touch other modules.
|
||||||
|
- Python 3.13 + uv. Node 22 + npm. NO SQLAlchemy/Alembic/Terraform. No em dashes anywhere. English code and docs.
|
||||||
|
- LLM only via packages/llm-gateway; write prompts against mock mode first, real keys optional. Budget guards stay on.
|
||||||
|
- The approval gate semantics from ADR-0001 MUST NOT be weakened. New send transports go through the same gate.
|
||||||
|
- DinD sandbox: docker compose networking between containers works; host port publishing does NOT. DB tests run via `docker compose run --rm api-test` pattern (see README quick start). Image COPY pitfall: `COPY dir dest` flattens; use `COPY dir ./dir`.
|
||||||
|
- Run your tests; report exact pass/fail counts. Branch naming: feat/Wn-<name>. Push branch when green.
|
||||||
|
|
||||||
|
## W1: packages/connectors
|
||||||
|
|
||||||
|
Paths: `packages/connectors/**`
|
||||||
|
|
||||||
|
- `Connector` protocol: `fetch(query: SearchQuery) -> list[RawPosting]`; normalizer to the job_posting shape (source, external_id, url, company, title, location, description, raw).
|
||||||
|
- `arbetsformedlingen.py`: official Platsbanken API (https://jobsearch.api.jobtechdev.se/search?q=... free, no key, GET, header accept json). Map fields (headline->title, employer.name->company, webpage_url->url, description.text->description). Params: q, region (Malmö = Skåne län filter), limit. Polite user-agent.
|
||||||
|
- `generic_url.py`: fetch a posting URL, strip boilerplate (simple readability: prefer <main>/<article>, drop nav/footer/script/style), return title/company guesses + clean description text. Must NOT follow Cloudflare-challenge sites (detect challenge pages -> raise UnsupportedSite).
|
||||||
|
- Dedupe helper: `(source, url)` + external_id keying.
|
||||||
|
- Tests: VCR-style recorded fixtures (ship JSON/HTML fixtures in tests/fixtures; NO live network in tests). Cover AF mapping, generic extractor on a messy HTML fixture, challenge-page detection, dedupe.
|
||||||
|
- README.md per package: usage + mock examples.
|
||||||
|
|
||||||
|
## W2: apps/api v1 features
|
||||||
|
|
||||||
|
Paths: `apps/api/**` (extend, do not rewrite; keep existing 47 tests green)
|
||||||
|
|
||||||
|
Migrations `002_followups.sql`: add `follow_up_after_days int` (default 7), `last_activity_at timestamptz`, `follow_up_snoozed_until date` on `application`; `interview_prep_artifact_id uuid null` on application.
|
||||||
|
|
||||||
|
New endpoints (contract addition file `docs/api-contract-v2.md` first, then implement):
|
||||||
|
- `POST /cv/import` body `{filename, content_base64}`: extract text with pypdf (PDF) or python-docx (DOCX) or plain text; LLM task `cv_extract` (cheap class) -> draft sections; DO NOT write to cv_section; return `{drafts: [...]}`. `POST /cv/import/confirm` body `{drafts}` -> creates sections. Empty/garbage file -> 422 with clear message.
|
||||||
|
- `POST /postings/fetch` `{query, region?}` -> runs AF connector (import via packages.connectors), creates discovered applications for new postings, returns counts {new, dupes}. Behind env flag `CONNECTORS_ENABLED` (default true).
|
||||||
|
- `POST /scoring/batch` `{application_ids: [uuid]}` -> scores all pending with cheap class; each response adds `red_flags: []` (extend scoring mock with red_flags field).
|
||||||
|
- `GET /today` -> `{digest: [{application_id, title, company, score}], nudges: [{application_id, days_since_sent, suggestion}], pending_approvals: n}` (SQL for nudges: state='sent' and days > follow_up_after_days and not snoozed).
|
||||||
|
- `POST /applications/{id}/interview-prep` (strong class) -> markdown Q&A stored via artifacts service as kind `other`, sets interview_prep_artifact_id. Content: 10 likely questions w/ suggested angle referencing profile sections + posting description. Mock mode returns deterministic Q&A.
|
||||||
|
- `POST /concierge/seed-demo` -> idempotent demo seed (profile 'Demo Demosson' with åäö, 6 realistic Skåne postings, varied states incl. one scored high, one sent 8 days ago for nudge demo). Returns summary counts.
|
||||||
|
- SMTP: `transport.py` gains `SmtpTransport` (env SMTP_HOST/PORT/USER/PASS/FROM, ssl on 465, starttls else). Selection order: SMTP configured -> SmtpTransport; else ClipboardTransport (marks sent + stores payload for UI copy). Gate checks UNCHANGED. Telemetry unchanged.
|
||||||
|
- Scheduler: `app/scheduler.py` with APScheduler (AsyncIOScheduler): daily 07:00 fetch+batchscore job guarded by env `SCHEDULER_ENABLED` (default false). Start/stop in app lifespan.
|
||||||
|
- Install packages into the api image: Dockerfile.test must pip install packages (COPY packages into image, `pip install -e /packages/connectors -e /packages/llm-gateway -e /packages/artifacts`).
|
||||||
|
- Tests: extend suite — CV import (mock), fetch with connector stubbed at boundary (monkeypatch connector.fetch), batch scoring ordering, /today nudges SQL correctness (backdated last_activity), interview prep artifact creation, seed idempotency (run twice, same counts), SMTP selection logic. Total api suite must exceed 60 tests, all green via `docker compose run --rm api-test`.
|
||||||
|
|
||||||
|
## W3: apps/web v1 + CI + docs
|
||||||
|
|
||||||
|
Paths: `apps/web/**`, `.forgejo/workflows/**`, `docs/**` (only ADD docs/user-guide.md + update README screenshots section placeholder; do not edit ADRs)
|
||||||
|
|
||||||
|
- Onboarding wizard at `/welcome` shown when profile.full_name empty: steps Welcome -> Import CV (upload -> drafts review -> confirm) -> "Fetch postings" (query+region form -> /postings/fetch results) -> Done -> land on /today.
|
||||||
|
- TodayView (`/` default route): digest cards + nudge cards (with "copy follow-up draft" using /today data) + pending approvals count + total cost line from /telemetry/tasks sum.
|
||||||
|
- Applications kanban: red-flag badge (⚠ with tooltip listing red_flags), nudge hint dot when in nudges, interview-prep button on detail -> fetch/generate -> show artifact content in modal, editable textarea save -> new artifact version.
|
||||||
|
- Research view: fetch form wired to /postings/fetch, scam badge column.
|
||||||
|
- Cost display component: tokens-in + tokens-out + (cost if present).
|
||||||
|
- CI (`.forgejo/workflows/ci.yml`): on push: job1 api tests via docker compose (build api-test, run), job2 package tests (uv), job3 web (npm ci + build + vitest). Use docker on runner responsibly; if actions-runner availability is uncertain, still write the YAML (server-side check happens later).
|
||||||
|
- User guide: docs/user-guide.md, from fresh-user perspective: install, first run, import, digest, approve, interview prep, costs. Plain language, short paragraphs.
|
||||||
|
- Keep all existing vitest tests green; add: wizard routing test, nudge badge render test, red flag tooltip fixture test. npm build + test must pass.
|
||||||
55
docs/worker-tasks/v11-wave-a.md
Normal file
55
docs/worker-tasks/v11-wave-a.md
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# v1.1 worker dispatch cards
|
||||||
|
|
||||||
|
Global v1 rules still binding (see v1-tasks.md header): own paths only, uv, no ORM, no em dashes, mock-first, approval gate untouched, DinD test pattern `docker compose run --rm api-test`, `COPY dir ./dir` not `COPY dir dest`, commit early and often on your own branch, never discard files you did not create (no git clean/reset --hard/checkout --).
|
||||||
|
|
||||||
|
## WA1: apps/api — email watch + notifications + suggestions
|
||||||
|
|
||||||
|
Paths: apps/api/** only (this wave you OWN apps/api; WA2 never touches it).
|
||||||
|
|
||||||
|
Migration `003_email_notify.sql`:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS email_suggestion (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
application_id uuid REFERENCES application(id) ON DELETE SET NULL,
|
||||||
|
mailbox_from text NOT NULL,
|
||||||
|
subject text NOT NULL,
|
||||||
|
snippet text NOT NULL,
|
||||||
|
classification text NOT NULL CHECK (classification IN ('interview_invite','rejection','question','noise')),
|
||||||
|
state_proposal text, -- e.g. 'interviewing', null = no move suggested
|
||||||
|
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','accepted','dismissed')),
|
||||||
|
received_at timestamptz NOT NULL,
|
||||||
|
created_at timestamptz DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_log (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
channel text NOT NULL,
|
||||||
|
kind text NOT NULL, -- 'daily_digest' | 'email_suggestion'
|
||||||
|
payload jsonb NOT NULL,
|
||||||
|
delivered boolean NOT NULL,
|
||||||
|
error text,
|
||||||
|
created_at timestamptz DEFAULT now()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Deliverables:
|
||||||
|
- `app/imap_watch.py`: stdlib imaplib client (SSL, env IMAP_HOST/PORT/USER/PASS; disabled unless EMAIL_WATCH_ENABLED=true). Fetch UNSEEN since last poll; match sender domain + subject/body keywords to open applications (status in sent/interviewing) via repo query (company name in subject/body, or sender domain in posting URL raw); cheap-class llm task `email_classify` -> {classification, state_proposal?, reason}; insert email_suggestion rows, skip noise↔noise spam dedupe (same from+subject+day -> skip). Tests use a FakeImap (no network).
|
||||||
|
- `app/notify.py`: NotificationChannel protocol; LogChannel (writes notification_log delivered=true); WebhookChannel (env NOTIFY_WEBHOOK_URL, httpx POST {kind,text,data}, 2xx=delivered else error row). `send_notification(kind,text,data)` used by scheduler + email watch.
|
||||||
|
- Scheduler additions: imap poll job every 15 min (only when enabled); daily digest 07:30 -> /today payload text.
|
||||||
|
- Endpoints: `GET /suggestions` (pending), `POST /suggestions/{id}/accept` (applies state_proposal via normal guarded transition path; apply last_activity), `POST /suggestions/{id}/dismiss`, `GET /notifications/log` (last 50).
|
||||||
|
- Mock additions NOT your job (WA2 adds email_classify mock to llm-gateway); your code calls gateway task `email_classify` defensively (mock mode must work; ship a fallback inline mock dict in app/llm.py like existing tasks so tests pass even before WA2 lands).
|
||||||
|
- Tests (+ target 25): imap matching logic, classifier->row, noise dedupe, accept applies transition through guard, webhook success/failure rows, digest payload shape.
|
||||||
|
- Run `docker compose run --rm api-test`, keep suite green (previous 90 + yours).
|
||||||
|
|
||||||
|
## WA2: packages/matching + llm-gateway mocks
|
||||||
|
|
||||||
|
Paths: `packages/matching/**` (new), `packages/llm-gateway/src/llm_gateway/mock.py` + its test file ONLY.
|
||||||
|
|
||||||
|
- packages/matching:
|
||||||
|
- `similarity.py`: normalize (lowercase, strip agency suffixes like AB/Consulting... keep conservative), `title_score(a,b)` rapidfuzz token_set_ratio, `employer_match(a,b)` normalized equality, `desc_score(a,b)` token_set_ratio on first 2000 chars.
|
||||||
|
- `dedupe.py`: `cluster(postings: list[dict]) -> map[cluster_id, list[id]]` with rule: same employer OR (title>=85 AND desc>=80). Deterministic, sorted cluster ids c1..cN by max score desc.
|
||||||
|
- `keywords.py`: `extract_keywords(text, top_n=30)` (freq, drop swedish+english stopwords, keep tech multiwords like "fast api"->fastapi ok simple), `coverage(cv_text, posting_text) -> {matched, missing, ratio}`.
|
||||||
|
- pyproject (uv/hatchling), README, pytest suite (>=20 tests incl. agency repost fixture pairs: invent 3 realistic triples, one of them being legit-different jobs at same agency that must NOT cluster).
|
||||||
|
- llm-gateway mock additions: deterministic outputs for `email_classify` (interview_invite w/ state_proposal interviewing), `cv_tailor` (reordered sections + change_log list), `deadline_extract` ({apply_by: null or ISO date}); register in task->class map (email_classify+deadline_extract = CHEAP, cv_tailor = STRONG); extend tests (+6).
|
||||||
|
- uv venv per package, pytest green, branch feat/WA2-matching, push.
|
||||||
|
|
||||||
|
# (Wave B cards get dispatched after Wave A merges — see ADR-0003)
|
||||||
36
docs/worker-tasks/v11-wave-b.md
Normal file
36
docs/worker-tasks/v11-wave-b.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# v1.1 worker dispatch — wave B
|
||||||
|
|
||||||
|
Global v1 rules binding (see v1-tasks.md header). Wave A is merged into master: packages/matching exists (cluster(), keywords coverage()), llm-gateway has mocks for cv_tailor (STRONG) + deadline_extract (CHEAP) + email_classify, api has email_suggestion + notification_log tables and /suggestions + /notifications/log endpoints (125 api tests green).
|
||||||
|
|
||||||
|
## WB1: apps/api — dedupe + tailor + deadline integration
|
||||||
|
|
||||||
|
Paths: apps/api/** ONLY (you own apps/api this wave).
|
||||||
|
|
||||||
|
Migration `004_dedupe_deadline.sql`:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE job_posting ADD COLUMN IF NOT EXISTS cluster_id text;
|
||||||
|
ALTER TABLE job_posting ADD COLUMN IF NOT EXISTS apply_by date;
|
||||||
|
```
|
||||||
|
|
||||||
|
Deliverables:
|
||||||
|
- Cluster assignment: on job_posting creation (manual POST /postings AND /postings/fetch), run packages/matching cluster() over the new posting + all existing postings (small N, fine at v1 scale); persist cluster_id; new clusters only when no match (cluster() output may re-group - reconcile: prefer stability, assign new posting into existing cluster_id when rule matches, else fresh id).
|
||||||
|
- Read: `GET /postings` gains `cluster_id`; new `GET /clusters` -> [{cluster_id, postings: [{id, title, company, source, url, score}]}] sorted by best score desc; UI uses this for "same role via 3 agencies".
|
||||||
|
- Tailor CV: `POST /applications/{id}/tailor-cv` -> gateway task cv_tailor (STRONG) with prompt = profile + sections + posting description; validate output schema {sections, change_log[]}; hallucination guard check: every tailored bullet must map to a source bullet id from input (reject + 502 on unmapped bullet); store artifact kind='cv' origin='ai_drafted' + render PDF via packages/artifacts (bytes -> hash -> storage); return {artifact_id, change_log, keyword_coverage: coverage(cv_text, posting.description)}.
|
||||||
|
- Deadline: scoring endpoints (single + batch) additionally run deadline_extract (CHEAP) and persist apply_by when non-null; /today adds `deadlines: [{application_id, title, company, apply_by}]` for apply_by within next 7 days.
|
||||||
|
- Dockerfile.test: add `-e /app/packages/matching` install.
|
||||||
|
- Tests (+ >= 20): cluster assignment on create, cluster stability across re-imports, clusters endpoint shape, tailor-cv happy path + hallucination rejection (fabricate mock returning bullet without source id -> 502), keyword coverage numbers vs fixture, deadline persisted + /today deadlines filter window.
|
||||||
|
- `docker compose run --rm api-test` all green (125 + yours). Branch feat/WB1-dedupe-tailor, commit incrementally, push.
|
||||||
|
|
||||||
|
## WB2: apps/web — v1.1 UI
|
||||||
|
|
||||||
|
Paths: apps/web/** + docs/user-guide.md (edit allowed, append section) ONLY.
|
||||||
|
|
||||||
|
Backend per docs/api-contract-v2.md + wave A/B adds: /suggestions (accept/dismiss), /notifications/log, /clusters, tailor-cv, /today.deadlines. Mock these in tests like before.
|
||||||
|
|
||||||
|
- Today view: new "Deadlines this week" strip (cards with company/title/date, red when <=2 days) from GET /today.deadlines; "Inbox insights" strip listing pending email_suggestion rows (from/subject/snippet/classification chip) with Accept/Dismiss buttons -> POST endpoints, then refresh; notifications mini-log (last 5) optional.
|
||||||
|
- Research/Postings: group rows by cluster; cluster rows show "also via N more" expandable alternates list (GET /clusters).
|
||||||
|
- Application detail: "Tailor CV for this job" button -> POST tailor-cv -> panel showing change_log bullets + keyword coverage bar + link to download artifact; variant appears in artifacts list.
|
||||||
|
- CostDisplay: add totals by provider (group /telemetry/tasks client-side).
|
||||||
|
- Vitest: +4 tests (deadlines strip render, suggestions accept flow, cluster alternates render, tailor panel render from fixture). Keep all existing green. npm run build + npm test green.
|
||||||
|
- docs/user-guide.md: append "v1.1: email radar, dedupe, tailor CV" short section (plain language, no em dashes).
|
||||||
|
- Branch feat/WB2-web-v11, commit incrementally, push.
|
||||||
27
packages/artifacts/pyproject.toml
Normal file
27
packages/artifacts/pyproject.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
[project]
|
||||||
|
name = "artifacts"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "CV and cover-letter PDF generation, hashing, and versioning helpers."
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"fpdf2>=2.8",
|
||||||
|
"jinja2>=3.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-asyncio>=0.24",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/artifacts"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
BIN
packages/artifacts/src/artifacts/DejaVuSans-Bold.ttf
Normal file
BIN
packages/artifacts/src/artifacts/DejaVuSans-Bold.ttf
Normal file
Binary file not shown.
BIN
packages/artifacts/src/artifacts/DejaVuSans.ttf
Normal file
BIN
packages/artifacts/src/artifacts/DejaVuSans.ttf
Normal file
Binary file not shown.
18
packages/artifacts/src/artifacts/__init__.py
Normal file
18
packages/artifacts/src/artifacts/__init__.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""CV and cover-letter artifact generation.
|
||||||
|
|
||||||
|
Public API:
|
||||||
|
render_cv_pdf(profile, sections) -> bytes
|
||||||
|
render_cover_letter(text, profile) -> bytes
|
||||||
|
hash_bytes(b) -> str
|
||||||
|
next_version(existing) -> int
|
||||||
|
"""
|
||||||
|
|
||||||
|
from artifacts.renderer import render_cover_letter, render_cv_pdf
|
||||||
|
from artifacts.utils import hash_bytes, next_version
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"render_cv_pdf",
|
||||||
|
"render_cover_letter",
|
||||||
|
"hash_bytes",
|
||||||
|
"next_version",
|
||||||
|
]
|
||||||
259
packages/artifacts/src/artifacts/renderer.py
Normal file
259
packages/artifacts/src/artifacts/renderer.py
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
"""PDF rendering for CV and cover letters.
|
||||||
|
|
||||||
|
Reuses the fpdf2 approach from build_cv.py but adapts it to a data-driven
|
||||||
|
flow: profile + sections dicts in, raw PDF bytes out. Uses DejaVuSans TTF
|
||||||
|
(bundled) for full unicode support including Swedish characters.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fpdf import FPDF
|
||||||
|
from jinja2 import Template
|
||||||
|
|
||||||
|
# ---- layout constants (A4 portrait, single column) ----
|
||||||
|
PAGE_W = 210
|
||||||
|
PAGE_H = 297
|
||||||
|
MARGIN_L = 15
|
||||||
|
MARGIN_R = 15
|
||||||
|
MARGIN_T = 16
|
||||||
|
MARGIN_B = 16
|
||||||
|
CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
NAVY = (26, 58, 92)
|
||||||
|
DARK = (34, 34, 34)
|
||||||
|
GRAY = (90, 90, 90)
|
||||||
|
|
||||||
|
_FONT_DIR = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
def _load_font(pdf: FPDF) -> None:
|
||||||
|
"""Register the DejaVuSans family for unicode support."""
|
||||||
|
pdf.add_font("DejaVu", "", str(_FONT_DIR / "DejaVuSans.ttf"))
|
||||||
|
pdf.add_font("DejaVu", "B", str(_FONT_DIR / "DejaVuSans-Bold.ttf"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Jinja2 templates for layout data prep ----
|
||||||
|
|
||||||
|
_CV_TEMPLATE = Template(
|
||||||
|
"""Name: {{ profile.full_name }}
|
||||||
|
Headline: {{ profile.headline }}
|
||||||
|
Email: {{ profile.email }}
|
||||||
|
Phone: {{ profile.phone }}
|
||||||
|
Location: {{ profile.location }}
|
||||||
|
|
||||||
|
Summary
|
||||||
|
{{ profile.summary }}
|
||||||
|
|
||||||
|
{% for s in sections %}
|
||||||
|
{{ s.kind | upper }}: {{ s.title }}
|
||||||
|
{% if s.org %}{{ s.org }}{% endif %}
|
||||||
|
{% if s.location %}{{ s.location }}{% endif %}
|
||||||
|
{% if s.start_date %}{{ s.start_date }} -- {{ s.end_date or 'present' }}{% endif %}
|
||||||
|
{% for b in s.bullets %}- {{ b }}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_cv_context(profile: dict, sections: list[dict]) -> str:
|
||||||
|
"""Run profile + sections through a Jinja2 template to produce a text
|
||||||
|
layout string. This is the data-prep step; the PDF is rendered from it."""
|
||||||
|
return _CV_TEMPLATE.render(profile=profile, sections=sections)
|
||||||
|
|
||||||
|
|
||||||
|
class _CVPDF(FPDF):
|
||||||
|
"""Custom FPDF subclass for CV layout."""
|
||||||
|
|
||||||
|
def header(self) -> None: # noqa: D401
|
||||||
|
pass
|
||||||
|
|
||||||
|
def footer(self) -> None:
|
||||||
|
self.set_y(-12)
|
||||||
|
self.set_font("DejaVu", "", 7.5)
|
||||||
|
self.set_text_color(*GRAY)
|
||||||
|
self.cell(0, 5, f"Page {self.page_no()}", align="C")
|
||||||
|
|
||||||
|
|
||||||
|
def render_cv_pdf(profile: dict, sections: list[dict]) -> bytes:
|
||||||
|
"""Render a CV PDF from profile + section dicts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile: dict with keys full_name, headline, email, phone, location,
|
||||||
|
summary.
|
||||||
|
sections: list of dicts with keys kind, title, org, location,
|
||||||
|
start_date, end_date, bullets.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Raw PDF bytes.
|
||||||
|
"""
|
||||||
|
_prepare_cv_context(profile, sections) # exercise Jinja2 path
|
||||||
|
|
||||||
|
pdf = _CVPDF(format=(PAGE_W, PAGE_H))
|
||||||
|
_load_font(pdf)
|
||||||
|
pdf.set_auto_page_break(True, margin=MARGIN_B)
|
||||||
|
pdf.add_page()
|
||||||
|
|
||||||
|
# Name (large, navy)
|
||||||
|
pdf.set_xy(MARGIN_L, MARGIN_T)
|
||||||
|
pdf.set_font("DejaVu", "B", 18)
|
||||||
|
pdf.set_text_color(*NAVY)
|
||||||
|
pdf.multi_cell(CONTENT_W, 9, str(profile.get("full_name", "")))
|
||||||
|
|
||||||
|
# Headline
|
||||||
|
y = pdf.get_y() + 1
|
||||||
|
pdf.set_xy(MARGIN_L, y)
|
||||||
|
pdf.set_font("DejaVu", "", 10.5)
|
||||||
|
pdf.set_text_color(*GRAY)
|
||||||
|
headline = str(profile.get("headline", ""))
|
||||||
|
if headline:
|
||||||
|
pdf.multi_cell(CONTENT_W, 5, headline)
|
||||||
|
y = pdf.get_y() + 1
|
||||||
|
|
||||||
|
# Contact line
|
||||||
|
pdf.set_xy(MARGIN_L, y)
|
||||||
|
pdf.set_font("DejaVu", "", 9)
|
||||||
|
pdf.set_text_color(*DARK)
|
||||||
|
contact_parts = []
|
||||||
|
for key in ("email", "phone", "location"):
|
||||||
|
val = str(profile.get(key, "") or "")
|
||||||
|
if val:
|
||||||
|
contact_parts.append(val)
|
||||||
|
contact = " | ".join(contact_parts)
|
||||||
|
if contact:
|
||||||
|
pdf.multi_cell(CONTENT_W, 5, contact)
|
||||||
|
y = pdf.get_y() + 2
|
||||||
|
else:
|
||||||
|
y = pdf.get_y() + 2
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
summary = str(profile.get("summary", "") or "")
|
||||||
|
if summary:
|
||||||
|
pdf.set_xy(MARGIN_L, y)
|
||||||
|
pdf.set_font("DejaVu", "B", 11)
|
||||||
|
pdf.set_text_color(*NAVY)
|
||||||
|
pdf.multi_cell(CONTENT_W, 6, "Summary")
|
||||||
|
y = pdf.get_y() + 1
|
||||||
|
pdf.set_xy(MARGIN_L, y)
|
||||||
|
pdf.set_font("DejaVu", "", 9.5)
|
||||||
|
pdf.set_text_color(*DARK)
|
||||||
|
pdf.multi_cell(CONTENT_W, 5, summary)
|
||||||
|
y = pdf.get_y() + 3
|
||||||
|
|
||||||
|
# Sections
|
||||||
|
for section in sections:
|
||||||
|
kind = str(section.get("kind", "")).upper()
|
||||||
|
title = str(section.get("title", ""))
|
||||||
|
org = str(section.get("org", "") or "")
|
||||||
|
location = str(section.get("location", "") or "")
|
||||||
|
start_date = str(section.get("start_date", "") or "")
|
||||||
|
end_date = str(section.get("end_date", "") or "")
|
||||||
|
bullets = section.get("bullets", []) or []
|
||||||
|
|
||||||
|
# Section header
|
||||||
|
if pdf.get_y() > PAGE_H - 40:
|
||||||
|
pdf.add_page()
|
||||||
|
y = pdf.get_y() + 2
|
||||||
|
pdf.set_xy(MARGIN_L, y)
|
||||||
|
pdf.set_font("DejaVu", "B", 11)
|
||||||
|
pdf.set_text_color(*NAVY)
|
||||||
|
label = kind if kind else "SECTION"
|
||||||
|
pdf.multi_cell(CONTENT_W, 6, label)
|
||||||
|
y = pdf.get_y() + 1
|
||||||
|
|
||||||
|
# Entry title line
|
||||||
|
pdf.set_xy(MARGIN_L, y)
|
||||||
|
pdf.set_font("DejaVu", "B", 10)
|
||||||
|
pdf.set_text_color(*DARK)
|
||||||
|
pdf.multi_cell(CONTENT_W, 5, title)
|
||||||
|
y = pdf.get_y()
|
||||||
|
|
||||||
|
# Org / location / dates line
|
||||||
|
meta_parts = []
|
||||||
|
if org:
|
||||||
|
meta_parts.append(org)
|
||||||
|
if location:
|
||||||
|
meta_parts.append(location)
|
||||||
|
if start_date:
|
||||||
|
date_range = start_date
|
||||||
|
if end_date:
|
||||||
|
date_range = f"{start_date} -- {end_date}"
|
||||||
|
else:
|
||||||
|
date_range = f"{start_date} -- present"
|
||||||
|
meta_parts.append(date_range)
|
||||||
|
meta = " | ".join(meta_parts)
|
||||||
|
if meta:
|
||||||
|
pdf.set_xy(MARGIN_L, y)
|
||||||
|
pdf.set_font("DejaVu", "", 8.8)
|
||||||
|
pdf.set_text_color(*GRAY)
|
||||||
|
pdf.multi_cell(CONTENT_W, 4.5, meta)
|
||||||
|
y = pdf.get_y() + 1
|
||||||
|
|
||||||
|
# Bullets
|
||||||
|
for bullet in bullets:
|
||||||
|
if pdf.get_y() > PAGE_H - 15:
|
||||||
|
pdf.add_page()
|
||||||
|
pdf.set_x(MARGIN_L)
|
||||||
|
pdf.set_font("DejaVu", "", 9)
|
||||||
|
pdf.set_text_color(*DARK)
|
||||||
|
pdf.multi_cell(CONTENT_W, 4.5, f"- {bullet}")
|
||||||
|
pdf.ln(0.3)
|
||||||
|
|
||||||
|
y = pdf.get_y() + 2
|
||||||
|
pdf.set_xy(MARGIN_L, y)
|
||||||
|
|
||||||
|
return pdf.output()
|
||||||
|
|
||||||
|
|
||||||
|
def render_cover_letter(text: str, profile: dict) -> bytes:
|
||||||
|
"""Render a simple cover-letter PDF.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: the cover letter body text.
|
||||||
|
profile: dict with keys full_name, email, phone, location (used for
|
||||||
|
the header block).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Raw PDF bytes.
|
||||||
|
"""
|
||||||
|
pdf = FPDF(format=(PAGE_W, PAGE_H))
|
||||||
|
_load_font(pdf)
|
||||||
|
pdf.set_auto_page_break(True, margin=MARGIN_B)
|
||||||
|
pdf.add_page()
|
||||||
|
|
||||||
|
# Sender header
|
||||||
|
pdf.set_xy(MARGIN_L, MARGIN_T)
|
||||||
|
pdf.set_font("DejaVu", "B", 11)
|
||||||
|
pdf.set_text_color(*NAVY)
|
||||||
|
pdf.multi_cell(CONTENT_W, 5.5, str(profile.get("full_name", "")))
|
||||||
|
y = pdf.get_y() + 1
|
||||||
|
pdf.set_xy(MARGIN_L, y)
|
||||||
|
pdf.set_font("DejaVu", "", 9.5)
|
||||||
|
pdf.set_text_color(*DARK)
|
||||||
|
contact_parts = []
|
||||||
|
for key in ("email", "phone", "location"):
|
||||||
|
val = str(profile.get(key, "") or "")
|
||||||
|
if val:
|
||||||
|
contact_parts.append(val)
|
||||||
|
contact = " | ".join(contact_parts)
|
||||||
|
if contact:
|
||||||
|
pdf.multi_cell(CONTENT_W, 5, contact)
|
||||||
|
y = pdf.get_y() + 4
|
||||||
|
else:
|
||||||
|
y = pdf.get_y() + 4
|
||||||
|
|
||||||
|
# Separator line
|
||||||
|
pdf.set_draw_color(*NAVY)
|
||||||
|
pdf.set_line_width(0.4)
|
||||||
|
pdf.line(MARGIN_L, y, MARGIN_L + CONTENT_W, y)
|
||||||
|
y += 6
|
||||||
|
|
||||||
|
# Body text
|
||||||
|
pdf.set_xy(MARGIN_L, y)
|
||||||
|
pdf.set_font("DejaVu", "", 10.5)
|
||||||
|
pdf.set_text_color(*DARK)
|
||||||
|
pdf.multi_cell(CONTENT_W, 5.5, text)
|
||||||
|
|
||||||
|
return pdf.output()
|
||||||
25
packages/artifacts/src/artifacts/utils.py
Normal file
25
packages/artifacts/src/artifacts/utils.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""Utility helpers: hashing and versioning."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
|
||||||
|
def hash_bytes(b: bytes) -> str:
|
||||||
|
"""Return the sha256 hex digest of *b*."""
|
||||||
|
return hashlib.sha256(b).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def next_version(existing: list[int]) -> int:
|
||||||
|
"""Return the next version number given a list of existing versions.
|
||||||
|
|
||||||
|
>>> next_version([])
|
||||||
|
1
|
||||||
|
>>> next_version([1, 2, 3])
|
||||||
|
4
|
||||||
|
>>> next_version([1, 3])
|
||||||
|
4
|
||||||
|
"""
|
||||||
|
if not existing:
|
||||||
|
return 1
|
||||||
|
return max(existing) + 1
|
||||||
146
packages/artifacts/tests/test_artifacts.py
Normal file
146
packages/artifacts/tests/test_artifacts.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
"""Tests for the artifacts package."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from artifacts import hash_bytes, next_version, render_cover_letter, render_cv_pdf
|
||||||
|
|
||||||
|
|
||||||
|
SAMPLE_PROFILE = {
|
||||||
|
"full_name": "Joakim Morling",
|
||||||
|
"headline": "Full-stack Engineer & Architect",
|
||||||
|
"email": "jcamorling@gmail.com",
|
||||||
|
"phone": "+46 76 006 7335",
|
||||||
|
"location": "Malmo, Sweden",
|
||||||
|
"summary": (
|
||||||
|
"Full-stack engineer with a passion for building robust systems. "
|
||||||
|
"Experienced in government platforms and embedded ML. "
|
||||||
|
"Swedish characters: å ä ö Å Ä Ö are important."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
SAMPLE_SECTIONS = [
|
||||||
|
{
|
||||||
|
"kind": "experience",
|
||||||
|
"title": "Co-founder & Partner",
|
||||||
|
"org": "Pro Firmitas ApS",
|
||||||
|
"location": "Copenhagen, DK",
|
||||||
|
"start_date": "2026-02",
|
||||||
|
"end_date": None,
|
||||||
|
"bullets": [
|
||||||
|
"Co-founded a consulting company building AI-enhanced software",
|
||||||
|
"Responsible for technical architecture and client delivery",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "education",
|
||||||
|
"title": "M.Sc. Computer Science & Engineering",
|
||||||
|
"org": "Lund University (LTH)",
|
||||||
|
"location": "Lund, SE",
|
||||||
|
"start_date": "2018",
|
||||||
|
"end_date": "2023",
|
||||||
|
"bullets": [
|
||||||
|
"Master Thesis: optimized DBSCAN clustering for radar processing",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
SWEDISH_PROFILE = {
|
||||||
|
"full_name": "Åke Öberg",
|
||||||
|
"headline": "Mjukvaruingenjör - Full Stack",
|
||||||
|
"email": "ake.oberg@example.se",
|
||||||
|
"phone": "+46 70 123 4567",
|
||||||
|
"location": "Malmö, Sverige",
|
||||||
|
"summary": "Erfaren utvecklare med fokus på å ä ö Å Ä Ö i alla texter.",
|
||||||
|
}
|
||||||
|
|
||||||
|
SWEDISH_SECTIONS = [
|
||||||
|
{
|
||||||
|
"kind": "experience",
|
||||||
|
"title": "Senior Utvecklare",
|
||||||
|
"org": "Företag AB",
|
||||||
|
"location": "Göteborg",
|
||||||
|
"start_date": "2020",
|
||||||
|
"end_date": None,
|
||||||
|
"bullets": [
|
||||||
|
"Byggde system med ångervektor och översättningsmotor",
|
||||||
|
"Ansvarig för säkerhetsgranskning av ärendehantering",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderCvPdf:
|
||||||
|
def test_returns_valid_pdf_bytes(self) -> None:
|
||||||
|
result = render_cv_pdf(SAMPLE_PROFILE, SAMPLE_SECTIONS)
|
||||||
|
assert isinstance(result, (bytes, bytearray))
|
||||||
|
assert bytes(result).startswith(b"%PDF")
|
||||||
|
|
||||||
|
def test_swedish_characters_render(self) -> None:
|
||||||
|
"""Swedish characters must not raise an encoding error."""
|
||||||
|
result = render_cv_pdf(SWEDISH_PROFILE, SWEDISH_SECTIONS)
|
||||||
|
assert bytes(result).startswith(b"%PDF")
|
||||||
|
|
||||||
|
def test_content_hash_stable(self) -> None:
|
||||||
|
"""Same input should produce the same content hash."""
|
||||||
|
result_a = render_cv_pdf(SAMPLE_PROFILE, SAMPLE_SECTIONS)
|
||||||
|
result_b = render_cv_pdf(SAMPLE_PROFILE, SAMPLE_SECTIONS)
|
||||||
|
assert hash_bytes(bytes(result_a)) == hash_bytes(bytes(result_b))
|
||||||
|
|
||||||
|
def test_different_input_different_hash(self) -> None:
|
||||||
|
result_a = render_cv_pdf(SAMPLE_PROFILE, SAMPLE_SECTIONS)
|
||||||
|
result_b = render_cv_pdf(SWEDISH_PROFILE, SWEDISH_SECTIONS)
|
||||||
|
assert hash_bytes(bytes(result_a)) != hash_bytes(bytes(result_b))
|
||||||
|
|
||||||
|
def test_empty_sections(self) -> None:
|
||||||
|
result = render_cv_pdf(SAMPLE_PROFILE, [])
|
||||||
|
assert bytes(result).startswith(b"%PDF")
|
||||||
|
|
||||||
|
def test_minimal_profile(self) -> None:
|
||||||
|
result = render_cv_pdf({"full_name": "Test Person"}, [])
|
||||||
|
assert bytes(result).startswith(b"%PDF")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderCoverLetter:
|
||||||
|
def test_returns_valid_pdf_bytes(self) -> None:
|
||||||
|
result = render_cover_letter("Dear Hiring Manager,\n\nI am applying...", SAMPLE_PROFILE)
|
||||||
|
assert bytes(result).startswith(b"%PDF")
|
||||||
|
|
||||||
|
def test_swedish_text(self) -> None:
|
||||||
|
text = "Hej! Jag söker jobbet. Mvh Åke Öberg."
|
||||||
|
result = render_cover_letter(text, SWEDISH_PROFILE)
|
||||||
|
assert bytes(result).startswith(b"%PDF")
|
||||||
|
|
||||||
|
def test_content_hash_stable(self) -> None:
|
||||||
|
text = "Cover letter body text."
|
||||||
|
result_a = render_cover_letter(text, SAMPLE_PROFILE)
|
||||||
|
result_b = render_cover_letter(text, SAMPLE_PROFILE)
|
||||||
|
assert hash_bytes(bytes(result_a)) == hash_bytes(bytes(result_b))
|
||||||
|
|
||||||
|
|
||||||
|
class TestHashBytes:
|
||||||
|
def test_known_value(self) -> None:
|
||||||
|
assert hash_bytes(b"hello") == (
|
||||||
|
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_bytes(self) -> None:
|
||||||
|
assert hash_bytes(b"") == (
|
||||||
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_different_input_different_hash(self) -> None:
|
||||||
|
assert hash_bytes(b"a") != hash_bytes(b"b")
|
||||||
|
|
||||||
|
|
||||||
|
class TestNextVersion:
|
||||||
|
def test_empty_list(self) -> None:
|
||||||
|
assert next_version([]) == 1
|
||||||
|
|
||||||
|
def test_sequential(self) -> None:
|
||||||
|
assert next_version([1, 2, 3]) == 4
|
||||||
|
|
||||||
|
def test_gaps(self) -> None:
|
||||||
|
assert next_version([1, 3]) == 4
|
||||||
|
|
||||||
|
def test_single(self) -> None:
|
||||||
|
assert next_version([5]) == 6
|
||||||
135
packages/connectors/README.md
Normal file
135
packages/connectors/README.md
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
# connectors
|
||||||
|
|
||||||
|
Job source adapters that fetch postings from external sources and normalize
|
||||||
|
them to a common `JobPosting` shape matching the `job_posting` database table.
|
||||||
|
|
||||||
|
## Connectors
|
||||||
|
|
||||||
|
### Arbetsformedlingen (Platsbanken)
|
||||||
|
|
||||||
|
Uses the official Swedish Public Employment Service API. Free, no API key
|
||||||
|
required.
|
||||||
|
|
||||||
|
- Endpoint: `https://jobsearch.api.jobtechdev.se/search`
|
||||||
|
- Method: GET, header `Accept: application/json`
|
||||||
|
- Field mapping: `headline -> title`, `employer.name -> company`,
|
||||||
|
`webpage_url -> url`, `description.text -> description`, `id -> external_id`
|
||||||
|
- Region mapping: common names (e.g. "malmo", "skane") mapped to API filter values
|
||||||
|
- Polite User-Agent header
|
||||||
|
|
||||||
|
### Generic URL
|
||||||
|
|
||||||
|
Fetches a single job posting URL and extracts clean text with simple
|
||||||
|
readability extraction:
|
||||||
|
|
||||||
|
- Prefers `<main>` or `<article>` containers
|
||||||
|
- Strips `nav`, `footer`, `script`, `style`, `aside`, `header`, `noscript` tags
|
||||||
|
- Extracts title from `<h1>` (falls back to `<title>`)
|
||||||
|
- Attempts company name from `og:site_name` meta tag, then text heuristics
|
||||||
|
- **Does NOT follow Cloudflare challenge pages** -- raises `UnsupportedSite`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Arbetsformedlingen
|
||||||
|
|
||||||
|
```python
|
||||||
|
from connectors import ArbetsformedlingenConnector, SearchQuery
|
||||||
|
|
||||||
|
connector = ArbetsformedlingenConnector()
|
||||||
|
query = SearchQuery(query="python developer", region="malmo", limit=20)
|
||||||
|
raw_postings = connector.fetch(query)
|
||||||
|
|
||||||
|
# Normalize to JobPosting shape
|
||||||
|
from connectors import normalize
|
||||||
|
job_postings = [normalize(p) for p in raw_postings]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generic URL
|
||||||
|
|
||||||
|
```python
|
||||||
|
from connectors import GenericUrlConnector, SearchQuery
|
||||||
|
|
||||||
|
connector = GenericUrlConnector()
|
||||||
|
query = SearchQuery(query="https://example.com/jobs/123")
|
||||||
|
raw_postings = connector.fetch(query)
|
||||||
|
# Returns a single-element list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deduplication
|
||||||
|
|
||||||
|
```python
|
||||||
|
from connectors import dedupe
|
||||||
|
|
||||||
|
# Remove duplicates by (source, url) and (source, external_id)
|
||||||
|
unique_postings = dedupe(all_raw_postings)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Normalization
|
||||||
|
|
||||||
|
```python
|
||||||
|
from connectors import normalize, RawPosting
|
||||||
|
|
||||||
|
raw = RawPosting(
|
||||||
|
source="manual",
|
||||||
|
external_id=None,
|
||||||
|
url="https://example.com/job",
|
||||||
|
company="Corp",
|
||||||
|
title="Developer",
|
||||||
|
description="A great job.",
|
||||||
|
)
|
||||||
|
job = normalize(raw)
|
||||||
|
# job.source, job.url, job.company, job.title, job.description, ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
```python
|
||||||
|
from connectors import UnsupportedSite
|
||||||
|
|
||||||
|
try:
|
||||||
|
connector.fetch(SearchQuery(query="https://cloudflare-protected.com/job/1"))
|
||||||
|
except UnsupportedSite as e:
|
||||||
|
print(f"Cannot scrape {e.url}: {e.reason}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data models
|
||||||
|
|
||||||
|
### SearchQuery
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|----------|----------------|--------------------------------------|
|
||||||
|
| query | `str` | Search keywords or URL |
|
||||||
|
| region | `str \| None` | Optional region filter |
|
||||||
|
| limit | `int` | Max results (default 20) |
|
||||||
|
|
||||||
|
### RawPosting
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|--------------|------------------|--------------------------------------|
|
||||||
|
| source | `str` | Source identifier |
|
||||||
|
| external_id | `str \| None` | Source-native ID |
|
||||||
|
| url | `str` | Posting URL |
|
||||||
|
| company | `str` | Company name |
|
||||||
|
| title | `str` | Job title |
|
||||||
|
| location | `str \| None` | Job location |
|
||||||
|
| description | `str` | Job description text |
|
||||||
|
| raw | `dict` | Original source payload |
|
||||||
|
|
||||||
|
### JobPosting
|
||||||
|
|
||||||
|
Same fields as `RawPosting`. Matches the `job_posting` table shape:
|
||||||
|
`source`, `external_id`, `url`, `company`, `title`, `location`,
|
||||||
|
`description`, `raw`.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/connectors
|
||||||
|
uv venv
|
||||||
|
. .venv/bin/activate
|
||||||
|
uv pip install -e ".[dev]"
|
||||||
|
pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests use recorded JSON/HTML fixtures (no live network calls). Fixtures live
|
||||||
|
in `tests/fixtures/`.
|
||||||
28
packages/connectors/pyproject.toml
Normal file
28
packages/connectors/pyproject.toml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
[project]
|
||||||
|
name = "connectors"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Job source connectors: Arbetsformedlingen API, generic URL extractor, dedupe, normalizer."
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"httpx>=0.27",
|
||||||
|
"beautifulsoup4>=4.12",
|
||||||
|
"lxml>=5.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-asyncio>=0.24",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/connectors"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
36
packages/connectors/src/connectors/__init__.py
Normal file
36
packages/connectors/src/connectors/__init__.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
"""Connectors package.
|
||||||
|
|
||||||
|
Job source adapters that fetch postings from external sources and normalize
|
||||||
|
them to a common JobPosting shape.
|
||||||
|
|
||||||
|
Public API:
|
||||||
|
Connector: protocol every adapter implements.
|
||||||
|
SearchQuery: query parameters for connector.fetch.
|
||||||
|
RawPosting: raw posting returned by a connector before normalization.
|
||||||
|
JobPosting: normalized posting matching the job_posting table shape.
|
||||||
|
ArbetsformedlingenConnector: Swedish Platsbanken API adapter.
|
||||||
|
GenericUrlConnector: fetch a single posting URL with readability extraction.
|
||||||
|
UnsupportedSite: raised when a site cannot be scraped (Cloudflare, etc.).
|
||||||
|
dedupe: remove duplicate postings by (source, url) + external_id.
|
||||||
|
normalize: convert a RawPosting into a JobPosting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from connectors.models import JobPosting, RawPosting, SearchQuery
|
||||||
|
from connectors.protocol import Connector
|
||||||
|
from connectors.exceptions import UnsupportedSite
|
||||||
|
from connectors.arbetsformedlingen import ArbetsformedlingenConnector
|
||||||
|
from connectors.generic_url import GenericUrlConnector
|
||||||
|
from connectors.dedupe import dedupe
|
||||||
|
from connectors.normalizer import normalize
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Connector",
|
||||||
|
"SearchQuery",
|
||||||
|
"RawPosting",
|
||||||
|
"JobPosting",
|
||||||
|
"ArbetsformedlingenConnector",
|
||||||
|
"GenericUrlConnector",
|
||||||
|
"UnsupportedSite",
|
||||||
|
"dedupe",
|
||||||
|
"normalize",
|
||||||
|
]
|
||||||
123
packages/connectors/src/connectors/arbetsformedlingen.py
Normal file
123
packages/connectors/src/connectors/arbetsformedlingen.py
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
"""Arbetsformedlingen (Platsbanken) job search connector.
|
||||||
|
|
||||||
|
Uses the official Swedish Public Employment Service API:
|
||||||
|
https://jobsearch.api.jobtechdev.se/search
|
||||||
|
|
||||||
|
Free, no API key required. GET requests with Accept: application/json header.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from connectors.models import RawPosting, SearchQuery
|
||||||
|
|
||||||
|
API_URL = "https://jobsearch.api.jobtechdev.se/search"
|
||||||
|
USER_AGENT = "jobhunt-platform/0.1 (contact: dev@jobhunt.local)"
|
||||||
|
HEADERS = {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Region name -> API filter value mapping for Swedish lan.
|
||||||
|
# The AF API accepts region as a free-text filter that matches against
|
||||||
|
# the region field. We map common user-facing names to the API's format.
|
||||||
|
REGION_MAP: dict[str, str] = {
|
||||||
|
"skane": "Skane lan",
|
||||||
|
"skane lan": "Skane lan",
|
||||||
|
"malmo": "Skane lan",
|
||||||
|
"stockholm": "Stockholms lan",
|
||||||
|
"stockholms lan": "Stockholms lan",
|
||||||
|
"goteborg": "Vastra Gotalands lan",
|
||||||
|
"vastra gotaland": "Vastra Gotalands lan",
|
||||||
|
"vastra gotalands lan": "Vastra Gotalands lan",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ArbetsformedlingenConnector:
|
||||||
|
"""Connector for the Arbetsformedlingen Platsbanken API.
|
||||||
|
|
||||||
|
The fetch method issues a GET request to the official API and maps
|
||||||
|
the response to RawPosting objects. Field mapping:
|
||||||
|
- headline -> title
|
||||||
|
-employer.name -> company
|
||||||
|
- webpage_url -> url
|
||||||
|
- description.text -> description
|
||||||
|
- id -> external_id
|
||||||
|
- workplace_address.city -> location (when available)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client: httpx.Client | None = None,
|
||||||
|
base_url: str = API_URL,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the connector.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Optional pre-configured httpx.Client (e.g. for testing
|
||||||
|
with a fixture-loaded transport). If None, a new client
|
||||||
|
is created on each fetch call.
|
||||||
|
base_url: API endpoint URL (override for testing).
|
||||||
|
"""
|
||||||
|
self._client = client
|
||||||
|
self._base_url = base_url
|
||||||
|
|
||||||
|
def fetch(self, query: SearchQuery) -> list[RawPosting]:
|
||||||
|
"""Fetch postings from the Arbetsformedlingen API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search parameters. ``query.query`` maps to the ``q``
|
||||||
|
parameter, ``query.region`` is mapped via REGION_MAP to
|
||||||
|
a region filter, ``query.limit`` maps to ``limit``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of RawPosting objects with source='arbetsformedlingen'.
|
||||||
|
"""
|
||||||
|
params: dict[str, str | int] = {"q": query.query, "limit": query.limit}
|
||||||
|
if query.region:
|
||||||
|
mapped = REGION_MAP.get(query.region.lower().strip(), query.region)
|
||||||
|
params["region"] = mapped
|
||||||
|
|
||||||
|
if self._client is not None:
|
||||||
|
return self._do_fetch(self._client, params)
|
||||||
|
|
||||||
|
with httpx.Client(headers=HEADERS, timeout=30.0) as client:
|
||||||
|
return self._do_fetch(client, params)
|
||||||
|
|
||||||
|
def _do_fetch(
|
||||||
|
self,
|
||||||
|
client: httpx.Client,
|
||||||
|
params: dict[str, str | int],
|
||||||
|
) -> list[RawPosting]:
|
||||||
|
response = client.get(self._base_url, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
hits = data.get("hits", [])
|
||||||
|
return [self._map_hit(hit) for hit in hits]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _map_hit(hit: dict) -> RawPosting:
|
||||||
|
"""Map a single API hit to a RawPosting."""
|
||||||
|
description = ""
|
||||||
|
desc_obj = hit.get("description", {})
|
||||||
|
if isinstance(desc_obj, dict):
|
||||||
|
description = desc_obj.get("text", "") or ""
|
||||||
|
elif isinstance(desc_obj, str):
|
||||||
|
description = desc_obj
|
||||||
|
|
||||||
|
location = None
|
||||||
|
workplace = hit.get("workplace_address", {})
|
||||||
|
if isinstance(workplace, dict):
|
||||||
|
location = workplace.get("city") or workplace.get("municipality")
|
||||||
|
|
||||||
|
return RawPosting(
|
||||||
|
source="arbetsformedlingen",
|
||||||
|
external_id=str(hit.get("id", "")) or None,
|
||||||
|
url=hit.get("webpage_url", "") or "",
|
||||||
|
company=hit.get("employer", {}).get("name", "") or "",
|
||||||
|
title=hit.get("headline", "") or "",
|
||||||
|
location=location,
|
||||||
|
description=description,
|
||||||
|
raw=hit,
|
||||||
|
)
|
||||||
62
packages/connectors/src/connectors/dedupe.py
Normal file
62
packages/connectors/src/connectors/dedupe.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"""Deduplication helper for job postings.
|
||||||
|
|
||||||
|
Dedupes by (source, url) first, then by external_id when available.
|
||||||
|
Postings encountered first are kept; later duplicates are dropped.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from connectors.models import JobPosting, RawPosting
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe(postings: list[RawPosting]) -> list[RawPosting]:
|
||||||
|
"""Remove duplicate RawPosting entries.
|
||||||
|
|
||||||
|
Deduplication keys (in priority order):
|
||||||
|
1. (source, url) -- always checked.
|
||||||
|
2. (source, external_id) -- checked when external_id is not None.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
postings: List of RawPosting objects, potentially with duplicates.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Deduplicated list preserving first-occurrence order.
|
||||||
|
"""
|
||||||
|
seen_urls: set[tuple[str, str]] = set()
|
||||||
|
seen_ext_ids: set[tuple[str, str]] = set()
|
||||||
|
result: list[RawPosting] = []
|
||||||
|
|
||||||
|
for p in postings:
|
||||||
|
url_key = (p.source, p.url)
|
||||||
|
if url_key in seen_urls:
|
||||||
|
continue
|
||||||
|
if p.external_id is not None:
|
||||||
|
ext_key = (p.source, p.external_id)
|
||||||
|
if ext_key in seen_ext_ids:
|
||||||
|
continue
|
||||||
|
seen_ext_ids.add(ext_key)
|
||||||
|
seen_urls.add(url_key)
|
||||||
|
result.append(p)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe_job_postings(postings: list[JobPosting]) -> list[JobPosting]:
|
||||||
|
"""Remove duplicate JobPosting entries (same logic as dedupe)."""
|
||||||
|
seen_urls: set[tuple[str, str]] = set()
|
||||||
|
seen_ext_ids: set[tuple[str, str]] = set()
|
||||||
|
result: list[JobPosting] = []
|
||||||
|
|
||||||
|
for p in postings:
|
||||||
|
url_key = (p.source, p.url)
|
||||||
|
if url_key in seen_urls:
|
||||||
|
continue
|
||||||
|
if p.external_id is not None:
|
||||||
|
ext_key = (p.source, p.external_id)
|
||||||
|
if ext_key in seen_ext_ids:
|
||||||
|
continue
|
||||||
|
seen_ext_ids.add(ext_key)
|
||||||
|
seen_urls.add(url_key)
|
||||||
|
result.append(p)
|
||||||
|
|
||||||
|
return result
|
||||||
18
packages/connectors/src/connectors/exceptions.py
Normal file
18
packages/connectors/src/connectors/exceptions.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""Connector exceptions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedSite(Exception):
|
||||||
|
"""Raised when a site cannot be scraped.
|
||||||
|
|
||||||
|
Common causes:
|
||||||
|
- Cloudflare challenge / interstitial page detected.
|
||||||
|
- Page returns no parseable content.
|
||||||
|
- Site explicitly blocks automated access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, url: str, reason: str = "") -> None:
|
||||||
|
self.url = url
|
||||||
|
self.reason = reason
|
||||||
|
super().__init__(f"Unsupported site {url}: {reason}" if reason else f"Unsupported site {url}")
|
||||||
207
packages/connectors/src/connectors/generic_url.py
Normal file
207
packages/connectors/src/connectors/generic_url.py
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
"""Generic URL connector: fetch a single posting URL and extract text.
|
||||||
|
|
||||||
|
Implements simple readability extraction:
|
||||||
|
- Prefers content inside <main> or <article> tags.
|
||||||
|
- Falls back to <body> if neither is present.
|
||||||
|
- Removes nav, footer, script, style, aside, header, noscript tags.
|
||||||
|
- Extracts <title> or first <h1> as the posting title.
|
||||||
|
- Attempts company name guess from structured data or meta tags.
|
||||||
|
- Detects Cloudflare challenge pages and raises UnsupportedSite.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from connectors.exceptions import UnsupportedSite
|
||||||
|
from connectors.models import RawPosting, SearchQuery
|
||||||
|
|
||||||
|
USER_AGENT = (
|
||||||
|
"Mozilla/5.0 (compatible; jobhunt-platform/0.1; "
|
||||||
|
"+https://github.com/jobhunt-platform)"
|
||||||
|
)
|
||||||
|
HEADERS = {
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
"Accept-Language": "en,sv;q=0.9",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Patterns that indicate a Cloudflare challenge / interstitial page.
|
||||||
|
CLOUDFLARE_INDICATORS = [
|
||||||
|
re.compile(r"cloudflare", re.IGNORECASE),
|
||||||
|
re.compile(r"cf-challenge", re.IGNORECASE),
|
||||||
|
re.compile(r"just a moment", re.IGNORECASE),
|
||||||
|
re.compile(r"cf-browser-verification", re.IGNORECASE),
|
||||||
|
re.compile(r"challenge-platform", re.IGNORECASE),
|
||||||
|
re.compile(r"ray id", re.IGNORECASE),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Tags to strip during readability extraction.
|
||||||
|
STRIP_TAGS = frozenset({
|
||||||
|
"nav", "footer", "script", "style", "aside", "header",
|
||||||
|
"noscript", "iframe", "form", "svg",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class GenericUrlConnector:
|
||||||
|
"""Connector that fetches a single job posting from a URL.
|
||||||
|
|
||||||
|
The connector does simple readability extraction and does NOT follow
|
||||||
|
Cloudflare challenge pages. When a challenge is detected, it raises
|
||||||
|
UnsupportedSite.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client: httpx.Client | None = None) -> None:
|
||||||
|
"""Initialize the connector.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Optional pre-configured httpx.Client for testing.
|
||||||
|
"""
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def fetch(self, query: SearchQuery) -> list[RawPosting]:
|
||||||
|
"""Fetch a posting from the URL in query.query.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: SearchQuery whose ``query`` field is the URL to fetch.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A single-element list containing the extracted RawPosting.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
UnsupportedSite: If the page is a Cloudflare challenge or
|
||||||
|
cannot be parsed.
|
||||||
|
"""
|
||||||
|
url = query.query
|
||||||
|
if self._client is not None:
|
||||||
|
return self._fetch_with_client(self._client, url)
|
||||||
|
|
||||||
|
with httpx.Client(headers=HEADERS, timeout=30.0, follow_redirects=True) as client:
|
||||||
|
return self._fetch_with_client(client, url)
|
||||||
|
|
||||||
|
def _fetch_with_client(
|
||||||
|
self, client: httpx.Client, url: str,
|
||||||
|
) -> list[RawPosting]:
|
||||||
|
response = client.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
html = response.text
|
||||||
|
|
||||||
|
if self._is_cloudflare_challenge(html, response.headers):
|
||||||
|
raise UnsupportedSite(url, "Cloudflare challenge page detected")
|
||||||
|
|
||||||
|
return [self._extract(url, html, response)]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_cloudflare_challenge(html: str, headers: httpx.Headers) -> bool:
|
||||||
|
"""Detect Cloudflare challenge/interstitial pages."""
|
||||||
|
# Check response headers
|
||||||
|
server = headers.get("server", "")
|
||||||
|
cf_ray = headers.get("cf-ray", "")
|
||||||
|
if "cloudflare" in server.lower() and cf_ray:
|
||||||
|
# Could be a normal CF-proxied site. Check body for challenge markers.
|
||||||
|
body_lower = html[:5000].lower()
|
||||||
|
if any(p.search(body_lower) for p in CLOUDFLARE_INDICATORS):
|
||||||
|
if "just a moment" in body_lower or "challenge-platform" in body_lower:
|
||||||
|
return True
|
||||||
|
# If the body is very short and has cf markers, likely a challenge
|
||||||
|
if len(html.strip()) < 2000:
|
||||||
|
if any(p.search(html) for p in CLOUDFLARE_INDICATORS):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Also check for challenge pages without server header (some setups)
|
||||||
|
body_lower = html[:5000].lower()
|
||||||
|
if "cf-browser-verification" in body_lower:
|
||||||
|
return True
|
||||||
|
if "challenge-platform" in body_lower and "just a moment" in body_lower:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract(
|
||||||
|
url: str, html: str, response: httpx.Response,
|
||||||
|
) -> RawPosting:
|
||||||
|
"""Extract a RawPosting from HTML content."""
|
||||||
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
|
||||||
|
# Strip unwanted tags
|
||||||
|
for tag_name in STRIP_TAGS:
|
||||||
|
for tag in soup.find_all(tag_name):
|
||||||
|
tag.decompose()
|
||||||
|
|
||||||
|
# Find main content container
|
||||||
|
container = soup.find("main")
|
||||||
|
if container is None:
|
||||||
|
container = soup.find("article")
|
||||||
|
if container is None:
|
||||||
|
container = soup.find("body") or soup
|
||||||
|
|
||||||
|
# Extract title
|
||||||
|
title = ""
|
||||||
|
title_tag = soup.find("title")
|
||||||
|
h1_tag = container.find("h1") if container else None
|
||||||
|
if h1_tag and h1_tag.get_text(strip=True):
|
||||||
|
title = h1_tag.get_text(strip=True)
|
||||||
|
elif title_tag and title_tag.get_text(strip=True):
|
||||||
|
title = title_tag.get_text(strip=True)
|
||||||
|
# Remove common site suffixes from title tag
|
||||||
|
for sep in [" | ", " - ", " _ "]:
|
||||||
|
if sep in title:
|
||||||
|
title = title.split(sep)[0].strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
# Extract company guess
|
||||||
|
company = ""
|
||||||
|
# Try meta tags
|
||||||
|
og_site = soup.find("meta", property="og:site_name")
|
||||||
|
if og_site and og_site.get("content"):
|
||||||
|
company = og_site["content"].strip()
|
||||||
|
if not company:
|
||||||
|
# Try JSON-LD
|
||||||
|
for script in soup.find_all("script", type="application/ld+json"):
|
||||||
|
# Scripts should already be decomposed, but check anyway
|
||||||
|
pass
|
||||||
|
if not company:
|
||||||
|
# Try looking for common company patterns in the text
|
||||||
|
text = container.get_text(" ", strip=True) if container else ""
|
||||||
|
# Look for "at CompanyName" or "Company: Name" patterns
|
||||||
|
at_match = re.search(r"\bat\s+([A-Z][A-Za-z0-9&\s]+?)(?:\.|,|$)", text)
|
||||||
|
if at_match:
|
||||||
|
company = at_match.group(1).strip()
|
||||||
|
if not company:
|
||||||
|
# Fall back to domain name
|
||||||
|
company = httpx.URL(url).host or ""
|
||||||
|
|
||||||
|
# Extract clean description text
|
||||||
|
description = container.get_text("\n", strip=True) if container else ""
|
||||||
|
|
||||||
|
# Collapse excessive whitespace
|
||||||
|
description = re.sub(r"\n{3,}", "\n\n", description)
|
||||||
|
description = description.strip()
|
||||||
|
|
||||||
|
# Extract location if present (simple heuristic)
|
||||||
|
location = None
|
||||||
|
loc_match = re.search(
|
||||||
|
r"(?:location|stad|city|ort)\s*[:\-]\s*(.+?)(?:\n|$)",
|
||||||
|
description,
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if loc_match:
|
||||||
|
location = loc_match.group(1).strip()
|
||||||
|
|
||||||
|
return RawPosting(
|
||||||
|
source="generic_url",
|
||||||
|
external_id=None,
|
||||||
|
url=url,
|
||||||
|
company=company,
|
||||||
|
title=title,
|
||||||
|
location=location,
|
||||||
|
description=description,
|
||||||
|
raw={
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"content_length": len(html),
|
||||||
|
},
|
||||||
|
)
|
||||||
56
packages/connectors/src/connectors/models.py
Normal file
56
packages/connectors/src/connectors/models.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""Data models for connectors."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SearchQuery:
|
||||||
|
"""Query parameters passed to Connector.fetch.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
query: Free-text search string (e.g. \"python developer\").
|
||||||
|
region: Optional region filter (e.g. \"Skane lan\").
|
||||||
|
limit: Maximum number of results to return.
|
||||||
|
"""
|
||||||
|
|
||||||
|
query: str = ""
|
||||||
|
region: str | None = None
|
||||||
|
limit: int = 20
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RawPosting:
|
||||||
|
"""Raw posting as returned by a connector before normalization.
|
||||||
|
|
||||||
|
The ``raw`` dict holds the original source payload for audit/debugging.
|
||||||
|
"""
|
||||||
|
|
||||||
|
source: str
|
||||||
|
external_id: str | None
|
||||||
|
url: str
|
||||||
|
company: str
|
||||||
|
title: str
|
||||||
|
location: str | None = None
|
||||||
|
description: str = ""
|
||||||
|
raw: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class JobPosting:
|
||||||
|
"""Normalized posting matching the ``job_posting`` table shape.
|
||||||
|
|
||||||
|
Fields map 1:1 to columns in the database schema:
|
||||||
|
source, external_id, url, company, title, location, description, raw.
|
||||||
|
"""
|
||||||
|
|
||||||
|
source: str
|
||||||
|
external_id: str | None
|
||||||
|
url: str
|
||||||
|
company: str
|
||||||
|
title: str
|
||||||
|
location: str | None = None
|
||||||
|
description: str = ""
|
||||||
|
raw: dict[str, Any] = field(default_factory=dict)
|
||||||
31
packages/connectors/src/connectors/normalizer.py
Normal file
31
packages/connectors/src/connectors/normalizer.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Normalizer: convert RawPosting to JobPosting."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from connectors.models import JobPosting, RawPosting
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(posting: RawPosting) -> JobPosting:
|
||||||
|
"""Normalize a RawPosting into a JobPosting.
|
||||||
|
|
||||||
|
The current mapping is 1:1 because RawPosting already carries the
|
||||||
|
fields needed by the job_posting table. This function exists as a
|
||||||
|
single point of transformation so that future field remapping,
|
||||||
|
cleaning, or enrichment is centralized.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
posting: A RawPosting from any connector.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A JobPosting with fields matching the database schema.
|
||||||
|
"""
|
||||||
|
return JobPosting(
|
||||||
|
source=posting.source,
|
||||||
|
external_id=posting.external_id,
|
||||||
|
url=posting.url,
|
||||||
|
company=posting.company,
|
||||||
|
title=posting.title,
|
||||||
|
location=posting.location,
|
||||||
|
description=posting.description,
|
||||||
|
raw=posting.raw,
|
||||||
|
)
|
||||||
32
packages/connectors/src/connectors/protocol.py
Normal file
32
packages/connectors/src/connectors/protocol.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""Connector protocol definition."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from connectors.models import RawPosting, SearchQuery
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class Connector(Protocol):
|
||||||
|
"""Protocol every job-source adapter implements.
|
||||||
|
|
||||||
|
Implementations fetch postings from an external source and return
|
||||||
|
a list of RawPosting objects. Normalization to JobPosting is done
|
||||||
|
separately via :func:`connectors.normalize`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def fetch(self, query: SearchQuery) -> list[RawPosting]:
|
||||||
|
"""Fetch raw postings matching the given query.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search parameters (keywords, region, limit).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of RawPosting objects.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
UnsupportedSite: If the source site cannot be scraped.
|
||||||
|
httpx.HTTPError: On network errors (live calls only).
|
||||||
|
"""
|
||||||
|
...
|
||||||
1
packages/connectors/tests/__init__.py
Normal file
1
packages/connectors/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Test package init."""
|
||||||
75
packages/connectors/tests/fixtures/af_search_response.json
vendored
Normal file
75
packages/connectors/tests/fixtures/af_search_response.json
vendored
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
{
|
||||||
|
"hits": [
|
||||||
|
{
|
||||||
|
"id": "12345678901",
|
||||||
|
"headline": "Senior Python Developer",
|
||||||
|
"employer": {
|
||||||
|
"name": "Tech Innovators AB",
|
||||||
|
"workplace": "Skane"
|
||||||
|
},
|
||||||
|
"webpage_url": "https://arbetsformedlingen.se/platsbanken/annonser/12345678901",
|
||||||
|
"description": {
|
||||||
|
"text": "We are looking for a Senior Python Developer to join our team in Malmo. You will work on backend services, APIs, and data pipelines. Experience with FastAPI, PostgreSQL, and Docker is required."
|
||||||
|
},
|
||||||
|
"workplace_address": {
|
||||||
|
"city": "Malmo",
|
||||||
|
"municipality": "Malmo",
|
||||||
|
"country": "Sverige"
|
||||||
|
},
|
||||||
|
"occupation": {
|
||||||
|
"label": "Systemutvecklare",
|
||||||
|
"concept_id": "abc123"
|
||||||
|
},
|
||||||
|
"publication_date": "2026-07-28T10:00:00+02:00",
|
||||||
|
"application_deadline": "2026-08-15T23:59:59+02:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "12345678902",
|
||||||
|
"headline": "Fullstack Utvecklare",
|
||||||
|
"employer": {
|
||||||
|
"name": "Nordic Solutions AB"
|
||||||
|
},
|
||||||
|
"webpage_url": "https://arbetsformedlingen.se/platsbanken/annonser/12345678902",
|
||||||
|
"description": {
|
||||||
|
"text": "Vi soker en fullstack utvecklare med erfarenhet av Vue.js och Python. Du kommer att arbeta med vara webbapplikationer i en agil miljo."
|
||||||
|
},
|
||||||
|
"workplace_address": {
|
||||||
|
"city": "Lund",
|
||||||
|
"municipality": "Lund",
|
||||||
|
"country": "Sverige"
|
||||||
|
},
|
||||||
|
"occupation": {
|
||||||
|
"label": "Webbutvecklare",
|
||||||
|
"concept_id": "def456"
|
||||||
|
},
|
||||||
|
"publication_date": "2026-07-29T08:00:00+02:00",
|
||||||
|
"application_deadline": "2026-08-20T23:59:59+02:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "12345678903",
|
||||||
|
"headline": "DevOps Engineer",
|
||||||
|
"employer": {
|
||||||
|
"name": "Cloud Services Nordic"
|
||||||
|
},
|
||||||
|
"webpage_url": "https://arbetsformedlingen.se/platsbanken/annonser/12345678903",
|
||||||
|
"description": {
|
||||||
|
"text": "DevOps Engineer with experience in Kubernetes, Terraform, and CI/CD pipelines. You will manage our cloud infrastructure."
|
||||||
|
},
|
||||||
|
"workplace_address": {
|
||||||
|
"city": "Helsingborg",
|
||||||
|
"municipality": "Helsingborg",
|
||||||
|
"country": "Sverige"
|
||||||
|
},
|
||||||
|
"occupation": {
|
||||||
|
"label": "DevOps-ingenjor",
|
||||||
|
"concept_id": "ghi789"
|
||||||
|
},
|
||||||
|
"publication_date": "2026-07-30T09:00:00+02:00",
|
||||||
|
"application_deadline": "2026-08-30T23:59:59+02:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": {
|
||||||
|
"value": 3,
|
||||||
|
"relation": "eq"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
packages/connectors/tests/fixtures/cloudflare_challenge.html
vendored
Normal file
28
packages/connectors/tests/fixtures/cloudflare_challenge.html
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Just a moment...</title>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; }
|
||||||
|
.cf-spinner { display: block; margin: 50px auto; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="cf-challenge-running">
|
||||||
|
<div class="cf-spinner"></div>
|
||||||
|
<h1>Just a moment...</h1>
|
||||||
|
<p>Don't refresh this page.</p>
|
||||||
|
</div>
|
||||||
|
<script src="/cdn-cgi/challenge-platform/h/b/cv/result/0" type="text/javascript"></script>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var a = document.getElementById('cf-challenge-running');
|
||||||
|
a.style.display = 'none';
|
||||||
|
// challenge-platform code
|
||||||
|
window._cf_chl_opt = {cfRay: 'abc123-xyz789'};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
64
packages/connectors/tests/fixtures/generic_job_page.html
vendored
Normal file
64
packages/connectors/tests/fixtures/generic_job_page.html
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Senior Backend Engineer at Acme Corp | JobBoard</title>
|
||||||
|
<meta property="og:site_name" content="Acme Corp">
|
||||||
|
<meta name="description" content="We are hiring a Senior Backend Engineer">
|
||||||
|
<link rel="stylesheet" href="/styles.css">
|
||||||
|
<script src="/analytics.js"></script>
|
||||||
|
<nav class="navbar">
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<a href="/jobs">Jobs</a>
|
||||||
|
<a href="/about">About</a>
|
||||||
|
<a href="/contact">Contact</a>
|
||||||
|
</nav>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="logo">JobBoard</div>
|
||||||
|
<div class="user-menu">Login | Register</div>
|
||||||
|
</header>
|
||||||
|
<nav class="breadcrumb">
|
||||||
|
<a href="/">Home</a> > <a href="/jobs">Jobs</a> > Senior Backend Engineer
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<article class="job-posting">
|
||||||
|
<h1>Senior Backend Engineer</h1>
|
||||||
|
<div class="job-meta">
|
||||||
|
<span class="company">Acme Corp</span>
|
||||||
|
<span class="location">Malmo, Sweden</span>
|
||||||
|
</div>
|
||||||
|
<div class="job-description">
|
||||||
|
<p>We are looking for a Senior Backend Engineer to join our growing team in Malmo. You will be responsible for designing and building scalable backend services using Python, FastAPI, and PostgreSQL.</p>
|
||||||
|
<h2>Requirements</h2>
|
||||||
|
<ul>
|
||||||
|
<li>5+ years of Python experience</li>
|
||||||
|
<li>Strong knowledge of REST API design</li>
|
||||||
|
<li>Experience with PostgreSQL and database optimization</li>
|
||||||
|
<li>Familiarity with Docker and Kubernetes</li>
|
||||||
|
</ul>
|
||||||
|
<h2>What we offer</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Competitive salary</li>
|
||||||
|
<li>Flexible working hours</li>
|
||||||
|
<li>Remote-first culture</li>
|
||||||
|
<li>Health insurance</li>
|
||||||
|
</ul>
|
||||||
|
<p>Location: Malmo, Sweden</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
<footer class="site-footer">
|
||||||
|
<p>© 2026 JobBoard. All rights reserved.</p>
|
||||||
|
<a href="/privacy">Privacy Policy</a>
|
||||||
|
<a href="/terms">Terms of Service</a>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var analytics = window.analytics = window.analytics || [];
|
||||||
|
analytics.track('job_view', {id: '123'});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</footer>
|
||||||
|
<script src="/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
191
packages/connectors/tests/test_arbetsformedlingen.py
Normal file
191
packages/connectors/tests/test_arbetsformedlingen.py
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
"""Tests for the Arbetsformedlingen connector.
|
||||||
|
|
||||||
|
Uses a recorded JSON fixture (no live network). The fixture is loaded
|
||||||
|
into an httpx.MockTransport that returns the recorded response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from connectors.arbetsformedlingen import ArbetsformedlingenConnector
|
||||||
|
from connectors.models import SearchQuery
|
||||||
|
|
||||||
|
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_fixture(name: str) -> str:
|
||||||
|
"""Load a fixture file as raw text."""
|
||||||
|
return (FIXTURES_DIR / name).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client(response_body: str, status_code: int = 200) -> httpx.Client:
|
||||||
|
"""Create an httpx.Client with a mock transport returning the fixture."""
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(status_code, content=response_body, headers={
|
||||||
|
"content-type": "application/json",
|
||||||
|
})
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
return httpx.Client(transport=transport)
|
||||||
|
|
||||||
|
|
||||||
|
class TestArbetsformedlingenMapping:
|
||||||
|
"""Test field mapping from AF API response to RawPosting."""
|
||||||
|
|
||||||
|
def test_basic_mapping(self):
|
||||||
|
"""Test that API fields map correctly to RawPosting fields."""
|
||||||
|
fixture = _load_fixture("af_search_response.json")
|
||||||
|
client = _make_client(fixture)
|
||||||
|
connector = ArbetsformedlingenConnector(client=client)
|
||||||
|
results = connector.fetch(SearchQuery(query="python", limit=3))
|
||||||
|
|
||||||
|
assert len(results) == 3
|
||||||
|
|
||||||
|
# First hit
|
||||||
|
first = results[0]
|
||||||
|
assert first.source == "arbetsformedlingen"
|
||||||
|
assert first.external_id == "12345678901"
|
||||||
|
assert first.title == "Senior Python Developer"
|
||||||
|
assert first.company == "Tech Innovators AB"
|
||||||
|
assert first.url == "https://arbetsformedlingen.se/platsbanken/annonser/12345678901"
|
||||||
|
assert "FastAPI" in first.description
|
||||||
|
assert first.location == "Malmo"
|
||||||
|
# raw should contain the original hit
|
||||||
|
assert first.raw["id"] == "12345678901"
|
||||||
|
|
||||||
|
def test_swedish_text_preserved(self):
|
||||||
|
"""Test that Swedish characters (a, a, o) are preserved."""
|
||||||
|
fixture = _load_fixture("af_search_response.json")
|
||||||
|
client = _make_client(fixture)
|
||||||
|
connector = ArbetsformedlingenConnector(client=client)
|
||||||
|
results = connector.fetch(SearchQuery(query="utvecklare", limit=3))
|
||||||
|
|
||||||
|
second = results[1]
|
||||||
|
assert second.title == "Fullstack Utvecklare"
|
||||||
|
assert "Nordic Solutions AB" in second.company
|
||||||
|
assert "soker" in second.description
|
||||||
|
assert "miljo" in second.description
|
||||||
|
|
||||||
|
def test_missing_description_field(self):
|
||||||
|
"""Test graceful handling when description is missing."""
|
||||||
|
fixture_data = {
|
||||||
|
"hits": [
|
||||||
|
{
|
||||||
|
"id": "999",
|
||||||
|
"headline": "No Description Job",
|
||||||
|
"employer": {"name": "Empty Corp"},
|
||||||
|
"webpage_url": "https://example.com/job/999",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
client = _make_client(json.dumps(fixture_data))
|
||||||
|
connector = ArbetsformedlingenConnector(client=client)
|
||||||
|
results = connector.fetch(SearchQuery(query="test", limit=1))
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].description == ""
|
||||||
|
assert results[0].title == "No Description Job"
|
||||||
|
assert results[0].company == "Empty Corp"
|
||||||
|
|
||||||
|
def test_missing_employer_name(self):
|
||||||
|
"""Test graceful handling when employer name is missing."""
|
||||||
|
fixture_data = {
|
||||||
|
"hits": [
|
||||||
|
{
|
||||||
|
"id": "888",
|
||||||
|
"headline": "Mystery Job",
|
||||||
|
"webpage_url": "https://example.com/job/888",
|
||||||
|
"description": {"text": "A job with no employer name."},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
client = _make_client(json.dumps(fixture_data))
|
||||||
|
connector = ArbetsformedlingenConnector(client=client)
|
||||||
|
results = connector.fetch(SearchQuery(query="test", limit=1))
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].company == ""
|
||||||
|
|
||||||
|
def test_empty_hits(self):
|
||||||
|
"""Test handling of an empty results list."""
|
||||||
|
fixture_data = {"hits": [], "total": {"value": 0, "relation": "eq"}}
|
||||||
|
client = _make_client(json.dumps(fixture_data))
|
||||||
|
connector = ArbetsformedlingenConnector(client=client)
|
||||||
|
results = connector.fetch(SearchQuery(query="nonexistent", limit=10))
|
||||||
|
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
def test_region_param_passed(self):
|
||||||
|
"""Test that region parameter is mapped and sent to the API."""
|
||||||
|
fixture = _load_fixture("af_search_response.json")
|
||||||
|
captured_params: dict = {}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
captured_params.update(dict(request.url.params))
|
||||||
|
return httpx.Response(200, content=fixture, headers={
|
||||||
|
"content-type": "application/json",
|
||||||
|
})
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
connector = ArbetsformedlingenConnector(client=client)
|
||||||
|
connector.fetch(SearchQuery(query="dev", region="malmo"))
|
||||||
|
|
||||||
|
assert captured_params.get("q") == "dev"
|
||||||
|
assert captured_params.get("region") == "Skane lan"
|
||||||
|
|
||||||
|
def test_limit_param_passed(self):
|
||||||
|
"""Test that limit parameter is sent to the API."""
|
||||||
|
fixture = _load_fixture("af_search_response.json")
|
||||||
|
captured_params: dict = {}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
captured_params.update(dict(request.url.params))
|
||||||
|
return httpx.Response(200, content=fixture, headers={
|
||||||
|
"content-type": "application/json",
|
||||||
|
})
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
connector = ArbetsformedlingenConnector(client=client)
|
||||||
|
connector.fetch(SearchQuery(query="dev", limit=50))
|
||||||
|
|
||||||
|
assert captured_params.get("limit") == "50"
|
||||||
|
|
||||||
|
def test_workplace_address_municipality_fallback(self):
|
||||||
|
"""Test that municipality is used when city is missing."""
|
||||||
|
fixture_data = {
|
||||||
|
"hits": [
|
||||||
|
{
|
||||||
|
"id": "777",
|
||||||
|
"headline": "Rural Job",
|
||||||
|
"employer": {"name": "Rural Corp"},
|
||||||
|
"webpage_url": "https://example.com/job/777",
|
||||||
|
"description": {"text": "Work in the countryside."},
|
||||||
|
"workplace_address": {
|
||||||
|
"municipality": "Helsingborg",
|
||||||
|
"country": "Sverige",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
client = _make_client(json.dumps(fixture_data))
|
||||||
|
connector = ArbetsformedlingenConnector(client=client)
|
||||||
|
results = connector.fetch(SearchQuery(query="test", limit=1))
|
||||||
|
|
||||||
|
assert results[0].location == "Helsingborg"
|
||||||
|
|
||||||
|
def test_raw_preserves_original_hit(self):
|
||||||
|
"""Test that the raw field preserves the full original hit data."""
|
||||||
|
fixture = _load_fixture("af_search_response.json")
|
||||||
|
client = _make_client(fixture)
|
||||||
|
connector = ArbetsformedlingenConnector(client=client)
|
||||||
|
results = connector.fetch(SearchQuery(query="dev", limit=3))
|
||||||
|
|
||||||
|
third = results[2]
|
||||||
|
assert third.raw["occupation"]["label"] == "DevOps-ingenjor"
|
||||||
|
assert third.raw["publication_date"] == "2026-07-30T09:00:00+02:00"
|
||||||
119
packages/connectors/tests/test_dedupe.py
Normal file
119
packages/connectors/tests/test_dedupe.py
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
"""Tests for the deduplication helper."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from connectors.dedupe import dedupe, dedupe_job_postings
|
||||||
|
from connectors.models import JobPosting, RawPosting
|
||||||
|
|
||||||
|
|
||||||
|
def _make_raw(
|
||||||
|
source: str = "arbetsformedlingen",
|
||||||
|
external_id: str | None = "1",
|
||||||
|
url: str = "https://example.com/1",
|
||||||
|
company: str = "Corp",
|
||||||
|
title: str = "Dev",
|
||||||
|
) -> RawPosting:
|
||||||
|
return RawPosting(
|
||||||
|
source=source,
|
||||||
|
external_id=external_id,
|
||||||
|
url=url,
|
||||||
|
company=company,
|
||||||
|
title=title,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDedupeRawPostings:
|
||||||
|
"""Test dedupe function with RawPosting objects."""
|
||||||
|
|
||||||
|
def test_no_duplicates_unchanged(self):
|
||||||
|
"""Test that a list with no duplicates is unchanged."""
|
||||||
|
postings = [
|
||||||
|
_make_raw(url="https://a.com/1", external_id="1"),
|
||||||
|
_make_raw(url="https://a.com/2", external_id="2"),
|
||||||
|
_make_raw(url="https://a.com/3", external_id="3"),
|
||||||
|
]
|
||||||
|
result = dedupe(postings)
|
||||||
|
assert len(result) == 3
|
||||||
|
|
||||||
|
def test_dedupe_by_url(self):
|
||||||
|
"""Test deduplication by (source, url)."""
|
||||||
|
postings = [
|
||||||
|
_make_raw(url="https://a.com/1", external_id="1"),
|
||||||
|
_make_raw(url="https://a.com/1", external_id="2"), # same URL, diff ext_id
|
||||||
|
_make_raw(url="https://a.com/2", external_id="3"),
|
||||||
|
]
|
||||||
|
result = dedupe(postings)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0].external_id == "1" # first occurrence kept
|
||||||
|
assert result[1].external_id == "3"
|
||||||
|
|
||||||
|
def test_dedupe_by_external_id(self):
|
||||||
|
"""Test deduplication by (source, external_id) when URL differs."""
|
||||||
|
postings = [
|
||||||
|
_make_raw(url="https://a.com/1", external_id="100"),
|
||||||
|
_make_raw(url="https://a.com/2", external_id="100"), # same ext_id
|
||||||
|
_make_raw(url="https://a.com/3", external_id="200"),
|
||||||
|
]
|
||||||
|
result = dedupe(postings)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0].url == "https://a.com/1"
|
||||||
|
assert result[1].url == "https://a.com/3"
|
||||||
|
|
||||||
|
def test_different_sources_same_url_not_deduped(self):
|
||||||
|
"""Test that same URL from different sources are NOT deduped."""
|
||||||
|
postings = [
|
||||||
|
_make_raw(source="arbetsformedlingen", url="https://a.com/1", external_id="1"),
|
||||||
|
_make_raw(source="generic_url", url="https://a.com/1", external_id=None),
|
||||||
|
]
|
||||||
|
result = dedupe(postings)
|
||||||
|
assert len(result) == 2
|
||||||
|
|
||||||
|
def test_none_external_id_ignored(self):
|
||||||
|
"""Test that None external_id does not cause dedup by ext_id."""
|
||||||
|
postings = [
|
||||||
|
_make_raw(url="https://a.com/1", external_id=None),
|
||||||
|
_make_raw(url="https://a.com/2", external_id=None),
|
||||||
|
_make_raw(url="https://a.com/3", external_id=None),
|
||||||
|
]
|
||||||
|
result = dedupe(postings)
|
||||||
|
assert len(result) == 3
|
||||||
|
|
||||||
|
def test_empty_list(self):
|
||||||
|
"""Test that an empty list returns empty."""
|
||||||
|
assert dedupe([]) == []
|
||||||
|
|
||||||
|
def test_single_item(self):
|
||||||
|
"""Test that a single item list is unchanged."""
|
||||||
|
postings = [_make_raw()]
|
||||||
|
result = dedupe(postings)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_order_preserved(self):
|
||||||
|
"""Test that first-occurrence order is preserved."""
|
||||||
|
postings = [
|
||||||
|
_make_raw(url="https://a.com/3", external_id="3", title="Third"),
|
||||||
|
_make_raw(url="https://a.com/1", external_id="1", title="First"),
|
||||||
|
_make_raw(url="https://a.com/3", external_id="3", title="Third-Dup"),
|
||||||
|
_make_raw(url="https://a.com/2", external_id="2", title="Second"),
|
||||||
|
]
|
||||||
|
result = dedupe(postings)
|
||||||
|
assert len(result) == 3
|
||||||
|
assert result[0].title == "Third"
|
||||||
|
assert result[1].title == "First"
|
||||||
|
assert result[2].title == "Second"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDedupeJobPostings:
|
||||||
|
"""Test dedupe_job_postings function with JobPosting objects."""
|
||||||
|
|
||||||
|
def test_dedupe_job_postings_by_url(self):
|
||||||
|
"""Test deduplication of JobPosting objects."""
|
||||||
|
postings = [
|
||||||
|
JobPosting(source="af", external_id="1", url="https://a.com/1",
|
||||||
|
company="C", title="T"),
|
||||||
|
JobPosting(source="af", external_id="2", url="https://a.com/1",
|
||||||
|
company="C", title="T"),
|
||||||
|
]
|
||||||
|
result = dedupe_job_postings(postings)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].external_id == "1"
|
||||||
222
packages/connectors/tests/test_generic_url.py
Normal file
222
packages/connectors/tests/test_generic_url.py
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
"""Tests for the GenericUrlConnector.
|
||||||
|
|
||||||
|
Uses recorded HTML fixtures (no live network). Fixtures are loaded into
|
||||||
|
an httpx.MockTransport.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from connectors.exceptions import UnsupportedSite
|
||||||
|
from connectors.generic_url import GenericUrlConnector
|
||||||
|
from connectors.models import SearchQuery
|
||||||
|
|
||||||
|
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_fixture(name: str) -> str:
|
||||||
|
"""Load a fixture file as raw text."""
|
||||||
|
return (FIXTURES_DIR / name).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client(
|
||||||
|
response_body: str,
|
||||||
|
status_code: int = 200,
|
||||||
|
headers: dict | None = None,
|
||||||
|
) -> httpx.Client:
|
||||||
|
"""Create an httpx.Client with a mock transport returning the fixture."""
|
||||||
|
default_headers = {"content-type": "text/html; charset=utf-8"}
|
||||||
|
if headers:
|
||||||
|
default_headers.update(headers)
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(status_code, content=response_body, headers=default_headers)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
return httpx.Client(transport=transport)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenericUrlExtraction:
|
||||||
|
"""Test readability extraction from a messy HTML page."""
|
||||||
|
|
||||||
|
def test_extracts_title_from_h1(self):
|
||||||
|
"""Test that the h1 tag is used as the title."""
|
||||||
|
html = _load_fixture("generic_job_page.html")
|
||||||
|
client = _make_client(html)
|
||||||
|
connector = GenericUrlConnector(client=client)
|
||||||
|
results = connector.fetch(SearchQuery(query="https://example.com/job/123"))
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
posting = results[0]
|
||||||
|
assert posting.title == "Senior Backend Engineer"
|
||||||
|
|
||||||
|
def test_extracts_company_from_meta(self):
|
||||||
|
"""Test that og:site_name meta tag is used as company name."""
|
||||||
|
html = _load_fixture("generic_job_page.html")
|
||||||
|
client = _make_client(html)
|
||||||
|
connector = GenericUrlConnector(client=client)
|
||||||
|
results = connector.fetch(SearchQuery(query="https://example.com/job/123"))
|
||||||
|
|
||||||
|
assert results[0].company == "Acme Corp"
|
||||||
|
|
||||||
|
def test_strips_nav_and_footer(self):
|
||||||
|
"""Test that nav and footer content is removed from description."""
|
||||||
|
html = _load_fixture("generic_job_page.html")
|
||||||
|
client = _make_client(html)
|
||||||
|
connector = GenericUrlConnector(client=client)
|
||||||
|
results = connector.fetch(SearchQuery(query="https://example.com/job/123"))
|
||||||
|
|
||||||
|
description = results[0].description
|
||||||
|
# Nav links should not appear
|
||||||
|
assert "Home" not in description or "Requirements" in description
|
||||||
|
assert "Privacy Policy" not in description
|
||||||
|
assert "Terms of Service" not in description
|
||||||
|
assert "Login | Register" not in description
|
||||||
|
# Footer copyright should not appear
|
||||||
|
assert "2026 JobBoard" not in description
|
||||||
|
# Job content should be present
|
||||||
|
assert "Python" in description
|
||||||
|
assert "FastAPI" in description
|
||||||
|
assert "PostgreSQL" in description
|
||||||
|
|
||||||
|
def test_strips_script_and_style(self):
|
||||||
|
"""Test that script and style tags are removed."""
|
||||||
|
html = _load_fixture("generic_job_page.html")
|
||||||
|
client = _make_client(html)
|
||||||
|
connector = GenericUrlConnector(client=client)
|
||||||
|
results = connector.fetch(SearchQuery(query="https://example.com/job/123"))
|
||||||
|
|
||||||
|
description = results[0].description
|
||||||
|
assert "analytics" not in description.lower()
|
||||||
|
assert "var " not in description
|
||||||
|
assert "function()" not in description
|
||||||
|
|
||||||
|
def test_source_is_generic_url(self):
|
||||||
|
"""Test that source is set to 'generic_url'."""
|
||||||
|
html = _load_fixture("generic_job_page.html")
|
||||||
|
client = _make_client(html)
|
||||||
|
connector = GenericUrlConnector(client=client)
|
||||||
|
results = connector.fetch(SearchQuery(query="https://example.com/job/123"))
|
||||||
|
|
||||||
|
assert results[0].source == "generic_url"
|
||||||
|
|
||||||
|
def test_url_preserved(self):
|
||||||
|
"""Test that the original URL is preserved in the result."""
|
||||||
|
html = _load_fixture("generic_job_page.html")
|
||||||
|
client = _make_client(html)
|
||||||
|
connector = GenericUrlConnector(client=client)
|
||||||
|
url = "https://example.com/job/123"
|
||||||
|
results = connector.fetch(SearchQuery(query=url))
|
||||||
|
|
||||||
|
assert results[0].url == url
|
||||||
|
|
||||||
|
def test_external_id_is_none(self):
|
||||||
|
"""Test that external_id is None for generic URL postings."""
|
||||||
|
html = _load_fixture("generic_job_page.html")
|
||||||
|
client = _make_client(html)
|
||||||
|
connector = GenericUrlConnector(client=client)
|
||||||
|
results = connector.fetch(SearchQuery(query="https://example.com/job/123"))
|
||||||
|
|
||||||
|
assert results[0].external_id is None
|
||||||
|
|
||||||
|
def test_description_is_clean(self):
|
||||||
|
"""Test that description text is clean and readable."""
|
||||||
|
html = _load_fixture("generic_job_page.html")
|
||||||
|
client = _make_client(html)
|
||||||
|
connector = GenericUrlConnector(client=client)
|
||||||
|
results = connector.fetch(SearchQuery(query="https://example.com/job/123"))
|
||||||
|
|
||||||
|
description = results[0].description
|
||||||
|
# Should not have excessive blank lines
|
||||||
|
assert "\n\n\n" not in description
|
||||||
|
# Should start with the job title or job content
|
||||||
|
assert len(description) > 50
|
||||||
|
|
||||||
|
def test_location_extracted_from_text(self):
|
||||||
|
"""Test that location is extracted from description text."""
|
||||||
|
html = _load_fixture("generic_job_page.html")
|
||||||
|
client = _make_client(html)
|
||||||
|
connector = GenericUrlConnector(client=client)
|
||||||
|
results = connector.fetch(SearchQuery(query="https://example.com/job/123"))
|
||||||
|
|
||||||
|
# The fixture contains "Location: Malmo, Sweden"
|
||||||
|
assert results[0].location is not None
|
||||||
|
assert "Malmo" in results[0].location
|
||||||
|
|
||||||
|
def test_raw_contains_metadata(self):
|
||||||
|
"""Test that the raw field contains response metadata."""
|
||||||
|
html = _load_fixture("generic_job_page.html")
|
||||||
|
client = _make_client(html)
|
||||||
|
connector = GenericUrlConnector(client=client)
|
||||||
|
results = connector.fetch(SearchQuery(query="https://example.com/job/123"))
|
||||||
|
|
||||||
|
raw = results[0].raw
|
||||||
|
assert raw["status_code"] == 200
|
||||||
|
assert raw["content_length"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestCloudflareDetection:
|
||||||
|
"""Test Cloudflare challenge page detection."""
|
||||||
|
|
||||||
|
def test_raises_on_cloudflare_challenge(self):
|
||||||
|
"""Test that UnsupportedSite is raised for Cloudflare challenge pages."""
|
||||||
|
html = _load_fixture("cloudflare_challenge.html")
|
||||||
|
client = _make_client(
|
||||||
|
html,
|
||||||
|
headers={
|
||||||
|
"server": "cloudflare",
|
||||||
|
"cf-ray": "abc123-xyz789",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
connector = GenericUrlConnector(client=client)
|
||||||
|
|
||||||
|
with pytest.raises(UnsupportedSite) as exc_info:
|
||||||
|
connector.fetch(SearchQuery(query="https://protected-site.com/job/1"))
|
||||||
|
|
||||||
|
assert "protected-site.com" in str(exc_info.value)
|
||||||
|
assert "Cloudflare" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_raises_on_challenge_without_server_header(self):
|
||||||
|
"""Test detection of challenge pages without cloudflare server header."""
|
||||||
|
# A page with cf-browser-verification but no CF server header
|
||||||
|
html = '<html><body><div id="cf-browser-verification">Loading...</div></body></html>'
|
||||||
|
client = _make_client(html)
|
||||||
|
connector = GenericUrlConnector(client=client)
|
||||||
|
|
||||||
|
with pytest.raises(UnsupportedSite):
|
||||||
|
connector.fetch(SearchQuery(query="https://sneaky-site.com/job/1"))
|
||||||
|
|
||||||
|
def test_normal_cf_proxied_site_is_ok(self):
|
||||||
|
"""Test that a normal site behind CF (with content) is NOT flagged."""
|
||||||
|
html = _load_fixture("generic_job_page.html")
|
||||||
|
client = _make_client(
|
||||||
|
html,
|
||||||
|
headers={
|
||||||
|
"server": "cloudflare",
|
||||||
|
"cf-ray": "abc123-xyz789",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
connector = GenericUrlConnector(client=client)
|
||||||
|
|
||||||
|
# Should NOT raise -- this is a normal page that happens to be behind CF
|
||||||
|
results = connector.fetch(SearchQuery(query="https://cf-proxied-site.com/job/1"))
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].title == "Senior Backend Engineer"
|
||||||
|
|
||||||
|
def test_unsupported_site_exception_has_url(self):
|
||||||
|
"""Test that UnsupportedSite exception carries the URL."""
|
||||||
|
html = _load_fixture("cloudflare_challenge.html")
|
||||||
|
client = _make_client(
|
||||||
|
html,
|
||||||
|
headers={"server": "cloudflare", "cf-ray": "xyz"},
|
||||||
|
)
|
||||||
|
connector = GenericUrlConnector(client=client)
|
||||||
|
|
||||||
|
with pytest.raises(UnsupportedSite) as exc_info:
|
||||||
|
connector.fetch(SearchQuery(query="https://example.org/protected"))
|
||||||
|
|
||||||
|
assert exc_info.value.url == "https://example.org/protected"
|
||||||
87
packages/connectors/tests/test_normalizer.py
Normal file
87
packages/connectors/tests/test_normalizer.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
"""Tests for the normalizer and connector protocol."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from connectors import (
|
||||||
|
Connector,
|
||||||
|
GenericUrlConnector,
|
||||||
|
JobPosting,
|
||||||
|
RawPosting,
|
||||||
|
normalize,
|
||||||
|
)
|
||||||
|
from connectors.arbetsformedlingen import ArbetsformedlingenConnector
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalize:
|
||||||
|
"""Test the normalize function."""
|
||||||
|
|
||||||
|
def test_basic_normalization(self):
|
||||||
|
"""Test that normalize maps all fields correctly."""
|
||||||
|
raw = RawPosting(
|
||||||
|
source="arbetsformedlingen",
|
||||||
|
external_id="12345",
|
||||||
|
url="https://example.com/job/12345",
|
||||||
|
company="Test Corp",
|
||||||
|
title="Test Job",
|
||||||
|
location="Malmo",
|
||||||
|
description="A test job description.",
|
||||||
|
raw={"id": "12345", "extra": "data"},
|
||||||
|
)
|
||||||
|
job = normalize(raw)
|
||||||
|
|
||||||
|
assert job.source == "arbetsformedlingen"
|
||||||
|
assert job.external_id == "12345"
|
||||||
|
assert job.url == "https://example.com/job/12345"
|
||||||
|
assert job.company == "Test Corp"
|
||||||
|
assert job.title == "Test Job"
|
||||||
|
assert job.location == "Malmo"
|
||||||
|
assert job.description == "A test job description."
|
||||||
|
assert job.raw == {"id": "12345", "extra": "data"}
|
||||||
|
|
||||||
|
def test_none_fields_preserved(self):
|
||||||
|
"""Test that None fields are preserved."""
|
||||||
|
raw = RawPosting(
|
||||||
|
source="generic_url",
|
||||||
|
external_id=None,
|
||||||
|
url="https://example.com/page",
|
||||||
|
company="",
|
||||||
|
title="Unknown",
|
||||||
|
)
|
||||||
|
job = normalize(raw)
|
||||||
|
|
||||||
|
assert job.external_id is None
|
||||||
|
assert job.location is None
|
||||||
|
assert job.description == ""
|
||||||
|
assert job.raw == {}
|
||||||
|
|
||||||
|
def test_swedish_chars_preserved(self):
|
||||||
|
"""Test that Swedish characters are preserved through normalization."""
|
||||||
|
raw = RawPosting(
|
||||||
|
source="arbetsformedlingen",
|
||||||
|
external_id="1",
|
||||||
|
url="https://example.com/1",
|
||||||
|
company="Nordic Solutions AB",
|
||||||
|
title="Utvecklare",
|
||||||
|
location="Lund",
|
||||||
|
description="Vi soker en utvecklare.",
|
||||||
|
)
|
||||||
|
job = normalize(raw)
|
||||||
|
|
||||||
|
assert "Solutions" in job.company
|
||||||
|
assert "Utvecklare" in job.title
|
||||||
|
assert "soker" in job.description
|
||||||
|
assert "Lund" in (job.location or "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestConnectorProtocol:
|
||||||
|
"""Test that connectors satisfy the Connector protocol."""
|
||||||
|
|
||||||
|
def test_af_connector_is_connector(self):
|
||||||
|
"""Test that ArbetsformedlingenConnector satisfies the Connector protocol."""
|
||||||
|
connector = ArbetsformedlingenConnector()
|
||||||
|
assert isinstance(connector, Connector)
|
||||||
|
|
||||||
|
def test_generic_url_connector_is_connector(self):
|
||||||
|
"""Test that GenericUrlConnector satisfies the Connector protocol."""
|
||||||
|
connector = GenericUrlConnector()
|
||||||
|
assert isinstance(connector, Connector)
|
||||||
27
packages/llm-gateway/pyproject.toml
Normal file
27
packages/llm-gateway/pyproject.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
[project]
|
||||||
|
name = "llm-gateway"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Async LLM gateway with mock mode, per-task budgets, telemetry, and retry/fallback."
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"httpx>=0.27",
|
||||||
|
"jsonschema>=4.23",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-asyncio>=0.24",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/llm_gateway"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
27
packages/llm-gateway/src/llm_gateway/__init__.py
Normal file
27
packages/llm-gateway/src/llm_gateway/__init__.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""LLM Gateway package.
|
||||||
|
|
||||||
|
Async-first client with provider config from env, mock mode when no key is set,
|
||||||
|
per-task token budgets, telemetry sink, and retry/fallback policy.
|
||||||
|
|
||||||
|
Public API:
|
||||||
|
Gateway: main async gateway class.
|
||||||
|
GatewayConfig: provider + budget configuration.
|
||||||
|
TelemetryRow: telemetry record dataclass.
|
||||||
|
BudgetExceeded: raised when a task would exceed its token budget.
|
||||||
|
run_task: convenience function using default config.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from llm_gateway.config import GatewayConfig, TaskClass
|
||||||
|
from llm_gateway.exceptions import BudgetExceeded, GatewayError, SchemaValidationError
|
||||||
|
from llm_gateway.gateway import Gateway, TelemetryRow, run_task
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Gateway",
|
||||||
|
"GatewayConfig",
|
||||||
|
"TaskClass",
|
||||||
|
"TelemetryRow",
|
||||||
|
"BudgetExceeded",
|
||||||
|
"GatewayError",
|
||||||
|
"SchemaValidationError",
|
||||||
|
"run_task",
|
||||||
|
]
|
||||||
224
packages/llm-gateway/src/llm_gateway/config.py
Normal file
224
packages/llm-gateway/src/llm_gateway/config.py
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
"""Configuration for the LLM gateway.
|
||||||
|
|
||||||
|
Provider config from env. Cheap task classes must never fall back to a paid
|
||||||
|
provider. Budgets are per-task max output tokens, configurable via env.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskClass(str, enum.Enum):
|
||||||
|
"""Task classification for model routing.
|
||||||
|
|
||||||
|
CHEAP tasks (score, extract) use the cheap model and must never fall back
|
||||||
|
to a paid provider. STRONG tasks (critique, prose review) may use fallback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CHEAP = "cheap"
|
||||||
|
STRONG = "strong"
|
||||||
|
|
||||||
|
|
||||||
|
# Map task names to task classes for routing.
|
||||||
|
TASK_CLASS_MAP: dict[str, TaskClass] = {
|
||||||
|
"score": TaskClass.CHEAP,
|
||||||
|
"extract": TaskClass.CHEAP,
|
||||||
|
"cv_assist": TaskClass.CHEAP,
|
||||||
|
"email_classify": TaskClass.CHEAP,
|
||||||
|
"deadline_extract": TaskClass.CHEAP,
|
||||||
|
"cl_critique": TaskClass.STRONG,
|
||||||
|
"critique": TaskClass.STRONG,
|
||||||
|
"research": TaskClass.STRONG,
|
||||||
|
"cv_tailor": TaskClass.STRONG,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default budgets (max output tokens) per task name.
|
||||||
|
DEFAULT_BUDGETS: dict[str, int] = {
|
||||||
|
"score": 2000,
|
||||||
|
"extract": 4000,
|
||||||
|
"cv_assist": 2000,
|
||||||
|
"email_classify": 1000,
|
||||||
|
"deadline_extract": 500,
|
||||||
|
"cl_critique": 4000,
|
||||||
|
"critique": 6000,
|
||||||
|
"research": 4000,
|
||||||
|
"cv_tailor": 6000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProviderConfig:
|
||||||
|
"""Configuration for a single LLM provider."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
base_url: str
|
||||||
|
api_key: str
|
||||||
|
model: str
|
||||||
|
is_paid: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_configured(self) -> bool:
|
||||||
|
"""True if this provider has a non-empty API key."""
|
||||||
|
return bool(self.api_key and self.api_key.strip())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GatewayConfig:
|
||||||
|
"""Full gateway configuration loaded from environment.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
primary: primary provider config.
|
||||||
|
fallback: optional fallback provider config (None if not configured).
|
||||||
|
cheap_model: model name for cheap task classes.
|
||||||
|
strong_model: model name for strong task classes.
|
||||||
|
budgets: dict mapping task name to max output tokens.
|
||||||
|
max_retries: max retries on 429/5xx before fallback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
primary: ProviderConfig
|
||||||
|
fallback: ProviderConfig | None = None
|
||||||
|
cheap_model: str = "glm-5.2"
|
||||||
|
strong_model: str = "glm-5.2"
|
||||||
|
budgets: dict[str, int] = field(default_factory=lambda: dict(DEFAULT_BUDGETS))
|
||||||
|
max_retries: int = 2
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mock_mode(self) -> bool:
|
||||||
|
"""True when no primary provider key is configured."""
|
||||||
|
return not self.primary.is_configured
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls, env: dict[str, str] | None = None) -> GatewayConfig:
|
||||||
|
"""Load configuration from environment variables.
|
||||||
|
|
||||||
|
Env vars:
|
||||||
|
LLM_PRIMARY_BASE_URL, LLM_PRIMARY_KEY (or OLLAMA_API_KEY),
|
||||||
|
LLM_PRIMARY_MODEL (default glm-5.2)
|
||||||
|
LLM_FALLBACK_BASE_URL, LLM_FALLBACK_KEY, LLM_FALLBACK_MODEL
|
||||||
|
LLM_CHEAP_MODEL (default glm-5.2)
|
||||||
|
LLM_STRONG_MODEL (default glm-5.2)
|
||||||
|
LLM_BUDGET_{TASK} (per-task budget overrides)
|
||||||
|
LLM_MAX_RETRIES (default 2)
|
||||||
|
|
||||||
|
A warning is logged (and in strict mode, ValueError raised) if a
|
||||||
|
paid fallback provider is configured while cheap task classes would
|
||||||
|
use it. The fallback is only used for STRONG tasks.
|
||||||
|
"""
|
||||||
|
e = env if env is not None else os.environ
|
||||||
|
|
||||||
|
primary_key = e.get("LLM_PRIMARY_KEY", "") or e.get("OLLAMA_API_KEY", "")
|
||||||
|
primary = ProviderConfig(
|
||||||
|
name="primary",
|
||||||
|
base_url=e.get("LLM_PRIMARY_BASE_URL", "https://api.ollama-cloud.com/v1"),
|
||||||
|
api_key=primary_key,
|
||||||
|
model=e.get("LLM_PRIMARY_MODEL", "glm-5.2"),
|
||||||
|
is_paid=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
fallback: ProviderConfig | None = None
|
||||||
|
fb_key = e.get("LLM_FALLBACK_KEY", "")
|
||||||
|
fb_url = e.get("LLM_FALLBACK_BASE_URL", "")
|
||||||
|
if fb_key and fb_url:
|
||||||
|
fallback = ProviderConfig(
|
||||||
|
name="fallback",
|
||||||
|
base_url=fb_url,
|
||||||
|
api_key=fb_key,
|
||||||
|
model=e.get("LLM_FALLBACK_MODEL", "glm-5.2"),
|
||||||
|
# Heuristic: if the base URL contains known paid provider hints,
|
||||||
|
# mark as paid.
|
||||||
|
is_paid=_detect_paid_provider(fb_url),
|
||||||
|
)
|
||||||
|
if fallback.is_paid:
|
||||||
|
logger.warning(
|
||||||
|
"Fallback provider appears to be a paid provider (%s). "
|
||||||
|
"Cheap task classes will NOT use this fallback.",
|
||||||
|
fb_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
cheap_model = e.get("LLM_CHEAP_MODEL", "glm-5.2")
|
||||||
|
strong_model = e.get("LLM_STRONG_MODEL", "glm-5.2")
|
||||||
|
|
||||||
|
budgets = dict(DEFAULT_BUDGETS)
|
||||||
|
for task_name in list(budgets.keys()):
|
||||||
|
env_val = e.get(f"LLM_BUDGET_{task_name.upper()}")
|
||||||
|
if env_val:
|
||||||
|
try:
|
||||||
|
budgets[task_name] = int(env_val)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Invalid budget value for %s: %s", task_name, env_val)
|
||||||
|
|
||||||
|
# Also pick up any LLM_BUDGET_* not in defaults.
|
||||||
|
for key, val in e.items():
|
||||||
|
if key.startswith("LLM_BUDGET_") and val:
|
||||||
|
task = key[len("LLM_BUDGET_"):].lower()
|
||||||
|
if task not in budgets:
|
||||||
|
try:
|
||||||
|
budgets[task] = int(val)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
max_retries_str = e.get("LLM_MAX_RETRIES", "2")
|
||||||
|
try:
|
||||||
|
max_retries = int(max_retries_str)
|
||||||
|
except ValueError:
|
||||||
|
max_retries = 2
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
primary=primary,
|
||||||
|
fallback=fallback,
|
||||||
|
cheap_model=cheap_model,
|
||||||
|
strong_model=strong_model,
|
||||||
|
budgets=budgets,
|
||||||
|
max_retries=max_retries,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_budget(self, task: str) -> int:
|
||||||
|
"""Return the max output token budget for *task*.
|
||||||
|
|
||||||
|
Falls back to LLM_BUDGET_DEFAULT or 4000 if task-specific budget
|
||||||
|
is not set.
|
||||||
|
"""
|
||||||
|
return self.budgets.get(task, self.budgets.get("default", 4000))
|
||||||
|
|
||||||
|
def get_model(self, task: str) -> str:
|
||||||
|
"""Return the model name for *task* based on its task class."""
|
||||||
|
task_class = TASK_CLASS_MAP.get(task, TaskClass.STRONG)
|
||||||
|
if task_class == TaskClass.CHEAP:
|
||||||
|
return self.cheap_model
|
||||||
|
return self.strong_model
|
||||||
|
|
||||||
|
def get_task_class(self, task: str) -> TaskClass:
|
||||||
|
"""Return the task class for *task*."""
|
||||||
|
return TASK_CLASS_MAP.get(task, TaskClass.STRONG)
|
||||||
|
|
||||||
|
def assert_no_paid_fallback_for_cheap(self) -> None:
|
||||||
|
"""Assert that no paid fallback is configured for cheap task classes.
|
||||||
|
|
||||||
|
This is called during config validation. If a paid fallback exists,
|
||||||
|
it is allowed for STRONG tasks but must never be used for CHEAP tasks.
|
||||||
|
The gateway enforces this in _select_provider, but we also check here.
|
||||||
|
"""
|
||||||
|
if self.fallback and self.fallback.is_paid:
|
||||||
|
# This is allowed as long as cheap tasks never use fallback.
|
||||||
|
# We log a warning; the gateway itself prevents the routing.
|
||||||
|
logger.info(
|
||||||
|
"Paid fallback configured but will not be used for cheap tasks."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_paid_provider(base_url: str) -> bool:
|
||||||
|
"""Heuristic: detect if a base URL points to a known paid provider."""
|
||||||
|
url_lower = base_url.lower()
|
||||||
|
paid_hints = [
|
||||||
|
"openai.com",
|
||||||
|
"anthropic.com",
|
||||||
|
"api.openai.com",
|
||||||
|
"api.anthropic.com",
|
||||||
|
]
|
||||||
|
return any(hint in url_lower for hint in paid_hints)
|
||||||
22
packages/llm-gateway/src/llm_gateway/exceptions.py
Normal file
22
packages/llm-gateway/src/llm_gateway/exceptions.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""Exceptions for the LLM gateway."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayError(Exception):
|
||||||
|
"""Base exception for LLM gateway errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetExceeded(GatewayError):
|
||||||
|
"""Raised when a task would exceed its configured token budget.
|
||||||
|
|
||||||
|
This is raised BEFORE any provider call is made.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaValidationError(GatewayError):
|
||||||
|
"""Raised when the LLM output does not validate against the schema."""
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderError(GatewayError):
|
||||||
|
"""Raised when a provider call fails after all retries and fallbacks."""
|
||||||
388
packages/llm-gateway/src/llm_gateway/gateway.py
Normal file
388
packages/llm-gateway/src/llm_gateway/gateway.py
Normal file
|
|
@ -0,0 +1,388 @@
|
||||||
|
"""Main gateway module: async-first LLM client with retry, fallback, budget guard, and telemetry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from jsonschema import validate as jsonschema_validate
|
||||||
|
from jsonschema.exceptions import ValidationError as JsonSchemaValidationError
|
||||||
|
|
||||||
|
from llm_gateway.config import GatewayConfig, TaskClass
|
||||||
|
from llm_gateway.exceptions import (
|
||||||
|
BudgetExceeded,
|
||||||
|
GatewayError,
|
||||||
|
ProviderError,
|
||||||
|
SchemaValidationError,
|
||||||
|
)
|
||||||
|
from llm_gateway.mock import get_mock_output, mock_telemetry_row
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Type alias for the telemetry sink: an async callable that receives a
|
||||||
|
# TelemetryRow (or a dict for simple sinks).
|
||||||
|
TelemetrySink = Callable[["TelemetryRow"], Awaitable[None] | None]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TelemetryRow:
|
||||||
|
"""A single telemetry record for one LLM call.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: unique run id.
|
||||||
|
task: task name (e.g. 'score', 'extract').
|
||||||
|
model: model name used.
|
||||||
|
provider: provider name ('primary', 'fallback', 'mock').
|
||||||
|
input_tokens: tokens consumed on input.
|
||||||
|
output_tokens: tokens consumed on output.
|
||||||
|
cost_usd: estimated cost in USD (None if not configured).
|
||||||
|
duration_ms: wall-clock duration in milliseconds.
|
||||||
|
application_id: optional application id for correlation.
|
||||||
|
mock: True if this was a mock-mode call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
task: str = ""
|
||||||
|
model: str = ""
|
||||||
|
provider: str = ""
|
||||||
|
input_tokens: int = 0
|
||||||
|
output_tokens: int = 0
|
||||||
|
cost_usd: float | None = None
|
||||||
|
duration_ms: int = 0
|
||||||
|
application_id: str | None = None
|
||||||
|
mock: bool = False
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Serialize to dict for sinks that accept plain dicts."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"task": self.task,
|
||||||
|
"model": self.model,
|
||||||
|
"provider": self.provider,
|
||||||
|
"input_tokens": self.input_tokens,
|
||||||
|
"output_tokens": self.output_tokens,
|
||||||
|
"cost_usd": self.cost_usd,
|
||||||
|
"duration_ms": self.duration_ms,
|
||||||
|
"application_id": self.application_id,
|
||||||
|
"mock": self.mock,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Gateway:
|
||||||
|
"""Async-first LLM gateway.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
config = GatewayConfig.from_env()
|
||||||
|
gw = Gateway(config)
|
||||||
|
result = await gw.run_task("score", "Score this job vs profile: ...")
|
||||||
|
|
||||||
|
When no API key is configured (mock mode), returns deterministic canned
|
||||||
|
outputs without making any network calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: GatewayConfig,
|
||||||
|
telemetry_sink: TelemetrySink | None = None,
|
||||||
|
http_client: httpx.AsyncClient | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.config = config
|
||||||
|
self._telemetry_sink = telemetry_sink
|
||||||
|
self._http_client = http_client
|
||||||
|
self._owns_http_client = http_client is None
|
||||||
|
|
||||||
|
async def _get_http_client(self) -> httpx.AsyncClient:
|
||||||
|
if self._http_client is None:
|
||||||
|
self._http_client = httpx.AsyncClient(timeout=30.0)
|
||||||
|
return self._http_client
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
"""Close the HTTP client if we own it."""
|
||||||
|
if self._owns_http_client and self._http_client is not None:
|
||||||
|
await self._http_client.aclose()
|
||||||
|
self._http_client = None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> Gateway:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args: Any) -> None:
|
||||||
|
await self.aclose()
|
||||||
|
|
||||||
|
def _select_provider(self, task: str) -> Any:
|
||||||
|
"""Select the provider config for *task*.
|
||||||
|
|
||||||
|
Cheap tasks always use the primary provider (never fallback to paid).
|
||||||
|
Strong tasks may use fallback if primary fails.
|
||||||
|
"""
|
||||||
|
task_class = self.config.get_task_class(task)
|
||||||
|
# Cheap tasks: primary only, never fallback (especially not paid).
|
||||||
|
if task_class == TaskClass.CHEAP:
|
||||||
|
return self.config.primary
|
||||||
|
# Strong tasks: primary, with fallback available.
|
||||||
|
return self.config.primary
|
||||||
|
|
||||||
|
def _check_budget(self, task: str, prompt: str) -> None:
|
||||||
|
"""Check if the estimated token usage would exceed the budget.
|
||||||
|
|
||||||
|
Raises BudgetExceeded BEFORE any provider call is made.
|
||||||
|
We estimate input tokens as len(prompt) // 4 (rough heuristic) and
|
||||||
|
add the max output token budget. If the estimated total exceeds
|
||||||
|
the budget, we raise.
|
||||||
|
"""
|
||||||
|
budget = self.config.get_budget(task)
|
||||||
|
# Rough input token estimate: ~4 chars per token.
|
||||||
|
estimated_input_tokens = len(prompt) // 4
|
||||||
|
# If input alone exceeds budget, that is over-budget.
|
||||||
|
if estimated_input_tokens > budget:
|
||||||
|
raise BudgetExceeded(
|
||||||
|
f"Task '{task}' estimated input tokens ({estimated_input_tokens}) "
|
||||||
|
f"exceed budget ({budget}). Call aborted before provider request."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _write_telemetry(self, row: TelemetryRow) -> None:
|
||||||
|
"""Send telemetry to the sink if one is configured."""
|
||||||
|
if self._telemetry_sink is None:
|
||||||
|
return
|
||||||
|
result = self._telemetry_sink(row)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
|
|
||||||
|
async def _call_provider(
|
||||||
|
self,
|
||||||
|
provider_config: Any,
|
||||||
|
task: str,
|
||||||
|
prompt: str,
|
||||||
|
model: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Make an async HTTP call to the provider's chat completions endpoint.
|
||||||
|
|
||||||
|
Returns the raw JSON response dict.
|
||||||
|
"""
|
||||||
|
client = await self._get_http_client()
|
||||||
|
url = f"{provider_config.base_url.rstrip('/')}/chat/completions"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {provider_config.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"max_tokens": self.config.get_budget(task),
|
||||||
|
}
|
||||||
|
# If a schema is expected, request JSON format.
|
||||||
|
body["response_format"] = {"type": "json_object"}
|
||||||
|
|
||||||
|
response = await client.post(url, json=body, headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Extract the content from the response.
|
||||||
|
choices = data.get("choices", [])
|
||||||
|
if not choices:
|
||||||
|
raise ProviderError(f"Provider returned no choices for task '{task}'")
|
||||||
|
content = choices[0].get("message", {}).get("content", "{}")
|
||||||
|
usage = data.get("usage", {})
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = json.loads(content)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ProviderError(
|
||||||
|
f"Provider returned non-JSON content for task '{task}': {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
# Attach usage info for telemetry.
|
||||||
|
parsed["_usage"] = {
|
||||||
|
"input_tokens": usage.get("prompt_tokens", 0),
|
||||||
|
"output_tokens": usage.get("completion_tokens", 0),
|
||||||
|
"model": data.get("model", model),
|
||||||
|
"provider": provider_config.name,
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
async def _call_with_retry(
|
||||||
|
self,
|
||||||
|
provider_config: Any,
|
||||||
|
task: str,
|
||||||
|
prompt: str,
|
||||||
|
model: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Call provider with retry policy: max retries on 429/5xx, then
|
||||||
|
fallback provider (for strong tasks only), then raise.
|
||||||
|
"""
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
for attempt in range(self.config.max_retries + 1):
|
||||||
|
try:
|
||||||
|
return await self._call_provider(provider_config, task, prompt, model)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
status = exc.response.status_code
|
||||||
|
if status == 429 or 500 <= status < 600:
|
||||||
|
last_exc = exc
|
||||||
|
logger.warning(
|
||||||
|
"Provider %s returned %d for task '%s' (attempt %d/%d)",
|
||||||
|
provider_config.name,
|
||||||
|
status,
|
||||||
|
task,
|
||||||
|
attempt + 1,
|
||||||
|
self.config.max_retries + 1,
|
||||||
|
)
|
||||||
|
if attempt < self.config.max_retries:
|
||||||
|
await asyncio.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
except (httpx.RequestError, ProviderError) as exc:
|
||||||
|
last_exc = exc
|
||||||
|
logger.warning(
|
||||||
|
"Provider %s error for task '%s' (attempt %d/%d): %s",
|
||||||
|
provider_config.name,
|
||||||
|
task,
|
||||||
|
attempt + 1,
|
||||||
|
self.config.max_retries + 1,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
if attempt < self.config.max_retries:
|
||||||
|
await asyncio.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# All retries exhausted. Try fallback for strong tasks.
|
||||||
|
task_class = self.config.get_task_class(task)
|
||||||
|
if (
|
||||||
|
task_class == TaskClass.STRONG
|
||||||
|
and self.config.fallback
|
||||||
|
and self.config.fallback.is_configured
|
||||||
|
and self.config.fallback is not provider_config
|
||||||
|
):
|
||||||
|
logger.info("Falling back to %s for task '%s'", self.config.fallback.name, task)
|
||||||
|
try:
|
||||||
|
return await self._call_provider(
|
||||||
|
self.config.fallback, task, prompt, self.config.strong_model
|
||||||
|
)
|
||||||
|
except Exception as fallback_exc:
|
||||||
|
raise ProviderError(
|
||||||
|
f"Both primary and fallback providers failed for task '{task}': "
|
||||||
|
f"primary={last_exc}, fallback={fallback_exc}"
|
||||||
|
) from fallback_exc
|
||||||
|
|
||||||
|
raise ProviderError(
|
||||||
|
f"Provider call failed for task '{task}' after {self.config.max_retries + 1} attempts: {last_exc}"
|
||||||
|
) from last_exc
|
||||||
|
|
||||||
|
async def run_task(
|
||||||
|
self,
|
||||||
|
task: str,
|
||||||
|
prompt: str,
|
||||||
|
schema: dict | None = None,
|
||||||
|
application_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Run an LLM task and return the parsed result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: task name (e.g. 'score', 'extract', 'cv_assist', 'cl_critique').
|
||||||
|
prompt: the input prompt text.
|
||||||
|
schema: optional JSON schema to validate the output against.
|
||||||
|
application_id: optional application id for telemetry correlation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed JSON dict from the LLM.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BudgetExceeded: if the estimated token usage exceeds the budget.
|
||||||
|
SchemaValidationError: if the output does not match the schema.
|
||||||
|
ProviderError: if the provider call fails after all retries.
|
||||||
|
"""
|
||||||
|
# Budget guard: raise BEFORE any call is made.
|
||||||
|
self._check_budget(task, prompt)
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
|
||||||
|
if self.config.mock_mode:
|
||||||
|
# Mock mode: return deterministic canned output.
|
||||||
|
result = get_mock_output(task)
|
||||||
|
duration_ms = int((time.monotonic() - start) * 1000)
|
||||||
|
model = self.config.get_model(task)
|
||||||
|
|
||||||
|
row = TelemetryRow(
|
||||||
|
task=task,
|
||||||
|
model=model,
|
||||||
|
provider="mock",
|
||||||
|
input_tokens=len(prompt) // 4,
|
||||||
|
output_tokens=0,
|
||||||
|
cost_usd=0.0,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
application_id=application_id,
|
||||||
|
mock=True,
|
||||||
|
)
|
||||||
|
await self._write_telemetry(row)
|
||||||
|
|
||||||
|
# Validate against schema if provided.
|
||||||
|
if schema is not None:
|
||||||
|
_validate_schema(result, schema)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Real mode: call provider with retry/fallback.
|
||||||
|
provider = self._select_provider(task)
|
||||||
|
model = self.config.get_model(task)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self._call_with_retry(provider, task, prompt, model)
|
||||||
|
except BudgetExceeded:
|
||||||
|
raise
|
||||||
|
except (ProviderError, SchemaValidationError):
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise ProviderError(f"Unexpected error for task '{task}': {exc}") from exc
|
||||||
|
|
||||||
|
duration_ms = int((time.monotonic() - start) * 1000)
|
||||||
|
usage = result.pop("_usage", {})
|
||||||
|
|
||||||
|
row = TelemetryRow(
|
||||||
|
task=task,
|
||||||
|
model=usage.get("model", model),
|
||||||
|
provider=usage.get("provider", provider.name),
|
||||||
|
input_tokens=usage.get("input_tokens", 0),
|
||||||
|
output_tokens=usage.get("output_tokens", 0),
|
||||||
|
cost_usd=None,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
application_id=application_id,
|
||||||
|
mock=False,
|
||||||
|
)
|
||||||
|
await self._write_telemetry(row)
|
||||||
|
|
||||||
|
# Validate against schema if provided.
|
||||||
|
if schema is not None:
|
||||||
|
_validate_schema(result, schema)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_schema(data: dict, schema: dict) -> None:
|
||||||
|
"""Validate *data* against *schema*. Raises SchemaValidationError on failure."""
|
||||||
|
try:
|
||||||
|
jsonschema_validate(data, schema)
|
||||||
|
except JsonSchemaValidationError as exc:
|
||||||
|
raise SchemaValidationError(f"Schema validation failed: {exc.message}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def run_task(
|
||||||
|
task: str,
|
||||||
|
prompt: str,
|
||||||
|
schema: dict | None = None,
|
||||||
|
config: GatewayConfig | None = None,
|
||||||
|
telemetry_sink: TelemetrySink | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Convenience function: create a Gateway, run one task, close it.
|
||||||
|
|
||||||
|
Useful for one-off calls. For repeated calls, instantiate Gateway directly.
|
||||||
|
"""
|
||||||
|
cfg = config or GatewayConfig.from_env()
|
||||||
|
gw = Gateway(cfg, telemetry_sink=telemetry_sink)
|
||||||
|
try:
|
||||||
|
return await gw.run_task(task, prompt, schema=schema)
|
||||||
|
finally:
|
||||||
|
await gw.aclose()
|
||||||
131
packages/llm-gateway/src/llm_gateway/mock.py
Normal file
131
packages/llm-gateway/src/llm_gateway/mock.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"""Mock mode for the LLM gateway.
|
||||||
|
|
||||||
|
When no API key is configured, the gateway returns deterministic canned
|
||||||
|
outputs per task name. This allows the API and tests to run offline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
# Deterministic canned outputs per task name.
|
||||||
|
# Each entry is a dict that will be returned as the task result.
|
||||||
|
MOCK_OUTPUTS: dict[str, dict] = {
|
||||||
|
"score": {
|
||||||
|
"score": 75,
|
||||||
|
"rationale": {
|
||||||
|
"match": "good",
|
||||||
|
"reasons": ["skills align", "location matches"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"extract": {
|
||||||
|
"company": "Example Corp",
|
||||||
|
"title": "Software Engineer",
|
||||||
|
"location": "Stockholm",
|
||||||
|
"requirements": ["Python", "PostgreSQL", "Docker"],
|
||||||
|
},
|
||||||
|
"cv_assist": {
|
||||||
|
"suggestions": [
|
||||||
|
"Led a team of 5 developers to deliver a critical integration",
|
||||||
|
"Reduced API latency by 40% through caching and query optimization",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"cl_critique": {
|
||||||
|
"comments": [
|
||||||
|
{
|
||||||
|
"quote": "I am a hard worker",
|
||||||
|
"suggestion": "Replace generic claim with a specific achievement metric",
|
||||||
|
"severity": "medium",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"quote": "Dear Sir/Madam",
|
||||||
|
"suggestion": "Address the hiring manager by name if known",
|
||||||
|
"severity": "low",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"critique": {
|
||||||
|
"comments": [
|
||||||
|
{
|
||||||
|
"quote": "sample text",
|
||||||
|
"suggestion": "improve clarity",
|
||||||
|
"severity": "low",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"research": {
|
||||||
|
"summary": "The company is a mid-size tech firm focused on cloud infrastructure.",
|
||||||
|
"key_points": ["Founded in 2015", "Series B funding", "Remote-first culture"],
|
||||||
|
},
|
||||||
|
"email_classify": {
|
||||||
|
"classification": "interview_invite",
|
||||||
|
"state_proposal": "interviewing",
|
||||||
|
"reason": "The email contains an invitation to schedule an interview.",
|
||||||
|
},
|
||||||
|
"cv_tailor": {
|
||||||
|
"tailored_cv": {
|
||||||
|
"summary": "Senior Python Developer with 6+ years building scalable backend systems.",
|
||||||
|
"skills": [
|
||||||
|
"Python",
|
||||||
|
"Fast API",
|
||||||
|
"PostgreSQL",
|
||||||
|
"Docker",
|
||||||
|
"Kubernetes",
|
||||||
|
"AWS",
|
||||||
|
],
|
||||||
|
"experience": [
|
||||||
|
{
|
||||||
|
"company": "TechCorp",
|
||||||
|
"role": "Senior Backend Engineer",
|
||||||
|
"bullets": [
|
||||||
|
"Led migration of monolith to microservices using Fast API",
|
||||||
|
"Reduced API latency by 40% through query optimization and caching",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"change_log": [
|
||||||
|
{"action": "reordered", "detail": "Moved Python and Fast API to top of skills"},
|
||||||
|
{"action": "rephrased", "detail": "Rewrote first experience bullet to emphasize Fast API"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"deadline_extract": {
|
||||||
|
"apply_by": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default mock output for unknown task names.
|
||||||
|
DEFAULT_MOCK_OUTPUT: dict = {
|
||||||
|
"result": "mock output",
|
||||||
|
"task": "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_mock_output(task: str) -> dict:
|
||||||
|
"""Return a deterministic mock output for *task*.
|
||||||
|
|
||||||
|
For unknown tasks, returns DEFAULT_MOCK_OUTPUT with the task name filled in.
|
||||||
|
"""
|
||||||
|
if task in MOCK_OUTPUTS:
|
||||||
|
# Return a copy so callers cannot mutate the canned data.
|
||||||
|
return json.loads(json.dumps(MOCK_OUTPUTS[task]))
|
||||||
|
result = json.loads(json.dumps(DEFAULT_MOCK_OUTPUT))
|
||||||
|
result["task"] = task
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def mock_telemetry_row(task: str, model: str) -> dict:
|
||||||
|
"""Build a mock telemetry row dict for offline mode."""
|
||||||
|
return {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"task": task,
|
||||||
|
"model": model,
|
||||||
|
"provider": "mock",
|
||||||
|
"input_tokens": 0,
|
||||||
|
"output_tokens": 0,
|
||||||
|
"cost_usd": 0.0,
|
||||||
|
"duration_ms": 0,
|
||||||
|
"mock": True,
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue