Compare commits

...

15 commits

Author SHA1 Message Date
7ede416a3b Drop host port publish for postgres (5433 taken by bilhej-postgres-prod on host)
All checks were successful
CI / api-tests (push) Successful in 34s
CI / package-tests (push) Successful in 26s
CI / web-tests (push) Successful in 23s
2026-07-31 10:15:18 +00:00
20e14800ed Add production compose + bilhej-style CI/deploy workflows (host docker deployment)
Some checks failed
CI / api-tests (push) Failing after 51s
CI / package-tests (push) Successful in 27s
CI / web-tests (push) Successful in 24s
2026-07-31 10:02:01 +00:00
hermes
0741a8b717 Vision-verified UI fixes: flat company/title joins on kanban+detail, keyword coverage ratio object rendering, change log action/detail fields, Swedish diacritics in demo seed and UI labels (Skåne, Malmö, Göteborg, Ängelholm, Fråga, Arbetsförmedlingen)
Some checks failed
CI / api-tests (push) Failing after 34s
CI / package-tests (push) Failing after 31s
CI / web-tests (push) Failing after 34s
🤖 Generated with Hermes Agent
2026-07-30 22:05:19 +00:00
hermes
aa858bd2c1 Screenshot pipeline + fixes found under real render (CORS, batch state stomp, prompt-aware cv_tailor mock)
Some checks failed
CI / api-tests (push) Failing after 32s
CI / package-tests (push) Failing after 38s
CI / web-tests (push) Failing after 36s
- scripts/screenshots.py + scripts/Dockerfile.shots: playwright-in-compose
  runner, 7 demo shots against the seeded stack (DinD-safe, output volume)
- docker-compose.yml: api + web services (nginx SPA + /api proxy-less via
  build-arg VITE_API_BASE), shots service with volume output
- apps/api: add CORSMiddleware (SPA origin web:80 could not read api:8000),
  batch scoring now refuses to rewrite applications beyond
  discovered/scored (kanban page load previously reset sent/interviewing
  to scored), prompt-aware cv_tailor mock so demo passes hallucination
  guard, hallucination-guard tests repointed to monkeypatched run_task
- apps/web: Applications kanban batches only unscored applications, red
  flag display falls back to stored rationale
- 159 api tests + 14 web tests green after fixes
2026-07-30 21:40:14 +00:00
hermes
9be1fd991b WS1: fix date parsing in seed counts (integration lead commit after worker iteration limit) 2026-07-30 21:15:11 +00:00
hermes
ba51a00f2b WS1: Add 8 extended seed-demo tests (cluster, deadlines, red flags, suggestions, notifications, telemetry, artifacts) 2026-07-30 21:10:30 +00:00
hermes
71a16d35ed WS1: Extend seed-demo with agency cluster, deadlines, red flags, artifacts, telemetry, suggestions, notifications 2026-07-30 21:10:18 +00:00
hermes
641f70b68c WB2: append v1.1 section to user guide (email radar, dedupe, tailor CV)
Some checks failed
CI / api-tests (push) Failing after 25s
CI / package-tests (push) Failing after 38s
CI / web-tests (push) Failing after 39s
2026-07-30 21:02:46 +00:00
hermes
62b8af2ab8 WB2: fix TodayView type for getToday returning TodayResponseV11 (deadlines field) 2026-07-30 21:02:42 +00:00
hermes
d8967f29f0 WB1: add >=20 tests for cluster, tailor-cv, deadline integration
Some checks failed
CI / api-tests (push) Failing after 26s
CI / package-tests (push) Failing after 42s
CI / web-tests (push) Failing after 43s
2026-07-30 20:53:21 +00:00
hermes
b3a1f588ef WB1: migration 004, cluster assignment, GET /clusters, tailor-cv endpoint, deadline integration, Dockerfile.test matching install 2026-07-30 20:52:25 +00:00
hermes
59ea645125 v1.1 wave B dispatch cards (dedupe+tailor+deadline api, web v1.1 UI)
Some checks failed
CI / api-tests (push) Failing after 30s
CI / package-tests (push) Failing after 38s
CI / web-tests (push) Failing after 33s
2026-07-30 20:47:07 +00:00
hermes
7bd9e5bfc6 Merge remote-tracking branch 'origin/feat/WA1-email-notify'
Some checks failed
CI / api-tests (push) Has been cancelled
CI / package-tests (push) Has been cancelled
CI / web-tests (push) Has been cancelled
2026-07-30 20:46:13 +00:00
hermes
8dceedca2b WA2: llm-gateway deterministic mocks for email_classify (CHEAP), cv_tailor (STRONG), deadline_extract (CHEAP) + 9 tests 2026-07-30 20:45:11 +00:00
hermes
31a3509bd3 WA2: packages/matching - similarity, dedupe cluster, keywords extract+coverage, 65 tests with agency-repost fixture triples 2026-07-30 20:43:28 +00:00
38 changed files with 3415 additions and 143 deletions

View file

@ -1,6 +1,7 @@
# ---- Database ---- # ---- Database ----
# Used by apps/api to connect to the postgres service defined in docker-compose.yml. # Used by apps/api to connect to the postgres service defined in docker-compose.yml.
DATABASE_URL=postgresql://jobhunt:***@localhost:5433/jobhunt # Postgres is not published to the host; all services run inside the compose network.
DATABASE_URL=postgresql://jobhunt:***@postgres:5432/jobhunt
# ---- LLM Gateway ---- # ---- LLM Gateway ----
# Primary provider (default: GLM-5.2 via ollama-cloud). # Primary provider (default: GLM-5.2 via ollama-cloud).

View file

@ -11,7 +11,12 @@ jobs:
api-tests: api-tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - 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 - name: Set up Docker
run: | run: |
docker --version docker --version
@ -24,7 +29,12 @@ jobs:
package-tests: package-tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - 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 - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
@ -53,17 +63,27 @@ jobs:
. .venv/bin/activate . .venv/bin/activate
uv pip install -e ".[dev]" uv pip install -e ".[dev]"
pytest -q pytest -q
- name: Run matching tests
run: |
cd packages/matching
uv venv
. .venv/bin/activate
uv pip install -e ".[dev]"
pytest -q
web-tests: web-tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - 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 - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "22" node-version: "22"
cache: npm
cache-dependency-path: apps/web/package-lock.json
- name: Install dependencies - name: Install dependencies
run: | run: |
cd apps/web cd apps/web
@ -75,4 +95,4 @@ jobs:
- name: Test - name: Test
run: | run: |
cd apps/web cd apps/web
npm test npm test

View 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 ""

View file

@ -20,6 +20,7 @@ WORKDIR /app/apps/api
RUN pip install --no-cache-dir -e ".[dev]" \ 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/llm-gateway \
&& pip install --no-cache-dir -e /app/packages/artifacts \ && 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 && pip install --no-cache-dir pypdf python-docx apscheduler
CMD ["pytest", "-q"] CMD ["pytest", "-q"]

View file

@ -55,6 +55,28 @@ def get_job_posting(posting_id: str) -> dict[str, Any] | None:
return _normalize_posting(row) 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]]: def list_postings() -> list[dict[str, Any]]:
rows = fetch_all("SELECT * FROM job_posting ORDER BY fetched_at DESC") rows = fetch_all("SELECT * FROM job_posting ORDER BY fetched_at DESC")
return [_normalize_posting(r) for r in rows] return [_normalize_posting(r) for r in rows]
@ -71,6 +93,8 @@ def _normalize_posting(row: dict[str, Any]) -> dict[str, Any]:
"location": row.get("location"), "location": row.get("location"),
"description": row.get("description", ""), "description": row.get("description", ""),
"fetched_at": row["fetched_at"].isoformat() if row.get("fetched_at") else None, "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,
} }
@ -482,4 +506,32 @@ def get_digest(limit: int = 20) -> list[dict[str, Any]]:
""", """,
(limit,), (limit,),
) )
return [_normalize_application(r) for r in rows] 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

View file

