Compare commits

..

No commits in common. "master" and "feat/WB2-web-v11-final" have entirely different histories.

23 changed files with 155 additions and 997 deletions

View file

@ -1,7 +1,6 @@
# ---- Database ---- # ---- Database ----
# Used by apps/api to connect to the postgres service defined in docker-compose.yml. # Used by apps/api to connect to the postgres service defined in docker-compose.yml.
# Postgres is not published to the host; all services run inside the compose network. DATABASE_URL=postgresql://jobhunt:***@localhost:5433/jobhunt
DATABASE_URL=postgresql://jobhunt:***@postgres:5432/jobhunt
# ---- LLM Gateway ---- # ---- LLM Gateway ----
# Primary provider (default: GLM-5.2 via ollama-cloud). # Primary provider (default: GLM-5.2 via ollama-cloud).

View file

@ -11,12 +11,7 @@ jobs:
api-tests: api-tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - uses: actions/checkout@v4
run: |
git init
git remote add origin https://x-access-token:${FORGEJO_TOKEN}@srvr.nu/git/hermes/jobhunt-platform.git
git fetch --depth 1 origin ${GITHUB_SHA}
git checkout FETCH_HEAD
- name: Set up Docker - name: Set up Docker
run: | run: |
docker --version docker --version
@ -29,12 +24,7 @@ jobs:
package-tests: package-tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - uses: actions/checkout@v4
run: |
git init
git remote add origin https://x-access-token:${FORGEJO_TOKEN}@srvr.nu/git/hermes/jobhunt-platform.git
git fetch --depth 1 origin ${GITHUB_SHA}
git checkout FETCH_HEAD
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
@ -63,27 +53,17 @@ jobs:
. .venv/bin/activate . .venv/bin/activate
uv pip install -e ".[dev]" uv pip install -e ".[dev]"
pytest -q pytest -q
- name: Run matching tests
run: |
cd packages/matching
uv venv
. .venv/bin/activate
uv pip install -e ".[dev]"
pytest -q
web-tests: web-tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - uses: actions/checkout@v4
run: |
git init
git remote add origin https://x-access-token:${FORGEJO_TOKEN}@srvr.nu/git/hermes/jobhunt-platform.git
git fetch --depth 1 origin ${GITHUB_SHA}
git checkout FETCH_HEAD
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "22" node-version: "22"
cache: npm
cache-dependency-path: apps/web/package-lock.json
- name: Install dependencies - name: Install dependencies
run: | run: |
cd apps/web cd apps/web

View file

@ -1,145 +0,0 @@
name: Deploy to Production
on:
workflow_dispatch:
inputs:
version:
description: 'Leave as "auto" to bump from latest git tag, or enter a specific version (e.g. v0.1.2)'
required: false
default: 'auto'
type: string
jobs:
deploy:
name: Build and deploy
runs-on: ubuntu-latest
steps:
- name: Checkout repository
run: |
git init
git remote add origin https://x-access-token:${FORGEJO_TOKEN}@srvr.nu/git/hermes/jobhunt-platform.git
git fetch --depth 1 origin ${GITHUB_SHA}
git checkout FETCH_HEAD
- name: Resolve version
run: |
INPUT_VERSION="${{ github.event.inputs.version }}"
if [ -z "$INPUT_VERSION" ] || [ "$INPUT_VERSION" = "auto" ]; then
git fetch --tags origin
LATEST=$(git tag --list 'v*' --sort=-v:refname | head -1)
if [ -z "$LATEST" ]; then LATEST="v0.0.0"; fi
BASE="${LATEST#v}"
MAJOR=$(echo "$BASE" | cut -d. -f1)
MINOR=$(echo "$BASE" | cut -d. -f2)
PATCH=$(echo "$BASE" | cut -d. -f3)
PATCH=$(( ${PATCH:-0} + 1 ))
VERSION="v${MAJOR:-0}.${MINOR:-0}.${PATCH}"
echo "Latest tag: $LATEST → auto-bumped to $VERSION"
else
VERSION="$INPUT_VERSION"
echo "Using manual version: $VERSION"
fi
if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "ERROR: resolved version '$VERSION' is not valid semver (expected vX.Y.Z)"
exit 1
fi
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Tag version
run: |
git tag -d ${{ env.VERSION }} 2>/dev/null || true
git push origin --delete ${{ env.VERSION }} 2>/dev/null || true
git tag ${{ env.VERSION }}
git push origin ${{ env.VERSION }}
- name: Write production .env
env:
LLM_PRIMARY_KEY: ${{ secrets.LLM_PRIMARY_KEY }}
run: |
{
printf 'DATABASE_URL=%s\n' 'postgresql://jobhunt:jobhunt@postgres:5432/jobhunt'
printf 'LLM_PRIMARY_BASE_URL=%s\n' 'https://ollama.com/v1'
printf 'LLM_PRIMARY_KEY=%s\n' "$LLM_PRIMARY_KEY"
printf 'LLM_PRIMARY_MODEL=%s\n' 'glm-5.2'
printf 'LLM_CHEAP_MODEL=%s\n' 'glm-5.2'
printf 'LLM_STRONG_MODEL=%s\n' 'glm-5.2'
printf 'VITE_API_BASE=%s\n' '/api'
} > .env
- name: Build and start production stack
run: |
docker compose -p jobhunt -f docker-compose.prod.yml down
docker compose -p jobhunt -f docker-compose.prod.yml up --build -d
- name: Health checks with rollback
run: |
echo "Waiting for services to start..."
sleep 15
API_OK=false
for i in 1 2 3 4 5 6 7 8 9 10; do
if docker run --rm --network jobhunt_default curlimages/curl:8.5.0 \
-sf http://jobhunt-api:8000/api/health > /dev/null; then
echo "API is healthy"
API_OK=true
break
fi
echo "API check attempt $i failed, retrying in 5s..."
sleep 5
done
WEB_OK=false
for i in 1 2 3 4 5; do
if docker run --rm --network jobhunt_default curlimages/curl:8.5.0 \
-sf http://jobhunt-web/ > /dev/null; then
echo "Frontend is serving"
WEB_OK=true
break
fi
echo "Frontend check attempt $i failed, retrying in 5s..."
sleep 5
done
if [ "$API_OK" != "true" ] || [ "$WEB_OK" != "true" ]; then
echo ""
echo "═══════════════════════════════════════════════════"
echo " HEALTH CHECK FAILED — DIAGNOSTICS"
echo "═══════════════════════════════════════════════════"
echo ""
docker compose -p jobhunt -f docker-compose.prod.yml ps
echo ""
echo "--- API logs ---"
docker logs jobhunt-api 2>&1 | tail -80 || true
echo ""
echo "--- Postgres logs ---"
docker logs jobhunt-postgres 2>&1 | tail -30 || true
echo ""
echo "═══════════════════════════════════════════════════"
echo " ROLLING BACK DEPLOYMENT"
echo "═══════════════════════════════════════════════════"
echo ""
docker compose -p jobhunt -f docker-compose.prod.yml down
echo ""
echo "Rolled back. Containers stopped. DB volume preserved."
echo "Read API logs above to find the root cause before redeploying."
exit 1
fi
- name: Seed demo data (idempotent)
run: |
docker run --rm --network jobhunt_default curlimages/curl:8.5.0 \
-sf -X POST http://jobhunt-api:8000/api/concierge/seed-demo || \
echo "WARN: demo seed failed (non-fatal)"
- name: Print deploy status
run: |
echo ""
echo "═══════════════════════════════════════════════════"
echo " Deployed ${{ env.VERSION }} to production"
echo "═══════════════════════════════════════════════════"
echo ""
docker compose -p jobhunt -f docker-compose.prod.yml ps
echo ""
echo "Web UI: http://tocke:8085"
echo "API: http://tocke:8000/api/health"
echo ""

