1042 lines
No EOL
35 KiB
Python
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) |