@ -128,9 +128,63 @@ MOCK_OUTPUTS: dict[str, dict[str, Any]] = {
"state_proposal": "interviewing", "state_proposal": "interviewing",
"reason": "The email mentions an interview invitation.", "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( def run_task(
task: str, task: str,
prompt: str, prompt: str,
@ -148,6 +202,8 @@ def run_task(
# Mock mode # Mock mode
time.sleep(0.01) # simulate latency time.sleep(0.01) # simulate latency
result = MOCK_OUTPUTS.get(task, {"result": "mock"}) result = MOCK_OUTPUTS.get(task, {"result": "mock"})
if task == "cv_tailor":
result = _mock_cv_tailor(prompt)
# Validate against schema if provided (basic check) # Validate against schema if provided (basic check)
# In real gateway this would be jsonschema validation # In real gateway this would be jsonschema validation

View file

@ -27,6 +27,8 @@ from app.schemas import (
BatchScoreRequest, BatchScoreRequest,
BatchScoreResponse, BatchScoreResponse,
BatchScoreResult, BatchScoreResult,
ClusterOut,
ClusterPostingOut,
CoverLetterRequest, CoverLetterRequest,
CoverLetterResponse, CoverLetterResponse,
CvImportConfirmRequest, CvImportConfirmRequest,
@ -36,6 +38,7 @@ from app.schemas import (
CvSectionCreate, CvSectionCreate,
CvSectionOut, CvSectionOut,
CvSectionUpdate, CvSectionUpdate,
DeadlineItem,
DigestItem, DigestItem,
EmailSuggestionOut, EmailSuggestionOut,
ErrorOut, ErrorOut,
@ -52,6 +55,7 @@ from app.schemas import (
ProfileUpdate, ProfileUpdate,
ScoreResponse, ScoreResponse,
SeedDemoResponse, SeedDemoResponse,
TailorCvResponse,
TaskRunOut, TaskRunOut,
TodayResponse, TodayResponse,
TransitionRequest, TransitionRequest,
@ -59,6 +63,20 @@ from app.schemas import (
from app.statemachine import TransitionContext, check_transition, InvalidTransition from app.statemachine import TransitionContext, check_transition, InvalidTransition
from app.transport import get_transport, reset_transport from app.transport import get_transport, reset_transport
# v1.1: matching package for cluster + coverage
try:
from matching import cluster as _cluster_postings, coverage as _keyword_coverage
HAS_MATCHING = True
except ImportError:
HAS_MATCHING = False
# v1.1: artifacts package for PDF rendering
try:
from artifacts import render_cv_pdf as _render_cv_pdf, hash_bytes as _hash_bytes, next_version as _next_version
HAS_ARTIFACTS = True
except ImportError:
HAS_ARTIFACTS = False
# Defensive import for connectors (may not exist yet) # Defensive import for connectors (may not exist yet)
try: try:
from packages.connectors.arbetsformedlingen import ArbetsformedlingenConnector # type: ignore from packages.connectors.arbetsformedlingen import ArbetsformedlingenConnector # type: ignore
@ -69,6 +87,18 @@ except ImportError:
app = FastAPI(title="Jobhunt API", version="0.2.0") app = FastAPI(title="Jobhunt API", version="0.2.0")
# Self-hosted single-user app; SPA origin differs from API origin (e.g. web:80 -> api:8000).
from fastapi.middleware.cors import CORSMiddleware
_cors_origins = os.environ.get("CORS_ORIGINS", "*").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in _cors_origins if o.strip()],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup") @app.on_event("startup")
def _startup() -> None: def _startup() -> None:
@ -159,6 +189,71 @@ def ai_assist(section_id: str, body: AiAssistRequest) -> Any:
# --- Job Postings --- # --- Job Postings ---
def _assign_cluster_id(new_posting_id: str) -> str | None:
"""Run cluster() over the new posting + all existing postings.
Reconcile for stability: if the new posting joins an existing cluster,
reuse the existing cluster_id. Otherwise assign a fresh cluster_id from
the clustering output.
Returns the cluster_id assigned to the new posting (or None if matching
package is unavailable or no postings exist).
"""
if not HAS_MATCHING:
return None
all_postings = repo_app.list_postings()
if not all_postings:
return None
# Build posting dicts for cluster() -- needs id, employer, title, description
posting_dicts = [
{
"id": p["id"],
"employer": p.get("company", ""),
"title": p.get("title", ""),
"description": p.get("description", ""),
}
for p in all_postings
]
clusters = _cluster_postings(posting_dicts)
# Find which cluster the new posting ended up in
for cluster_id, posting_ids in clusters.items():
if new_posting_id in posting_ids:
# Check if any existing posting in this cluster already has a cluster_id
existing_cluster_ids: set[str] = set()
for pid in posting_ids:
if pid == new_posting_id:
continue
for p in all_postings:
if p["id"] == pid and p.get("cluster_id"):
existing_cluster_ids.add(p["cluster_id"])
if existing_cluster_ids:
# Prefer existing cluster_id for stability
stable_id = sorted(existing_cluster_ids)[0]
else:
# New cluster: use the computed cluster_id
stable_id = cluster_id
# Assign cluster_id to all postings in the cluster that don't have one yet
for pid in posting_ids:
# Check if this posting already has the cluster_id
p_row = None
for p in all_postings:
if p["id"] == pid:
p_row = p
break
if p_row and p_row.get("cluster_id") != stable_id:
repo_app.update_posting_cluster_id(pid, stable_id)
return stable_id
return None
@app.post("/api/postings", response_model=ApplicationOut, status_code=201) @app.post("/api/postings", response_model=ApplicationOut, status_code=201)
def create_posting(body: JobPostingCreate) -> Any: def create_posting(body: JobPostingCreate) -> Any:
"""Create a job posting from a URL. For POC: manual_url source.""" """Create a job posting from a URL. For POC: manual_url source."""
@ -177,6 +272,10 @@ def create_posting(body: JobPostingCreate) -> Any:
description="", description="",
raw={"url": url}, raw={"url": url},
) )
# Cluster assignment
_assign_cluster_id(posting["id"])
application = repo_app.create_application(posting["id"]) application = repo_app.create_application(posting["id"])
return application return application
@ -186,6 +285,60 @@ def get_postings() -> Any:
return repo_app.list_postings() return repo_app.list_postings()
@app.get("/api/clusters", response_model=list[ClusterOut])
def get_clusters() -> Any:
"""Return job postings grouped by cluster_id, sorted by best score desc.
Each cluster includes postings with their id, title, company, source, url, score.
Clusters with no cluster_id are excluded.
"""
if not HAS_MATCHING:
return []
postings = repo_app.list_postings()
# Build a map of cluster_id -> list of postings
cluster_map: dict[str, list[dict[str, Any]]] = {}
for p in postings:
cid = p.get("cluster_id")
if not cid:
continue
cluster_map.setdefault(cid, []).append(p)
# For each cluster, look up application scores
apps = repo_app.list_applications()
# Build posting_id -> score map
score_map: dict[str, float | None] = {}
for a in apps:
score_map[a["job_posting_id"]] = a.get("score")
result: list[dict[str, Any]] = []
for cid, cluster_postings in cluster_map.items():
cluster_postings_out: list[dict[str, Any]] = []
best_score: float | None = None
for p in cluster_postings:
score = score_map.get(p["id"])
cluster_postings_out.append({
"id": p["id"],
"title": p.get("title", ""),
"company": p.get("company", ""),
"source": p.get("source", ""),
"url": p.get("url", ""),
"score": score,
})
if score is not None:
if best_score is None or score > best_score:
best_score = score
result.append({
"cluster_id": cid,
"postings": cluster_postings_out,
"_best_score": best_score,
})
# Sort by best score descending
result.sort(key=lambda c: (-(c.pop("_best_score") or -1)))
return result
@app.post("/api/postings/{posting_id}/score", response_model=ScoreResponse) @app.post("/api/postings/{posting_id}/score", response_model=ScoreResponse)
def score_posting(posting_id: str) -> Any: def score_posting(posting_id: str) -> Any:
"""Score a job posting against the profile.""" """Score a job posting against the profile."""
@ -217,6 +370,32 @@ def score_posting(posting_id: str) -> Any:
rationale = result.get("rationale", {}) rationale = result.get("rationale", {})
repo_app.update_application_score(app_for_posting["id"], score, rationale) repo_app.update_application_score(app_for_posting["id"], score, rationale)
# Deadline extraction (CHEAP task)
deadline_result = llm.run_task(
"deadline_extract",
f"Extract apply-by deadline from: {posting['title']} at {posting['company']}. Description: {posting.get('description', '')}",
telemetry_sink=lambda info: repo_app.create_task_run({
**info,
"application_id": app_for_posting["id"],
}),
)
apply_by = deadline_result.get("apply_by")
if apply_by is not None:
# Parse and persist
try:
from datetime import date as date_cls
if isinstance(apply_by, str):
parsed_date = date_cls.fromisoformat(apply_by)
elif isinstance(apply_by, date_cls):
parsed_date = apply_by
else:
parsed_date = None
if parsed_date is not None:
repo_app.update_posting_apply_by(posting["id"], parsed_date)
except (ValueError, TypeError):
pass # Skip invalid date
return {"score": score, "rationale": rationale} return {"score": score, "rationale": rationale}
@ -654,6 +833,8 @@ def _fetch_and_create_postings(query: str, region: str | None = None) -> dict[st
raw=rp.get("raw", {}), raw=rp.get("raw", {}),
) )
repo_app.create_application(posting["id"]) repo_app.create_application(posting["id"])
# Cluster assignment
_assign_cluster_id(posting["id"])
new_count += 1 new_count += 1
return {"new": new_count, "dupes": dupe_count} return {"new": new_count, "dupes": dupe_count}
@ -689,7 +870,13 @@ def postings_fetch(body: PostingsFetchRequest) -> Any:
# --- v1: Batch Scoring --- # --- v1: Batch Scoring ---
def _batch_score_internal(application_ids: list[str]) -> list[dict[str, Any]]: def _batch_score_internal(application_ids: list[str]) -> list[dict[str, Any]]:
"""Internal: score multiple applications, return results with red_flags.""" """Internal: score multiple applications, return results with red_flags.
Safety rule (post-bug): batch scoring only touches applications in
'discovered' or 'scored' state. Anything further down the pipeline
(approved/sent/interviewing/...) keeps its state and stored scores;
those rows are returned unchanged with skipped=True.
"""
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
for app_id in application_ids: for app_id in application_ids:
app_row = repo_app.get_application(app_id) app_row = repo_app.get_application(app_id)
@ -699,6 +886,17 @@ def _batch_score_internal(application_ids: list[str]) -> list[dict[str, Any]]:
if posting is None: if posting is None:
continue continue
if app_row["state"] not in ("discovered", "scored"):
existing = app_row.get("score_rationale") or {}
results.append({
"application_id": app_id,
"score": app_row.get("score"),
"rationale": existing,
"red_flags": existing.get("red_flags", []),
"skipped": True,
})
continue
result = llm.run_task( result = llm.run_task(
"score", "score",
f"Score this posting: {posting['title']} at {posting['company']}", f"Score this posting: {posting['title']} at {posting['company']}",
@ -713,6 +911,31 @@ def _batch_score_internal(application_ids: list[str]) -> list[dict[str, Any]]:
red_flags = result.get("red_flags", []) red_flags = result.get("red_flags", [])
repo_app.update_application_score(app_id, score, rationale) repo_app.update_application_score(app_id, score, rationale)
# Deadline extraction (CHEAP task)
deadline_result = llm.run_task(
"deadline_extract",
f"Extract apply-by deadline from: {posting['title']} at {posting['company']}. Description: {posting.get('description', '')}",
telemetry_sink=lambda info, aid=app_id: repo_app.create_task_run({
**info,
"application_id": aid,
}),
)
apply_by = deadline_result.get("apply_by")
if apply_by is not None:
try:
from datetime import date as date_cls
if isinstance(apply_by, str):
parsed_date = date_cls.fromisoformat(apply_by)
elif isinstance(apply_by, date_cls):
parsed_date = apply_by
else:
parsed_date = None
if parsed_date is not None:
repo_app.update_posting_apply_by(posting["id"], parsed_date)
except (ValueError, TypeError):
pass # Skip invalid date
results.append({ results.append({
"application_id": app_id, "application_id": app_id,
"score": score, "score": score,
@ -763,10 +986,23 @@ def get_today() -> Any:
# Pending approvals count # Pending approvals count
pending = repo_app.count_pending_approvals() pending = repo_app.count_pending_approvals()
# Deadlines: apply_by within next 7 days
deadline_rows = repo_app.get_upcoming_deadlines(days=7)
deadlines = [
DeadlineItem(
application_id=d["application_id"],
title=d["title"],
company=d["company"],
apply_by=d.get("apply_by"),
)
for d in deadline_rows
]
return { return {
"digest": digest, "digest": digest,
"nudges": nudges, "nudges": nudges,
"pending_approvals": pending, "pending_approvals": pending,
"deadlines": deadlines,
} }
@ -828,34 +1064,96 @@ def interview_prep(app_id: str) -> Any:
# --- v1: Concierge / Demo Seed --- # --- v1: Concierge / Demo Seed ---
def _seed_demo_counts() -> dict[str, Any]:
"""Compute counts for the seed-demo response from current DB state."""
postings = repo_app.list_postings()
apps = repo_app.list_applications()
sections = repo_profile.list_sections()
# Clusters: postings with a non-null cluster_id
cluster_postings = [p for p in postings if p.get("cluster_id")]
cluster_ids = set(p["cluster_id"] for p in cluster_postings if p["cluster_id"])
# Deadlines: postings with apply_by in the next 7 days
from datetime import date, timedelta
today = datetime.now(timezone.utc).date()
deadline_window = today + timedelta(days=7)
deadline_count = 0
for p in postings:
apply_by_raw = p.get("apply_by")
if not apply_by_raw:
continue
# _normalize_posting returns apply_by as ISO string
if isinstance(apply_by_raw, str):
apply_by_date = date.fromisoformat(apply_by_raw)
else:
apply_by_date = apply_by_raw
if today <= apply_by_date <= deadline_window:
deadline_count += 1
# Pending email suggestions
from app.imap_watch import list_pending_suggestions
suggestions = list_pending_suggestions()
# Notification log rows
from app.notify import list_notification_log
notifications = list_notification_log(limit=50)
# Task runs
task_runs = repo_app.list_task_runs()
# CV artifacts (kind=cv, origin=ai_drafted)
cv_count = 0
for a in apps:
artifacts = repo_app.list_artifacts(a["id"])
cv_count += sum(1 for art in artifacts if art["kind"] == "cv" and art["origin"] == "ai_drafted")
return {
"profile": "Demo Demosson",
"postings": len(postings),
"applications": len(apps),
"sections": len(sections),
"clusters": len(cluster_ids),
"deadlines": deadline_count,
"suggestions": len(suggestions),
"notifications": len(notifications),
"task_runs": len(task_runs),
"cv_artifacts": cv_count,
}
@app.post("/api/concierge/seed-demo", response_model=SeedDemoResponse) @app.post("/api/concierge/seed-demo", response_model=SeedDemoResponse)
def seed_demo() -> Any: def seed_demo() -> Any:
"""Idempotent demo seed: profile + 6 postings + varied application states.""" """Idempotent demo seed: profile + postings + varied application states + artifacts + telemetry.
Populates a complete demo-worthy dataset:
- 6 standalone postings with varied application states
- 3-posting agency cluster (same role reposted by 3 fictional agencies)
- 2 postings with apply_by deadlines in the next 4 days
- 1 posting with red_flags in its application score_rationale
- Applications: 1 sent (backdated 8 days for nudge), 1 interviewing, 1 approved with cover letter
- 2 pending email_suggestion rows
- 3 notification_log rows (daily_digest delivered, email_suggestion delivered, webhook failed)
- 6 task_run telemetry rows across providers/models
- 1 cv-tailor artifact (kind cv, origin ai_drafted) on the interviewing application
"""
from datetime import date, timedelta
# Check if demo profile already exists # Check if demo profile already exists
existing = fetch_one( existing = fetch_one(
"SELECT * FROM profile WHERE full_name = 'Demo Demosson'" "SELECT * FROM profile WHERE full_name = 'Demo Demosson'"
) )
if existing: if existing:
# Already seeded -- return current counts return _seed_demo_counts()
profile = repo_profile._normalize_profile(existing)
postings = repo_app.list_postings()
apps = repo_app.list_applications()
sections = repo_profile.list_sections()
return {
"profile": profile["full_name"],
"postings": len(postings),
"applications": len(apps),
"sections": len(sections),
}
# Create demo profile with Swedish characters # -- Create demo profile with Swedish characters --
repo_profile.get_or_create_profile() repo_profile.get_or_create_profile()
profile = repo_profile.update_profile({ profile = repo_profile.update_profile({
"full_name": "Demo Demosson", "full_name": "Demo Demosson",
"email": "demo@example.com", "email": "demo@example.com",
"location": "Malmo", "location": "Malmö",
"headline": "Software Developer", "headline": "Software Developer",
"summary": "Experienced developer looking for opportunities in Skane.", "summary": "Experienced developer looking for opportunities in Skåne.",
"languages": [{"code": "sv", "level": "native"}, {"code": "en", "level": "fluent"}], "languages": [{"code": "sv", "level": "native"}, {"code": "en", "level": "fluent"}],
}) })
if profile is None: if profile is None:
@ -863,7 +1161,7 @@ def seed_demo() -> Any:
# Create demo CV sections # Create demo CV sections
demo_sections = [ demo_sections = [
{"kind": "experience", "title": "Backend Developer", "org": "TechSkane AB", "bullets": ["Built REST APIs", "Improved performance by 30%"], "tags": ["python", "fastapi"]}, {"kind": "experience", "title": "Backend Developer", "org": "TechSkåne AB", "bullets": ["Built REST APIs", "Improved performance by 30%"], "tags": ["python", "fastapi"]},
{"kind": "experience", "title": "Junior Developer", "org": "Lund Software", "bullets": ["Maintained web apps"], "tags": ["javascript"]}, {"kind": "experience", "title": "Junior Developer", "org": "Lund Software", "bullets": ["Maintained web apps"], "tags": ["javascript"]},
{"kind": "education", "title": "MSc Computer Science", "org": "Lunds Universitet", "bullets": ["Distributed systems specialization"], "tags": ["algorithms"]}, {"kind": "education", "title": "MSc Computer Science", "org": "Lunds Universitet", "bullets": ["Distributed systems specialization"], "tags": ["algorithms"]},
{"kind": "skills", "title": "Technical Skills", "bullets": ["Python", "PostgreSQL", "Docker", "FastAPI"], "tags": ["python", "docker"]}, {"kind": "skills", "title": "Technical Skills", "bullets": ["Python", "PostgreSQL", "Docker", "FastAPI"], "tags": ["python", "docker"]},
@ -871,14 +1169,19 @@ def seed_demo() -> Any:
for s in demo_sections: for s in demo_sections:
repo_profile.create_section(profile["id"], s) repo_profile.create_section(profile["id"], s)
# Create 6 demo postings with varied states # -- Track application IDs for later linking --
sent_app_id: str | None = None
interviewing_app_id: str | None = None
approved_app_id: str | None = None
# -- Create 6 standalone demo postings with varied states --
demo_postings = [ demo_postings = [
{"company": "Skane Tech AB", "title": "Senior Python Developer", "location": "Malmo", "url": "https://example.com/af/1", "state": "scored", "score": 85}, {"company": "Skåne Tech AB", "title": "Senior Python Developer", "location": "Malmö", "url": "https://example.com/af/1", "state": "scored", "score": 85},
{"company": "Lund Systems", "title": "Fullstack Engineer", "location": "Lund", "url": "https://example.com/af/2", "state": "discovered", "score": None}, {"company": "Lund Systems", "title": "Fullstack Engineer", "location": "Lund", "url": "https://example.com/af/2", "state": "discovered", "score": None},
{"company": "Copenhagen Digital", "title": "Backend Developer", "location": "Copenhagen", "url": "https://example.com/af/3", "state": "approved", "score": 70}, {"company": "Copenhagen Digital", "title": "Backend Developer", "location": "Copenhagen", "url": "https://example.com/af/3", "state": "approved", "score": 70},
{"company": "Helsingborg IT", "title": "DevOps Engineer", "location": "Helsingborg", "url": "https://example.com/af/4", "state": "sent", "score": 65}, {"company": "Helsingborg IT", "title": "DevOps Engineer", "location": "Helsingborg", "url": "https://example.com/af/4", "state": "sent", "score": 65},
{"company": "Malmo Startup", "title": "Software Engineer", "location": "Malmo", "url": "https://example.com/af/5", "state": "rejected", "score": 30}, {"company": "Malmö Startup", "title": "Software Engineer", "location": "Malmö", "url": "https://example.com/af/5", "state": "rejected", "score": 30},
{"company": "Angelholm Tech", "title": "Data Engineer", "location": "Angelholm", "url": "https://example.com/af/6", "state": "discovered", "score": None}, {"company": "Ängelholm Tech", "title": "Data Engineer", "location": "Ängelholm", "url": "https://example.com/af/6", "state": "discovered", "score": None},
] ]
for dp in demo_postings: for dp in demo_postings:
@ -893,48 +1196,264 @@ def seed_demo() -> Any:
) )
app_row = repo_app.create_application(posting["id"]) app_row = repo_app.create_application(posting["id"])
if dp["state"] == "sent":
sent_app_id = app_row["id"]
if dp["state"] == "approved":
approved_app_id = app_row["id"]
# Set state and score # Set state and score
if dp["score"] is not None: if dp["score"] is not None:
repo_app.update_application_score(app_row["id"], dp["score"], {"factors": {}}) repo_app.update_application_score(app_row["id"], dp["score"], {"factors": {}})
if dp["state"] != "discovered" and dp["state"] != "scored": if dp["state"] != "discovered" and dp["state"] != "scored":
# Transition through states
if dp["state"] in ("approved", "rejected"): if dp["state"] in ("approved", "rejected"):
# First set to scored if needed
if dp["score"] is not None: if dp["score"] is not None:
repo_app.update_application_state(app_row["id"], "scored") repo_app.update_application_state(app_row["id"], "scored")
repo_app.update_application_state(app_row["id"], dp["state"]) repo_app.update_application_state(app_row["id"], dp["state"])
elif dp["state"] == "sent": elif dp["state"] == "sent":
# approved -> drafting -> sent
if dp["score"] is not None: if dp["score"] is not None:
repo_app.update_application_state(app_row["id"], "scored") 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"], "approved")
repo_app.update_application_state(app_row["id"], "drafting") repo_app.update_application_state(app_row["id"], "drafting")
# We need confirmed approval for drafting->sent, so directly set state
execute( execute(
"UPDATE application SET state = 'sent', state_changed_at = now(), last_activity_at = now() WHERE id = %s", "UPDATE application SET state = 'sent', state_changed_at = now(), last_activity_at = now() WHERE id = %s",
(app_row["id"],), (app_row["id"],),
) )
# Backdate the 'sent' application ( posting 4) to 8 days ago for nudge demo # -- 3-posting AGENCY CLUSTER (same real role, 3 fictional agencies, near-identical title+description) --
from datetime import timedelta cluster_title = "Senior Backend Developer"
backdated = datetime.now(timezone.utc) - timedelta(days=8) cluster_desc = (
"We are looking for a senior backend developer with strong Python skills. "
"You will build and maintain scalable REST APIs, work with PostgreSQL, "
"and collaborate in an agile team. Experience with Docker and cloud "
"deployment is a plus."
)
agency_companies = ["Aderanto AB", "Wise IT", "TechTalent Nord"]
agency_posting_ids: list[str] = []
for i, agency in enumerate(agency_companies):
posting = repo_app.create_job_posting(
source="arbetsformedlingen",
url=f"https://example.com/af/agency/{i+1}",
company=agency,
title=cluster_title,
location="Stockholm",
description=cluster_desc,
raw={},
)
agency_posting_ids.append(posting["id"])
# Create applications for these too (scored for digest demo)
app_row = repo_app.create_application(posting["id"])
repo_app.update_application_score(app_row["id"], 75 + i, {"factors": {}})
# Assign cluster IDs to all postings (the matching package will cluster the 3 agency postings together)
for pid in agency_posting_ids:
_assign_cluster_id(pid)
# -- 2 postings with apply_by in the next 4 days (deadlines strip) --
today = datetime.now(timezone.utc).date()
for j in range(2):
deadline = today + timedelta(days=2 + j)
posting = repo_app.create_job_posting(
source="arbetsformedlingen",
url=f"https://example.com/af/deadline/{j+1}",
company=f"Deadline Corp {j+1}",
title=f"Urgent Developer Role {j+1}",
location="Göteborg",
description="Urgent hire for a developer with deadline approaching.",
raw={},
)
repo_app.update_posting_apply_by(posting["id"], deadline)
app_row = repo_app.create_application(posting["id"])
repo_app.update_application_score(app_row["id"], 60 + j * 5, {"factors": {}})
# -- 1 posting with red_flags on its application score_rationale --
red_flag_posting = repo_app.create_job_posting(
source="arbetsformedlingen",
url="https://example.com/af/redflag/1",
company="ShadyCorp AB",
title="Junior Developer",
location="Remote",
description="Entry level developer position with trial period.",
raw={},
)
red_flag_app = repo_app.create_application(red_flag_posting["id"])
red_flag_rationale = {
"factors": {"salary": "below market rate", "trial_period": "3 months unpaid"},
"red_flags": ["requests unpaid trial work", "salary significantly below market rate"],
"summary": "Multiple red flags detected during scoring.",
}
repo_app.update_application_score(red_flag_app["id"], 25, red_flag_rationale)
# -- Application in 'interviewing' state --
interviewing_posting = repo_app.create_job_posting(
source="arbetsformedlingen",
url="https://example.com/af/interview/1",
company="Festina Digital AB",
title="Full Stack Developer",
location="Malmö",
description="Full stack developer with React, Python, and cloud experience.",
raw={},
)
interviewing_app = repo_app.create_application(interviewing_posting["id"])
repo_app.update_application_score(interviewing_app["id"], 90, {"factors": {}})
repo_app.update_application_state(interviewing_app["id"], "scored")
repo_app.update_application_state(interviewing_app["id"], "approved")
repo_app.update_application_state(interviewing_app["id"], "drafting")
execute( execute(
"UPDATE application SET last_activity_at = %s WHERE state = 'sent'", "UPDATE application SET state = 'sent', state_changed_at = now(), last_activity_at = now() WHERE id = %s",
(backdated,), (interviewing_app["id"],),
)
repo_app.update_application_state(interviewing_app["id"], "interviewing")
interviewing_app_id = interviewing_app["id"]
# -- Cover letter artifact on the 'approved' application (Swedish text, origin user_drafted) --
storage_dir = os.path.join(tempfile.gettempdir(), "jobhunt_artifacts")
os.makedirs(storage_dir, exist_ok=True)
if approved_app_id:
cover_text = (
"Basta rekryterare,\n\n"
"Jag ansoker om tjansen som Backend Developer hos er. "
"Med min erfarenhet av Python, FastAPI och PostgreSQL "
"tror jag att jag skulle vara en bra tillgang for ert team.\n\n"
"Jag ser fram emot att diskutera rollen vidare.\n\n"
"Vanliga halsningar,\n"
"Demo Demosson"
)
cover_bytes = cover_text.encode("utf-8")
cl_filename = f"cover_letter_{approved_app_id[:8]}.txt"
cl_storage_path = os.path.join(storage_dir, cl_filename)
with open(cl_storage_path, "wb") as f:
f.write(cover_bytes)
repo_app.create_artifact(
application_id=approved_app_id,
kind="cover_letter",
filename=cl_filename,
content_bytes=cover_bytes,
storage_path=cl_storage_path,
origin="user_drafted",
)
# -- CV tailor artifact on the interviewing application (kind cv, origin ai_drafted, real PDF bytes + hash) --
if interviewing_app_id and HAS_ARTIFACTS:
_render_fn = _render_cv_pdf # type: ignore[possibly-unbound]
cv_profile = {
"full_name": profile.get("full_name", ""),
"headline": profile.get("headline", ""),
"email": profile.get("email", ""),
"phone": profile.get("phone", ""),
"location": profile.get("location", ""),
"summary": "Tailored CV for full stack developer role at Festina Digital.",
}
cv_sections = [
{
"kind": "experience",
"title": "Backend Developer",
"org": "TechSkane AB",
"bullets": ["Built REST APIs", "Improved performance by 30%"],
},
{
"kind": "skills",
"title": "Technical Skills",
"bullets": ["Python", "PostgreSQL", "Docker", "FastAPI", "React"],
},
]
pdf_bytes = _render_fn(cv_profile, cv_sections)
cv_filename = f"cv_tailored_{interviewing_app_id[:8]}.pdf"
cv_storage_path = os.path.join(storage_dir, cv_filename)
with open(cv_storage_path, "wb") as f:
f.write(pdf_bytes)
repo_app.create_artifact(
application_id=interviewing_app_id,
kind="cv",
filename=cv_filename,
content_bytes=pdf_bytes,
storage_path=cv_storage_path,
origin="ai_drafted",
)
# -- Backdate the 'sent' application to 8 days ago for nudge demo --
backdated = datetime.now(timezone.utc) - timedelta(days=8)
if sent_app_id:
execute(
"UPDATE application SET last_activity_at = %s WHERE id = %s",
(backdated, sent_app_id),
)
# -- 2 pending email_suggestion rows --
from app.imap_watch import create_email_suggestion
suggestion_time = datetime.now(timezone.utc) - timedelta(hours=3)
if sent_app_id:
create_email_suggestion(
application_id=sent_app_id,
mailbox_from="recruiter@festina-demo.se",
subject="Inbjudan till intervju",
snippet="Hej, vi skulle vilja boka in en intervju med dig...",
classification="interview_invite",
state_proposal="interviewing",
received_at=suggestion_time,
)
if interviewing_app_id:
create_email_suggestion(
application_id=interviewing_app_id,
mailbox_from="hr@festina-demo.se",
subject="Fråga om din erfarenhet",
snippet="Vi har några frågor om din bakgrund inom Python...",
classification="question",
state_proposal=None,
received_at=datetime.now(timezone.utc) - timedelta(hours=1),
)
# -- 3 notification_log rows --
# 1) daily_digest delivered (via LogChannel-style insert)
execute(
"""
INSERT INTO notification_log (channel, kind, payload, delivered, error)
VALUES ('log', 'daily_digest', %s, true, NULL)
""",
(json.dumps({"text": "Your daily digest is ready", "items": 5}),),
) )
# Count results # 2) email_suggestion delivered
postings_count = len(repo_app.list_postings()) execute(
apps_count = len(repo_app.list_applications()) """
sections_count = len(repo_profile.list_sections()) INSERT INTO notification_log (channel, kind, payload, delivered, error)
VALUES ('log', 'email_suggestion', %s, true, NULL)
""",
(json.dumps({"text": "New email suggestion received", "suggestion_id": "demo"}),),
)
return { # 3) webhook failed with error text
"profile": "Demo Demosson", execute(
"postings": postings_count, """
"applications": apps_count, INSERT INTO notification_log (channel, kind, payload, delivered, error)
"sections": sections_count, VALUES ('webhook', 'daily_digest', %s, false, %s)
} """,
(
json.dumps({"text": "Daily digest delivery attempt", "items": 5}),
"HTTP 503: Service Unavailable (webhook endpoint down)",
),
)
# -- 6 task_run telemetry rows across providers/models --
demo_task_runs = [
{"task": "score_application", "model": "gpt-4o-mini", "provider": "openai", "input_tokens": 1200, "output_tokens": 80, "cost_usd": 0.0012, "duration_ms": 1500},
{"task": "score_application", "model": "gpt-4o", "provider": "openai", "input_tokens": 1500, "output_tokens": 120, "cost_usd": 0.0180, "duration_ms": 2200},
{"task": "cv_tailor", "model": "claude-sonnet-4-20250514", "provider": "anthropic", "input_tokens": 3000, "output_tokens": 800, "cost_usd": 0.0450, "duration_ms": 4500},
{"task": "cl_critique", "model": "gpt-4o-mini", "provider": "openai", "input_tokens": 900, "output_tokens": 200, "cost_usd": 0.0009, "duration_ms": 1800},
{"task": "interview_prep", "model": "claude-3-5-sonnet-20241022", "provider": "anthropic", "input_tokens": 2200, "output_tokens": 600, "cost_usd": 0.0330, "duration_ms": 3100},
{"task": "score_application", "model": "gemini-1.5-flash", "provider": "google", "input_tokens": 1000, "output_tokens": 90, "cost_usd": 0.0005, "duration_ms": 900},
]
link_app = interviewing_app_id or sent_app_id
for idx, tr in enumerate(demo_task_runs):
repo_app.create_task_run({
**tr,
"application_id": link_app if idx % 2 == 0 else None,
})
# -- Return counts --
return _seed_demo_counts()
# --- v1.1: Email Suggestions --- # --- v1.1: Email Suggestions ---
@ -1039,4 +1558,175 @@ def dismiss_suggestion(suggestion_id: str) -> Any:
def get_notification_log() -> Any: def get_notification_log() -> Any:
"""Return last 50 notification log entries.""" """Return last 50 notification log entries."""
from app.notify import list_notification_log from app.notify import list_notification_log
return list_notification_log(limit=50) return list_notification_log(limit=50)
# --- v1.1: Tailor CV ---
@app.post("/api/applications/{app_id}/tailor-cv", response_model=TailorCvResponse)
def tailor_cv(app_id: str) -> Any:
"""Tailor the user's CV for the posting linked to this application.
Uses cv_tailor (STRONG task) to generate a tailored CV variant.
Validates the output: every tailored bullet must map to a source bullet
id from the input (hallucination guard). Rejects + 502 on unmapped bullets.
Stores artifact kind='cv' origin='ai_drafted' + renders PDF via packages/artifacts.
Returns {artifact_id, change_log, keyword_coverage}.
"""
app_row = repo_app.get_application(app_id)
if app_row is None:
raise HTTPException(status_code=404, detail="Application not found")
posting = repo_app.get_job_posting(app_row["job_posting_id"])
if posting is None:
raise HTTPException(status_code=404, detail="Posting not found")
# Build profile + sections from the profile repo
profile = repo_profile.get_or_create_profile()
if profile is None:
raise HTTPException(status_code=500, detail="No profile found")
sections = repo_profile.list_sections()
# Build the prompt with profile + sections + posting description
import json as _json
prompt_parts = [
f"Profile: {_json.dumps({k: profile.get(k) for k in ('full_name', 'headline', 'email', 'phone', 'location', 'summary')}, default=str)}",
f"Sections: {_json.dumps(sections, default=str)}",
f"Posting: title={posting.get('title', '')}, company={posting.get('company', '')}, description={posting.get('description', '')}",
]
prompt = "\n".join(prompt_parts)
# Run cv_tailor (STRONG task)
result = llm.run_task(
"cv_tailor",
prompt,
telemetry_sink=lambda info: repo_app.create_task_run({
**info,
"application_id": app_id,
}),
)
tailored_cv = result.get("tailored_cv", {})
change_log = result.get("change_log", [])
# Hallucination guard: every tailored bullet must map to a source bullet id.
# We collect all source bullet texts from sections and check that each
# bullet in the tailored output is a rephrase/reorder of an existing one.
source_bullets: set[str] = set()
for s in sections:
for b in s.get("bullets", []):
source_bullets.add(str(b).lower().strip())
# Check bullets in tailored experience sections
unmapped_bullets: list[str] = []
for exp in tailored_cv.get("experience", []):
for bullet in exp.get("bullets", []):
bullet_lower = str(bullet).lower().strip()
# Check if this bullet is a rephrase of any source bullet.
# We use a simple containment check: at least 50% of the words
# in the tailored bullet should appear in some source bullet.
bullet_words = set(bullet_lower.split())
if not bullet_words:
continue
found = False
for sb in source_bullets:
sb_words = set(sb.split())
if not sb_words:
continue
overlap = len(bullet_words & sb_words) / len(bullet_words)
if overlap >= 0.5:
found = True
break
if not found and source_bullets:
unmapped_bullets.append(bullet)
if unmapped_bullets:
raise HTTPException(
status_code=502,
detail={
"code": "hallucination_guard",
"message": f"Tailored CV contains bullets that do not map to source bullets: {unmapped_bullets}",
},
)
# Build sections for PDF rendering
pdf_sections: list[dict[str, Any]] = []
# Add experience sections from tailored CV
for exp in tailored_cv.get("experience", []):
pdf_sections.append({
"kind": "experience",
"title": exp.get("role", ""),
"org": exp.get("company", ""),
"bullets": exp.get("bullets", []),
})
# Add skills section
if tailored_cv.get("skills"):
pdf_sections.append({
"kind": "skills",
"title": "Technical Skills",
"bullets": tailored_cv.get("skills", []),
})
# If no experience sections from tailor, fall back to original sections
if not pdf_sections:
pdf_sections = sections
# Render PDF
if not HAS_ARTIFACTS:
raise HTTPException(
status_code=500,
detail={"code": "missing_dependency", "message": "artifacts package not installed"},
)
pdf_profile = {
"full_name": profile.get("full_name", ""),
"headline": profile.get("headline", ""),
"email": profile.get("email", ""),
"phone": profile.get("phone", ""),
"location": profile.get("location", ""),
"summary": tailored_cv.get("summary", profile.get("summary", "")),
}
pdf_bytes = _render_cv_pdf(pdf_profile, pdf_sections)
# Store artifact
storage_dir = os.path.join(tempfile.gettempdir(), "jobhunt_artifacts")
os.makedirs(storage_dir, exist_ok=True)
filename = f"cv_tailored_{app_id[:8]}.pdf"
storage_path = os.path.join(storage_dir, filename)
with open(storage_path, "wb") as f:
f.write(pdf_bytes)
# Get existing CV artifacts for version numbering
existing_artifacts = repo_app.list_artifacts(app_id)
cv_versions = [a["version"] for a in existing_artifacts if a["kind"] == "cv"]
version = _next_version(cv_versions)
artifact = repo_app.create_artifact(
application_id=app_id,
kind="cv",
filename=filename,
content_bytes=pdf_bytes,
storage_path=storage_path,
origin="ai_drafted",
version=version,
)
# Compute keyword coverage
# Build CV text from the tailored sections
cv_text_parts = [
pdf_profile.get("summary", ""),
]
for s in pdf_sections:
cv_text_parts.append(s.get("title", ""))
cv_text_parts.append(s.get("org", ""))
cv_text_parts.extend(s.get("bullets", []))
cv_text = " ".join(str(p) for p in cv_text_parts)
posting_description = posting.get("description", "") or f"{posting.get('title', '')} at {posting.get('company', '')}"
keyword_coverage = _keyword_coverage(cv_text, posting_description) if HAS_MATCHING else {"matched": [], "missing": [], "ratio": 0.0}
return {
"artifact_id": artifact["id"],
"change_log": change_log,
"keyword_coverage": keyword_coverage,
}

View file

@ -108,6 +108,8 @@ class JobPostingOut(BaseModel):
location: str | None = None location: str | None = None
description: str = "" description: str = ""
fetched_at: str | None = None fetched_at: str | None = None
cluster_id: str | None = None
apply_by: str | None = None
class ScoreResponse(BaseModel): class ScoreResponse(BaseModel):
@ -304,6 +306,7 @@ class TodayResponse(BaseModel):
digest: list[DigestItem] digest: list[DigestItem]
nudges: list[NudgeItem] nudges: list[NudgeItem]
pending_approvals: int pending_approvals: int
deadlines: list[DeadlineItem] = []
# --- v1: Interview Prep --- # --- v1: Interview Prep ---
@ -320,6 +323,12 @@ class SeedDemoResponse(BaseModel):
postings: int postings: int
applications: int applications: int
sections: 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 --- # --- v1.1: Email Suggestions ---
@ -346,4 +355,37 @@ class NotificationLogOut(BaseModel):
payload: dict[str, Any] = {} payload: dict[str, Any] = {}
delivered: bool delivered: bool
error: str | None = None error: str | None = None
created_at: 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

View 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;

View 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]

View file

@ -279,14 +279,22 @@ class TestInterviewPrep:
class TestSeedDemo: class TestSeedDemo:
def test_seed_demo_creates_data(self, client): def test_seed_demo_creates_data(self, client):
"""POST /concierge/seed-demo creates profile, postings, applications.""" """POST /concierge/seed-demo creates profile, postings, applications, and extended data."""
resp = client.post("/api/concierge/seed-demo") resp = client.post("/api/concierge/seed-demo")
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["profile"] == "Demo Demosson" assert data["profile"] == "Demo Demosson"
assert data["postings"] == 6
assert data["applications"] == 6
assert data["sections"] == 4 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): def test_seed_demo_idempotent(self, client):
"""Running seed-demo twice returns the same counts.""" """Running seed-demo twice returns the same counts."""
@ -298,6 +306,12 @@ class TestSeedDemo:
assert resp2.json()["postings"] == resp1.json()["postings"] assert resp2.json()["postings"] == resp1.json()["postings"]
assert resp2.json()["applications"] == resp1.json()["applications"] assert resp2.json()["applications"] == resp1.json()["applications"]
assert resp2.json()["sections"] == resp1.json()["sections"] 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): def test_seed_demo_has_nudge_candidate(self, client):
"""After seeding, /today should show a nudge for the backdated sent app.""" """After seeding, /today should show a nudge for the backdated sent app."""
@ -313,6 +327,104 @@ class TestSeedDemo:
digest = resp.json()["digest"] digest = resp.json()["digest"]
assert len(digest) >= 1 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 --- # --- SMTP Transport ---

14
apps/web/Dockerfile Normal file
View 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
View 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;
}
}

View file

@ -7,20 +7,25 @@ import type {
Approval, Approval,
Artifact, Artifact,
BatchScoringResponse, BatchScoringResponse,
Cluster,
CoverLetterResponse, CoverLetterResponse,
CritiqueComment, CritiqueComment,
CvImportConfirmResponse, CvImportConfirmResponse,
CvImportResponse, CvImportResponse,
CvSection, CvSection,
DemoSeedResponse, DemoSeedResponse,
EmailSuggestion,
InterviewPrepResponse, InterviewPrepResponse,
JobPosting, JobPosting,
NotificationLogEntry,
PostingsFetchResponse, PostingsFetchResponse,
Profile, Profile,
RenderCvResponse, RenderCvResponse,
ScoreResponse, ScoreResponse,
TailorCvResponse,
TaskRun, TaskRun,
TodayResponse TodayResponse,
TodayResponseV11
} from '@/types' } from '@/types'
const API_BASE: string = const API_BASE: string =
@ -200,8 +205,8 @@ export function batchScore(applicationIds: string[]): Promise<BatchScoringRespon
}) })
} }
export function getToday(): Promise<TodayResponse> { export function getToday(): Promise<TodayResponseV11> {
return request<TodayResponse>('/today') return request<TodayResponseV11>('/today')
} }
export function interviewPrep(applicationId: string): Promise<InterviewPrepResponse> { export function interviewPrep(applicationId: string): Promise<InterviewPrepResponse> {
@ -214,6 +219,34 @@ export function seedDemo(): Promise<DemoSeedResponse> {
return request<DemoSeedResponse>('/concierge/seed-demo', { method: 'POST' }) 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,
@ -221,18 +254,23 @@ export type {
Approval, Approval,
Artifact, Artifact,
BatchScoringResponse, BatchScoringResponse,
Cluster,
CoverLetterResponse, CoverLetterResponse,
CritiqueComment, CritiqueComment,
CvImportConfirmResponse, CvImportConfirmResponse,
CvImportResponse, CvImportResponse,
CvSection, CvSection,
DemoSeedResponse, DemoSeedResponse,
EmailSuggestion,
InterviewPrepResponse, InterviewPrepResponse,
JobPosting, JobPosting,
NotificationLogEntry,
PostingsFetchResponse, PostingsFetchResponse,
Profile, Profile,
RenderCvResponse, RenderCvResponse,
ScoreResponse, ScoreResponse,
TailorCvResponse,
TaskRun, TaskRun,
TodayResponse TodayResponse,
TodayResponseV11
} }

View file

@ -18,6 +18,23 @@ const totalCost = computed(() =>
) )
const hasCost = computed(() => tasks.value.some((t) => t.cost != null)) 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() { async function loadTasks() {
try { try {
tasks.value = await api.getTelemetryTasks() tasks.value = await api.getTelemetryTasks()
@ -50,6 +67,31 @@ onMounted(loadTasks)
<span class="font-medium">{{ totalCost.toFixed(4) }}</span> <span class="font-medium">{{ totalCost.toFixed(4) }}</span>
</div> </div>
<div class="text-xs text-gray-400 mt-1">{{ tasks.length }} task runs</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>
</div> </div>
</template> </template>

View file

@ -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'
@ -208,4 +210,75 @@ export interface TaskRun {
export interface RedFlagsMap { export interface RedFlagsMap {
[applicationId: string]: string[] [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
} }

View 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')
})
})

View file

@ -34,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'
}
} }
} }