View file

@ -161,30 +161,6 @@ MOCK_OUTPUTS: dict[str, dict[str, Any]] = {
} }
def _mock_cv_tailor(prompt: str) -> dict[str, Any]:
"""Prompt-aware mock tailor: extracts source bullets from the prompt and
rephrases them deterministically, so the result always passes the
hallucination guard (which requires traceable source overlap)."""
import re
bullets = re.findall(r'"([A-ZÅÄÖ][^"]{20,300})"', prompt)
bullets = [b for b in bullets if "{" not in b and ":" not in b][:4]
if not bullets:
bullets = ["Experienced backend developer focused on reliability"]
tailored = []
change_log = []
for b in bullets[:2]:
tailored.append(f"{b} (tailored for this posting)")
change_log.append({"action": "rephrased", "detail": f"Emphasized relevance of {b[:60]}"})
for b in bullets[2:]:
tailored.append(b)
change_log.append({"action": "kept", "detail": f"Retained as-is {b[:60]}"})
return {
"tailored_cv": {"summary": bullets[0][:160], "bullets": tailored},
"change_log": change_log,
}
def run_task( def run_task(
task: str, task: str,
prompt: str, prompt: str,
@ -202,8 +178,6 @@ def run_task(
# Mock mode # Mock mode
time.sleep(0.01) # simulate latency time.sleep(0.01) # simulate latency
result = MOCK_OUTPUTS.get(task, {"result": "mock"}) result = MOCK_OUTPUTS.get(task, {"result": "mock"})
if task == "cv_tailor":
result = _mock_cv_tailor(prompt)
# Validate against schema if provided (basic check) # Validate against schema if provided (basic check)
# In real gateway this would be jsonschema validation # In real gateway this would be jsonschema validation

View file

@ -87,18 +87,6 @@ except ImportError:
app = FastAPI(title="Jobhunt API", version="0.2.0") app = FastAPI(title="Jobhunt API", version="0.2.0")
# Self-hosted single-user app; SPA origin differs from API origin (e.g. web:80 -> api:8000).
from fastapi.middleware.cors import CORSMiddleware
_cors_origins = os.environ.get("CORS_ORIGINS", "*").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in _cors_origins if o.strip()],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup") @app.on_event("startup")
def _startup() -> None: def _startup() -> None:
@ -870,13 +858,7 @@ def postings_fetch(body: PostingsFetchRequest) -> Any:
# --- v1: Batch Scoring --- # --- v1: Batch Scoring ---
def _batch_score_internal(application_ids: list[str]) -> list[dict[str, Any]]: def _batch_score_internal(application_ids: list[str]) -> list[dict[str, Any]]:
"""Internal: score multiple applications, return results with red_flags. """Internal: score multiple applications, return results with red_flags."""
Safety rule (post-bug): batch scoring only touches applications in
'discovered' or 'scored' state. Anything further down the pipeline
(approved/sent/interviewing/...) keeps its state and stored scores;
those rows are returned unchanged with skipped=True.
"""
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
for app_id in application_ids: for app_id in application_ids:
app_row = repo_app.get_application(app_id) app_row = repo_app.get_application(app_id)
@ -886,17 +868,6 @@ def _batch_score_internal(application_ids: list[str]) -> list[dict[str, Any]]:
if posting is None: if posting is None:
continue continue
if app_row["state"] not in ("discovered", "scored"):
existing = app_row.get("score_rationale") or {}
results.append({
"application_id": app_id,
"score": app_row.get("score"),
"rationale": existing,
"red_flags": existing.get("red_flags", []),
"skipped": True,
})
continue
result = llm.run_task( result = llm.run_task(
"score", "score",
f"Score this posting: {posting['title']} at {posting['company']}", f"Score this posting: {posting['title']} at {posting['company']}",
@ -1064,96 +1035,34 @@ def interview_prep(app_id: str) -> Any:
# --- v1: Concierge / Demo Seed --- # --- v1: Concierge / Demo Seed ---
def _seed_demo_counts() -> dict[str, Any]:
"""Compute counts for the seed-demo response from current DB state."""
postings = repo_app.list_postings()
apps = repo_app.list_applications()
sections = repo_profile.list_sections()
# Clusters: postings with a non-null cluster_id
cluster_postings = [p for p in postings if p.get("cluster_id")]
cluster_ids = set(p["cluster_id"] for p in cluster_postings if p["cluster_id"])
# Deadlines: postings with apply_by in the next 7 days
from datetime import date, timedelta
today = datetime.now(timezone.utc).date()
deadline_window = today + timedelta(days=7)
deadline_count = 0
for p in postings:
apply_by_raw = p.get("apply_by")
if not apply_by_raw:
continue
# _normalize_posting returns apply_by as ISO string
if isinstance(apply_by_raw, str):
apply_by_date = date.fromisoformat(apply_by_raw)
else:
apply_by_date = apply_by_raw
if today <= apply_by_date <= deadline_window:
deadline_count += 1
# Pending email suggestions
from app.imap_watch import list_pending_suggestions
suggestions = list_pending_suggestions()
# Notification log rows
from app.notify import list_notification_log
notifications = list_notification_log(limit=50)
# Task runs
task_runs = repo_app.list_task_runs()
# CV artifacts (kind=cv, origin=ai_drafted)
cv_count = 0
for a in apps:
artifacts = repo_app.list_artifacts(a["id"])
cv_count += sum(1 for art in artifacts if art["kind"] == "cv" and art["origin"] == "ai_drafted")
return {
"profile": "Demo Demosson",
"postings": len(postings),
"applications": len(apps),
"sections": len(sections),
"clusters": len(cluster_ids),
"deadlines": deadline_count,
"suggestions": len(suggestions),
"notifications": len(notifications),
"task_runs": len(task_runs),
"cv_artifacts": cv_count,
}
@app.post("/api/concierge/seed-demo", response_model=SeedDemoResponse) @app.post("/api/concierge/seed-demo", response_model=SeedDemoResponse)
def seed_demo() -> Any: def seed_demo() -> Any:
"""Idempotent demo seed: profile + postings + varied application states + artifacts + telemetry. """Idempotent demo seed: profile + 6 postings + varied application states."""
Populates a complete demo-worthy dataset:
- 6 standalone postings with varied application states
- 3-posting agency cluster (same role reposted by 3 fictional agencies)
- 2 postings with apply_by deadlines in the next 4 days
- 1 posting with red_flags in its application score_rationale
- Applications: 1 sent (backdated 8 days for nudge), 1 interviewing, 1 approved with cover letter
- 2 pending email_suggestion rows
- 3 notification_log rows (daily_digest delivered, email_suggestion delivered, webhook failed)
- 6 task_run telemetry rows across providers/models
- 1 cv-tailor artifact (kind cv, origin ai_drafted) on the interviewing application
"""
from datetime import date, timedelta
# Check if demo profile already exists # Check if demo profile already exists
existing = fetch_one( existing = fetch_one(
"SELECT * FROM profile WHERE full_name = 'Demo Demosson'" "SELECT * FROM profile WHERE full_name = 'Demo Demosson'"
) )
if existing: if existing:
return _seed_demo_counts() # 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 -- # Create demo profile with Swedish characters
repo_profile.get_or_create_profile() repo_profile.get_or_create_profile()
profile = repo_profile.update_profile({ profile = repo_profile.update_profile({
"full_name": "Demo Demosson", "full_name": "Demo Demosson",
"email": "demo@example.com", "email": "demo@example.com",
"location": "Malmö", "location": "Malmo",
"headline": "Software Developer", "headline": "Software Developer",
"summary": "Experienced developer looking for opportunities in Skåne.", "summary": "Experienced developer looking for opportunities in Skane.",
"languages": [{"code": "sv", "level": "native"}, {"code": "en", "level": "fluent"}], "languages": [{"code": "sv", "level": "native"}, {"code": "en", "level": "fluent"}],
}) })
if profile is None: if profile is None:
@ -1161,7 +1070,7 @@ def seed_demo() -> Any:
# Create demo CV sections # Create demo CV sections
demo_sections = [ demo_sections = [
{"kind": "experience", "title": "Backend Developer", "org": "TechSkåne AB", "bullets": ["Built REST APIs", "Improved performance by 30%"], "tags": ["python", "fastapi"]}, {"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": "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": "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"]}, {"kind": "skills", "title": "Technical Skills", "bullets": ["Python", "PostgreSQL", "Docker", "FastAPI"], "tags": ["python", "docker"]},
@ -1169,19 +1078,14 @@ def seed_demo() -> Any:
for s in demo_sections: for s in demo_sections:
repo_profile.create_section(profile["id"], s) repo_profile.create_section(profile["id"], s)
# -- Track application IDs for later linking -- # Create 6 demo postings with varied states
sent_app_id: str | None = None
interviewing_app_id: str | None = None
approved_app_id: str | None = None
# -- Create 6 standalone demo postings with varied states --
demo_postings = [ demo_postings = [
{"company": "Skåne Tech AB", "title": "Senior Python Developer", "location": "Malmö", "url": "https://example.com/af/1", "state": "scored", "score": 85}, {"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": "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": "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": "Helsingborg IT", "title": "DevOps Engineer", "location": "Helsingborg", "url": "https://example.com/af/4", "state": "sent", "score": 65},
{"company": "Malmö Startup", "title": "Software Engineer", "location": "Malmö", "url": "https://example.com/af/5", "state": "rejected", "score": 30}, {"company": "Malmo Startup", "title": "Software Engineer", "location": "Malmo", "url": "https://example.com/af/5", "state": "rejected", "score": 30},
{"company": "Ängelholm Tech", "title": "Data Engineer", "location": "Ängelholm", "url": "https://example.com/af/6", "state": "discovered", "score": None}, {"company": "Angelholm Tech", "title": "Data Engineer", "location": "Angelholm", "url": "https://example.com/af/6", "state": "discovered", "score": None},
] ]
for dp in demo_postings: for dp in demo_postings:
@ -1196,264 +1100,48 @@ def seed_demo() -> Any:
) )
app_row = repo_app.create_application(posting["id"]) app_row = repo_app.create_application(posting["id"])
if dp["state"] == "sent":
sent_app_id = app_row["id"]
if dp["state"] == "approved":
approved_app_id = app_row["id"]
# Set state and score # Set state and score
if dp["score"] is not None: if dp["score"] is not None:
repo_app.update_application_score(app_row["id"], dp["score"], {"factors": {}}) repo_app.update_application_score(app_row["id"], dp["score"], {"factors": {}})
if dp["state"] != "discovered" and dp["state"] != "scored": if dp["state"] != "discovered" and dp["state"] != "scored":
# Transition through states
if dp["state"] in ("approved", "rejected"): if dp["state"] in ("approved", "rejected"):
# First set to scored if needed
if dp["score"] is not None: if dp["score"] is not None:
repo_app.update_application_state(app_row["id"], "scored") repo_app.update_application_state(app_row["id"], "scored")
repo_app.update_application_state(app_row["id"], dp["state"]) repo_app.update_application_state(app_row["id"], dp["state"])
elif dp["state"] == "sent": elif dp["state"] == "sent":
# approved -> drafting -> sent
if dp["score"] is not None: if dp["score"] is not None:
repo_app.update_application_state(app_row["id"], "scored") 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"], "approved")
repo_app.update_application_state(app_row["id"], "drafting") repo_app.update_application_state(app_row["id"], "drafting")
# We need confirmed approval for drafting->sent, so directly set state
execute( execute(
"UPDATE application SET state = 'sent', state_changed_at = now(), last_activity_at = now() WHERE id = %s", "UPDATE application SET state = 'sent', state_changed_at = now(), last_activity_at = now() WHERE id = %s",
(app_row["id"],), (app_row["id"],),
) )
# -- 3-posting AGENCY CLUSTER (same real role, 3 fictional agencies, near-identical title+description) -- # Backdate the 'sent' application ( posting 4) to 8 days ago for nudge demo
cluster_title = "Senior Backend Developer" from datetime import timedelta
cluster_desc = (
"We are looking for a senior backend developer with strong Python skills. "
"You will build and maintain scalable REST APIs, work with PostgreSQL, "
"and collaborate in an agile team. Experience with Docker and cloud "
"deployment is a plus."
)
agency_companies = ["Aderanto AB", "Wise IT", "TechTalent Nord"]
agency_posting_ids: list[str] = []
for i, agency in enumerate(agency_companies):
posting = repo_app.create_job_posting(
source="arbetsformedlingen",
url=f"https://example.com/af/agency/{i+1}",
company=agency,
title=cluster_title,
location="Stockholm",
description=cluster_desc,
raw={},
)
agency_posting_ids.append(posting["id"])
# Create applications for these too (scored for digest demo)
app_row = repo_app.create_application(posting["id"])
repo_app.update_application_score(app_row["id"], 75 + i, {"factors": {}})
# Assign cluster IDs to all postings (the matching package will cluster the 3 agency postings together)
for pid in agency_posting_ids:
_assign_cluster_id(pid)
# -- 2 postings with apply_by in the next 4 days (deadlines strip) --
today = datetime.now(timezone.utc).date()
for j in range(2):
deadline = today + timedelta(days=2 + j)
posting = repo_app.create_job_posting(
source="arbetsformedlingen",
url=f"https://example.com/af/deadline/{j+1}",
company=f"Deadline Corp {j+1}",
title=f"Urgent Developer Role {j+1}",
location="Göteborg",
description="Urgent hire for a developer with deadline approaching.",
raw={},
)
repo_app.update_posting_apply_by(posting["id"], deadline)
app_row = repo_app.create_application(posting["id"])
repo_app.update_application_score(app_row["id"], 60 + j * 5, {"factors": {}})
# -- 1 posting with red_flags on its application score_rationale --
red_flag_posting = repo_app.create_job_posting(
source="arbetsformedlingen",
url="https://example.com/af/redflag/1",
company="ShadyCorp AB",
title="Junior Developer",
location="Remote",
description="Entry level developer position with trial period.",
raw={},
)
red_flag_app = repo_app.create_application(red_flag_posting["id"])
red_flag_rationale = {
"factors": {"salary": "below market rate", "trial_period": "3 months unpaid"},
"red_flags": ["requests unpaid trial work", "salary significantly below market rate"],
"summary": "Multiple red flags detected during scoring.",
}
repo_app.update_application_score(red_flag_app["id"], 25, red_flag_rationale)
# -- Application in 'interviewing' state --
interviewing_posting = repo_app.create_job_posting(
source="arbetsformedlingen",
url="https://example.com/af/interview/1",
company="Festina Digital AB",
title="Full Stack Developer",
location="Malmö",
description="Full stack developer with React, Python, and cloud experience.",
raw={},
)
interviewing_app = repo_app.create_application(interviewing_posting["id"])
repo_app.update_application_score(interviewing_app["id"], 90, {"factors": {}})
repo_app.update_application_state(interviewing_app["id"], "scored")
repo_app.update_application_state(interviewing_app["id"], "approved")
repo_app.update_application_state(interviewing_app["id"], "drafting")
execute(
"UPDATE application SET state = 'sent', state_changed_at = now(), last_activity_at = now() WHERE id = %s",
(interviewing_app["id"],),
)
repo_app.update_application_state(interviewing_app["id"], "interviewing")
interviewing_app_id = interviewing_app["id"]
# -- Cover letter artifact on the 'approved' application (Swedish text, origin user_drafted) --
storage_dir = os.path.join(tempfile.gettempdir(), "jobhunt_artifacts")
os.makedirs(storage_dir, exist_ok=True)
if approved_app_id:
cover_text = (
"Basta rekryterare,\n\n"
"Jag ansoker om tjansen som Backend Developer hos er. "
"Med min erfarenhet av Python, FastAPI och PostgreSQL "
"tror jag att jag skulle vara en bra tillgang for ert team.\n\n"
"Jag ser fram emot att diskutera rollen vidare.\n\n"
"Vanliga halsningar,\n"
"Demo Demosson"
)
cover_bytes = cover_text.encode("utf-8")
cl_filename = f"cover_letter_{approved_app_id[:8]}.txt"
cl_storage_path = os.path.join(storage_dir, cl_filename)
with open(cl_storage_path, "wb") as f:
f.write(cover_bytes)
repo_app.create_artifact(
application_id=approved_app_id,
kind="cover_letter",
filename=cl_filename,
content_bytes=cover_bytes,
storage_path=cl_storage_path,
origin="user_drafted",
)
# -- CV tailor artifact on the interviewing application (kind cv, origin ai_drafted, real PDF bytes + hash) --
if interviewing_app_id and HAS_ARTIFACTS:
_render_fn = _render_cv_pdf # type: ignore[possibly-unbound]
cv_profile = {
"full_name": profile.get("full_name", ""),
"headline": profile.get("headline", ""),
"email": profile.get("email", ""),
"phone": profile.get("phone", ""),
"location": profile.get("location", ""),
"summary": "Tailored CV for full stack developer role at Festina Digital.",
}
cv_sections = [
{
"kind": "experience",
"title": "Backend Developer",
"org": "TechSkane AB",
"bullets": ["Built REST APIs", "Improved performance by 30%"],
},
{
"kind": "skills",
"title": "Technical Skills",
"bullets": ["Python", "PostgreSQL", "Docker", "FastAPI", "React"],
},
]
pdf_bytes = _render_fn(cv_profile, cv_sections)
cv_filename = f"cv_tailored_{interviewing_app_id[:8]}.pdf"
cv_storage_path = os.path.join(storage_dir, cv_filename)
with open(cv_storage_path, "wb") as f:
f.write(pdf_bytes)
repo_app.create_artifact(
application_id=interviewing_app_id,
kind="cv",
filename=cv_filename,
content_bytes=pdf_bytes,
storage_path=cv_storage_path,
origin="ai_drafted",
)
# -- Backdate the 'sent' application to 8 days ago for nudge demo --
backdated = datetime.now(timezone.utc) - timedelta(days=8) backdated = datetime.now(timezone.utc) - timedelta(days=8)
if sent_app_id:
execute(
"UPDATE application SET last_activity_at = %s WHERE id = %s",
(backdated, sent_app_id),
)
# -- 2 pending email_suggestion rows --
from app.imap_watch import create_email_suggestion
suggestion_time = datetime.now(timezone.utc) - timedelta(hours=3)
if sent_app_id:
create_email_suggestion(
application_id=sent_app_id,
mailbox_from="recruiter@festina-demo.se",
subject="Inbjudan till intervju",
snippet="Hej, vi skulle vilja boka in en intervju med dig...",
classification="interview_invite",
state_proposal="interviewing",
received_at=suggestion_time,
)
if interviewing_app_id:
create_email_suggestion(
application_id=interviewing_app_id,
mailbox_from="hr@festina-demo.se",
subject="Fråga om din erfarenhet",
snippet="Vi har några frågor om din bakgrund inom Python...",
classification="question",
state_proposal=None,
received_at=datetime.now(timezone.utc) - timedelta(hours=1),
)
# -- 3 notification_log rows --
# 1) daily_digest delivered (via LogChannel-style insert)
execute( execute(
""" "UPDATE application SET last_activity_at = %s WHERE state = 'sent'",
INSERT INTO notification_log (channel, kind, payload, delivered, error) (backdated,),
VALUES ('log', 'daily_digest', %s, true, NULL)
""",
(json.dumps({"text": "Your daily digest is ready", "items": 5}),),
) )
# 2) email_suggestion delivered # Count results
execute( postings_count = len(repo_app.list_postings())
""" apps_count = len(repo_app.list_applications())
INSERT INTO notification_log (channel, kind, payload, delivered, error) sections_count = len(repo_profile.list_sections())
VALUES ('log', 'email_suggestion', %s, true, NULL)
""",
(json.dumps({"text": "New email suggestion received", "suggestion_id": "demo"}),),
)
# 3) webhook failed with error text return {
execute( "profile": "Demo Demosson",
""" "postings": postings_count,
INSERT INTO notification_log (channel, kind, payload, delivered, error) "applications": apps_count,
VALUES ('webhook', 'daily_digest', %s, false, %s) "sections": sections_count,
""", }
(
json.dumps({"text": "Daily digest delivery attempt", "items": 5}),
"HTTP 503: Service Unavailable (webhook endpoint down)",
),
)
# -- 6 task_run telemetry rows across providers/models --
demo_task_runs = [
{"task": "score_application", "model": "gpt-4o-mini", "provider": "openai", "input_tokens": 1200, "output_tokens": 80, "cost_usd": 0.0012, "duration_ms": 1500},
{"task": "score_application", "model": "gpt-4o", "provider": "openai", "input_tokens": 1500, "output_tokens": 120, "cost_usd": 0.0180, "duration_ms": 2200},
{"task": "cv_tailor", "model": "claude-sonnet-4-20250514", "provider": "anthropic", "input_tokens": 3000, "output_tokens": 800, "cost_usd": 0.0450, "duration_ms": 4500},
{"task": "cl_critique", "model": "gpt-4o-mini", "provider": "openai", "input_tokens": 900, "output_tokens": 200, "cost_usd": 0.0009, "duration_ms": 1800},
{"task": "interview_prep", "model": "claude-3-5-sonnet-20241022", "provider": "anthropic", "input_tokens": 2200, "output_tokens": 600, "cost_usd": 0.0330, "duration_ms": 3100},
{"task": "score_application", "model": "gemini-1.5-flash", "provider": "google", "input_tokens": 1000, "output_tokens": 90, "cost_usd": 0.0005, "duration_ms": 900},
]
link_app = interviewing_app_id or sent_app_id
for idx, tr in enumerate(demo_task_runs):
repo_app.create_task_run({
**tr,
"application_id": link_app if idx % 2 == 0 else None,
})
# -- Return counts --
return _seed_demo_counts()
# --- v1.1: Email Suggestions --- # --- v1.1: Email Suggestions ---

View file

@ -323,12 +323,6 @@ class SeedDemoResponse(BaseModel):
postings: int postings: int
applications: int applications: int
sections: int sections: int
clusters: int = 0
deadlines: int = 0
suggestions: int = 0
notifications: int = 0
task_runs: int = 0
cv_artifacts: int = 0
# --- v1.1: Email Suggestions --- # --- v1.1: Email Suggestions ---

View file

@ -398,35 +398,36 @@ class TestTailorCvHappyPath:
# ======================================================================== # ========================================================================
class TestTailorCvHallucinationGuard: class TestTailorCvHallucinationGuard:
def test_hallucination_rejection_502(self, client, monkeypatch): def test_hallucination_rejection_502(self, client):
"""When the tailor output has bullets with no source mapping, return 502.""" """When mock returns bullet with no source mapping, return 502."""
import app.llm as llm_mod
app_id = _create_app_with_profile_and_sections(client) app_id = _create_app_with_profile_and_sections(client)
fabricated = {
"tailored_cv": { # Patch the mock to return a fabricated bullet
"summary": "Developer", import app.llm as llm_mod
"skills": ["Python"], original = llm_mod.MOCK_OUTPUTS.get("cv_tailor", {}).copy()
"experience": [ try:
{ llm_mod.MOCK_OUTPUTS["cv_tailor"] = {
"company": "FakeCorp", "tailored_cv": {
"role": "Fake Role", "summary": "Developer",
"bullets": [ "skills": ["Python"],
"Completely fabricated achievement that has no overlap with any source bullet xyzqwerty", "experience": [
], {
}, "company": "FakeCorp",
], "role": "Fake Role",
}, "bullets": [
"change_log": [{"action": "invented", "detail": "Made up a bullet"}], "Completely fabricated achievement that has no overlap with any source bullet xyzqwerty",
} ],
monkeypatch.setattr( },
llm_mod, "run_task", ],
lambda task, prompt, *a, **k: fabricated, },
) "change_log": [{"action": "invented", "detail": "Made up a bullet"}],
resp = client.post(f"/api/applications/{app_id}/tailor-cv") }
assert resp.status_code == 502 resp = client.post(f"/api/applications/{app_id}/tailor-cv")
detail = resp.json()["detail"] assert resp.status_code == 502
assert "hallucination_guard" in str(detail) detail = resp.json()["detail"]
assert "hallucination_guard" in str(detail)
finally:
llm_mod.MOCK_OUTPUTS["cv_tailor"] = original
def test_hallucination_rejection_with_empty_source_bullets(self, client): def test_hallucination_rejection_with_empty_source_bullets(self, client):
"""When there are no source bullets, hallucination guard is not triggered (no source to map to).""" """When there are no source bullets, hallucination guard is not triggered (no source to map to)."""
@ -455,36 +456,37 @@ class TestTailorCvHallucinationGuard:
# Should succeed since source_bullets is empty -> guard not triggered # Should succeed since source_bullets is empty -> guard not triggered
assert resp.status_code == 200 assert resp.status_code == 200
def test_hallucination_rejection_preserves_existing_output(self, client, monkeypatch): def test_hallucination_rejection_preserves_existing_output(self, client):
"""After a 502 hallucination rejection, a subsequent valid call works.""" """After a 502 hallucination rejection, a subsequent valid call works."""
import app.llm as llm_mod
app_id = _create_app_with_profile_and_sections(client) app_id = _create_app_with_profile_and_sections(client)
fabricated = {
"tailored_cv": { # First: trigger hallucination
"summary": "Dev", import app.llm as llm_mod
"skills": ["Python"], original = llm_mod.MOCK_OUTPUTS.get("cv_tailor", {}).copy()
"experience": [ try:
{ llm_mod.MOCK_OUTPUTS["cv_tailor"] = {
"company": "X", "tailored_cv": {
"role": "X", "summary": "Dev",
"bullets": ["Fabricated xyzqwerty zzz new content"], "skills": ["Python"],
}, "experience": [
], {
}, "company": "X",
"change_log": [], "role": "X",
} "bullets": ["Fabricated xyzqwerty zzz new content"],
with monkeypatch.context() as mp: },
mp.setattr( ],
llm_mod, "run_task", },
lambda task, prompt, *a, **k: fabricated, "change_log": [],
) }
resp1 = client.post(f"/api/applications/{app_id}/tailor-cv") resp1 = client.post(f"/api/applications/{app_id}/tailor-cv")
assert resp1.status_code == 502 assert resp1.status_code == 502
# Default prompt-aware mock is guard-safe -> succeeds # Restore and retry
resp2 = client.post(f"/api/applications/{app_id}/tailor-cv") llm_mod.MOCK_OUTPUTS["cv_tailor"] = original
assert resp2.status_code == 200 resp2 = client.post(f"/api/applications/{app_id}/tailor-cv")
assert resp2.status_code == 200
finally:
llm_mod.MOCK_OUTPUTS["cv_tailor"] = original
# ======================================================================== # ========================================================================

View file

@ -279,22 +279,14 @@ class TestInterviewPrep:
class TestSeedDemo: class TestSeedDemo:
def test_seed_demo_creates_data(self, client): def test_seed_demo_creates_data(self, client):
"""POST /concierge/seed-demo creates profile, postings, applications, and extended data.""" """POST /concierge/seed-demo creates profile, postings, applications."""
resp = client.post("/api/concierge/seed-demo") resp = client.post("/api/concierge/seed-demo")
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["profile"] == "Demo Demosson" assert data["profile"] == "Demo Demosson"
assert data["postings"] == 6
assert data["applications"] == 6
assert data["sections"] == 4 assert data["sections"] == 4
# 6 standalone + 3 agency + 2 deadline + 1 redflag + 1 interviewing = 13 postings
assert data["postings"] == 13
# 6 standalone + 3 agency + 2 deadline + 1 redflag + 1 interviewing = 13 applications
assert data["applications"] == 13
assert data["clusters"] >= 1
assert data["deadlines"] >= 2
assert data["suggestions"] == 2
assert data["notifications"] == 3
assert data["task_runs"] == 6
assert data["cv_artifacts"] >= 1
def test_seed_demo_idempotent(self, client): def test_seed_demo_idempotent(self, client):
"""Running seed-demo twice returns the same counts.""" """Running seed-demo twice returns the same counts."""
@ -306,12 +298,6 @@ class TestSeedDemo:
assert resp2.json()["postings"] == resp1.json()["postings"] assert resp2.json()["postings"] == resp1.json()["postings"]
assert resp2.json()["applications"] == resp1.json()["applications"] assert resp2.json()["applications"] == resp1.json()["applications"]
assert resp2.json()["sections"] == resp1.json()["sections"] assert resp2.json()["sections"] == resp1.json()["sections"]
assert resp2.json()["clusters"] == resp1.json()["clusters"]
assert resp2.json()["deadlines"] == resp1.json()["deadlines"]
assert resp2.json()["suggestions"] == resp1.json()["suggestions"]
assert resp2.json()["notifications"] == resp1.json()["notifications"]
assert resp2.json()["task_runs"] == resp1.json()["task_runs"]
assert resp2.json()["cv_artifacts"] == resp1.json()["cv_artifacts"]
def test_seed_demo_has_nudge_candidate(self, client): def test_seed_demo_has_nudge_candidate(self, client):
"""After seeding, /today should show a nudge for the backdated sent app.""" """After seeding, /today should show a nudge for the backdated sent app."""
@ -327,104 +313,6 @@ class TestSeedDemo:
digest = resp.json()["digest"] digest = resp.json()["digest"]
assert len(digest) >= 1 assert len(digest) >= 1
# --- WS1: Extended seed demo tests (8 new) ---
def test_seed_demo_agency_cluster_present(self, client):
"""Seed creates a 3-posting agency cluster with the same cluster_id."""
client.post("/api/concierge/seed-demo")
postings = client.get("/api/postings").json()
agency_names = {"Aderanto AB", "Wise IT", "TechTalent Nord"}
agency_postings = [p for p in postings if p.get("company") in agency_names]
assert len(agency_postings) == 3
cluster_ids = {p["cluster_id"] for p in agency_postings if p.get("cluster_id")}
assert len(cluster_ids) == 1, f"Expected 1 cluster_id, got {cluster_ids}"
def test_seed_demo_deadlines_populated(self, client):
"""Seed creates at least 2 postings with apply_by in the next 7 days."""
client.post("/api/concierge/seed-demo")
resp = client.get("/api/today")
assert resp.status_code == 200
deadlines = resp.json().get("deadlines", [])
assert len(deadlines) >= 2
for d in deadlines:
assert d["apply_by"] is not None
def test_seed_demo_red_flag_rationale(self, client):
"""Seed creates an application with red_flags in its score_rationale."""
client.post("/api/concierge/seed-demo")
apps = client.get("/api/applications").json()
red_flag_apps = [
a for a in apps
if a.get("score_rationale") and isinstance(a["score_rationale"], dict)
and "red_flags" in a["score_rationale"]
]
assert len(red_flag_apps) >= 1
red_flags = red_flag_apps[0]["score_rationale"]["red_flags"]
assert isinstance(red_flags, list)
assert any("unpaid trial" in str(rf).lower() for rf in red_flags)
def test_seed_demo_has_interviewing_application(self, client):
"""Seed creates at least one application in interviewing state."""
client.post("/api/concierge/seed-demo")
apps = client.get("/api/applications").json()
interviewing = [a for a in apps if a["state"] == "interviewing"]
assert len(interviewing) >= 1
def test_seed_demo_has_cover_letter_artifact(self, client):
"""Seed creates a cover_letter artifact (origin user_drafted) with Swedish text on the approved app."""
client.post("/api/concierge/seed-demo")
apps = client.get("/api/applications").json()
for a in apps:
artifacts = client.get(f"/api/applications/{a['id']}/artifacts").json()
for art in artifacts:
if art["kind"] == "cover_letter" and art["origin"] == "user_drafted":
return
assert False, "No user_drafted cover_letter artifact found"
def test_seed_demo_suggestions_present(self, client):
"""Seed creates 2 pending email_suggestion rows with expected classifications."""
client.post("/api/concierge/seed-demo")
suggestions = client.get("/api/suggestions").json()
assert len(suggestions) == 2
classifications = {s["classification"] for s in suggestions}
assert "interview_invite" in classifications
assert "question" in classifications
# Verify the interview_invite comes from recruiter@festina-demo.se
interview_suggestion = [s for s in suggestions if s["classification"] == "interview_invite"][0]
assert interview_suggestion["mailbox_from"] == "recruiter@festina-demo.se"
def test_seed_demo_notification_log_present(self, client):
"""Seed creates 3 notification_log rows: 2 delivered, 1 webhook failed."""
client.post("/api/concierge/seed-demo")
resp = client.get("/api/notifications/log")
assert resp.status_code == 200
logs = resp.json()
assert len(logs) == 3
# At least one delivered (daily_digest or email_suggestion)
delivered = [l for l in logs if l["delivered"] is True]
assert len(delivered) >= 2
# At least one webhook failed with error text
failed = [l for l in logs if l["delivered"] is False]
assert len(failed) >= 1
assert failed[0]["error"] is not None
assert len(failed[0]["error"]) > 0
def test_seed_demo_task_run_telemetry_variance(self, client):
"""Seed creates 6 task_run rows across multiple providers and models."""
client.post("/api/concierge/seed-demo")
resp = client.get("/api/telemetry/tasks")
assert resp.status_code == 200
tasks = resp.json()
assert len(tasks) == 6
providers = {t["provider"] for t in tasks}
models = {t["model"] for t in tasks}
assert len(providers) >= 3, f"Expected >= 3 providers, got {providers}"
assert len(models) >= 4, f"Expected >= 4 models, got {models}"
# Verify cost variance for CostDisplay
costs = [t["cost_usd"] for t in tasks if t["cost_usd"] is not None]
assert len(costs) >= 2
assert max(costs) > min(costs)
# --- SMTP Transport --- # --- SMTP Transport ---

View file

@ -1,14 +0,0 @@
# Web production image: build the SPA, serve via nginx with SPA fallback
FROM node:22-alpine AS build
WORKDIR /w
ARG VITE_API_BASE=http://api:8000/api
ENV VITE_API_BASE=$VITE_API_BASE
COPY apps/web/package.json apps/web/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY apps/web ./
RUN npm run build
FROM nginx:1.27-alpine
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /w/dist /usr/share/nginx/html
EXPOSE 80

View file

@ -1,15 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://api:8000/api/;
proxy_set_header Host $host;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View file

@ -66,10 +66,8 @@ export interface Application {
notes: string notes: string
state_changed_at: string state_changed_at: string
created_at: string created_at: string
// joined posting info (flat fields from GET /applications) // joined posting info (from GET /applications)
company?: string | null posting?: JobPosting
title?: string | null
location?: string | null
} }
export type ArtifactKind = 'cv' | 'cover_letter' | 'email' | 'other' export type ArtifactKind = 'cv' | 'cover_letter' | 'email' | 'other'
@ -264,21 +262,14 @@ export interface Cluster {
postings: ClusterPosting[] postings: ClusterPosting[]
} }
export interface TailorKeywordCoverage {
ratio: number
matched: string[]
missing: string[]
}
export interface TailorChangeLogEntry { export interface TailorChangeLogEntry {
action?: string section: string
section?: string change: string
detail?: string bullet_id: string
change?: string
} }
export interface TailorCvResponse { export interface TailorCvResponse {
artifact_id: string artifact_id: string
change_log: TailorChangeLogEntry[] change_log: TailorChangeLogEntry[]
keyword_coverage: TailorKeywordCoverage keyword_coverage: number
} }

