Compare commits
No commits in common. "065c02feb7c66967c0f13d9430afc31f5e2c2221" and "173da4908703b1b320433a4ca4b44ba267e00668" have entirely different histories.
065c02feb7
...
173da49087
13 changed files with 262 additions and 1631 deletions
|
|
@ -154,7 +154,7 @@ Re-enter the wizard any time via **Settings → Developer → Reset wizard**.
|
|||
| Calendar sync (Google, Apple) | Paid |
|
||||
| Slack notifications | Paid |
|
||||
| CircuitForge shared cover-letter model | Paid |
|
||||
| Vue 3 SPA — full UI with onboarding wizard, job board, apply workspace, sort/filter, research modal, draft cover letter | Free |
|
||||
| Vue 3 SPA beta UI | Paid |
|
||||
| **Voice guidelines** (custom writing style & tone) | Premium with LLM¹ ² |
|
||||
| Cover letter model fine-tuning (your writing, your model) | Premium |
|
||||
| Multi-user support | Premium |
|
||||
|
|
|
|||
287
app/Home.py
287
app/Home.py
|
|
@ -19,8 +19,8 @@ _profile = UserProfile(_USER_YAML) if UserProfile.exists(_USER_YAML) else None
|
|||
_name = _profile.name if _profile else "Job Seeker"
|
||||
|
||||
from scripts.db import init_db, get_job_counts, purge_jobs, purge_email_data, \
|
||||
purge_non_remote, archive_jobs, kill_stuck_tasks, cancel_task, \
|
||||
get_task_for_job, get_active_tasks, insert_job, get_existing_urls
|
||||
purge_non_remote, archive_jobs, kill_stuck_tasks, get_task_for_job, get_active_tasks, \
|
||||
insert_job, get_existing_urls
|
||||
from scripts.task_runner import submit_task
|
||||
from app.cloud_session import resolve_session, get_db_path
|
||||
|
||||
|
|
@ -376,145 +376,178 @@ _scrape_status()
|
|||
|
||||
st.divider()
|
||||
|
||||
# ── Danger zone ───────────────────────────────────────────────────────────────
|
||||
# ── Danger zone: purge + re-scrape ────────────────────────────────────────────
|
||||
with st.expander("⚠️ Danger Zone", expanded=False):
|
||||
|
||||
# ── Queue reset (the common case) ─────────────────────────────────────────
|
||||
st.markdown("**Queue reset**")
|
||||
st.caption(
|
||||
"Archive clears your review queue while keeping job URLs for dedup, "
|
||||
"so the same listings won't resurface on the next discovery run. "
|
||||
"Use hard purge only if you want a full clean slate including dedup history."
|
||||
"**Purge** permanently deletes jobs from the local database. "
|
||||
"Applied and synced jobs are never touched."
|
||||
)
|
||||
|
||||
_scope = st.radio(
|
||||
"Clear scope",
|
||||
["Pending only", "Pending + approved (stale search)"],
|
||||
horizontal=True,
|
||||
label_visibility="collapsed",
|
||||
)
|
||||
_scope_statuses = (
|
||||
["pending"] if _scope == "Pending only" else ["pending", "approved"]
|
||||
)
|
||||
purge_col, rescrape_col, email_col, tasks_col = st.columns(4)
|
||||
|
||||
_qc1, _qc2, _qc3 = st.columns([2, 2, 4])
|
||||
if _qc1.button("📦 Archive & reset", use_container_width=True, type="primary"):
|
||||
st.session_state["confirm_dz"] = "archive"
|
||||
if _qc2.button("🗑 Hard purge (delete)", use_container_width=True):
|
||||
st.session_state["confirm_dz"] = "purge"
|
||||
with purge_col:
|
||||
st.markdown("**Purge pending & rejected**")
|
||||
st.caption("Removes all _pending_ and _rejected_ listings so the next discovery starts fresh.")
|
||||
if st.button("🗑 Purge Pending + Rejected", use_container_width=True):
|
||||
st.session_state["confirm_purge"] = "partial"
|
||||
|
||||
if st.session_state.get("confirm_dz") == "archive":
|
||||
st.info(
|
||||
f"Archive **{', '.join(_scope_statuses)}** jobs? "
|
||||
"URLs are kept for dedup — nothing is permanently deleted."
|
||||
)
|
||||
_dc1, _dc2 = st.columns(2)
|
||||
if _dc1.button("Yes, archive", type="primary", use_container_width=True, key="dz_archive_confirm"):
|
||||
n = archive_jobs(get_db_path(), statuses=_scope_statuses)
|
||||
st.success(f"Archived {n} jobs.")
|
||||
st.session_state.pop("confirm_dz", None)
|
||||
st.rerun()
|
||||
if _dc2.button("Cancel", use_container_width=True, key="dz_archive_cancel"):
|
||||
st.session_state.pop("confirm_dz", None)
|
||||
st.rerun()
|
||||
|
||||
if st.session_state.get("confirm_dz") == "purge":
|
||||
st.warning(
|
||||
f"Permanently delete **{', '.join(_scope_statuses)}** jobs? "
|
||||
"This removes the URLs from dedup history too. Cannot be undone."
|
||||
)
|
||||
_dc1, _dc2 = st.columns(2)
|
||||
if _dc1.button("Yes, delete", type="primary", use_container_width=True, key="dz_purge_confirm"):
|
||||
n = purge_jobs(get_db_path(), statuses=_scope_statuses)
|
||||
st.success(f"Deleted {n} jobs.")
|
||||
st.session_state.pop("confirm_dz", None)
|
||||
st.rerun()
|
||||
if _dc2.button("Cancel", use_container_width=True, key="dz_purge_cancel"):
|
||||
st.session_state.pop("confirm_dz", None)
|
||||
st.rerun()
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── Background tasks ──────────────────────────────────────────────────────
|
||||
_active = get_active_tasks(get_db_path())
|
||||
st.markdown(f"**Background tasks** — {len(_active)} active")
|
||||
|
||||
if _active:
|
||||
_task_icons = {"cover_letter": "✉️", "research": "🔍", "discovery": "🌐", "enrich_descriptions": "📝"}
|
||||
for _t in _active:
|
||||
_tc1, _tc2, _tc3 = st.columns([3, 4, 2])
|
||||
_icon = _task_icons.get(_t["task_type"], "⚙️")
|
||||
_tc1.caption(f"{_icon} `{_t['task_type']}`")
|
||||
_job_label = f"{_t['title']} @ {_t['company']}" if _t.get("title") else f"job #{_t['job_id']}"
|
||||
_tc2.caption(_job_label)
|
||||
_tc3.caption(f"_{_t['status']}_")
|
||||
if st.button("✕ Cancel", key=f"dz_cancel_task_{_t['id']}", use_container_width=True):
|
||||
cancel_task(get_db_path(), _t["id"])
|
||||
if st.session_state.get("confirm_purge") == "partial":
|
||||
st.warning("Are you sure? This cannot be undone.")
|
||||
c1, c2 = st.columns(2)
|
||||
if c1.button("Yes, purge", type="primary", use_container_width=True):
|
||||
deleted = purge_jobs(get_db_path(), statuses=["pending", "rejected"])
|
||||
st.success(f"Purged {deleted} jobs.")
|
||||
st.session_state.pop("confirm_purge", None)
|
||||
st.rerun()
|
||||
if c2.button("Cancel", use_container_width=True):
|
||||
st.session_state.pop("confirm_purge", None)
|
||||
st.rerun()
|
||||
st.caption("")
|
||||
|
||||
_kill_col, _ = st.columns([2, 6])
|
||||
if _kill_col.button("⏹ Kill all stuck", use_container_width=True, disabled=len(_active) == 0):
|
||||
killed = kill_stuck_tasks(get_db_path())
|
||||
st.success(f"Killed {killed} task(s).")
|
||||
st.rerun()
|
||||
with email_col:
|
||||
st.markdown("**Purge email data**")
|
||||
st.caption("Clears all email thread logs and email-sourced pending jobs so the next sync starts fresh.")
|
||||
if st.button("📧 Purge Email Data", use_container_width=True):
|
||||
st.session_state["confirm_purge"] = "email"
|
||||
|
||||
if st.session_state.get("confirm_purge") == "email":
|
||||
st.warning("This deletes all email contacts and email-sourced jobs. Cannot be undone.")
|
||||
c1, c2 = st.columns(2)
|
||||
if c1.button("Yes, purge emails", type="primary", use_container_width=True):
|
||||
contacts, jobs = purge_email_data(get_db_path())
|
||||
st.success(f"Purged {contacts} email contacts, {jobs} email jobs.")
|
||||
st.session_state.pop("confirm_purge", None)
|
||||
st.rerun()
|
||||
if c2.button("Cancel ", use_container_width=True):
|
||||
st.session_state.pop("confirm_purge", None)
|
||||
st.rerun()
|
||||
|
||||
with tasks_col:
|
||||
_active = get_active_tasks(get_db_path())
|
||||
st.markdown("**Kill stuck tasks**")
|
||||
st.caption(f"Force-fail all queued/running background tasks. Currently **{len(_active)}** active.")
|
||||
if st.button("⏹ Kill All Tasks", use_container_width=True, disabled=len(_active) == 0):
|
||||
killed = kill_stuck_tasks(get_db_path())
|
||||
st.success(f"Killed {killed} task(s).")
|
||||
st.rerun()
|
||||
|
||||
with rescrape_col:
|
||||
st.markdown("**Purge all & re-scrape**")
|
||||
st.caption("Wipes _all_ non-applied, non-synced jobs then immediately runs a fresh discovery.")
|
||||
if st.button("🔄 Purge All + Re-scrape", use_container_width=True):
|
||||
st.session_state["confirm_purge"] = "full"
|
||||
|
||||
if st.session_state.get("confirm_purge") == "full":
|
||||
st.warning("This will delete ALL pending, approved, and rejected jobs, then re-scrape. Applied and synced records are kept.")
|
||||
c1, c2 = st.columns(2)
|
||||
if c1.button("Yes, wipe + scrape", type="primary", use_container_width=True):
|
||||
purge_jobs(get_db_path(), statuses=["pending", "approved", "rejected"])
|
||||
submit_task(get_db_path(), "discovery", 0)
|
||||
st.session_state.pop("confirm_purge", None)
|
||||
st.rerun()
|
||||
if c2.button("Cancel ", use_container_width=True):
|
||||
st.session_state.pop("confirm_purge", None)
|
||||
st.rerun()
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── Rarely needed (collapsed) ─────────────────────────────────────────────
|
||||
with st.expander("More options", expanded=False):
|
||||
_rare1, _rare2, _rare3 = st.columns(3)
|
||||
pending_col, nonremote_col, approved_col, _ = st.columns(4)
|
||||
|
||||
with _rare1:
|
||||
st.markdown("**Purge email data**")
|
||||
st.caption("Clears all email thread logs and email-sourced pending jobs.")
|
||||
if st.button("📧 Purge Email Data", use_container_width=True):
|
||||
st.session_state["confirm_dz"] = "email"
|
||||
if st.session_state.get("confirm_dz") == "email":
|
||||
st.warning("Deletes all email contacts and email-sourced jobs. Cannot be undone.")
|
||||
_ec1, _ec2 = st.columns(2)
|
||||
if _ec1.button("Yes, purge emails", type="primary", use_container_width=True, key="dz_email_confirm"):
|
||||
contacts, jobs = purge_email_data(get_db_path())
|
||||
st.success(f"Purged {contacts} email contacts, {jobs} email jobs.")
|
||||
st.session_state.pop("confirm_dz", None)
|
||||
st.rerun()
|
||||
if _ec2.button("Cancel", use_container_width=True, key="dz_email_cancel"):
|
||||
st.session_state.pop("confirm_dz", None)
|
||||
st.rerun()
|
||||
with pending_col:
|
||||
st.markdown("**Purge pending review**")
|
||||
st.caption("Removes only _pending_ listings, keeping your rejected history intact.")
|
||||
if st.button("🗑 Purge Pending Only", use_container_width=True):
|
||||
st.session_state["confirm_purge"] = "pending_only"
|
||||
|
||||
with _rare2:
|
||||
st.markdown("**Purge non-remote**")
|
||||
st.caption("Removes pending/approved/rejected on-site listings from the DB.")
|
||||
if st.button("🏢 Purge On-site Jobs", use_container_width=True):
|
||||
st.session_state["confirm_dz"] = "non_remote"
|
||||
if st.session_state.get("confirm_dz") == "non_remote":
|
||||
st.warning("Deletes all non-remote jobs not yet applied to. Cannot be undone.")
|
||||
_rc1, _rc2 = st.columns(2)
|
||||
if _rc1.button("Yes, purge on-site", type="primary", use_container_width=True, key="dz_nonremote_confirm"):
|
||||
deleted = purge_non_remote(get_db_path())
|
||||
st.success(f"Purged {deleted} non-remote jobs.")
|
||||
st.session_state.pop("confirm_dz", None)
|
||||
st.rerun()
|
||||
if _rc2.button("Cancel", use_container_width=True, key="dz_nonremote_cancel"):
|
||||
st.session_state.pop("confirm_dz", None)
|
||||
st.rerun()
|
||||
if st.session_state.get("confirm_purge") == "pending_only":
|
||||
st.warning("Deletes all pending jobs. Rejected jobs are kept. Cannot be undone.")
|
||||
c1, c2 = st.columns(2)
|
||||
if c1.button("Yes, purge pending", type="primary", use_container_width=True):
|
||||
deleted = purge_jobs(get_db_path(), statuses=["pending"])
|
||||
st.success(f"Purged {deleted} pending jobs.")
|
||||
st.session_state.pop("confirm_purge", None)
|
||||
st.rerun()
|
||||
if c2.button("Cancel ", use_container_width=True):
|
||||
st.session_state.pop("confirm_purge", None)
|
||||
st.rerun()
|
||||
|
||||
with _rare3:
|
||||
st.markdown("**Wipe all + re-scrape**")
|
||||
st.caption("Deletes all non-applied jobs then immediately runs a fresh discovery.")
|
||||
if st.button("🔄 Wipe + Re-scrape", use_container_width=True):
|
||||
st.session_state["confirm_dz"] = "rescrape"
|
||||
if st.session_state.get("confirm_dz") == "rescrape":
|
||||
st.warning("Wipes ALL pending, approved, and rejected jobs, then re-scrapes. Applied and synced records are kept.")
|
||||
_wc1, _wc2 = st.columns(2)
|
||||
if _wc1.button("Yes, wipe + scrape", type="primary", use_container_width=True, key="dz_rescrape_confirm"):
|
||||
purge_jobs(get_db_path(), statuses=["pending", "approved", "rejected"])
|
||||
submit_task(get_db_path(), "discovery", 0)
|
||||
st.session_state.pop("confirm_dz", None)
|
||||
st.rerun()
|
||||
if _wc2.button("Cancel", use_container_width=True, key="dz_rescrape_cancel"):
|
||||
st.session_state.pop("confirm_dz", None)
|
||||
st.rerun()
|
||||
with nonremote_col:
|
||||
st.markdown("**Purge non-remote**")
|
||||
st.caption("Removes pending/approved/rejected jobs where remote is not set. Keeps anything already in the pipeline.")
|
||||
if st.button("🏢 Purge On-site Jobs", use_container_width=True):
|
||||
st.session_state["confirm_purge"] = "non_remote"
|
||||
|
||||
if st.session_state.get("confirm_purge") == "non_remote":
|
||||
st.warning("Deletes all non-remote jobs not yet applied to. Cannot be undone.")
|
||||
c1, c2 = st.columns(2)
|
||||
if c1.button("Yes, purge on-site", type="primary", use_container_width=True):
|
||||
deleted = purge_non_remote(get_db_path())
|
||||
st.success(f"Purged {deleted} non-remote jobs.")
|
||||
st.session_state.pop("confirm_purge", None)
|
||||
st.rerun()
|
||||
if c2.button("Cancel ", use_container_width=True):
|
||||
st.session_state.pop("confirm_purge", None)
|
||||
st.rerun()
|
||||
|
||||
with approved_col:
|
||||
st.markdown("**Purge approved (unapplied)**")
|
||||
st.caption("Removes _approved_ jobs you haven't applied to yet — e.g. to reset after a review pass.")
|
||||
if st.button("🗑 Purge Approved", use_container_width=True):
|
||||
st.session_state["confirm_purge"] = "approved_only"
|
||||
|
||||
if st.session_state.get("confirm_purge") == "approved_only":
|
||||
st.warning("Deletes all approved-but-not-applied jobs. Cannot be undone.")
|
||||
c1, c2 = st.columns(2)
|
||||
if c1.button("Yes, purge approved", type="primary", use_container_width=True):
|
||||
deleted = purge_jobs(get_db_path(), statuses=["approved"])
|
||||
st.success(f"Purged {deleted} approved jobs.")
|
||||
st.session_state.pop("confirm_purge", None)
|
||||
st.rerun()
|
||||
if c2.button("Cancel ", use_container_width=True):
|
||||
st.session_state.pop("confirm_purge", None)
|
||||
st.rerun()
|
||||
|
||||
st.divider()
|
||||
|
||||
archive_col1, archive_col2, _, _ = st.columns(4)
|
||||
|
||||
with archive_col1:
|
||||
st.markdown("**Archive remaining**")
|
||||
st.caption(
|
||||
"Move all _pending_ and _rejected_ jobs to archived status. "
|
||||
"Archived jobs stay in the DB for dedup — they just won't appear in Job Review."
|
||||
)
|
||||
if st.button("📦 Archive Pending + Rejected", use_container_width=True):
|
||||
st.session_state["confirm_purge"] = "archive_remaining"
|
||||
|
||||
if st.session_state.get("confirm_purge") == "archive_remaining":
|
||||
st.info("Jobs will be archived (not deleted) — URLs are kept for dedup.")
|
||||
c1, c2 = st.columns(2)
|
||||
if c1.button("Yes, archive", type="primary", use_container_width=True):
|
||||
archived = archive_jobs(get_db_path(), statuses=["pending", "rejected"])
|
||||
st.success(f"Archived {archived} jobs.")
|
||||
st.session_state.pop("confirm_purge", None)
|
||||
st.rerun()
|
||||
if c2.button("Cancel ", use_container_width=True):
|
||||
st.session_state.pop("confirm_purge", None)
|
||||
st.rerun()
|
||||
|
||||
with archive_col2:
|
||||
st.markdown("**Archive approved (unapplied)**")
|
||||
st.caption("Archive _approved_ listings you decided to skip — keeps history without cluttering the apply queue.")
|
||||
if st.button("📦 Archive Approved", use_container_width=True):
|
||||
st.session_state["confirm_purge"] = "archive_approved"
|
||||
|
||||
if st.session_state.get("confirm_purge") == "archive_approved":
|
||||
st.info("Approved jobs will be archived (not deleted).")
|
||||
c1, c2 = st.columns(2)
|
||||
if c1.button("Yes, archive approved", type="primary", use_container_width=True):
|
||||
archived = archive_jobs(get_db_path(), statuses=["approved"])
|
||||
st.success(f"Archived {archived} approved jobs.")
|
||||
st.session_state.pop("confirm_purge", None)
|
||||
st.rerun()
|
||||
if c2.button("Cancel ", use_container_width=True):
|
||||
st.session_state.pop("confirm_purge", None)
|
||||
st.rerun()
|
||||
|
||||
# ── Setup banners ─────────────────────────────────────────────────────────────
|
||||
if _profile and _profile.wizard_complete:
|
||||
|
|
|
|||
|
|
@ -45,30 +45,6 @@ services:
|
|||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: peregrine/Dockerfile.cfcore
|
||||
command: >
|
||||
bash -c "uvicorn dev_api:app --host 0.0.0.0 --port 8601"
|
||||
volumes:
|
||||
- /devl/menagerie-data:/devl/menagerie-data
|
||||
- ./config/llm.cloud.yaml:/app/config/llm.yaml:ro
|
||||
environment:
|
||||
- CLOUD_MODE=true
|
||||
- CLOUD_DATA_ROOT=/devl/menagerie-data
|
||||
- STAGING_DB=/devl/menagerie-data/cloud-default.db
|
||||
- DIRECTUS_JWT_SECRET=${DIRECTUS_JWT_SECRET}
|
||||
- CF_SERVER_SECRET=${CF_SERVER_SECRET}
|
||||
- PLATFORM_DB_URL=${PLATFORM_DB_URL}
|
||||
- HEIMDALL_URL=${HEIMDALL_URL:-http://cf-license:8000}
|
||||
- HEIMDALL_ADMIN_TOKEN=${HEIMDALL_ADMIN_TOKEN}
|
||||
- PYTHONUNBUFFERED=1
|
||||
- FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
|
|
@ -77,8 +53,6 @@ services:
|
|||
VITE_BASE_PATH: /peregrine/
|
||||
ports:
|
||||
- "8508:80"
|
||||
depends_on:
|
||||
- api
|
||||
restart: unless-stopped
|
||||
|
||||
searxng:
|
||||
|
|
|
|||
577
dev-api.py
577
dev-api.py
|
|
@ -15,7 +15,6 @@ import ssl as ssl_mod
|
|||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
|
|
@ -24,7 +23,7 @@ from urllib.parse import urlparse
|
|||
import requests
|
||||
import yaml
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi import FastAPI, HTTPException, Request, Response, UploadFile
|
||||
from fastapi import FastAPI, HTTPException, Response, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
|
@ -33,18 +32,10 @@ PEREGRINE_ROOT = Path("/Library/Development/CircuitForge/peregrine")
|
|||
if str(PEREGRINE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PEREGRINE_ROOT))
|
||||
|
||||
from circuitforge_core.config.settings import load_env as _load_env # noqa: E402
|
||||
from scripts.credential_store import get_credential, set_credential, delete_credential # noqa: E402
|
||||
|
||||
DB_PATH = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db")
|
||||
|
||||
_CLOUD_MODE = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true")
|
||||
_CLOUD_DATA_ROOT = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/menagerie-data"))
|
||||
_DIRECTUS_SECRET = os.environ.get("DIRECTUS_JWT_SECRET", "")
|
||||
|
||||
# Per-request DB path — set by cloud_session_middleware; falls back to DB_PATH
|
||||
_request_db: ContextVar[str | None] = ContextVar("_request_db", default=None)
|
||||
|
||||
app = FastAPI(title="Peregrine Dev API")
|
||||
|
||||
app.add_middleware(
|
||||
|
|
@ -55,65 +46,8 @@ app.add_middleware(
|
|||
)
|
||||
|
||||
|
||||
_log = logging.getLogger("peregrine.session")
|
||||
|
||||
def _resolve_cf_user_id(cookie_str: str) -> str | None:
|
||||
"""Extract cf_session JWT from Cookie string and return Directus user_id.
|
||||
|
||||
Directus signs with the raw bytes of its JWT_SECRET (which is base64-encoded
|
||||
in env). Try the raw string first, then fall back to base64-decoded bytes.
|
||||
"""
|
||||
if not cookie_str:
|
||||
_log.debug("_resolve_cf_user_id: empty cookie string")
|
||||
return None
|
||||
m = re.search(r'(?:^|;)\s*cf_session=([^;]+)', cookie_str)
|
||||
if not m:
|
||||
_log.debug("_resolve_cf_user_id: no cf_session in cookie: %s…", cookie_str[:80])
|
||||
return None
|
||||
token = m.group(1).strip()
|
||||
import base64
|
||||
import jwt # PyJWT
|
||||
secrets_to_try: list[str | bytes] = [_DIRECTUS_SECRET]
|
||||
try:
|
||||
secrets_to_try.append(base64.b64decode(_DIRECTUS_SECRET))
|
||||
except Exception:
|
||||
pass
|
||||
# Skip exp verification — we use the token for routing only, not auth.
|
||||
# Directus manages actual auth; Caddy gates on cookie presence.
|
||||
decode_opts = {"verify_exp": False}
|
||||
for secret in secrets_to_try:
|
||||
try:
|
||||
payload = jwt.decode(token, secret, algorithms=["HS256"], options=decode_opts)
|
||||
user_id = payload.get("id") or payload.get("sub")
|
||||
if user_id:
|
||||
_log.debug("_resolve_cf_user_id: resolved user_id=%s", user_id)
|
||||
return user_id
|
||||
except Exception as exc:
|
||||
_log.debug("_resolve_cf_user_id: decode failed (%s): %s", type(exc).__name__, exc)
|
||||
continue
|
||||
_log.warning("_resolve_cf_user_id: all secrets failed for token prefix %s…", token[:20])
|
||||
return None
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def cloud_session_middleware(request: Request, call_next):
|
||||
"""In cloud mode, resolve per-user staging.db from the X-CF-Session header."""
|
||||
if _CLOUD_MODE and _DIRECTUS_SECRET:
|
||||
cookie_header = request.headers.get("X-CF-Session", "")
|
||||
user_id = _resolve_cf_user_id(cookie_header)
|
||||
if user_id:
|
||||
user_db = str(_CLOUD_DATA_ROOT / user_id / "peregrine" / "staging.db")
|
||||
token = _request_db.set(user_db)
|
||||
try:
|
||||
return await call_next(request)
|
||||
finally:
|
||||
_request_db.reset(token)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def _get_db():
|
||||
path = _request_db.get() or DB_PATH
|
||||
db = sqlite3.connect(path)
|
||||
db = sqlite3.connect(DB_PATH)
|
||||
db.row_factory = sqlite3.Row
|
||||
return db
|
||||
|
||||
|
|
@ -132,10 +66,7 @@ def _strip_html(text: str | None) -> str | None:
|
|||
|
||||
@app.on_event("startup")
|
||||
def _startup():
|
||||
"""Load .env then ensure digest_queue table exists."""
|
||||
# Load .env before any runtime env reads — safe because startup doesn't run
|
||||
# when dev_api is imported by tests (only when uvicorn actually starts).
|
||||
_load_env(PEREGRINE_ROOT / ".env")
|
||||
"""Ensure digest_queue table exists (dev-api may run against an existing DB)."""
|
||||
db = _get_db()
|
||||
try:
|
||||
db.execute("""
|
||||
|
|
@ -689,117 +620,6 @@ def download_pdf(job_id: int):
|
|||
raise HTTPException(501, "reportlab not installed — install it to generate PDFs")
|
||||
|
||||
|
||||
# ── Application Q&A endpoints ─────────────────────────────────────────────────
|
||||
|
||||
def _ensure_qa_column(db) -> None:
|
||||
"""Add application_qa TEXT column to jobs if not present (idempotent)."""
|
||||
try:
|
||||
db.execute("ALTER TABLE jobs ADD COLUMN application_qa TEXT")
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass # Column already exists
|
||||
|
||||
|
||||
class QAItem(BaseModel):
|
||||
id: str
|
||||
question: str
|
||||
answer: str
|
||||
|
||||
|
||||
class QAPayload(BaseModel):
|
||||
items: List[QAItem]
|
||||
|
||||
|
||||
class QASuggestPayload(BaseModel):
|
||||
question: str
|
||||
|
||||
|
||||
@app.get("/api/jobs/{job_id}/qa")
|
||||
def get_qa(job_id: int):
|
||||
db = _get_db()
|
||||
_ensure_qa_column(db)
|
||||
row = db.execute("SELECT application_qa FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
||||
db.close()
|
||||
if not row:
|
||||
raise HTTPException(404, "Job not found")
|
||||
try:
|
||||
items = json.loads(row["application_qa"] or "[]")
|
||||
except Exception:
|
||||
items = []
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@app.patch("/api/jobs/{job_id}/qa")
|
||||
def save_qa(job_id: int, payload: QAPayload):
|
||||
db = _get_db()
|
||||
_ensure_qa_column(db)
|
||||
row = db.execute("SELECT id FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
||||
if not row:
|
||||
db.close()
|
||||
raise HTTPException(404, "Job not found")
|
||||
db.execute(
|
||||
"UPDATE jobs SET application_qa = ? WHERE id = ?",
|
||||
(json.dumps([item.model_dump() for item in payload.items]), job_id),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/jobs/{job_id}/qa/suggest")
|
||||
def suggest_qa_answer(job_id: int, payload: QASuggestPayload):
|
||||
"""Synchronously generate an LLM answer for an application Q&A question."""
|
||||
db = _get_db()
|
||||
job_row = db.execute(
|
||||
"SELECT title, company, description FROM jobs WHERE id = ?", (job_id,)
|
||||
).fetchone()
|
||||
db.close()
|
||||
if not job_row:
|
||||
raise HTTPException(404, "Job not found")
|
||||
|
||||
# Load resume summary for context
|
||||
resume_context = ""
|
||||
try:
|
||||
resume_path = _resume_path()
|
||||
if resume_path.exists():
|
||||
with open(resume_path) as f:
|
||||
resume_data = yaml.safe_load(f) or {}
|
||||
parts = []
|
||||
if resume_data.get("name"):
|
||||
parts.append(f"Candidate: {resume_data['name']}")
|
||||
if resume_data.get("skills"):
|
||||
parts.append(f"Skills: {', '.join(resume_data['skills'][:20])}")
|
||||
if resume_data.get("experience"):
|
||||
exp = resume_data["experience"]
|
||||
if isinstance(exp, list) and exp:
|
||||
titles = [e.get("title", "") for e in exp[:3] if e.get("title")]
|
||||
if titles:
|
||||
parts.append(f"Recent roles: {', '.join(titles)}")
|
||||
if resume_data.get("career_summary"):
|
||||
parts.append(f"Summary: {resume_data['career_summary'][:400]}")
|
||||
resume_context = "\n".join(parts)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
prompt = (
|
||||
f"You are helping a job applicant answer an application question.\n\n"
|
||||
f"Job: {job_row['title']} at {job_row['company']}\n"
|
||||
f"Job description excerpt:\n{(job_row['description'] or '')[:800]}\n\n"
|
||||
f"Candidate background:\n{resume_context or 'Not provided'}\n\n"
|
||||
f"Application question: {payload.question}\n\n"
|
||||
"Write a concise, professional answer (2–4 sentences) in first person. "
|
||||
"Be specific and genuine. Do not use hollow filler phrases."
|
||||
)
|
||||
|
||||
try:
|
||||
from scripts.llm_router import LLMRouter
|
||||
router = LLMRouter()
|
||||
answer = router.complete(prompt)
|
||||
return {"answer": answer.strip()}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"LLM generation failed: {e}")
|
||||
|
||||
|
||||
# ── GET /api/interviews ────────────────────────────────────────────────────────
|
||||
|
||||
PIPELINE_STATUSES = {
|
||||
|
|
@ -895,230 +715,6 @@ def email_sync_status():
|
|||
}
|
||||
|
||||
|
||||
# ── Task management routes ─────────────────────────────────────────────────────
|
||||
|
||||
def _db_path() -> Path:
|
||||
"""Return the effective staging.db path (cloud-aware)."""
|
||||
return Path(_request_db.get() or DB_PATH)
|
||||
|
||||
|
||||
@app.get("/api/tasks")
|
||||
def list_active_tasks():
|
||||
from scripts.db import get_active_tasks
|
||||
return get_active_tasks(_db_path())
|
||||
|
||||
|
||||
@app.delete("/api/tasks/{task_id}")
|
||||
def cancel_task_by_id(task_id: int):
|
||||
from scripts.db import cancel_task
|
||||
ok = cancel_task(_db_path(), task_id)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
@app.post("/api/tasks/kill")
|
||||
def kill_stuck():
|
||||
from scripts.db import kill_stuck_tasks
|
||||
killed = kill_stuck_tasks(_db_path())
|
||||
return {"killed": killed}
|
||||
|
||||
|
||||
@app.post("/api/tasks/discovery", status_code=202)
|
||||
def trigger_discovery():
|
||||
from scripts.task_runner import submit_task
|
||||
task_id, is_new = submit_task(_db_path(), "discovery", 0)
|
||||
return {"task_id": task_id, "is_new": is_new}
|
||||
|
||||
|
||||
@app.post("/api/tasks/email-sync", status_code=202)
|
||||
def trigger_email_sync_task():
|
||||
from scripts.task_runner import submit_task
|
||||
task_id, is_new = submit_task(_db_path(), "email_sync", 0)
|
||||
return {"task_id": task_id, "is_new": is_new}
|
||||
|
||||
|
||||
@app.post("/api/tasks/enrich", status_code=202)
|
||||
def trigger_enrich_task():
|
||||
from scripts.task_runner import submit_task
|
||||
task_id, is_new = submit_task(_db_path(), "enrich_descriptions", 0)
|
||||
return {"task_id": task_id, "is_new": is_new}
|
||||
|
||||
|
||||
@app.post("/api/tasks/score")
|
||||
def trigger_score():
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "scripts/match.py"],
|
||||
capture_output=True, text=True, cwd=str(PEREGRINE_ROOT),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return {"ok": True, "output": result.stdout}
|
||||
raise HTTPException(status_code=500, detail=result.stderr)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/tasks/sync")
|
||||
def trigger_notion_sync():
|
||||
try:
|
||||
from scripts.sync import sync_to_notion
|
||||
count = sync_to_notion(_db_path())
|
||||
return {"ok": True, "count": count}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ── Bulk job actions ───────────────────────────────────────────────────────────
|
||||
|
||||
class BulkArchiveBody(BaseModel):
|
||||
statuses: List[str]
|
||||
|
||||
|
||||
@app.post("/api/jobs/archive")
|
||||
def bulk_archive_jobs(body: BulkArchiveBody):
|
||||
from scripts.db import archive_jobs
|
||||
n = archive_jobs(_db_path(), statuses=body.statuses)
|
||||
return {"archived": n}
|
||||
|
||||
|
||||
class BulkPurgeBody(BaseModel):
|
||||
statuses: Optional[List[str]] = None
|
||||
target: Optional[str] = None # "email", "non_remote", "rescrape"
|
||||
|
||||
|
||||
@app.post("/api/jobs/purge")
|
||||
def bulk_purge_jobs(body: BulkPurgeBody):
|
||||
from scripts.db import purge_jobs, purge_email_data, purge_non_remote
|
||||
if body.target == "email":
|
||||
contacts, jobs = purge_email_data(_db_path())
|
||||
return {"ok": True, "contacts": contacts, "jobs": jobs}
|
||||
if body.target == "non_remote":
|
||||
n = purge_non_remote(_db_path())
|
||||
return {"ok": True, "deleted": n}
|
||||
if body.target == "rescrape":
|
||||
purge_jobs(_db_path(), statuses=["pending", "approved", "rejected"])
|
||||
from scripts.task_runner import submit_task
|
||||
submit_task(_db_path(), "discovery", 0)
|
||||
return {"ok": True}
|
||||
statuses = body.statuses or ["pending", "rejected"]
|
||||
n = purge_jobs(_db_path(), statuses=statuses)
|
||||
return {"ok": True, "deleted": n}
|
||||
|
||||
|
||||
class AddJobsBody(BaseModel):
|
||||
urls: List[str]
|
||||
|
||||
|
||||
@app.post("/api/jobs/add", status_code=202)
|
||||
def add_jobs_by_url(body: AddJobsBody):
|
||||
try:
|
||||
from datetime import datetime as _dt
|
||||
from scripts.scrape_url import canonicalize_url
|
||||
from scripts.db import get_existing_urls, insert_job
|
||||
from scripts.task_runner import submit_task
|
||||
db_path = _db_path()
|
||||
existing = get_existing_urls(db_path)
|
||||
queued = 0
|
||||
for raw_url in body.urls:
|
||||
url = canonicalize_url(raw_url.strip())
|
||||
if not url.startswith("http") or url in existing:
|
||||
continue
|
||||
job_id = insert_job(db_path, {
|
||||
"title": "Importing...", "company": "", "url": url,
|
||||
"source": "manual", "location": "", "description": "",
|
||||
"date_found": _dt.now().isoformat()[:10],
|
||||
})
|
||||
if job_id:
|
||||
submit_task(db_path, "scrape_url", job_id)
|
||||
queued += 1
|
||||
return {"queued": queued}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/jobs/upload-csv", status_code=202)
|
||||
async def upload_jobs_csv(file: UploadFile):
|
||||
try:
|
||||
import csv as _csv
|
||||
import io as _io
|
||||
from datetime import datetime as _dt
|
||||
from scripts.scrape_url import canonicalize_url
|
||||
from scripts.db import get_existing_urls, insert_job
|
||||
from scripts.task_runner import submit_task
|
||||
content = await file.read()
|
||||
reader = _csv.DictReader(_io.StringIO(content.decode("utf-8", errors="replace")))
|
||||
urls: list[str] = []
|
||||
for row in reader:
|
||||
for val in row.values():
|
||||
if val and val.strip().startswith("http"):
|
||||
urls.append(val.strip())
|
||||
break
|
||||
db_path = _db_path()
|
||||
existing = get_existing_urls(db_path)
|
||||
queued = 0
|
||||
for raw_url in urls:
|
||||
url = canonicalize_url(raw_url)
|
||||
if not url.startswith("http") or url in existing:
|
||||
continue
|
||||
job_id = insert_job(db_path, {
|
||||
"title": "Importing...", "company": "", "url": url,
|
||||
"source": "manual", "location": "", "description": "",
|
||||
"date_found": _dt.now().isoformat()[:10],
|
||||
})
|
||||
if job_id:
|
||||
submit_task(db_path, "scrape_url", job_id)
|
||||
queued += 1
|
||||
return {"queued": queued, "total": len(urls)}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ── Setup banners ──────────────────────────────────────────────────────────────
|
||||
|
||||
_SETUP_BANNERS = [
|
||||
{"key": "connect_cloud", "text": "Connect a cloud service for resume/cover letter storage", "link": "/settings?tab=integrations"},
|
||||
{"key": "setup_email", "text": "Set up email sync to catch recruiter outreach", "link": "/settings?tab=email"},
|
||||
{"key": "setup_email_labels", "text": "Set up email label filters for auto-classification", "link": "/settings?tab=email"},
|
||||
{"key": "tune_mission", "text": "Tune your mission preferences for better cover letters", "link": "/settings?tab=profile"},
|
||||
{"key": "configure_keywords", "text": "Configure keywords and blocklist for smarter search", "link": "/settings?tab=search"},
|
||||
{"key": "upload_corpus", "text": "Upload your cover letter corpus for voice fine-tuning", "link": "/settings?tab=fine-tune"},
|
||||
{"key": "configure_linkedin", "text": "Configure LinkedIn Easy Apply automation", "link": "/settings?tab=integrations"},
|
||||
{"key": "setup_searxng", "text": "Set up company research with SearXNG", "link": "/settings?tab=system"},
|
||||
{"key": "target_companies", "text": "Build a target company list for focused outreach", "link": "/settings?tab=search"},
|
||||
{"key": "setup_notifications", "text": "Set up notifications for stage changes", "link": "/settings?tab=integrations"},
|
||||
{"key": "tune_model", "text": "Tune a custom cover letter model on your writing", "link": "/settings?tab=fine-tune"},
|
||||
{"key": "review_training", "text": "Review and curate training data for model tuning", "link": "/settings?tab=fine-tune"},
|
||||
{"key": "setup_calendar", "text": "Set up calendar sync to track interview dates", "link": "/settings?tab=integrations"},
|
||||
]
|
||||
|
||||
|
||||
@app.get("/api/config/setup-banners")
|
||||
def get_setup_banners():
|
||||
try:
|
||||
cfg = _load_user_config()
|
||||
if not cfg.get("wizard_complete"):
|
||||
return []
|
||||
dismissed = set(cfg.get("dismissed_banners", []))
|
||||
return [b for b in _SETUP_BANNERS if b["key"] not in dismissed]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@app.post("/api/config/setup-banners/{key}/dismiss")
|
||||
def dismiss_setup_banner(key: str):
|
||||
try:
|
||||
cfg = _load_user_config()
|
||||
dismissed = cfg.get("dismissed_banners", [])
|
||||
if key not in dismissed:
|
||||
dismissed.append(key)
|
||||
cfg["dismissed_banners"] = dismissed
|
||||
_save_user_config(cfg)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ── POST /api/stage-signals/{id}/dismiss ─────────────────────────────────
|
||||
|
||||
@app.post("/api/stage-signals/{signal_id}/dismiss")
|
||||
|
|
@ -1352,16 +948,13 @@ def get_app_config():
|
|||
valid_tiers = {"free", "paid", "premium", "ultra"}
|
||||
raw_tier = os.environ.get("APP_TIER", "free")
|
||||
|
||||
# Cloud users always bypass the wizard — they configure through Settings
|
||||
is_cloud = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true")
|
||||
if is_cloud:
|
||||
wizard_complete = True
|
||||
else:
|
||||
try:
|
||||
cfg = load_user_profile(_user_yaml_path())
|
||||
wizard_complete = bool(cfg.get("wizard_complete", False))
|
||||
except Exception:
|
||||
wizard_complete = False
|
||||
# wizard_complete: read from user.yaml so the guard reflects live state
|
||||
wizard_complete = True
|
||||
try:
|
||||
cfg = load_user_profile(_user_yaml_path())
|
||||
wizard_complete = bool(cfg.get("wizard_complete", False))
|
||||
except Exception:
|
||||
wizard_complete = False
|
||||
|
||||
return {
|
||||
"isCloud": os.environ.get("CLOUD_MODE", "").lower() in ("1", "true"),
|
||||
|
|
@ -1395,12 +988,12 @@ from scripts.user_profile import load_user_profile, save_user_profile
|
|||
|
||||
|
||||
def _user_yaml_path() -> str:
|
||||
"""Resolve user.yaml path relative to the active staging.db.
|
||||
"""Resolve user.yaml path relative to the current STAGING_DB location.
|
||||
|
||||
In cloud mode the ContextVar holds the per-user db path; elsewhere
|
||||
falls back to STAGING_DB env var. Never crosses user boundaries.
|
||||
Never falls back to another user's config directory — callers must handle
|
||||
a missing file gracefully (return defaults / empty wizard state).
|
||||
"""
|
||||
db = _request_db.get() or os.environ.get("STAGING_DB", "/devl/peregrine/staging.db")
|
||||
db = os.environ.get("STAGING_DB", "/devl/peregrine/staging.db")
|
||||
return os.path.join(os.path.dirname(db), "config", "user.yaml")
|
||||
|
||||
|
||||
|
|
@ -1468,23 +1061,6 @@ class IdentitySyncPayload(BaseModel):
|
|||
phone: str = ""
|
||||
linkedin_url: str = ""
|
||||
|
||||
class UIPrefPayload(BaseModel):
|
||||
preference: str # "streamlit" | "vue"
|
||||
|
||||
@app.post("/api/settings/ui-preference")
|
||||
def set_ui_preference(payload: UIPrefPayload):
|
||||
"""Persist UI preference to user.yaml so Streamlit doesn't re-set the cookie."""
|
||||
if payload.preference not in ("streamlit", "vue"):
|
||||
raise HTTPException(status_code=400, detail="preference must be 'streamlit' or 'vue'")
|
||||
try:
|
||||
data = load_user_profile(_user_yaml_path())
|
||||
data["ui_preference"] = payload.preference
|
||||
save_user_profile(_user_yaml_path(), data)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/settings/resume/sync-identity")
|
||||
def sync_identity(payload: IdentitySyncPayload):
|
||||
"""Sync identity fields from profile store back to user.yaml."""
|
||||
|
|
@ -1541,54 +1117,9 @@ class ResumePayload(BaseModel):
|
|||
veteran_status: str = ""; disability: str = ""
|
||||
skills: List[str] = []; domains: List[str] = []; keywords: List[str] = []
|
||||
|
||||
def _config_dir() -> Path:
|
||||
"""Resolve per-user config directory. Always co-located with user.yaml."""
|
||||
return Path(_user_yaml_path()).parent
|
||||
|
||||
def _resume_path() -> Path:
|
||||
"""Resolve plain_text_resume.yaml co-located with user.yaml (user-isolated)."""
|
||||
return _config_dir() / "plain_text_resume.yaml"
|
||||
|
||||
def _search_prefs_path() -> Path:
|
||||
return _config_dir() / "search_profiles.yaml"
|
||||
|
||||
def _license_path() -> Path:
|
||||
return _config_dir() / "license.yaml"
|
||||
|
||||
def _tokens_path() -> Path:
|
||||
return _config_dir() / "tokens.yaml"
|
||||
|
||||
def _normalize_experience(raw: list) -> list:
|
||||
"""Normalize AIHawk-style experience entries to the Vue WorkEntry schema.
|
||||
|
||||
Parser / AIHawk stores: bullets (list[str]), start_date, end_date
|
||||
Vue WorkEntry expects: responsibilities (str), period (str)
|
||||
"""
|
||||
out = []
|
||||
for e in raw:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
entry = dict(e)
|
||||
# bullets → responsibilities
|
||||
if "responsibilities" not in entry or not entry["responsibilities"]:
|
||||
bullets = entry.pop("bullets", None) or []
|
||||
if isinstance(bullets, list):
|
||||
entry["responsibilities"] = "\n".join(b for b in bullets if b)
|
||||
elif isinstance(bullets, str):
|
||||
entry["responsibilities"] = bullets
|
||||
else:
|
||||
entry.pop("bullets", None)
|
||||
# start_date + end_date → period
|
||||
if "period" not in entry or not entry["period"]:
|
||||
start = entry.pop("start_date", "") or ""
|
||||
end = entry.pop("end_date", "") or ""
|
||||
entry["period"] = f"{start} – {end}".strip(" –") if (start or end) else ""
|
||||
else:
|
||||
entry.pop("start_date", None)
|
||||
entry.pop("end_date", None)
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
return Path(_user_yaml_path()).parent / "plain_text_resume.yaml"
|
||||
|
||||
@app.get("/api/settings/resume")
|
||||
def get_resume():
|
||||
|
|
@ -1599,8 +1130,6 @@ def get_resume():
|
|||
with open(resume_path) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
data["exists"] = True
|
||||
if "experience" in data and isinstance(data["experience"], list):
|
||||
data["experience"] = _normalize_experience(data["experience"])
|
||||
return data
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
|
@ -1648,13 +1177,8 @@ async def upload_resume(file: UploadFile):
|
|||
raw_text = extract_text_from_docx(file_bytes)
|
||||
|
||||
result, err = structure_resume(raw_text)
|
||||
if err and not result:
|
||||
return {"ok": False, "error": err}
|
||||
# Persist parsed data so store.load() reads the updated file
|
||||
resume_path = _resume_path()
|
||||
resume_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(resume_path, "w") as f:
|
||||
yaml.dump(result, f, allow_unicode=True, default_flow_style=False)
|
||||
if err:
|
||||
return {"ok": False, "error": err, "data": result}
|
||||
result["exists"] = True
|
||||
return {"ok": True, "data": result}
|
||||
except Exception as e:
|
||||
|
|
@ -1674,13 +1198,14 @@ class SearchPrefsPayload(BaseModel):
|
|||
blocklist_industries: List[str] = []
|
||||
blocklist_locations: List[str] = []
|
||||
|
||||
SEARCH_PREFS_PATH = Path("config/search_profiles.yaml")
|
||||
|
||||
@app.get("/api/settings/search")
|
||||
def get_search_prefs():
|
||||
try:
|
||||
p = _search_prefs_path()
|
||||
if not p.exists():
|
||||
if not SEARCH_PREFS_PATH.exists():
|
||||
return {}
|
||||
with open(p) as f:
|
||||
with open(SEARCH_PREFS_PATH) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
return data.get("default", {})
|
||||
except Exception as e:
|
||||
|
|
@ -1689,14 +1214,12 @@ def get_search_prefs():
|
|||
@app.put("/api/settings/search")
|
||||
def save_search_prefs(payload: SearchPrefsPayload):
|
||||
try:
|
||||
p = _search_prefs_path()
|
||||
data = {}
|
||||
if p.exists():
|
||||
with open(p) as f:
|
||||
if SEARCH_PREFS_PATH.exists():
|
||||
with open(SEARCH_PREFS_PATH) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
data["default"] = payload.model_dump()
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(p, "w") as f:
|
||||
with open(SEARCH_PREFS_PATH, "w") as f:
|
||||
yaml.dump(data, f, allow_unicode=True, default_flow_style=False)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
|
|
@ -1824,7 +1347,7 @@ def stop_service(name: str):
|
|||
|
||||
# ── Settings: System — Email ──────────────────────────────────────────────────
|
||||
|
||||
# EMAIL_PATH is resolved per-request via _config_dir()
|
||||
EMAIL_PATH = Path("config/email.yaml")
|
||||
EMAIL_CRED_SERVICE = "peregrine"
|
||||
EMAIL_CRED_KEY = "imap_password"
|
||||
|
||||
|
|
@ -1836,9 +1359,8 @@ EMAIL_YAML_FIELDS = ("host", "port", "ssl", "username", "sent_folder", "lookback
|
|||
def get_email_config():
|
||||
try:
|
||||
config = {}
|
||||
ep = _config_dir() / "email.yaml"
|
||||
if ep.exists():
|
||||
with open(ep) as f:
|
||||
if EMAIL_PATH.exists():
|
||||
with open(EMAIL_PATH) as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
# Never return the password — only indicate whether it's set
|
||||
password = get_credential(EMAIL_CRED_SERVICE, EMAIL_CRED_KEY)
|
||||
|
|
@ -1852,8 +1374,7 @@ def get_email_config():
|
|||
@app.put("/api/settings/system/email")
|
||||
def save_email_config(payload: dict):
|
||||
try:
|
||||
ep = _config_dir() / "email.yaml"
|
||||
ep.parent.mkdir(parents=True, exist_ok=True)
|
||||
EMAIL_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Extract password before writing yaml; discard the sentinel boolean regardless
|
||||
password = payload.pop("password", None)
|
||||
payload.pop("password_set", None) # always discard — boolean sentinel, not a secret
|
||||
|
|
@ -1861,7 +1382,7 @@ def save_email_config(payload: dict):
|
|||
set_credential(EMAIL_CRED_SERVICE, EMAIL_CRED_KEY, password)
|
||||
# Write non-secret fields to yaml (chmod 600 still, contains username)
|
||||
safe_config = {k: v for k, v in payload.items() if k in EMAIL_YAML_FIELDS}
|
||||
fd = os.open(str(ep), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
fd = os.open(str(EMAIL_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
yaml.dump(safe_config, f, allow_unicode=True, default_flow_style=False)
|
||||
return {"ok": True}
|
||||
|
|
@ -2080,7 +1601,12 @@ def finetune_local_status():
|
|||
|
||||
# ── Settings: License ─────────────────────────────────────────────────────────
|
||||
|
||||
# _config_dir() / _license_path() / _tokens_path() are per-request (see helpers above)
|
||||
# CONFIG_DIR resolves relative to staging.db location (same convention as _user_yaml_path)
|
||||
CONFIG_DIR = Path(os.path.dirname(DB_PATH)) / "config"
|
||||
if not CONFIG_DIR.exists():
|
||||
CONFIG_DIR = Path("/devl/job-seeker/config")
|
||||
|
||||
LICENSE_PATH = CONFIG_DIR / "license.yaml"
|
||||
|
||||
|
||||
def _load_user_config() -> dict:
|
||||
|
|
@ -2096,9 +1622,8 @@ def _save_user_config(cfg: dict) -> None:
|
|||
@app.get("/api/settings/license")
|
||||
def get_license():
|
||||
try:
|
||||
lp = _license_path()
|
||||
if lp.exists():
|
||||
with open(lp) as f:
|
||||
if LICENSE_PATH.exists():
|
||||
with open(LICENSE_PATH) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
else:
|
||||
data = {}
|
||||
|
|
@ -2122,10 +1647,9 @@ def activate_license(payload: LicenseActivatePayload):
|
|||
key = payload.key.strip()
|
||||
if not re.match(r'^CFG-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$', key):
|
||||
return {"ok": False, "error": "Invalid key format"}
|
||||
lp = _license_path()
|
||||
data = {"tier": "paid", "key": key, "active": True}
|
||||
lp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(lp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(LICENSE_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
yaml.dump(data, f, allow_unicode=True, default_flow_style=False)
|
||||
return {"ok": True, "tier": "paid"}
|
||||
|
|
@ -2136,12 +1660,11 @@ def activate_license(payload: LicenseActivatePayload):
|
|||
@app.post("/api/settings/license/deactivate")
|
||||
def deactivate_license():
|
||||
try:
|
||||
lp = _license_path()
|
||||
if lp.exists():
|
||||
with open(lp) as f:
|
||||
if LICENSE_PATH.exists():
|
||||
with open(LICENSE_PATH) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
data["active"] = False
|
||||
fd = os.open(str(lp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
fd = os.open(str(LICENSE_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
yaml.dump(data, f, allow_unicode=True, default_flow_style=False)
|
||||
return {"ok": True}
|
||||
|
|
@ -2159,19 +1682,18 @@ def create_backup(payload: BackupCreatePayload):
|
|||
try:
|
||||
import zipfile
|
||||
import datetime
|
||||
cfg_dir = _config_dir()
|
||||
backup_dir = cfg_dir.parent / "backups"
|
||||
backup_dir = Path("data/backups")
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
dest = backup_dir / f"peregrine_backup_{ts}.zip"
|
||||
file_count = 0
|
||||
with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for cfg_file in cfg_dir.glob("*.yaml"):
|
||||
for cfg_file in CONFIG_DIR.glob("*.yaml"):
|
||||
if cfg_file.name not in ("tokens.yaml",):
|
||||
zf.write(cfg_file, f"config/{cfg_file.name}")
|
||||
file_count += 1
|
||||
if payload.include_db:
|
||||
db_path = Path(_request_db.get() or DB_PATH)
|
||||
db_path = Path(DB_PATH)
|
||||
if db_path.exists():
|
||||
zf.write(db_path, "data/staging.db")
|
||||
file_count += 1
|
||||
|
|
@ -2215,14 +1737,15 @@ def save_privacy(payload: dict):
|
|||
|
||||
# ── Settings: Developer ───────────────────────────────────────────────────────
|
||||
|
||||
TOKENS_PATH = CONFIG_DIR / "tokens.yaml"
|
||||
|
||||
@app.get("/api/settings/developer")
|
||||
def get_developer():
|
||||
try:
|
||||
cfg = _load_user_config()
|
||||
tokens = {}
|
||||
tp = _tokens_path()
|
||||
if tp.exists():
|
||||
with open(tp) as f:
|
||||
if TOKENS_PATH.exists():
|
||||
with open(TOKENS_PATH) as f:
|
||||
tokens = yaml.safe_load(f) or {}
|
||||
return {
|
||||
"dev_tier_override": cfg.get("dev_tier_override"),
|
||||
|
|
@ -2457,7 +1980,7 @@ def wizard_save_step(payload: WizardStepPayload):
|
|||
# Persist search preferences to search_profiles.yaml
|
||||
titles = data.get("titles", [])
|
||||
locations = data.get("locations", [])
|
||||
search_path = _search_prefs_path()
|
||||
search_path = SEARCH_PREFS_PATH
|
||||
existing_search: dict = {}
|
||||
if search_path.exists():
|
||||
with open(search_path) as f:
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ server {
|
|||
listen 80;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 20m;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
|
|
|
|||
|
|
@ -383,19 +383,6 @@ def mark_applied(db_path: Path = DEFAULT_DB, ids: list[int] = None) -> None:
|
|||
conn.close()
|
||||
|
||||
|
||||
def cancel_task(db_path: Path = DEFAULT_DB, task_id: int = 0) -> bool:
|
||||
"""Cancel a single queued/running task by id. Returns True if a row was updated."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
count = conn.execute(
|
||||
"UPDATE background_tasks SET status='failed', error='Cancelled by user',"
|
||||
" finished_at=datetime('now') WHERE id=? AND status IN ('queued','running')",
|
||||
(task_id,),
|
||||
).rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return count > 0
|
||||
|
||||
|
||||
def kill_stuck_tasks(db_path: Path = DEFAULT_DB) -> int:
|
||||
"""Mark all queued/running background tasks as failed. Returns count killed."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
|
|
|
|||
|
|
@ -40,9 +40,6 @@
|
|||
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
|
||||
<span class="sidebar__label">Settings</span>
|
||||
</RouterLink>
|
||||
<button class="sidebar__classic-btn" @click="switchToClassic" title="Switch to Classic (Streamlit) UI">
|
||||
⚡ Classic
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
|
@ -108,23 +105,6 @@ function exitHackerMode() {
|
|||
localStorage.removeItem('cf-hacker-mode')
|
||||
}
|
||||
|
||||
const _apiBase = import.meta.env.BASE_URL.replace(/\/$/, '')
|
||||
|
||||
async function switchToClassic() {
|
||||
// Persist preference via API so Streamlit reads streamlit from user.yaml
|
||||
// and won't re-set the cookie back to vue (avoids the ?prgn_switch rerun cycle)
|
||||
try {
|
||||
await fetch(_apiBase + '/api/settings/ui-preference', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ preference: 'streamlit' }),
|
||||
})
|
||||
} catch { /* non-fatal — cookie below is enough for immediate redirect */ }
|
||||
document.cookie = 'prgn_ui=streamlit; path=/; SameSite=Lax'
|
||||
// Navigate to root (no query params) — Caddy routes to Streamlit based on cookie
|
||||
window.location.href = window.location.origin + '/'
|
||||
}
|
||||
|
||||
const navLinks = computed(() => [
|
||||
{ to: '/', icon: HomeIcon, label: 'Home' },
|
||||
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
|
||||
|
|
@ -292,29 +272,6 @@ const mobileLinks = [
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar__classic-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
margin-top: var(--space-1);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 150ms, background 150ms;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar__classic-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--color-surface-alt);
|
||||
}
|
||||
|
||||
/* ── Mobile tab bar (<1024px) ───────────────────────── */
|
||||
.app-tabbar {
|
||||
display: none; /* hidden on desktop */
|
||||
|
|
|
|||
|
|
@ -56,49 +56,6 @@
|
|||
<span v-if="gaps.length > 6" class="gaps-more">+{{ gaps.length - 6 }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Resume Highlights -->
|
||||
<div
|
||||
v-if="resumeSkills.length || resumeDomains.length || resumeKeywords.length"
|
||||
class="resume-highlights"
|
||||
>
|
||||
<button class="section-toggle" @click="highlightsExpanded = !highlightsExpanded">
|
||||
<span class="section-toggle__label">My Resume Highlights</span>
|
||||
<span class="section-toggle__icon" aria-hidden="true">{{ highlightsExpanded ? '▲' : '▼' }}</span>
|
||||
</button>
|
||||
<div v-if="highlightsExpanded" class="highlights-body">
|
||||
<div v-if="resumeSkills.length" class="chips-group">
|
||||
<span class="chips-group__label">Skills</span>
|
||||
<div class="chips-wrap">
|
||||
<span
|
||||
v-for="s in resumeSkills" :key="s"
|
||||
class="hl-chip"
|
||||
:class="{ 'hl-chip--match': jobMatchSet.has(s.toLowerCase()) }"
|
||||
>{{ s }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="resumeDomains.length" class="chips-group">
|
||||
<span class="chips-group__label">Domains</span>
|
||||
<div class="chips-wrap">
|
||||
<span
|
||||
v-for="d in resumeDomains" :key="d"
|
||||
class="hl-chip"
|
||||
:class="{ 'hl-chip--match': jobMatchSet.has(d.toLowerCase()) }"
|
||||
>{{ d }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="resumeKeywords.length" class="chips-group">
|
||||
<span class="chips-group__label">Keywords</span>
|
||||
<div class="chips-wrap">
|
||||
<span
|
||||
v-for="k in resumeKeywords" :key="k"
|
||||
class="hl-chip"
|
||||
:class="{ 'hl-chip--match': jobMatchSet.has(k.toLowerCase()) }"
|
||||
>{{ k }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a v-if="job.url" :href="job.url" target="_blank" rel="noopener noreferrer" class="job-details__link">
|
||||
View listing ↗
|
||||
</a>
|
||||
|
|
@ -194,61 +151,6 @@
|
|||
<!-- ── ATS Resume Optimizer ──────────────────────────────── -->
|
||||
<ResumeOptimizerPanel :job-id="props.jobId" />
|
||||
|
||||
<!-- ── Application Q&A ───────────────────────────────────── -->
|
||||
<div class="qa-section">
|
||||
<button class="section-toggle" @click="qaExpanded = !qaExpanded">
|
||||
<span class="section-toggle__label">Application Q&A</span>
|
||||
<span v-if="qaItems.length" class="qa-count">{{ qaItems.length }}</span>
|
||||
<span class="section-toggle__icon" aria-hidden="true">{{ qaExpanded ? '▲' : '▼' }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="qaExpanded" class="qa-body">
|
||||
<p v-if="!qaItems.length" class="qa-empty">
|
||||
No questions yet — add one below to get LLM-suggested answers.
|
||||
</p>
|
||||
|
||||
<div v-for="(item, i) in qaItems" :key="item.id" class="qa-item">
|
||||
<div class="qa-item__header">
|
||||
<span class="qa-item__q">{{ item.question }}</span>
|
||||
<button class="qa-item__del" aria-label="Remove question" @click="removeQA(i)">✕</button>
|
||||
</div>
|
||||
<textarea
|
||||
class="qa-item__answer"
|
||||
:value="item.answer"
|
||||
placeholder="Your answer…"
|
||||
rows="3"
|
||||
@input="updateAnswer(item.id, ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
<button
|
||||
class="btn-ghost btn-ghost--sm qa-suggest-btn"
|
||||
:disabled="suggesting === item.id"
|
||||
@click="suggestAnswer(item)"
|
||||
>
|
||||
{{ suggesting === item.id ? '✨ Thinking…' : '✨ Suggest' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="qa-add">
|
||||
<input
|
||||
v-model="newQuestion"
|
||||
class="qa-add__input"
|
||||
placeholder="Add a question from the application…"
|
||||
@keydown.enter.prevent="addQA"
|
||||
/>
|
||||
<button class="btn-ghost btn-ghost--sm" :disabled="!newQuestion.trim()" @click="addQA">Add</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="qaItems.length"
|
||||
class="btn-ghost qa-save-btn"
|
||||
:disabled="qaSaved || qaSaving"
|
||||
@click="saveQA"
|
||||
>
|
||||
{{ qaSaving ? 'Saving…' : (qaSaved ? '✓ Saved' : 'Save All') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Bottom action bar ──────────────────────────────────── -->
|
||||
<div class="workspace__actions">
|
||||
<button
|
||||
|
|
@ -457,96 +359,6 @@ async function rejectListing() {
|
|||
setTimeout(() => emit('job-removed'), 1000)
|
||||
}
|
||||
|
||||
// ─── Resume highlights ────────────────────────────────────────────────────────
|
||||
|
||||
const resumeSkills = ref<string[]>([])
|
||||
const resumeDomains = ref<string[]>([])
|
||||
const resumeKeywords = ref<string[]>([])
|
||||
const highlightsExpanded = ref(false)
|
||||
|
||||
// Words from the resume that also appear in the job description text
|
||||
const jobMatchSet = computed<Set<string>>(() => {
|
||||
const desc = (job.value?.description ?? '').toLowerCase()
|
||||
const all = [...resumeSkills.value, ...resumeDomains.value, ...resumeKeywords.value]
|
||||
return new Set(all.filter(t => desc.includes(t.toLowerCase())))
|
||||
})
|
||||
|
||||
async function fetchResume() {
|
||||
const { data } = await useApiFetch<{ skills?: string[]; domains?: string[]; keywords?: string[] }>(
|
||||
'/api/settings/resume',
|
||||
)
|
||||
if (!data) return
|
||||
resumeSkills.value = data.skills ?? []
|
||||
resumeDomains.value = data.domains ?? []
|
||||
resumeKeywords.value = data.keywords ?? []
|
||||
if (resumeSkills.value.length || resumeDomains.value.length || resumeKeywords.value.length) {
|
||||
highlightsExpanded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Application Q&A ─────────────────────────────────────────────────────────
|
||||
|
||||
interface QAItem { id: string; question: string; answer: string }
|
||||
|
||||
const qaItems = ref<QAItem[]>([])
|
||||
const qaExpanded = ref(false)
|
||||
const qaSaved = ref(true)
|
||||
const qaSaving = ref(false)
|
||||
const newQuestion = ref('')
|
||||
const suggesting = ref<string | null>(null)
|
||||
|
||||
function addQA() {
|
||||
const q = newQuestion.value.trim()
|
||||
if (!q) return
|
||||
qaItems.value = [...qaItems.value, { id: crypto.randomUUID(), question: q, answer: '' }]
|
||||
newQuestion.value = ''
|
||||
qaSaved.value = false
|
||||
qaExpanded.value = true
|
||||
}
|
||||
|
||||
function removeQA(index: number) {
|
||||
qaItems.value = qaItems.value.filter((_, i) => i !== index)
|
||||
qaSaved.value = false
|
||||
}
|
||||
|
||||
function updateAnswer(id: string, value: string) {
|
||||
qaItems.value = qaItems.value.map(q => q.id === id ? { ...q, answer: value } : q)
|
||||
qaSaved.value = false
|
||||
}
|
||||
|
||||
async function saveQA() {
|
||||
qaSaving.value = true
|
||||
const { error } = await useApiFetch(`/api/jobs/${props.jobId}/qa`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ items: qaItems.value }),
|
||||
})
|
||||
qaSaving.value = false
|
||||
if (error) { showToast('Save failed — please try again'); return }
|
||||
qaSaved.value = true
|
||||
}
|
||||
|
||||
async function suggestAnswer(item: QAItem) {
|
||||
suggesting.value = item.id
|
||||
const { data, error } = await useApiFetch<{ answer: string }>(`/api/jobs/${props.jobId}/qa/suggest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ question: item.question }),
|
||||
})
|
||||
suggesting.value = null
|
||||
if (error || !data?.answer) { showToast('Suggestion failed — check your LLM backend'); return }
|
||||
qaItems.value = qaItems.value.map(q => q.id === item.id ? { ...q, answer: data.answer } : q)
|
||||
qaSaved.value = false
|
||||
}
|
||||
|
||||
async function fetchQA() {
|
||||
const { data } = await useApiFetch<{ items: QAItem[] }>(`/api/jobs/${props.jobId}/qa`)
|
||||
if (data?.items?.length) {
|
||||
qaItems.value = data.items
|
||||
qaExpanded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Toast ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const toast = ref<string | null>(null)
|
||||
|
|
@ -594,10 +406,6 @@ onMounted(async () => {
|
|||
await fetchJob()
|
||||
loadingJob.value = false
|
||||
|
||||
// Load resume highlights and saved Q&A in parallel
|
||||
fetchResume()
|
||||
fetchQA()
|
||||
|
||||
// Check if a generation task is already in flight
|
||||
if (clState.value === 'none') {
|
||||
const { data } = await useApiFetch<{ status: string; stage: string | null }>(`/api/jobs/${props.jobId}/cover_letter/task`)
|
||||
|
|
@ -1035,205 +843,6 @@ declare module '../stores/review' {
|
|||
.toast-enter-active, .toast-leave-active { transition: opacity 250ms ease, transform 250ms ease; }
|
||||
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||
|
||||
/* ── Resume Highlights ───────────────────────────────────────────────── */
|
||||
|
||||
.resume-highlights {
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
.section-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.section-toggle__label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section-toggle__icon {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.highlights-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.chips-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
|
||||
.chips-group__label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.chips-wrap { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
|
||||
.hl-chip {
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
background: var(--color-surface-alt);
|
||||
border: 1px solid var(--color-border-light);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.hl-chip--match {
|
||||
background: rgba(39, 174, 96, 0.10);
|
||||
border-color: rgba(39, 174, 96, 0.35);
|
||||
color: var(--color-success);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Application Q&A ─────────────────────────────────────────────────── */
|
||||
|
||||
.qa-section {
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qa-section > .section-toggle {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.qa-section > .section-toggle:hover { background: var(--color-surface-alt); }
|
||||
|
||||
.qa-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--app-primary-light);
|
||||
color: var(--app-primary);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.qa-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.qa-empty {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.qa-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.qa-item:last-of-type { border-bottom: none; }
|
||||
|
||||
.qa-item__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.qa-item__q {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.qa-item__del {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
padding: 2px 4px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
|
||||
.qa-item__del:hover { opacity: 1; color: var(--color-error); }
|
||||
|
||||
.qa-item__answer {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-alt);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.qa-item__answer:focus {
|
||||
outline: none;
|
||||
border-color: var(--app-primary);
|
||||
}
|
||||
|
||||
.qa-suggest-btn { align-self: flex-end; }
|
||||
|
||||
.qa-add {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.qa-add__input {
|
||||
flex: 1;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-alt);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.qa-add__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--app-primary);
|
||||
}
|
||||
|
||||
.qa-add__input::placeholder { color: var(--color-text-muted); }
|
||||
|
||||
.qa-save-btn { align-self: flex-end; }
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
|
|
|
|||
|
|
@ -2,15 +2,12 @@ export type ApiError =
|
|||
| { kind: 'network'; message: string }
|
||||
| { kind: 'http'; status: number; detail: string }
|
||||
|
||||
// Strip trailing slash so '/peregrine/' + '/api/...' → '/peregrine/api/...'
|
||||
const _apiBase = import.meta.env.BASE_URL.replace(/\/$/, '')
|
||||
|
||||
export async function useApiFetch<T>(
|
||||
url: string,
|
||||
opts?: RequestInit,
|
||||
): Promise<{ data: T | null; error: ApiError | null }> {
|
||||
try {
|
||||
const res = await fetch(_apiBase + url, opts)
|
||||
const res = await fetch(url, opts)
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => '')
|
||||
return { data: null, error: { kind: 'http', status: res.status, detail } }
|
||||
|
|
@ -34,7 +31,7 @@ export function useApiSSE(
|
|||
onComplete?: () => void,
|
||||
onError?: (e: Event) => void,
|
||||
): () => void {
|
||||
const es = new EventSource(_apiBase + url)
|
||||
const es = new EventSource(url)
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data) as Record<string, unknown>
|
||||
|
|
|
|||
|
|
@ -53,13 +53,6 @@
|
|||
:loading="taskRunning === 'score'"
|
||||
@click="scoreUnscored"
|
||||
/>
|
||||
<WorkflowButton
|
||||
emoji="🔍"
|
||||
label="Fill Missing Descriptions"
|
||||
description="Re-fetch truncated job descriptions"
|
||||
:loading="taskRunning === 'enrich'"
|
||||
@click="runEnrich"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
|
@ -87,6 +80,7 @@
|
|||
? `Last enriched ${formatRelative(store.status.enrichment_last_run)}`
|
||||
: 'Auto-enrichment active' }}
|
||||
</span>
|
||||
<button class="btn-ghost btn-ghost--sm" @click="runEnrich">Run Now</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -168,194 +162,24 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<!-- Advanced -->
|
||||
<section class="home__section">
|
||||
<details class="danger-zone">
|
||||
<summary class="danger-zone__summary">⚠️ Danger Zone</summary>
|
||||
<div class="danger-zone__body">
|
||||
|
||||
<!-- Queue reset -->
|
||||
<div class="dz-block">
|
||||
<p class="dz-block__title">Queue reset</p>
|
||||
<p class="dz-block__desc">
|
||||
Archive clears your review queue while keeping job URLs for dedup — same listings
|
||||
won't resurface on the next discovery run. Use hard purge only for a full clean slate
|
||||
including dedup history.
|
||||
</p>
|
||||
|
||||
<fieldset class="dz-scope" aria-label="Clear scope">
|
||||
<legend class="dz-scope__legend">Clear scope</legend>
|
||||
<label class="dz-scope__option">
|
||||
<input type="radio" v-model="dangerScope" value="pending" />
|
||||
Pending only
|
||||
</label>
|
||||
<label class="dz-scope__option">
|
||||
<input type="radio" v-model="dangerScope" value="pending_approved" />
|
||||
Pending + approved (stale search)
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="dz-actions">
|
||||
<button
|
||||
class="action-btn action-btn--primary"
|
||||
:disabled="!!confirmAction"
|
||||
@click="beginConfirm('archive')"
|
||||
>
|
||||
📦 Archive & reset
|
||||
</button>
|
||||
<button
|
||||
class="action-btn action-btn--secondary"
|
||||
:disabled="!!confirmAction"
|
||||
@click="beginConfirm('purge')"
|
||||
>
|
||||
🗑 Hard purge (delete)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Inline confirm -->
|
||||
<div v-if="confirmAction" class="dz-confirm" role="alertdialog" aria-live="assertive">
|
||||
<p v-if="confirmAction.type === 'archive'" class="dz-confirm__msg dz-confirm__msg--info">
|
||||
Archive <strong>{{ confirmAction.statuses.join(' + ') }}</strong> jobs?
|
||||
URLs are kept for dedup — nothing is permanently deleted.
|
||||
</p>
|
||||
<p v-else class="dz-confirm__msg dz-confirm__msg--warn">
|
||||
Permanently delete <strong>{{ confirmAction.statuses.join(' + ') }}</strong> jobs?
|
||||
This removes URLs from dedup history too. Cannot be undone.
|
||||
</p>
|
||||
<div class="dz-confirm__actions">
|
||||
<button class="action-btn action-btn--primary" @click="executeConfirm">
|
||||
{{ confirmAction.type === 'archive' ? 'Yes, archive' : 'Yes, delete' }}
|
||||
</button>
|
||||
<button class="action-btn action-btn--secondary" @click="confirmAction = null">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="dz-divider" />
|
||||
|
||||
<!-- Background tasks -->
|
||||
<div class="dz-block">
|
||||
<p class="dz-block__title">Background tasks — {{ activeTasks.length }} active</p>
|
||||
<template v-if="activeTasks.length > 0">
|
||||
<div
|
||||
v-for="task in activeTasks"
|
||||
:key="task.id"
|
||||
class="dz-task"
|
||||
>
|
||||
<span class="dz-task__icon">{{ taskIcon(task.task_type) }}</span>
|
||||
<span class="dz-task__type">{{ task.task_type.replace(/_/g, ' ') }}</span>
|
||||
<span class="dz-task__label">
|
||||
{{ task.title ? `${task.title}${task.company ? ' @ ' + task.company : ''}` : `job #${task.job_id}` }}
|
||||
</span>
|
||||
<span class="dz-task__status">{{ task.status }}</span>
|
||||
<button
|
||||
class="btn-ghost btn-ghost--sm dz-task__cancel"
|
||||
@click="cancelTaskById(task.id)"
|
||||
:aria-label="`Cancel ${task.task_type} task`"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<button
|
||||
class="action-btn action-btn--secondary dz-kill"
|
||||
:disabled="activeTasks.length === 0"
|
||||
@click="killAll"
|
||||
>
|
||||
⏹ Kill all stuck
|
||||
<details class="advanced">
|
||||
<summary class="advanced__summary">Advanced</summary>
|
||||
<div class="advanced__body">
|
||||
<p class="advanced__warning">⚠️ These actions are destructive and cannot be undone.</p>
|
||||
<div class="home__actions home__actions--danger">
|
||||
<button class="action-btn action-btn--danger" @click="confirmPurge">
|
||||
🗑️ Purge Pending + Rejected
|
||||
</button>
|
||||
<button class="action-btn action-btn--danger" @click="killTasks">
|
||||
🛑 Kill Stuck Tasks
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr class="dz-divider" />
|
||||
|
||||
<!-- More options -->
|
||||
<details class="dz-more">
|
||||
<summary class="dz-more__summary">More options</summary>
|
||||
<div class="dz-more__body">
|
||||
|
||||
<!-- Email purge -->
|
||||
<div class="dz-more__item">
|
||||
<p class="dz-block__title">Purge email data</p>
|
||||
<p class="dz-block__desc">Clears all email thread logs and email-sourced pending jobs.</p>
|
||||
<template v-if="moreConfirm === 'email'">
|
||||
<p class="dz-confirm__msg dz-confirm__msg--warn">
|
||||
Deletes all email contacts and email-sourced jobs. Cannot be undone.
|
||||
</p>
|
||||
<div class="dz-confirm__actions">
|
||||
<button class="action-btn action-btn--primary" @click="executePurgeTarget('email')">Yes, purge emails</button>
|
||||
<button class="action-btn action-btn--secondary" @click="moreConfirm = null">Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
<button v-else class="action-btn action-btn--secondary" @click="moreConfirm = 'email'">
|
||||
📧 Purge Email Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Non-remote purge -->
|
||||
<div class="dz-more__item">
|
||||
<p class="dz-block__title">Purge non-remote</p>
|
||||
<p class="dz-block__desc">Removes pending/approved/rejected on-site listings from the DB.</p>
|
||||
<template v-if="moreConfirm === 'non_remote'">
|
||||
<p class="dz-confirm__msg dz-confirm__msg--warn">
|
||||
Deletes all non-remote jobs not yet applied to. Cannot be undone.
|
||||
</p>
|
||||
<div class="dz-confirm__actions">
|
||||
<button class="action-btn action-btn--primary" @click="executePurgeTarget('non_remote')">Yes, purge on-site</button>
|
||||
<button class="action-btn action-btn--secondary" @click="moreConfirm = null">Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
<button v-else class="action-btn action-btn--secondary" @click="moreConfirm = 'non_remote'">
|
||||
🏢 Purge On-site Jobs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Wipe + re-scrape -->
|
||||
<div class="dz-more__item">
|
||||
<p class="dz-block__title">Wipe all + re-scrape</p>
|
||||
<p class="dz-block__desc">Deletes all non-applied jobs then immediately runs a fresh discovery.</p>
|
||||
<template v-if="moreConfirm === 'rescrape'">
|
||||
<p class="dz-confirm__msg dz-confirm__msg--warn">
|
||||
Wipes ALL pending, approved, and rejected jobs, then re-scrapes.
|
||||
Applied and synced records are kept.
|
||||
</p>
|
||||
<div class="dz-confirm__actions">
|
||||
<button class="action-btn action-btn--primary" @click="executePurgeTarget('rescrape')">Yes, wipe + scrape</button>
|
||||
<button class="action-btn action-btn--secondary" @click="moreConfirm = null">Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
<button v-else class="action-btn action-btn--secondary" @click="moreConfirm = 'rescrape'">
|
||||
🔄 Wipe + Re-scrape
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<!-- Setup banners -->
|
||||
<section v-if="banners.length > 0" class="home__section" aria-labelledby="setup-heading">
|
||||
<h2 id="setup-heading" class="home__section-title">Finish setting up Peregrine</h2>
|
||||
<div class="banners">
|
||||
<div v-for="banner in banners" :key="banner.key" class="banner">
|
||||
<span class="banner__icon" aria-hidden="true">💡</span>
|
||||
<span class="banner__text">{{ banner.text }}</span>
|
||||
<RouterLink :to="banner.link" class="banner__link">Go to settings →</RouterLink>
|
||||
<button
|
||||
class="btn-ghost btn-ghost--sm banner__dismiss"
|
||||
@click="dismissBanner(banner.key)"
|
||||
:aria-label="`Dismiss: ${banner.text}`"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stoop speed toast — easter egg 9.2 -->
|
||||
<Transition name="toast">
|
||||
<div v-if="stoopToast" class="stoop-toast" role="status" aria-live="polite">
|
||||
|
|
@ -366,7 +190,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useJobsStore } from '../stores/jobs'
|
||||
import { useApiFetch } from '../composables/useApi'
|
||||
|
|
@ -407,8 +231,6 @@ function formatRelative(isoStr: string) {
|
|||
return hrs === 1 ? '1 hour ago' : `${hrs} hours ago`
|
||||
}
|
||||
|
||||
// ── Task execution ─────────────────────────────────────────────────────────
|
||||
|
||||
const taskRunning = ref<string | null>(null)
|
||||
const stoopToast = ref(false)
|
||||
|
||||
|
|
@ -417,16 +239,13 @@ async function runTask(key: string, endpoint: string) {
|
|||
await useApiFetch(endpoint, { method: 'POST' })
|
||||
taskRunning.value = null
|
||||
store.refresh()
|
||||
fetchActiveTasks()
|
||||
}
|
||||
|
||||
const runDiscovery = () => runTask('discovery', '/api/tasks/discovery')
|
||||
const syncEmails = () => runTask('email', '/api/tasks/email-sync')
|
||||
const scoreUnscored = () => runTask('score', '/api/tasks/score')
|
||||
const syncIntegration = () => runTask('sync', '/api/tasks/sync')
|
||||
const runEnrich = () => runTask('enrich', '/api/tasks/enrich')
|
||||
|
||||
// ── Add jobs ───────────────────────────────────────────────────────────────
|
||||
const runEnrich = () => useApiFetch('/api/tasks/enrich', { method: 'POST' })
|
||||
|
||||
const addTab = ref<'url' | 'csv'>('url')
|
||||
const urlInput = ref('')
|
||||
|
|
@ -450,8 +269,6 @@ function handleCsvUpload(e: Event) {
|
|||
useApiFetch('/api/jobs/upload-csv', { method: 'POST', body: form })
|
||||
}
|
||||
|
||||
// ── Backlog archive ────────────────────────────────────────────────────────
|
||||
|
||||
async function archiveByStatus(statuses: string[]) {
|
||||
await useApiFetch('/api/jobs/archive', {
|
||||
method: 'POST',
|
||||
|
|
@ -461,100 +278,26 @@ async function archiveByStatus(statuses: string[]) {
|
|||
store.refresh()
|
||||
}
|
||||
|
||||
// ── Danger Zone ────────────────────────────────────────────────────────────
|
||||
|
||||
interface TaskRow { id: number; task_type: string; status: string; title?: string; company?: string; job_id: number }
|
||||
interface Banner { key: string; text: string; link: string }
|
||||
interface ConfirmAction { type: 'archive' | 'purge'; statuses: string[] }
|
||||
|
||||
const activeTasks = ref<TaskRow[]>([])
|
||||
const dangerScope = ref<'pending' | 'pending_approved'>('pending')
|
||||
const confirmAction = ref<ConfirmAction | null>(null)
|
||||
const moreConfirm = ref<string | null>(null)
|
||||
const banners = ref<Banner[]>([])
|
||||
|
||||
let taskPollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function fetchActiveTasks() {
|
||||
const { data } = await useApiFetch<TaskRow[]>('/api/tasks')
|
||||
activeTasks.value = data ?? []
|
||||
}
|
||||
|
||||
async function fetchBanners() {
|
||||
const { data } = await useApiFetch<Banner[]>('/api/config/setup-banners')
|
||||
banners.value = data ?? []
|
||||
}
|
||||
|
||||
function scopeStatuses(): string[] {
|
||||
return dangerScope.value === 'pending' ? ['pending'] : ['pending', 'approved']
|
||||
}
|
||||
|
||||
function beginConfirm(type: 'archive' | 'purge') {
|
||||
moreConfirm.value = null
|
||||
confirmAction.value = { type, statuses: scopeStatuses() }
|
||||
}
|
||||
|
||||
async function executeConfirm() {
|
||||
const action = confirmAction.value
|
||||
confirmAction.value = null
|
||||
if (!action) return
|
||||
const endpoint = action.type === 'archive' ? '/api/jobs/archive' : '/api/jobs/purge'
|
||||
const key = action.type === 'archive' ? 'statuses' : 'statuses'
|
||||
await useApiFetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [key]: action.statuses }),
|
||||
})
|
||||
store.refresh()
|
||||
fetchActiveTasks()
|
||||
}
|
||||
|
||||
async function cancelTaskById(id: number) {
|
||||
await useApiFetch(`/api/tasks/${id}`, { method: 'DELETE' })
|
||||
fetchActiveTasks()
|
||||
}
|
||||
|
||||
async function killAll() {
|
||||
await useApiFetch('/api/tasks/kill', { method: 'POST' })
|
||||
fetchActiveTasks()
|
||||
}
|
||||
|
||||
async function executePurgeTarget(target: string) {
|
||||
moreConfirm.value = null
|
||||
await useApiFetch('/api/jobs/purge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ target }),
|
||||
})
|
||||
store.refresh()
|
||||
fetchActiveTasks()
|
||||
}
|
||||
|
||||
async function dismissBanner(key: string) {
|
||||
await useApiFetch(`/api/config/setup-banners/${key}/dismiss`, { method: 'POST' })
|
||||
banners.value = banners.value.filter(b => b.key !== key)
|
||||
}
|
||||
|
||||
function taskIcon(taskType: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
cover_letter: '✉️', company_research: '🔍', discovery: '🌐',
|
||||
enrich_descriptions: '📝', email_sync: '📧', score: '📊',
|
||||
scrape_url: '🔗',
|
||||
function confirmPurge() {
|
||||
// TODO: replace with ConfirmModal component
|
||||
if (confirm('Permanently delete all pending and rejected jobs? This cannot be undone.')) {
|
||||
useApiFetch('/api/jobs/purge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ target: 'pending_rejected' }),
|
||||
})
|
||||
store.refresh()
|
||||
}
|
||||
return icons[taskType] ?? '⚙️'
|
||||
}
|
||||
|
||||
async function killTasks() {
|
||||
await useApiFetch('/api/tasks/kill', { method: 'POST' })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
store.refresh()
|
||||
const { data } = await useApiFetch<{ name: string }>('/api/config/user')
|
||||
if (data?.name) userName.value = data.name
|
||||
fetchActiveTasks()
|
||||
fetchBanners()
|
||||
taskPollInterval = setInterval(fetchActiveTasks, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (taskPollInterval) clearInterval(taskPollInterval)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -649,11 +392,12 @@ onUnmounted(() => {
|
|||
|
||||
.home__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.home__actions--secondary { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
|
||||
.home__actions--danger { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
||||
|
||||
.sync-banner {
|
||||
display: flex;
|
||||
|
|
@ -707,7 +451,9 @@ onUnmounted(() => {
|
|||
|
||||
.action-btn--secondary { background: var(--color-surface-alt); color: var(--color-text); border: 1px solid var(--color-border); }
|
||||
.action-btn--secondary:hover { background: var(--color-border-light); }
|
||||
.action-btn--secondary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.action-btn--danger { background: transparent; color: var(--color-error); border: 1px solid var(--color-error); }
|
||||
.action-btn--danger:hover { background: rgba(192, 57, 43, 0.08); }
|
||||
|
||||
.enrichment-row {
|
||||
display: flex;
|
||||
|
|
@ -782,15 +528,13 @@ onUnmounted(() => {
|
|||
|
||||
.add-jobs__textarea:focus { outline: 2px solid var(--app-primary); outline-offset: 1px; }
|
||||
|
||||
/* ── Danger Zone ──────────────────────────────────────── */
|
||||
|
||||
.danger-zone {
|
||||
.advanced {
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.danger-zone__summary {
|
||||
.advanced__summary {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
|
|
@ -800,172 +544,21 @@ onUnmounted(() => {
|
|||
user-select: none;
|
||||
}
|
||||
|
||||
.danger-zone__summary::-webkit-details-marker { display: none; }
|
||||
.danger-zone__summary::before { content: '▶ '; font-size: 0.7em; }
|
||||
details[open] > .danger-zone__summary::before { content: '▼ '; }
|
||||
.advanced__summary::-webkit-details-marker { display: none; }
|
||||
.advanced__summary::before { content: '▶ '; font-size: 0.7em; }
|
||||
details[open] > .advanced__summary::before { content: '▼ '; }
|
||||
|
||||
.danger-zone__body {
|
||||
padding: 0 var(--space-4) var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.advanced__body { padding: 0 var(--space-4) var(--space-4); display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
|
||||
.dz-block { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
|
||||
.dz-block__title {
|
||||
.advanced__warning {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.dz-block__desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.dz-scope {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
gap: var(--space-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dz-scope__legend {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: var(--space-2);
|
||||
float: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dz-scope__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dz-actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dz-confirm {
|
||||
color: var(--color-warning);
|
||||
background: rgba(212, 137, 26, 0.08);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
border-left: 3px solid var(--color-warning);
|
||||
}
|
||||
|
||||
.dz-confirm__msg {
|
||||
font-size: var(--text-sm);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
border-left: 3px solid;
|
||||
}
|
||||
|
||||
.dz-confirm__msg--info {
|
||||
background: rgba(52, 152, 219, 0.1);
|
||||
border-color: var(--app-primary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.dz-confirm__msg--warn {
|
||||
background: rgba(192, 57, 43, 0.08);
|
||||
border-color: var(--color-error);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.dz-confirm__actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.dz-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dz-task {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-surface-alt);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.dz-task__icon { flex-shrink: 0; }
|
||||
.dz-task__type { font-family: var(--font-mono); color: var(--color-text-muted); min-width: 120px; }
|
||||
.dz-task__label { flex: 1; color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dz-task__status { color: var(--color-text-muted); font-style: italic; }
|
||||
.dz-task__cancel { margin-left: var(--space-2); }
|
||||
|
||||
.dz-kill { align-self: flex-start; }
|
||||
|
||||
.dz-more {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.dz-more__summary {
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.dz-more__summary::-webkit-details-marker { display: none; }
|
||||
.dz-more__summary::before { content: '▶ '; font-size: 0.7em; }
|
||||
details[open] > .dz-more__summary::before { content: '▼ '; }
|
||||
|
||||
.dz-more__body {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: var(--space-5);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.dz-more__item { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
|
||||
/* ── Setup banners ────────────────────────────────────── */
|
||||
|
||||
.banners {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.banner__icon { flex-shrink: 0; }
|
||||
.banner__text { flex: 1; color: var(--color-text); }
|
||||
.banner__link { color: var(--app-primary); text-decoration: none; white-space: nowrap; font-weight: 500; }
|
||||
.banner__link:hover { text-decoration: underline; }
|
||||
.banner__dismiss { margin-left: var(--space-1); }
|
||||
|
||||
/* ── Toast ────────────────────────────────────────────── */
|
||||
|
||||
.stoop-toast {
|
||||
position: fixed;
|
||||
bottom: var(--space-6);
|
||||
|
|
@ -995,7 +588,6 @@ details[open] > .dz-more__summary::before { content: '▼ '; }
|
|||
.home { padding: var(--space-4); gap: var(--space-6); }
|
||||
.home__greeting { font-size: var(--text-2xl); }
|
||||
.home__metrics { grid-template-columns: repeat(3, 1fr); }
|
||||
.dz-more__body { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!config.isCloud" class="field-row">
|
||||
<div class="field-row">
|
||||
<label class="field-label" for="profile-inference">Inference profile</label>
|
||||
<select id="profile-inference" v-model="store.inference_profile" class="select-input">
|
||||
<option value="remote">Remote</option>
|
||||
|
|
|
|||
|
|
@ -15,13 +15,7 @@
|
|||
<div class="empty-card">
|
||||
<h3>Upload & Parse</h3>
|
||||
<p>Upload a PDF, DOCX, or ODT and we'll extract your info automatically.</p>
|
||||
<input type="file" accept=".pdf,.docx,.odt" @change="handleFileSelect" ref="fileInput" />
|
||||
<button
|
||||
v-if="pendingFile"
|
||||
@click="handleUpload"
|
||||
:disabled="uploading"
|
||||
style="margin-top:10px"
|
||||
>{{ uploading ? 'Parsing…' : `Parse "${pendingFile.name}"` }}</button>
|
||||
<input type="file" accept=".pdf,.docx,.odt" @change="handleUpload" ref="fileInput" />
|
||||
<p v-if="uploadError" class="error">{{ uploadError }}</p>
|
||||
</div>
|
||||
<!-- Blank -->
|
||||
|
|
@ -30,8 +24,8 @@
|
|||
<p>Start with a blank form and fill in your details.</p>
|
||||
<button @click="store.createBlank()" :disabled="store.loading">Start from Scratch</button>
|
||||
</div>
|
||||
<!-- Wizard — self-hosted only -->
|
||||
<div v-if="!config.isCloud" class="empty-card">
|
||||
<!-- Wizard -->
|
||||
<div class="empty-card">
|
||||
<h3>Run Setup Wizard</h3>
|
||||
<p>Walk through the onboarding wizard to set up your profile step by step.</p>
|
||||
<RouterLink to="/setup">Open Setup Wizard →</RouterLink>
|
||||
|
|
@ -41,21 +35,6 @@
|
|||
|
||||
<!-- Full form (when resume exists) -->
|
||||
<template v-else-if="store.hasResume">
|
||||
<!-- Replace resume via upload -->
|
||||
<section class="form-section replace-section">
|
||||
<h3>Replace Resume</h3>
|
||||
<p class="section-note">Upload a new PDF, DOCX, or ODT to re-parse and overwrite the current data.</p>
|
||||
<input type="file" accept=".pdf,.docx,.odt" @change="handleFileSelect" ref="replaceFileInput" />
|
||||
<button
|
||||
v-if="pendingFile"
|
||||
@click="handleUpload"
|
||||
:disabled="uploading"
|
||||
class="btn-primary"
|
||||
style="margin-top:10px"
|
||||
>{{ uploading ? 'Parsing…' : `Parse "${pendingFile.name}"` }}</button>
|
||||
<p v-if="uploadError" class="error">{{ uploadError }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Personal Information -->
|
||||
<section class="form-section">
|
||||
<h3>Personal Information</h3>
|
||||
|
|
@ -242,22 +221,17 @@ import { ref, onMounted } from 'vue'
|
|||
import { storeToRefs } from 'pinia'
|
||||
import { useResumeStore } from '../../stores/settings/resume'
|
||||
import { useProfileStore } from '../../stores/settings/profile'
|
||||
import { useAppConfigStore } from '../../stores/appConfig'
|
||||
import { useApiFetch } from '../../composables/useApi'
|
||||
|
||||
const store = useResumeStore()
|
||||
const profileStore = useProfileStore()
|
||||
const config = useAppConfigStore()
|
||||
const { loadError } = storeToRefs(store)
|
||||
const showSelfId = ref(false)
|
||||
const skillInput = ref('')
|
||||
const domainInput = ref('')
|
||||
const kwInput = ref('')
|
||||
const uploadError = ref<string | null>(null)
|
||||
const uploading = ref(false)
|
||||
const pendingFile = ref<File | null>(null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const replaceFileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
await store.load()
|
||||
|
|
@ -272,16 +246,9 @@ onMounted(async () => {
|
|||
}
|
||||
})
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
async function handleUpload(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
pendingFile.value = file ?? null
|
||||
uploadError.value = null
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
const file = pendingFile.value
|
||||
if (!file) return
|
||||
uploading.value = true
|
||||
uploadError.value = null
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
|
@ -289,14 +256,10 @@ async function handleUpload() {
|
|||
'/api/settings/resume/upload',
|
||||
{ method: 'POST', body: formData }
|
||||
)
|
||||
uploading.value = false
|
||||
if (error || !data?.ok) {
|
||||
uploadError.value = data?.error ?? (typeof error === 'string' ? error : (error?.kind === 'network' ? error.message : error?.detail ?? 'Upload failed'))
|
||||
return
|
||||
}
|
||||
pendingFile.value = null
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
if (replaceFileInput.value) replaceFileInput.value.value = ''
|
||||
if (data.data) {
|
||||
await store.load()
|
||||
}
|
||||
|
|
@ -344,5 +307,4 @@ h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); col
|
|||
.section-note { font-size: 0.8rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 16px; }
|
||||
.toggle-btn { margin-left: 10px; padding: 2px 10px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.15)); border-radius: 4px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.78rem; }
|
||||
.loading { text-align: center; padding: var(--space-8, 48px); color: var(--color-text-secondary, #94a3b8); }
|
||||
.replace-section { background: var(--color-surface-2, rgba(255,255,255,0.03)); border-radius: 8px; padding: var(--space-4, 24px); }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -41,8 +41,7 @@ const config = useAppConfigStore()
|
|||
const devOverride = computed(() => !!config.devTierOverride)
|
||||
const gpuProfiles = ['single-gpu', 'dual-gpu']
|
||||
|
||||
const showSystem = computed(() => !config.isCloud)
|
||||
const showData = computed(() => !config.isCloud)
|
||||
const showSystem = computed(() => !config.isCloud)
|
||||
const showFineTune = computed(() => {
|
||||
if (config.isCloud) return config.tier === 'premium'
|
||||
return gpuProfiles.includes(config.inferenceProfile)
|
||||
|
|
@ -66,7 +65,7 @@ const allGroups = [
|
|||
]},
|
||||
{ label: 'Account', items: [
|
||||
{ key: 'license', path: '/settings/license', label: 'License', show: true },
|
||||
{ key: 'data', path: '/settings/data', label: 'Data', show: showData },
|
||||
{ key: 'data', path: '/settings/data', label: 'Data', show: true },
|
||||
{ key: 'privacy', path: '/settings/privacy', label: 'Privacy', show: true },
|
||||
]},
|
||||
{ label: 'Dev', items: [
|
||||
|
|
|
|||
Loading…
Reference in a new issue