Compare commits

..

No commits in common. "c69fb128bddcb1a1d74056186fb26c5e2b0caf46" and "d1753bb70a9bd249e74d72aeb107acf8825e19a5" have entirely different histories.

52 changed files with 38 additions and 4776 deletions

View file

@ -1,78 +0,0 @@
name: CI
on:
push:
branches: ["*"]
pull_request:
branches: ["*"]
jobs:
api-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker
run: |
docker --version
docker compose version || docker-compose --version
- name: Build and run API tests
run: |
docker compose build api-test
docker compose run --rm api-test
package-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install uv
run: |
pip install uv
- name: Run connectors tests
run: |
cd packages/connectors
uv venv
. .venv/bin/activate
uv pip install -e ".[dev]"
pytest -q
- name: Run llm-gateway tests
run: |
cd packages/llm-gateway
uv venv
. .venv/bin/activate
uv pip install -e ".[dev]"
pytest -q
- name: Run artifacts tests
run: |
cd packages/artifacts
uv venv
. .venv/bin/activate
uv pip install -e ".[dev]"
pytest -q
web-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: apps/web/package-lock.json
- name: Install dependencies
run: |
cd apps/web
npm ci
- name: Build
run: |
cd apps/web
npm run build
- name: Test
run: |
cd apps/web
npm test

View file

@ -57,16 +57,3 @@ Note: in sandboxed Docker-in-Docker environments host port publishing may not wo
## Status
POC scaffolding in progress. See `docs/` for the design.
## Screenshots
Screenshots will be added here as the UI stabilizes.
| View | Description | Screenshot |
|------|-------------|------------|
| Today | Daily digest with top matches, nudge cards, and cost summary | _placeholder_ |
| Onboarding Wizard | Welcome, CV import, postings fetch, done steps | _placeholder_ |
| CV Editor | Profile form, sections list, AI assist, PDF render | _placeholder_ |
| Research | Postings table with fetch form, scam column, and scoring | _placeholder_ |
| Applications Kanban | Drag-and-drop board with red-flag badges and nudge dots | _placeholder_ |
| Application Detail | Posting info, interview prep modal, cover letter, approval gate | _placeholder_ |

View file

@ -4,22 +4,12 @@ FROM python:3.13-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
# Copy packages from build context root
COPY packages ./packages
COPY pyproject.toml ./
COPY app ./app
COPY schema.sql ./schema.sql
COPY migrations ./migrations
COPY tests ./tests
# Copy api app
COPY apps/api/pyproject.toml ./apps/api/
COPY apps/api/app ./apps/api/app
COPY apps/api/schema.sql ./apps/api/schema.sql
COPY apps/api/migrations ./apps/api/migrations
COPY apps/api/tests ./apps/api/tests
RUN pip install --no-cache-dir -e ".[dev]"
WORKDIR /app/apps/api
# Install the api package with dev deps, plus the local packages
RUN pip install --no-cache-dir -e ".[dev]" \
&& pip install --no-cache-dir -e /app/packages/llm-gateway \
&& pip install --no-cache-dir -e /app/packages/artifacts \
&& pip install --no-cache-dir pypdf python-docx apscheduler
CMD ["pytest", "-q"]
CMD ["pytest", "-q"]

View file

@ -124,7 +124,7 @@ def update_application_state(
row = execute(
"""
UPDATE application
SET state = %s, state_changed_at = now(), last_activity_at = now()
SET state = %s, state_changed_at = now()
WHERE id = %s
RETURNING *
""",
@ -170,10 +170,6 @@ def _normalize_application(row: dict[str, Any]) -> dict[str, Any]:
"company": row.get("company"),
"title": row.get("title"),
"location": row.get("location"),
"follow_up_after_days": row.get("follow_up_after_days", 7),
"last_activity_at": row["last_activity_at"].isoformat() if row.get("last_activity_at") is not None else None,
"follow_up_snoozed_until": row.get("follow_up_snoozed_until").isoformat() if row.get("follow_up_snoozed_until") else None,
"interview_prep_artifact_id": str(row["interview_prep_artifact_id"]) if row.get("interview_prep_artifact_id") else None,
}
@ -390,96 +386,4 @@ def _normalize_task_run(row: dict[str, Any]) -> dict[str, Any]:
"duration_ms": row["duration_ms"],
"application_id": str(row["application_id"]) if row.get("application_id") else None,
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
}
# --- Follow-up nudges ---
def get_nudge_applications() -> list[dict[str, Any]]:
"""Return applications in 'sent' state past follow_up_after_days and not snoozed."""
rows = fetch_all(
"""
SELECT a.*, j.company, j.title, j.location,
(now() - a.last_activity_at) AS elapsed
FROM application a
JOIN job_posting j ON a.job_posting_id = j.id
WHERE a.state = 'sent'
AND EXTRACT(day FROM now() - a.last_activity_at) > a.follow_up_after_days
AND (a.follow_up_snoozed_until IS NULL OR a.follow_up_snoozed_until < CURRENT_DATE)
ORDER BY a.last_activity_at ASC
"""
)
results: list[dict[str, Any]] = []
for row in rows:
app = _normalize_application(row)
elapsed = row.get("elapsed")
days = None
if elapsed is not None:
days = abs(int(elapsed.days))
app["days_since_sent"] = days
results.append(app)
return results
def snooze_follow_up(app_id: str, until_date: Any) -> dict[str, Any] | None:
"""Snooze follow-up nudge for an application until a given date."""
row = execute(
"UPDATE application SET follow_up_snoozed_until = %s WHERE id = %s RETURNING *",
(until_date, app_id),
)
if row is None:
return None
return _normalize_application(row)
# --- Interview prep ---
def set_interview_prep_artifact(app_id: str, artifact_id: str) -> dict[str, Any] | None:
"""Link an interview prep artifact to the application."""
row = execute(
"""
UPDATE application
SET interview_prep_artifact_id = %s, last_activity_at = now()
WHERE id = %s
RETURNING *
""",
(artifact_id, app_id),
)
if row is None:
return None
return _normalize_application(row)
# --- Pending approvals count ---
def count_pending_approvals() -> int:
"""Count approvals that are not yet confirmed and not expired."""
row = fetch_one(
"""
SELECT count(*) AS cnt
FROM approval
WHERE confirmed_by_user = false
AND expires_at > now()
"""
)
if row is None:
return 0
return int(row["cnt"])
# --- Digest (scored applications, top by score) ---
def get_digest(limit: int = 20) -> list[dict[str, Any]]:
"""Return scored applications ordered by score descending, with posting info."""
rows = fetch_all(
"""
SELECT a.*, j.company, j.title, j.location
FROM application a
JOIN job_posting j ON a.job_posting_id = j.id
WHERE a.score IS NOT NULL
ORDER BY a.score DESC NULLS LAST
LIMIT %s
""",
(limit,),
)
return [_normalize_application(r) for r in rows]
}

View file

@ -62,66 +62,6 @@ MOCK_OUTPUTS: dict[str, dict[str, Any]] = {
"match": 0.72,
"factors": {"skills": 0.8, "location": 0.6, "experience": 0.75},
},
"red_flags": [],
},
"cv_extract": {
"drafts": [
{
"kind": "experience",
"title": "Software Engineer",
"org": "Extracted Company",
"location": "Malmo",
"start_date": "2020-01",
"end_date": None,
"bullets": ["Developed web applications", "Led team of 3"],
"tags": ["python", "javascript"],
},
{
"kind": "education",
"title": "MSc Computer Science",
"org": "Lund University",
"location": "Lund",
"start_date": "2016-09",
"end_date": "2018-06",
"bullets": ["Specialized in distributed systems"],
"tags": ["algorithms", "distributed systems"],
},
]
},
"interview_prep": {
"content": (
"# Interview Prep\n\n"
"## Q1: Tell us about yourself\n"
"**Suggested angle:** Highlight your experience with Python and FastAPI, "
"and your ability to deliver features on time.\n\n"
"## Q2: Why are you interested in this role?\n"
"**Suggested angle:** Reference the specific technologies mentioned in the "
"posting and your experience with similar stacks.\n\n"
"## Q3: Describe a challenging project\n"
"**Suggested angle:** Use the STAR method. Reference your experience building "
"distributed systems at Lund University.\n\n"
"## Q4: How do you handle tight deadlines?\n"
"**Suggested angle:** Mention your track record of delivering 2 weeks ahead "
"of schedule and your automation-first approach.\n\n"
"## Q5: What are your salary expectations?\n"
"**Suggested angle:** Research market rates for the Skane region. "
"Be prepared to give a range.\n\n"
"## Q6: Tell us about a time you failed\n"
"**Suggested angle:** Pick something real but not catastrophic. Show what "
"you learned and how you changed your approach.\n\n"
"## Q7: How do you stay current with technology?\n"
"**Suggested angle:** Mention your tags: python, javascript, distributed "
"systems. Talk about hands-on side projects.\n\n"
"## Q8: Describe your ideal work environment\n"
"**Suggested angle:** Be honest but flexible. Mention collaboration and "
"autonomy.\n\n"
"## Q9: What questions do you have for us?\n"
"**Suggested angle:** Ask about team structure, current projects, and "
"growth opportunities.\n\n"
"## Q10: Why should we hire you?\n"
"**Suggested angle:** Summarize your top 3 qualifications matching the "
"posting requirements. Be specific."
),
},
}

View file