View file

@ -34,9 +34,11 @@ function makeApp(): Application {
notes: '', notes: '',
state_changed_at: '2026-01-01T00:00:00Z', state_changed_at: '2026-01-01T00:00:00Z',
created_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
company: 'Acme', posting: {
title: 'Engineer', id: 'j-1', source: 'manual_url', external_id: null, url: 'http://x',
location: 'Remote' company: 'Acme', title: 'Engineer', location: 'Remote', description: '',
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
}
} }
} }
@ -58,10 +60,10 @@ function makeTailorResult(): TailorCvResponse {
return { return {
artifact_id: 'art-tailor-1', artifact_id: 'art-tailor-1',
change_log: [ change_log: [
{ action: 'experience', detail: 'Reordered to highlight Python backend work' }, { section: 'experience', change: 'Reordered to highlight Python backend work', bullet_id: 'b-1' },
{ action: 'skills', detail: 'Moved Docker and Kubernetes higher' } { section: 'skills', change: 'Moved Docker and Kubernetes higher', bullet_id: 'b-5' }
], ],
keyword_coverage: { ratio: 0.75, matched: ['python', 'docker'], missing: ['kubernetes'] } keyword_coverage: 0.75
} }
} }

View file

@ -34,9 +34,11 @@ function makeApp(): Application {
notes: '', notes: '',
state_changed_at: '2026-01-01T00:00:00Z', state_changed_at: '2026-01-01T00:00:00Z',
created_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
company: 'Acme', posting: {
title: 'Engineer', id: 'j-1', source: 'manual_url', external_id: null, url: 'http://x',
location: 'Remote' company: 'Acme', title: 'Engineer', location: 'Remote', description: '',
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
}
} }
} }