View file

@ -10,7 +10,8 @@ import type {
Approval, Approval,
ApprovalAction, ApprovalAction,
CoverLetterResponse, CoverLetterResponse,
CritiqueComment CritiqueComment,
TailorCvResponse
} from '@/types' } from '@/types'
const props = defineProps<{ id: string }>() const props = defineProps<{ id: string }>()
@ -36,6 +37,10 @@ const sending = ref(false)
// Interview prep modal // Interview prep modal
const showPrepModal = ref(false) 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)
@ -47,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()
@ -117,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'
@ -139,6 +167,27 @@ function closeInterviewPrep() {
showPrepModal.value = false 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>
@ -151,14 +200,77 @@ 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 --> <!-- Interview prep -->
<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">Interview Prep</h2> <h2 class="font-semibold">Interview Prep</h2>
@ -184,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>

View file

@ -30,11 +30,9 @@ function makeApp(id: string, state: string, company: string): 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,
id: 'j-' + id, source: 'manual_url', external_id: null, url: 'http://x', title: 'Engineer',
company, title: 'Engineer', location: 'Remote', description: '', location: 'Remote'
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
}
} }
} }

View file

@ -31,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',
@ -46,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'
}
} }
] ]
} }

View file

@ -41,7 +41,10 @@ function hasRedFlags(app: Application): boolean {
} }
function redFlagsFor(app: Application): string[] { function redFlagsFor(app: Application): string[] {
return redFlagsMap.value[app.id] ?? [] 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 { function hasNudge(app: Application): boolean {
@ -53,7 +56,11 @@ async function loadApplications() {
applications.value = await api.getApplications() applications.value = await api.getApplications()
// Load red flags via batch scoring and nudges via today endpoint // Load red flags via batch scoring and nudges via today endpoint
const [batchResult, todayResult] = await Promise.allSettled([ const [batchResult, todayResult] = await Promise.allSettled([
api.batchScore(applications.value.map((a) => a.id)), api.batchScore(
applications.value
.filter((a) => a.state === 'discovered' || a.state === 'scored')
.map((a) => a.id)
),
api.getToday() api.getToday()
]) ])
if (batchResult.status === 'fulfilled') { if (batchResult.status === 'fulfilled') {
@ -164,9 +171,9 @@ onMounted(loadApplications)
class="inline-block w-2 h-2 rounded-full bg-orange-500 flex-shrink-0" class="inline-block w-2 h-2 rounded-full bg-orange-500 flex-shrink-0"
title="Follow-up nudge pending" title="Follow-up nudge pending"
></span> ></span>
<div class="font-medium text-sm truncate">{{ app.posting?.company ?? 'Unknown' }}</div> <div class="font-medium text-sm truncate">{{ app.company ?? 'Unknown' }}</div>
</div> </div>
<div class="text-xs text-gray-500 truncate">{{ app.posting?.title ?? 'No title' }}</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>

View 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')
})
})

View file

@ -1,18 +1,20 @@
<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 { HttpError } from '@/api' import { HttpError } from '@/api'
import type { JobPosting } from '@/types' 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 redFlagsMap = ref<Record<string, string[]>>({})
const expandedClusters = ref<Set<string>>(new Set())
// Fetch form // Fetch form
const fetchQuery = ref('') const fetchQuery = ref('')
@ -20,9 +22,51 @@ const fetchRegion = ref('')
const fetching = ref(false) const fetching = ref(false)
const fetchResult = ref<{ new: number; dupes: number } | null>(null) 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 // Load red flags for existing postings via batch scoring
if (postings.value.length > 0) { if (postings.value.length > 0) {
try { try {
@ -109,7 +153,7 @@ onMounted(loadPostings)
<!-- Fetch form (Arbetsformedlingen connector) --> <!-- Fetch form (Arbetsformedlingen connector) -->
<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">Fetch from Arbetsformedlingen</h2> <h2 class="font-semibold">Fetch from Arbetsförmedlingen</h2>
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
v-model="fetchQuery" v-model="fetchQuery"
@ -150,49 +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">Scam</th> <!-- Cluster header -->
<th class="px-3 py-2">Fetched</th> <div
<th class="px-3 py-2">Actions</th> v-if="clusterAlternatives(group.cluster_id).length > 0"
</tr> class="flex items-center justify-between px-4 py-2 border-b border-gray-100 cursor-pointer hover:bg-gray-50"
</thead> @click="toggleExpand(group.cluster_id)"
<tbody> >
<tr v-for="p in postings" :key="p.id" class="border-t border-gray-100"> <span class="text-sm font-medium text-gray-700">
<td class="px-3 py-2">{{ p.company }}</td> {{ group.items[0]?.company ?? 'Unknown' }} - {{ group.items[0]?.title ?? 'No title' }}
<td class="px-3 py-2">{{ p.title }}</td> </span>
<td class="px-3 py-2">{{ p.location }}</td> <span class="text-xs text-gray-500" data-testid="cluster-alternates-toggle">
<td class="px-3 py-2">{{ p.source }}</td> Also via {{ clusterAlternatives(group.cluster_id).length }} more
<td class="px-3 py-2"> <span v-if="isExpanded(group.cluster_id)">&#x25B2;</span>
<span <span v-else>&#x25BC;</span>
v-if="hasScamFlag(p)" </span>
class="text-red-600 font-bold" </div>
:title="scamFlagsFor(p).join('; ')"
<!-- 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('; ')"
>
&#x26A0;
</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"
> >
&#x26A0; <span>
</span> <a :href="alt.url" target="_blank" rel="noopener" class="text-indigo-600 hover:underline">
<span v-else class="text-gray-400">-</span> {{ alt.company }}
</td> </a>
<td class="px-3 py-2 text-gray-500">{{ p.fetched_at?.slice(0, 10) }}</td> <span class="text-gray-400 ml-2">({{ alt.source }})</span>
<td class="px-3 py-2"> </span>
<button <span v-if="alt.score" class="text-xs bg-green-100 text-green-800 rounded px-2 py-0.5">
@click="scorePosting(p)" {{ alt.score }}
:disabled="scoringId === p.id" </span>
class="text-indigo-600 hover:underline text-sm" </li>
> </ul>
{{ scoringId === p.id ? 'Scoring...' : 'Score' }} </div>
</button> </div>
<span v-if="scoreMap[p.id]" class="ml-2 text-xs bg-green-100 text-green-800 rounded px-2 py-0.5"> </div>
{{ scoreMap[p.id].score }}
</span>
</td>
</tr>
</tbody>
</table>
</div> </div>
</template> </template>

View 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')
})
})

View 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')
})
})

