Merge branch 'feat/W2-api-v1'

This commit is contained in:
hermes 2026-07-30 18:29:18 +00:00
commit 9ee5449acb
15 changed files with 1885 additions and 22 deletions

View file

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

View file

@ -124,7 +124,7 @@ def update_application_state(
row = execute( row = execute(
""" """
UPDATE application UPDATE application
SET state = %s, state_changed_at = now() SET state = %s, state_changed_at = now(), last_activity_at = now()
WHERE id = %s WHERE id = %s
RETURNING * RETURNING *
""", """,
@ -170,6 +170,10 @@ def _normalize_application(row: dict[str, Any]) -> dict[str, Any]:
"company": row.get("company"), "company": row.get("company"),
"title": row.get("title"), "title": row.get("title"),
"location": row.get("location"), "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,
} }
@ -387,3 +391,95 @@ def _normalize_task_run(row: dict[str, Any]) -> dict[str, Any]:
"application_id": str(row["application_id"]) if row.get("application_id") else None, "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, "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,6 +62,66 @@ MOCK_OUTPUTS: dict[str, dict[str, Any]] = {
"match": 0.72, "match": 0.72,
"factors": {"skills": 0.8, "location": 0.6, "experience": 0.75}, "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,7 +1,8 @@
"""FastAPI application main entry point.""" """FastAPI application -- main entry point."""
from __future__ import annotations from __future__ import annotations
import base64
import hashlib import hashlib
import json import json
import os import os
@ -23,26 +24,48 @@ from app.schemas import (
ApprovalOut, ApprovalOut,
ArtifactCreate, ArtifactCreate,
ArtifactOut, ArtifactOut,
BatchScoreRequest,
BatchScoreResponse,
BatchScoreResult,
CoverLetterRequest, CoverLetterRequest,
CoverLetterResponse, CoverLetterResponse,
CvImportConfirmRequest,
CvImportConfirmResponse,
CvImportRequest,
CvImportResponse,
CvSectionCreate, CvSectionCreate,
CvSectionOut, CvSectionOut,
CvSectionUpdate, CvSectionUpdate,
DigestItem,
ErrorOut, ErrorOut,
InterviewPrepResponse,
JobPostingCreate, JobPostingCreate,
JobPostingOut, JobPostingOut,
NudgeItem,
OutboxOut, OutboxOut,
OutboxSendRequest, OutboxSendRequest,
PostingsFetchRequest,
PostingsFetchResponse,
ProfileOut, ProfileOut,
ProfileUpdate, ProfileUpdate,
ScoreResponse, ScoreResponse,
SeedDemoResponse,
TaskRunOut, TaskRunOut,
TodayResponse,
TransitionRequest, TransitionRequest,
) )
from app.statemachine import TransitionContext, check_transition, InvalidTransition from app.statemachine import TransitionContext, check_transition, InvalidTransition
from app.transport import get_transport from app.transport import get_transport, reset_transport
app = FastAPI(title="Jobhunt API", version="0.1.0") # 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.on_event("startup") @app.on_event("startup")
@ -50,10 +73,15 @@ def _startup() -> None:
"""Ensure pool is initialized and migrations are applied.""" """Ensure pool is initialized and migrations are applied."""
get_pool() get_pool()
migrate_mod.run_migrations() migrate_mod.run_migrations()
# Start scheduler if enabled
from app import scheduler
scheduler.start_scheduler()
@app.on_event("shutdown") @app.on_event("shutdown")
def _shutdown() -> None: def _shutdown() -> None:
from app import scheduler
scheduler.stop_scheduler()
close_pool() close_pool()
@ -448,3 +476,460 @@ def send_outbox(body: OutboxSendRequest) -> Any:
@app.get("/api/telemetry/tasks", response_model=list[TaskRunOut]) @app.get("/api/telemetry/tasks", response_model=list[TaskRunOut])
def get_telemetry() -> Any: def get_telemetry() -> Any:
return repo_app.list_task_runs() 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,
}

92
apps/api/app/scheduler.py Normal file
View file

@ -0,0 +1,92 @@
"""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,6 +130,11 @@ class ApplicationOut(BaseModel):
company: str | None = None company: str | None = None
title: str | None = None title: str | None = None
location: 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): class TransitionRequest(BaseModel):
@ -218,3 +223,100 @@ class TaskRunOut(BaseModel):
class ErrorOut(BaseModel): class ErrorOut(BaseModel):
error: dict[str, str] 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

View file

@ -1,13 +1,22 @@
"""Send transport interface. """Send transport interface.
Pluggable Transport interface for the outbox send operation. Pluggable Transport interface for the outbox send operation.
Default EchoTransport records the payload and marks as sent. Selection order:
No real email in POC. 1. SMTP configured (SMTP_HOST set) -> SmtpTransport
2. Else -> ClipboardTransport (marks sent + stores payload for UI copy)
The approval gate checks (confirmed, unexpired, hash match) are UNCHANGED
and enforced in the API layer before transport.send() is called.
""" """
from __future__ import annotations from __future__ import annotations
import os
import smtplib
import ssl
from datetime import datetime, timezone from datetime import datetime, timezone
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Any, Protocol from typing import Any, Protocol
@ -35,17 +44,115 @@ 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 # Singleton instance
_default_transport: Transport | None = None _default_transport: Transport | None = None
def get_transport() -> Transport: def get_transport() -> Transport:
"""Get the transport. Selects SMTP if configured, else Clipboard."""
global _default_transport global _default_transport
if _default_transport is None: if _default_transport is None:
_default_transport = EchoTransport() if is_smtp_configured():
_default_transport = SmtpTransport()
else:
_default_transport = ClipboardTransport()
return _default_transport return _default_transport
def set_transport(t: Transport) -> None: def set_transport(t: Transport) -> None:
"""Override the transport (for testing)."""
global _default_transport global _default_transport
_default_transport = t _default_transport = t
def reset_transport() -> None:
"""Reset to default (for testing)."""
global _default_transport
_default_transport = None

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,398 @@
"""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

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

199
docs/api-contract-v2.md Normal file
View file

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