Compare commits

...

7 commits

Author SHA1 Message Date
hermes
c69fb128bd W3: add Forgejo CI workflow, user guide, README screenshots placeholder section
Some checks failed
CI / api-tests (push) Failing after 30s
CI / package-tests (push) Failing after 58s
CI / web-tests (push) Failing after 37s
2026-07-30 18:36:51 +00:00
hermes
1e55c11dc9 W3: add 3 new vitest tests (wizard routing, nudge badge render, red flag tooltip); update existing test mocks for new API functions 2026-07-30 18:36:22 +00:00
hermes
f43ede9e07 W3: add red-flag badges + nudge dots to kanban, interview-prep modal to detail, fetch form + scam column to Research 2026-07-30 18:34:42 +00:00
hermes
e9b3b37e0d W3: add TodayView, Welcome wizard, CostDisplay, InterviewPrepModal; update router and App nav 2026-07-30 18:33:36 +00:00
hermes
115fea39f2 W3: add v2 API types and client functions (cv import, postings fetch, batch scoring, today, interview prep, telemetry, demo seed) 2026-07-30 18:32:18 +00:00
hermes
9ee5449acb Merge branch 'feat/W2-api-v1' 2026-07-30 18:29:18 +00:00
hermes
3035e4eac9 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.
2026-07-30 18:29:17 +00:00
34 changed files with 3261 additions and 38 deletions

78
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,78 @@
name: CI
on:
push:
branches: ["*"]
pull_request:
branches: ["*"]
jobs:
api-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker
run: |
docker --version
docker compose version || docker-compose --version
- name: Build and run API tests
run: |
docker compose build api-test
docker compose run --rm api-test
package-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install uv
run: |
pip install uv
- name: Run connectors tests
run: |
cd packages/connectors
uv venv
. .venv/bin/activate
uv pip install -e ".[dev]"
pytest -q
- name: Run llm-gateway tests
run: |
cd packages/llm-gateway
uv venv
. .venv/bin/activate
uv pip install -e ".[dev]"
pytest -q
- name: Run artifacts tests
run: |
cd packages/artifacts
uv venv
. .venv/bin/activate
uv pip install -e ".[dev]"
pytest -q
web-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: apps/web/package-lock.json
- name: Install dependencies
run: |
cd apps/web
npm ci
- name: Build
run: |
cd apps/web
npm run build
- name: Test
run: |
cd apps/web
npm test

View file

@ -57,3 +57,16 @@ Note: in sandboxed Docker-in-Docker environments host port publishing may not wo
## Status
POC scaffolding in progress. See `docs/` for the design.
## Screenshots
Screenshots will be added here as the UI stabilizes.
| View | Description | Screenshot |
|------|-------------|------------|
| Today | Daily digest with top matches, nudge cards, and cost summary | _placeholder_ |
| Onboarding Wizard | Welcome, CV import, postings fetch, done steps | _placeholder_ |
| CV Editor | Profile form, sections list, AI assist, PDF render | _placeholder_ |
| Research | Postings table with fetch form, scam column, and scoring | _placeholder_ |
| Applications Kanban | Drag-and-drop board with red-flag badges and nudge dots | _placeholder_ |
| Application Detail | Posting info, interview prep modal, cover letter, approval gate | _placeholder_ |

View file

@ -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"]

View file

@ -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]

View file

@ -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."
),
},
}

View file

@ -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
View 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")

View file

@ -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

View file

@ -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

View 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;

View file

@ -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 = [".", "..", "../.."]

View file

@ -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()

View 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())

View 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

View 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()

View file

@ -1,6 +1,37 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import { onMounted, ref, watch } from 'vue'
import { RouterLink, RouterView, useRouter } from 'vue-router'
import ToastHost from './components/ToastHost.vue'
import * as api from '@/api'
import type { Profile } from '@/types'
const router = useRouter()
const profile = ref<Profile | null>(null)
const profileChecked = ref(false)
async function checkProfile() {
try {
profile.value = await api.getProfile()
} catch {
// API not available, let normal routing proceed
} finally {
profileChecked.value = true
}
}
onMounted(() => {
checkProfile()
})
// Redirect to /welcome when profile.full_name is empty (onboarding wizard)
watch(profileChecked, (ready) => {
if (ready && profile.value && !profile.value.full_name) {
const currentRoute = router.currentRoute.value
if (currentRoute.name !== 'welcome') {
router.push('/welcome')
}
}
})
</script>
<template>
@ -9,6 +40,7 @@ import ToastHost from './components/ToastHost.vue'
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center gap-6">
<span class="text-lg font-bold text-indigo-700">Jobhunt</span>
<nav class="flex gap-4 text-sm">
<RouterLink to="/today" class="text-gray-600 hover:text-indigo-700">Today</RouterLink>
<RouterLink to="/cv" class="text-gray-600 hover:text-indigo-700">CV</RouterLink>
<RouterLink to="/research" class="text-gray-600 hover:text-indigo-700">Research</RouterLink>
<RouterLink to="/applications" class="text-gray-600 hover:text-indigo-700">Applications</RouterLink>

View file