View file

@ -4,19 +4,73 @@ 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 CostDisplay from '@/components/CostDisplay.vue' import CostDisplay from '@/components/CostDisplay.vue'
import type { TodayResponse } from '@/types' import type { TodayResponseV11, TodayDeadline, EmailSuggestion, NotificationLogEntry, SuggestionClassification } from '@/types'
const toast = useToastStore() const toast = useToastStore()
const router = useRouter() const router = useRouter()
const today = ref<TodayResponse | null>(null) const today = ref<TodayResponseV11 | null>(null)
const loading = ref(true) 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 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() { async function loadToday() {
try { try {
today.value = await api.getToday() 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 { } catch {
toast.push('Failed to load today digest', 'error') toast.push('Failed to load today digest', 'error')
} finally { } finally {
@ -24,6 +78,36 @@ async function loadToday() {
} }
} }
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) { function goToApplication(id: string) {
router.push(`/applications/${id}`) router.push(`/applications/${id}`)
} }
@ -56,6 +140,76 @@ onMounted(loadToday)
</span> </span>
</div> </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 --> <!-- Digest cards -->
<section> <section>
<h2 class="font-semibold text-lg mb-3">Top Matches Today</h2> <h2 class="font-semibold text-lg mb-3">Top Matches Today</h2>
@ -115,6 +269,18 @@ onMounted(loadToday)
</div> </div>
</section> </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 --> <!-- Cost display -->
<CostDisplay /> <CostDisplay />
</template> </template>

View file

@ -223,7 +223,7 @@ function prev() {
<div v-if="step === 2" class="bg-white rounded-lg border border-gray-200 p-6 space-y-4"> <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> <h2 class="font-semibold text-lg">Fetch Job Postings</h2>
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600">
Search for job postings from the Arbetsformedlingen connector. New postings will be added to your applications. Search for job postings from the Arbetsförmedlingen connector. New postings will be added to your applications.
</p> </p>
<div class="space-y-2"> <div class="space-y-2">
<label class="block"> <label class="block">

57
docker-compose.prod.yml Normal file
View 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

View file

@ -8,8 +8,8 @@ services:
POSTGRES_USER: jobhunt POSTGRES_USER: jobhunt
POSTGRES_PASSWORD: jobhunt POSTGRES_PASSWORD: jobhunt
POSTGRES_DB: jobhunt POSTGRES_DB: jobhunt
ports: # No host port publishing: CI/tests run inside the compose network, and on
- "5433:5432" # this host 5433 is already taken by bilhej-postgres-prod.
volumes: volumes:
- jobhunt_pgdata:/var/lib/postgresql/data - jobhunt_pgdata:/var/lib/postgresql/data
healthcheck: healthcheck:
@ -29,6 +29,42 @@ services:
condition: service_healthy condition: service_healthy
restart: "no" 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: volumes:
jobhunt_pgdata: jobhunt_pgdata:
name: jobhunt_pgdata name: jobhunt_pgdata
shots_out:

View file

@ -116,4 +116,33 @@ This transparency helps you make informed decisions about when to use AI feature
- 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. - 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. - 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. - 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. - 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.

View 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.

View file

@ -30,9 +30,12 @@ TASK_CLASS_MAP: dict[str, TaskClass] = {
"score": TaskClass.CHEAP, "score": TaskClass.CHEAP,
"extract": TaskClass.CHEAP, "extract": TaskClass.CHEAP,
"cv_assist": TaskClass.CHEAP, "cv_assist": TaskClass.CHEAP,
"email_classify": TaskClass.CHEAP,
"deadline_extract": TaskClass.CHEAP,
"cl_critique": TaskClass.STRONG, "cl_critique": TaskClass.STRONG,
"critique": TaskClass.STRONG, "critique": TaskClass.STRONG,
"research": TaskClass.STRONG, "research": TaskClass.STRONG,
"cv_tailor": TaskClass.STRONG,
} }
# Default budgets (max output tokens) per task name. # Default budgets (max output tokens) per task name.
@ -40,9 +43,12 @@ DEFAULT_BUDGETS: dict[str, int] = {
"score": 2000, "score": 2000,
"extract": 4000, "extract": 4000,
"cv_assist": 2000, "cv_assist": 2000,
"email_classify": 1000,
"deadline_extract": 500,
"cl_critique": 4000, "cl_critique": 4000,
"critique": 6000, "critique": 6000,
"research": 4000, "research": 4000,
"cv_tailor": 6000,
} }