View file

@ -52,22 +52,17 @@ const severityClass: Record<string, string> = {
low: 'bg-blue-50 border-blue-200' low: 'bg-blue-50 border-blue-200'
} }
const coverageRatio = computed(() => {
if (!tailorResult.value) return 0
const kc = tailorResult.value.keyword_coverage
return typeof kc === 'object' && kc !== null ? (kc.ratio ?? 0) : Number(kc) || 0
})
const coverageColor = computed(() => { const coverageColor = computed(() => {
if (!tailorResult.value) return 'bg-gray-300' if (!tailorResult.value) return 'bg-gray-300'
const c = coverageRatio.value const c = tailorResult.value.keyword_coverage
if (c >= 0.7) return 'bg-green-500' if (c >= 0.7) return 'bg-green-500'
if (c >= 0.4) return 'bg-yellow-500' if (c >= 0.4) return 'bg-yellow-500'
return 'bg-red-500' return 'bg-red-500'
}) })
const coveragePercent = computed(() => { const coveragePercent = computed(() => {
return Math.round(coverageRatio.value * 100) if (!tailorResult.value) return 0
return Math.round(tailorResult.value.keyword_coverage * 100)
}) })
const downloadUrl = computed(() => { const downloadUrl = computed(() => {
@ -145,7 +140,7 @@ async function sendOutbox() {
if (!approval.value || !canSend.value) return if (!approval.value || !canSend.value) return
sending.value = true sending.value = true
try { try {
await api.outboxSend(approval.value.id, { to: application.value?.company ?? '' }) await api.outboxSend(approval.value.id, { to: application.value?.posting?.company ?? '' })
toast.push('Sent successfully', 'success') toast.push('Sent successfully', 'success')
} catch (err) { } catch (err) {
let msg = 'Send failed' let msg = 'Send failed'
@ -200,8 +195,8 @@ onMounted(loadData)
<template v-if="!loading && application"> <template v-if="!loading && application">
<!-- Posting info --> <!-- Posting info -->
<section class="bg-white rounded-lg border border-gray-200 p-4"> <section class="bg-white rounded-lg border border-gray-200 p-4">
<div class="font-semibold text-lg">{{ application.company ?? 'Unknown' }}</div> <div class="font-semibold text-lg">{{ application.posting?.company ?? 'Unknown' }}</div>
<div class="text-gray-600">{{ application.title ?? 'No title' }}</div> <div class="text-gray-600">{{ application.posting?.title ?? 'No title' }}</div>
<div class="text-sm text-gray-500 mt-1"> <div class="text-sm text-gray-500 mt-1">
State: <span class="capitalize font-medium">{{ application.state }}</span> State: <span class="capitalize font-medium">{{ application.state }}</span>
<span v-if="application.score != null" class="ml-3">Score: {{ application.score }}</span> <span v-if="application.score != null" class="ml-3">Score: {{ application.score }}</span>
@ -250,8 +245,8 @@ onMounted(loadData)
:key="i" :key="i"
class="border-b border-gray-100 py-1" class="border-b border-gray-100 py-1"
> >
<span class="font-medium text-gray-700">{{ entry.action || entry.section || 'change' }}:</span> <span class="font-medium text-gray-700">{{ entry.section }}:</span>
<span class="text-gray-600 ml-1">{{ entry.detail || entry.change }}</span> <span class="text-gray-600 ml-1">{{ entry.change }}</span>
</li> </li>
</ul> </ul>
</div> </div>

View file

@ -30,9 +30,11 @@ function makeApp(id: string, state: string, company: string): Application {
notes: '', notes: '',
state_changed_at: '2026-01-01T00:00:00Z', state_changed_at: '2026-01-01T00:00:00Z',
created_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
company, posting: {
title: 'Engineer', id: 'j-' + id, source: 'manual_url', external_id: null, url: 'http://x',
location: 'Remote' company, title: 'Engineer', location: 'Remote', description: '',
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
}
} }
} }

View file

@ -31,9 +31,11 @@ function fixture(): Application[] {
notes: '', notes: '',
state_changed_at: '2026-01-01T00:00:00Z', state_changed_at: '2026-01-01T00:00:00Z',
created_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
company: 'Acme', posting: {
title: 'Engineer', id: 'j-1', source: 'manual_url', external_id: null, url: 'http://x',
location: 'Remote' company: 'Acme', title: 'Engineer', location: 'Remote', description: '',
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
}
}, },
{ {
id: 'app-2', id: 'app-2',
@ -44,9 +46,11 @@ function fixture(): Application[] {
notes: '', notes: '',
state_changed_at: '2026-01-01T00:00:00Z', state_changed_at: '2026-01-01T00:00:00Z',
created_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
company: 'Globex', posting: {
title: 'Manager', id: 'j-2', source: 'linkedin', external_id: null, url: 'http://y',
location: 'Malmo' company: 'Globex', title: 'Manager', location: 'Malmo', description: '',
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
}
} }
] ]
} }