@ -1,8 +1,7 @@
"""FastAPI application -- main entry point."""
"""FastAPI application main entry point."""
from __future__ import annotations
import base64
import hashlib
import json
import os
@ -24,48 +23,26 @@ from app.schemas import (
ApprovalOut,
ArtifactCreate,
ArtifactOut,
BatchScoreRequest,
BatchScoreResponse,
BatchScoreResult,
CoverLetterRequest,
CoverLetterResponse,
CvImportConfirmRequest,
CvImportConfirmResponse,
CvImportRequest,
CvImportResponse,
CvSectionCreate,
CvSectionOut,
CvSectionUpdate,
DigestItem,
ErrorOut,
InterviewPrepResponse,
JobPostingCreate,
JobPostingOut,
NudgeItem,
OutboxOut,
OutboxSendRequest,
PostingsFetchRequest,
PostingsFetchResponse,
ProfileOut,
ProfileUpdate,
ScoreResponse,
SeedDemoResponse,
TaskRunOut,
TodayResponse,
TransitionRequest,
)
from app.statemachine import TransitionContext, check_transition, InvalidTransition
from app.transport import get_transport, reset_transport
from app.transport import get_transport
# Defensive import for connectors (may not exist yet)
try:
from packages.connectors.arbetsformedlingen import ArbetsformedlingenConnector # type: ignore
from packages.connectors.base import SearchQuery # type: ignore
CONNECTORS_AVAILABLE = True
except ImportError:
CONNECTORS_AVAILABLE = False
app = FastAPI(title="Jobhunt API", version="0.2.0")
app = FastAPI(title="Jobhunt API", version="0.1.0")
@app.on_event("startup")
@ -73,15 +50,10 @@ def _startup() -> None:
"""Ensure pool is initialized and migrations are applied."""
get_pool()
migrate_mod.run_migrations()
# Start scheduler if enabled
from app import scheduler
scheduler.start_scheduler()
@app.on_event("shutdown")
def _shutdown() -> None:
from app import scheduler
scheduler.stop_scheduler()
close_pool()
@ -475,461 +447,4 @@ def send_outbox(body: OutboxSendRequest) -> Any:
@app.get("/api/telemetry/tasks", response_model=list[TaskRunOut])
def get_telemetry() -> Any:
return repo_app.list_task_runs()
# --- v1: CV Import ---
def _extract_text_from_pdf(raw_bytes: bytes) -> str:
"""Extract text from PDF bytes using pypdf."""
try:
from pypdf import PdfReader
except ImportError:
raise HTTPException(
status_code=500,
detail={"code": "missing_dependency", "message": "pypdf not installed"},
)
import io
reader = PdfReader(io.BytesIO(raw_bytes))
parts: list[str] = []
for page in reader.pages:
text = page.extract_text()
if text:
parts.append(text)
return "\n".join(parts)
def _extract_text_from_docx(raw_bytes: bytes) -> str:
"""Extract text from DOCX bytes using python-docx."""
try:
import docx
except ImportError:
raise HTTPException(
status_code=500,
detail={"code": "missing_dependency", "message": "python-docx not installed"},
)
import io
doc = docx.Document(io.BytesIO(raw_bytes))
parts: list[str] = []
for para in doc.paragraphs:
if para.text.strip():
parts.append(para.text)
return "\n".join(parts)
@app.post("/api/cv/import", response_model=CvImportResponse)
def cv_import(body: CvImportRequest) -> Any:
"""Extract text from uploaded file and generate draft CV sections via LLM.
Does NOT write to cv_section -- returns drafts for user review.
"""
try:
raw_bytes = base64.b64decode(body.content_base64)
except Exception:
raise HTTPException(
status_code=422,
detail={"code": "invalid_base64", "message": "content_base64 is not valid base64"},
)
if not raw_bytes:
raise HTTPException(
status_code=422,
detail={"code": "empty_file", "message": "File is empty or contains no data"},
)
filename = body.filename.lower()
text = ""
if filename.endswith(".pdf"):
text = _extract_text_from_pdf(raw_bytes)
elif filename.endswith(".docx"):
text = _extract_text_from_docx(raw_bytes)
elif filename.endswith(".txt") or filename.endswith(".md"):
text = raw_bytes.decode("utf-8", errors="replace")
else:
raise HTTPException(
status_code=422,
detail={
"code": "unsupported_format",
"message": f"Unsupported file format: {body.filename}. Supported: .pdf, .docx, .txt, .md",
},
)
text = text.strip()
if not text:
raise HTTPException(
status_code=422,
detail={"code": "empty_file", "message": "File contains no extractable text"},
)
# Run LLM extraction (cheap class)
result = llm.run_task(
"cv_extract",
text,
telemetry_sink=lambda info: repo_app.create_task_run({
**info,
"application_id": None,
}),
)
drafts = result.get("drafts", [])
return {"drafts": drafts}
@app.post("/api/cv/import/confirm", response_model=CvImportConfirmResponse, status_code=201)
def cv_import_confirm(body: CvImportConfirmRequest) -> Any:
"""Create cv_section rows from drafts returned by /cv/import."""
profile = repo_profile.get_or_create_profile()
if profile is None:
raise HTTPException(status_code=500, detail="Failed to create profile")
created_sections: list[dict[str, Any]] = []
for draft in body.drafts:
section = repo_profile.create_section(profile["id"], {
"kind": draft.kind,
"title": draft.title,
"org": draft.org,
"location": draft.location,
"start_date": draft.start_date,
"end_date": draft.end_date,
"bullets": draft.bullets,
"tags": draft.tags,
"sort_order": 0,
})
created_sections.append(section)
return {"created": len(created_sections), "sections": created_sections}
# --- v1: Postings Fetch ---
def _get_connectors_enabled() -> bool:
return os.environ.get("CONNECTORS_ENABLED", "true").lower() in (
"true", "1", "yes",
)
def _get_arbetsformedlingen_connector():
"""Get the AF connector instance, or None if not available."""
if not CONNECTORS_AVAILABLE:
return None
return ArbetsformedlingenConnector()
def _fetch_and_create_postings(query: str, region: str | None = None) -> dict[str, Any]:
"""Internal: fetch postings via connector and create applications for new ones."""
connector = _get_arbetsformedlingen_connector()
if connector is None:
return {"new": 0, "dupes": 0}
# Build search query -- use a dict if SearchQuery is not available
if CONNECTORS_AVAILABLE and "SearchQuery" in globals():
sq = SearchQuery(query=query, region=region) if region else SearchQuery(query=query)
else:
# Fallback: pass a simple dict-like object
sq = {"query": query, "region": region}
raw_postings = connector.fetch(sq)
new_count = 0
dupe_count = 0
for rp in raw_postings:
# Check if (source, url) already exists
existing = fetch_one(
"SELECT id FROM job_posting WHERE source = %s AND url = %s",
(rp.get("source", "arbetsformedlingen"), rp.get("url", "")),
)
if existing:
dupe_count += 1
continue
posting = repo_app.create_job_posting(
source=rp.get("source", "arbetsformedlingen"),
url=rp.get("url", ""),
company=rp.get("company", "Unknown"),
title=rp.get("title", "Unknown"),
location=rp.get("location"),
description=rp.get("description", ""),
external_id=rp.get("external_id"),
raw=rp.get("raw", {}),
)
repo_app.create_application(posting["id"])
new_count += 1
return {"new": new_count, "dupes": dupe_count}
@app.post("/api/postings/fetch", response_model=PostingsFetchResponse)
def postings_fetch(body: PostingsFetchRequest) -> Any:
"""Fetch job postings via the Arbetsformedlingen connector.
Behind env flag CONNECTORS_ENABLED (default true).
"""
if not _get_connectors_enabled():
raise HTTPException(
status_code=503,
detail={
"code": "connectors_disabled",
"message": "Connectors are not enabled. Set CONNECTORS_ENABLED=true to enable.",
},
)
if not CONNECTORS_AVAILABLE:
raise HTTPException(
status_code=503,
detail={
"code": "connectors_unavailable",
"message": "Connectors package is not installed.",
},
)
return _fetch_and_create_postings(body.query, body.region)
# --- v1: Batch Scoring ---
def _batch_score_internal(application_ids: list[str]) -> list[dict[str, Any]]:
"""Internal: score multiple applications, return results with red_flags."""
results: list[dict[str, Any]] = []
for app_id in application_ids:
app_row = repo_app.get_application(app_id)
if app_row is None:
continue
posting = repo_app.get_job_posting(app_row["job_posting_id"])
if posting is None:
continue
result = llm.run_task(
"score",
f"Score this posting: {posting['title']} at {posting['company']}",
telemetry_sink=lambda info, aid=app_id: repo_app.create_task_run({
**info,
"application_id": aid,
}),
)
score = float(result.get("score", 50))
rationale = result.get("rationale", {})
red_flags = result.get("red_flags", [])
repo_app.update_application_score(app_id, score, rationale)
results.append({
"application_id": app_id,
"score": score,
"rationale": rationale,
"red_flags": red_flags,
})
return results
@app.post("/api/scoring/batch", response_model=BatchScoreResponse)
def batch_score(body: BatchScoreRequest) -> Any:
"""Score multiple applications in one call (cheap class)."""
results = _batch_score_internal(body.application_ids)
return {"results": results}
# --- v1: Today ---
@app.get("/api/today", response_model=TodayResponse)
def get_today() -> Any:
"""Return daily digest: ranked postings, follow-up nudges, pending approvals."""
# Digest: scored applications ordered by score desc
digest_apps = repo_app.get_digest(limit=20)
digest = [
DigestItem(
application_id=a["id"],
title=a.get("title", ""),
company=a.get("company", ""),
score=a.get("score"),
)
for a in digest_apps
]
# Nudges: sent applications past follow_up_after_days and not snoozed
nudge_apps = repo_app.get_nudge_applications()
nudges = [
NudgeItem(
application_id=a["id"],
days_since_sent=a.get("days_since_sent", 0) or 0,
suggestion=(
"Consider sending a follow-up email asking about the status "
"of your application."
),
)
for a in nudge_apps
]
# Pending approvals count
pending = repo_app.count_pending_approvals()
return {
"digest": digest,
"nudges": nudges,
"pending_approvals": pending,
}
# --- v1: Interview Prep ---
@app.post("/api/applications/{app_id}/interview-prep", response_model=InterviewPrepResponse)
def interview_prep(app_id: str) -> Any:
"""Generate interview prep Q&A (strong class), stored as artifact."""
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")
prompt = (
f"Generate interview prep for: {posting['title']} at {posting['company']}. "
f"Description: {posting.get('description', '')}"
)
result = llm.run_task(
"interview_prep",
prompt,
telemetry_sink=lambda info: repo_app.create_task_run({
**info,
"application_id": app_id,
}),
)
content = result.get("content", "")
content_bytes = content.encode("utf-8")
# Store as artifact
storage_dir = os.path.join(tempfile.gettempdir(), "jobhunt_artifacts")
os.makedirs(storage_dir, exist_ok=True)
filename = f"interview_prep_{app_id[:8]}.md"
storage_path = os.path.join(storage_dir, filename)
with open(storage_path, "wb") as f:
f.write(content_bytes)
artifact = repo_app.create_artifact(
application_id=app_id,
kind="other",
filename=filename,
content_bytes=content_bytes,
storage_path=storage_path,
origin="ai_drafted",
)
# Link to application
repo_app.set_interview_prep_artifact(app_id, artifact["id"])
return {
"artifact_id": artifact["id"],
"content": content,
}
# --- v1: Concierge / Demo Seed ---
@app.post("/api/concierge/seed-demo", response_model=SeedDemoResponse)
def seed_demo() -> Any:
"""Idempotent demo seed: profile + 6 postings + varied application states."""
# Check if demo profile already exists
existing = fetch_one(
"SELECT * FROM profile WHERE full_name = 'Demo Demosson'"
)
if existing:
# Already seeded -- return current 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
repo_profile.get_or_create_profile()
profile = repo_profile.update_profile({
"full_name": "Demo Demosson",
"email": "demo@example.com",
"location": "Malmo",
"headline": "Software Developer",
"summary": "Experienced developer looking for opportunities in Skane.",
"languages": [{"code": "sv", "level": "native"}, {"code": "en", "level": "fluent"}],
})
if profile is None:
raise HTTPException(status_code=500, detail="Failed to create demo profile")
# Create demo CV 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": "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": "skills", "title": "Technical Skills", "bullets": ["Python", "PostgreSQL", "Docker", "FastAPI"], "tags": ["python", "docker"]},
]
for s in demo_sections:
repo_profile.create_section(profile["id"], s)
# Create 6 demo postings with varied states
demo_postings = [
{"company": "Skane Tech AB", "title": "Senior Python Developer", "location": "Malmo", "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": "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": "Malmo Startup", "title": "Software Engineer", "location": "Malmo", "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},
]
for dp in demo_postings:
posting = repo_app.create_job_posting(
source="arbetsformedlingen",
url=dp["url"],
company=dp["company"],
title=dp["title"],
location=dp["location"],
description=f"Job description for {dp['title']} at {dp['company']}",
raw={},
)
app_row = repo_app.create_application(posting["id"])
# Set state and score
if dp["score"] is not None:
repo_app.update_application_score(app_row["id"], dp["score"], {"factors": {}})
if dp["state"] != "discovered" and dp["state"] != "scored":
# Transition through states
if dp["state"] in ("approved", "rejected"):
# First set to scored if needed
if dp["score"] is not None:
repo_app.update_application_state(app_row["id"], "scored")
repo_app.update_application_state(app_row["id"], dp["state"])
elif dp["state"] == "sent":
# approved -> drafting -> sent
if dp["score"] is not None:
repo_app.update_application_state(app_row["id"], "scored")
repo_app.update_application_state(app_row["id"], "approved")
repo_app.update_application_state(app_row["id"], "drafting")
# We need confirmed approval for drafting->sent, so directly set state
execute(
"UPDATE application SET state = 'sent', state_changed_at = now(), last_activity_at = now() WHERE id = %s",
(app_row["id"],),
)
# Backdate the 'sent' application ( posting 4) to 8 days ago for nudge demo
from datetime import timedelta
backdated = datetime.now(timezone.utc) - timedelta(days=8)
execute(
"UPDATE application SET last_activity_at = %s WHERE state = 'sent'",
(backdated,),
)
# Count results
postings_count = len(repo_app.list_postings())
apps_count = len(repo_app.list_applications())
sections_count = len(repo_profile.list_sections())
return {
"profile": "Demo Demosson",
"postings": postings_count,
"applications": apps_count,
"sections": sections_count,
}
return repo_app.list_task_runs()

View file

@ -1,92 +0,0 @@
"""APScheduler integration: daily fetch + batch score job.
Starts during app lifespan when SCHEDULER_ENABLED=true (default false).
Runs a daily job at 07:00 that fetches postings and batch-scores pending applications.
"""
from __future__ import annotations
import logging
import os
logger = logging.getLogger(__name__)
_scheduler = None
def is_scheduler_enabled() -> bool:
"""Check if the scheduler is enabled via env."""
return os.environ.get("SCHEDULER_ENABLED", "false").lower() in (
"true",
"1",
"yes",
)
async def _daily_fetch_and_score() -> None:
"""Daily job: fetch postings and batch-score pending applications."""
logger.info("Scheduler: running daily fetch + batch score")
try:
# Import here to avoid circular imports
from app.main import _fetch_and_create_postings, _batch_score_internal
# Fetch default query
fetch_result = _fetch_and_create_postings(
query="developer",
region="Skane lan",
)
logger.info(
"Scheduler: fetched %s new, %s dupes",
fetch_result.get("new", 0),
fetch_result.get("dupes", 0),
)
# Batch score all discovered applications
from app.db import repo_app
apps = repo_app.list_applications()
discovered_ids = [
a["id"] for a in apps if a["state"] == "discovered"
]
if discovered_ids:
results = _batch_score_internal(discovered_ids)
logger.info(
"Scheduler: batch-scored %s applications", len(results)
)
except Exception:
logger.exception("Scheduler: daily job failed")
def start_scheduler() -> None:
"""Start the APScheduler if enabled."""
global _scheduler
if not is_scheduler_enabled():
logger.info("Scheduler disabled (SCHEDULER_ENABLED != true)")
return
try:
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
except ImportError:
logger.warning(
"APScheduler not installed; scheduler will not start."
)
return
_scheduler = AsyncIOScheduler()
_scheduler.add_job(
_daily_fetch_and_score,
CronTrigger(hour=7, minute=0),
id="daily_fetch_score",
replace_existing=True,
)
_scheduler.start()
logger.info("Scheduler started: daily fetch+score at 07:00")
def stop_scheduler() -> None:
"""Stop the scheduler if running."""
global _scheduler
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
logger.info("Scheduler stopped")

View file

@ -130,11 +130,6 @@ class ApplicationOut(BaseModel):
company: str | None = None
title: str | None = None
location: str | None = None
# v1 follow-up fields
follow_up_after_days: int = 7
last_activity_at: str | None = None
follow_up_snoozed_until: str | None = None
interview_prep_artifact_id: str | None = None
class TransitionRequest(BaseModel):
@ -222,101 +217,4 @@ class TaskRunOut(BaseModel):
# --- Errors ---
class ErrorOut(BaseModel):
error: dict[str, str]
# --- v1: CV Import ---
class CvImportRequest(BaseModel):
filename: str
content_base64: str
class CvDraft(BaseModel):
kind: str = "experience"
title: str = ""
org: str | None = None
location: str | None = None
start_date: str | None = None
end_date: str | None = None
bullets: list[str] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
class CvImportResponse(BaseModel):
drafts: list[dict[str, Any]]
class CvImportConfirmRequest(BaseModel):
drafts: list[CvDraft]
class CvImportConfirmResponse(BaseModel):
created: int
sections: list[CvSectionOut]
# --- v1: Postings Fetch ---
class PostingsFetchRequest(BaseModel):
query: str
region: str | None = None
class PostingsFetchResponse(BaseModel):
new: int
dupes: int
# --- v1: Batch Scoring ---
class BatchScoreRequest(BaseModel):
application_ids: list[str]
class BatchScoreResult(BaseModel):
application_id: str
score: float
rationale: dict[str, Any]
red_flags: list[str] = Field(default_factory=list)
class BatchScoreResponse(BaseModel):
results: list[BatchScoreResult]
# --- v1: Today ---
class DigestItem(BaseModel):
application_id: str
title: str
company: str
score: float | None = None
class NudgeItem(BaseModel):
application_id: str
days_since_sent: int
suggestion: str
class TodayResponse(BaseModel):
digest: list[DigestItem]
nudges: list[NudgeItem]
pending_approvals: int
# --- v1: Interview Prep ---
class InterviewPrepResponse(BaseModel):
artifact_id: str
content: str
# --- v1: Concierge Seed ---
class SeedDemoResponse(BaseModel):
profile: str
postings: int
applications: int
sections: int
error: dict[str, str]

View file

@ -1,22 +1,13 @@
"""Send transport interface.
Pluggable Transport interface for the outbox send operation.
Selection order:
1. SMTP configured (SMTP_HOST set) -> SmtpTransport
2. Else -> ClipboardTransport (marks sent + stores payload for UI copy)
The approval gate checks (confirmed, unexpired, hash match) are UNCHANGED
and enforced in the API layer before transport.send() is called.
Default EchoTransport records the payload and marks as sent.
No real email in POC.
"""
from __future__ import annotations
import os
import smtplib
import ssl
from datetime import datetime, timezone
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Any, Protocol
@ -44,115 +35,17 @@ class EchoTransport:
}
class ClipboardTransport:
"""Fallback transport: marks sent and stores payload for UI copy/paste.
No real email is sent. The payload is stored so the UI can show it
to the user for manual copy-paste into their email client.
"""
def __init__(self) -> None:
self.sent: list[dict[str, Any]] = []
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
self.sent.append(payload)
return {
"success": True,
"sent_at": datetime.now(timezone.utc).isoformat(),
"transport": "clipboard",
"payload": payload,
}
class SmtpTransport:
"""SMTP transport: sends real email via SMTP.
Configuration from env:
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM
SSL on port 465, STARTTLS otherwise.
"""
def __init__(
self,
host: str | None = None,
port: int | None = None,
user: str | None = None,
password: str | None = None,
from_addr: str | None = None,
) -> None:
self.host = host or os.environ.get("SMTP_HOST", "")
self.port = int(port or os.environ.get("SMTP_PORT", "587"))
self.user = user or os.environ.get("SMTP_USER", "")
self.password = password or os.environ.get("SMTP_PASS", "")
self.from_addr = from_addr or os.environ.get("SMTP_FROM", self.user)
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
to = payload.get("to", "")
subject = payload.get("subject", "(no subject)")
body = payload.get("body", "")
msg = MIMEMultipart()
msg["From"] = self.from_addr
msg["To"] = to
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain"))
try:
if self.port == 465:
context = ssl.create_default_context()
with smtplib.SMTP_SSL(self.host, self.port, context=context) as server:
if self.user and self.password:
server.login(self.user, self.password)
server.sendmail(self.from_addr, [to], msg.as_string())
else:
with smtplib.SMTP(self.host, self.port) as server:
server.ehlo()
if self.user and self.password:
server.starttls()
server.ehlo()
server.login(self.user, self.password)
server.sendmail(self.from_addr, [to], msg.as_string())
return {
"success": True,
"sent_at": datetime.now(timezone.utc).isoformat(),
"transport": "smtp",
}
except Exception as exc:
return {
"success": False,
"sent_at": datetime.now(timezone.utc).isoformat(),
"transport": "smtp",
"error": str(exc),
}
def is_smtp_configured() -> bool:
"""Check if SMTP is configured (SMTP_HOST is set)."""
return bool(os.environ.get("SMTP_HOST", "").strip())
# Singleton instance
_default_transport: Transport | None = None
def get_transport() -> Transport:
"""Get the transport. Selects SMTP if configured, else Clipboard."""
global _default_transport
if _default_transport is None:
if is_smtp_configured():
_default_transport = SmtpTransport()
else:
_default_transport = ClipboardTransport()
_default_transport = EchoTransport()
return _default_transport
def set_transport(t: Transport) -> None:
"""Override the transport (for testing)."""
global _default_transport
_default_transport = t
def reset_transport() -> None:
"""Reset to default (for testing)."""
global _default_transport
_default_transport = None
_default_transport = t

View file

@ -1,12 +0,0 @@
-- 002_followups.sql -- follow-up rules + interview prep on application
ALTER TABLE application
ADD COLUMN IF NOT EXISTS follow_up_after_days int NOT NULL DEFAULT 7,
ADD COLUMN IF NOT EXISTS last_activity_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN IF NOT EXISTS follow_up_snoozed_until date,
ADD COLUMN IF NOT EXISTS interview_prep_artifact_id uuid REFERENCES artifact(id);
-- last_activity_at defaults to created_at for existing rows
UPDATE application
SET last_activity_at = created_at
WHERE last_activity_at IS NULL;

View file