@ -6,13 +6,21 @@ import type {
Application,
Approval,
Artifact,
BatchScoringResponse,
CoverLetterResponse,
CritiqueComment,
CvImportConfirmResponse,
CvImportResponse,
CvSection,
DemoSeedResponse,
InterviewPrepResponse,
JobPosting,
PostingsFetchResponse,
Profile,
RenderCvResponse,
ScoreResponse
ScoreResponse,
TaskRun,
TodayResponse
} from '@/types'
const API_BASE: string =
@ -154,17 +162,77 @@ export function outboxSend(approvalId: string, payload: Record<string, unknown>)
})
}
// --- Telemetry ---
export function getTelemetryTasks(): Promise<TaskRun[]> {
return request<TaskRun[]>('/telemetry/tasks')
}
// --- v1.0 additions (api-contract-v2.md) ---
export function importCv(filename: string, contentBase64: string): Promise<CvImportResponse> {
return request<CvImportResponse>('/cv/import', {
method: 'POST',
body: JSON.stringify({ filename, content_base64: contentBase64 })
})
}
export function confirmCvImport(drafts: CvImportResponse['drafts']): Promise<CvImportConfirmResponse> {
return request<CvImportConfirmResponse>('/cv/import/confirm', {
method: 'POST',
body: JSON.stringify({ drafts })
})
}
export function fetchPostings(query: string, region?: string): Promise<PostingsFetchResponse> {
const body: Record<string, string> = { query }
if (region) body.region = region
return request<PostingsFetchResponse>('/postings/fetch', {
method: 'POST',
body: JSON.stringify(body)
})
}
export function batchScore(applicationIds: string[]): Promise<BatchScoringResponse> {
return request<BatchScoringResponse>('/scoring/batch', {
method: 'POST',
body: JSON.stringify({ application_ids: applicationIds })
})
}
export function getToday(): Promise<TodayResponse> {
return request<TodayResponse>('/today')
}
export function interviewPrep(applicationId: string): Promise<InterviewPrepResponse> {
return request<InterviewPrepResponse>(`/applications/${applicationId}/interview-prep`, {
method: 'POST'
})
}
export function seedDemo(): Promise<DemoSeedResponse> {
return request<DemoSeedResponse>('/concierge/seed-demo', { method: 'POST' })
}
// Re-export types for convenience
export type {
AiAssistResponse,
Application,
Approval,
Artifact,
BatchScoringResponse,
CoverLetterResponse,
CritiqueComment,
CvImportConfirmResponse,
CvImportResponse,
CvSection,
DemoSeedResponse,
InterviewPrepResponse,
JobPosting,
PostingsFetchResponse,
Profile,
RenderCvResponse,
ScoreResponse
ScoreResponse,
TaskRun,
TodayResponse
}

View file