View file

@ -41,10 +41,7 @@ function hasRedFlags(app: Application): boolean {
} }
function redFlagsFor(app: Application): string[] { function redFlagsFor(app: Application): string[] {
const fromBatch = redFlagsMap.value[app.id] return redFlagsMap.value[app.id] ?? []
if (fromBatch) return fromBatch
const stored = (app.score_rationale as { red_flags?: string[] } | null)?.red_flags
return stored ?? []
} }
function hasNudge(app: Application): boolean { function hasNudge(app: Application): boolean {
@ -56,11 +53,7 @@ async function loadApplications() {
applications.value = await api.getApplications() applications.value = await api.getApplications()
// Load red flags via batch scoring and nudges via today endpoint // Load red flags via batch scoring and nudges via today endpoint
const [batchResult, todayResult] = await Promise.allSettled([ const [batchResult, todayResult] = await Promise.allSettled([
api.batchScore( api.batchScore(applications.value.map((a) => a.id)),
applications.value
.filter((a) => a.state === 'discovered' || a.state === 'scored')
.map((a) => a.id)
),
api.getToday() api.getToday()
]) ])
if (batchResult.status === 'fulfilled') { if (batchResult.status === 'fulfilled') {
@ -171,9 +164,9 @@ onMounted(loadApplications)
class="inline-block w-2 h-2 rounded-full bg-orange-500 flex-shrink-0" class="inline-block w-2 h-2 rounded-full bg-orange-500 flex-shrink-0"
title="Follow-up nudge pending" title="Follow-up nudge pending"
></span> ></span>
<div class="font-medium text-sm truncate">{{ app.company ?? 'Unknown' }}</div> <div class="font-medium text-sm truncate">{{ app.posting?.company ?? 'Unknown' }}</div>
</div> </div>
<div class="text-xs text-gray-500 truncate">{{ app.title ?? 'No title' }}</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"> <div v-if="app.score != null" class="text-xs text-green-700 mt-1">
Score: {{ app.score }} Score: {{ app.score }}
</div> </div>

View file

@ -153,7 +153,7 @@ onMounted(loadPostings)
<!-- Fetch form (Arbetsformedlingen connector) --> <!-- Fetch form (Arbetsformedlingen connector) -->
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3"> <section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
<h2 class="font-semibold">Fetch from Arbetsförmedlingen</h2> <h2 class="font-semibold">Fetch from Arbetsformedlingen</h2>
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
v-model="fetchQuery" v-model="fetchQuery"

View file

@ -223,7 +223,7 @@ function prev() {
<div v-if="step === 2" class="bg-white rounded-lg border border-gray-200 p-6 space-y-4"> <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> <h2 class="font-semibold text-lg">Fetch Job Postings</h2>
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600">
Search for job postings from the Arbetsförmedlingen connector. New postings will be added to your applications. Search for job postings from the Arbetsformedlingen connector. New postings will be added to your applications.
</p> </p>
<div class="space-y-2"> <div class="space-y-2">
<label class="block"> <label class="block">

View file

@ -1,57 +0,0 @@
# Production stack for jobhunt-platform.
# Built and started by .forgejo/workflows/deploy.yml on the host docker daemon.
# Web UI is published on http://<host>:8085, API on :8000. Postgres is internal only.
services:
postgres:
image: postgres:16
container_name: jobhunt-postgres
environment:
POSTGRES_USER: jobhunt
POSTGRES_PASSWORD: jobhunt
POSTGRES_DB: jobhunt
volumes:
- jobhunt_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U jobhunt -d jobhunt"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
api:
build:
context: .
dockerfile: apps/api/Dockerfile.test
image: jobhunt-api
container_name: jobhunt-api
entrypoint: uvicorn app.main:app --host 0.0.0.0 --port 8000
env_file: .env
environment:
DATABASE_URL: postgresql://jobhunt:jobhunt@postgres:5432/jobhunt
working_dir: /app/apps/api
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
web:
build:
context: .
dockerfile: apps/web/Dockerfile
args:
# Baked into the SPA at build time. Relative /api goes through the
# nginx proxy in apps/web/nginx.conf -> http://api:8000/api/
VITE_API_BASE: /api
image: jobhunt-web
container_name: jobhunt-web
ports:
- "8085:80"
depends_on:
- api
restart: unless-stopped
volumes:
jobhunt_pgdata:
name: jobhunt_pgdata

View file

@ -8,8 +8,8 @@ services:
POSTGRES_USER: jobhunt POSTGRES_USER: jobhunt
POSTGRES_PASSWORD: jobhunt POSTGRES_PASSWORD: jobhunt
POSTGRES_DB: jobhunt POSTGRES_DB: jobhunt
# No host port publishing: CI/tests run inside the compose network, and on ports:
# this host 5433 is already taken by bilhej-postgres-prod. - "5433:5432"
volumes: volumes:
- jobhunt_pgdata:/var/lib/postgresql/data - jobhunt_pgdata:/var/lib/postgresql/data
healthcheck: healthcheck:
@ -29,42 +29,6 @@ services:
condition: service_healthy condition: service_healthy
restart: "no" restart: "no"
api:
build:
context: .
dockerfile: apps/api/Dockerfile.test
image: jobhunt-platform-api-test
entrypoint: uvicorn app.main:app --host 0.0.0.0 --port 8000
environment:
DATABASE_URL: postgresql://jobhunt:jobhunt@postgres:5432/jobhunt
working_dir: /app/apps/api
depends_on:
postgres:
condition: service_healthy
restart: "no"
web:
build:
context: .
dockerfile: apps/web/Dockerfile
args:
VITE_API_BASE: http://api:8000/api
depends_on:
- api
restart: "no"
shots:
build:
context: .
dockerfile: scripts/Dockerfile.shots
volumes:
- shots_out:/out
depends_on:
- api
- web
restart: "no"
volumes: volumes:
jobhunt_pgdata: jobhunt_pgdata:
name: jobhunt_pgdata name: jobhunt_pgdata
shots_out:

View file

@ -1,5 +0,0 @@
FROM mcr.microsoft.com/playwright/python:v1.55.0-jammy
WORKDIR /shots
RUN pip install --no-cache-dir playwright==1.55.0
COPY scripts/screenshots.py ./
CMD ["python", "screenshots.py"]

View file

@ -1,84 +0,0 @@
"""Screenshot runner: drives the seed demo UI and saves PNGs to /out.
Runs inside the compose network; browser resolves 'web' and 'api' directly.
"""
import json
import sys
import time
import urllib.request
from playwright.sync_api import sync_playwright
BASE = "http://web"
API = "http://api:8000/api"
OUT = "/out"
def api(method, path, body=None):
req = urllib.request.Request(
API + path,
data=json.dumps(body).encode() if body else None,
method=method,
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
def main():
# Idempotent: ensures full demo dataset exists
seed = api("POST", "/concierge/seed-demo")
print("seed:", json.dumps(seed)[:200])
apps = api("GET", "/applications")
interview = next((a for a in apps if a["state"] == "interviewing"), apps[0])
detail_id = interview["id"]
print("detail id:", detail_id)
shots = [
("welcome", "/", {}), # first-run wizard may redirect away; force below
("welcome", "/welcome", {}),
("today", "/today", {}),
("cv", "/cv", {}),
("research", "/research", {}),
("applications", "/applications", {}),
("detail", f"/applications/{detail_id}", {}),
]
with sync_playwright() as p:
browser = p.chromium.launch(args=["--disable-dev-shm-usage"])
page = browser.new_page(viewport={"width": 1440, "height": 900},
device_scale_factor=2)
for name, path, _opts in shots[1:]: # skip the "/" duplicate
try:
page.goto(BASE + path, wait_until="networkidle", timeout=30000)
page.wait_for_timeout(1200)
# close possible wizard redirect back
if name != "welcome" and page.url.endswith("/welcome"):
page.goto(BASE + path, wait_until="networkidle", timeout=30000)
page.wait_for_timeout(800)
page.screenshot(path=f"{OUT}/{name}.png")
print("shot:", name, "<-", page.url)
except Exception as e:
print("FAILED:", name, type(e).__name__, str(e)[:150])
# Interaction shot: open the Tailor CV panel on an approved application
approved = next((a for a in apps if a["state"] in ("approved", "interviewing")), apps[0])
try:
page.goto(f"{BASE}/applications/{approved['id']}", wait_until="networkidle", timeout=30000)
page.wait_for_timeout(1000)
btn = page.locator('[data-testid="tailor-cv-btn"]')
btn.click(timeout=8000)
page.wait_for_selector('[data-testid="tailor-panel"]', timeout=20000)
page.wait_for_timeout(800)
page.screenshot(path=f"{OUT}/detail-tailor.png")
print("shot: detail-tailor")
except Exception as e:
print("FAILED: detail-tailor", type(e).__name__, str(e)[:150])
browser.close()
print("DONE")
if __name__ == "__main__":
sys.exit(main())