jobhunt-platform/apps/api/app/main.py

1042 lines
No EOL
35 KiB
Python

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