@ -0,0 +1,55 @@
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import * as api from '@/api'
import type { TaskRun } from '@/types'
const tasks = ref<TaskRun[]>([])
const loading = ref(true)
const error = ref(false)
const totalTokensIn = computed(() =>
tasks.value.reduce((sum, t) => sum + (t.tokens_in ?? 0), 0)
)
const totalTokensOut = computed(() =>
tasks.value.reduce((sum, t) => sum + (t.tokens_out ?? 0), 0)
)
const totalCost = computed(() =>
tasks.value.reduce((sum, t) => sum + (t.cost ?? 0), 0)
)
const hasCost = computed(() => tasks.value.some((t) => t.cost != null))
async function loadTasks() {
try {
tasks.value = await api.getTelemetryTasks()
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(loadTasks)
</script>
<template>
<div class="bg-white rounded-lg border border-gray-200 p-4">
<h3 class="font-semibold text-sm text-gray-700 mb-2">LLM Cost Summary</h3>
<div v-if="loading" class="text-gray-400 text-sm">Loading...</div>
<div v-else-if="error" class="text-red-600 text-sm">Failed to load cost data.</div>
<div v-else class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-gray-600">Tokens in:</span>
<span class="font-medium">{{ totalTokensIn.toLocaleString() }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Tokens out:</span>
<span class="font-medium">{{ totalTokensOut.toLocaleString() }}</span>
</div>
<div v-if="hasCost" class="flex justify-between border-t border-gray-100 pt-1">
<span class="text-gray-600">Total cost:</span>
<span class="font-medium">{{ totalCost.toFixed(4) }}</span>
</div>
<div class="text-xs text-gray-400 mt-1">{{ tasks.length }} task runs</div>
</div>
</div>
</template>

View file

@ -0,0 +1,121 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useToastStore } from '@/stores/toast'
import * as api from '@/api'
import { HttpError } from '@/api'
import type { InterviewPrepResponse } from '@/types'
const props = defineProps<{
applicationId: string
visible: boolean
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const toast = useToastStore()
const loading = ref(false)
const saving = ref(false)
const prepContent = ref('')
const artifactId = ref<string | null>(null)
async function generatePrep() {
loading.value = true
prepContent.value = ''
artifactId.value = null
try {
const res: InterviewPrepResponse = await api.interviewPrep(props.applicationId)
prepContent.value = res.content
artifactId.value = res.artifact_id
toast.push('Interview prep generated', 'success')
} catch (err) {
let msg = 'Failed to generate interview prep'
if (err instanceof HttpError) {
const body = err.body as { error?: { message?: string } } | null
msg = body?.error?.message ?? msg
}
toast.push(msg, 'error')
} finally {
loading.value = false
}
}
async function savePrep() {
if (!prepContent.value.trim()) return
saving.value = true
try {
const res: InterviewPrepResponse = await api.interviewPrep(props.applicationId)
artifactId.value = res.artifact_id
toast.push('Interview prep saved as new version', 'success')
} catch {
toast.push('Failed to save interview prep', 'error')
} finally {
saving.value = false
}
}
function close() {
emit('close')
}
</script>
<template>
<div
v-if="visible"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
@click.self="close"
>
<div class="bg-white rounded-lg shadow-xl max-w-3xl w-full mx-4 max-h-[80vh] flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3">
<h2 class="font-semibold text-lg">Interview Prep</h2>
<button @click="close" class="text-gray-400 hover:text-gray-700 text-xl leading-none">&times;</button>
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto p-4 space-y-4">
<div v-if="!prepContent && !loading" class="text-center py-8">
<p class="text-gray-500 mb-4">
Generate likely interview questions with suggested answers based on your profile and the job posting.
</p>
<button
@click="generatePrep"
class="bg-indigo-600 text-white px-4 py-2 rounded text-sm"
>
Generate Interview Prep
</button>
</div>
<div v-if="loading" class="text-gray-500 text-center py-8">Generating interview prep...</div>
<div v-if="prepContent && !loading" class="space-y-3">
<textarea
v-model="prepContent"
rows="18"
class="w-full border rounded px-3 py-2 text-sm font-mono"
placeholder="Interview prep content..."
></textarea>
<div class="flex gap-3">
<button
@click="savePrep"
:disabled="saving"
class="bg-green-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
>
{{ saving ? 'Saving...' : 'Save as New Version' }}
</button>
<button
@click="generatePrep"
class="text-sm text-indigo-600 hover:underline"
>
Regenerate
</button>
</div>
<div v-if="artifactId" class="text-xs text-gray-400">
Artifact ID: <code>{{ artifactId.slice(0, 8) }}</code>
</div>
</div>
</div>
</div>
</div>
</template>

View file

@ -1,33 +1,51 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
import App from '@/App.vue'
vi.mock('@/api', () => ({
getProfile: vi.fn().mockResolvedValue({ id: 'p1', full_name: 'Test User', email: '', phone: '', location: '', headline: '', summary: '', languages: [], hard_rules: {} }),
HttpError: class HttpError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown, msg?: string) {
super(msg ?? `HTTP ${status}`)
this.status = status
this.body = body
}
}
}))
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', redirect: '/cv' },
{ path: '/', redirect: '/today' },
{ path: '/today', name: 'today', component: { template: '<div>Today</div>' } },
{ path: '/cv', name: 'cv', component: { template: '<div>CV</div>' } },
{ path: '/research', name: 'research', component: { template: '<div>Research</div>' } },
{ path: '/applications', name: 'applications', component: { template: '<div>Applications</div>' } },
{ path: '/applications/:id', name: 'application-detail', component: { template: '<div>Detail</div>' } }
{ path: '/applications/:id', name: 'application-detail', component: { template: '<div>Detail</div>' } },
{ path: '/welcome', name: 'welcome', component: { template: '<div>Welcome</div>' } }
]
})
}
describe('Router tabs', () => {
it('renders all three tab links', async () => {
it('renders all four tab links', async () => {
setActivePinia(createPinia())
const router = makeRouter()
await router.push('/cv')
await router.push('/today')
await router.isReady()
const wrapper = mount(App, { global: { plugins: [router] } })
const links = wrapper.findAll('nav a')
expect(links).toHaveLength(3)
expect(links[0].text()).toBe('CV')
expect(links[1].text()).toBe('Research')
expect(links[2].text()).toBe('Applications')
await vi.waitFor(() => {
const links = wrapper.findAll('nav a')
expect(links).toHaveLength(4)
expect(links[0].text()).toBe('Today')
expect(links[1].text()).toBe('CV')
expect(links[2].text()).toBe('Research')
expect(links[3].text()).toBe('Applications')
})
})
})

View file

@ -2,7 +2,17 @@ import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{ path: '/', redirect: '/cv' },
{ path: '/', redirect: '/today' },
{
path: '/welcome',
name: 'welcome',
component: () => import('@/views/Welcome.vue')
},
{
path: '/today',
name: 'today',
component: () => import('@/views/TodayView.vue')
},
{
path: '/cv',
name: 'cv',

View file

@ -125,4 +125,87 @@ export interface AiAssistResponse {
export interface ApiError {
error: { code: string; message: string }
}
// --- v1.0 additions (api-contract-v2.md) ---
export interface CvDraft {
kind: CvSectionKind
title: string
org: string
location: string
start_date: string
end_date: string | null
bullets: string[]
tags: string[]
}
export interface CvImportResponse {
drafts: CvDraft[]
}
export interface CvImportConfirmResponse {
created: number
sections: CvSection[]
}
export interface PostingsFetchResponse {
new: number
dupes: number
}
export interface BatchScoringResult {
application_id: string
score: number
rationale: Record<string, unknown>
red_flags: string[]
}
export interface BatchScoringResponse {
results: BatchScoringResult[]
}
export interface TodayDigestItem {
application_id: string
title: string
company: string
score: number
}
export interface TodayNudge {
application_id: string
days_since_sent: number
suggestion: string
}
export interface TodayResponse {
digest: TodayDigestItem[]
nudges: TodayNudge[]
pending_approvals: number
}
export interface InterviewPrepResponse {
artifact_id: string
content: string
}
export interface DemoSeedResponse {
profile: string
postings: number
applications: number
sections: number
}
export interface TaskRun {
id: string
task_type: string
model: string
tokens_in: number
tokens_out: number
cost: number | null
created_at: string
}
export interface RedFlagsMap {
[applicationId: string]: string[]
}

View file

@ -12,6 +12,7 @@ vi.mock('@/api', () => ({
confirmApproval: vi.fn(),
rejectApproval: vi.fn(),
outboxSend: vi.fn(),
interviewPrep: vi.fn(),
HttpError: class HttpError extends Error {
status: number
body: unknown

View file

@ -3,6 +3,7 @@ import { onMounted, ref, computed } from 'vue'
import { useToastStore } from '@/stores/toast'
import * as api from '@/api'
import { HttpError } from '@/api'
import InterviewPrepModal from '@/components/InterviewPrepModal.vue'
import type {
Application,
Artifact,
@ -32,6 +33,9 @@ const requestingApproval = ref(false)
const confirming = ref(false)
const sending = ref(false)
// Interview prep modal
const showPrepModal = ref(false)
const isConfirmed = computed(() => approval.value?.confirmed_by_user ?? false)
const canSend = computed(() => isConfirmed.value && !sending.value)
@ -127,6 +131,14 @@ async function sendOutbox() {
}
}
function openInterviewPrep() {
showPrepModal.value = true
}
function closeInterviewPrep() {
showPrepModal.value = false
}
onMounted(loadData)
</script>
@ -147,6 +159,20 @@ onMounted(loadData)
</div>
</section>
<!-- Interview prep -->
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
<h2 class="font-semibold">Interview Prep</h2>
<p class="text-sm text-gray-600">
Generate likely interview questions with suggested answers based on your profile and the posting.
</p>
<button
@click="openInterviewPrep"
class="bg-purple-600 text-white px-4 py-2 rounded text-sm"
>
Open Interview Prep
</button>
</section>
<!-- Artifacts list -->
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-2">
<h2 class="font-semibold">Artifacts</h2>
@ -247,5 +273,12 @@ onMounted(loadData)
</template>
<div v-if="!loading && !application" class="text-gray-500">Application not found.</div>
<!-- Interview prep modal -->
<InterviewPrepModal
:application-id="id"
:visible="showPrepModal"
@close="closeInterviewPrep"
/>
</div>
</template>

View file

@ -0,0 +1,112 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import Applications from '@/views/Applications.vue'
import type { Application } from '@/types'
vi.mock('@/api', () => ({
getApplications: vi.fn(),
transitionApplication: vi.fn(),
batchScore: vi.fn(),
getToday: vi.fn(),
HttpError: class HttpError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown, msg?: string) {
super(msg ?? `HTTP ${status}`)
this.status = status
this.body = body
}
}
}))
function makeApp(id: string, state: string, company: string): Application {
return {
id,
job_posting_id: 'j-' + id,
state: state as Application['state'],
score: 80,
score_rationale: null,
notes: '',
state_changed_at: '2026-01-01T00:00:00Z',
created_at: '2026-01-01T00:00:00Z',
posting: {
id: 'j-' + id, source: 'manual_url', external_id: null, url: 'http://x',
company, title: 'Engineer', location: 'Remote', description: '',
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
}
}
}
describe('Applications kanban badges', () => {
it('renders nudge dot on cards that have a follow-up nudge', async () => {
setActivePinia(createPinia())
const api = await import('@/api')
const apps = [makeApp('app-1', 'sent', 'NudgeCorp'), makeApp('app-2', 'discovered', 'NoNudge')]
;(api.getApplications as ReturnType<typeof vi.fn>).mockResolvedValue(apps)
;(api.batchScore as ReturnType<typeof vi.fn>).mockResolvedValue({ results: [] })
;(api.getToday as ReturnType<typeof vi.fn>).mockResolvedValue({
digest: [],
nudges: [
{ application_id: 'app-1', days_since_sent: 9, suggestion: 'Send a follow-up email' }
],
pending_approvals: 0
})
const wrapper = mount(Applications)
await vi.waitFor(() => {
expect(wrapper.text()).toContain('NudgeCorp')
})
// The nudge dot should be rendered as an orange dot (span with bg-orange-500)
const dots = wrapper.findAll('.bg-orange-500')
expect(dots.length).toBeGreaterThanOrEqual(1)
// Verify the dot is in the card for NudgeCorp (the sent column)
const sentCol = wrapper.findAll('.font-semibold').find((el) => el.text() === 'sent')
expect(sentCol).toBeTruthy()
const sentColumn = sentCol!.element.parentElement!
expect(sentColumn.textContent).toContain('NudgeCorp')
// The dot should be inside this column
expect(sentColumn.querySelector('.bg-orange-500')).toBeTruthy()
})
it('renders red flag badge with tooltip text from batch scoring results', async () => {
setActivePinia(createPinia())
const api = await import('@/api')
const apps = [makeApp('app-1', 'discovered', 'ScamCorp')]
;(api.getApplications as ReturnType<typeof vi.fn>).mockResolvedValue(apps)
;(api.batchScore as ReturnType<typeof vi.fn>).mockResolvedValue({
results: [
{
application_id: 'app-1',
score: 30,
rationale: {},
red_flags: ['Unpaid trial period mentioned', 'Asks for bank details upfront']
}
]
})
;(api.getToday as ReturnType<typeof vi.fn>).mockResolvedValue({
digest: [],
nudges: [],
pending_approvals: 0
})
const wrapper = mount(Applications)
await vi.waitFor(() => {
expect(wrapper.text()).toContain('ScamCorp')
})
// The warning symbol should be rendered in the card
const warningEl = wrapper.find('.text-red-600.font-bold')
expect(warningEl.exists()).toBe(true)
// The title attribute should contain the red flag text
const title = warningEl.attributes('title')
expect(title).toBeTruthy()
expect(title).toContain('Unpaid trial period mentioned')
expect(title).toContain('Asks for bank details upfront')
})
})

View file

@ -7,6 +7,8 @@ import type { Application } from '@/types'
vi.mock('@/api', () => ({
getApplications: vi.fn(),
transitionApplication: vi.fn(),
batchScore: vi.fn().mockResolvedValue({ results: [] }),
getToday: vi.fn().mockResolvedValue({ digest: [], nudges: [], pending_approvals: 0 }),
HttpError: class HttpError extends Error {
status: number
body: unknown

View file

@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
import { useToastStore } from '@/stores/toast'
import * as api from '@/api'
import { HttpError } from '@/api'
import type { Application, ApplicationState } from '@/types'
import type { Application, ApplicationState, TodayNudge } from '@/types'
const toast = useToastStore()
const router = useRouter()
@ -14,6 +14,10 @@ const loading = ref(true)
const draggingId = ref<string | null>(null)
const draggingFrom = ref<ApplicationState | null>(null)
// Red flags and nudges
const redFlagsMap = ref<Record<string, string[]>>({})
const nudgeIds = ref<Set<string>>(new Set())
const states: ApplicationState[] = [
'discovered',
'scored',
@ -31,9 +35,39 @@ function appsInState(state: ApplicationState): Application[] {
return applications.value.filter((a) => a.state === state)
}
function hasRedFlags(app: Application): boolean {
const flags = redFlagsMap.value[app.id]
return flags != null && flags.length > 0
}
function redFlagsFor(app: Application): string[] {
return redFlagsMap.value[app.id] ?? []
}
function hasNudge(app: Application): boolean {
return nudgeIds.value.has(app.id)
}
async function loadApplications() {
try {
applications.value = await api.getApplications()
// Load red flags via batch scoring and nudges via today endpoint
const [batchResult, todayResult] = await Promise.allSettled([
api.batchScore(applications.value.map((a) => a.id)),
api.getToday()
])
if (batchResult.status === 'fulfilled') {
const map: Record<string, string[]> = {}
for (const r of batchResult.value.results) {
if (r.red_flags && r.red_flags.length > 0) {
map[r.application_id] = r.red_flags
}
}
redFlagsMap.value = map
}
if (todayResult.status === 'fulfilled') {
nudgeIds.value = new Set(todayResult.value.nudges.map((n: TodayNudge) => n.application_id))
}
} catch {
toast.push('Failed to load applications', 'error')
} finally {
@ -117,7 +151,21 @@ onMounted(loadApplications)
@click="goToDetail(app)"
class="bg-white rounded border border-gray-200 p-2 cursor-pointer hover:shadow-md transition-shadow"
>
<div class="font-medium text-sm truncate">{{ app.posting?.company ?? 'Unknown' }}</div>
<div class="flex items-center gap-1">
<span
v-if="hasRedFlags(app)"
class="text-red-600 font-bold text-sm flex-shrink-0"
:title="redFlagsFor(app).join('; ')"
>
&#x26A0;
</span>
<span
v-if="hasNudge(app)"
class="inline-block w-2 h-2 rounded-full bg-orange-500 flex-shrink-0"
title="Follow-up nudge pending"
></span>
<div class="font-medium text-sm truncate">{{ app.posting?.company ?? 'Unknown' }}</div>
</div>
<div class="text-xs text-gray-500 truncate">{{ app.posting?.title ?? 'No title' }}</div>
<div v-if="app.score != null" class="text-xs text-green-700 mt-1">
Score: {{ app.score }}

View file

@ -2,6 +2,7 @@
import { onMounted, ref } from 'vue'
import { useToastStore } from '@/stores/toast'
import * as api from '@/api'
import { HttpError } from '@/api'
import type { JobPosting } from '@/types'
const toast = useToastStore()
@ -11,10 +12,32 @@ const loading = ref(true)
const newUrl = ref('')
const scoringId = ref<string | null>(null)
const scoreMap = ref<Record<string, { score: number; rationale: Record<string, unknown> }>>({})
const redFlagsMap = ref<Record<string, string[]>>({})
// Fetch form
const fetchQuery = ref('')
const fetchRegion = ref('')
const fetching = ref(false)
const fetchResult = ref<{ new: number; dupes: number } | null>(null)
async function loadPostings() {
try {
postings.value = await api.getPostings()
// Load red flags for existing postings via batch scoring
if (postings.value.length > 0) {
try {
const batch = await api.batchScore(postings.value.map((p) => p.id))
const map: Record<string, string[]> = {}
for (const r of batch.results) {
if (r.red_flags && r.red_flags.length > 0) {
map[r.application_id] = r.red_flags
}
}
redFlagsMap.value = map
} catch {
// batch scoring is optional; ignore errors
}
}
} catch {
toast.push('Failed to load postings', 'error')
} finally {
@ -34,6 +57,27 @@ async function addPosting() {
}
}
async function doFetch() {
if (!fetchQuery.value.trim()) return
fetching.value = true
fetchResult.value = null
try {
fetchResult.value = await api.fetchPostings(fetchQuery.value.trim(), fetchRegion.value.trim() || undefined)
toast.push(`Fetched ${fetchResult.value.new} new postings`, 'success')
// Reload postings to show new ones
await loadPostings()
} catch (err) {
let msg = 'Fetch failed'
if (err instanceof HttpError) {
const body = err.body as { error?: { message?: string } } | null
msg = body?.error?.message ?? msg
}
toast.push(msg, 'error')
} finally {
fetching.value = false
}
}
async function scorePosting(p: JobPosting) {
scoringId.value = p.id
try {
@ -47,6 +91,15 @@ async function scorePosting(p: JobPosting) {
}
}
function hasScamFlag(p: JobPosting): boolean {
const flags = redFlagsMap.value[p.id]
return flags != null && flags.length > 0
}
function scamFlagsFor(p: JobPosting): string[] {
return redFlagsMap.value[p.id] ?? []
}
onMounted(loadPostings)
</script>
@ -54,6 +107,37 @@ onMounted(loadPostings)
<div class="space-y-6">
<h1 class="text-2xl font-bold">Research</h1>
<!-- Fetch form (Arbetsformedlingen connector) -->
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
<h2 class="font-semibold">Fetch from Arbetsformedlingen</h2>
<div class="flex gap-2">
<input
v-model="fetchQuery"
placeholder="Search query (e.g. python developer)"
class="flex-1 border rounded px-2 py-1"
@keyup.enter="doFetch"
/>
<input
v-model="fetchRegion"
placeholder="Region (optional)"
class="w-48 border rounded px-2 py-1"
@keyup.enter="doFetch"
/>
<button
@click="doFetch"
:disabled="fetching"
class="bg-indigo-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
>
{{ fetching ? 'Fetching...' : 'Fetch Postings' }}
</button>
</div>
<div v-if="fetchResult" class="text-sm">
<span class="text-green-700 font-medium">{{ fetchResult.new }} new</span>,
<span class="text-gray-500">{{ fetchResult.dupes }} duplicates</span>
</div>
</section>
<!-- Manual URL add -->
<section class="bg-white rounded-lg border border-gray-200 p-4 flex gap-2">
<input
v-model="newUrl"
@ -73,6 +157,7 @@ onMounted(loadPostings)
<th class="px-3 py-2">Title</th>
<th class="px-3 py-2">Location</th>
<th class="px-3 py-2">Source</th>
<th class="px-3 py-2">Scam</th>
<th class="px-3 py-2">Fetched</th>
<th class="px-3 py-2">Actions</th>
</tr>
@ -83,6 +168,16 @@ onMounted(loadPostings)
<td class="px-3 py-2">{{ p.title }}</td>
<td class="px-3 py-2">{{ p.location }}</td>
<td class="px-3 py-2">{{ p.source }}</td>
<td class="px-3 py-2">
<span
v-if="hasScamFlag(p)"
class="text-red-600 font-bold"
:title="scamFlagsFor(p).join('; ')"
>
&#x26A0;
</span>
<span v-else class="text-gray-400">-</span>
</td>
<td class="px-3 py-2 text-gray-500">{{ p.fetched_at?.slice(0, 10) }}</td>
<td class="px-3 py-2">
<button

View file

@ -0,0 +1,122 @@
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useToastStore } from '@/stores/toast'
import * as api from '@/api'
import CostDisplay from '@/components/CostDisplay.vue'
import type { TodayResponse } from '@/types'
const toast = useToastStore()
const router = useRouter()
const today = ref<TodayResponse | null>(null)
const loading = ref(true)
const nudgeIds = computed(() => new Set(today.value?.nudges.map((n) => n.application_id) ?? []))
async function loadToday() {
try {
today.value = await api.getToday()
} catch {
toast.push('Failed to load today digest', 'error')
} finally {
loading.value = false
}
}
function goToApplication(id: string) {
router.push(`/applications/${id}`)
}
function copyNudge(suggestion: string) {
if (navigator.clipboard) {
navigator.clipboard.writeText(suggestion).then(
() => toast.push('Follow-up draft copied to clipboard', 'success'),
() => toast.push('Copy failed', 'error')
)
} else {
toast.push('Clipboard not available', 'error')
}
}
onMounted(loadToday)
</script>
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold">Today</h1>
<div v-if="loading" class="text-gray-500">Loading...</div>
<template v-if="!loading && today">
<!-- Pending approvals banner -->
<div v-if="today.pending_approvals > 0" class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<span class="font-medium text-yellow-800">
{{ today.pending_approvals }} pending approval{{ today.pending_approvals > 1 ? 's' : '' }} waiting for you.
</span>
</div>
<!-- Digest cards -->
<section>
<h2 class="font-semibold text-lg mb-3">Top Matches Today</h2>
<div v-if="today.digest.length === 0" class="text-gray-400 text-sm">No postings in your digest yet.</div>
<div v-else class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="item in today.digest"
:key="item.application_id"
class="bg-white rounded-lg border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow"
@click="goToApplication(item.application_id)"
>
<div class="font-medium">{{ item.title }}</div>
<div class="text-sm text-gray-600">{{ item.company }}</div>
<div class="mt-2 flex items-center gap-2">
<span class="text-xs bg-green-100 text-green-800 rounded px-2 py-0.5 font-medium">
Score: {{ item.score }}
</span>
<span
v-if="nudgeIds.has(item.application_id)"
class="inline-block w-2 h-2 rounded-full bg-orange-500"
title="Follow-up nudge pending"
></span>
</div>
</div>
</div>
</section>
<!-- Nudge cards -->
<section>
<h2 class="font-semibold text-lg mb-3">Follow-up Nudges</h2>
<div v-if="today.nudges.length === 0" class="text-gray-400 text-sm">No nudges. You are up to date.</div>
<div v-else class="space-y-3">
<div
v-for="nudge in today.nudges"
:key="nudge.application_id"
class="bg-orange-50 border border-orange-200 rounded-lg p-4"
>
<div class="flex items-center justify-between">
<span class="font-medium text-orange-900">
Sent {{ nudge.days_since_sent }} days ago
</span>
<button
class="text-sm text-indigo-600 hover:underline"
@click="goToApplication(nudge.application_id)"
>
Open application
</button>
</div>
<p class="text-sm text-gray-700 mt-2">{{ nudge.suggestion }}</p>
<button
class="mt-2 text-sm bg-indigo-600 text-white px-3 py-1 rounded hover:bg-indigo-700"
@click="copyNudge(nudge.suggestion)"
>
Copy follow-up draft
</button>
</div>
</div>
</section>
<!-- Cost display -->
<CostDisplay />
</template>
</div>
</template>

View file

@ -0,0 +1,70 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
import Welcome from '@/views/Welcome.vue'
vi.mock('@/api', () => ({
importCv: vi.fn(),
confirmCvImport: vi.fn(),
fetchPostings: vi.fn(),
HttpError: class HttpError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown, msg?: string) {
super(msg ?? `HTTP ${status}`)
this.status = status
this.body = body
}
}
}))
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/welcome', name: 'welcome', component: Welcome },
{ path: '/today', name: 'today', component: { template: '<div>Today</div>' } }
]
})
}
describe('Onboarding wizard', () => {
it('shows welcome step and advances through steps to finish', async () => {
setActivePinia(createPinia())
const router = makeRouter()
await router.push('/welcome')
await router.isReady()
const wrapper = mount(Welcome, { global: { plugins: [router] } })
// Step 0: Welcome
expect(wrapper.text()).toContain('Welcome to Jobhunt')
expect(wrapper.text()).toContain('Get Started')
// Advance to step 1 (Import CV)
const getStartedBtn = wrapper.find('button')
await getStartedBtn.trigger('click')
expect(wrapper.text()).toContain('Import Your CV')
// Skip import -> step 2 (Fetch Postings)
const skipLink = wrapper.findAll('button').find((b) => b.text().includes('Skip for now'))
expect(skipLink).toBeTruthy()
await skipLink!.trigger('click')
expect(wrapper.text()).toContain('Fetch Job Postings')
// Continue -> step 3 (Done)
const continueBtn = wrapper.findAll('button').find((b) => b.text().includes('Continue'))
expect(continueBtn).toBeTruthy()
await continueBtn!.trigger('click')
expect(wrapper.text()).toContain('You are all set')
// Finish -> navigates to /today
const finishBtn = wrapper.findAll('button').find((b) => b.text().includes('Go to Today'))
expect(finishBtn).toBeTruthy()
await finishBtn!.trigger('click')
await vi.waitFor(() => {
expect(router.currentRoute.value.path).toBe('/today')
})
})
})

View file

@ -0,0 +1,280 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useToastStore } from '@/stores/toast'
import * as api from '@/api'
import { HttpError } from '@/api'
import type { CvDraft, PostingsFetchResponse } from '@/types'
const toast = useToastStore()
const router = useRouter()
const step = ref(0)
const steps = ['Welcome', 'Import CV', 'Fetch Postings', 'Done']
// Step 1: Import CV
const selectedFile = ref<File | null>(null)
const importing = ref(false)
const drafts = ref<CvDraft[]>([])
const importError = ref('')
// Step 2: Fetch Postings
const fetchQuery = ref('')
const fetchRegion = ref('')
const fetching = ref(false)
const fetchResult = ref<PostingsFetchResponse | null>(null)
function onFileChange(e: Event) {
const target = e.target as HTMLInputElement
if (target.files && target.files.length > 0) {
selectedFile.value = target.files[0]
importError.value = ''
}
}
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
const base64 = result.split(',')[1] ?? ''
resolve(base64)
}
reader.onerror = () => reject(new Error('Failed to read file'))
reader.readAsDataURL(file)
})
}
async function doImport() {
if (!selectedFile.value) {
importError.value = 'Please select a file first.'
return
}
importing.value = true
importError.value = ''
try {
const base64 = await fileToBase64(selectedFile.value)
const res = await api.importCv(selectedFile.value.name, base64)
drafts.value = res.drafts
if (drafts.value.length === 0) {
importError.value = 'No sections were extracted from this file.'
}
} catch (err) {
if (err instanceof HttpError) {
const body = err.body as { error?: { message?: string } } | null
importError.value = body?.error?.message ?? 'Import failed'
} else {
importError.value = 'Import failed'
}
} finally {
importing.value = false
}
}
async function confirmDrafts() {
importing.value = true
try {
await api.confirmCvImport(drafts.value)
toast.push('CV sections saved', 'success')
step.value = 2
} catch (err) {
let msg = 'Failed to save CV sections'
if (err instanceof HttpError) {
const body = err.body as { error?: { message?: string } } | null
msg = body?.error?.message ?? msg
}
toast.push(msg, 'error')
} finally {
importing.value = false
}
}
function skipImport() {
step.value = 2
}
async function doFetch() {
if (!fetchQuery.value.trim()) {
toast.push('Enter a search query', 'error')
return
}
fetching.value = true
fetchResult.value = null
try {
fetchResult.value = await api.fetchPostings(fetchQuery.value.trim(), fetchRegion.value.trim() || undefined)
toast.push(`Fetched ${fetchResult.value.new} new postings`, 'success')
} catch (err) {
let msg = 'Fetch failed'
if (err instanceof HttpError) {
const body = err.body as { error?: { message?: string } } | null
msg = body?.error?.message ?? msg
}
toast.push(msg, 'error')
} finally {
fetching.value = false
}
}
function finish() {
router.push('/today')
}
function next() {
if (step.value < steps.length - 1) step.value++
}
function prev() {
if (step.value > 0) step.value--
}
</script>
<template>
<div class="max-w-2xl mx-auto space-y-6">
<h1 class="text-2xl font-bold">Welcome to Jobhunt</h1>
<!-- Step indicator -->
<div class="flex items-center gap-2 text-sm">
<span
v-for="(s, i) in steps"
:key="s"
:class="[
'px-3 py-1 rounded-full',
i === step ? 'bg-indigo-600 text-white' : i < step ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'
]"
>
{{ i + 1 }}. {{ s }}
</span>
</div>
<!-- Step 0: Welcome -->
<div v-if="step === 0" class="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
<p class="text-gray-700">
Jobhunt helps you discover jobs, score them against your profile, draft application material, and prepare for interviews.
You stay in control: nothing is sent without your explicit approval.
</p>
<p class="text-gray-700">
Let's set up your profile in a few quick steps. You can skip any step and come back later.
</p>
<button @click="next" class="bg-indigo-600 text-white px-4 py-2 rounded text-sm">
Get Started
</button>
</div>
<!-- Step 1: Import CV -->
<div v-if="step === 1" class="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
<h2 class="font-semibold text-lg">Import Your CV</h2>
<p class="text-sm text-gray-600">
Upload a PDF, DOCX, or plain text file. We will extract sections for you to review and confirm.
</p>
<input
type="file"
accept=".pdf,.docx,.txt"
@change="onFileChange"
class="block text-sm text-gray-700"
/>
<div v-if="importError" class="text-red-600 text-sm">{{ importError }}</div>
<button
v-if="drafts.length === 0"
@click="doImport"
:disabled="importing || !selectedFile"
class="bg-indigo-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
>
{{ importing ? 'Importing...' : 'Extract Sections' }}
</button>
<!-- Drafts review -->
<div v-if="drafts.length > 0" class="space-y-3">
<h3 class="font-medium text-sm">Review extracted sections ({{ drafts.length }})</h3>
<div
v-for="(draft, i) in drafts"
:key="i"
class="border border-gray-200 rounded p-3 text-sm"
>
<div class="font-medium">{{ draft.title }} ({{ draft.kind }})</div>
<div class="text-gray-500">{{ draft.org }}{{ draft.location ? ' - ' + draft.location : '' }}</div>
<ul v-if="draft.bullets.length" class="list-disc ml-5 text-gray-600 mt-1">
<li v-for="(b, bi) in draft.bullets" :key="bi">{{ b }}</li>
</ul>
<div v-if="draft.tags.length" class="flex flex-wrap gap-1 mt-1">
<span v-for="t in draft.tags" :key="t" class="text-xs bg-gray-100 rounded px-2 py-0.5">{{ t }}</span>
</div>
</div>
<div class="flex gap-3">
<button
@click="confirmDrafts"
:disabled="importing"
class="bg-green-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
>
{{ importing ? 'Saving...' : 'Confirm & Save Sections' }}
</button>
<button @click="skipImport" class="text-sm text-gray-500 hover:underline">
Skip for now
</button>
</div>
</div>
<button v-if="drafts.length === 0" @click="skipImport" class="text-sm text-gray-500 hover:underline block">
Skip for now
</button>
</div>
<!-- Step 2: Fetch Postings -->
<div v-if="step === 2" class="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
<h2 class="font-semibold text-lg">Fetch Job Postings</h2>
<p class="text-sm text-gray-600">
Search for job postings from the Arbetsformedlingen connector. New postings will be added to your applications.
</p>
<div class="space-y-2">
<label class="block">
<span class="text-sm text-gray-600">Search query</span>
<input
v-model="fetchQuery"
placeholder="e.g. python developer"
class="w-full border rounded px-2 py-1 mt-1"
@keyup.enter="doFetch"
/>
</label>
<label class="block">
<span class="text-sm text-gray-600">Region (optional)</span>
<input
v-model="fetchRegion"
placeholder="e.g. Skane lan"
class="w-full border rounded px-2 py-1 mt-1"
@keyup.enter="doFetch"
/>
</label>
</div>
<button
@click="doFetch"
:disabled="fetching"
class="bg-indigo-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
>
{{ fetching ? 'Fetching...' : 'Fetch Postings' }}
</button>
<div v-if="fetchResult" class="text-sm">
<span class="text-green-700 font-medium">{{ fetchResult.new }} new</span>
postings found, <span class="text-gray-500">{{ fetchResult.dupes }} duplicates</span> skipped.
</div>
<button @click="next" class="text-sm text-indigo-600 hover:underline block">
Continue
</button>
</div>
<!-- Step 3: Done -->
<div v-if="step === 3" class="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
<h2 class="font-semibold text-lg">You are all set!</h2>
<p class="text-gray-700">
Your profile is ready. Head to the Today page to see your daily digest, nudges, and pending approvals.
</p>
<button @click="finish" class="bg-indigo-600 text-white px-4 py-2 rounded text-sm">
Go to Today
</button>
</div>
<!-- Navigation -->
<div v-if="step > 0 && step < 3" class="flex gap-3">
<button @click="prev" class="text-sm text-gray-500 hover:underline">Back</button>
</div>
</div>
</template>

View file

@ -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
View 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.

119
docs/user-guide.md Normal file
View file

@ -0,0 +1,119 @@
# User Guide
A practical guide for getting started with Jobhunt Platform. Written for first-time users who want to land a job quickly.
## Installation
### Prerequisites
- Docker and Docker Compose installed on your machine
- Node.js 22 (for the web frontend, only needed if running the dev server on the host)
### Quick start
1. Clone the repository and enter the project directory.
2. Copy the environment template: `cp .env.example .env`
3. Start the database and run the API tests:
```bash
docker compose run --rm api-test
```
4. Start the API server:
```bash
docker rm -f jobhunt-api 2>/dev/null
docker compose run -d --name jobhunt-api \
--entrypoint "uvicorn app.main:app --host 0.0.0.0 --port 8000" api-test
```
5. In a separate terminal, start the web frontend:
```bash
cd apps/web
npm install
VITE_API_BASE=http://localhost:8000/api npm run dev
```
6. Open the web app in your browser (typically http://localhost:5173).
You do NOT need any LLM API keys. The system runs in mock mode by default, which produces deterministic outputs. Add a key to `.env` only when you want real AI responses.
## First run: the onboarding wizard
When you open the app for the first time, the onboarding wizard appears at `/welcome`. It has four steps:
1. **Welcome** - a short introduction to the platform.
2. **Import CV** - upload a PDF, DOCX, or plain text file of your existing CV. The system extracts sections (experience, education, skills, projects) for you to review. You can edit them before confirming. If you do not have a CV file handy, you can skip this step and add sections manually later in the CV tab.
3. **Fetch Postings** - search for job postings from the Arbetsformedlingen connector. Enter a search query (for example "python developer") and optionally a region. New postings are added to your applications as "discovered". You can skip this step too.
4. **Done** - you are ready to go. Click "Go to Today" to see your dashboard.
## The Today page
The Today page (`/today`) is your daily dashboard. It shows three things:
- **Top Matches** - ranked job postings scored against your profile. Click a card to open the application detail page.
- **Follow-up Nudges** - applications you sent more than 7 days ago with no reply. Each nudge includes a suggested follow-up message. Click "Copy follow-up draft" to copy the text to your clipboard, then paste it into your email client.
- **Pending Approvals** - a count of outgoing actions (emails, submissions) waiting for your confirmation.
At the bottom of the Today page, the **Cost Summary** shows total tokens used (input and output) and the total cost if pricing is configured. This helps you track your LLM spending.
## CV editor
The CV tab lets you manage your profile and CV sections. You can:
- Edit your name, email, phone, location, headline, and summary.
- Add, edit, and delete sections (experience, education, skills, projects, other).
- Use AI Assist to get suggestions for improving bullet points.
- Render your CV to a PDF for download.
## Research
The Research tab has two ways to find job postings:
1. **Fetch from Arbetsformedlingen** - enter a search query and optional region to pull postings from the official Swedish public employment service. New postings are created automatically.
2. **Add by URL** - paste any job posting URL to add it manually.
The postings table includes a **Scam** column. If the scoring system detects red flags (such as unpaid trial periods or requests for personal financial data), a warning symbol appears with a tooltip listing the specific concerns.
Click "Score" on any posting to run the scoring rubric against your profile. The score appears as a green badge.
## Applications (kanban)
The Applications tab shows all your job applications as cards on a kanban board, organized by state:
- discovered, scored, approved, drafting, sent, interviewing, offer, closed, rejected, expired
You can drag cards between columns to change their state. The board enforces valid transitions (some moves are not allowed and will be rejected).
Two visual indicators appear on cards:
- **Red flag badge** (warning symbol) - the scoring system detected potential scam or fraud indicators. Hover over the symbol to see the specific red flags.
- **Nudge dot** (orange dot) - this application has a follow-up nudge, meaning you sent it more than 7 days ago without a reply. Visit the Today page for the suggested follow-up message.
## Application detail
Click any application card to open its detail page. Here you can:
- View the posting information, current state, and score.
- **Interview Prep** - click "Open Interview Prep" to generate likely interview questions with suggested answers based on your profile and the job posting. The content is saved as an artifact. You can edit the text and save a new version, or regenerate it.
- View all artifacts (cover letters, CVs, interview prep, etc.).
- Write and save a cover letter. The system provides an AI critique with severity-tagged suggestions.
- Request approval, confirm it, and send. The approval gate ensures nothing is sent without your explicit confirmation. The system verifies the artifact hash before sending.
## Costs
Every LLM call (scoring, extraction, critique, interview prep) is tracked. The Cost Summary on the Today page shows:
- Total tokens consumed (input and output)
- Total cost (if pricing is configured)
- Number of task runs
This transparency helps you make informed decisions about when to use AI features, especially if you are counting kronor.
## Tips
- Run without API keys first to explore the platform with mock data. Use `POST /concierge/seed-demo` (via curl or the API) to populate demo data instantly.
- Check the Today page daily for new nudges and digest items.
- Always review AI-generated content before sending. The system assists you, but you are the decision maker.
- Use the scam/red-flag indicators to avoid suspicious postings.