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

1703 lines
No EOL
61 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,
ClusterOut,
ClusterPostingOut,
CoverLetterRequest,
CoverLetterResponse,
CvImportConfirmRequest,
CvImportConfirmResponse,
CvImportRequest,
CvImportResponse,
CvSectionCreate,
CvSectionOut,
CvSectionUpdate,
DeadlineItem,
DigestItem,
EmailSuggestionOut,
ErrorOut,
InterviewPrepResponse,
JobPostingCreate,
JobPostingOut,
NudgeItem,
NotificationLogOut,
OutboxOut,
OutboxSendRequest,
PostingsFetchRequest,
PostingsFetchResponse,
ProfileOut,
ProfileUpdate,
ScoreResponse,
SeedDemoResponse,
TailorCvResponse,
TaskRunOut,
TodayResponse,
TransitionRequest,
)
from app.statemachine import TransitionContext, check_transition, InvalidTransition
from app.transport import get_transport, reset_transport
# v1.1: matching package for cluster + coverage
try:
from matching import cluster as _cluster_postings, coverage as _keyword_coverage
HAS_MATCHING = True
except ImportError:
HAS_MATCHING = False
# v1.1: artifacts package for PDF rendering
try:
from artifacts import render_cv_pdf as _render_cv_pdf, hash_bytes as _hash_bytes, next_version as _next_version
HAS_ARTIFACTS = True
except ImportError:
HAS_ARTIFACTS = False
# Defensive import for connectors (may not exist yet)
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 ---
def _assign_cluster_id(new_posting_id: str) -> str | None:
"""Run cluster() over the new posting + all existing postings.
Reconcile for stability: if the new posting joins an existing cluster,
reuse the existing cluster_id. Otherwise assign a fresh cluster_id from
the clustering output.
Returns the cluster_id assigned to the new posting (or None if matching
package is unavailable or no postings exist).
"""
if not HAS_MATCHING:
return None
all_postings = repo_app.list_postings()
if not all_postings:
return None
# Build posting dicts for cluster() -- needs id, employer, title, description
posting_dicts = [
{
"id": p["id"],
"employer": p.get("company", ""),
"title": p.get("title", ""),
"description": p.get("description", ""),
}
for p in all_postings
]
clusters = _cluster_postings(posting_dicts)
# Find which cluster the new posting ended up in
for cluster_id, posting_ids in clusters.items():
if new_posting_id in posting_ids:
# Check if any existing posting in this cluster already has a cluster_id
existing_cluster_ids: set[str] = set()
for pid in posting_ids:
if pid == new_posting_id:
continue
for p in all_postings:
if p["id"] == pid and p.get("cluster_id"):
existing_cluster_ids.add(p["cluster_id"])
if existing_cluster_ids:
# Prefer existing cluster_id for stability
stable_id = sorted(existing_cluster_ids)[0]
else:
# New cluster: use the computed cluster_id
stable_id = cluster_id
# Assign cluster_id to all postings in the cluster that don't have one yet
for pid in posting_ids:
# Check if this posting already has the cluster_id
p_row = None
for p in all_postings:
if p["id"] == pid:
p_row = p
break
if p_row and p_row.get("cluster_id") != stable_id:
repo_app.update_posting_cluster_id(pid, stable_id)
return stable_id
return None
@app.post("/api/postings", response_model=ApplicationOut, status_code=201)
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},
)
# Cluster assignment
_assign_cluster_id(posting["id"])
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.get("/api/clusters", response_model=list[ClusterOut])
def get_clusters() -> Any:
"""Return job postings grouped by cluster_id, sorted by best score desc.
Each cluster includes postings with their id, title, company, source, url, score.
Clusters with no cluster_id are excluded.
"""
if not HAS_MATCHING:
return []
postings = repo_app.list_postings()
# Build a map of cluster_id -> list of postings
cluster_map: dict[str, list[dict[str, Any]]] = {}
for p in postings:
cid = p.get("cluster_id")
if not cid:
continue
cluster_map.setdefault(cid, []).append(p)
# For each cluster, look up application scores
apps = repo_app.list_applications()
# Build posting_id -> score map
score_map: dict[str, float | None] = {}
for a in apps:
score_map[a["job_posting_id"]] = a.get("score")
result: list[dict[str, Any]] = []
for cid, cluster_postings in cluster_map.items():
cluster_postings_out: list[dict[str, Any]] = []
best_score: float | None = None
for p in cluster_postings:
score = score_map.get(p["id"])
cluster_postings_out.append({
"id": p["id"],
"title": p.get("title", ""),
"company": p.get("company", ""),
"source": p.get("source", ""),
"url": p.get("url", ""),
"score": score,
})
if score is not None:
if best_score is None or score > best_score:
best_score = score
result.append({
"cluster_id": cid,
"postings": cluster_postings_out,
"_best_score": best_score,
})
# Sort by best score descending
result.sort(key=lambda c: (-(c.pop("_best_score") or -1)))
return result
@app.post("/api/postings/{posting_id}/score", response_model=ScoreResponse)
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)
# Deadline extraction (CHEAP task)
deadline_result = llm.run_task(
"deadline_extract",
f"Extract apply-by deadline from: {posting['title']} at {posting['company']}. Description: {posting.get('description', '')}",
telemetry_sink=lambda info: repo_app.create_task_run({
**info,
"application_id": app_for_posting["id"],
}),
)
apply_by = deadline_result.get("apply_by")
if apply_by is not None:
# Parse and persist
try:
from datetime import date as date_cls
if isinstance(apply_by, str):
parsed_date = date_cls.fromisoformat(apply_by)
elif isinstance(apply_by, date_cls):
parsed_date = apply_by
else:
parsed_date = None
if parsed_date is not None:
repo_app.update_posting_apply_by(posting["id"], parsed_date)
except (ValueError, TypeError):
pass # Skip invalid date
return {"score": score, "rationale": rationale}
# --- 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"])
# Cluster assignment
_assign_cluster_id(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)
# Deadline extraction (CHEAP task)
deadline_result = llm.run_task(
"deadline_extract",
f"Extract apply-by deadline from: {posting['title']} at {posting['company']}. Description: {posting.get('description', '')}",
telemetry_sink=lambda info, aid=app_id: repo_app.create_task_run({
**info,
"application_id": aid,
}),
)
apply_by = deadline_result.get("apply_by")
if apply_by is not None:
try:
from datetime import date as date_cls
if isinstance(apply_by, str):
parsed_date = date_cls.fromisoformat(apply_by)
elif isinstance(apply_by, date_cls):
parsed_date = apply_by
else:
parsed_date = None
if parsed_date is not None:
repo_app.update_posting_apply_by(posting["id"], parsed_date)
except (ValueError, TypeError):
pass # Skip invalid date
results.append({
"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()
# Deadlines: apply_by within next 7 days
deadline_rows = repo_app.get_upcoming_deadlines(days=7)
deadlines = [
DeadlineItem(
application_id=d["application_id"],
title=d["title"],
company=d["company"],
apply_by=d.get("apply_by"),
)
for d in deadline_rows
]
return {
"digest": digest,
"nudges": nudges,
"pending_approvals": pending,
"deadlines": deadlines,
}
# --- 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 ---
def _seed_demo_counts() -> dict[str, Any]:
"""Compute counts for the seed-demo response from current DB state."""
postings = repo_app.list_postings()
apps = repo_app.list_applications()
sections = repo_profile.list_sections()
# Clusters: postings with a non-null cluster_id
cluster_postings = [p for p in postings if p.get("cluster_id")]
cluster_ids = set(p["cluster_id"] for p in cluster_postings if p["cluster_id"])
# Deadlines: postings with apply_by in the next 7 days
from datetime import date, timedelta
today = datetime.now(timezone.utc).date()
deadline_window = today + timedelta(days=7)
deadline_count = 0
for p in postings:
apply_by_raw = p.get("apply_by")
if not apply_by_raw:
continue
# _normalize_posting returns apply_by as ISO string
if isinstance(apply_by_raw, str):
apply_by_date = date.fromisoformat(apply_by_raw)
else:
apply_by_date = apply_by_raw
if today <= apply_by_date <= deadline_window:
deadline_count += 1
# Pending email suggestions
from app.imap_watch import list_pending_suggestions
suggestions = list_pending_suggestions()
# Notification log rows
from app.notify import list_notification_log
notifications = list_notification_log(limit=50)
# Task runs
task_runs = repo_app.list_task_runs()
# CV artifacts (kind=cv, origin=ai_drafted)
cv_count = 0
for a in apps:
artifacts = repo_app.list_artifacts(a["id"])
cv_count += sum(1 for art in artifacts if art["kind"] == "cv" and art["origin"] == "ai_drafted")
return {
"profile": "Demo Demosson",
"postings": len(postings),
"applications": len(apps),
"sections": len(sections),
"clusters": len(cluster_ids),
"deadlines": deadline_count,
"suggestions": len(suggestions),
"notifications": len(notifications),
"task_runs": len(task_runs),
"cv_artifacts": cv_count,
}
@app.post("/api/concierge/seed-demo", response_model=SeedDemoResponse)
def seed_demo() -> Any:
"""Idempotent demo seed: profile + postings + varied application states + artifacts + telemetry.
Populates a complete demo-worthy dataset:
- 6 standalone postings with varied application states
- 3-posting agency cluster (same role reposted by 3 fictional agencies)
- 2 postings with apply_by deadlines in the next 4 days
- 1 posting with red_flags in its application score_rationale
- Applications: 1 sent (backdated 8 days for nudge), 1 interviewing, 1 approved with cover letter
- 2 pending email_suggestion rows
- 3 notification_log rows (daily_digest delivered, email_suggestion delivered, webhook failed)
- 6 task_run telemetry rows across providers/models
- 1 cv-tailor artifact (kind cv, origin ai_drafted) on the interviewing application
"""
from datetime import date, timedelta
# Check if demo profile already exists
existing = fetch_one(
"SELECT * FROM profile WHERE full_name = 'Demo Demosson'"
)
if existing:
return _seed_demo_counts()
# -- 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)
# -- Track application IDs for later linking --
sent_app_id: str | None = None
interviewing_app_id: str | None = None
approved_app_id: str | None = None
# -- Create 6 standalone demo postings with varied states --
demo_postings = [
{"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"])
if dp["state"] == "sent":
sent_app_id = app_row["id"]
if dp["state"] == "approved":
approved_app_id = app_row["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":
if dp["state"] in ("approved", "rejected"):
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":
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")
execute(
"UPDATE application SET state = 'sent', state_changed_at = now(), last_activity_at = now() WHERE id = %s",
(app_row["id"],),
)
# -- 3-posting AGENCY CLUSTER (same real role, 3 fictional agencies, near-identical title+description) --
cluster_title = "Senior Backend Developer"
cluster_desc = (
"We are looking for a senior backend developer with strong Python skills. "
"You will build and maintain scalable REST APIs, work with PostgreSQL, "
"and collaborate in an agile team. Experience with Docker and cloud "
"deployment is a plus."
)
agency_companies = ["Aderanto AB", "Wise IT", "TechTalent Nord"]
agency_posting_ids: list[str] = []
for i, agency in enumerate(agency_companies):
posting = repo_app.create_job_posting(
source="arbetsformedlingen",
url=f"https://example.com/af/agency/{i+1}",
company=agency,
title=cluster_title,
location="Stockholm",
description=cluster_desc,
raw={},
)
agency_posting_ids.append(posting["id"])
# Create applications for these too (scored for digest demo)
app_row = repo_app.create_application(posting["id"])
repo_app.update_application_score(app_row["id"], 75 + i, {"factors": {}})
# Assign cluster IDs to all postings (the matching package will cluster the 3 agency postings together)
for pid in agency_posting_ids:
_assign_cluster_id(pid)
# -- 2 postings with apply_by in the next 4 days (deadlines strip) --
today = datetime.now(timezone.utc).date()
for j in range(2):
deadline = today + timedelta(days=2 + j)
posting = repo_app.create_job_posting(
source="arbetsformedlingen",
url=f"https://example.com/af/deadline/{j+1}",
company=f"Deadline Corp {j+1}",
title=f"Urgent Developer Role {j+1}",
location="Goteborg",
description="Urgent hire for a developer with deadline approaching.",
raw={},
)
repo_app.update_posting_apply_by(posting["id"], deadline)
app_row = repo_app.create_application(posting["id"])
repo_app.update_application_score(app_row["id"], 60 + j * 5, {"factors": {}})
# -- 1 posting with red_flags on its application score_rationale --
red_flag_posting = repo_app.create_job_posting(
source="arbetsformedlingen",
url="https://example.com/af/redflag/1",
company="ShadyCorp AB",
title="Junior Developer",
location="Remote",
description="Entry level developer position with trial period.",
raw={},
)
red_flag_app = repo_app.create_application(red_flag_posting["id"])
red_flag_rationale = {
"factors": {"salary": "below market rate", "trial_period": "3 months unpaid"},
"red_flags": ["requests unpaid trial work", "salary significantly below market rate"],
"summary": "Multiple red flags detected during scoring.",
}
repo_app.update_application_score(red_flag_app["id"], 25, red_flag_rationale)
# -- Application in 'interviewing' state --
interviewing_posting = repo_app.create_job_posting(
source="arbetsformedlingen",
url="https://example.com/af/interview/1",
company="Festina Digital AB",
title="Full Stack Developer",
location="Malmo",
description="Full stack developer with React, Python, and cloud experience.",
raw={},
)
interviewing_app = repo_app.create_application(interviewing_posting["id"])
repo_app.update_application_score(interviewing_app["id"], 90, {"factors": {}})
repo_app.update_application_state(interviewing_app["id"], "scored")
repo_app.update_application_state(interviewing_app["id"], "approved")
repo_app.update_application_state(interviewing_app["id"], "drafting")
execute(
"UPDATE application SET state = 'sent', state_changed_at = now(), last_activity_at = now() WHERE id = %s",
(interviewing_app["id"],),
)
repo_app.update_application_state(interviewing_app["id"], "interviewing")
interviewing_app_id = interviewing_app["id"]
# -- Cover letter artifact on the 'approved' application (Swedish text, origin user_drafted) --
storage_dir = os.path.join(tempfile.gettempdir(), "jobhunt_artifacts")
os.makedirs(storage_dir, exist_ok=True)
if approved_app_id:
cover_text = (
"Basta rekryterare,\n\n"
"Jag ansoker om tjansen som Backend Developer hos er. "
"Med min erfarenhet av Python, FastAPI och PostgreSQL "
"tror jag att jag skulle vara en bra tillgang for ert team.\n\n"
"Jag ser fram emot att diskutera rollen vidare.\n\n"
"Vanliga halsningar,\n"
"Demo Demosson"
)
cover_bytes = cover_text.encode("utf-8")
cl_filename = f"cover_letter_{approved_app_id[:8]}.txt"
cl_storage_path = os.path.join(storage_dir, cl_filename)
with open(cl_storage_path, "wb") as f:
f.write(cover_bytes)
repo_app.create_artifact(
application_id=approved_app_id,
kind="cover_letter",
filename=cl_filename,
content_bytes=cover_bytes,
storage_path=cl_storage_path,
origin="user_drafted",
)
# -- CV tailor artifact on the interviewing application (kind cv, origin ai_drafted, real PDF bytes + hash) --
if interviewing_app_id and HAS_ARTIFACTS:
_render_fn = _render_cv_pdf # type: ignore[possibly-unbound]
cv_profile = {
"full_name": profile.get("full_name", ""),
"headline": profile.get("headline", ""),
"email": profile.get("email", ""),
"phone": profile.get("phone", ""),
"location": profile.get("location", ""),
"summary": "Tailored CV for full stack developer role at Festina Digital.",
}
cv_sections = [
{
"kind": "experience",
"title": "Backend Developer",
"org": "TechSkane AB",
"bullets": ["Built REST APIs", "Improved performance by 30%"],
},
{
"kind": "skills",
"title": "Technical Skills",
"bullets": ["Python", "PostgreSQL", "Docker", "FastAPI", "React"],
},
]
pdf_bytes = _render_fn(cv_profile, cv_sections)
cv_filename = f"cv_tailored_{interviewing_app_id[:8]}.pdf"
cv_storage_path = os.path.join(storage_dir, cv_filename)
with open(cv_storage_path, "wb") as f:
f.write(pdf_bytes)
repo_app.create_artifact(
application_id=interviewing_app_id,
kind="cv",
filename=cv_filename,
content_bytes=pdf_bytes,
storage_path=cv_storage_path,
origin="ai_drafted",
)
# -- Backdate the 'sent' application to 8 days ago for nudge demo --
backdated = datetime.now(timezone.utc) - timedelta(days=8)
if sent_app_id:
execute(
"UPDATE application SET last_activity_at = %s WHERE id = %s",
(backdated, sent_app_id),
)
# -- 2 pending email_suggestion rows --
from app.imap_watch import create_email_suggestion
suggestion_time = datetime.now(timezone.utc) - timedelta(hours=3)
if sent_app_id:
create_email_suggestion(
application_id=sent_app_id,
mailbox_from="recruiter@festina-demo.se",
subject="Inbjudan till intervju",
snippet="Hej, vi skulle vilja boka in en intervju med dig...",
classification="interview_invite",
state_proposal="interviewing",
received_at=suggestion_time,
)
if interviewing_app_id:
create_email_suggestion(
application_id=interviewing_app_id,
mailbox_from="hr@festina-demo.se",
subject="Fraga om din erfarenhet",
snippet="Vi har nagra fragor om din bakgrund inom Python...",
classification="question",
state_proposal=None,
received_at=datetime.now(timezone.utc) - timedelta(hours=1),
)
# -- 3 notification_log rows --
# 1) daily_digest delivered (via LogChannel-style insert)
execute(
"""
INSERT INTO notification_log (channel, kind, payload, delivered, error)
VALUES ('log', 'daily_digest', %s, true, NULL)
""",
(json.dumps({"text": "Your daily digest is ready", "items": 5}),),
)
# 2) email_suggestion delivered
execute(
"""
INSERT INTO notification_log (channel, kind, payload, delivered, error)
VALUES ('log', 'email_suggestion', %s, true, NULL)
""",
(json.dumps({"text": "New email suggestion received", "suggestion_id": "demo"}),),
)
# 3) webhook failed with error text
execute(
"""
INSERT INTO notification_log (channel, kind, payload, delivered, error)
VALUES ('webhook', 'daily_digest', %s, false, %s)
""",
(
json.dumps({"text": "Daily digest delivery attempt", "items": 5}),
"HTTP 503: Service Unavailable (webhook endpoint down)",
),
)
# -- 6 task_run telemetry rows across providers/models --
demo_task_runs = [
{"task": "score_application", "model": "gpt-4o-mini", "provider": "openai", "input_tokens": 1200, "output_tokens": 80, "cost_usd": 0.0012, "duration_ms": 1500},
{"task": "score_application", "model": "gpt-4o", "provider": "openai", "input_tokens": 1500, "output_tokens": 120, "cost_usd": 0.0180, "duration_ms": 2200},
{"task": "cv_tailor", "model": "claude-sonnet-4-20250514", "provider": "anthropic", "input_tokens": 3000, "output_tokens": 800, "cost_usd": 0.0450, "duration_ms": 4500},
{"task": "cl_critique", "model": "gpt-4o-mini", "provider": "openai", "input_tokens": 900, "output_tokens": 200, "cost_usd": 0.0009, "duration_ms": 1800},
{"task": "interview_prep", "model": "claude-3-5-sonnet-20241022", "provider": "anthropic", "input_tokens": 2200, "output_tokens": 600, "cost_usd": 0.0330, "duration_ms": 3100},
{"task": "score_application", "model": "gemini-1.5-flash", "provider": "google", "input_tokens": 1000, "output_tokens": 90, "cost_usd": 0.0005, "duration_ms": 900},
]
link_app = interviewing_app_id or sent_app_id
for idx, tr in enumerate(demo_task_runs):
repo_app.create_task_run({
**tr,
"application_id": link_app if idx % 2 == 0 else None,
})
# -- Return counts --
return _seed_demo_counts()
# --- v1.1: Email Suggestions ---
@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)
# --- v1.1: Tailor CV ---
@app.post("/api/applications/{app_id}/tailor-cv", response_model=TailorCvResponse)
def tailor_cv(app_id: str) -> Any:
"""Tailor the user's CV for the posting linked to this application.
Uses cv_tailor (STRONG task) to generate a tailored CV variant.
Validates the output: every tailored bullet must map to a source bullet
id from the input (hallucination guard). Rejects + 502 on unmapped bullets.
Stores artifact kind='cv' origin='ai_drafted' + renders PDF via packages/artifacts.
Returns {artifact_id, change_log, keyword_coverage}.
"""
app_row = repo_app.get_application(app_id)
if app_row is None:
raise HTTPException(status_code=404, detail="Application not found")
posting = repo_app.get_job_posting(app_row["job_posting_id"])
if posting is None:
raise HTTPException(status_code=404, detail="Posting not found")
# Build profile + sections from the profile repo
profile = repo_profile.get_or_create_profile()
if profile is None:
raise HTTPException(status_code=500, detail="No profile found")
sections = repo_profile.list_sections()
# Build the prompt with profile + sections + posting description
import json as _json
prompt_parts = [
f"Profile: {_json.dumps({k: profile.get(k) for k in ('full_name', 'headline', 'email', 'phone', 'location', 'summary')}, default=str)}",
f"Sections: {_json.dumps(sections, default=str)}",
f"Posting: title={posting.get('title', '')}, company={posting.get('company', '')}, description={posting.get('description', '')}",
]
prompt = "\n".join(prompt_parts)
# Run cv_tailor (STRONG task)
result = llm.run_task(
"cv_tailor",
prompt,
telemetry_sink=lambda info: repo_app.create_task_run({
**info,
"application_id": app_id,
}),
)
tailored_cv = result.get("tailored_cv", {})
change_log = result.get("change_log", [])
# Hallucination guard: every tailored bullet must map to a source bullet id.
# We collect all source bullet texts from sections and check that each
# bullet in the tailored output is a rephrase/reorder of an existing one.
source_bullets: set[str] = set()
for s in sections:
for b in s.get("bullets", []):
source_bullets.add(str(b).lower().strip())
# Check bullets in tailored experience sections
unmapped_bullets: list[str] = []
for exp in tailored_cv.get("experience", []):
for bullet in exp.get("bullets", []):
bullet_lower = str(bullet).lower().strip()
# Check if this bullet is a rephrase of any source bullet.
# We use a simple containment check: at least 50% of the words
# in the tailored bullet should appear in some source bullet.
bullet_words = set(bullet_lower.split())
if not bullet_words:
continue
found = False
for sb in source_bullets:
sb_words = set(sb.split())
if not sb_words:
continue
overlap = len(bullet_words & sb_words) / len(bullet_words)
if overlap >= 0.5:
found = True
break
if not found and source_bullets:
unmapped_bullets.append(bullet)
if unmapped_bullets:
raise HTTPException(
status_code=502,
detail={
"code": "hallucination_guard",
"message": f"Tailored CV contains bullets that do not map to source bullets: {unmapped_bullets}",
},
)
# Build sections for PDF rendering
pdf_sections: list[dict[str, Any]] = []
# Add experience sections from tailored CV
for exp in tailored_cv.get("experience", []):
pdf_sections.append({
"kind": "experience",
"title": exp.get("role", ""),
"org": exp.get("company", ""),
"bullets": exp.get("bullets", []),
})
# Add skills section
if tailored_cv.get("skills"):
pdf_sections.append({
"kind": "skills",
"title": "Technical Skills",
"bullets": tailored_cv.get("skills", []),
})
# If no experience sections from tailor, fall back to original sections
if not pdf_sections:
pdf_sections = sections
# Render PDF
if not HAS_ARTIFACTS:
raise HTTPException(
status_code=500,
detail={"code": "missing_dependency", "message": "artifacts package not installed"},
)
pdf_profile = {
"full_name": profile.get("full_name", ""),
"headline": profile.get("headline", ""),
"email": profile.get("email", ""),
"phone": profile.get("phone", ""),
"location": profile.get("location", ""),
"summary": tailored_cv.get("summary", profile.get("summary", "")),
}
pdf_bytes = _render_cv_pdf(pdf_profile, pdf_sections)
# Store artifact
storage_dir = os.path.join(tempfile.gettempdir(), "jobhunt_artifacts")
os.makedirs(storage_dir, exist_ok=True)
filename = f"cv_tailored_{app_id[:8]}.pdf"
storage_path = os.path.join(storage_dir, filename)
with open(storage_path, "wb") as f:
f.write(pdf_bytes)
# Get existing CV artifacts for version numbering
existing_artifacts = repo_app.list_artifacts(app_id)
cv_versions = [a["version"] for a in existing_artifacts if a["kind"] == "cv"]
version = _next_version(cv_versions)
artifact = repo_app.create_artifact(
application_id=app_id,
kind="cv",
filename=filename,
content_bytes=pdf_bytes,
storage_path=storage_path,
origin="ai_drafted",
version=version,
)
# Compute keyword coverage
# Build CV text from the tailored sections
cv_text_parts = [
pdf_profile.get("summary", ""),
]
for s in pdf_sections:
cv_text_parts.append(s.get("title", ""))
cv_text_parts.append(s.get("org", ""))
cv_text_parts.extend(s.get("bullets", []))
cv_text = " ".join(str(p) for p in cv_text_parts)
posting_description = posting.get("description", "") or f"{posting.get('title', '')} at {posting.get('company', '')}"
keyword_coverage = _keyword_coverage(cv_text, posting_description) if HAS_MATCHING else {"matched": [], "missing": [], "ratio": 0.0}
return {
"artifact_id": artifact["id"],
"change_log": change_log,
"keyword_coverage": keyword_coverage,
}