@ -15,9 +15,6 @@ dependencies = [
dev = [
"pytest>=8.3",
"httpx>=0.27",
"pypdf>=4.0",
"python-docx>=1.1",
"apscheduler>=3.10",
]
[build-system]
@ -29,4 +26,4 @@ packages = ["app", "app.db"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = [".", "..", "../.."]
pythonpath = ["."]

View file

@ -40,7 +40,7 @@ def _truncate_tables():
"""
TRUNCATE TABLE task_run, outbox, approval, artifact,
application, job_posting, cv_section, profile
RESTART IDENTITY CASCADE
CASCADE
"""
)
conn.commit()

View file

@ -1,162 +0,0 @@
"""Tests for v1 CV import endpoints (mock LLM mode)."""
from __future__ import annotations
import base64
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def client():
from app.main import app
return TestClient(app)
class TestCvImport:
def test_import_txt_file(self, client):
"""POST /cv/import with a .txt file returns drafts."""
content = b"John Doe\nSoftware Engineer at TechCorp\n5 years experience with Python"
resp = client.post(
"/api/cv/import",
json={
"filename": "my_cv.txt",
"content_base64": base64.b64encode(content).decode(),
},
)
assert resp.status_code == 200
data = resp.json()
assert "drafts" in data
assert len(data["drafts"]) > 0
def test_import_empty_file_422(self, client):
"""Empty file -> 422."""
content = b""
resp = client.post(
"/api/cv/import",
json={
"filename": "empty.txt",
"content_base64": base64.b64encode(content).decode(),
},
)
assert resp.status_code == 422
assert "empty" in resp.text.lower()
def test_import_whitespace_only_file_422(self, client):
"""File with only whitespace -> 422."""
content = b" \n\n\t "
resp = client.post(
"/api/cv/import",
json={
"filename": "blank.txt",
"content_base64": base64.b64encode(content).decode(),
},
)
assert resp.status_code == 422
assert "empty" in resp.text.lower()
def test_import_unsupported_format_422(self, client):
"""Unsupported file format -> 422."""
content = b"some data"
resp = client.post(
"/api/cv/import",
json={
"filename": "file.xyz",
"content_base64": base64.b64encode(content).decode(),
},
)
assert resp.status_code == 422
assert "unsupported" in resp.text.lower()
def test_import_invalid_base64_422(self, client):
"""Invalid base64 -> 422."""
resp = client.post(
"/api/cv/import",
json={
"filename": "file.txt",
"content_base64": "!!!not-base64!!!",
},
)
assert resp.status_code == 422
def test_import_md_file(self, client):
"""POST /cv/import with a .md file returns drafts."""
content = b"# Jane Doe\n\n## Experience\nSenior Developer at Acme"
resp = client.post(
"/api/cv/import",
json={
"filename": "cv.md",
"content_base64": base64.b64encode(content).decode(),
},
)
assert resp.status_code == 200
assert "drafts" in resp.json()
def test_import_creates_telemetry(self, client):
"""CV import should create a task_run entry."""
content = b"Some CV text with experience"
client.post(
"/api/cv/import",
json={
"filename": "cv.txt",
"content_base64": base64.b64encode(content).decode(),
},
)
resp = client.get("/api/telemetry/tasks")
tasks = resp.json()
assert any(t["task"] == "cv_extract" for t in tasks)
class TestCvImportConfirm:
def test_confirm_creates_sections(self, client):
"""POST /cv/import/confirm creates cv_section rows."""
client.get("/api/profile")
resp = client.post(
"/api/cv/import/confirm",
json={
"drafts": [
{
"kind": "experience",
"title": "Dev",
"org": "Corp",
"bullets": ["did stuff"],
"tags": ["python"],
},
{
"kind": "education",
"title": "MSc",
"org": "Uni",
},
]
},
)
assert resp.status_code == 201
data = resp.json()
assert data["created"] == 2
assert len(data["sections"]) == 2
def test_confirm_empty_drafts(self, client):
"""Empty drafts list creates zero sections."""
client.get("/api/profile")
resp = client.post(
"/api/cv/import/confirm",
json={"drafts": []},
)
assert resp.status_code == 201
assert resp.json()["created"] == 0
def test_confirm_sections_appear_in_list(self, client):
"""Confirmed sections appear in GET /profile/sections."""
client.get("/api/profile")
client.post(
"/api/cv/import/confirm",
json={
"drafts": [
{"kind": "skills", "title": "Python Dev", "bullets": ["FastAPI"]},
]
},
)
resp = client.get("/api/profile/sections")
assert resp.status_code == 200
assert any(s["title"] == "Python Dev" for s in resp.json())

View file

@ -1,137 +0,0 @@
"""Tests for v1 postings fetch endpoint (connector stubbed)."""
from __future__ import annotations
import os
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def client():
from app.main import app
return TestClient(app)
class FakeRawPosting:
"""Minimal raw posting dict for testing."""
pass
def _make_fake_postings():
"""Return fake raw postings as dicts."""
return [
{
"source": "arbetsformedlingen",
"external_id": "af-001",
"url": "https://arbetsformedlingen.se/job/001",
"company": "Skane Tech",
"title": "Python Developer",
"location": "Malmo",
"description": "Great Python job in Malmo.",
"raw": {"id": "af-001"},
},
{
"source": "arbetsformedlingen",
"external_id": "af-002",
"url": "https://arbetsformedlingen.se/job/002",
"company": "Lund Systems",
"title": "Backend Engineer",
"location": "Lund",
"description": "Backend engineer at Lund.",
"raw": {"id": "af-002"},
},
]
class FakeConnector:
"""Fake connector that returns predefined postings."""
def fetch(self, query):
# Accept both SearchQuery objects and dicts
return _make_fake_postings()
class TestPostingsFetch:
def test_fetch_disabled_503(self, client, monkeypatch):
"""CONNECTORS_ENABLED=false -> 503."""
monkeypatch.setenv("CONNECTORS_ENABLED", "false")
resp = client.post(
"/api/postings/fetch",
json={"query": "python developer"},
)
assert resp.status_code == 503
assert "connectors_disabled" in resp.text
def test_fetch_enabled_with_stubbed_connector(self, client, monkeypatch):
"""Fetch with monkeypatched connector creates new applications."""
monkeypatch.setenv("CONNECTORS_ENABLED", "true")
# Monkeypatch the connector lookup
import app.main as main_mod
monkeypatch.setattr(main_mod, "CONNECTORS_AVAILABLE", True)
monkeypatch.setattr(
main_mod,
"_get_arbetsformedlingen_connector",
lambda: FakeConnector(),
)
resp = client.post(
"/api/postings/fetch",
json={"query": "python developer", "region": "Skane lan"},
)
assert resp.status_code == 200
data = resp.json()
assert data["new"] == 2
assert data["dupes"] == 0
def test_fetch_dedupes_existing_postings(self, client, monkeypatch):
"""Second fetch of same postings counts as dupes."""
monkeypatch.setenv("CONNECTORS_ENABLED", "true")
import app.main as main_mod
monkeypatch.setattr(main_mod, "CONNECTORS_AVAILABLE", True)
monkeypatch.setattr(
main_mod,
"_get_arbetsformedlingen_connector",
lambda: FakeConnector(),
)
# First fetch
resp1 = client.post(
"/api/postings/fetch",
json={"query": "python"},
)
assert resp1.status_code == 200
assert resp1.json()["new"] == 2
# Second fetch: same postings should be dupes
resp2 = client.post(
"/api/postings/fetch",
json={"query": "python"},
)
assert resp2.status_code == 200
assert resp2.json()["new"] == 0
assert resp2.json()["dupes"] == 2
def test_fetch_empty_results(self, client, monkeypatch):
"""Connector returning no postings -> new=0, dupes=0."""
monkeypatch.setenv("CONNECTORS_ENABLED", "true")
import app.main as main_mod
monkeypatch.setattr(main_mod, "CONNECTORS_AVAILABLE", True)
class EmptyConnector:
def fetch(self, query):
return []
monkeypatch.setattr(
main_mod,
"_get_arbetsformedlingen_connector",
lambda: EmptyConnector(),
)
resp = client.post(
"/api/postings/fetch",
json={"query": "rare keyword"},
)
assert resp.status_code == 200
assert resp.json()["new"] == 0
assert resp.json()["dupes"] == 0

View file

@ -1,398 +0,0 @@
"""Tests for v1 batch scoring, today digest, interview prep, seed demo, and SMTP transport."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import psycopg
import pytest
from fastapi.testclient import TestClient
from app.config import DATABASE_URL
from app.db import repo_app, repo_profile
from app.transport import (
ClipboardTransport,
SmtpTransport,
get_transport,
is_smtp_configured,
reset_transport,
set_transport,
)
@pytest.fixture()
def client():
from app.main import app
return TestClient(app)
# --- Batch Scoring ---
class TestBatchScoring:
def test_batch_score_multiple(self, client):
"""Batch score multiple applications."""
ids = []
for i in range(3):
resp = client.post(
"/api/postings",
json={"url": f"https://example.com/batch/{i}"},
)
ids.append(resp.json()["id"])
resp = client.post(
"/api/scoring/batch",
json={"application_ids": ids},
)
assert resp.status_code == 200
data = resp.json()
assert len(data["results"]) == 3
for r in data["results"]:
assert "score" in r
assert "red_flags" in r
assert isinstance(r["red_flags"], list)
def test_batch_score_empty_list(self, client):
"""Empty application_ids list -> 200 with empty results."""
resp = client.post(
"/api/scoring/batch",
json={"application_ids": []},
)
assert resp.status_code == 200
assert resp.json()["results"] == []
def test_batch_score_nonexistent_app_skipped(self, client):
"""Nonexistent application IDs are silently skipped."""
resp = client.post(
"/api/scoring/batch",
json={"application_ids": ["00000000-0000-0000-0000-000000000000"]},
)
assert resp.status_code == 200
assert resp.json()["results"] == []
def test_batch_score_includes_red_flags(self, client):
"""Batch score results include red_flags field."""
resp = client.post(
"/api/postings",
json={"url": "https://example.com/redflag/test"},
)
app_id = resp.json()["id"]
resp = client.post(
"/api/scoring/batch",
json={"application_ids": [app_id]},
)
assert resp.status_code == 200
result = resp.json()["results"][0]
assert "red_flags" in result
assert isinstance(result["red_flags"], list)
def test_batch_score_updates_application_state(self, client):
"""After batch scoring, application state should be 'scored'."""
resp = client.post(
"/api/postings",
json={"url": "https://example.com/state/test"},
)
app_id = resp.json()["id"]
client.post(
"/api/scoring/batch",
json={"application_ids": [app_id]},
)
apps = client.get("/api/applications").json()
scored = [a for a in apps if a["id"] == app_id]
assert len(scored) == 1
assert scored[0]["state"] == "scored"
assert scored[0]["score"] is not None
# --- Today ---
class TestToday:
def test_today_empty(self, client):
"""Today endpoint with no data returns empty digest and zero pending."""
resp = client.get("/api/today")
assert resp.status_code == 200
data = resp.json()
assert data["digest"] == []
assert data["nudges"] == []
assert data["pending_approvals"] == 0
def test_today_with_scored_applications(self, client):
"""Today digest includes scored applications ordered by score desc."""
# Create and score two applications
for url in ["https://example.com/today/1", "https://example.com/today/2"]:
client.post("/api/postings", json={"url": url})
apps = client.get("/api/applications").json()
client.post(
"/api/scoring/batch",
json={"application_ids": [a["id"] for a in apps]},
)
resp = client.get("/api/today")
assert resp.status_code == 200
digest = resp.json()["digest"]
assert len(digest) == 2
# Should be ordered by score desc
assert digest[0]["score"] >= digest[1]["score"]
def test_today_nudge_for_backdated_sent(self, client):
"""A sent application backdated 8 days should appear in nudges."""
# Create posting + application
resp = client.post("/api/postings", json={"url": "https://example.com/nudge/1"})
app_id = resp.json()["id"]
# Score it, then move through states to sent
client.post("/api/scoring/batch", json={"application_ids": [app_id]})
client.post(f"/api/applications/{app_id}/transition", json={"to": "approved"})
client.post(f"/api/applications/{app_id}/transition", json={"to": "drafting"})
# Directly set state to sent (bypassing guard for test)
with psycopg.connect(DATABASE_URL) as conn:
conn.execute(
"UPDATE application SET state = 'sent', last_activity_at = %s WHERE id = %s",
(datetime.now(timezone.utc) - timedelta(days=8), app_id),
)
conn.commit()
resp = client.get("/api/today")
assert resp.status_code == 200
nudges = resp.json()["nudges"]
assert len(nudges) == 1
assert nudges[0]["application_id"] == app_id
assert nudges[0]["days_since_sent"] >= 8
def test_today_nudge_snoozed_excluded(self, client):
"""A snoozed application should not appear in nudges."""
resp = client.post("/api/postings", json={"url": "https://example.com/nudge/2"})
app_id = resp.json()["id"]
# Score and set to sent with backdated activity
client.post("/api/scoring/batch", json={"application_ids": [app_id]})
with psycopg.connect(DATABASE_URL) as conn:
conn.execute(
"UPDATE application SET state = 'sent', last_activity_at = %s WHERE id = %s",
(datetime.now(timezone.utc) - timedelta(days=10), app_id),
)
conn.commit()
# Verify it shows up first
resp = client.get("/api/today")
assert len(resp.json()["nudges"]) == 1
# Snooze it for a future date
future = (datetime.now(timezone.utc) + timedelta(days=7)).date()
with psycopg.connect(DATABASE_URL) as conn:
conn.execute(
"UPDATE application SET follow_up_snoozed_until = %s WHERE id = %s",
(future, app_id),
)
conn.commit()
resp = client.get("/api/today")
assert len(resp.json()["nudges"]) == 0
def test_today_pending_approvals_count(self, client):
"""Today endpoint counts pending (unconfirmed, unexpired) approvals."""
# Create posting + application + artifact
resp = client.post("/api/postings", json={"url": "https://example.com/today/3"})
app_id = resp.json()["id"]
client.post(
f"/api/applications/{app_id}/artifacts",
json={"kind": "email", "content": "test content"},
)
artifacts = client.get(f"/api/applications/{app_id}/artifacts").json()
artifact_id = artifacts[0]["id"]
# Create approval (not confirmed)
client.post(
f"/api/applications/{app_id}/approvals",
json={"action": "send_email", "artifact_id": artifact_id},
)
resp = client.get("/api/today")
assert resp.json()["pending_approvals"] == 1
# --- Interview Prep ---
class TestInterviewPrep:
def test_interview_prep_creates_artifact(self, client):
"""POST /applications/{id}/interview-prep creates an artifact."""
resp = client.post("/api/postings", json={"url": "https://example.com/iprep/1"})
app_id = resp.json()["id"]
resp = client.post(f"/api/applications/{app_id}/interview-prep")
assert resp.status_code == 200
data = resp.json()
assert "artifact_id" in data
assert "content" in data
assert len(data["content"]) > 0
assert "Q1" in data["content"]
def test_interview_prep_artifact_in_list(self, client):
"""Interview prep artifact appears in GET artifacts list."""
resp = client.post("/api/postings", json={"url": "https://example.com/iprep/2"})
app_id = resp.json()["id"]
resp = client.post(f"/api/applications/{app_id}/interview-prep")
artifact_id = resp.json()["artifact_id"]
resp = client.get(f"/api/applications/{app_id}/artifacts")
artifacts = resp.json()
assert any(a["id"] == artifact_id for a in artifacts)
def test_interview_prep_sets_artifact_id_on_app(self, client):
"""interview_prep_artifact_id is set on the application."""
resp = client.post("/api/postings", json={"url": "https://example.com/iprep/3"})
app_id = resp.json()["id"]
resp = client.post(f"/api/applications/{app_id}/interview-prep")
artifact_id = resp.json()["artifact_id"]
apps = client.get("/api/applications").json()
app_row = [a for a in apps if a["id"] == app_id][0]
assert app_row["interview_prep_artifact_id"] == artifact_id
def test_interview_prep_404_nonexistent_app(self, client):
"""404 for nonexistent application."""
resp = client.post(
"/api/applications/00000000-0000-0000-0000-000000000000/interview-prep"
)
assert resp.status_code == 404
def test_interview_prep_creates_telemetry(self, client):
"""Interview prep creates a task_run entry."""
resp = client.post("/api/postings", json={"url": "https://example.com/iprep/4"})
app_id = resp.json()["id"]
client.post(f"/api/applications/{app_id}/interview-prep")
resp = client.get("/api/telemetry/tasks")
tasks = resp.json()
assert any(t["task"] == "interview_prep" for t in tasks)
# --- Seed Demo ---
class TestSeedDemo:
def test_seed_demo_creates_data(self, client):
"""POST /concierge/seed-demo creates profile, postings, applications."""
resp = client.post("/api/concierge/seed-demo")
assert resp.status_code == 200
data = resp.json()
assert data["profile"] == "Demo Demosson"
assert data["postings"] == 6
assert data["applications"] == 6
assert data["sections"] == 4
def test_seed_demo_idempotent(self, client):
"""Running seed-demo twice returns the same counts."""
resp1 = client.post("/api/concierge/seed-demo")
assert resp1.status_code == 200
resp2 = client.post("/api/concierge/seed-demo")
assert resp2.status_code == 200
assert resp2.json()["postings"] == resp1.json()["postings"]
assert resp2.json()["applications"] == resp1.json()["applications"]
assert resp2.json()["sections"] == resp1.json()["sections"]
def test_seed_demo_has_nudge_candidate(self, client):
"""After seeding, /today should show a nudge for the backdated sent app."""
client.post("/api/concierge/seed-demo")
resp = client.get("/api/today")
nudges = resp.json()["nudges"]
assert len(nudges) >= 1
def test_seed_demo_has_scored_digest(self, client):
"""After seeding, /today digest should have scored applications."""
client.post("/api/concierge/seed-demo")
resp = client.get("/api/today")
digest = resp.json()["digest"]
assert len(digest) >= 1
# --- SMTP Transport ---
class TestSmtpTransportSelection:
def test_clipboard_when_no_smtp(self, monkeypatch):
"""Without SMTP_HOST, transport is ClipboardTransport."""
monkeypatch.delenv("SMTP_HOST", raising=False)
reset_transport()
t = get_transport()
assert isinstance(t, ClipboardTransport)
def test_smtp_when_configured(self, monkeypatch):
"""With SMTP_HOST set, transport is SmtpTransport."""
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
monkeypatch.setenv("SMTP_PORT", "587")
monkeypatch.setenv("SMTP_USER", "user@example.com")
monkeypatch.setenv("SMTP_PASS", "pass")
monkeypatch.setenv("SMTP_FROM", "from@example.com")
reset_transport()
t = get_transport()
assert isinstance(t, SmtpTransport)
assert t.host == "smtp.example.com"
assert t.port == 587
reset_transport()
def test_smtp_ssl_on_465(self, monkeypatch):
"""SMTP port 465 triggers SSL."""
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
monkeypatch.setenv("SMTP_PORT", "465")
reset_transport()
t = get_transport()
assert isinstance(t, SmtpTransport)
assert t.port == 465
reset_transport()
def test_clipboard_send_success(self):
"""ClipboardTransport.send returns success with payload."""
t = ClipboardTransport()
payload = {"to": "test@example.com", "subject": "Hi", "body": "Hello"}
result = t.send(payload)
assert result["success"] is True
assert result["transport"] == "clipboard"
assert result["payload"] == payload
def test_set_transport_override(self):
"""set_transport overrides the default."""
custom = ClipboardTransport()
set_transport(custom)
assert get_transport() is custom
reset_transport()
def test_is_smtp_configured_false(self, monkeypatch):
"""is_smtp_configured returns False when no SMTP_HOST."""
monkeypatch.delenv("SMTP_HOST", raising=False)
assert not is_smtp_configured()
def test_is_smtp_configured_true(self, monkeypatch):
"""is_smtp_configured returns True when SMTP_HOST is set."""
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
assert is_smtp_configured()
# --- Scheduler ---
class TestScheduler:
def test_scheduler_disabled_by_default(self, monkeypatch):
"""SCHEDULER_ENABLED defaults to false."""
from app.scheduler import is_scheduler_enabled
monkeypatch.delenv("SCHEDULER_ENABLED", raising=False)
assert not is_scheduler_enabled()
def test_scheduler_enabled_when_true(self, monkeypatch):
"""SCHEDULER_ENABLED=true enables scheduler."""
from app.scheduler import is_scheduler_enabled
monkeypatch.setenv("SCHEDULER_ENABLED", "true")
assert is_scheduler_enabled()
def test_start_scheduler_noop_when_disabled(self, monkeypatch):
"""start_scheduler does nothing when disabled."""
from app import scheduler
monkeypatch.setenv("SCHEDULER_ENABLED", "false")
scheduler.start_scheduler() # should not raise
scheduler.stop_scheduler()

View file

@ -1,37 +1,6 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { RouterLink, RouterView, useRouter } from 'vue-router'
import { RouterLink, RouterView } from 'vue-router'
import ToastHost from './components/ToastHost.vue'
import * as api from '@/api'
import type { Profile } from '@/types'
const router = useRouter()
const profile = ref<Profile | null>(null)
const profileChecked = ref(false)
async function checkProfile() {
try {
profile.value = await api.getProfile()
} catch {
// API not available, let normal routing proceed
} finally {
profileChecked.value = true
}
}
onMounted(() => {
checkProfile()
})
// Redirect to /welcome when profile.full_name is empty (onboarding wizard)
watch(profileChecked, (ready) => {
if (ready && profile.value && !profile.value.full_name) {
const currentRoute = router.currentRoute.value
if (currentRoute.name !== 'welcome') {
router.push('/welcome')
}
}
})
</script>
<template>
@ -40,7 +9,6 @@ watch(profileChecked, (ready) => {
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center gap-6">
<span class="text-lg font-bold text-indigo-700">Jobhunt</span>
<nav class="flex gap-4 text-sm">
<RouterLink to="/today" class="text-gray-600 hover:text-indigo-700">Today</RouterLink>
<RouterLink to="/cv" class="text-gray-600 hover:text-indigo-700">CV</RouterLink>
<RouterLink to="/research" class="text-gray-600 hover:text-indigo-700">Research</RouterLink>
<RouterLink to="/applications" class="text-gray-600 hover:text-indigo-700">Applications</RouterLink>

View file

@ -6,21 +6,13 @@ import type {
Application,
Approval,
Artifact,
BatchScoringResponse,
CoverLetterResponse,
CritiqueComment,
CvImportConfirmResponse,
CvImportResponse,
CvSection,
DemoSeedResponse,
InterviewPrepResponse,
JobPosting,
PostingsFetchResponse,
Profile,
RenderCvResponse,
ScoreResponse,
TaskRun,
TodayResponse
ScoreResponse
} from '@/types'
const API_BASE: string =
@ -162,77 +154,17 @@ export function outboxSend(approvalId: string, payload: Record<string, unknown>)
})
}
// --- Telemetry ---
export function getTelemetryTasks(): Promise<TaskRun[]> {
return request<TaskRun[]>('/telemetry/tasks')
}
// --- v1.0 additions (api-contract-v2.md) ---
export function importCv(filename: string, contentBase64: string): Promise<CvImportResponse> {
return request<CvImportResponse>('/cv/import', {
method: 'POST',
body: JSON.stringify({ filename, content_base64: contentBase64 })
})
}
export function confirmCvImport(drafts: CvImportResponse['drafts']): Promise<CvImportConfirmResponse> {
return request<CvImportConfirmResponse>('/cv/import/confirm', {
method: 'POST',
body: JSON.stringify({ drafts })
})
}
export function fetchPostings(query: string, region?: string): Promise<PostingsFetchResponse> {
const body: Record<string, string> = { query }
if (region) body.region = region
return request<PostingsFetchResponse>('/postings/fetch', {
method: 'POST',
body: JSON.stringify(body)
})
}
export function batchScore(applicationIds: string[]): Promise<BatchScoringResponse> {
return request<BatchScoringResponse>('/scoring/batch', {
method: 'POST',
body: JSON.stringify({ application_ids: applicationIds })
})
}
export function getToday(): Promise<TodayResponse> {
return request<TodayResponse>('/today')
}
export function interviewPrep(applicationId: string): Promise<InterviewPrepResponse> {
return request<InterviewPrepResponse>(`/applications/${applicationId}/interview-prep`, {
method: 'POST'
})
}
export function seedDemo(): Promise<DemoSeedResponse> {
return request<DemoSeedResponse>('/concierge/seed-demo', { method: 'POST' })
}
// Re-export types for convenience
export type {
AiAssistResponse,
Application,
Approval,
Artifact,
BatchScoringResponse,
CoverLetterResponse,
CritiqueComment,
CvImportConfirmResponse,
CvImportResponse,
CvSection,
DemoSeedResponse,
InterviewPrepResponse,
JobPosting,
PostingsFetchResponse,
Profile,
RenderCvResponse,
ScoreResponse,
TaskRun,
TodayResponse
ScoreResponse
}

View file

@ -1,55 +0,0 @@
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import * as api from '@/api'
import type { TaskRun } from '@/types'
const tasks = ref<TaskRun[]>([])
const loading = ref(true)
const error = ref(false)
const totalTokensIn = computed(() =>
tasks.value.reduce((sum, t) => sum + (t.tokens_in ?? 0), 0)
)
const totalTokensOut = computed(() =>
tasks.value.reduce((sum, t) => sum + (t.tokens_out ?? 0), 0)
)
const totalCost = computed(() =>
tasks.value.reduce((sum, t) => sum + (t.cost ?? 0), 0)
)
const hasCost = computed(() => tasks.value.some((t) => t.cost != null))
async function loadTasks() {
try {
tasks.value = await api.getTelemetryTasks()
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(loadTasks)
</script>
<template>
<div class="bg-white rounded-lg border border-gray-200 p-4">
<h3 class="font-semibold text-sm text-gray-700 mb-2">LLM Cost Summary</h3>
<div v-if="loading" class="text-gray-400 text-sm">Loading...</div>
<div v-else-if="error" class="text-red-600 text-sm">Failed to load cost data.</div>
<div v-else class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-gray-600">Tokens in:</span>
<span class="font-medium">{{ totalTokensIn.toLocaleString() }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Tokens out:</span>
<span class="font-medium">{{ totalTokensOut.toLocaleString() }}</span>
</div>
<div v-if="hasCost" class="flex justify-between border-t border-gray-100 pt-1">
<span class="text-gray-600">Total cost:</span>
<span class="font-medium">{{ totalCost.toFixed(4) }}</span>
</div>
<div class="text-xs text-gray-400 mt-1">{{ tasks.length }} task runs</div>
</div>
</div>
</template>

View file

@ -1,121 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useToastStore } from '@/stores/toast'
import * as api from '@/api'
import { HttpError } from '@/api'
import type { InterviewPrepResponse } from '@/types'
const props = defineProps<{
applicationId: string
visible: boolean
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const toast = useToastStore()
const loading = ref(false)
const saving = ref(false)
const prepContent = ref('')
const artifactId = ref<string | null>(null)
async function generatePrep() {
loading.value = true
prepContent.value = ''
artifactId.value = null
try {
const res: InterviewPrepResponse = await api.interviewPrep(props.applicationId)
prepContent.value = res.content
artifactId.value = res.artifact_id
toast.push('Interview prep generated', 'success')
} catch (err) {
let msg = 'Failed to generate interview prep'
if (err instanceof HttpError) {
const body = err.body as { error?: { message?: string } } | null
msg = body?.error?.message ?? msg
}
toast.push(msg, 'error')
} finally {
loading.value = false
}
}
async function savePrep() {
if (!prepContent.value.trim()) return
saving.value = true
try {
const res: InterviewPrepResponse = await api.interviewPrep(props.applicationId)
artifactId.value = res.artifact_id
toast.push('Interview prep saved as new version', 'success')
} catch {
toast.push('Failed to save interview prep', 'error')
} finally {
saving.value = false
}
}
function close() {
emit('close')
}
</script>
<template>
<div
v-if="visible"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
@click.self="close"
>
<div class="bg-white rounded-lg shadow-xl max-w-3xl w-full mx-4 max-h-[80vh] flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3">
<h2 class="font-semibold text-lg">Interview Prep</h2>
<button @click="close" class="text-gray-400 hover:text-gray-700 text-xl leading-none">&times;</button>
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto p-4 space-y-4">
<div v-if="!prepContent && !loading" class="text-center py-8">
<p class="text-gray-500 mb-4">
Generate likely interview questions with suggested answers based on your profile and the job posting.
</p>
<button
@click="generatePrep"
class="bg-indigo-600 text-white px-4 py-2 rounded text-sm"
>
Generate Interview Prep
</button>
</div>
<div v-if="loading" class="text-gray-500 text-center py-8">Generating interview prep...</div>
<div v-if="prepContent && !loading" class="space-y-3">
<textarea
v-model="prepContent"
rows="18"
class="w-full border rounded px-3 py-2 text-sm font-mono"
placeholder="Interview prep content..."
></textarea>
<div class="flex gap-3">
<button
@click="savePrep"
:disabled="saving"
class="bg-green-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
>
{{ saving ? 'Saving...' : 'Save as New Version' }}
</button>
<button
@click="generatePrep"
class="text-sm text-indigo-600 hover:underline"
>
Regenerate
</button>
</div>
<div v-if="artifactId" class="text-xs text-gray-400">
Artifact ID: <code>{{ artifactId.slice(0, 8) }}</code>
</div>
</div>
</div>
</div>
</div>
</template>

View file

@ -1,51 +1,33 @@
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
import App from '@/App.vue'
vi.mock('@/api', () => ({
getProfile: vi.fn().mockResolvedValue({ id: 'p1', full_name: 'Test User', email: '', phone: '', location: '', headline: '', summary: '', languages: [], hard_rules: {} }),
HttpError: class HttpError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown, msg?: string) {
super(msg ?? `HTTP ${status}`)
this.status = status
this.body = body
}
}
}))
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', redirect: '/today' },
{ path: '/today', name: 'today', component: { template: '<div>Today</div>' } },
{ path: '/', redirect: '/cv' },
{ path: '/cv', name: 'cv', component: { template: '<div>CV</div>' } },
{ path: '/research', name: 'research', component: { template: '<div>Research</div>' } },
{ path: '/applications', name: 'applications', component: { template: '<div>Applications</div>' } },
{ path: '/applications/:id', name: 'application-detail', component: { template: '<div>Detail</div>' } },
{ path: '/welcome', name: 'welcome', component: { template: '<div>Welcome</div>' } }
{ path: '/applications/:id', name: 'application-detail', component: { template: '<div>Detail</div>' } }
]
})
}
describe('Router tabs', () => {
it('renders all four tab links', async () => {
it('renders all three tab links', async () => {
setActivePinia(createPinia())
const router = makeRouter()
await router.push('/today')
await router.push('/cv')
await router.isReady()
const wrapper = mount(App, { global: { plugins: [router] } })
await vi.waitFor(() => {
const links = wrapper.findAll('nav a')
expect(links).toHaveLength(4)
expect(links[0].text()).toBe('Today')
expect(links[1].text()).toBe('CV')
expect(links[2].text()).toBe('Research')
expect(links[3].text()).toBe('Applications')
})
const links = wrapper.findAll('nav a')
expect(links).toHaveLength(3)
expect(links[0].text()).toBe('CV')
expect(links[1].text()).toBe('Research')
expect(links[2].text()).toBe('Applications')
})
})

View file

@ -2,17 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{ path: '/', redirect: '/today' },
{
path: '/welcome',
name: 'welcome',
component: () => import('@/views/Welcome.vue')
},
{
path: '/today',
name: 'today',
component: () => import('@/views/TodayView.vue')
},
{ path: '/', redirect: '/cv' },
{
path: '/cv',
name: 'cv',

View file

@ -125,87 +125,4 @@ export interface AiAssistResponse {
export interface ApiError {
error: { code: string; message: string }
}
// --- v1.0 additions (api-contract-v2.md) ---
export interface CvDraft {
kind: CvSectionKind
title: string
org: string
location: string
start_date: string
end_date: string | null
bullets: string[]
tags: string[]
}
export interface CvImportResponse {
drafts: CvDraft[]
}
export interface CvImportConfirmResponse {
created: number
sections: CvSection[]
}
export interface PostingsFetchResponse {
new: number
dupes: number
}
export interface BatchScoringResult {
application_id: string
score: number
rationale: Record<string, unknown>
red_flags: string[]
}
export interface BatchScoringResponse {
results: BatchScoringResult[]
}
export interface TodayDigestItem {
application_id: string
title: string
company: string
score: number
}
export interface TodayNudge {
application_id: string
days_since_sent: number
suggestion: string
}
export interface TodayResponse {
digest: TodayDigestItem[]
nudges: TodayNudge[]
pending_approvals: number
}
export interface InterviewPrepResponse {
artifact_id: string
content: string
}
export interface DemoSeedResponse {
profile: string
postings: number
applications: number
sections: number
}
export interface TaskRun {
id: string
task_type: string
model: string
tokens_in: number
tokens_out: number
cost: number | null
created_at: string
}
export interface RedFlagsMap {
[applicationId: string]: string[]
}

View file

@ -12,7 +12,6 @@ vi.mock('@/api', () => ({
confirmApproval: vi.fn(),
rejectApproval: vi.fn(),
outboxSend: vi.fn(),
interviewPrep: vi.fn(),
HttpError: class HttpError extends Error {
status: number
body: unknown

View file

@ -3,7 +3,6 @@ import { onMounted, ref, computed } from 'vue'
import { useToastStore } from '@/stores/toast'
import * as api from '@/api'
import { HttpError } from '@/api'
import InterviewPrepModal from '@/components/InterviewPrepModal.vue'
import type {
Application,
Artifact,
@ -33,9 +32,6 @@ const requestingApproval = ref(false)
const confirming = ref(false)
const sending = ref(false)
// Interview prep modal
const showPrepModal = ref(false)
const isConfirmed = computed(() => approval.value?.confirmed_by_user ?? false)
const canSend = computed(() => isConfirmed.value && !sending.value)
@ -131,14 +127,6 @@ async function sendOutbox() {
}
}
function openInterviewPrep() {
showPrepModal.value = true
}
function closeInterviewPrep() {
showPrepModal.value = false
}
onMounted(loadData)
</script>
@ -159,20 +147,6 @@ onMounted(loadData)
</div>
</section>
<!-- Interview prep -->
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
<h2 class="font-semibold">Interview Prep</h2>
<p class="text-sm text-gray-600">
Generate likely interview questions with suggested answers based on your profile and the posting.
</p>
<button
@click="openInterviewPrep"
class="bg-purple-600 text-white px-4 py-2 rounded text-sm"
>
Open Interview Prep
</button>
</section>
<!-- Artifacts list -->
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-2">
<h2 class="font-semibold">Artifacts</h2>
@ -273,12 +247,5 @@ onMounted(loadData)
</template>
<div v-if="!loading && !application" class="text-gray-500">Application not found.</div>
<!-- Interview prep modal -->
<InterviewPrepModal
:application-id="id"
:visible="showPrepModal"
@close="closeInterviewPrep"
/>
</div>
</template>

View file

@ -1,112 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import Applications from '@/views/Applications.vue'
import type { Application } from '@/types'
vi.mock('@/api', () => ({
getApplications: vi.fn(),
transitionApplication: vi.fn(),
batchScore: vi.fn(),
getToday: vi.fn(),
HttpError: class HttpError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown, msg?: string) {
super(msg ?? `HTTP ${status}`)
this.status = status
this.body = body
}
}
}))
function makeApp(id: string, state: string, company: string): Application {
return {
id,
job_posting_id: 'j-' + id,
state: state as Application['state'],
score: 80,
score_rationale: null,
notes: '',
state_changed_at: '2026-01-01T00:00:00Z',
created_at: '2026-01-01T00:00:00Z',
posting: {
id: 'j-' + id, source: 'manual_url', external_id: null, url: 'http://x',
company, title: 'Engineer', location: 'Remote', description: '',
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
}
}
}
describe('Applications kanban badges', () => {
it('renders nudge dot on cards that have a follow-up nudge', async () => {
setActivePinia(createPinia())
const api = await import('@/api')
const apps = [makeApp('app-1', 'sent', 'NudgeCorp'), makeApp('app-2', 'discovered', 'NoNudge')]
;(api.getApplications as ReturnType<typeof vi.fn>).mockResolvedValue(apps)
;(api.batchScore as ReturnType<typeof vi.fn>).mockResolvedValue({ results: [] })
;(api.getToday as ReturnType<typeof vi.fn>).mockResolvedValue({
digest: [],
nudges: [
{ application_id: 'app-1', days_since_sent: 9, suggestion: 'Send a follow-up email' }
],
pending_approvals: 0
})
const wrapper = mount(Applications)
await vi.waitFor(() => {
expect(wrapper.text()).toContain('NudgeCorp')
})
// The nudge dot should be rendered as an orange dot (span with bg-orange-500)
const dots = wrapper.findAll('.bg-orange-500')
expect(dots.length).toBeGreaterThanOrEqual(1)
// Verify the dot is in the card for NudgeCorp (the sent column)
const sentCol = wrapper.findAll('.font-semibold').find((el) => el.text() === 'sent')
expect(sentCol).toBeTruthy()
const sentColumn = sentCol!.element.parentElement!
expect(sentColumn.textContent).toContain('NudgeCorp')
// The dot should be inside this column
expect(sentColumn.querySelector('.bg-orange-500')).toBeTruthy()
})
it('renders red flag badge with tooltip text from batch scoring results', async () => {
setActivePinia(createPinia())
const api = await import('@/api')
const apps = [makeApp('app-1', 'discovered', 'ScamCorp')]
;(api.getApplications as ReturnType<typeof vi.fn>).mockResolvedValue(apps)
;(api.batchScore as ReturnType<typeof vi.fn>).mockResolvedValue({
results: [
{
application_id: 'app-1',
score: 30,
rationale: {},
red_flags: ['Unpaid trial period mentioned', 'Asks for bank details upfront']
}
]
})
;(api.getToday as ReturnType<typeof vi.fn>).mockResolvedValue({
digest: [],
nudges: [],
pending_approvals: 0
})
const wrapper = mount(Applications)
await vi.waitFor(() => {
expect(wrapper.text()).toContain('ScamCorp')
})
// The warning symbol should be rendered in the card
const warningEl = wrapper.find('.text-red-600.font-bold')
expect(warningEl.exists()).toBe(true)
// The title attribute should contain the red flag text
const title = warningEl.attributes('title')
expect(title).toBeTruthy()
expect(title).toContain('Unpaid trial period mentioned')
expect(title).toContain('Asks for bank details upfront')
})
})

View file

@ -7,8 +7,6 @@ import type { Application } from '@/types'
vi.mock('@/api', () => ({
getApplications: vi.fn(),
transitionApplication: vi.fn(),
batchScore: vi.fn().mockResolvedValue({ results: [] }),
getToday: vi.fn().mockResolvedValue({ digest: [], nudges: [], pending_approvals: 0 }),
HttpError: class HttpError extends Error {
status: number
body: unknown

View file

@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
import { useToastStore } from '@/stores/toast'
import * as api from '@/api'
import { HttpError } from '@/api'
import type { Application, ApplicationState, TodayNudge } from '@/types'
import type { Application, ApplicationState } from '@/types'
const toast = useToastStore()
const router = useRouter()
@ -14,10 +14,6 @@ const loading = ref(true)
const draggingId = ref<string | null>(null)
const draggingFrom = ref<ApplicationState | null>(null)
// Red flags and nudges
const redFlagsMap = ref<Record<string, string[]>>({})
const nudgeIds = ref<Set<string>>(new Set())
const states: ApplicationState[] = [
'discovered',
'scored',
@ -35,39 +31,9 @@ function appsInState(state: ApplicationState): Application[] {
return applications.value.filter((a) => a.state === state)
}
function hasRedFlags(app: Application): boolean {
const flags = redFlagsMap.value[app.id]
return flags != null && flags.length > 0
}
function redFlagsFor(app: Application): string[] {
return redFlagsMap.value[app.id] ?? []
}
function hasNudge(app: Application): boolean {
return nudgeIds.value.has(app.id)
}
async function loadApplications() {
try {
applications.value = await api.getApplications()
// Load red flags via batch scoring and nudges via today endpoint
const [batchResult, todayResult] = await Promise.allSettled([
api.batchScore(applications.value.map((a) => a.id)),
api.getToday()
])
if (batchResult.status === 'fulfilled') {
const map: Record<string, string[]> = {}
for (const r of batchResult.value.results) {
if (r.red_flags && r.red_flags.length > 0) {
map[r.application_id] = r.red_flags
}
}
redFlagsMap.value = map
}
if (todayResult.status === 'fulfilled') {
nudgeIds.value = new Set(todayResult.value.nudges.map((n: TodayNudge) => n.application_id))
}
} catch {
toast.push('Failed to load applications', 'error')
} finally {
@ -151,21 +117,7 @@ onMounted(loadApplications)
@click="goToDetail(app)"
class="bg-white rounded border border-gray-200 p-2 cursor-pointer hover:shadow-md transition-shadow"
>
<div class="flex items-center gap-1">
<span
v-if="hasRedFlags(app)"
class="text-red-600 font-bold text-sm flex-shrink-0"
:title="redFlagsFor(app).join('; ')"
>
&#x26A0;
</span>
<span
v-if="hasNudge(app)"
class="inline-block w-2 h-2 rounded-full bg-orange-500 flex-shrink-0"
title="Follow-up nudge pending"
></span>
<div class="font-medium text-sm truncate">{{ app.posting?.company ?? 'Unknown' }}</div>
</div>
<div class="font-medium text-sm truncate">{{ app.posting?.company ?? 'Unknown' }}</div>
<div class="text-xs text-gray-500 truncate">{{ app.posting?.title ?? 'No title' }}</div>
<div v-if="app.score != null" class="text-xs text-green-700 mt-1">
Score: {{ app.score }}

View file

@ -2,7 +2,6 @@
import { onMounted, ref } from 'vue'
import { useToastStore } from '@/stores/toast'
import * as api from '@/api'
import { HttpError } from '@/api'
import type { JobPosting } from '@/types'
const toast = useToastStore()
@ -12,32 +11,10 @@ const loading = ref(true)
const newUrl = ref('')
const scoringId = ref<string | null>(null)
const scoreMap = ref<Record<string, { score: number; rationale: Record<string, unknown> }>>({})
const redFlagsMap = ref<Record<string, string[]>>({})
// Fetch form
const fetchQuery = ref('')
const fetchRegion = ref('')
const fetching = ref(false)
const fetchResult = ref<{ new: number; dupes: number } | null>(null)
async function loadPostings() {
try {
postings.value = await api.getPostings()
// Load red flags for existing postings via batch scoring
if (postings.value.length > 0) {
try {
const batch = await api.batchScore(postings.value.map((p) => p.id))
const map: Record<string, string[]> = {}
for (const r of batch.results) {
if (r.red_flags && r.red_flags.length > 0) {
map[r.application_id] = r.red_flags
}
}
redFlagsMap.value = map
} catch {
// batch scoring is optional; ignore errors
}
}
} catch {
toast.push('Failed to load postings', 'error')
} finally {
@ -57,27 +34,6 @@ async function addPosting() {
}
}
async function doFetch() {
if (!fetchQuery.value.trim()) return
fetching.value = true
fetchResult.value = null
try {
fetchResult.value = await api.fetchPostings(fetchQuery.value.trim(), fetchRegion.value.trim() || undefined)
toast.push(`Fetched ${fetchResult.value.new} new postings`, 'success')
// Reload postings to show new ones
await loadPostings()
} catch (err) {
let msg = 'Fetch failed'
if (err instanceof HttpError) {
const body = err.body as { error?: { message?: string } } | null
msg = body?.error?.message ?? msg
}
toast.push(msg, 'error')
} finally {
fetching.value = false
}
}
async function scorePosting(p: JobPosting) {
scoringId.value = p.id
try {
@ -91,15 +47,6 @@ async function scorePosting(p: JobPosting) {
}
}
function hasScamFlag(p: JobPosting): boolean {
const flags = redFlagsMap.value[p.id]
return flags != null && flags.length > 0
}
function scamFlagsFor(p: JobPosting): string[] {
return redFlagsMap.value[p.id] ?? []
}
onMounted(loadPostings)
</script>
@ -107,37 +54,6 @@ onMounted(loadPostings)
<div class="space-y-6">
<h1 class="text-2xl font-bold">Research</h1>
<!-- Fetch form (Arbetsformedlingen connector) -->
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
<h2 class="font-semibold">Fetch from Arbetsformedlingen</h2>
<div class="flex gap-2">
<input
v-model="fetchQuery"
placeholder="Search query (e.g. python developer)"
class="flex-1 border rounded px-2 py-1"
@keyup.enter="doFetch"
/>
<input
v-model="fetchRegion"
placeholder="Region (optional)"
class="w-48 border rounded px-2 py-1"
@keyup.enter="doFetch"
/>
<button
@click="doFetch"
:disabled="fetching"
class="bg-indigo-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
>
{{ fetching ? 'Fetching...' : 'Fetch Postings' }}
</button>
</div>
<div v-if="fetchResult" class="text-sm">
<span class="text-green-700 font-medium">{{ fetchResult.new }} new</span>,
<span class="text-gray-500">{{ fetchResult.dupes }} duplicates</span>
</div>
</section>
<!-- Manual URL add -->
<section class="bg-white rounded-lg border border-gray-200 p-4 flex gap-2">
<input
v-model="newUrl"
@ -157,7 +73,6 @@ onMounted(loadPostings)
<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>
@ -168,16 +83,6 @@ onMounted(loadPostings)
<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

View file

@ -1,122 +0,0 @@
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useToastStore } from '@/stores/toast'
import * as api from '@/api'
import CostDisplay from '@/components/CostDisplay.vue'
import type { TodayResponse } from '@/types'
const toast = useToastStore()
const router = useRouter()
const today = ref<TodayResponse | null>(null)
const loading = ref(true)
const nudgeIds = computed(() => new Set(today.value?.nudges.map((n) => n.application_id) ?? []))
async function loadToday() {
try {
today.value = await api.getToday()
} catch {
toast.push('Failed to load today digest', 'error')
} finally {
loading.value = false
}
}
function goToApplication(id: string) {
router.push(`/applications/${id}`)
}
function copyNudge(suggestion: string) {
if (navigator.clipboard) {
navigator.clipboard.writeText(suggestion).then(
() => toast.push('Follow-up draft copied to clipboard', 'success'),
() => toast.push('Copy failed', 'error')
)
} else {
toast.push('Clipboard not available', 'error')
}
}
onMounted(loadToday)
</script>
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold">Today</h1>
<div v-if="loading" class="text-gray-500">Loading...</div>
<template v-if="!loading && today">
<!-- Pending approvals banner -->
<div v-if="today.pending_approvals > 0" class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<span class="font-medium text-yellow-800">
{{ today.pending_approvals }} pending approval{{ today.pending_approvals > 1 ? 's' : '' }} waiting for you.
</span>
</div>
<!-- Digest cards -->
<section>
<h2 class="font-semibold text-lg mb-3">Top Matches Today</h2>
<div v-if="today.digest.length === 0" class="text-gray-400 text-sm">No postings in your digest yet.</div>
<div v-else class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="item in today.digest"
:key="item.application_id"
class="bg-white rounded-lg border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow"
@click="goToApplication(item.application_id)"
>
<div class="font-medium">{{ item.title }}</div>
<div class="text-sm text-gray-600">{{ item.company }}</div>
<div class="mt-2 flex items-center gap-2">
<span class="text-xs bg-green-100 text-green-800 rounded px-2 py-0.5 font-medium">
Score: {{ item.score }}
</span>
<span
v-if="nudgeIds.has(item.application_id)"
class="inline-block w-2 h-2 rounded-full bg-orange-500"
title="Follow-up nudge pending"
></span>
</div>
</div>
</div>
</section>
<!-- Nudge cards -->
<section>
<h2 class="font-semibold text-lg mb-3">Follow-up Nudges</h2>
<div v-if="today.nudges.length === 0" class="text-gray-400 text-sm">No nudges. You are up to date.</div>
<div v-else class="space-y-3">
<div
v-for="nudge in today.nudges"
:key="nudge.application_id"
class="bg-orange-50 border border-orange-200 rounded-lg p-4"
>
<div class="flex items-center justify-between">
<span class="font-medium text-orange-900">
Sent {{ nudge.days_since_sent }} days ago
</span>
<button
class="text-sm text-indigo-600 hover:underline"
@click="goToApplication(nudge.application_id)"
>
Open application
</button>
</div>
<p class="text-sm text-gray-700 mt-2">{{ nudge.suggestion }}</p>
<button
class="mt-2 text-sm bg-indigo-600 text-white px-3 py-1 rounded hover:bg-indigo-700"
@click="copyNudge(nudge.suggestion)"
>
Copy follow-up draft
</button>
</div>
</div>
</section>
<!-- Cost display -->
<CostDisplay />
</template>
</div>
</template>

View file

@ -1,70 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
import Welcome from '@/views/Welcome.vue'
vi.mock('@/api', () => ({
importCv: vi.fn(),
confirmCvImport: vi.fn(),
fetchPostings: vi.fn(),
HttpError: class HttpError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown, msg?: string) {
super(msg ?? `HTTP ${status}`)
this.status = status
this.body = body
}
}
}))
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/welcome', name: 'welcome', component: Welcome },
{ path: '/today', name: 'today', component: { template: '<div>Today</div>' } }
]
})
}
describe('Onboarding wizard', () => {
it('shows welcome step and advances through steps to finish', async () => {
setActivePinia(createPinia())
const router = makeRouter()
await router.push('/welcome')
await router.isReady()
const wrapper = mount(Welcome, { global: { plugins: [router] } })
// Step 0: Welcome
expect(wrapper.text()).toContain('Welcome to Jobhunt')
expect(wrapper.text()).toContain('Get Started')
// Advance to step 1 (Import CV)
const getStartedBtn = wrapper.find('button')
await getStartedBtn.trigger('click')
expect(wrapper.text()).toContain('Import Your CV')
// Skip import -> step 2 (Fetch Postings)
const skipLink = wrapper.findAll('button').find((b) => b.text().includes('Skip for now'))
expect(skipLink).toBeTruthy()
await skipLink!.trigger('click')
expect(wrapper.text()).toContain('Fetch Job Postings')
// Continue -> step 3 (Done)
const continueBtn = wrapper.findAll('button').find((b) => b.text().includes('Continue'))
expect(continueBtn).toBeTruthy()
await continueBtn!.trigger('click')
expect(wrapper.text()).toContain('You are all set')
// Finish -> navigates to /today
const finishBtn = wrapper.findAll('button').find((b) => b.text().includes('Go to Today'))
expect(finishBtn).toBeTruthy()
await finishBtn!.trigger('click')
await vi.waitFor(() => {
expect(router.currentRoute.value.path).toBe('/today')
})
})
})

View file

@ -1,280 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useToastStore } from '@/stores/toast'
import * as api from '@/api'
import { HttpError } from '@/api'
import type { CvDraft, PostingsFetchResponse } from '@/types'
const toast = useToastStore()
const router = useRouter()
const step = ref(0)
const steps = ['Welcome', 'Import CV', 'Fetch Postings', 'Done']
// Step 1: Import CV
const selectedFile = ref<File | null>(null)
const importing = ref(false)
const drafts = ref<CvDraft[]>([])
const importError = ref('')
// Step 2: Fetch Postings
const fetchQuery = ref('')
const fetchRegion = ref('')
const fetching = ref(false)
const fetchResult = ref<PostingsFetchResponse | null>(null)
function onFileChange(e: Event) {
const target = e.target as HTMLInputElement
if (target.files && target.files.length > 0) {
selectedFile.value = target.files[0]
importError.value = ''
}
}
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
const base64 = result.split(',')[1] ?? ''
resolve(base64)
}
reader.onerror = () => reject(new Error('Failed to read file'))
reader.readAsDataURL(file)
})
}
async function doImport() {
if (!selectedFile.value) {
importError.value = 'Please select a file first.'
return
}
importing.value = true
importError.value = ''
try {
const base64 = await fileToBase64(selectedFile.value)
const res = await api.importCv(selectedFile.value.name, base64)
drafts.value = res.drafts
if (drafts.value.length === 0) {
importError.value = 'No sections were extracted from this file.'
}
} catch (err) {
if (err instanceof HttpError) {
const body = err.body as { error?: { message?: string } } | null
importError.value = body?.error?.message ?? 'Import failed'
} else {
importError.value = 'Import failed'
}
} finally {
importing.value = false
}
}
async function confirmDrafts() {
importing.value = true
try {
await api.confirmCvImport(drafts.value)
toast.push('CV sections saved', 'success')
step.value = 2
} catch (err) {
let msg = 'Failed to save CV sections'
if (err instanceof HttpError) {
const body = err.body as { error?: { message?: string } } | null
msg = body?.error?.message ?? msg
}
toast.push(msg, 'error')
} finally {
importing.value = false
}
}
function skipImport() {
step.value = 2
}
async function doFetch() {
if (!fetchQuery.value.trim()) {
toast.push('Enter a search query', 'error')
return
}
fetching.value = true
fetchResult.value = null
try {
fetchResult.value = await api.fetchPostings(fetchQuery.value.trim(), fetchRegion.value.trim() || undefined)
toast.push(`Fetched ${fetchResult.value.new} new postings`, 'success')
} catch (err) {
let msg = 'Fetch failed'
if (err instanceof HttpError) {
const body = err.body as { error?: { message?: string } } | null
msg = body?.error?.message ?? msg
}
toast.push(msg, 'error')
} finally {
fetching.value = false
}
}
function finish() {
router.push('/today')
}
function next() {
if (step.value < steps.length - 1) step.value++
}
function prev() {
if (step.value > 0) step.value--
}
</script>
<template>
<div class="max-w-2xl mx-auto space-y-6">
<h1 class="text-2xl font-bold">Welcome to Jobhunt</h1>
<!-- Step indicator -->
<div class="flex items-center gap-2 text-sm">
<span
v-for="(s, i) in steps"
:key="s"
:class="[
'px-3 py-1 rounded-full',
i === step ? 'bg-indigo-600 text-white' : i < step ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'
]"
>
{{ i + 1 }}. {{ s }}
</span>
</div>
<!-- Step 0: Welcome -->
<div v-if="step === 0" class="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
<p class="text-gray-700">
Jobhunt helps you discover jobs, score them against your profile, draft application material, and prepare for interviews.
You stay in control: nothing is sent without your explicit approval.
</p>
<p class="text-gray-700">
Let's set up your profile in a few quick steps. You can skip any step and come back later.
</p>
<button @click="next" class="bg-indigo-600 text-white px-4 py-2 rounded text-sm">
Get Started
</button>
</div>
<!-- Step 1: Import CV -->
<div v-if="step === 1" class="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
<h2 class="font-semibold text-lg">Import Your CV</h2>
<p class="text-sm text-gray-600">
Upload a PDF, DOCX, or plain text file. We will extract sections for you to review and confirm.
</p>
<input
type="file"
accept=".pdf,.docx,.txt"
@change="onFileChange"
class="block text-sm text-gray-700"
/>
<div v-if="importError" class="text-red-600 text-sm">{{ importError }}</div>
<button
v-if="drafts.length === 0"
@click="doImport"
:disabled="importing || !selectedFile"
class="bg-indigo-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
>
{{ importing ? 'Importing...' : 'Extract Sections' }}
</button>
<!-- Drafts review -->
<div v-if="drafts.length > 0" class="space-y-3">
<h3 class="font-medium text-sm">Review extracted sections ({{ drafts.length }})</h3>
<div
v-for="(draft, i) in drafts"
:key="i"
class="border border-gray-200 rounded p-3 text-sm"
>
<div class="font-medium">{{ draft.title }} ({{ draft.kind }})</div>
<div class="text-gray-500">{{ draft.org }}{{ draft.location ? ' - ' + draft.location : '' }}</div>
<ul v-if="draft.bullets.length" class="list-disc ml-5 text-gray-600 mt-1">
<li v-for="(b, bi) in draft.bullets" :key="bi">{{ b }}</li>
</ul>
<div v-if="draft.tags.length" class="flex flex-wrap gap-1 mt-1">
<span v-for="t in draft.tags" :key="t" class="text-xs bg-gray-100 rounded px-2 py-0.5">{{ t }}</span>
</div>
</div>
<div class="flex gap-3">
<button
@click="confirmDrafts"
:disabled="importing"
class="bg-green-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
>
{{ importing ? 'Saving...' : 'Confirm & Save Sections' }}
</button>
<button @click="skipImport" class="text-sm text-gray-500 hover:underline">
Skip for now
</button>
</div>
</div>
<button v-if="drafts.length === 0" @click="skipImport" class="text-sm text-gray-500 hover:underline block">
Skip for now
</button>
</div>
<!-- Step 2: Fetch Postings -->
<div v-if="step === 2" class="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
<h2 class="font-semibold text-lg">Fetch Job Postings</h2>
<p class="text-sm text-gray-600">
Search for job postings from the Arbetsformedlingen connector. New postings will be added to your applications.
</p>
<div class="space-y-2">
<label class="block">
<span class="text-sm text-gray-600">Search query</span>
<input
v-model="fetchQuery"
placeholder="e.g. python developer"
class="w-full border rounded px-2 py-1 mt-1"
@keyup.enter="doFetch"
/>
</label>
<label class="block">
<span class="text-sm text-gray-600">Region (optional)</span>
<input
v-model="fetchRegion"
placeholder="e.g. Skane lan"
class="w-full border rounded px-2 py-1 mt-1"
@keyup.enter="doFetch"
/>
</label>
</div>
<button
@click="doFetch"
:disabled="fetching"
class="bg-indigo-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
>
{{ fetching ? 'Fetching...' : 'Fetch Postings' }}
</button>
<div v-if="fetchResult" class="text-sm">
<span class="text-green-700 font-medium">{{ fetchResult.new }} new</span>
postings found, <span class="text-gray-500">{{ fetchResult.dupes }} duplicates</span> skipped.
</div>
<button @click="next" class="text-sm text-indigo-600 hover:underline block">
Continue
</button>
</div>
<!-- Step 3: Done -->
<div v-if="step === 3" class="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
<h2 class="font-semibold text-lg">You are all set!</h2>
<p class="text-gray-700">
Your profile is ready. Head to the Today page to see your daily digest, nudges, and pending approvals.
</p>
<button @click="finish" class="bg-indigo-600 text-white px-4 py-2 rounded text-sm">
Go to Today
</button>
</div>
<!-- Navigation -->
<div v-if="step > 0 && step < 3" class="flex gap-3">
<button @click="prev" class="text-sm text-gray-500 hover:underline">Back</button>
</div>
</div>
</template>

View file

@ -20,8 +20,8 @@ services:
api-test:
build:
context: .
dockerfile: apps/api/Dockerfile.test
context: ./apps/api
dockerfile: Dockerfile.test
environment:
DATABASE_URL: postgresql://jobhunt:jobhunt@postgres:5432/jobhunt
depends_on:

View file

@ -1,199 +0,0 @@
# API contract v2 (v1.0 additions)
Base: `/api`. JSON everywhere. Errors as `{error: {code, message}}` with proper HTTP status.
This file documents the **new** endpoints added on top of `docs/api-contract.md` (POC).
All existing POC endpoints remain unchanged.
## CV Import
### `POST /cv/import`
Extract text from an uploaded file and generate draft CV sections via the LLM gateway (cheap class).
Does NOT write to `cv_section` -- returns drafts for user review.
Request body:
```json
{
"filename": "my_cv.pdf",
"content_base64": "JVBERi0xLjQK..."
}
```
Response 200:
```json
{
"drafts": [
{
"kind": "experience",
"title": "Software Engineer",
"org": "TechCorp",
"location": "Malmo",
"start_date": "2022-01",
"end_date": null,
"bullets": ["Built feature X", "Improved performance by 20%"],
"tags": ["python", "fastapi"]
}
]
}
```
Errors:
- 422 `{error: {code: "empty_file", message: "..."}}` when the file is empty or contains no extractable text.
- 422 `{error: {code: "unsupported_format", message: "..."}}` when the file type is not recognized.
### `POST /cv/import/confirm`
Create `cv_section` rows from the drafts returned by `/cv/import`.
Request body:
```json
{
"drafts": [
{
"kind": "experience",
"title": "Software Engineer",
"org": "TechCorp",
"bullets": ["Built feature X"],
"tags": ["python"]
}
]
}
```
Response 201:
```json
{
"created": 3,
"sections": [/* CvSectionOut[] */]
}
```
## Postings Fetch (Arbetsformedlingen connector)
### `POST /postings/fetch`
Fetch job postings from the Arbetsformedlingen connector, create `job_posting` + `application(discovered)` for new postings, skip duplicates.
Request body:
```json
{
"query": "python developer",
"region": "Skane lan"
}
```
Response 200:
```json
{
"new": 12,
"dupes": 3
}
```
Errors:
- 503 `{error: {code: "connectors_disabled", message: "Connectors are not enabled. Set CONNECTORS_ENABLED=true to enable."}}` when `CONNECTORS_ENABLED=false`.
## Batch Scoring
### `POST /scoring/batch`
Score multiple applications in one call (cheap class). Each response includes `red_flags` (scam/shield checks).
Request body:
```json
{
"application_ids": ["uuid1", "uuid2"]
}
```
Response 200:
```json
{
"results": [
{
"application_id": "uuid1",
"score": 72,
"rationale": {"match": 0.72, "factors": {"skills": 0.8}},
"red_flags": ["unpaid trial period mentioned"]
}
]
}
```
## Today Digest
### `GET /today`
Returns the daily digest: ranked postings, follow-up nudges, and pending approval count.
Response 200:
```json
{
"digest": [
{
"application_id": "uuid",
"title": "Backend Developer",
"company": "TechCorp",
"score": 85
}
],
"nudges": [
{
"application_id": "uuid",
"days_since_sent": 9,
"suggestion": "Consider sending a follow-up email asking about the status of your application."
}
],
"pending_approvals": 2
}
```
Nudge SQL: `state = 'sent' AND days_since(last_activity_at) > follow_up_after_days AND (follow_up_snoozed_until IS NULL OR follow_up_snoozed_until < today)`.
## Interview Prep
### `POST /applications/{id}/interview-prep`
Generate interview prep Q&A (strong LLM class), stored as an artifact of kind `other`.
Sets `interview_prep_artifact_id` on the application.
Response 200:
```json
{
"artifact_id": "uuid",
"content": "# Interview Prep\n\n## Q1: ..."
}
```
Mock mode returns deterministic 10-question Q&A markdown.
## Concierge / Demo Seed
### `POST /concierge/seed-demo`
Idempotent: seeds a demo profile ('Demo Demosson') with Swedish characters (a,a,o), 6 realistic Skane postings, varied application states (one scored high, one sent 8 days ago for nudge demo). Calling twice does not duplicate data.
Response 200:
```json
{
"profile": "Demo Demosson",
"postings": 6,
"applications": 6,
"sections": 4
}
```
## SMTP Transport (no new endpoint)
The outbox `POST /outbox/send` now selects transport at call time:
1. If `SMTP_HOST` is set: `SmtpTransport` (ssl on port 465, starttls otherwise, auth with `SMTP_USER`/`SMTP_PASS`, from `SMTP_FROM`).
2. Else: `ClipboardTransport` (marks sent + stores payload for UI copy/paste).
The approval gate checks (confirmed, unexpired, hash match) are UNCHANGED.
## Scheduler (no new endpoint)
APScheduler `AsyncIOScheduler` starts during app lifespan when `SCHEDULER_ENABLED=true` (default false).
Runs a daily job at 07:00 that fetches postings and batch-scores pending applications.

View file

@ -1,119 +0,0 @@
# User Guide
A practical guide for getting started with Jobhunt Platform. Written for first-time users who want to land a job quickly.
## Installation
### Prerequisites
- Docker and Docker Compose installed on your machine
- Node.js 22 (for the web frontend, only needed if running the dev server on the host)
### Quick start
1. Clone the repository and enter the project directory.
2. Copy the environment template: `cp .env.example .env`
3. Start the database and run the API tests:
```bash
docker compose run --rm api-test
```
4. Start the API server:
```bash
docker rm -f jobhunt-api 2>/dev/null
docker compose run -d --name jobhunt-api \
--entrypoint "uvicorn app.main:app --host 0.0.0.0 --port 8000" api-test
```
5. In a separate terminal, start the web frontend:
```bash
cd apps/web
npm install
VITE_API_BASE=http://localhost:8000/api npm run dev
```
6. Open the web app in your browser (typically http://localhost:5173).
You do NOT need any LLM API keys. The system runs in mock mode by default, which produces deterministic outputs. Add a key to `.env` only when you want real AI responses.
## First run: the onboarding wizard
When you open the app for the first time, the onboarding wizard appears at `/welcome`. It has four steps:
1. **Welcome** - a short introduction to the platform.
2. **Import CV** - upload a PDF, DOCX, or plain text file of your existing CV. The system extracts sections (experience, education, skills, projects) for you to review. You can edit them before confirming. If you do not have a CV file handy, you can skip this step and add sections manually later in the CV tab.
3. **Fetch Postings** - search for job postings from the Arbetsformedlingen connector. Enter a search query (for example "python developer") and optionally a region. New postings are added to your applications as "discovered". You can skip this step too.
4. **Done** - you are ready to go. Click "Go to Today" to see your dashboard.
## The Today page
The Today page (`/today`) is your daily dashboard. It shows three things:
- **Top Matches** - ranked job postings scored against your profile. Click a card to open the application detail page.
- **Follow-up Nudges** - applications you sent more than 7 days ago with no reply. Each nudge includes a suggested follow-up message. Click "Copy follow-up draft" to copy the text to your clipboard, then paste it into your email client.
- **Pending Approvals** - a count of outgoing actions (emails, submissions) waiting for your confirmation.
At the bottom of the Today page, the **Cost Summary** shows total tokens used (input and output) and the total cost if pricing is configured. This helps you track your LLM spending.
## CV editor
The CV tab lets you manage your profile and CV sections. You can:
- Edit your name, email, phone, location, headline, and summary.
- Add, edit, and delete sections (experience, education, skills, projects, other).
- Use AI Assist to get suggestions for improving bullet points.
- Render your CV to a PDF for download.
## Research
The Research tab has two ways to find job postings:
1. **Fetch from Arbetsformedlingen** - enter a search query and optional region to pull postings from the official Swedish public employment service. New postings are created automatically.
2. **Add by URL** - paste any job posting URL to add it manually.
The postings table includes a **Scam** column. If the scoring system detects red flags (such as unpaid trial periods or requests for personal financial data), a warning symbol appears with a tooltip listing the specific concerns.
Click "Score" on any posting to run the scoring rubric against your profile. The score appears as a green badge.
## Applications (kanban)
The Applications tab shows all your job applications as cards on a kanban board, organized by state:
- discovered, scored, approved, drafting, sent, interviewing, offer, closed, rejected, expired
You can drag cards between columns to change their state. The board enforces valid transitions (some moves are not allowed and will be rejected).
Two visual indicators appear on cards:
- **Red flag badge** (warning symbol) - the scoring system detected potential scam or fraud indicators. Hover over the symbol to see the specific red flags.
- **Nudge dot** (orange dot) - this application has a follow-up nudge, meaning you sent it more than 7 days ago without a reply. Visit the Today page for the suggested follow-up message.
## Application detail
Click any application card to open its detail page. Here you can:
- View the posting information, current state, and score.
- **Interview Prep** - click "Open Interview Prep" to generate likely interview questions with suggested answers based on your profile and the job posting. The content is saved as an artifact. You can edit the text and save a new version, or regenerate it.
- View all artifacts (cover letters, CVs, interview prep, etc.).
- Write and save a cover letter. The system provides an AI critique with severity-tagged suggestions.
- Request approval, confirm it, and send. The approval gate ensures nothing is sent without your explicit confirmation. The system verifies the artifact hash before sending.
## Costs
Every LLM call (scoring, extraction, critique, interview prep) is tracked. The Cost Summary on the Today page shows:
- Total tokens consumed (input and output)
- Total cost (if pricing is configured)
- Number of task runs
This transparency helps you make informed decisions about when to use AI features, especially if you are counting kronor.
## Tips
- Run without API keys first to explore the platform with mock data. Use `POST /concierge/seed-demo` (via curl or the API) to populate demo data instantly.
- Check the Today page daily for new nudges and digest items.
- Always review AI-generated content before sending. The system assists you, but you are the decision maker.
- Use the scam/red-flag indicators to avoid suspicious postings.

View file

@ -1,135 +0,0 @@
# connectors
Job source adapters that fetch postings from external sources and normalize
them to a common `JobPosting` shape matching the `job_posting` database table.
## Connectors
### Arbetsformedlingen (Platsbanken)
Uses the official Swedish Public Employment Service API. Free, no API key
required.
- Endpoint: `https://jobsearch.api.jobtechdev.se/search`
- Method: GET, header `Accept: application/json`
- Field mapping: `headline -> title`, `employer.name -> company`,
`webpage_url -> url`, `description.text -> description`, `id -> external_id`
- Region mapping: common names (e.g. "malmo", "skane") mapped to API filter values
- Polite User-Agent header
### Generic URL
Fetches a single job posting URL and extracts clean text with simple
readability extraction:
- Prefers `<main>` or `<article>` containers
- Strips `nav`, `footer`, `script`, `style`, `aside`, `header`, `noscript` tags
- Extracts title from `<h1>` (falls back to `<title>`)
- Attempts company name from `og:site_name` meta tag, then text heuristics
- **Does NOT follow Cloudflare challenge pages** -- raises `UnsupportedSite`
## Usage
### Arbetsformedlingen
```python
from connectors import ArbetsformedlingenConnector, SearchQuery
connector = ArbetsformedlingenConnector()
query = SearchQuery(query="python developer", region="malmo", limit=20)
raw_postings = connector.fetch(query)
# Normalize to JobPosting shape
from connectors import normalize
job_postings = [normalize(p) for p in raw_postings]
```
### Generic URL
```python
from connectors import GenericUrlConnector, SearchQuery
connector = GenericUrlConnector()
query = SearchQuery(query="https://example.com/jobs/123")
raw_postings = connector.fetch(query)
# Returns a single-element list
```
### Deduplication
```python
from connectors import dedupe
# Remove duplicates by (source, url) and (source, external_id)
unique_postings = dedupe(all_raw_postings)
```
### Normalization
```python
from connectors import normalize, RawPosting
raw = RawPosting(
source="manual",
external_id=None,
url="https://example.com/job",
company="Corp",
title="Developer",
description="A great job.",
)
job = normalize(raw)
# job.source, job.url, job.company, job.title, job.description, ...
```
## Error handling
```python
from connectors import UnsupportedSite
try:
connector.fetch(SearchQuery(query="https://cloudflare-protected.com/job/1"))
except UnsupportedSite as e:
print(f"Cannot scrape {e.url}: {e.reason}")
```
## Data models
### SearchQuery
| Field | Type | Description |
|----------|----------------|--------------------------------------|
| query | `str` | Search keywords or URL |
| region | `str \| None` | Optional region filter |
| limit | `int` | Max results (default 20) |
### RawPosting
| Field | Type | Description |
|--------------|------------------|--------------------------------------|
| source | `str` | Source identifier |
| external_id | `str \| None` | Source-native ID |
| url | `str` | Posting URL |
| company | `str` | Company name |
| title | `str` | Job title |
| location | `str \| None` | Job location |
| description | `str` | Job description text |
| raw | `dict` | Original source payload |
### JobPosting
Same fields as `RawPosting`. Matches the `job_posting` table shape:
`source`, `external_id`, `url`, `company`, `title`, `location`,
`description`, `raw`.
## Development
```bash
cd packages/connectors
uv venv
. .venv/bin/activate
uv pip install -e ".[dev]"
pytest -q
```
Tests use recorded JSON/HTML fixtures (no live network calls). Fixtures live
in `tests/fixtures/`.

View file

@ -1,28 +0,0 @@
[project]
name = "connectors"
version = "0.1.0"
description = "Job source connectors: Arbetsformedlingen API, generic URL extractor, dedupe, normalizer."
requires-python = ">=3.13"
dependencies = [
"httpx>=0.27",
"beautifulsoup4>=4.12",
"lxml>=5.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/connectors"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
asyncio_mode = "auto"

View file

@ -1,36 +0,0 @@
"""Connectors package.
Job source adapters that fetch postings from external sources and normalize
them to a common JobPosting shape.
Public API:
Connector: protocol every adapter implements.
SearchQuery: query parameters for connector.fetch.
RawPosting: raw posting returned by a connector before normalization.
JobPosting: normalized posting matching the job_posting table shape.
ArbetsformedlingenConnector: Swedish Platsbanken API adapter.
GenericUrlConnector: fetch a single posting URL with readability extraction.
UnsupportedSite: raised when a site cannot be scraped (Cloudflare, etc.).
dedupe: remove duplicate postings by (source, url) + external_id.
normalize: convert a RawPosting into a JobPosting.
"""
from connectors.models import JobPosting, RawPosting, SearchQuery
from connectors.protocol import Connector
from connectors.exceptions import UnsupportedSite
from connectors.arbetsformedlingen import ArbetsformedlingenConnector
from connectors.generic_url import GenericUrlConnector
from connectors.dedupe import dedupe
from connectors.normalizer import normalize
__all__ = [
"Connector",
"SearchQuery",
"RawPosting",
"JobPosting",
"ArbetsformedlingenConnector",
"GenericUrlConnector",
"UnsupportedSite",
"dedupe",
"normalize",
]

View file

@ -1,123 +0,0 @@
"""Arbetsformedlingen (Platsbanken) job search connector.
Uses the official Swedish Public Employment Service API:
https://jobsearch.api.jobtechdev.se/search
Free, no API key required. GET requests with Accept: application/json header.
"""
from __future__ import annotations
import httpx
from connectors.models import RawPosting, SearchQuery
API_URL = "https://jobsearch.api.jobtechdev.se/search"
USER_AGENT = "jobhunt-platform/0.1 (contact: dev@jobhunt.local)"
HEADERS = {
"Accept": "application/json",
"User-Agent": USER_AGENT,
}
# Region name -> API filter value mapping for Swedish lan.
# The AF API accepts region as a free-text filter that matches against
# the region field. We map common user-facing names to the API's format.
REGION_MAP: dict[str, str] = {
"skane": "Skane lan",
"skane lan": "Skane lan",
"malmo": "Skane lan",
"stockholm": "Stockholms lan",
"stockholms lan": "Stockholms lan",
"goteborg": "Vastra Gotalands lan",
"vastra gotaland": "Vastra Gotalands lan",
"vastra gotalands lan": "Vastra Gotalands lan",
}
class ArbetsformedlingenConnector:
"""Connector for the Arbetsformedlingen Platsbanken API.
The fetch method issues a GET request to the official API and maps
the response to RawPosting objects. Field mapping:
- headline -> title
-employer.name -> company
- webpage_url -> url
- description.text -> description
- id -> external_id
- workplace_address.city -> location (when available)
"""
def __init__(
self,
client: httpx.Client | None = None,
base_url: str = API_URL,
) -> None:
"""Initialize the connector.
Args:
client: Optional pre-configured httpx.Client (e.g. for testing
with a fixture-loaded transport). If None, a new client
is created on each fetch call.
base_url: API endpoint URL (override for testing).
"""
self._client = client
self._base_url = base_url
def fetch(self, query: SearchQuery) -> list[RawPosting]:
"""Fetch postings from the Arbetsformedlingen API.
Args:
query: Search parameters. ``query.query`` maps to the ``q``
parameter, ``query.region`` is mapped via REGION_MAP to
a region filter, ``query.limit`` maps to ``limit``.
Returns:
List of RawPosting objects with source='arbetsformedlingen'.
"""
params: dict[str, str | int] = {"q": query.query, "limit": query.limit}
if query.region:
mapped = REGION_MAP.get(query.region.lower().strip(), query.region)
params["region"] = mapped
if self._client is not None:
return self._do_fetch(self._client, params)
with httpx.Client(headers=HEADERS, timeout=30.0) as client:
return self._do_fetch(client, params)
def _do_fetch(
self,
client: httpx.Client,
params: dict[str, str | int],
) -> list[RawPosting]:
response = client.get(self._base_url, params=params)
response.raise_for_status()
data = response.json()
hits = data.get("hits", [])
return [self._map_hit(hit) for hit in hits]
@staticmethod
def _map_hit(hit: dict) -> RawPosting:
"""Map a single API hit to a RawPosting."""
description = ""
desc_obj = hit.get("description", {})
if isinstance(desc_obj, dict):
description = desc_obj.get("text", "") or ""
elif isinstance(desc_obj, str):
description = desc_obj
location = None
workplace = hit.get("workplace_address", {})
if isinstance(workplace, dict):
location = workplace.get("city") or workplace.get("municipality")
return RawPosting(
source="arbetsformedlingen",
external_id=str(hit.get("id", "")) or None,
url=hit.get("webpage_url", "") or "",
company=hit.get("employer", {}).get("name", "") or "",
title=hit.get("headline", "") or "",
location=location,
description=description,
raw=hit,
)

View file

@ -1,62 +0,0 @@
"""Deduplication helper for job postings.
Dedupes by (source, url) first, then by external_id when available.
Postings encountered first are kept; later duplicates are dropped.
"""
from __future__ import annotations
from connectors.models import JobPosting, RawPosting
def dedupe(postings: list[RawPosting]) -> list[RawPosting]:
"""Remove duplicate RawPosting entries.
Deduplication keys (in priority order):
1. (source, url) -- always checked.
2. (source, external_id) -- checked when external_id is not None.
Args:
postings: List of RawPosting objects, potentially with duplicates.
Returns:
Deduplicated list preserving first-occurrence order.
"""
seen_urls: set[tuple[str, str]] = set()
seen_ext_ids: set[tuple[str, str]] = set()
result: list[RawPosting] = []
for p in postings:
url_key = (p.source, p.url)
if url_key in seen_urls:
continue
if p.external_id is not None:
ext_key = (p.source, p.external_id)
if ext_key in seen_ext_ids:
continue
seen_ext_ids.add(ext_key)
seen_urls.add(url_key)
result.append(p)
return result
def dedupe_job_postings(postings: list[JobPosting]) -> list[JobPosting]:
"""Remove duplicate JobPosting entries (same logic as dedupe)."""
seen_urls: set[tuple[str, str]] = set()
seen_ext_ids: set[tuple[str, str]] = set()
result: list[JobPosting] = []
for p in postings:
url_key = (p.source, p.url)
if url_key in seen_urls:
continue
if p.external_id is not None:
ext_key = (p.source, p.external_id)
if ext_key in seen_ext_ids:
continue
seen_ext_ids.add(ext_key)
seen_urls.add(url_key)
result.append(p)
return result

View file

@ -1,18 +0,0 @@
"""Connector exceptions."""
from __future__ import annotations
class UnsupportedSite(Exception):
"""Raised when a site cannot be scraped.
Common causes:
- Cloudflare challenge / interstitial page detected.
- Page returns no parseable content.
- Site explicitly blocks automated access.
"""
def __init__(self, url: str, reason: str = "") -> None:
self.url = url
self.reason = reason
super().__init__(f"Unsupported site {url}: {reason}" if reason else f"Unsupported site {url}")

View file

@ -1,207 +0,0 @@
"""Generic URL connector: fetch a single posting URL and extract text.
Implements simple readability extraction:
- Prefers content inside <main> or <article> tags.
- Falls back to <body> if neither is present.
- Removes nav, footer, script, style, aside, header, noscript tags.
- Extracts <title> or first <h1> as the posting title.
- Attempts company name guess from structured data or meta tags.
- Detects Cloudflare challenge pages and raises UnsupportedSite.
"""
from __future__ import annotations
import re
import httpx
from bs4 import BeautifulSoup
from connectors.exceptions import UnsupportedSite
from connectors.models import RawPosting, SearchQuery
USER_AGENT = (
"Mozilla/5.0 (compatible; jobhunt-platform/0.1; "
"+https://github.com/jobhunt-platform)"
)
HEADERS = {
"User-Agent": USER_AGENT,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en,sv;q=0.9",
}
# Patterns that indicate a Cloudflare challenge / interstitial page.
CLOUDFLARE_INDICATORS = [
re.compile(r"cloudflare", re.IGNORECASE),
re.compile(r"cf-challenge", re.IGNORECASE),
re.compile(r"just a moment", re.IGNORECASE),
re.compile(r"cf-browser-verification", re.IGNORECASE),
re.compile(r"challenge-platform", re.IGNORECASE),
re.compile(r"ray id", re.IGNORECASE),
]
# Tags to strip during readability extraction.
STRIP_TAGS = frozenset({
"nav", "footer", "script", "style", "aside", "header",
"noscript", "iframe", "form", "svg",
})
class GenericUrlConnector:
"""Connector that fetches a single job posting from a URL.
The connector does simple readability extraction and does NOT follow
Cloudflare challenge pages. When a challenge is detected, it raises
UnsupportedSite.
"""
def __init__(self, client: httpx.Client | None = None) -> None:
"""Initialize the connector.
Args:
client: Optional pre-configured httpx.Client for testing.
"""
self._client = client
def fetch(self, query: SearchQuery) -> list[RawPosting]:
"""Fetch a posting from the URL in query.query.
Args:
query: SearchQuery whose ``query`` field is the URL to fetch.
Returns:
A single-element list containing the extracted RawPosting.
Raises:
UnsupportedSite: If the page is a Cloudflare challenge or
cannot be parsed.
"""
url = query.query
if self._client is not None:
return self._fetch_with_client(self._client, url)
with httpx.Client(headers=HEADERS, timeout=30.0, follow_redirects=True) as client:
return self._fetch_with_client(client, url)
def _fetch_with_client(
self, client: httpx.Client, url: str,
) -> list[RawPosting]:
response = client.get(url)
response.raise_for_status()
html = response.text
if self._is_cloudflare_challenge(html, response.headers):
raise UnsupportedSite(url, "Cloudflare challenge page detected")
return [self._extract(url, html, response)]
@staticmethod
def _is_cloudflare_challenge(html: str, headers: httpx.Headers) -> bool:
"""Detect Cloudflare challenge/interstitial pages."""
# Check response headers
server = headers.get("server", "")
cf_ray = headers.get("cf-ray", "")
if "cloudflare" in server.lower() and cf_ray:
# Could be a normal CF-proxied site. Check body for challenge markers.
body_lower = html[:5000].lower()
if any(p.search(body_lower) for p in CLOUDFLARE_INDICATORS):
if "just a moment" in body_lower or "challenge-platform" in body_lower:
return True
# If the body is very short and has cf markers, likely a challenge
if len(html.strip()) < 2000:
if any(p.search(html) for p in CLOUDFLARE_INDICATORS):
return True
# Also check for challenge pages without server header (some setups)
body_lower = html[:5000].lower()
if "cf-browser-verification" in body_lower:
return True
if "challenge-platform" in body_lower and "just a moment" in body_lower:
return True
return False
@staticmethod
def _extract(
url: str, html: str, response: httpx.Response,
) -> RawPosting:
"""Extract a RawPosting from HTML content."""
soup = BeautifulSoup(html, "lxml")
# Strip unwanted tags
for tag_name in STRIP_TAGS:
for tag in soup.find_all(tag_name):
tag.decompose()
# Find main content container
container = soup.find("main")
if container is None:
container = soup.find("article")
if container is None:
container = soup.find("body") or soup
# Extract title
title = ""
title_tag = soup.find("title")
h1_tag = container.find("h1") if container else None
if h1_tag and h1_tag.get_text(strip=True):
title = h1_tag.get_text(strip=True)
elif title_tag and title_tag.get_text(strip=True):
title = title_tag.get_text(strip=True)
# Remove common site suffixes from title tag
for sep in [" | ", " - ", " _ "]:
if sep in title:
title = title.split(sep)[0].strip()
break
# Extract company guess
company = ""
# Try meta tags
og_site = soup.find("meta", property="og:site_name")
if og_site and og_site.get("content"):
company = og_site["content"].strip()
if not company:
# Try JSON-LD
for script in soup.find_all("script", type="application/ld+json"):
# Scripts should already be decomposed, but check anyway
pass
if not company:
# Try looking for common company patterns in the text
text = container.get_text(" ", strip=True) if container else ""
# Look for "at CompanyName" or "Company: Name" patterns
at_match = re.search(r"\bat\s+([A-Z][A-Za-z0-9&\s]+?)(?:\.|,|$)", text)
if at_match:
company = at_match.group(1).strip()
if not company:
# Fall back to domain name
company = httpx.URL(url).host or ""
# Extract clean description text
description = container.get_text("\n", strip=True) if container else ""
# Collapse excessive whitespace
description = re.sub(r"\n{3,}", "\n\n", description)
description = description.strip()
# Extract location if present (simple heuristic)
location = None
loc_match = re.search(
r"(?:location|stad|city|ort)\s*[:\-]\s*(.+?)(?:\n|$)",
description,
re.IGNORECASE,
)
if loc_match:
location = loc_match.group(1).strip()
return RawPosting(
source="generic_url",
external_id=None,
url=url,
company=company,
title=title,
location=location,
description=description,
raw={
"status_code": response.status_code,
"content_length": len(html),
},
)

View file

@ -1,56 +0,0 @@
"""Data models for connectors."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class SearchQuery:
"""Query parameters passed to Connector.fetch.
Attributes:
query: Free-text search string (e.g. \"python developer\").
region: Optional region filter (e.g. \"Skane lan\").
limit: Maximum number of results to return.
"""
query: str = ""
region: str | None = None
limit: int = 20
@dataclass
class RawPosting:
"""Raw posting as returned by a connector before normalization.
The ``raw`` dict holds the original source payload for audit/debugging.
"""
source: str
external_id: str | None
url: str
company: str
title: str
location: str | None = None
description: str = ""
raw: dict[str, Any] = field(default_factory=dict)
@dataclass
class JobPosting:
"""Normalized posting matching the ``job_posting`` table shape.
Fields map 1:1 to columns in the database schema:
source, external_id, url, company, title, location, description, raw.
"""
source: str
external_id: str | None
url: str
company: str
title: str
location: str | None = None
description: str = ""
raw: dict[str, Any] = field(default_factory=dict)

View file

@ -1,31 +0,0 @@
"""Normalizer: convert RawPosting to JobPosting."""
from __future__ import annotations
from connectors.models import JobPosting, RawPosting
def normalize(posting: RawPosting) -> JobPosting:
"""Normalize a RawPosting into a JobPosting.
The current mapping is 1:1 because RawPosting already carries the
fields needed by the job_posting table. This function exists as a
single point of transformation so that future field remapping,
cleaning, or enrichment is centralized.
Args:
posting: A RawPosting from any connector.
Returns:
A JobPosting with fields matching the database schema.
"""
return JobPosting(
source=posting.source,
external_id=posting.external_id,
url=posting.url,
company=posting.company,
title=posting.title,
location=posting.location,
description=posting.description,
raw=posting.raw,
)

View file

@ -1,32 +0,0 @@
"""Connector protocol definition."""
from __future__ import annotations
from typing import Protocol, runtime_checkable
from connectors.models import RawPosting, SearchQuery
@runtime_checkable
class Connector(Protocol):
"""Protocol every job-source adapter implements.
Implementations fetch postings from an external source and return
a list of RawPosting objects. Normalization to JobPosting is done
separately via :func:`connectors.normalize`.
"""
def fetch(self, query: SearchQuery) -> list[RawPosting]:
"""Fetch raw postings matching the given query.
Args:
query: Search parameters (keywords, region, limit).
Returns:
List of RawPosting objects.
Raises:
UnsupportedSite: If the source site cannot be scraped.
httpx.HTTPError: On network errors (live calls only).
"""
...

View file

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

View file

@ -1,75 +0,0 @@
{
"hits": [
{
"id": "12345678901",
"headline": "Senior Python Developer",
"employer": {
"name": "Tech Innovators AB",
"workplace": "Skane"
},
"webpage_url": "https://arbetsformedlingen.se/platsbanken/annonser/12345678901",
"description": {
"text": "We are looking for a Senior Python Developer to join our team in Malmo. You will work on backend services, APIs, and data pipelines. Experience with FastAPI, PostgreSQL, and Docker is required."
},
"workplace_address": {
"city": "Malmo",
"municipality": "Malmo",
"country": "Sverige"
},
"occupation": {
"label": "Systemutvecklare",
"concept_id": "abc123"
},
"publication_date": "2026-07-28T10:00:00+02:00",
"application_deadline": "2026-08-15T23:59:59+02:00"
},
{
"id": "12345678902",
"headline": "Fullstack Utvecklare",
"employer": {
"name": "Nordic Solutions AB"
},
"webpage_url": "https://arbetsformedlingen.se/platsbanken/annonser/12345678902",
"description": {
"text": "Vi soker en fullstack utvecklare med erfarenhet av Vue.js och Python. Du kommer att arbeta med vara webbapplikationer i en agil miljo."
},
"workplace_address": {
"city": "Lund",
"municipality": "Lund",
"country": "Sverige"
},
"occupation": {
"label": "Webbutvecklare",
"concept_id": "def456"
},
"publication_date": "2026-07-29T08:00:00+02:00",
"application_deadline": "2026-08-20T23:59:59+02:00"
},
{
"id": "12345678903",
"headline": "DevOps Engineer",
"employer": {
"name": "Cloud Services Nordic"
},
"webpage_url": "https://arbetsformedlingen.se/platsbanken/annonser/12345678903",
"description": {
"text": "DevOps Engineer with experience in Kubernetes, Terraform, and CI/CD pipelines. You will manage our cloud infrastructure."
},
"workplace_address": {
"city": "Helsingborg",
"municipality": "Helsingborg",
"country": "Sverige"
},
"occupation": {
"label": "DevOps-ingenjor",
"concept_id": "ghi789"
},
"publication_date": "2026-07-30T09:00:00+02:00",
"application_deadline": "2026-08-30T23:59:59+02:00"
}
],
"total": {
"value": 3,
"relation": "eq"
}
}

View file

@ -1,28 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Just a moment...</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
body { font-family: sans-serif; }
.cf-spinner { display: block; margin: 50px auto; }
</style>
</head>
<body>
<div id="cf-challenge-running">
<div class="cf-spinner"></div>
<h1>Just a moment...</h1>
<p>Don't refresh this page.</p>
</div>
<script src="/cdn-cgi/challenge-platform/h/b/cv/result/0" type="text/javascript"></script>
<script>
(function(){
var a = document.getElementById('cf-challenge-running');
a.style.display = 'none';
// challenge-platform code
window._cf_chl_opt = {cfRay: 'abc123-xyz789'};
})();
</script>
</body>
</html>

View file

@ -1,64 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Senior Backend Engineer at Acme Corp | JobBoard</title>
<meta property="og:site_name" content="Acme Corp">
<meta name="description" content="We are hiring a Senior Backend Engineer">
<link rel="stylesheet" href="/styles.css">
<script src="/analytics.js"></script>
<nav class="navbar">
<a href="/">Home</a>
<a href="/jobs">Jobs</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
</head>
<body>
<header class="site-header">
<div class="logo">JobBoard</div>
<div class="user-menu">Login | Register</div>
</header>
<nav class="breadcrumb">
<a href="/">Home</a> > <a href="/jobs">Jobs</a> > Senior Backend Engineer
</nav>
<main>
<article class="job-posting">
<h1>Senior Backend Engineer</h1>
<div class="job-meta">
<span class="company">Acme Corp</span>
<span class="location">Malmo, Sweden</span>
</div>
<div class="job-description">
<p>We are looking for a Senior Backend Engineer to join our growing team in Malmo. You will be responsible for designing and building scalable backend services using Python, FastAPI, and PostgreSQL.</p>
<h2>Requirements</h2>
<ul>
<li>5+ years of Python experience</li>
<li>Strong knowledge of REST API design</li>
<li>Experience with PostgreSQL and database optimization</li>
<li>Familiarity with Docker and Kubernetes</li>
</ul>
<h2>What we offer</h2>
<ul>
<li>Competitive salary</li>
<li>Flexible working hours</li>
<li>Remote-first culture</li>
<li>Health insurance</li>
</ul>
<p>Location: Malmo, Sweden</p>
</div>
</article>
</main>
<footer class="site-footer">
<p>&copy; 2026 JobBoard. All rights reserved.</p>
<a href="/privacy">Privacy Policy</a>
<a href="/terms">Terms of Service</a>
<script>
(function() {
var analytics = window.analytics = window.analytics || [];
analytics.track('job_view', {id: '123'});
})();
</script>
</footer>
<script src="/main.js"></script>
</body>
</html>

View file

@ -1,191 +0,0 @@
"""Tests for the Arbetsformedlingen connector.
Uses a recorded JSON fixture (no live network). The fixture is loaded
into an httpx.MockTransport that returns the recorded response.
"""
from __future__ import annotations
import json
from pathlib import Path
import httpx
import pytest
from connectors.arbetsformedlingen import ArbetsformedlingenConnector
from connectors.models import SearchQuery
FIXTURES_DIR = Path(__file__).parent / "fixtures"
def _load_fixture(name: str) -> str:
"""Load a fixture file as raw text."""
return (FIXTURES_DIR / name).read_text(encoding="utf-8")
def _make_client(response_body: str, status_code: int = 200) -> httpx.Client:
"""Create an httpx.Client with a mock transport returning the fixture."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(status_code, content=response_body, headers={
"content-type": "application/json",
})
transport = httpx.MockTransport(handler)
return httpx.Client(transport=transport)
class TestArbetsformedlingenMapping:
"""Test field mapping from AF API response to RawPosting."""
def test_basic_mapping(self):
"""Test that API fields map correctly to RawPosting fields."""
fixture = _load_fixture("af_search_response.json")
client = _make_client(fixture)
connector = ArbetsformedlingenConnector(client=client)
results = connector.fetch(SearchQuery(query="python", limit=3))
assert len(results) == 3
# First hit
first = results[0]
assert first.source == "arbetsformedlingen"
assert first.external_id == "12345678901"
assert first.title == "Senior Python Developer"
assert first.company == "Tech Innovators AB"
assert first.url == "https://arbetsformedlingen.se/platsbanken/annonser/12345678901"
assert "FastAPI" in first.description
assert first.location == "Malmo"
# raw should contain the original hit
assert first.raw["id"] == "12345678901"
def test_swedish_text_preserved(self):
"""Test that Swedish characters (a, a, o) are preserved."""
fixture = _load_fixture("af_search_response.json")
client = _make_client(fixture)
connector = ArbetsformedlingenConnector(client=client)
results = connector.fetch(SearchQuery(query="utvecklare", limit=3))
second = results[1]
assert second.title == "Fullstack Utvecklare"
assert "Nordic Solutions AB" in second.company
assert "soker" in second.description
assert "miljo" in second.description
def test_missing_description_field(self):
"""Test graceful handling when description is missing."""
fixture_data = {
"hits": [
{
"id": "999",
"headline": "No Description Job",
"employer": {"name": "Empty Corp"},
"webpage_url": "https://example.com/job/999",
}
]
}
client = _make_client(json.dumps(fixture_data))
connector = ArbetsformedlingenConnector(client=client)
results = connector.fetch(SearchQuery(query="test", limit=1))
assert len(results) == 1
assert results[0].description == ""
assert results[0].title == "No Description Job"
assert results[0].company == "Empty Corp"
def test_missing_employer_name(self):
"""Test graceful handling when employer name is missing."""
fixture_data = {
"hits": [
{
"id": "888",
"headline": "Mystery Job",
"webpage_url": "https://example.com/job/888",
"description": {"text": "A job with no employer name."},
}
]
}
client = _make_client(json.dumps(fixture_data))
connector = ArbetsformedlingenConnector(client=client)
results = connector.fetch(SearchQuery(query="test", limit=1))
assert len(results) == 1
assert results[0].company == ""
def test_empty_hits(self):
"""Test handling of an empty results list."""
fixture_data = {"hits": [], "total": {"value": 0, "relation": "eq"}}
client = _make_client(json.dumps(fixture_data))
connector = ArbetsformedlingenConnector(client=client)
results = connector.fetch(SearchQuery(query="nonexistent", limit=10))
assert results == []
def test_region_param_passed(self):
"""Test that region parameter is mapped and sent to the API."""
fixture = _load_fixture("af_search_response.json")
captured_params: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured_params.update(dict(request.url.params))
return httpx.Response(200, content=fixture, headers={
"content-type": "application/json",
})
transport = httpx.MockTransport(handler)
client = httpx.Client(transport=transport)
connector = ArbetsformedlingenConnector(client=client)
connector.fetch(SearchQuery(query="dev", region="malmo"))
assert captured_params.get("q") == "dev"
assert captured_params.get("region") == "Skane lan"
def test_limit_param_passed(self):
"""Test that limit parameter is sent to the API."""
fixture = _load_fixture("af_search_response.json")
captured_params: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured_params.update(dict(request.url.params))
return httpx.Response(200, content=fixture, headers={
"content-type": "application/json",
})
transport = httpx.MockTransport(handler)
client = httpx.Client(transport=transport)
connector = ArbetsformedlingenConnector(client=client)
connector.fetch(SearchQuery(query="dev", limit=50))
assert captured_params.get("limit") == "50"
def test_workplace_address_municipality_fallback(self):
"""Test that municipality is used when city is missing."""
fixture_data = {
"hits": [
{
"id": "777",
"headline": "Rural Job",
"employer": {"name": "Rural Corp"},
"webpage_url": "https://example.com/job/777",
"description": {"text": "Work in the countryside."},
"workplace_address": {
"municipality": "Helsingborg",
"country": "Sverige",
},
}
]
}
client = _make_client(json.dumps(fixture_data))
connector = ArbetsformedlingenConnector(client=client)
results = connector.fetch(SearchQuery(query="test", limit=1))
assert results[0].location == "Helsingborg"
def test_raw_preserves_original_hit(self):
"""Test that the raw field preserves the full original hit data."""
fixture = _load_fixture("af_search_response.json")
client = _make_client(fixture)
connector = ArbetsformedlingenConnector(client=client)
results = connector.fetch(SearchQuery(query="dev", limit=3))
third = results[2]
assert third.raw["occupation"]["label"] == "DevOps-ingenjor"
assert third.raw["publication_date"] == "2026-07-30T09:00:00+02:00"

View file

@ -1,119 +0,0 @@
"""Tests for the deduplication helper."""
from __future__ import annotations
from connectors.dedupe import dedupe, dedupe_job_postings
from connectors.models import JobPosting, RawPosting
def _make_raw(
source: str = "arbetsformedlingen",
external_id: str | None = "1",
url: str = "https://example.com/1",
company: str = "Corp",
title: str = "Dev",
) -> RawPosting:
return RawPosting(
source=source,
external_id=external_id,
url=url,
company=company,
title=title,
)
class TestDedupeRawPostings:
"""Test dedupe function with RawPosting objects."""
def test_no_duplicates_unchanged(self):
"""Test that a list with no duplicates is unchanged."""
postings = [
_make_raw(url="https://a.com/1", external_id="1"),
_make_raw(url="https://a.com/2", external_id="2"),
_make_raw(url="https://a.com/3", external_id="3"),
]
result = dedupe(postings)
assert len(result) == 3
def test_dedupe_by_url(self):
"""Test deduplication by (source, url)."""
postings = [
_make_raw(url="https://a.com/1", external_id="1"),
_make_raw(url="https://a.com/1", external_id="2"), # same URL, diff ext_id
_make_raw(url="https://a.com/2", external_id="3"),
]
result = dedupe(postings)
assert len(result) == 2
assert result[0].external_id == "1" # first occurrence kept
assert result[1].external_id == "3"
def test_dedupe_by_external_id(self):
"""Test deduplication by (source, external_id) when URL differs."""
postings = [
_make_raw(url="https://a.com/1", external_id="100"),
_make_raw(url="https://a.com/2", external_id="100"), # same ext_id
_make_raw(url="https://a.com/3", external_id="200"),
]
result = dedupe(postings)
assert len(result) == 2
assert result[0].url == "https://a.com/1"
assert result[1].url == "https://a.com/3"
def test_different_sources_same_url_not_deduped(self):
"""Test that same URL from different sources are NOT deduped."""
postings = [
_make_raw(source="arbetsformedlingen", url="https://a.com/1", external_id="1"),
_make_raw(source="generic_url", url="https://a.com/1", external_id=None),
]
result = dedupe(postings)
assert len(result) == 2
def test_none_external_id_ignored(self):
"""Test that None external_id does not cause dedup by ext_id."""
postings = [
_make_raw(url="https://a.com/1", external_id=None),
_make_raw(url="https://a.com/2", external_id=None),
_make_raw(url="https://a.com/3", external_id=None),
]
result = dedupe(postings)
assert len(result) == 3
def test_empty_list(self):
"""Test that an empty list returns empty."""
assert dedupe([]) == []
def test_single_item(self):
"""Test that a single item list is unchanged."""
postings = [_make_raw()]
result = dedupe(postings)
assert len(result) == 1
def test_order_preserved(self):
"""Test that first-occurrence order is preserved."""
postings = [
_make_raw(url="https://a.com/3", external_id="3", title="Third"),
_make_raw(url="https://a.com/1", external_id="1", title="First"),
_make_raw(url="https://a.com/3", external_id="3", title="Third-Dup"),
_make_raw(url="https://a.com/2", external_id="2", title="Second"),
]
result = dedupe(postings)
assert len(result) == 3
assert result[0].title == "Third"
assert result[1].title == "First"
assert result[2].title == "Second"
class TestDedupeJobPostings:
"""Test dedupe_job_postings function with JobPosting objects."""
def test_dedupe_job_postings_by_url(self):
"""Test deduplication of JobPosting objects."""
postings = [
JobPosting(source="af", external_id="1", url="https://a.com/1",
company="C", title="T"),
JobPosting(source="af", external_id="2", url="https://a.com/1",
company="C", title="T"),
]
result = dedupe_job_postings(postings)
assert len(result) == 1
assert result[0].external_id == "1"

View file

@ -1,222 +0,0 @@
"""Tests for the GenericUrlConnector.
Uses recorded HTML fixtures (no live network). Fixtures are loaded into
an httpx.MockTransport.
"""
from __future__ import annotations
from pathlib import Path
import httpx
import pytest
from connectors.exceptions import UnsupportedSite
from connectors.generic_url import GenericUrlConnector
from connectors.models import SearchQuery
FIXTURES_DIR = Path(__file__).parent / "fixtures"
def _load_fixture(name: str) -> str:
"""Load a fixture file as raw text."""
return (FIXTURES_DIR / name).read_text(encoding="utf-8")
def _make_client(
response_body: str,
status_code: int = 200,
headers: dict | None = None,
) -> httpx.Client:
"""Create an httpx.Client with a mock transport returning the fixture."""
default_headers = {"content-type": "text/html; charset=utf-8"}
if headers:
default_headers.update(headers)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(status_code, content=response_body, headers=default_headers)
transport = httpx.MockTransport(handler)
return httpx.Client(transport=transport)
class TestGenericUrlExtraction:
"""Test readability extraction from a messy HTML page."""
def test_extracts_title_from_h1(self):
"""Test that the h1 tag is used as the title."""
html = _load_fixture("generic_job_page.html")
client = _make_client(html)
connector = GenericUrlConnector(client=client)
results = connector.fetch(SearchQuery(query="https://example.com/job/123"))
assert len(results) == 1
posting = results[0]
assert posting.title == "Senior Backend Engineer"
def test_extracts_company_from_meta(self):
"""Test that og:site_name meta tag is used as company name."""
html = _load_fixture("generic_job_page.html")
client = _make_client(html)
connector = GenericUrlConnector(client=client)
results = connector.fetch(SearchQuery(query="https://example.com/job/123"))
assert results[0].company == "Acme Corp"
def test_strips_nav_and_footer(self):
"""Test that nav and footer content is removed from description."""
html = _load_fixture("generic_job_page.html")
client = _make_client(html)
connector = GenericUrlConnector(client=client)
results = connector.fetch(SearchQuery(query="https://example.com/job/123"))
description = results[0].description
# Nav links should not appear
assert "Home" not in description or "Requirements" in description
assert "Privacy Policy" not in description
assert "Terms of Service" not in description
assert "Login | Register" not in description
# Footer copyright should not appear
assert "2026 JobBoard" not in description
# Job content should be present
assert "Python" in description
assert "FastAPI" in description
assert "PostgreSQL" in description
def test_strips_script_and_style(self):
"""Test that script and style tags are removed."""
html = _load_fixture("generic_job_page.html")
client = _make_client(html)
connector = GenericUrlConnector(client=client)
results = connector.fetch(SearchQuery(query="https://example.com/job/123"))
description = results[0].description
assert "analytics" not in description.lower()
assert "var " not in description
assert "function()" not in description
def test_source_is_generic_url(self):
"""Test that source is set to 'generic_url'."""
html = _load_fixture("generic_job_page.html")
client = _make_client(html)
connector = GenericUrlConnector(client=client)
results = connector.fetch(SearchQuery(query="https://example.com/job/123"))
assert results[0].source == "generic_url"
def test_url_preserved(self):
"""Test that the original URL is preserved in the result."""
html = _load_fixture("generic_job_page.html")
client = _make_client(html)
connector = GenericUrlConnector(client=client)
url = "https://example.com/job/123"
results = connector.fetch(SearchQuery(query=url))
assert results[0].url == url
def test_external_id_is_none(self):
"""Test that external_id is None for generic URL postings."""
html = _load_fixture("generic_job_page.html")
client = _make_client(html)
connector = GenericUrlConnector(client=client)
results = connector.fetch(SearchQuery(query="https://example.com/job/123"))
assert results[0].external_id is None
def test_description_is_clean(self):
"""Test that description text is clean and readable."""
html = _load_fixture("generic_job_page.html")
client = _make_client(html)
connector = GenericUrlConnector(client=client)
results = connector.fetch(SearchQuery(query="https://example.com/job/123"))
description = results[0].description
# Should not have excessive blank lines
assert "\n\n\n" not in description
# Should start with the job title or job content
assert len(description) > 50
def test_location_extracted_from_text(self):
"""Test that location is extracted from description text."""
html = _load_fixture("generic_job_page.html")
client = _make_client(html)
connector = GenericUrlConnector(client=client)
results = connector.fetch(SearchQuery(query="https://example.com/job/123"))
# The fixture contains "Location: Malmo, Sweden"
assert results[0].location is not None
assert "Malmo" in results[0].location
def test_raw_contains_metadata(self):
"""Test that the raw field contains response metadata."""
html = _load_fixture("generic_job_page.html")
client = _make_client(html)
connector = GenericUrlConnector(client=client)
results = connector.fetch(SearchQuery(query="https://example.com/job/123"))
raw = results[0].raw
assert raw["status_code"] == 200
assert raw["content_length"] > 0
class TestCloudflareDetection:
"""Test Cloudflare challenge page detection."""
def test_raises_on_cloudflare_challenge(self):
"""Test that UnsupportedSite is raised for Cloudflare challenge pages."""
html = _load_fixture("cloudflare_challenge.html")
client = _make_client(
html,
headers={
"server": "cloudflare",
"cf-ray": "abc123-xyz789",
},
)
connector = GenericUrlConnector(client=client)
with pytest.raises(UnsupportedSite) as exc_info:
connector.fetch(SearchQuery(query="https://protected-site.com/job/1"))
assert "protected-site.com" in str(exc_info.value)
assert "Cloudflare" in str(exc_info.value)
def test_raises_on_challenge_without_server_header(self):
"""Test detection of challenge pages without cloudflare server header."""
# A page with cf-browser-verification but no CF server header
html = '<html><body><div id="cf-browser-verification">Loading...</div></body></html>'
client = _make_client(html)
connector = GenericUrlConnector(client=client)
with pytest.raises(UnsupportedSite):
connector.fetch(SearchQuery(query="https://sneaky-site.com/job/1"))
def test_normal_cf_proxied_site_is_ok(self):
"""Test that a normal site behind CF (with content) is NOT flagged."""
html = _load_fixture("generic_job_page.html")
client = _make_client(
html,
headers={
"server": "cloudflare",
"cf-ray": "abc123-xyz789",
},
)
connector = GenericUrlConnector(client=client)
# Should NOT raise -- this is a normal page that happens to be behind CF
results = connector.fetch(SearchQuery(query="https://cf-proxied-site.com/job/1"))
assert len(results) == 1
assert results[0].title == "Senior Backend Engineer"
def test_unsupported_site_exception_has_url(self):
"""Test that UnsupportedSite exception carries the URL."""
html = _load_fixture("cloudflare_challenge.html")
client = _make_client(
html,
headers={"server": "cloudflare", "cf-ray": "xyz"},
)
connector = GenericUrlConnector(client=client)
with pytest.raises(UnsupportedSite) as exc_info:
connector.fetch(SearchQuery(query="https://example.org/protected"))
assert exc_info.value.url == "https://example.org/protected"

View file

@ -1,87 +0,0 @@
"""Tests for the normalizer and connector protocol."""
from __future__ import annotations
from connectors import (
Connector,
GenericUrlConnector,
JobPosting,
RawPosting,
normalize,
)
from connectors.arbetsformedlingen import ArbetsformedlingenConnector
class TestNormalize:
"""Test the normalize function."""
def test_basic_normalization(self):
"""Test that normalize maps all fields correctly."""
raw = RawPosting(
source="arbetsformedlingen",
external_id="12345",
url="https://example.com/job/12345",
company="Test Corp",
title="Test Job",
location="Malmo",
description="A test job description.",
raw={"id": "12345", "extra": "data"},
)
job = normalize(raw)
assert job.source == "arbetsformedlingen"
assert job.external_id == "12345"
assert job.url == "https://example.com/job/12345"
assert job.company == "Test Corp"
assert job.title == "Test Job"
assert job.location == "Malmo"
assert job.description == "A test job description."
assert job.raw == {"id": "12345", "extra": "data"}
def test_none_fields_preserved(self):
"""Test that None fields are preserved."""
raw = RawPosting(
source="generic_url",
external_id=None,
url="https://example.com/page",
company="",
title="Unknown",
)
job = normalize(raw)
assert job.external_id is None
assert job.location is None
assert job.description == ""
assert job.raw == {}
def test_swedish_chars_preserved(self):
"""Test that Swedish characters are preserved through normalization."""
raw = RawPosting(
source="arbetsformedlingen",
external_id="1",
url="https://example.com/1",
company="Nordic Solutions AB",
title="Utvecklare",
location="Lund",
description="Vi soker en utvecklare.",
)
job = normalize(raw)
assert "Solutions" in job.company
assert "Utvecklare" in job.title
assert "soker" in job.description
assert "Lund" in (job.location or "")
class TestConnectorProtocol:
"""Test that connectors satisfy the Connector protocol."""
def test_af_connector_is_connector(self):
"""Test that ArbetsformedlingenConnector satisfies the Connector protocol."""
connector = ArbetsformedlingenConnector()
assert isinstance(connector, Connector)
def test_generic_url_connector_is_connector(self):
"""Test that GenericUrlConnector satisfies the Connector protocol."""
connector = GenericUrlConnector()
assert isinstance(connector, Connector)