Merge branch 'feat/W2-api-v1'
This commit is contained in:
commit
9ee5449acb
15 changed files with 1885 additions and 22 deletions
|
|
@ -4,12 +4,22 @@ FROM python:3.13-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
COPY pyproject.toml ./
|
# Copy packages from build context root
|
||||||
COPY app ./app
|
COPY packages ./packages
|
||||||
COPY schema.sql ./schema.sql
|
|
||||||
COPY migrations ./migrations
|
|
||||||
COPY tests ./tests
|
|
||||||
|
|
||||||
RUN pip install --no-cache-dir -e ".[dev]"
|
# Copy api app
|
||||||
|
COPY apps/api/pyproject.toml ./apps/api/
|
||||||
|
COPY apps/api/app ./apps/api/app
|
||||||
|
COPY apps/api/schema.sql ./apps/api/schema.sql
|
||||||
|
COPY apps/api/migrations ./apps/api/migrations
|
||||||
|
COPY apps/api/tests ./apps/api/tests
|
||||||
|
|
||||||
CMD ["pytest", "-q"]
|
WORKDIR /app/apps/api
|
||||||
|
|
||||||
|
# Install the api package with dev deps, plus the local packages
|
||||||
|
RUN pip install --no-cache-dir -e ".[dev]" \
|
||||||
|
&& pip install --no-cache-dir -e /app/packages/llm-gateway \
|
||||||
|
&& pip install --no-cache-dir -e /app/packages/artifacts \
|
||||||
|
&& pip install --no-cache-dir pypdf python-docx apscheduler
|
||||||
|
|
||||||
|
CMD ["pytest", "-q"]
|
||||||
|
|
@ -124,7 +124,7 @@ def update_application_state(
|
||||||
row = execute(
|
row = execute(
|
||||||
"""
|
"""
|
||||||
UPDATE application
|
UPDATE application
|
||||||
SET state = %s, state_changed_at = now()
|
SET state = %s, state_changed_at = now(), last_activity_at = now()
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
|
|
@ -170,6 +170,10 @@ def _normalize_application(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
"company": row.get("company"),
|
"company": row.get("company"),
|
||||||
"title": row.get("title"),
|
"title": row.get("title"),
|
||||||
"location": row.get("location"),
|
"location": row.get("location"),
|
||||||
|
"follow_up_after_days": row.get("follow_up_after_days", 7),
|
||||||
|
"last_activity_at": row["last_activity_at"].isoformat() if row.get("last_activity_at") is not None else None,
|
||||||
|
"follow_up_snoozed_until": row.get("follow_up_snoozed_until").isoformat() if row.get("follow_up_snoozed_until") else None,
|
||||||
|
"interview_prep_artifact_id": str(row["interview_prep_artifact_id"]) if row.get("interview_prep_artifact_id") else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -386,4 +390,96 @@ def _normalize_task_run(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
"duration_ms": row["duration_ms"],
|
"duration_ms": row["duration_ms"],
|
||||||
"application_id": str(row["application_id"]) if row.get("application_id") else None,
|
"application_id": str(row["application_id"]) if row.get("application_id") else None,
|
||||||
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
|
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Follow-up nudges ---
|
||||||
|
|
||||||
|
def get_nudge_applications() -> list[dict[str, Any]]:
|
||||||
|
"""Return applications in 'sent' state past follow_up_after_days and not snoozed."""
|
||||||
|
rows = fetch_all(
|
||||||
|
"""
|
||||||
|
SELECT a.*, j.company, j.title, j.location,
|
||||||
|
(now() - a.last_activity_at) AS elapsed
|
||||||
|
FROM application a
|
||||||
|
JOIN job_posting j ON a.job_posting_id = j.id
|
||||||
|
WHERE a.state = 'sent'
|
||||||
|
AND EXTRACT(day FROM now() - a.last_activity_at) > a.follow_up_after_days
|
||||||
|
AND (a.follow_up_snoozed_until IS NULL OR a.follow_up_snoozed_until < CURRENT_DATE)
|
||||||
|
ORDER BY a.last_activity_at ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
app = _normalize_application(row)
|
||||||
|
elapsed = row.get("elapsed")
|
||||||
|
days = None
|
||||||
|
if elapsed is not None:
|
||||||
|
days = abs(int(elapsed.days))
|
||||||
|
app["days_since_sent"] = days
|
||||||
|
results.append(app)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def snooze_follow_up(app_id: str, until_date: Any) -> dict[str, Any] | None:
|
||||||
|
"""Snooze follow-up nudge for an application until a given date."""
|
||||||
|
row = execute(
|
||||||
|
"UPDATE application SET follow_up_snoozed_until = %s WHERE id = %s RETURNING *",
|
||||||
|
(until_date, app_id),
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return _normalize_application(row)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Interview prep ---
|
||||||
|
|
||||||
|
def set_interview_prep_artifact(app_id: str, artifact_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Link an interview prep artifact to the application."""
|
||||||
|
row = execute(
|
||||||
|
"""
|
||||||
|
UPDATE application
|
||||||
|
SET interview_prep_artifact_id = %s, last_activity_at = now()
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(artifact_id, app_id),
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return _normalize_application(row)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Pending approvals count ---
|
||||||
|
|
||||||
|
def count_pending_approvals() -> int:
|
||||||
|
"""Count approvals that are not yet confirmed and not expired."""
|
||||||
|
row = fetch_one(
|
||||||
|
"""
|
||||||
|
SELECT count(*) AS cnt
|
||||||
|
FROM approval
|
||||||
|
WHERE confirmed_by_user = false
|
||||||
|
AND expires_at > now()
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return 0
|
||||||
|
return int(row["cnt"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Digest (scored applications, top by score) ---
|
||||||
|
|
||||||
|
def get_digest(limit: int = 20) -> list[dict[str, Any]]:
|
||||||
|
"""Return scored applications ordered by score descending, with posting info."""
|
||||||
|
rows = fetch_all(
|
||||||
|
"""
|
||||||
|
SELECT a.*, j.company, j.title, j.location
|
||||||
|
FROM application a
|
||||||
|
JOIN job_posting j ON a.job_posting_id = j.id
|
||||||
|
WHERE a.score IS NOT NULL
|
||||||
|
ORDER BY a.score DESC NULLS LAST
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
return [_normalize_application(r) for r in rows]
|
||||||
|
|
@ -62,6 +62,66 @@ MOCK_OUTPUTS: dict[str, dict[str, Any]] = {
|
||||||
"match": 0.72,
|
"match": 0.72,
|
||||||
"factors": {"skills": 0.8, "location": 0.6, "experience": 0.75},
|
"factors": {"skills": 0.8, "location": 0.6, "experience": 0.75},
|
||||||
},
|
},
|
||||||
|
"red_flags": [],
|
||||||
|
},
|
||||||
|
"cv_extract": {
|
||||||
|
"drafts": [
|
||||||
|
{
|
||||||
|
"kind": "experience",
|
||||||
|
"title": "Software Engineer",
|
||||||
|
"org": "Extracted Company",
|
||||||
|
"location": "Malmo",
|
||||||
|
"start_date": "2020-01",
|
||||||
|
"end_date": None,
|
||||||
|
"bullets": ["Developed web applications", "Led team of 3"],
|
||||||
|
"tags": ["python", "javascript"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "education",
|
||||||
|
"title": "MSc Computer Science",
|
||||||
|
"org": "Lund University",
|
||||||
|
"location": "Lund",
|
||||||
|
"start_date": "2016-09",
|
||||||
|
"end_date": "2018-06",
|
||||||
|
"bullets": ["Specialized in distributed systems"],
|
||||||
|
"tags": ["algorithms", "distributed systems"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"interview_prep": {
|
||||||
|
"content": (
|
||||||
|
"# Interview Prep\n\n"
|
||||||
|
"## Q1: Tell us about yourself\n"
|
||||||
|
"**Suggested angle:** Highlight your experience with Python and FastAPI, "
|
||||||
|
"and your ability to deliver features on time.\n\n"
|
||||||
|
"## Q2: Why are you interested in this role?\n"
|
||||||
|
"**Suggested angle:** Reference the specific technologies mentioned in the "
|
||||||
|
"posting and your experience with similar stacks.\n\n"
|
||||||
|
"## Q3: Describe a challenging project\n"
|
||||||
|
"**Suggested angle:** Use the STAR method. Reference your experience building "
|
||||||
|
"distributed systems at Lund University.\n\n"
|
||||||
|
"## Q4: How do you handle tight deadlines?\n"
|
||||||
|
"**Suggested angle:** Mention your track record of delivering 2 weeks ahead "
|
||||||
|
"of schedule and your automation-first approach.\n\n"
|
||||||
|
"## Q5: What are your salary expectations?\n"
|
||||||
|
"**Suggested angle:** Research market rates for the Skane region. "
|
||||||
|
"Be prepared to give a range.\n\n"
|
||||||
|
"## Q6: Tell us about a time you failed\n"
|
||||||
|
"**Suggested angle:** Pick something real but not catastrophic. Show what "
|
||||||
|
"you learned and how you changed your approach.\n\n"
|
||||||
|
"## Q7: How do you stay current with technology?\n"
|
||||||
|
"**Suggested angle:** Mention your tags: python, javascript, distributed "
|
||||||
|
"systems. Talk about hands-on side projects.\n\n"
|
||||||
|
"## Q8: Describe your ideal work environment\n"
|
||||||
|
"**Suggested angle:** Be honest but flexible. Mention collaboration and "
|
||||||
|
"autonomy.\n\n"
|
||||||
|
"## Q9: What questions do you have for us?\n"
|
||||||
|
"**Suggested angle:** Ask about team structure, current projects, and "
|
||||||
|
"growth opportunities.\n\n"
|
||||||
|
"## Q10: Why should we hire you?\n"
|
||||||
|
"**Suggested angle:** Summarize your top 3 qualifications matching the "
|
||||||
|
"posting requirements. Be specific."
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"""FastAPI application — main entry point."""
|
"""FastAPI application -- main entry point."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
@ -23,26 +24,48 @@ from app.schemas import (
|
||||||
ApprovalOut,
|
ApprovalOut,
|
||||||
ArtifactCreate,
|
ArtifactCreate,
|
||||||
ArtifactOut,
|
ArtifactOut,
|
||||||
|
BatchScoreRequest,
|
||||||
|
BatchScoreResponse,
|
||||||
|
BatchScoreResult,
|
||||||
CoverLetterRequest,
|
CoverLetterRequest,
|
||||||
CoverLetterResponse,
|
CoverLetterResponse,
|
||||||
|
CvImportConfirmRequest,
|
||||||
|
CvImportConfirmResponse,
|
||||||
|
CvImportRequest,
|
||||||
|
CvImportResponse,
|
||||||
CvSectionCreate,
|
CvSectionCreate,
|
||||||
CvSectionOut,
|
CvSectionOut,
|
||||||
CvSectionUpdate,
|
CvSectionUpdate,
|
||||||
|
DigestItem,
|
||||||
ErrorOut,
|
ErrorOut,
|
||||||
|
InterviewPrepResponse,
|
||||||
JobPostingCreate,
|
JobPostingCreate,
|
||||||
JobPostingOut,
|
JobPostingOut,
|
||||||
|
NudgeItem,
|
||||||
OutboxOut,
|
OutboxOut,
|
||||||
OutboxSendRequest,
|
OutboxSendRequest,
|
||||||
|
PostingsFetchRequest,
|
||||||
|
PostingsFetchResponse,
|
||||||
ProfileOut,
|
ProfileOut,
|
||||||
ProfileUpdate,
|
ProfileUpdate,
|
||||||
ScoreResponse,
|
ScoreResponse,
|
||||||
|
SeedDemoResponse,
|
||||||
TaskRunOut,
|
TaskRunOut,
|
||||||
|
TodayResponse,
|
||||||
TransitionRequest,
|
TransitionRequest,
|
||||||
)
|
)
|
||||||
from app.statemachine import TransitionContext, check_transition, InvalidTransition
|
from app.statemachine import TransitionContext, check_transition, InvalidTransition
|
||||||
from app.transport import get_transport
|
from app.transport import get_transport, reset_transport
|
||||||
|
|
||||||
app = FastAPI(title="Jobhunt API", version="0.1.0")
|
# Defensive import for connectors (may not exist yet)
|
||||||
|
try:
|
||||||
|
from packages.connectors.arbetsformedlingen import ArbetsformedlingenConnector # type: ignore
|
||||||
|
from packages.connectors.base import SearchQuery # type: ignore
|
||||||
|
CONNECTORS_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
CONNECTORS_AVAILABLE = False
|
||||||
|
|
||||||
|
app = FastAPI(title="Jobhunt API", version="0.2.0")
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
|
|
@ -50,10 +73,15 @@ def _startup() -> None:
|
||||||
"""Ensure pool is initialized and migrations are applied."""
|
"""Ensure pool is initialized and migrations are applied."""
|
||||||
get_pool()
|
get_pool()
|
||||||
migrate_mod.run_migrations()
|
migrate_mod.run_migrations()
|
||||||
|
# Start scheduler if enabled
|
||||||
|
from app import scheduler
|
||||||
|
scheduler.start_scheduler()
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
def _shutdown() -> None:
|
def _shutdown() -> None:
|
||||||
|
from app import scheduler
|
||||||
|
scheduler.stop_scheduler()
|
||||||
close_pool()
|
close_pool()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -447,4 +475,461 @@ def send_outbox(body: OutboxSendRequest) -> Any:
|
||||||
|
|
||||||
@app.get("/api/telemetry/tasks", response_model=list[TaskRunOut])
|
@app.get("/api/telemetry/tasks", response_model=list[TaskRunOut])
|
||||||
def get_telemetry() -> Any:
|
def get_telemetry() -> Any:
|
||||||
return repo_app.list_task_runs()
|
return repo_app.list_task_runs()
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1: CV Import ---
|
||||||
|
|
||||||
|
def _extract_text_from_pdf(raw_bytes: bytes) -> str:
|
||||||
|
"""Extract text from PDF bytes using pypdf."""
|
||||||
|
try:
|
||||||
|
from pypdf import PdfReader
|
||||||
|
except ImportError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail={"code": "missing_dependency", "message": "pypdf not installed"},
|
||||||
|
)
|
||||||
|
import io
|
||||||
|
reader = PdfReader(io.BytesIO(raw_bytes))
|
||||||
|
parts: list[str] = []
|
||||||
|
for page in reader.pages:
|
||||||
|
text = page.extract_text()
|
||||||
|
if text:
|
||||||
|
parts.append(text)
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_text_from_docx(raw_bytes: bytes) -> str:
|
||||||
|
"""Extract text from DOCX bytes using python-docx."""
|
||||||
|
try:
|
||||||
|
import docx
|
||||||
|
except ImportError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail={"code": "missing_dependency", "message": "python-docx not installed"},
|
||||||
|
)
|
||||||
|
import io
|
||||||
|
doc = docx.Document(io.BytesIO(raw_bytes))
|
||||||
|
parts: list[str] = []
|
||||||
|
for para in doc.paragraphs:
|
||||||
|
if para.text.strip():
|
||||||
|
parts.append(para.text)
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cv/import", response_model=CvImportResponse)
|
||||||
|
def cv_import(body: CvImportRequest) -> Any:
|
||||||
|
"""Extract text from uploaded file and generate draft CV sections via LLM.
|
||||||
|
|
||||||
|
Does NOT write to cv_section -- returns drafts for user review.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
raw_bytes = base64.b64decode(body.content_base64)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={"code": "invalid_base64", "message": "content_base64 is not valid base64"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not raw_bytes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={"code": "empty_file", "message": "File is empty or contains no data"},
|
||||||
|
)
|
||||||
|
|
||||||
|
filename = body.filename.lower()
|
||||||
|
text = ""
|
||||||
|
if filename.endswith(".pdf"):
|
||||||
|
text = _extract_text_from_pdf(raw_bytes)
|
||||||
|
elif filename.endswith(".docx"):
|
||||||
|
text = _extract_text_from_docx(raw_bytes)
|
||||||
|
elif filename.endswith(".txt") or filename.endswith(".md"):
|
||||||
|
text = raw_bytes.decode("utf-8", errors="replace")
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"code": "unsupported_format",
|
||||||
|
"message": f"Unsupported file format: {body.filename}. Supported: .pdf, .docx, .txt, .md",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={"code": "empty_file", "message": "File contains no extractable text"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run LLM extraction (cheap class)
|
||||||
|
result = llm.run_task(
|
||||||
|
"cv_extract",
|
||||||
|
text,
|
||||||
|
telemetry_sink=lambda info: repo_app.create_task_run({
|
||||||
|
**info,
|
||||||
|
"application_id": None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
drafts = result.get("drafts", [])
|
||||||
|
return {"drafts": drafts}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cv/import/confirm", response_model=CvImportConfirmResponse, status_code=201)
|
||||||
|
def cv_import_confirm(body: CvImportConfirmRequest) -> Any:
|
||||||
|
"""Create cv_section rows from drafts returned by /cv/import."""
|
||||||
|
profile = repo_profile.get_or_create_profile()
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create profile")
|
||||||
|
|
||||||
|
created_sections: list[dict[str, Any]] = []
|
||||||
|
for draft in body.drafts:
|
||||||
|
section = repo_profile.create_section(profile["id"], {
|
||||||
|
"kind": draft.kind,
|
||||||
|
"title": draft.title,
|
||||||
|
"org": draft.org,
|
||||||
|
"location": draft.location,
|
||||||
|
"start_date": draft.start_date,
|
||||||
|
"end_date": draft.end_date,
|
||||||
|
"bullets": draft.bullets,
|
||||||
|
"tags": draft.tags,
|
||||||
|
"sort_order": 0,
|
||||||
|
})
|
||||||
|
created_sections.append(section)
|
||||||
|
|
||||||
|
return {"created": len(created_sections), "sections": created_sections}
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1: Postings Fetch ---
|
||||||
|
|
||||||
|
def _get_connectors_enabled() -> bool:
|
||||||
|
return os.environ.get("CONNECTORS_ENABLED", "true").lower() in (
|
||||||
|
"true", "1", "yes",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_arbetsformedlingen_connector():
|
||||||
|
"""Get the AF connector instance, or None if not available."""
|
||||||
|
if not CONNECTORS_AVAILABLE:
|
||||||
|
return None
|
||||||
|
return ArbetsformedlingenConnector()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_and_create_postings(query: str, region: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Internal: fetch postings via connector and create applications for new ones."""
|
||||||
|
connector = _get_arbetsformedlingen_connector()
|
||||||
|
if connector is None:
|
||||||
|
return {"new": 0, "dupes": 0}
|
||||||
|
|
||||||
|
# Build search query -- use a dict if SearchQuery is not available
|
||||||
|
if CONNECTORS_AVAILABLE and "SearchQuery" in globals():
|
||||||
|
sq = SearchQuery(query=query, region=region) if region else SearchQuery(query=query)
|
||||||
|
else:
|
||||||
|
# Fallback: pass a simple dict-like object
|
||||||
|
sq = {"query": query, "region": region}
|
||||||
|
|
||||||
|
raw_postings = connector.fetch(sq)
|
||||||
|
|
||||||
|
new_count = 0
|
||||||
|
dupe_count = 0
|
||||||
|
for rp in raw_postings:
|
||||||
|
# Check if (source, url) already exists
|
||||||
|
existing = fetch_one(
|
||||||
|
"SELECT id FROM job_posting WHERE source = %s AND url = %s",
|
||||||
|
(rp.get("source", "arbetsformedlingen"), rp.get("url", "")),
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
dupe_count += 1
|
||||||
|
continue
|
||||||
|
posting = repo_app.create_job_posting(
|
||||||
|
source=rp.get("source", "arbetsformedlingen"),
|
||||||
|
url=rp.get("url", ""),
|
||||||
|
company=rp.get("company", "Unknown"),
|
||||||
|
title=rp.get("title", "Unknown"),
|
||||||
|
location=rp.get("location"),
|
||||||
|
description=rp.get("description", ""),
|
||||||
|
external_id=rp.get("external_id"),
|
||||||
|
raw=rp.get("raw", {}),
|
||||||
|
)
|
||||||
|
repo_app.create_application(posting["id"])
|
||||||
|
new_count += 1
|
||||||
|
|
||||||
|
return {"new": new_count, "dupes": dupe_count}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/postings/fetch", response_model=PostingsFetchResponse)
|
||||||
|
def postings_fetch(body: PostingsFetchRequest) -> Any:
|
||||||
|
"""Fetch job postings via the Arbetsformedlingen connector.
|
||||||
|
|
||||||
|
Behind env flag CONNECTORS_ENABLED (default true).
|
||||||
|
"""
|
||||||
|
if not _get_connectors_enabled():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail={
|
||||||
|
"code": "connectors_disabled",
|
||||||
|
"message": "Connectors are not enabled. Set CONNECTORS_ENABLED=true to enable.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not CONNECTORS_AVAILABLE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail={
|
||||||
|
"code": "connectors_unavailable",
|
||||||
|
"message": "Connectors package is not installed.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return _fetch_and_create_postings(body.query, body.region)
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1: Batch Scoring ---
|
||||||
|
|
||||||
|
def _batch_score_internal(application_ids: list[str]) -> list[dict[str, Any]]:
|
||||||
|
"""Internal: score multiple applications, return results with red_flags."""
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for app_id in application_ids:
|
||||||
|
app_row = repo_app.get_application(app_id)
|
||||||
|
if app_row is None:
|
||||||
|
continue
|
||||||
|
posting = repo_app.get_job_posting(app_row["job_posting_id"])
|
||||||
|
if posting is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = llm.run_task(
|
||||||
|
"score",
|
||||||
|
f"Score this posting: {posting['title']} at {posting['company']}",
|
||||||
|
telemetry_sink=lambda info, aid=app_id: repo_app.create_task_run({
|
||||||
|
**info,
|
||||||
|
"application_id": aid,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
score = float(result.get("score", 50))
|
||||||
|
rationale = result.get("rationale", {})
|
||||||
|
red_flags = result.get("red_flags", [])
|
||||||
|
|
||||||
|
repo_app.update_application_score(app_id, score, rationale)
|
||||||
|
results.append({
|
||||||
|
"application_id": app_id,
|
||||||
|
"score": score,
|
||||||
|
"rationale": rationale,
|
||||||
|
"red_flags": red_flags,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/scoring/batch", response_model=BatchScoreResponse)
|
||||||
|
def batch_score(body: BatchScoreRequest) -> Any:
|
||||||
|
"""Score multiple applications in one call (cheap class)."""
|
||||||
|
results = _batch_score_internal(body.application_ids)
|
||||||
|
return {"results": results}
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1: Today ---
|
||||||
|
|
||||||
|
@app.get("/api/today", response_model=TodayResponse)
|
||||||
|
def get_today() -> Any:
|
||||||
|
"""Return daily digest: ranked postings, follow-up nudges, pending approvals."""
|
||||||
|
# Digest: scored applications ordered by score desc
|
||||||
|
digest_apps = repo_app.get_digest(limit=20)
|
||||||
|
digest = [
|
||||||
|
DigestItem(
|
||||||
|
application_id=a["id"],
|
||||||
|
title=a.get("title", ""),
|
||||||
|
company=a.get("company", ""),
|
||||||
|
score=a.get("score"),
|
||||||
|
)
|
||||||
|
for a in digest_apps
|
||||||
|
]
|
||||||
|
|
||||||
|
# Nudges: sent applications past follow_up_after_days and not snoozed
|
||||||
|
nudge_apps = repo_app.get_nudge_applications()
|
||||||
|
nudges = [
|
||||||
|
NudgeItem(
|
||||||
|
application_id=a["id"],
|
||||||
|
days_since_sent=a.get("days_since_sent", 0) or 0,
|
||||||
|
suggestion=(
|
||||||
|
"Consider sending a follow-up email asking about the status "
|
||||||
|
"of your application."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for a in nudge_apps
|
||||||
|
]
|
||||||
|
|
||||||
|
# Pending approvals count
|
||||||
|
pending = repo_app.count_pending_approvals()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"digest": digest,
|
||||||
|
"nudges": nudges,
|
||||||
|
"pending_approvals": pending,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1: Interview Prep ---
|
||||||
|
|
||||||
|
@app.post("/api/applications/{app_id}/interview-prep", response_model=InterviewPrepResponse)
|
||||||
|
def interview_prep(app_id: str) -> Any:
|
||||||
|
"""Generate interview prep Q&A (strong class), stored as artifact."""
|
||||||
|
app_row = repo_app.get_application(app_id)
|
||||||
|
if app_row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Application not found")
|
||||||
|
|
||||||
|
posting = repo_app.get_job_posting(app_row["job_posting_id"])
|
||||||
|
if posting is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Posting not found")
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
f"Generate interview prep for: {posting['title']} at {posting['company']}. "
|
||||||
|
f"Description: {posting.get('description', '')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = llm.run_task(
|
||||||
|
"interview_prep",
|
||||||
|
prompt,
|
||||||
|
telemetry_sink=lambda info: repo_app.create_task_run({
|
||||||
|
**info,
|
||||||
|
"application_id": app_id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
content = result.get("content", "")
|
||||||
|
content_bytes = content.encode("utf-8")
|
||||||
|
|
||||||
|
# Store as artifact
|
||||||
|
storage_dir = os.path.join(tempfile.gettempdir(), "jobhunt_artifacts")
|
||||||
|
os.makedirs(storage_dir, exist_ok=True)
|
||||||
|
filename = f"interview_prep_{app_id[:8]}.md"
|
||||||
|
storage_path = os.path.join(storage_dir, filename)
|
||||||
|
with open(storage_path, "wb") as f:
|
||||||
|
f.write(content_bytes)
|
||||||
|
|
||||||
|
artifact = repo_app.create_artifact(
|
||||||
|
application_id=app_id,
|
||||||
|
kind="other",
|
||||||
|
filename=filename,
|
||||||
|
content_bytes=content_bytes,
|
||||||
|
storage_path=storage_path,
|
||||||
|
origin="ai_drafted",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Link to application
|
||||||
|
repo_app.set_interview_prep_artifact(app_id, artifact["id"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"artifact_id": artifact["id"],
|
||||||
|
"content": content,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1: Concierge / Demo Seed ---
|
||||||
|
|
||||||
|
@app.post("/api/concierge/seed-demo", response_model=SeedDemoResponse)
|
||||||
|
def seed_demo() -> Any:
|
||||||
|
"""Idempotent demo seed: profile + 6 postings + varied application states."""
|
||||||
|
# Check if demo profile already exists
|
||||||
|
existing = fetch_one(
|
||||||
|
"SELECT * FROM profile WHERE full_name = 'Demo Demosson'"
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
# Already seeded -- return current counts
|
||||||
|
profile = repo_profile._normalize_profile(existing)
|
||||||
|
postings = repo_app.list_postings()
|
||||||
|
apps = repo_app.list_applications()
|
||||||
|
sections = repo_profile.list_sections()
|
||||||
|
return {
|
||||||
|
"profile": profile["full_name"],
|
||||||
|
"postings": len(postings),
|
||||||
|
"applications": len(apps),
|
||||||
|
"sections": len(sections),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create demo profile with Swedish characters
|
||||||
|
repo_profile.get_or_create_profile()
|
||||||
|
profile = repo_profile.update_profile({
|
||||||
|
"full_name": "Demo Demosson",
|
||||||
|
"email": "demo@example.com",
|
||||||
|
"location": "Malmo",
|
||||||
|
"headline": "Software Developer",
|
||||||
|
"summary": "Experienced developer looking for opportunities in Skane.",
|
||||||
|
"languages": [{"code": "sv", "level": "native"}, {"code": "en", "level": "fluent"}],
|
||||||
|
})
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create demo profile")
|
||||||
|
|
||||||
|
# Create demo CV sections
|
||||||
|
demo_sections = [
|
||||||
|
{"kind": "experience", "title": "Backend Developer", "org": "TechSkane AB", "bullets": ["Built REST APIs", "Improved performance by 30%"], "tags": ["python", "fastapi"]},
|
||||||
|
{"kind": "experience", "title": "Junior Developer", "org": "Lund Software", "bullets": ["Maintained web apps"], "tags": ["javascript"]},
|
||||||
|
{"kind": "education", "title": "MSc Computer Science", "org": "Lunds Universitet", "bullets": ["Distributed systems specialization"], "tags": ["algorithms"]},
|
||||||
|
{"kind": "skills", "title": "Technical Skills", "bullets": ["Python", "PostgreSQL", "Docker", "FastAPI"], "tags": ["python", "docker"]},
|
||||||
|
]
|
||||||
|
for s in demo_sections:
|
||||||
|
repo_profile.create_section(profile["id"], s)
|
||||||
|
|
||||||
|
# Create 6 demo postings with varied states
|
||||||
|
demo_postings = [
|
||||||
|
{"company": "Skane Tech AB", "title": "Senior Python Developer", "location": "Malmo", "url": "https://example.com/af/1", "state": "scored", "score": 85},
|
||||||
|
{"company": "Lund Systems", "title": "Fullstack Engineer", "location": "Lund", "url": "https://example.com/af/2", "state": "discovered", "score": None},
|
||||||
|
{"company": "Copenhagen Digital", "title": "Backend Developer", "location": "Copenhagen", "url": "https://example.com/af/3", "state": "approved", "score": 70},
|
||||||
|
{"company": "Helsingborg IT", "title": "DevOps Engineer", "location": "Helsingborg", "url": "https://example.com/af/4", "state": "sent", "score": 65},
|
||||||
|
{"company": "Malmo Startup", "title": "Software Engineer", "location": "Malmo", "url": "https://example.com/af/5", "state": "rejected", "score": 30},
|
||||||
|
{"company": "Angelholm Tech", "title": "Data Engineer", "location": "Angelholm", "url": "https://example.com/af/6", "state": "discovered", "score": None},
|
||||||
|
]
|
||||||
|
|
||||||
|
for dp in demo_postings:
|
||||||
|
posting = repo_app.create_job_posting(
|
||||||
|
source="arbetsformedlingen",
|
||||||
|
url=dp["url"],
|
||||||
|
company=dp["company"],
|
||||||
|
title=dp["title"],
|
||||||
|
location=dp["location"],
|
||||||
|
description=f"Job description for {dp['title']} at {dp['company']}",
|
||||||
|
raw={},
|
||||||
|
)
|
||||||
|
app_row = repo_app.create_application(posting["id"])
|
||||||
|
|
||||||
|
# Set state and score
|
||||||
|
if dp["score"] is not None:
|
||||||
|
repo_app.update_application_score(app_row["id"], dp["score"], {"factors": {}})
|
||||||
|
|
||||||
|
if dp["state"] != "discovered" and dp["state"] != "scored":
|
||||||
|
# Transition through states
|
||||||
|
if dp["state"] in ("approved", "rejected"):
|
||||||
|
# First set to scored if needed
|
||||||
|
if dp["score"] is not None:
|
||||||
|
repo_app.update_application_state(app_row["id"], "scored")
|
||||||
|
repo_app.update_application_state(app_row["id"], dp["state"])
|
||||||
|
elif dp["state"] == "sent":
|
||||||
|
# approved -> drafting -> sent
|
||||||
|
if dp["score"] is not None:
|
||||||
|
repo_app.update_application_state(app_row["id"], "scored")
|
||||||
|
repo_app.update_application_state(app_row["id"], "approved")
|
||||||
|
repo_app.update_application_state(app_row["id"], "drafting")
|
||||||
|
# We need confirmed approval for drafting->sent, so directly set state
|
||||||
|
execute(
|
||||||
|
"UPDATE application SET state = 'sent', state_changed_at = now(), last_activity_at = now() WHERE id = %s",
|
||||||
|
(app_row["id"],),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backdate the 'sent' application ( posting 4) to 8 days ago for nudge demo
|
||||||
|
from datetime import timedelta
|
||||||
|
backdated = datetime.now(timezone.utc) - timedelta(days=8)
|
||||||
|
execute(
|
||||||
|
"UPDATE application SET last_activity_at = %s WHERE state = 'sent'",
|
||||||
|
(backdated,),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count results
|
||||||
|
postings_count = len(repo_app.list_postings())
|
||||||
|
apps_count = len(repo_app.list_applications())
|
||||||
|
sections_count = len(repo_profile.list_sections())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"profile": "Demo Demosson",
|
||||||
|
"postings": postings_count,
|
||||||
|
"applications": apps_count,
|
||||||
|
"sections": sections_count,
|
||||||
|
}
|
||||||
92
apps/api/app/scheduler.py
Normal file
92
apps/api/app/scheduler.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
"""APScheduler integration: daily fetch + batch score job.
|
||||||
|
|
||||||
|
Starts during app lifespan when SCHEDULER_ENABLED=true (default false).
|
||||||
|
Runs a daily job at 07:00 that fetches postings and batch-scores pending applications.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_scheduler = None
|
||||||
|
|
||||||
|
|
||||||
|
def is_scheduler_enabled() -> bool:
|
||||||
|
"""Check if the scheduler is enabled via env."""
|
||||||
|
return os.environ.get("SCHEDULER_ENABLED", "false").lower() in (
|
||||||
|
"true",
|
||||||
|
"1",
|
||||||
|
"yes",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _daily_fetch_and_score() -> None:
|
||||||
|
"""Daily job: fetch postings and batch-score pending applications."""
|
||||||
|
logger.info("Scheduler: running daily fetch + batch score")
|
||||||
|
try:
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from app.main import _fetch_and_create_postings, _batch_score_internal
|
||||||
|
|
||||||
|
# Fetch default query
|
||||||
|
fetch_result = _fetch_and_create_postings(
|
||||||
|
query="developer",
|
||||||
|
region="Skane lan",
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Scheduler: fetched %s new, %s dupes",
|
||||||
|
fetch_result.get("new", 0),
|
||||||
|
fetch_result.get("dupes", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Batch score all discovered applications
|
||||||
|
from app.db import repo_app
|
||||||
|
apps = repo_app.list_applications()
|
||||||
|
discovered_ids = [
|
||||||
|
a["id"] for a in apps if a["state"] == "discovered"
|
||||||
|
]
|
||||||
|
if discovered_ids:
|
||||||
|
results = _batch_score_internal(discovered_ids)
|
||||||
|
logger.info(
|
||||||
|
"Scheduler: batch-scored %s applications", len(results)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Scheduler: daily job failed")
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler() -> None:
|
||||||
|
"""Start the APScheduler if enabled."""
|
||||||
|
global _scheduler
|
||||||
|
if not is_scheduler_enabled():
|
||||||
|
logger.info("Scheduler disabled (SCHEDULER_ENABLED != true)")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
except ImportError:
|
||||||
|
logger.warning(
|
||||||
|
"APScheduler not installed; scheduler will not start."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
_scheduler = AsyncIOScheduler()
|
||||||
|
_scheduler.add_job(
|
||||||
|
_daily_fetch_and_score,
|
||||||
|
CronTrigger(hour=7, minute=0),
|
||||||
|
id="daily_fetch_score",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
_scheduler.start()
|
||||||
|
logger.info("Scheduler started: daily fetch+score at 07:00")
|
||||||
|
|
||||||
|
|
||||||
|
def stop_scheduler() -> None:
|
||||||
|
"""Stop the scheduler if running."""
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler is not None:
|
||||||
|
_scheduler.shutdown(wait=False)
|
||||||
|
_scheduler = None
|
||||||
|
logger.info("Scheduler stopped")
|
||||||
|
|
@ -130,6 +130,11 @@ class ApplicationOut(BaseModel):
|
||||||
company: str | None = None
|
company: str | None = None
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
location: str | None = None
|
location: str | None = None
|
||||||
|
# v1 follow-up fields
|
||||||
|
follow_up_after_days: int = 7
|
||||||
|
last_activity_at: str | None = None
|
||||||
|
follow_up_snoozed_until: str | None = None
|
||||||
|
interview_prep_artifact_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class TransitionRequest(BaseModel):
|
class TransitionRequest(BaseModel):
|
||||||
|
|
@ -217,4 +222,101 @@ class TaskRunOut(BaseModel):
|
||||||
# --- Errors ---
|
# --- Errors ---
|
||||||
|
|
||||||
class ErrorOut(BaseModel):
|
class ErrorOut(BaseModel):
|
||||||
error: dict[str, str]
|
error: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1: CV Import ---
|
||||||
|
|
||||||
|
class CvImportRequest(BaseModel):
|
||||||
|
filename: str
|
||||||
|
content_base64: str
|
||||||
|
|
||||||
|
|
||||||
|
class CvDraft(BaseModel):
|
||||||
|
kind: str = "experience"
|
||||||
|
title: str = ""
|
||||||
|
org: str | None = None
|
||||||
|
location: str | None = None
|
||||||
|
start_date: str | None = None
|
||||||
|
end_date: str | None = None
|
||||||
|
bullets: list[str] = Field(default_factory=list)
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CvImportResponse(BaseModel):
|
||||||
|
drafts: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class CvImportConfirmRequest(BaseModel):
|
||||||
|
drafts: list[CvDraft]
|
||||||
|
|
||||||
|
|
||||||
|
class CvImportConfirmResponse(BaseModel):
|
||||||
|
created: int
|
||||||
|
sections: list[CvSectionOut]
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1: Postings Fetch ---
|
||||||
|
|
||||||
|
class PostingsFetchRequest(BaseModel):
|
||||||
|
query: str
|
||||||
|
region: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PostingsFetchResponse(BaseModel):
|
||||||
|
new: int
|
||||||
|
dupes: int
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1: Batch Scoring ---
|
||||||
|
|
||||||
|
class BatchScoreRequest(BaseModel):
|
||||||
|
application_ids: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class BatchScoreResult(BaseModel):
|
||||||
|
application_id: str
|
||||||
|
score: float
|
||||||
|
rationale: dict[str, Any]
|
||||||
|
red_flags: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class BatchScoreResponse(BaseModel):
|
||||||
|
results: list[BatchScoreResult]
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1: Today ---
|
||||||
|
|
||||||
|
class DigestItem(BaseModel):
|
||||||
|
application_id: str
|
||||||
|
title: str
|
||||||
|
company: str
|
||||||
|
score: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class NudgeItem(BaseModel):
|
||||||
|
application_id: str
|
||||||
|
days_since_sent: int
|
||||||
|
suggestion: str
|
||||||
|
|
||||||
|
|
||||||
|
class TodayResponse(BaseModel):
|
||||||
|
digest: list[DigestItem]
|
||||||
|
nudges: list[NudgeItem]
|
||||||
|
pending_approvals: int
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1: Interview Prep ---
|
||||||
|
|
||||||
|
class InterviewPrepResponse(BaseModel):
|
||||||
|
artifact_id: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
# --- v1: Concierge Seed ---
|
||||||
|
|
||||||
|
class SeedDemoResponse(BaseModel):
|
||||||
|
profile: str
|
||||||
|
postings: int
|
||||||
|
applications: int
|
||||||
|
sections: int
|
||||||
|
|
@ -1,13 +1,22 @@
|
||||||
"""Send transport interface.
|
"""Send transport interface.
|
||||||
|
|
||||||
Pluggable Transport interface for the outbox send operation.
|
Pluggable Transport interface for the outbox send operation.
|
||||||
Default EchoTransport records the payload and marks as sent.
|
Selection order:
|
||||||
No real email in POC.
|
1. SMTP configured (SMTP_HOST set) -> SmtpTransport
|
||||||
|
2. Else -> ClipboardTransport (marks sent + stores payload for UI copy)
|
||||||
|
|
||||||
|
The approval gate checks (confirmed, unexpired, hash match) are UNCHANGED
|
||||||
|
and enforced in the API layer before transport.send() is called.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -35,17 +44,115 @@ class EchoTransport:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ClipboardTransport:
|
||||||
|
"""Fallback transport: marks sent and stores payload for UI copy/paste.
|
||||||
|
|
||||||
|
No real email is sent. The payload is stored so the UI can show it
|
||||||
|
to the user for manual copy-paste into their email client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.sent: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
self.sent.append(payload)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"sent_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"transport": "clipboard",
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpTransport:
|
||||||
|
"""SMTP transport: sends real email via SMTP.
|
||||||
|
|
||||||
|
Configuration from env:
|
||||||
|
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM
|
||||||
|
SSL on port 465, STARTTLS otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str | None = None,
|
||||||
|
port: int | None = None,
|
||||||
|
user: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
from_addr: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.host = host or os.environ.get("SMTP_HOST", "")
|
||||||
|
self.port = int(port or os.environ.get("SMTP_PORT", "587"))
|
||||||
|
self.user = user or os.environ.get("SMTP_USER", "")
|
||||||
|
self.password = password or os.environ.get("SMTP_PASS", "")
|
||||||
|
self.from_addr = from_addr or os.environ.get("SMTP_FROM", self.user)
|
||||||
|
|
||||||
|
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
to = payload.get("to", "")
|
||||||
|
subject = payload.get("subject", "(no subject)")
|
||||||
|
body = payload.get("body", "")
|
||||||
|
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
msg["From"] = self.from_addr
|
||||||
|
msg["To"] = to
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg.attach(MIMEText(body, "plain"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.port == 465:
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
with smtplib.SMTP_SSL(self.host, self.port, context=context) as server:
|
||||||
|
if self.user and self.password:
|
||||||
|
server.login(self.user, self.password)
|
||||||
|
server.sendmail(self.from_addr, [to], msg.as_string())
|
||||||
|
else:
|
||||||
|
with smtplib.SMTP(self.host, self.port) as server:
|
||||||
|
server.ehlo()
|
||||||
|
if self.user and self.password:
|
||||||
|
server.starttls()
|
||||||
|
server.ehlo()
|
||||||
|
server.login(self.user, self.password)
|
||||||
|
server.sendmail(self.from_addr, [to], msg.as_string())
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"sent_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"transport": "smtp",
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"sent_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"transport": "smtp",
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_smtp_configured() -> bool:
|
||||||
|
"""Check if SMTP is configured (SMTP_HOST is set)."""
|
||||||
|
return bool(os.environ.get("SMTP_HOST", "").strip())
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
# Singleton instance
|
||||||
_default_transport: Transport | None = None
|
_default_transport: Transport | None = None
|
||||||
|
|
||||||
|
|
||||||
def get_transport() -> Transport:
|
def get_transport() -> Transport:
|
||||||
|
"""Get the transport. Selects SMTP if configured, else Clipboard."""
|
||||||
global _default_transport
|
global _default_transport
|
||||||
if _default_transport is None:
|
if _default_transport is None:
|
||||||
_default_transport = EchoTransport()
|
if is_smtp_configured():
|
||||||
|
_default_transport = SmtpTransport()
|
||||||
|
else:
|
||||||
|
_default_transport = ClipboardTransport()
|
||||||
return _default_transport
|
return _default_transport
|
||||||
|
|
||||||
|
|
||||||
def set_transport(t: Transport) -> None:
|
def set_transport(t: Transport) -> None:
|
||||||
|
"""Override the transport (for testing)."""
|
||||||
global _default_transport
|
global _default_transport
|
||||||
_default_transport = t
|
_default_transport = t
|
||||||
|
|
||||||
|
|
||||||
|
def reset_transport() -> None:
|
||||||
|
"""Reset to default (for testing)."""
|
||||||
|
global _default_transport
|
||||||
|
_default_transport = None
|
||||||
12
apps/api/migrations/002_followups.sql
Normal file
12
apps/api/migrations/002_followups.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- 002_followups.sql -- follow-up rules + interview prep on application
|
||||||
|
|
||||||
|
ALTER TABLE application
|
||||||
|
ADD COLUMN IF NOT EXISTS follow_up_after_days int NOT NULL DEFAULT 7,
|
||||||
|
ADD COLUMN IF NOT EXISTS last_activity_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
ADD COLUMN IF NOT EXISTS follow_up_snoozed_until date,
|
||||||
|
ADD COLUMN IF NOT EXISTS interview_prep_artifact_id uuid REFERENCES artifact(id);
|
||||||
|
|
||||||
|
-- last_activity_at defaults to created_at for existing rows
|
||||||
|
UPDATE application
|
||||||
|
SET last_activity_at = created_at
|
||||||
|
WHERE last_activity_at IS NULL;
|
||||||
|
|
@ -15,6 +15,9 @@ dependencies = [
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.3",
|
"pytest>=8.3",
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
|
"pypdf>=4.0",
|
||||||
|
"python-docx>=1.1",
|
||||||
|
"apscheduler>=3.10",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|
@ -26,4 +29,4 @@ packages = ["app", "app.db"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
pythonpath = ["."]
|
pythonpath = [".", "..", "../.."]
|
||||||
|
|
@ -40,7 +40,7 @@ def _truncate_tables():
|
||||||
"""
|
"""
|
||||||
TRUNCATE TABLE task_run, outbox, approval, artifact,
|
TRUNCATE TABLE task_run, outbox, approval, artifact,
|
||||||
application, job_posting, cv_section, profile
|
application, job_posting, cv_section, profile
|
||||||
CASCADE
|
RESTART IDENTITY CASCADE
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
|
||||||
162
apps/api/tests/test_cv_import.py
Normal file
162
apps/api/tests/test_cv_import.py
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
"""Tests for v1 CV import endpoints (mock LLM mode)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCvImport:
|
||||||
|
def test_import_txt_file(self, client):
|
||||||
|
"""POST /cv/import with a .txt file returns drafts."""
|
||||||
|
content = b"John Doe\nSoftware Engineer at TechCorp\n5 years experience with Python"
|
||||||
|
resp = client.post(
|
||||||
|
"/api/cv/import",
|
||||||
|
json={
|
||||||
|
"filename": "my_cv.txt",
|
||||||
|
"content_base64": base64.b64encode(content).decode(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "drafts" in data
|
||||||
|
assert len(data["drafts"]) > 0
|
||||||
|
|
||||||
|
def test_import_empty_file_422(self, client):
|
||||||
|
"""Empty file -> 422."""
|
||||||
|
content = b""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/cv/import",
|
||||||
|
json={
|
||||||
|
"filename": "empty.txt",
|
||||||
|
"content_base64": base64.b64encode(content).decode(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "empty" in resp.text.lower()
|
||||||
|
|
||||||
|
def test_import_whitespace_only_file_422(self, client):
|
||||||
|
"""File with only whitespace -> 422."""
|
||||||
|
content = b" \n\n\t "
|
||||||
|
resp = client.post(
|
||||||
|
"/api/cv/import",
|
||||||
|
json={
|
||||||
|
"filename": "blank.txt",
|
||||||
|
"content_base64": base64.b64encode(content).decode(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "empty" in resp.text.lower()
|
||||||
|
|
||||||
|
def test_import_unsupported_format_422(self, client):
|
||||||
|
"""Unsupported file format -> 422."""
|
||||||
|
content = b"some data"
|
||||||
|
resp = client.post(
|
||||||
|
"/api/cv/import",
|
||||||
|
json={
|
||||||
|
"filename": "file.xyz",
|
||||||
|
"content_base64": base64.b64encode(content).decode(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "unsupported" in resp.text.lower()
|
||||||
|
|
||||||
|
def test_import_invalid_base64_422(self, client):
|
||||||
|
"""Invalid base64 -> 422."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/cv/import",
|
||||||
|
json={
|
||||||
|
"filename": "file.txt",
|
||||||
|
"content_base64": "!!!not-base64!!!",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_import_md_file(self, client):
|
||||||
|
"""POST /cv/import with a .md file returns drafts."""
|
||||||
|
content = b"# Jane Doe\n\n## Experience\nSenior Developer at Acme"
|
||||||
|
resp = client.post(
|
||||||
|
"/api/cv/import",
|
||||||
|
json={
|
||||||
|
"filename": "cv.md",
|
||||||
|
"content_base64": base64.b64encode(content).decode(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "drafts" in resp.json()
|
||||||
|
|
||||||
|
def test_import_creates_telemetry(self, client):
|
||||||
|
"""CV import should create a task_run entry."""
|
||||||
|
content = b"Some CV text with experience"
|
||||||
|
client.post(
|
||||||
|
"/api/cv/import",
|
||||||
|
json={
|
||||||
|
"filename": "cv.txt",
|
||||||
|
"content_base64": base64.b64encode(content).decode(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp = client.get("/api/telemetry/tasks")
|
||||||
|
tasks = resp.json()
|
||||||
|
assert any(t["task"] == "cv_extract" for t in tasks)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCvImportConfirm:
|
||||||
|
def test_confirm_creates_sections(self, client):
|
||||||
|
"""POST /cv/import/confirm creates cv_section rows."""
|
||||||
|
client.get("/api/profile")
|
||||||
|
resp = client.post(
|
||||||
|
"/api/cv/import/confirm",
|
||||||
|
json={
|
||||||
|
"drafts": [
|
||||||
|
{
|
||||||
|
"kind": "experience",
|
||||||
|
"title": "Dev",
|
||||||
|
"org": "Corp",
|
||||||
|
"bullets": ["did stuff"],
|
||||||
|
"tags": ["python"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "education",
|
||||||
|
"title": "MSc",
|
||||||
|
"org": "Uni",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert data["created"] == 2
|
||||||
|
assert len(data["sections"]) == 2
|
||||||
|
|
||||||
|
def test_confirm_empty_drafts(self, client):
|
||||||
|
"""Empty drafts list creates zero sections."""
|
||||||
|
client.get("/api/profile")
|
||||||
|
resp = client.post(
|
||||||
|
"/api/cv/import/confirm",
|
||||||
|
json={"drafts": []},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["created"] == 0
|
||||||
|
|
||||||
|
def test_confirm_sections_appear_in_list(self, client):
|
||||||
|
"""Confirmed sections appear in GET /profile/sections."""
|
||||||
|
client.get("/api/profile")
|
||||||
|
client.post(
|
||||||
|
"/api/cv/import/confirm",
|
||||||
|
json={
|
||||||
|
"drafts": [
|
||||||
|
{"kind": "skills", "title": "Python Dev", "bullets": ["FastAPI"]},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp = client.get("/api/profile/sections")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert any(s["title"] == "Python Dev" for s in resp.json())
|
||||||
137
apps/api/tests/test_postings_fetch.py
Normal file
137
apps/api/tests/test_postings_fetch.py
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
"""Tests for v1 postings fetch endpoint (connector stubbed)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRawPosting:
|
||||||
|
"""Minimal raw posting dict for testing."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fake_postings():
|
||||||
|
"""Return fake raw postings as dicts."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"source": "arbetsformedlingen",
|
||||||
|
"external_id": "af-001",
|
||||||
|
"url": "https://arbetsformedlingen.se/job/001",
|
||||||
|
"company": "Skane Tech",
|
||||||
|
"title": "Python Developer",
|
||||||
|
"location": "Malmo",
|
||||||
|
"description": "Great Python job in Malmo.",
|
||||||
|
"raw": {"id": "af-001"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "arbetsformedlingen",
|
||||||
|
"external_id": "af-002",
|
||||||
|
"url": "https://arbetsformedlingen.se/job/002",
|
||||||
|
"company": "Lund Systems",
|
||||||
|
"title": "Backend Engineer",
|
||||||
|
"location": "Lund",
|
||||||
|
"description": "Backend engineer at Lund.",
|
||||||
|
"raw": {"id": "af-002"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConnector:
|
||||||
|
"""Fake connector that returns predefined postings."""
|
||||||
|
def fetch(self, query):
|
||||||
|
# Accept both SearchQuery objects and dicts
|
||||||
|
return _make_fake_postings()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPostingsFetch:
|
||||||
|
def test_fetch_disabled_503(self, client, monkeypatch):
|
||||||
|
"""CONNECTORS_ENABLED=false -> 503."""
|
||||||
|
monkeypatch.setenv("CONNECTORS_ENABLED", "false")
|
||||||
|
resp = client.post(
|
||||||
|
"/api/postings/fetch",
|
||||||
|
json={"query": "python developer"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 503
|
||||||
|
assert "connectors_disabled" in resp.text
|
||||||
|
|
||||||
|
def test_fetch_enabled_with_stubbed_connector(self, client, monkeypatch):
|
||||||
|
"""Fetch with monkeypatched connector creates new applications."""
|
||||||
|
monkeypatch.setenv("CONNECTORS_ENABLED", "true")
|
||||||
|
# Monkeypatch the connector lookup
|
||||||
|
import app.main as main_mod
|
||||||
|
monkeypatch.setattr(main_mod, "CONNECTORS_AVAILABLE", True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main_mod,
|
||||||
|
"_get_arbetsformedlingen_connector",
|
||||||
|
lambda: FakeConnector(),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/postings/fetch",
|
||||||
|
json={"query": "python developer", "region": "Skane lan"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["new"] == 2
|
||||||
|
assert data["dupes"] == 0
|
||||||
|
|
||||||
|
def test_fetch_dedupes_existing_postings(self, client, monkeypatch):
|
||||||
|
"""Second fetch of same postings counts as dupes."""
|
||||||
|
monkeypatch.setenv("CONNECTORS_ENABLED", "true")
|
||||||
|
import app.main as main_mod
|
||||||
|
monkeypatch.setattr(main_mod, "CONNECTORS_AVAILABLE", True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main_mod,
|
||||||
|
"_get_arbetsformedlingen_connector",
|
||||||
|
lambda: FakeConnector(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# First fetch
|
||||||
|
resp1 = client.post(
|
||||||
|
"/api/postings/fetch",
|
||||||
|
json={"query": "python"},
|
||||||
|
)
|
||||||
|
assert resp1.status_code == 200
|
||||||
|
assert resp1.json()["new"] == 2
|
||||||
|
|
||||||
|
# Second fetch: same postings should be dupes
|
||||||
|
resp2 = client.post(
|
||||||
|
"/api/postings/fetch",
|
||||||
|
json={"query": "python"},
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
assert resp2.json()["new"] == 0
|
||||||
|
assert resp2.json()["dupes"] == 2
|
||||||
|
|
||||||
|
def test_fetch_empty_results(self, client, monkeypatch):
|
||||||
|
"""Connector returning no postings -> new=0, dupes=0."""
|
||||||
|
monkeypatch.setenv("CONNECTORS_ENABLED", "true")
|
||||||
|
import app.main as main_mod
|
||||||
|
monkeypatch.setattr(main_mod, "CONNECTORS_AVAILABLE", True)
|
||||||
|
|
||||||
|
class EmptyConnector:
|
||||||
|
def fetch(self, query):
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main_mod,
|
||||||
|
"_get_arbetsformedlingen_connector",
|
||||||
|
lambda: EmptyConnector(),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/postings/fetch",
|
||||||
|
json={"query": "rare keyword"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["new"] == 0
|
||||||
|
assert resp.json()["dupes"] == 0
|
||||||
398
apps/api/tests/test_v1_features.py
Normal file
398
apps/api/tests/test_v1_features.py
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
"""Tests for v1 batch scoring, today digest, interview prep, seed demo, and SMTP transport."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.config import DATABASE_URL
|
||||||
|
from app.db import repo_app, repo_profile
|
||||||
|
from app.transport import (
|
||||||
|
ClipboardTransport,
|
||||||
|
SmtpTransport,
|
||||||
|
get_transport,
|
||||||
|
is_smtp_configured,
|
||||||
|
reset_transport,
|
||||||
|
set_transport,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Batch Scoring ---
|
||||||
|
|
||||||
|
class TestBatchScoring:
|
||||||
|
def test_batch_score_multiple(self, client):
|
||||||
|
"""Batch score multiple applications."""
|
||||||
|
ids = []
|
||||||
|
for i in range(3):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/postings",
|
||||||
|
json={"url": f"https://example.com/batch/{i}"},
|
||||||
|
)
|
||||||
|
ids.append(resp.json()["id"])
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/scoring/batch",
|
||||||
|
json={"application_ids": ids},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data["results"]) == 3
|
||||||
|
for r in data["results"]:
|
||||||
|
assert "score" in r
|
||||||
|
assert "red_flags" in r
|
||||||
|
assert isinstance(r["red_flags"], list)
|
||||||
|
|
||||||
|
def test_batch_score_empty_list(self, client):
|
||||||
|
"""Empty application_ids list -> 200 with empty results."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/scoring/batch",
|
||||||
|
json={"application_ids": []},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["results"] == []
|
||||||
|
|
||||||
|
def test_batch_score_nonexistent_app_skipped(self, client):
|
||||||
|
"""Nonexistent application IDs are silently skipped."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/scoring/batch",
|
||||||
|
json={"application_ids": ["00000000-0000-0000-0000-000000000000"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["results"] == []
|
||||||
|
|
||||||
|
def test_batch_score_includes_red_flags(self, client):
|
||||||
|
"""Batch score results include red_flags field."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/postings",
|
||||||
|
json={"url": "https://example.com/redflag/test"},
|
||||||
|
)
|
||||||
|
app_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/scoring/batch",
|
||||||
|
json={"application_ids": [app_id]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
result = resp.json()["results"][0]
|
||||||
|
assert "red_flags" in result
|
||||||
|
assert isinstance(result["red_flags"], list)
|
||||||
|
|
||||||
|
def test_batch_score_updates_application_state(self, client):
|
||||||
|
"""After batch scoring, application state should be 'scored'."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/postings",
|
||||||
|
json={"url": "https://example.com/state/test"},
|
||||||
|
)
|
||||||
|
app_id = resp.json()["id"]
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
"/api/scoring/batch",
|
||||||
|
json={"application_ids": [app_id]},
|
||||||
|
)
|
||||||
|
|
||||||
|
apps = client.get("/api/applications").json()
|
||||||
|
scored = [a for a in apps if a["id"] == app_id]
|
||||||
|
assert len(scored) == 1
|
||||||
|
assert scored[0]["state"] == "scored"
|
||||||
|
assert scored[0]["score"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Today ---
|
||||||
|
|
||||||
|
class TestToday:
|
||||||
|
def test_today_empty(self, client):
|
||||||
|
"""Today endpoint with no data returns empty digest and zero pending."""
|
||||||
|
resp = client.get("/api/today")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["digest"] == []
|
||||||
|
assert data["nudges"] == []
|
||||||
|
assert data["pending_approvals"] == 0
|
||||||
|
|
||||||
|
def test_today_with_scored_applications(self, client):
|
||||||
|
"""Today digest includes scored applications ordered by score desc."""
|
||||||
|
# Create and score two applications
|
||||||
|
for url in ["https://example.com/today/1", "https://example.com/today/2"]:
|
||||||
|
client.post("/api/postings", json={"url": url})
|
||||||
|
|
||||||
|
apps = client.get("/api/applications").json()
|
||||||
|
client.post(
|
||||||
|
"/api/scoring/batch",
|
||||||
|
json={"application_ids": [a["id"] for a in apps]},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get("/api/today")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
digest = resp.json()["digest"]
|
||||||
|
assert len(digest) == 2
|
||||||
|
# Should be ordered by score desc
|
||||||
|
assert digest[0]["score"] >= digest[1]["score"]
|
||||||
|
|
||||||
|
def test_today_nudge_for_backdated_sent(self, client):
|
||||||
|
"""A sent application backdated 8 days should appear in nudges."""
|
||||||
|
# Create posting + application
|
||||||
|
resp = client.post("/api/postings", json={"url": "https://example.com/nudge/1"})
|
||||||
|
app_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# Score it, then move through states to sent
|
||||||
|
client.post("/api/scoring/batch", json={"application_ids": [app_id]})
|
||||||
|
client.post(f"/api/applications/{app_id}/transition", json={"to": "approved"})
|
||||||
|
client.post(f"/api/applications/{app_id}/transition", json={"to": "drafting"})
|
||||||
|
|
||||||
|
# Directly set state to sent (bypassing guard for test)
|
||||||
|
with psycopg.connect(DATABASE_URL) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE application SET state = 'sent', last_activity_at = %s WHERE id = %s",
|
||||||
|
(datetime.now(timezone.utc) - timedelta(days=8), app_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
resp = client.get("/api/today")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
nudges = resp.json()["nudges"]
|
||||||
|
assert len(nudges) == 1
|
||||||
|
assert nudges[0]["application_id"] == app_id
|
||||||
|
assert nudges[0]["days_since_sent"] >= 8
|
||||||
|
|
||||||
|
def test_today_nudge_snoozed_excluded(self, client):
|
||||||
|
"""A snoozed application should not appear in nudges."""
|
||||||
|
resp = client.post("/api/postings", json={"url": "https://example.com/nudge/2"})
|
||||||
|
app_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# Score and set to sent with backdated activity
|
||||||
|
client.post("/api/scoring/batch", json={"application_ids": [app_id]})
|
||||||
|
with psycopg.connect(DATABASE_URL) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE application SET state = 'sent', last_activity_at = %s WHERE id = %s",
|
||||||
|
(datetime.now(timezone.utc) - timedelta(days=10), app_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Verify it shows up first
|
||||||
|
resp = client.get("/api/today")
|
||||||
|
assert len(resp.json()["nudges"]) == 1
|
||||||
|
|
||||||
|
# Snooze it for a future date
|
||||||
|
future = (datetime.now(timezone.utc) + timedelta(days=7)).date()
|
||||||
|
with psycopg.connect(DATABASE_URL) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE application SET follow_up_snoozed_until = %s WHERE id = %s",
|
||||||
|
(future, app_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
resp = client.get("/api/today")
|
||||||
|
assert len(resp.json()["nudges"]) == 0
|
||||||
|
|
||||||
|
def test_today_pending_approvals_count(self, client):
|
||||||
|
"""Today endpoint counts pending (unconfirmed, unexpired) approvals."""
|
||||||
|
# Create posting + application + artifact
|
||||||
|
resp = client.post("/api/postings", json={"url": "https://example.com/today/3"})
|
||||||
|
app_id = resp.json()["id"]
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
f"/api/applications/{app_id}/artifacts",
|
||||||
|
json={"kind": "email", "content": "test content"},
|
||||||
|
)
|
||||||
|
artifacts = client.get(f"/api/applications/{app_id}/artifacts").json()
|
||||||
|
artifact_id = artifacts[0]["id"]
|
||||||
|
|
||||||
|
# Create approval (not confirmed)
|
||||||
|
client.post(
|
||||||
|
f"/api/applications/{app_id}/approvals",
|
||||||
|
json={"action": "send_email", "artifact_id": artifact_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get("/api/today")
|
||||||
|
assert resp.json()["pending_approvals"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- Interview Prep ---
|
||||||
|
|
||||||
|
class TestInterviewPrep:
|
||||||
|
def test_interview_prep_creates_artifact(self, client):
|
||||||
|
"""POST /applications/{id}/interview-prep creates an artifact."""
|
||||||
|
resp = client.post("/api/postings", json={"url": "https://example.com/iprep/1"})
|
||||||
|
app_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(f"/api/applications/{app_id}/interview-prep")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "artifact_id" in data
|
||||||
|
assert "content" in data
|
||||||
|
assert len(data["content"]) > 0
|
||||||
|
assert "Q1" in data["content"]
|
||||||
|
|
||||||
|
def test_interview_prep_artifact_in_list(self, client):
|
||||||
|
"""Interview prep artifact appears in GET artifacts list."""
|
||||||
|
resp = client.post("/api/postings", json={"url": "https://example.com/iprep/2"})
|
||||||
|
app_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(f"/api/applications/{app_id}/interview-prep")
|
||||||
|
artifact_id = resp.json()["artifact_id"]
|
||||||
|
|
||||||
|
resp = client.get(f"/api/applications/{app_id}/artifacts")
|
||||||
|
artifacts = resp.json()
|
||||||
|
assert any(a["id"] == artifact_id for a in artifacts)
|
||||||
|
|
||||||
|
def test_interview_prep_sets_artifact_id_on_app(self, client):
|
||||||
|
"""interview_prep_artifact_id is set on the application."""
|
||||||
|
resp = client.post("/api/postings", json={"url": "https://example.com/iprep/3"})
|
||||||
|
app_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(f"/api/applications/{app_id}/interview-prep")
|
||||||
|
artifact_id = resp.json()["artifact_id"]
|
||||||
|
|
||||||
|
apps = client.get("/api/applications").json()
|
||||||
|
app_row = [a for a in apps if a["id"] == app_id][0]
|
||||||
|
assert app_row["interview_prep_artifact_id"] == artifact_id
|
||||||
|
|
||||||
|
def test_interview_prep_404_nonexistent_app(self, client):
|
||||||
|
"""404 for nonexistent application."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/applications/00000000-0000-0000-0000-000000000000/interview-prep"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_interview_prep_creates_telemetry(self, client):
|
||||||
|
"""Interview prep creates a task_run entry."""
|
||||||
|
resp = client.post("/api/postings", json={"url": "https://example.com/iprep/4"})
|
||||||
|
app_id = resp.json()["id"]
|
||||||
|
|
||||||
|
client.post(f"/api/applications/{app_id}/interview-prep")
|
||||||
|
|
||||||
|
resp = client.get("/api/telemetry/tasks")
|
||||||
|
tasks = resp.json()
|
||||||
|
assert any(t["task"] == "interview_prep" for t in tasks)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Seed Demo ---
|
||||||
|
|
||||||
|
class TestSeedDemo:
|
||||||
|
def test_seed_demo_creates_data(self, client):
|
||||||
|
"""POST /concierge/seed-demo creates profile, postings, applications."""
|
||||||
|
resp = client.post("/api/concierge/seed-demo")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["profile"] == "Demo Demosson"
|
||||||
|
assert data["postings"] == 6
|
||||||
|
assert data["applications"] == 6
|
||||||
|
assert data["sections"] == 4
|
||||||
|
|
||||||
|
def test_seed_demo_idempotent(self, client):
|
||||||
|
"""Running seed-demo twice returns the same counts."""
|
||||||
|
resp1 = client.post("/api/concierge/seed-demo")
|
||||||
|
assert resp1.status_code == 200
|
||||||
|
|
||||||
|
resp2 = client.post("/api/concierge/seed-demo")
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
assert resp2.json()["postings"] == resp1.json()["postings"]
|
||||||
|
assert resp2.json()["applications"] == resp1.json()["applications"]
|
||||||
|
assert resp2.json()["sections"] == resp1.json()["sections"]
|
||||||
|
|
||||||
|
def test_seed_demo_has_nudge_candidate(self, client):
|
||||||
|
"""After seeding, /today should show a nudge for the backdated sent app."""
|
||||||
|
client.post("/api/concierge/seed-demo")
|
||||||
|
resp = client.get("/api/today")
|
||||||
|
nudges = resp.json()["nudges"]
|
||||||
|
assert len(nudges) >= 1
|
||||||
|
|
||||||
|
def test_seed_demo_has_scored_digest(self, client):
|
||||||
|
"""After seeding, /today digest should have scored applications."""
|
||||||
|
client.post("/api/concierge/seed-demo")
|
||||||
|
resp = client.get("/api/today")
|
||||||
|
digest = resp.json()["digest"]
|
||||||
|
assert len(digest) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- SMTP Transport ---
|
||||||
|
|
||||||
|
class TestSmtpTransportSelection:
|
||||||
|
def test_clipboard_when_no_smtp(self, monkeypatch):
|
||||||
|
"""Without SMTP_HOST, transport is ClipboardTransport."""
|
||||||
|
monkeypatch.delenv("SMTP_HOST", raising=False)
|
||||||
|
reset_transport()
|
||||||
|
t = get_transport()
|
||||||
|
assert isinstance(t, ClipboardTransport)
|
||||||
|
|
||||||
|
def test_smtp_when_configured(self, monkeypatch):
|
||||||
|
"""With SMTP_HOST set, transport is SmtpTransport."""
|
||||||
|
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
|
||||||
|
monkeypatch.setenv("SMTP_PORT", "587")
|
||||||
|
monkeypatch.setenv("SMTP_USER", "user@example.com")
|
||||||
|
monkeypatch.setenv("SMTP_PASS", "pass")
|
||||||
|
monkeypatch.setenv("SMTP_FROM", "from@example.com")
|
||||||
|
reset_transport()
|
||||||
|
t = get_transport()
|
||||||
|
assert isinstance(t, SmtpTransport)
|
||||||
|
assert t.host == "smtp.example.com"
|
||||||
|
assert t.port == 587
|
||||||
|
reset_transport()
|
||||||
|
|
||||||
|
def test_smtp_ssl_on_465(self, monkeypatch):
|
||||||
|
"""SMTP port 465 triggers SSL."""
|
||||||
|
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
|
||||||
|
monkeypatch.setenv("SMTP_PORT", "465")
|
||||||
|
reset_transport()
|
||||||
|
t = get_transport()
|
||||||
|
assert isinstance(t, SmtpTransport)
|
||||||
|
assert t.port == 465
|
||||||
|
reset_transport()
|
||||||
|
|
||||||
|
def test_clipboard_send_success(self):
|
||||||
|
"""ClipboardTransport.send returns success with payload."""
|
||||||
|
t = ClipboardTransport()
|
||||||
|
payload = {"to": "test@example.com", "subject": "Hi", "body": "Hello"}
|
||||||
|
result = t.send(payload)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["transport"] == "clipboard"
|
||||||
|
assert result["payload"] == payload
|
||||||
|
|
||||||
|
def test_set_transport_override(self):
|
||||||
|
"""set_transport overrides the default."""
|
||||||
|
custom = ClipboardTransport()
|
||||||
|
set_transport(custom)
|
||||||
|
assert get_transport() is custom
|
||||||
|
reset_transport()
|
||||||
|
|
||||||
|
def test_is_smtp_configured_false(self, monkeypatch):
|
||||||
|
"""is_smtp_configured returns False when no SMTP_HOST."""
|
||||||
|
monkeypatch.delenv("SMTP_HOST", raising=False)
|
||||||
|
assert not is_smtp_configured()
|
||||||
|
|
||||||
|
def test_is_smtp_configured_true(self, monkeypatch):
|
||||||
|
"""is_smtp_configured returns True when SMTP_HOST is set."""
|
||||||
|
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
|
||||||
|
assert is_smtp_configured()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Scheduler ---
|
||||||
|
|
||||||
|
class TestScheduler:
|
||||||
|
def test_scheduler_disabled_by_default(self, monkeypatch):
|
||||||
|
"""SCHEDULER_ENABLED defaults to false."""
|
||||||
|
from app.scheduler import is_scheduler_enabled
|
||||||
|
monkeypatch.delenv("SCHEDULER_ENABLED", raising=False)
|
||||||
|
assert not is_scheduler_enabled()
|
||||||
|
|
||||||
|
def test_scheduler_enabled_when_true(self, monkeypatch):
|
||||||
|
"""SCHEDULER_ENABLED=true enables scheduler."""
|
||||||
|
from app.scheduler import is_scheduler_enabled
|
||||||
|
monkeypatch.setenv("SCHEDULER_ENABLED", "true")
|
||||||
|
assert is_scheduler_enabled()
|
||||||
|
|
||||||
|
def test_start_scheduler_noop_when_disabled(self, monkeypatch):
|
||||||
|
"""start_scheduler does nothing when disabled."""
|
||||||
|
from app import scheduler
|
||||||
|
monkeypatch.setenv("SCHEDULER_ENABLED", "false")
|
||||||
|
scheduler.start_scheduler() # should not raise
|
||||||
|
scheduler.stop_scheduler()
|
||||||
|
|
@ -20,8 +20,8 @@ services:
|
||||||
|
|
||||||
api-test:
|
api-test:
|
||||||
build:
|
build:
|
||||||
context: ./apps/api
|
context: .
|
||||||
dockerfile: Dockerfile.test
|
dockerfile: apps/api/Dockerfile.test
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://jobhunt:jobhunt@postgres:5432/jobhunt
|
DATABASE_URL: postgresql://jobhunt:jobhunt@postgres:5432/jobhunt
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
|
||||||
199
docs/api-contract-v2.md
Normal file
199
docs/api-contract-v2.md
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
# API contract v2 (v1.0 additions)
|
||||||
|
|
||||||
|
Base: `/api`. JSON everywhere. Errors as `{error: {code, message}}` with proper HTTP status.
|
||||||
|
|
||||||
|
This file documents the **new** endpoints added on top of `docs/api-contract.md` (POC).
|
||||||
|
All existing POC endpoints remain unchanged.
|
||||||
|
|
||||||
|
## CV Import
|
||||||
|
|
||||||
|
### `POST /cv/import`
|
||||||
|
|
||||||
|
Extract text from an uploaded file and generate draft CV sections via the LLM gateway (cheap class).
|
||||||
|
Does NOT write to `cv_section` -- returns drafts for user review.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filename": "my_cv.pdf",
|
||||||
|
"content_base64": "JVBERi0xLjQK..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"drafts": [
|
||||||
|
{
|
||||||
|
"kind": "experience",
|
||||||
|
"title": "Software Engineer",
|
||||||
|
"org": "TechCorp",
|
||||||
|
"location": "Malmo",
|
||||||
|
"start_date": "2022-01",
|
||||||
|
"end_date": null,
|
||||||
|
"bullets": ["Built feature X", "Improved performance by 20%"],
|
||||||
|
"tags": ["python", "fastapi"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
- 422 `{error: {code: "empty_file", message: "..."}}` when the file is empty or contains no extractable text.
|
||||||
|
- 422 `{error: {code: "unsupported_format", message: "..."}}` when the file type is not recognized.
|
||||||
|
|
||||||
|
### `POST /cv/import/confirm`
|
||||||
|
|
||||||
|
Create `cv_section` rows from the drafts returned by `/cv/import`.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"drafts": [
|
||||||
|
{
|
||||||
|
"kind": "experience",
|
||||||
|
"title": "Software Engineer",
|
||||||
|
"org": "TechCorp",
|
||||||
|
"bullets": ["Built feature X"],
|
||||||
|
"tags": ["python"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 201:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"created": 3,
|
||||||
|
"sections": [/* CvSectionOut[] */]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Postings Fetch (Arbetsformedlingen connector)
|
||||||
|
|
||||||
|
### `POST /postings/fetch`
|
||||||
|
|
||||||
|
Fetch job postings from the Arbetsformedlingen connector, create `job_posting` + `application(discovered)` for new postings, skip duplicates.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": "python developer",
|
||||||
|
"region": "Skane lan"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"new": 12,
|
||||||
|
"dupes": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
- 503 `{error: {code: "connectors_disabled", message: "Connectors are not enabled. Set CONNECTORS_ENABLED=true to enable."}}` when `CONNECTORS_ENABLED=false`.
|
||||||
|
|
||||||
|
## Batch Scoring
|
||||||
|
|
||||||
|
### `POST /scoring/batch`
|
||||||
|
|
||||||
|
Score multiple applications in one call (cheap class). Each response includes `red_flags` (scam/shield checks).
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"application_ids": ["uuid1", "uuid2"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"application_id": "uuid1",
|
||||||
|
"score": 72,
|
||||||
|
"rationale": {"match": 0.72, "factors": {"skills": 0.8}},
|
||||||
|
"red_flags": ["unpaid trial period mentioned"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Today Digest
|
||||||
|
|
||||||
|
### `GET /today`
|
||||||
|
|
||||||
|
Returns the daily digest: ranked postings, follow-up nudges, and pending approval count.
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"digest": [
|
||||||
|
{
|
||||||
|
"application_id": "uuid",
|
||||||
|
"title": "Backend Developer",
|
||||||
|
"company": "TechCorp",
|
||||||
|
"score": 85
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nudges": [
|
||||||
|
{
|
||||||
|
"application_id": "uuid",
|
||||||
|
"days_since_sent": 9,
|
||||||
|
"suggestion": "Consider sending a follow-up email asking about the status of your application."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pending_approvals": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nudge SQL: `state = 'sent' AND days_since(last_activity_at) > follow_up_after_days AND (follow_up_snoozed_until IS NULL OR follow_up_snoozed_until < today)`.
|
||||||
|
|
||||||
|
## Interview Prep
|
||||||
|
|
||||||
|
### `POST /applications/{id}/interview-prep`
|
||||||
|
|
||||||
|
Generate interview prep Q&A (strong LLM class), stored as an artifact of kind `other`.
|
||||||
|
Sets `interview_prep_artifact_id` on the application.
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"artifact_id": "uuid",
|
||||||
|
"content": "# Interview Prep\n\n## Q1: ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Mock mode returns deterministic 10-question Q&A markdown.
|
||||||
|
|
||||||
|
## Concierge / Demo Seed
|
||||||
|
|
||||||
|
### `POST /concierge/seed-demo`
|
||||||
|
|
||||||
|
Idempotent: seeds a demo profile ('Demo Demosson') with Swedish characters (a,a,o), 6 realistic Skane postings, varied application states (one scored high, one sent 8 days ago for nudge demo). Calling twice does not duplicate data.
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"profile": "Demo Demosson",
|
||||||
|
"postings": 6,
|
||||||
|
"applications": 6,
|
||||||
|
"sections": 4
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## SMTP Transport (no new endpoint)
|
||||||
|
|
||||||
|
The outbox `POST /outbox/send` now selects transport at call time:
|
||||||
|
|
||||||
|
1. If `SMTP_HOST` is set: `SmtpTransport` (ssl on port 465, starttls otherwise, auth with `SMTP_USER`/`SMTP_PASS`, from `SMTP_FROM`).
|
||||||
|
2. Else: `ClipboardTransport` (marks sent + stores payload for UI copy/paste).
|
||||||
|
|
||||||
|
The approval gate checks (confirmed, unexpired, hash match) are UNCHANGED.
|
||||||
|
|
||||||
|
## Scheduler (no new endpoint)
|
||||||
|
|
||||||
|
APScheduler `AsyncIOScheduler` starts during app lifespan when `SCHEDULER_ENABLED=true` (default false).
|
||||||
|
Runs a daily job at 07:00 that fetches postings and batch-scores pending applications.
|
||||||
Loading…
Reference in a new issue