W2: api v1 features (CV import, AF fetch, batch scoring, today/nudges, interview prep, SMTP+clipboard transports, scheduler, 90 tests)
Implements docs/api-contract-v2.md. Migration 002 adds follow-up fields. EchoTransport replaced by SMTP->Clipboard selection behind unchanged approval gate. Scheduler (APScheduler) daily 07:00 fetch+score, env-gated, default off. Test image installs workspace packages; build context moved to repo root. Recovered and committed by integration lead after W2 worker hit iteration limit.
This commit is contained in:
parent
d1753bb70a
commit
3035e4eac9
15 changed files with 1885 additions and 22 deletions
|
|
@ -4,12 +4,22 @@ FROM python:3.13-slim
|
|||
WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
|
||||
COPY pyproject.toml ./
|
||||
COPY app ./app
|
||||
COPY schema.sql ./schema.sql
|
||||
COPY migrations ./migrations
|
||||
COPY tests ./tests
|
||||
# Copy packages from build context root
|
||||
COPY packages ./packages
|
||||
|
||||
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(
|
||||
"""
|
||||
UPDATE application
|
||||
SET state = %s, state_changed_at = now()
|
||||
SET state = %s, state_changed_at = now(), last_activity_at = now()
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""",
|
||||
|
|
@ -170,6 +170,10 @@ def _normalize_application(row: dict[str, Any]) -> dict[str, Any]:
|
|||
"company": row.get("company"),
|
||||
"title": row.get("title"),
|
||||
"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"],
|
||||
"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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# --- 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,
|
||||
"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
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
|
|
@ -23,26 +24,48 @@ from app.schemas import (
|
|||
ApprovalOut,
|
||||
ArtifactCreate,
|
||||
ArtifactOut,
|
||||
BatchScoreRequest,
|
||||
BatchScoreResponse,
|
||||
BatchScoreResult,
|
||||
CoverLetterRequest,
|
||||
CoverLetterResponse,
|
||||
CvImportConfirmRequest,
|
||||
CvImportConfirmResponse,
|
||||
CvImportRequest,
|
||||
CvImportResponse,
|
||||
CvSectionCreate,
|
||||
CvSectionOut,
|
||||
CvSectionUpdate,
|
||||
DigestItem,
|
||||
ErrorOut,
|
||||
InterviewPrepResponse,
|
||||
JobPostingCreate,
|
||||
JobPostingOut,
|
||||
NudgeItem,
|
||||
OutboxOut,
|
||||
OutboxSendRequest,
|
||||
PostingsFetchRequest,
|
||||
PostingsFetchResponse,
|
||||
ProfileOut,
|
||||
ProfileUpdate,
|
||||
ScoreResponse,
|
||||
SeedDemoResponse,
|
||||
TaskRunOut,
|
||||
TodayResponse,
|
||||
TransitionRequest,
|
||||
)
|
||||
from app.statemachine import TransitionContext, check_transition, InvalidTransition
|
||||
from app.transport import get_transport
|
||||
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")
|
||||
|
|
@ -50,10 +73,15 @@ 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()
|
||||
|
||||
|
||||
|
|
@ -447,4 +475,461 @@ def send_outbox(body: OutboxSendRequest) -> Any:
|
|||
|
||||
@app.get("/api/telemetry/tasks", response_model=list[TaskRunOut])
|
||||
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
|
||||
title: 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):
|
||||
|
|
@ -217,4 +222,101 @@ class TaskRunOut(BaseModel):
|
|||
# --- Errors ---
|
||||
|
||||
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.
|
||||
|
||||
Pluggable Transport interface for the outbox send operation.
|
||||
Default EchoTransport records the payload and marks as sent.
|
||||
No real email in POC.
|
||||
Selection order:
|
||||
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
|
||||
|
||||
import os
|
||||
import smtplib
|
||||
import ssl
|
||||
from datetime import datetime, timezone
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
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
|
||||
_default_transport: Transport | None = None
|
||||
|
||||
|
||||
def get_transport() -> Transport:
|
||||
"""Get the transport. Selects SMTP if configured, else Clipboard."""
|
||||
global _default_transport
|
||||
if _default_transport is None:
|
||||
_default_transport = EchoTransport()
|
||||
if is_smtp_configured():
|
||||
_default_transport = SmtpTransport()
|
||||
else:
|
||||
_default_transport = ClipboardTransport()
|
||||
return _default_transport
|
||||
|
||||
|
||||
def set_transport(t: Transport) -> None:
|
||||
"""Override the transport (for testing)."""
|
||||
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 = [
|
||||
"pytest>=8.3",
|
||||
"httpx>=0.27",
|
||||
"pypdf>=4.0",
|
||||
"python-docx>=1.1",
|
||||
"apscheduler>=3.10",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
|
@ -26,4 +29,4 @@ packages = ["app", "app.db"]
|
|||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
pythonpath = [".", "..", "../.."]
|
||||
|
|
@ -40,7 +40,7 @@ def _truncate_tables():
|
|||
"""
|
||||
TRUNCATE TABLE task_run, outbox, approval, artifact,
|
||||
application, job_posting, cv_section, profile
|
||||
CASCADE
|
||||
RESTART IDENTITY CASCADE
|
||||
"""
|
||||
)
|
||||
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:
|
||||
build:
|
||||
context: ./apps/api
|
||||
dockerfile: Dockerfile.test
|
||||
context: .
|
||||
dockerfile: apps/api/Dockerfile.test
|
||||
environment:
|
||||
DATABASE_URL: postgresql://jobhunt:jobhunt@postgres:5432/jobhunt
|
||||
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