From 3035e4eac9f4b8ef498e91fcba09d8973625adc8 Mon Sep 17 00:00:00 2001 From: hermes Date: Thu, 30 Jul 2026 18:29:17 +0000 Subject: [PATCH] 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. --- apps/api/Dockerfile.test | 24 +- apps/api/app/db/repo_app.py | 100 +++++- apps/api/app/llm.py | 60 ++++ apps/api/app/main.py | 493 +++++++++++++++++++++++++- apps/api/app/scheduler.py | 92 +++++ apps/api/app/schemas.py | 104 +++++- apps/api/app/transport.py | 115 +++++- apps/api/migrations/002_followups.sql | 12 + apps/api/pyproject.toml | 5 +- apps/api/tests/conftest.py | 2 +- apps/api/tests/test_cv_import.py | 162 +++++++++ apps/api/tests/test_postings_fetch.py | 137 +++++++ apps/api/tests/test_v1_features.py | 398 +++++++++++++++++++++ docker-compose.yml | 4 +- docs/api-contract-v2.md | 199 +++++++++++ 15 files changed, 1885 insertions(+), 22 deletions(-) create mode 100644 apps/api/app/scheduler.py create mode 100644 apps/api/migrations/002_followups.sql create mode 100644 apps/api/tests/test_cv_import.py create mode 100644 apps/api/tests/test_postings_fetch.py create mode 100644 apps/api/tests/test_v1_features.py create mode 100644 docs/api-contract-v2.md diff --git a/apps/api/Dockerfile.test b/apps/api/Dockerfile.test index 09fbb96..b763aa4 100644 --- a/apps/api/Dockerfile.test +++ b/apps/api/Dockerfile.test @@ -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"] \ No newline at end of file diff --git a/apps/api/app/db/repo_app.py b/apps/api/app/db/repo_app.py index 420e7ab..739bc81 100644 --- a/apps/api/app/db/repo_app.py +++ b/apps/api/app/db/repo_app.py @@ -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, - } \ No newline at end of file + } + + +# --- 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] \ No newline at end of file diff --git a/apps/api/app/llm.py b/apps/api/app/llm.py index cd1b91e..c9c1515 100644 --- a/apps/api/app/llm.py +++ b/apps/api/app/llm.py @@ -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." + ), }, } diff --git a/apps/api/app/main.py b/apps/api/app/main.py index e60a0f3..b5857ee 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -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() \ No newline at end of file + 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, + } \ No newline at end of file diff --git a/apps/api/app/scheduler.py b/apps/api/app/scheduler.py new file mode 100644 index 0000000..45de8bc --- /dev/null +++ b/apps/api/app/scheduler.py @@ -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") \ No newline at end of file diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index f455291..47b746d 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -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] \ No newline at end of file + 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 \ No newline at end of file diff --git a/apps/api/app/transport.py b/apps/api/app/transport.py index 6fe312b..9d5b348 100644 --- a/apps/api/app/transport.py +++ b/apps/api/app/transport.py @@ -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 \ No newline at end of file + _default_transport = t + + +def reset_transport() -> None: + """Reset to default (for testing).""" + global _default_transport + _default_transport = None \ No newline at end of file diff --git a/apps/api/migrations/002_followups.sql b/apps/api/migrations/002_followups.sql new file mode 100644 index 0000000..b0c389b --- /dev/null +++ b/apps/api/migrations/002_followups.sql @@ -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; \ No newline at end of file diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index 1ee74fb..737ee48 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -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 = ["."] \ No newline at end of file +pythonpath = [".", "..", "../.."] \ No newline at end of file diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py index 54262c9..2235f0a 100644 --- a/apps/api/tests/conftest.py +++ b/apps/api/tests/conftest.py @@ -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() diff --git a/apps/api/tests/test_cv_import.py b/apps/api/tests/test_cv_import.py new file mode 100644 index 0000000..74e27f4 --- /dev/null +++ b/apps/api/tests/test_cv_import.py @@ -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()) \ No newline at end of file diff --git a/apps/api/tests/test_postings_fetch.py b/apps/api/tests/test_postings_fetch.py new file mode 100644 index 0000000..8137b62 --- /dev/null +++ b/apps/api/tests/test_postings_fetch.py @@ -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 \ No newline at end of file diff --git a/apps/api/tests/test_v1_features.py b/apps/api/tests/test_v1_features.py new file mode 100644 index 0000000..611c6c6 --- /dev/null +++ b/apps/api/tests/test_v1_features.py @@ -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() \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index c8f3fac..698a2b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/api-contract-v2.md b/docs/api-contract-v2.md new file mode 100644 index 0000000..9cfe40b --- /dev/null +++ b/docs/api-contract-v2.md @@ -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. \ No newline at end of file