View file

@ -59,6 +59,41 @@ MOCK_OUTPUTS: dict[str, dict] = {
"summary": "The company is a mid-size tech firm focused on cloud infrastructure.", "summary": "The company is a mid-size tech firm focused on cloud infrastructure.",
"key_points": ["Founded in 2015", "Series B funding", "Remote-first culture"], "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 for unknown task names.

View file

@ -270,8 +270,11 @@ class TestGatewayConfig:
assert config.get_task_class("score") == TaskClass.CHEAP assert config.get_task_class("score") == TaskClass.CHEAP
assert config.get_task_class("extract") == TaskClass.CHEAP assert config.get_task_class("extract") == TaskClass.CHEAP
assert config.get_task_class("cv_assist") == TaskClass.CHEAP assert config.get_task_class("cv_assist") == TaskClass.CHEAP
assert config.get_task_class("email_classify") == TaskClass.CHEAP
assert config.get_task_class("deadline_extract") == TaskClass.CHEAP
assert config.get_task_class("critique") == TaskClass.STRONG assert config.get_task_class("critique") == TaskClass.STRONG
assert config.get_task_class("cl_critique") == TaskClass.STRONG assert config.get_task_class("cl_critique") == TaskClass.STRONG
assert config.get_task_class("cv_tailor") == TaskClass.STRONG
def test_get_model_routing(self) -> None: def test_get_model_routing(self) -> None:
config = mock_config(cheap_model="cheap-model", strong_model="strong-model") config = mock_config(cheap_model="cheap-model", strong_model="strong-model")

View file

@ -0,0 +1,125 @@
"""Tests for new v1.1 mock tasks: email_classify, cv_tailor, deadline_extract."""
from __future__ import annotations
import pytest
from llm_gateway.config import GatewayConfig, ProviderConfig, TaskClass
from llm_gateway.gateway import Gateway
from llm_gateway.mock import get_mock_output, MOCK_OUTPUTS
def mock_config(**overrides) -> GatewayConfig:
"""Build a config in mock mode (no API key)."""
primary = ProviderConfig(
name="primary",
base_url="https://mock.example.com/v1",
api_key="",
model="glm-5.2",
)
defaults = {
"primary": primary,
"fallback": None,
"cheap_model": "glm-5.2",
"strong_model": "glm-5.2",
"budgets": {
"score": 2000,
"extract": 4000,
"email_classify": 1000,
"deadline_extract": 500,
"cv_tailor": 6000,
"default": 4000,
},
"max_retries": 2,
}
defaults.update(overrides)
return GatewayConfig(**defaults)
class TestEmailClassifyMock:
async def test_email_classify_returns_deterministic(self) -> None:
"""email_classify mock returns interview_invite classification."""
config = mock_config()
gw = Gateway(config)
result_a = await gw.run_task("email_classify", "Email from recruiter")
result_b = await gw.run_task("email_classify", "Email from recruiter")
assert result_a == result_b
assert result_a["classification"] == "interview_invite"
assert result_a["state_proposal"] == "interviewing"
assert "reason" in result_a
await gw.aclose()
async def test_email_classify_is_cheap(self) -> None:
"""email_classify should be classified as CHEAP."""
config = mock_config()
assert config.get_task_class("email_classify") == TaskClass.CHEAP
assert config.get_model("email_classify") == config.cheap_model
def test_email_classify_in_mock_outputs(self) -> None:
"""email_classify should be in MOCK_OUTPUTS."""
assert "email_classify" in MOCK_OUTPUTS
output = get_mock_output("email_classify")
assert output["classification"] == "interview_invite"
assert output["state_proposal"] == "interviewing"
class TestCvTailorMock:
async def test_cv_tailor_returns_deterministic(self) -> None:
"""cv_tailor mock returns tailored CV with change_log."""
config = mock_config()
gw = Gateway(config)
result_a = await gw.run_task("cv_tailor", "Tailor CV for posting")
result_b = await gw.run_task("cv_tailor", "Tailor CV for posting")
assert result_a == result_b
assert "tailored_cv" in result_a
assert "change_log" in result_a
assert isinstance(result_a["change_log"], list)
assert len(result_a["change_log"]) >= 1
# Check change_log entries have action and detail.
for entry in result_a["change_log"]:
assert "action" in entry
assert "detail" in entry
await gw.aclose()
async def test_cv_tailor_is_strong(self) -> None:
"""cv_tailor should be classified as STRONG."""
config = mock_config()
assert config.get_task_class("cv_tailor") == TaskClass.STRONG
assert config.get_model("cv_tailor") == config.strong_model
def test_cv_tailor_in_mock_outputs(self) -> None:
"""cv_tailor should be in MOCK_OUTPUTS."""
assert "cv_tailor" in MOCK_OUTPUTS
output = get_mock_output("cv_tailor")
assert "tailored_cv" in output
assert "change_log" in output
# Check it has skills and experience.
assert "skills" in output["tailored_cv"]
assert "experience" in output["tailored_cv"]
class TestDeadlineExtractMock:
async def test_deadline_extract_returns_deterministic(self) -> None:
"""deadline_extract mock returns apply_by (null by default)."""
config = mock_config()
gw = Gateway(config)
result_a = await gw.run_task("deadline_extract", "Extract deadline")
result_b = await gw.run_task("deadline_extract", "Extract deadline")
assert result_a == result_b
assert "apply_by" in result_a
# Default mock has null deadline.
assert result_a["apply_by"] is None
await gw.aclose()
async def test_deadline_extract_is_cheap(self) -> None:
"""deadline_extract should be classified as CHEAP."""
config = mock_config()
assert config.get_task_class("deadline_extract") == TaskClass.CHEAP
assert config.get_model("deadline_extract") == config.cheap_model
def test_deadline_extract_in_mock_outputs(self) -> None:
"""deadline_extract should be in MOCK_OUTPUTS."""
assert "deadline_extract" in MOCK_OUTPUTS
output = get_mock_output("deadline_extract")
assert "apply_by" in output
assert output["apply_by"] is None

5
scripts/Dockerfile.shots Normal file
View file

@ -0,0 +1,5 @@
FROM mcr.microsoft.com/playwright/python:v1.55.0-jammy
WORKDIR /shots
RUN pip install --no-cache-dir playwright==1.55.0
COPY scripts/screenshots.py ./
CMD ["python", "screenshots.py"]

84
scripts/screenshots.py Normal file
View file

@ -0,0 +1,84 @@
"""Screenshot runner: drives the seed demo UI and saves PNGs to /out.
Runs inside the compose network; browser resolves 'web' and 'api' directly.
"""
import json
import sys
import time
import urllib.request
from playwright.sync_api import sync_playwright
BASE = "http://web"
API = "http://api:8000/api"
OUT = "/out"
def api(method, path, body=None):
req = urllib.request.Request(
API + path,
data=json.dumps(body).encode() if body else None,
method=method,
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
def main():
# Idempotent: ensures full demo dataset exists
seed = api("POST", "/concierge/seed-demo")
print("seed:", json.dumps(seed)[:200])
apps = api("GET", "/applications")
interview = next((a for a in apps if a["state"] == "interviewing"), apps[0])
detail_id = interview["id"]
print("detail id:", detail_id)
shots = [
("welcome", "/", {}), # first-run wizard may redirect away; force below
("welcome", "/welcome", {}),
("today", "/today", {}),
("cv", "/cv", {}),
("research", "/research", {}),
("applications", "/applications", {}),
("detail", f"/applications/{detail_id}", {}),
]
with sync_playwright() as p:
browser = p.chromium.launch(args=["--disable-dev-shm-usage"])
page = browser.new_page(viewport={"width": 1440, "height": 900},
device_scale_factor=2)
for name, path, _opts in shots[1:]: # skip the "/" duplicate
try:
page.goto(BASE + path, wait_until="networkidle", timeout=30000)
page.wait_for_timeout(1200)
# close possible wizard redirect back
if name != "welcome" and page.url.endswith("/welcome"):
page.goto(BASE + path, wait_until="networkidle", timeout=30000)
page.wait_for_timeout(800)
page.screenshot(path=f"{OUT}/{name}.png")
print("shot:", name, "<-", page.url)
except Exception as e:
print("FAILED:", name, type(e).__name__, str(e)[:150])
# Interaction shot: open the Tailor CV panel on an approved application
approved = next((a for a in apps if a["state"] in ("approved", "interviewing")), apps[0])
try:
page.goto(f"{BASE}/applications/{approved['id']}", wait_until="networkidle", timeout=30000)
page.wait_for_timeout(1000)
btn = page.locator('[data-testid="tailor-cv-btn"]')
btn.click(timeout=8000)
page.wait_for_selector('[data-testid="tailor-panel"]', timeout=20000)
page.wait_for_timeout(800)
page.screenshot(path=f"{OUT}/detail-tailor.png")
print("shot: detail-tailor")
except Exception as e:
print("FAILED: detail-tailor", type(e).__name__, str(e)[:150])
browser.close()
print("DONE")
if __name__ == "__main__":
sys.exit(main())