Compare commits
No commits in common. "main" and "feature/vue-spa" have entirely different histories.
main
...
feature/vu
101 changed files with 965 additions and 10195 deletions
21
.env.example
21
.env.example
|
|
@ -12,21 +12,10 @@ VISION_REVISION=2025-01-09
|
||||||
|
|
||||||
DOCS_DIR=~/Documents/JobSearch
|
DOCS_DIR=~/Documents/JobSearch
|
||||||
OLLAMA_MODELS_DIR=~/models/ollama
|
OLLAMA_MODELS_DIR=~/models/ollama
|
||||||
VLLM_MODELS_DIR=~/models/vllm # override with full path to your model dir
|
VLLM_MODELS_DIR=~/models/vllm
|
||||||
VLLM_MODEL=Ouro-1.4B # cover letters — fast 1.4B model
|
VLLM_MODEL=Ouro-1.4B
|
||||||
VLLM_RESEARCH_MODEL=Ouro-2.6B-Thinking # research — reasoning 2.6B model; restart vllm to switch
|
|
||||||
VLLM_MAX_MODEL_LEN=4096 # increase to 8192 for Thinking models with long CoT
|
|
||||||
VLLM_GPU_MEM_UTIL=0.75 # lower to 0.6 if sharing GPU with other services
|
|
||||||
OLLAMA_DEFAULT_MODEL=llama3.2:3b
|
OLLAMA_DEFAULT_MODEL=llama3.2:3b
|
||||||
|
|
||||||
# ── LLM env-var auto-config (alternative to config/llm.yaml) ─────────────────
|
|
||||||
# Set any of these to configure LLM backends without needing a config/llm.yaml.
|
|
||||||
# Priority: Anthropic > OpenAI-compat > Ollama (always tried as local fallback).
|
|
||||||
OLLAMA_HOST=http://localhost:11434 # Ollama host; override if on a different machine
|
|
||||||
OLLAMA_MODEL=llama3.2:3b # model to request from Ollama
|
|
||||||
OPENAI_MODEL=gpt-4o-mini # model override for OpenAI-compat backend
|
|
||||||
ANTHROPIC_MODEL=claude-haiku-4-5-20251001 # model override for Anthropic backend
|
|
||||||
|
|
||||||
# API keys (required for remote profile)
|
# API keys (required for remote profile)
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
OPENAI_COMPAT_URL=
|
OPENAI_COMPAT_URL=
|
||||||
|
|
@ -39,12 +28,6 @@ FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
|
||||||
# GITHUB_TOKEN= # future — enable when public mirror is active
|
# GITHUB_TOKEN= # future — enable when public mirror is active
|
||||||
# GITHUB_REPO= # future
|
# GITHUB_REPO= # future
|
||||||
|
|
||||||
# ── CF-hosted coordinator (Paid+ tier) ───────────────────────────────────────
|
|
||||||
# Set CF_LICENSE_KEY to authenticate with the hosted coordinator.
|
|
||||||
# Leave both blank for local self-hosted cf-orch or bare-metal inference.
|
|
||||||
CF_LICENSE_KEY=
|
|
||||||
CF_ORCH_URL=https://orch.circuitforge.tech
|
|
||||||
|
|
||||||
# Cloud multi-tenancy (compose.cloud.yml only — do not set for local installs)
|
# Cloud multi-tenancy (compose.cloud.yml only — do not set for local installs)
|
||||||
CLOUD_MODE=false
|
CLOUD_MODE=false
|
||||||
CLOUD_DATA_ROOT=/devl/menagerie-data
|
CLOUD_DATA_ROOT=/devl/menagerie-data
|
||||||
|
|
|
||||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -22,12 +22,6 @@ jobs:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
cache: pip
|
cache: pip
|
||||||
|
|
||||||
- name: Configure git credentials for Forgejo
|
|
||||||
env:
|
|
||||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
|
||||||
run: |
|
|
||||||
git config --global url."https://oauth2:${FORGEJO_TOKEN}@git.opensourcesolarpunk.com/".insteadOf "https://git.opensourcesolarpunk.com/"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip install -r requirements.txt
|
run: pip install -r requirements.txt
|
||||||
|
|
||||||
|
|
|
||||||
216
CHANGELOG.md
216
CHANGELOG.md
|
|
@ -9,222 +9,6 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [0.8.5] — 2026-04-02
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Vue onboarding wizard** — 7-step first-run setup replaces the Streamlit wizard
|
|
||||||
in the Vue SPA: Hardware detection → Tier → Resume upload/build → Identity →
|
|
||||||
Inference & API keys → Search preferences → Integrations. Progress saves to
|
|
||||||
`user.yaml` on every step; crash-recovery resumes from the last completed step.
|
|
||||||
- **Wizard API endpoints** — `GET /api/wizard/status`, `POST /api/wizard/step`,
|
|
||||||
`GET /api/wizard/hardware`, `POST /api/wizard/inference/test`,
|
|
||||||
`POST /api/wizard/complete`. Inference test always soft-fails so Ollama being
|
|
||||||
unreachable never blocks setup completion.
|
|
||||||
- **Cloud auto-skip** — cloud instances automatically complete steps 1 (hardware),
|
|
||||||
2 (tier), and 5 (inference) and drop the user directly on the Resume step.
|
|
||||||
- **`wizardGuard` router gate** — all Vue routes require wizard completion; completed
|
|
||||||
users are bounced away from `/setup` to `/`.
|
|
||||||
- **Chip-input search step** — job titles and locations entered as press-Enter/comma
|
|
||||||
chips; validates at least one title before advancing.
|
|
||||||
- **Integrations tile grid** — optional step 7 shows Notion, Calendar, Slack, Discord,
|
|
||||||
Drive with paid-tier badges; skippable on Finish.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **User config isolation: dangerous fallback removed** — `_user_yaml_path()` fell
|
|
||||||
back to `/devl/job-seeker/config/user.yaml` (legacy profile) when `user.yaml`
|
|
||||||
didn't exist at the expected path; new users now get an empty dict instead of
|
|
||||||
another user's data. Affects profile, resume, search, and all wizard endpoints.
|
|
||||||
- **Resume path not user-isolated** — `RESUME_PATH = Path("config/plain_text_resume.yaml")`
|
|
||||||
was a relative CWD path shared across all users. Replaced with `_resume_path()`
|
|
||||||
derived from `_user_yaml_path()` / `STAGING_DB`.
|
|
||||||
- **Resume upload silently returned empty data** — `upload_resume` was passing a
|
|
||||||
file path string to `structure_resume()` which expects raw text; now reads bytes
|
|
||||||
and dispatches to the correct extractor (`extract_text_from_pdf` / `_docx` / `_odt`).
|
|
||||||
- **Wizard resume step read wrong envelope field** — `WizardResumeStep.vue` read
|
|
||||||
`data.experience` but the upload response wraps parsed data under `data.data`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.8.4] — 2026-04-02
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Cloud: cover letter used wrong user's profile** — `generate_cover_letter.generate()`
|
|
||||||
loaded `_profile` from the global `config/user.yaml` at module import time, so all
|
|
||||||
cloud users got the default user's name, voice, and mission preferences in their
|
|
||||||
generated letters. `generate()` now accepts a `user_yaml_path` parameter; `task_runner`
|
|
||||||
derives it from the per-user config directory (`db_path/../config/user.yaml`) and
|
|
||||||
passes it through. `_build_system_context`, `_build_mission_notes`, `detect_mission_alignment`,
|
|
||||||
`build_prompt`, and `_trim_to_letter_end` all accept a `profile` override so the
|
|
||||||
per-call profile is used end-to-end without breaking CLI mode.
|
|
||||||
- **Apply Workspace: hardcoded config paths in cloud mode** — `4_Apply.py` was loading
|
|
||||||
`_USER_YAML` and `RESUME_YAML` from the repo-root `config/` before `resolve_session()`
|
|
||||||
ran, so cloud users saw the global (Meg's) resume in the Apply tab. Both paths now
|
|
||||||
derive from `get_config_dir()` after session resolution.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **Vue SPA open to all tiers** — Vue 3 frontend is no longer gated behind the beta
|
|
||||||
flag; all tier users can switch to the Vue UI from Settings.
|
|
||||||
- **LLM model candidates** — vllm backend now tries Qwen2.5-3B first, Phi-4-mini
|
|
||||||
as fallback (was reversed). cf_orch allocation block added to vllm config.
|
|
||||||
- **Preflight** — removed `vllm` from Docker adoption list; vllm is now managed
|
|
||||||
entirely by cf-orch and should not be stubbed by preflight.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.8.3] — 2026-04-01
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **CI: Forgejo auth** — GitHub Actions `pip install` was failing to fetch
|
|
||||||
`circuitforge-core` from the private Forgejo VCS URL. Added `FORGEJO_TOKEN`
|
|
||||||
repository secret and a `git config insteadOf` step to inject credentials
|
|
||||||
before `pip install`.
|
|
||||||
- **CI: settings API tests** — 6 `test_dev_api_settings` PUT/POST tests were
|
|
||||||
returning HTTP 500 in CI because `_user_yaml_path()` read the module-level
|
|
||||||
`DB_PATH` constant (frozen at import time), so `monkeypatch.setenv("STAGING_DB")`
|
|
||||||
had no effect. Fixed by reading `os.environ` at call time.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.8.2] — 2026-04-01
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **CI pipeline** — `pip install -r requirements.txt` was failing in GitHub Actions
|
|
||||||
because `-e ../circuitforge-core` requires a sibling directory that doesn't exist
|
|
||||||
in a single-repo checkout. Replaced with a `git+https://` VCS URL fallback;
|
|
||||||
`Dockerfile.cfcore` still installs from the local `COPY` to avoid redundant
|
|
||||||
network fetches during Docker builds.
|
|
||||||
- **Vue-nav reload loop** — `sync_ui_cookie()` was calling
|
|
||||||
`window.parent.location.reload()` on every render when `user.yaml` has
|
|
||||||
`ui_preference: vue` but no Caddy proxy is in the traffic path (test instances,
|
|
||||||
bare Docker). Gated the reload on `PEREGRINE_CADDY_PROXY=1`; instances without
|
|
||||||
the env var set the cookie silently and skip the reload.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **cfcore VRAM lease integration** — the task scheduler now acquires a VRAM lease
|
|
||||||
from the cf-orch coordinator before running a batch of LLM tasks and releases it
|
|
||||||
when the batch completes. Visible in the coordinator dashboard at `:7700`.
|
|
||||||
- **`CF_ORCH_URL` env var** — scheduler reads coordinator address from
|
|
||||||
`CF_ORCH_URL` (default `http://localhost:7700`); set to
|
|
||||||
`http://host.docker.internal:7700` in Docker compose files so containers can
|
|
||||||
reach the host coordinator.
|
|
||||||
- **All compose files on `Dockerfile.cfcore`** — `compose.yml`, `compose.cloud.yml`,
|
|
||||||
and `compose.test-cfcore.yml` all use the parent-context build. `build: .` is
|
|
||||||
removed from `compose.yml`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.8.1] — 2026-04-01
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Job title suggester silent failure** — when the LLM returned empty arrays or
|
|
||||||
non-JSON text, the spinner would complete with zero UI feedback. Now shows an
|
|
||||||
explicit "No new suggestions found" info message with a resume-upload hint for
|
|
||||||
new users who haven't uploaded a resume yet.
|
|
||||||
- **Suggester exception handling** — catch `Exception` instead of only
|
|
||||||
`RuntimeError` so connection errors and `FileNotFoundError` (missing llm.yaml)
|
|
||||||
surface as error messages rather than crashing the page silently.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **`Dockerfile.cfcore`** — parent-context Dockerfile that copies
|
|
||||||
`circuitforge-core/` alongside `peregrine/` before `pip install`, resolving
|
|
||||||
the `-e ../circuitforge-core` editable requirement inside Docker.
|
|
||||||
- **`compose.test-cfcore.yml`** — single-user test instance on port 8516 for
|
|
||||||
smoke-testing cfcore shim integration before promoting to the cloud instance.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.8.0] — 2026-04-01
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **ATS Resume Optimizer** (gap report free; LLM rewrite paid+)
|
|
||||||
- `scripts/resume_optimizer.py` — full pipeline: TF-IDF gap extraction →
|
|
||||||
`prioritize_gaps` → `rewrite_for_ats` → hallucination guard (anchor-set
|
|
||||||
diffing on employers, institutions, and dates)
|
|
||||||
- `scripts/db.py` — `optimized_resume` + `ats_gap_report` columns;
|
|
||||||
`save_optimized_resume` / `get_optimized_resume` helpers
|
|
||||||
- `GET /api/jobs/{id}/resume_optimizer` — fetch gap report + rewrite
|
|
||||||
- `POST /api/jobs/{id}/resume_optimizer/generate` — queue rewrite task
|
|
||||||
- `GET /api/jobs/{id}/resume_optimizer/task` — poll task status
|
|
||||||
- `web/src/components/ResumeOptimizerPanel.vue` — gap report (all tiers),
|
|
||||||
LLM rewrite section (paid+), hallucination warning badge, `.txt` download
|
|
||||||
- `ResumeOptimizerPanel` integrated into `ApplyWorkspace`
|
|
||||||
|
|
||||||
- **Vue SPA full merge** (closes #8) — `feature/vue-spa` merged to `main`
|
|
||||||
- `dev-api.py` — full FastAPI backend (settings, jobs, interviews, prep,
|
|
||||||
survey, digest, resume optimizer); cloud session middleware (JWT → per-user
|
|
||||||
SQLite); BYOK credential store
|
|
||||||
- `dev_api.py` — symlink → `dev-api.py` for importable module alias
|
|
||||||
- `scripts/job_ranker.py` — two-stage ranking for `/api/jobs/stack`
|
|
||||||
- `scripts/credential_store.py` — per-user BYOK API key management
|
|
||||||
- `scripts/user_profile.py` — `load_user_profile` / `save_user_profile`
|
|
||||||
- `web/src/components/TaskIndicator.vue` + `web/src/stores/tasks.ts` —
|
|
||||||
live background task queue display
|
|
||||||
- `web/public/` — peregrine logo assets (SVG + PNG)
|
|
||||||
|
|
||||||
- **API test suite** — 5 new test modules (622 tests total)
|
|
||||||
- `tests/test_dev_api_settings.py` (38 tests)
|
|
||||||
- `tests/test_dev_api_interviews.py`, `test_dev_api_prep.py`,
|
|
||||||
`test_dev_api_survey.py`, `test_dev_api_digest.py`
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Cloud DB routing** — `app/pages/1_Job_Review.py`, `5_Interviews.py`,
|
|
||||||
`6_Interview_Prep.py`, `7_Survey.py` were hardcoding `DEFAULT_DB`; now
|
|
||||||
use `get_db_path()` for correct per-user routing in cloud mode (#24)
|
|
||||||
- **Test isolation** — `importlib.reload(dev_api)` in digest/interviews
|
|
||||||
fixtures reset all module globals, silently breaking `monkeypatch.setattr`
|
|
||||||
in subsequent test files; replaced with targeted `monkeypatch.setattr(dev_api,
|
|
||||||
"DB_PATH", tmp_db)` (#26)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.7.0] — 2026-03-22
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Vue 3 SPA — beta access for paid tier** — The new Vue 3 frontend (built with
|
|
||||||
Vite + UnoCSS) is now merged into `main` and available to paid-tier subscribers
|
|
||||||
as an opt-in beta. The Streamlit UI remains the default and will continue to
|
|
||||||
receive full support.
|
|
||||||
- `web/` — full Vue 3 SPA source (components, stores, router, composables,
|
|
||||||
views) from `feature/vue-spa`
|
|
||||||
- `web/src/components/ClassicUIButton.vue` — one-click switch back to the
|
|
||||||
Classic (Streamlit) UI; sets `prgn_ui=streamlit` cookie and appends
|
|
||||||
`?prgn_switch=streamlit` so `user.yaml` stays in sync
|
|
||||||
- `web/src/composables/useFeatureFlag.ts` — reads `prgn_demo_tier` cookie for
|
|
||||||
demo toolbar visual consistency (display-only, not an authoritative gate)
|
|
||||||
|
|
||||||
- **UI switcher** — Reddit-style opt-in to the Vue SPA with durable preference
|
|
||||||
persistence and graceful fallback.
|
|
||||||
- `app/components/ui_switcher.py` — `sync_ui_cookie()`, `switch_ui()`,
|
|
||||||
`render_banner()`, `render_settings_toggle()`
|
|
||||||
- `scripts/user_profile.py` — `ui_preference` field (`streamlit` | `vue`,
|
|
||||||
default: `streamlit`) with round-trip `save()`
|
|
||||||
- `app/wizard/tiers.py` — `vue_ui_beta: "paid"` feature key; `demo_tier`
|
|
||||||
keyword arg on `can_use()` for thread-safe demo mode simulation
|
|
||||||
- Banner (dismissible, paid tier only) + Settings → System → Deployment toggle
|
|
||||||
- Caddy cookie routing: `prgn_ui=vue` → nginx Vue SPA; absent/`streamlit` →
|
|
||||||
Streamlit. 502 fallback clears cookie and redirects with `?ui_fallback=1`
|
|
||||||
|
|
||||||
- **Demo toolbar** — slim full-width tier-simulation bar for `DEMO_MODE`
|
|
||||||
instances. Free / Paid / Premium pills let demo visitors explore all feature
|
|
||||||
tiers without an account. Persists via `prgn_demo_tier` cookie. Default: Paid
|
|
||||||
(most compelling first impression). `app/components/demo_toolbar.py`
|
|
||||||
|
|
||||||
- **Docker `web` service** — multi-stage nginx container serving the Vue SPA
|
|
||||||
`dist/` build. Added to `compose.yml` (port 8506), `compose.demo.yml`
|
|
||||||
(port 8507), `compose.cloud.yml` (port 8508). `manage.sh build` now includes
|
|
||||||
the `web` service alongside `app`.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Caddy routing** — `menagerie.circuitforge.tech` and
|
|
||||||
`demo.circuitforge.tech` peregrine blocks now inspect the `prgn_ui` cookie
|
|
||||||
and fan-out to the Vue SPA service or Streamlit accordingly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.6.2] — 2026-03-18
|
## [0.6.2] — 2026-03-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
# Dockerfile.cfcore — build context must be the PARENT directory of peregrine/
|
|
||||||
#
|
|
||||||
# Used when circuitforge-core is installed from source (not PyPI).
|
|
||||||
# Both repos must be siblings on the build host:
|
|
||||||
# /devl/peregrine/ → WORKDIR /app
|
|
||||||
# /devl/circuitforge-core/ → installed to /circuitforge-core
|
|
||||||
#
|
|
||||||
# Build manually:
|
|
||||||
# docker build -f peregrine/Dockerfile.cfcore -t peregrine-cfcore ..
|
|
||||||
#
|
|
||||||
# Via compose (compose.test-cfcore.yml sets context: ..):
|
|
||||||
# docker compose -f compose.test-cfcore.yml build
|
|
||||||
FROM python:3.11-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# System deps for companyScraper (beautifulsoup4, fake-useragent, lxml) and PDF gen
|
|
||||||
# libsqlcipher-dev: required to build pysqlcipher3 (SQLCipher AES-256 encryption for cloud mode)
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
gcc libffi-dev curl libsqlcipher-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy circuitforge-core and install it from the local path before requirements.txt.
|
|
||||||
# requirements.txt has a git+https:// fallback URL for CI (where circuitforge-core
|
|
||||||
# is not a sibling directory), but Docker always has the local copy available here.
|
|
||||||
COPY circuitforge-core/ /circuitforge-core/
|
|
||||||
RUN pip install --no-cache-dir /circuitforge-core
|
|
||||||
|
|
||||||
COPY peregrine/requirements.txt .
|
|
||||||
# Skip the cfcore line — already installed above from the local copy
|
|
||||||
RUN grep -v 'circuitforge-core' requirements.txt | pip install --no-cache-dir -r /dev/stdin
|
|
||||||
|
|
||||||
# Install Playwright browser (cached separately from Python deps so requirements
|
|
||||||
# changes don't bust the ~600–900 MB Chromium layer and vice versa)
|
|
||||||
RUN playwright install chromium && playwright install-deps chromium
|
|
||||||
|
|
||||||
# Bundle companyScraper (company research web scraper)
|
|
||||||
COPY peregrine/scrapers/ /app/scrapers/
|
|
||||||
|
|
||||||
COPY peregrine/ .
|
|
||||||
|
|
||||||
EXPOSE 8501
|
|
||||||
|
|
||||||
CMD ["streamlit", "run", "app/app.py", \
|
|
||||||
"--server.port=8501", \
|
|
||||||
"--server.headless=true", \
|
|
||||||
"--server.fileWatcherType=none"]
|
|
||||||
42
README.md
42
README.md
|
|
@ -1,33 +1,16 @@
|
||||||
# Peregrine
|
# Peregrine
|
||||||
|
|
||||||
> **Primary development** happens at [git.opensourcesolarpunk.com](https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine) — GitHub and Codeberg are push mirrors. Issues and PRs are welcome on either platform.
|
> **Primary development** happens at [git.opensourcesolarpunk.com](https://git.opensourcesolarpunk.com/pyr0ball/peregrine) — GitHub and Codeberg are push mirrors. Issues and PRs are welcome on either platform.
|
||||||
|
|
||||||
[](./LICENSE-BSL)
|
[](./LICENSE-BSL)
|
||||||
[](https://github.com/CircuitForge/peregrine/actions/workflows/ci.yml)
|
[](https://github.com/CircuitForge/peregrine/actions/workflows/ci.yml)
|
||||||
|
|
||||||
**Job search pipeline — by [Circuit Forge LLC](https://circuitforge.tech)**
|
**AI-powered job search pipeline — by [Circuit Forge LLC](https://circuitforge.tech)**
|
||||||
|
|
||||||
> *"Tools for the jobs that the system made hard on purpose."*
|
> *"Don't be evil, for real and forever."*
|
||||||
|
|
||||||
---
|
Automates the full job search lifecycle: discovery → matching → cover letters → applications → interview prep.
|
||||||
|
Privacy-first, local-first. Your data never leaves your machine.
|
||||||
Job search is a second job nobody hired you for.
|
|
||||||
|
|
||||||
ATS filters designed to reject. Job boards that show the same listing eight times. Cover letter number forty-seven for a role that might already be filled. Hours of prep for a phone screen that lasts twelve minutes.
|
|
||||||
|
|
||||||
Peregrine handles the pipeline — discovery, matching, tracking, drafting, and prep — so you can spend your time doing the work you actually want to be doing.
|
|
||||||
|
|
||||||
**LLM support is optional.** The full discovery and tracking pipeline works without one. When you do configure a backend, the LLM drafts the parts that are genuinely miserable — cover letters, company research briefs, interview prep sheets — and waits for your approval before anything goes anywhere.
|
|
||||||
|
|
||||||
### What Peregrine does not do
|
|
||||||
|
|
||||||
Peregrine does **not** submit job applications for you. You still have to go to each employer's site and click apply yourself.
|
|
||||||
|
|
||||||
This is intentional. Automated mass-applying is a bad experience for everyone — it's also a trust violation with employers who took the time to post a real role. Peregrine is a preparation and organization tool, not a bot.
|
|
||||||
|
|
||||||
What it *does* cover is everything before and after that click: finding the jobs, matching them against your resume, generating cover letters and prep materials, and once you've applied — tracking where you stand, classifying the emails that come back, and surfacing company research when an interview lands on your calendar. The submit button is yours. The rest of the grind is ours.
|
|
||||||
|
|
||||||
> **Exception:** [AIHawk](https://github.com/nicolomantini/LinkedIn-Easy-Apply) is a separate, optional tool that handles LinkedIn Easy Apply automation. Peregrine integrates with it for AIHawk-compatible profiles, but it is not part of Peregrine's core pipeline.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -36,7 +19,7 @@ What it *does* cover is everything before and after that click: finding the jobs
|
||||||
**1. Clone and install dependencies** (Docker, NVIDIA toolkit if needed):
|
**1. Clone and install dependencies** (Docker, NVIDIA toolkit if needed):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine
|
git clone https://git.opensourcesolarpunk.com/pyr0ball/peregrine
|
||||||
cd peregrine
|
cd peregrine
|
||||||
./manage.sh setup
|
./manage.sh setup
|
||||||
```
|
```
|
||||||
|
|
@ -146,26 +129,21 @@ Re-enter the wizard any time via **Settings → Developer → Reset wizard**.
|
||||||
| **Company research briefs** | Free with LLM¹ |
|
| **Company research briefs** | Free with LLM¹ |
|
||||||
| **Interview prep & practice Q&A** | Free with LLM¹ |
|
| **Interview prep & practice Q&A** | Free with LLM¹ |
|
||||||
| **Survey assistant** (culture-fit Q&A, screenshot analysis) | Free with LLM¹ |
|
| **Survey assistant** (culture-fit Q&A, screenshot analysis) | Free with LLM¹ |
|
||||||
| **Wizard helpers** (career summary, bullet expansion, skill suggestions, job title suggestions, mission notes) | Free with LLM¹ |
|
| **AI wizard helpers** (career summary, bullet expansion, skill suggestions) | Free with LLM¹ |
|
||||||
| Managed cloud LLM (no API key needed) | Paid |
|
| Managed cloud LLM (no API key needed) | Paid |
|
||||||
| Email sync & auto-classification | Paid |
|
| Email sync & auto-classification | Paid |
|
||||||
| LLM-powered keyword blocklist | Paid |
|
|
||||||
| Job tracking integrations (Notion, Airtable, Google Sheets) | Paid |
|
| Job tracking integrations (Notion, Airtable, Google Sheets) | Paid |
|
||||||
| Calendar sync (Google, Apple) | Paid |
|
| Calendar sync (Google, Apple) | Paid |
|
||||||
| Slack notifications | Paid |
|
| Slack notifications | Paid |
|
||||||
| CircuitForge shared cover-letter model | 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 |
|
|
||||||
| **Voice guidelines** (custom writing style & tone) | Premium with LLM¹ ² |
|
|
||||||
| Cover letter model fine-tuning (your writing, your model) | Premium |
|
| Cover letter model fine-tuning (your writing, your model) | Premium |
|
||||||
| Multi-user support | Premium |
|
| Multi-user support | Premium |
|
||||||
|
|
||||||
¹ **BYOK (bring your own key/backend) unlock:** configure any LLM backend — a local [Ollama](https://ollama.com) or vLLM instance,
|
¹ **BYOK unlock:** configure any LLM backend — a local [Ollama](https://ollama.com) or vLLM instance,
|
||||||
or your own API key (Anthropic, OpenAI-compatible) — and all features marked **Free with LLM** or **Premium with LLM**
|
or your own API key (Anthropic, OpenAI-compatible) — and all AI features marked **Free with LLM**
|
||||||
unlock at no charge. The paid tier earns its price by providing managed cloud inference so you
|
unlock at no charge. The paid tier earns its price by providing managed cloud inference so you
|
||||||
don't need a key at all, plus integrations and email sync.
|
don't need a key at all, plus integrations and email sync.
|
||||||
|
|
||||||
² **Voice guidelines** requires Premium tier without a configured LLM backend. With BYOK, it unlocks at any tier.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Email Sync
|
## Email Sync
|
||||||
|
|
@ -223,6 +201,6 @@ Full documentation at: https://docs.circuitforge.tech/peregrine
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Core discovery pipeline: [MIT](LICENSE-MIT)
|
Core discovery pipeline: [MIT](LICENSE-MIT)
|
||||||
LLM features (cover letter generation, company research, interview prep, UI): [BSL 1.1](LICENSE-BSL)
|
AI features (cover letter generation, company research, interview prep, UI): [BSL 1.1](LICENSE-BSL)
|
||||||
|
|
||||||
© 2026 Circuit Forge LLC
|
© 2026 Circuit Forge LLC
|
||||||
|
|
|
||||||
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"
|
_name = _profile.name if _profile else "Job Seeker"
|
||||||
|
|
||||||
from scripts.db import init_db, get_job_counts, purge_jobs, purge_email_data, \
|
from scripts.db import init_db, get_job_counts, purge_jobs, purge_email_data, \
|
||||||
purge_non_remote, archive_jobs, kill_stuck_tasks, cancel_task, \
|
purge_non_remote, archive_jobs, kill_stuck_tasks, get_task_for_job, get_active_tasks, \
|
||||||
get_task_for_job, get_active_tasks, insert_job, get_existing_urls
|
insert_job, get_existing_urls
|
||||||
from scripts.task_runner import submit_task
|
from scripts.task_runner import submit_task
|
||||||
from app.cloud_session import resolve_session, get_db_path
|
from app.cloud_session import resolve_session, get_db_path
|
||||||
|
|
||||||
|
|
@ -376,145 +376,178 @@ _scrape_status()
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
|
|
||||||
# ── Danger zone ───────────────────────────────────────────────────────────────
|
# ── Danger zone: purge + re-scrape ────────────────────────────────────────────
|
||||||
with st.expander("⚠️ Danger Zone", expanded=False):
|
with st.expander("⚠️ Danger Zone", expanded=False):
|
||||||
|
|
||||||
# ── Queue reset (the common case) ─────────────────────────────────────────
|
|
||||||
st.markdown("**Queue reset**")
|
|
||||||
st.caption(
|
st.caption(
|
||||||
"Archive clears your review queue while keeping job URLs for dedup, "
|
"**Purge** permanently deletes jobs from the local database. "
|
||||||
"so the same listings won't resurface on the next discovery run. "
|
"Applied and synced jobs are never touched."
|
||||||
"Use hard purge only if you want a full clean slate including dedup history."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_scope = st.radio(
|
purge_col, rescrape_col, email_col, tasks_col = st.columns(4)
|
||||||
"Clear scope",
|
|
||||||
["Pending only", "Pending + approved (stale search)"],
|
|
||||||
horizontal=True,
|
|
||||||
label_visibility="collapsed",
|
|
||||||
)
|
|
||||||
_scope_statuses = (
|
|
||||||
["pending"] if _scope == "Pending only" else ["pending", "approved"]
|
|
||||||
)
|
|
||||||
|
|
||||||
_qc1, _qc2, _qc3 = st.columns([2, 2, 4])
|
with purge_col:
|
||||||
if _qc1.button("📦 Archive & reset", use_container_width=True, type="primary"):
|
st.markdown("**Purge pending & rejected**")
|
||||||
st.session_state["confirm_dz"] = "archive"
|
st.caption("Removes all _pending_ and _rejected_ listings so the next discovery starts fresh.")
|
||||||
if _qc2.button("🗑 Hard purge (delete)", use_container_width=True):
|
if st.button("🗑 Purge Pending + Rejected", use_container_width=True):
|
||||||
st.session_state["confirm_dz"] = "purge"
|
st.session_state["confirm_purge"] = "partial"
|
||||||
|
|
||||||
if st.session_state.get("confirm_dz") == "archive":
|
if st.session_state.get("confirm_purge") == "partial":
|
||||||
st.info(
|
st.warning("Are you sure? This cannot be undone.")
|
||||||
f"Archive **{', '.join(_scope_statuses)}** jobs? "
|
c1, c2 = st.columns(2)
|
||||||
"URLs are kept for dedup — nothing is permanently deleted."
|
if c1.button("Yes, purge", type="primary", use_container_width=True):
|
||||||
)
|
deleted = purge_jobs(get_db_path(), statuses=["pending", "rejected"])
|
||||||
_dc1, _dc2 = st.columns(2)
|
st.success(f"Purged {deleted} jobs.")
|
||||||
if _dc1.button("Yes, archive", type="primary", use_container_width=True, key="dz_archive_confirm"):
|
st.session_state.pop("confirm_purge", None)
|
||||||
n = archive_jobs(get_db_path(), statuses=_scope_statuses)
|
st.rerun()
|
||||||
st.success(f"Archived {n} jobs.")
|
if c2.button("Cancel", use_container_width=True):
|
||||||
st.session_state.pop("confirm_dz", None)
|
st.session_state.pop("confirm_purge", 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"])
|
|
||||||
st.rerun()
|
st.rerun()
|
||||||
st.caption("")
|
|
||||||
|
|
||||||
_kill_col, _ = st.columns([2, 6])
|
with email_col:
|
||||||
if _kill_col.button("⏹ Kill all stuck", use_container_width=True, disabled=len(_active) == 0):
|
st.markdown("**Purge email data**")
|
||||||
killed = kill_stuck_tasks(get_db_path())
|
st.caption("Clears all email thread logs and email-sourced pending jobs so the next sync starts fresh.")
|
||||||
st.success(f"Killed {killed} task(s).")
|
if st.button("📧 Purge Email Data", use_container_width=True):
|
||||||
st.rerun()
|
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()
|
st.divider()
|
||||||
|
|
||||||
# ── Rarely needed (collapsed) ─────────────────────────────────────────────
|
pending_col, nonremote_col, approved_col, _ = st.columns(4)
|
||||||
with st.expander("More options", expanded=False):
|
|
||||||
_rare1, _rare2, _rare3 = st.columns(3)
|
|
||||||
|
|
||||||
with _rare1:
|
with pending_col:
|
||||||
st.markdown("**Purge email data**")
|
st.markdown("**Purge pending review**")
|
||||||
st.caption("Clears all email thread logs and email-sourced pending jobs.")
|
st.caption("Removes only _pending_ listings, keeping your rejected history intact.")
|
||||||
if st.button("📧 Purge Email Data", use_container_width=True):
|
if st.button("🗑 Purge Pending Only", use_container_width=True):
|
||||||
st.session_state["confirm_dz"] = "email"
|
st.session_state["confirm_purge"] = "pending_only"
|
||||||
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 _rare2:
|
if st.session_state.get("confirm_purge") == "pending_only":
|
||||||
st.markdown("**Purge non-remote**")
|
st.warning("Deletes all pending jobs. Rejected jobs are kept. Cannot be undone.")
|
||||||
st.caption("Removes pending/approved/rejected on-site listings from the DB.")
|
c1, c2 = st.columns(2)
|
||||||
if st.button("🏢 Purge On-site Jobs", use_container_width=True):
|
if c1.button("Yes, purge pending", type="primary", use_container_width=True):
|
||||||
st.session_state["confirm_dz"] = "non_remote"
|
deleted = purge_jobs(get_db_path(), statuses=["pending"])
|
||||||
if st.session_state.get("confirm_dz") == "non_remote":
|
st.success(f"Purged {deleted} pending jobs.")
|
||||||
st.warning("Deletes all non-remote jobs not yet applied to. Cannot be undone.")
|
st.session_state.pop("confirm_purge", None)
|
||||||
_rc1, _rc2 = st.columns(2)
|
st.rerun()
|
||||||
if _rc1.button("Yes, purge on-site", type="primary", use_container_width=True, key="dz_nonremote_confirm"):
|
if c2.button("Cancel ", use_container_width=True):
|
||||||
deleted = purge_non_remote(get_db_path())
|
st.session_state.pop("confirm_purge", None)
|
||||||
st.success(f"Purged {deleted} non-remote jobs.")
|
st.rerun()
|
||||||
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()
|
|
||||||
|
|
||||||
with _rare3:
|
with nonremote_col:
|
||||||
st.markdown("**Wipe all + re-scrape**")
|
st.markdown("**Purge non-remote**")
|
||||||
st.caption("Deletes all non-applied jobs then immediately runs a fresh discovery.")
|
st.caption("Removes pending/approved/rejected jobs where remote is not set. Keeps anything already in the pipeline.")
|
||||||
if st.button("🔄 Wipe + Re-scrape", use_container_width=True):
|
if st.button("🏢 Purge On-site Jobs", use_container_width=True):
|
||||||
st.session_state["confirm_dz"] = "rescrape"
|
st.session_state["confirm_purge"] = "non_remote"
|
||||||
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.")
|
if st.session_state.get("confirm_purge") == "non_remote":
|
||||||
_wc1, _wc2 = st.columns(2)
|
st.warning("Deletes all non-remote jobs not yet applied to. Cannot be undone.")
|
||||||
if _wc1.button("Yes, wipe + scrape", type="primary", use_container_width=True, key="dz_rescrape_confirm"):
|
c1, c2 = st.columns(2)
|
||||||
purge_jobs(get_db_path(), statuses=["pending", "approved", "rejected"])
|
if c1.button("Yes, purge on-site", type="primary", use_container_width=True):
|
||||||
submit_task(get_db_path(), "discovery", 0)
|
deleted = purge_non_remote(get_db_path())
|
||||||
st.session_state.pop("confirm_dz", None)
|
st.success(f"Purged {deleted} non-remote jobs.")
|
||||||
st.rerun()
|
st.session_state.pop("confirm_purge", None)
|
||||||
if _wc2.button("Cancel", use_container_width=True, key="dz_rescrape_cancel"):
|
st.rerun()
|
||||||
st.session_state.pop("confirm_dz", None)
|
if c2.button("Cancel ", use_container_width=True):
|
||||||
st.rerun()
|
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 ─────────────────────────────────────────────────────────────
|
# ── Setup banners ─────────────────────────────────────────────────────────────
|
||||||
if _profile and _profile.wizard_complete:
|
if _profile and _profile.wizard_complete:
|
||||||
|
|
|
||||||
123
app/app.py
123
app/app.py
|
|
@ -17,39 +17,22 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
logging.basicConfig(level=logging.WARNING, format="%(name)s %(levelname)s: %(message)s")
|
logging.basicConfig(level=logging.WARNING, format="%(name)s %(levelname)s: %(message)s")
|
||||||
|
|
||||||
# Load .env before any os.environ reads — safe to call inside Docker too
|
|
||||||
# (uses setdefault, so Docker-injected vars take precedence over .env values)
|
|
||||||
from circuitforge_core.config.settings import load_env as _load_env
|
|
||||||
_load_env(Path(__file__).parent.parent / ".env")
|
|
||||||
|
|
||||||
IS_DEMO = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
|
IS_DEMO = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
from scripts.db import DEFAULT_DB, init_db, get_active_tasks
|
from scripts.db import DEFAULT_DB, init_db, get_active_tasks
|
||||||
from scripts.db_migrate import migrate_db
|
|
||||||
from app.feedback import inject_feedback_button
|
from app.feedback import inject_feedback_button
|
||||||
from app.cloud_session import resolve_session, get_db_path, get_config_dir, get_cloud_tier
|
from app.cloud_session import resolve_session, get_db_path, get_config_dir
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
_LOGO_CIRCLE = Path(__file__).parent / "static" / "peregrine_logo_circle.png"
|
|
||||||
_LOGO_FULL = Path(__file__).parent / "static" / "peregrine_logo.png"
|
|
||||||
|
|
||||||
st.set_page_config(
|
st.set_page_config(
|
||||||
page_title="Peregrine",
|
page_title="Peregrine",
|
||||||
page_icon=str(_LOGO_CIRCLE) if _LOGO_CIRCLE.exists() else "💼",
|
page_icon="💼",
|
||||||
layout="wide",
|
layout="wide",
|
||||||
)
|
)
|
||||||
|
|
||||||
resolve_session("peregrine")
|
resolve_session("peregrine")
|
||||||
init_db(get_db_path())
|
init_db(get_db_path())
|
||||||
migrate_db(Path(get_db_path()))
|
|
||||||
|
|
||||||
# Demo tier — initialize once per session (cookie persistence handled client-side)
|
|
||||||
if IS_DEMO and "simulated_tier" not in st.session_state:
|
|
||||||
st.session_state["simulated_tier"] = "paid"
|
|
||||||
|
|
||||||
if _LOGO_CIRCLE.exists():
|
|
||||||
st.logo(str(_LOGO_CIRCLE), icon_image=str(_LOGO_CIRCLE))
|
|
||||||
|
|
||||||
# ── Startup cleanup — runs once per server process via cache_resource ──────────
|
# ── Startup cleanup — runs once per server process via cache_resource ──────────
|
||||||
@st.cache_resource
|
@st.cache_resource
|
||||||
|
|
@ -106,15 +89,6 @@ _show_wizard = not IS_DEMO and (
|
||||||
if _show_wizard:
|
if _show_wizard:
|
||||||
_setup_page = st.Page("pages/0_Setup.py", title="Setup", icon="👋")
|
_setup_page = st.Page("pages/0_Setup.py", title="Setup", icon="👋")
|
||||||
st.navigation({"": [_setup_page]}).run()
|
st.navigation({"": [_setup_page]}).run()
|
||||||
# Sync UI cookie even during wizard so vue preference redirects correctly.
|
|
||||||
# Tier not yet computed here — use cloud tier (or "free" fallback).
|
|
||||||
try:
|
|
||||||
from app.components.ui_switcher import sync_ui_cookie as _sync_wizard_cookie
|
|
||||||
from app.cloud_session import get_cloud_tier as _gctr
|
|
||||||
_wizard_tier = _gctr() if _gctr() != "local" else "free"
|
|
||||||
_sync_wizard_cookie(_USER_YAML, _wizard_tier)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
# ── Navigation ─────────────────────────────────────────────────────────────────
|
# ── Navigation ─────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -139,21 +113,6 @@ pg = st.navigation(pages)
|
||||||
# ── Background task sidebar indicator ─────────────────────────────────────────
|
# ── Background task sidebar indicator ─────────────────────────────────────────
|
||||||
# Fragment polls every 3s so stage labels update live without a full page reload.
|
# Fragment polls every 3s so stage labels update live without a full page reload.
|
||||||
# The sidebar context WRAPS the fragment call — do not write to st.sidebar inside it.
|
# The sidebar context WRAPS the fragment call — do not write to st.sidebar inside it.
|
||||||
_TASK_LABELS = {
|
|
||||||
"cover_letter": "Cover letter",
|
|
||||||
"company_research": "Research",
|
|
||||||
"email_sync": "Email sync",
|
|
||||||
"discovery": "Discovery",
|
|
||||||
"enrich_descriptions": "Enriching descriptions",
|
|
||||||
"score": "Scoring matches",
|
|
||||||
"scrape_url": "Scraping listing",
|
|
||||||
"enrich_craigslist": "Enriching listing",
|
|
||||||
"wizard_generate": "Wizard generation",
|
|
||||||
"prepare_training": "Training data",
|
|
||||||
}
|
|
||||||
_DISCOVERY_PIPELINE = ["discovery", "enrich_descriptions", "score"]
|
|
||||||
|
|
||||||
|
|
||||||
@st.fragment(run_every=3)
|
@st.fragment(run_every=3)
|
||||||
def _task_indicator():
|
def _task_indicator():
|
||||||
tasks = get_active_tasks(get_db_path())
|
tasks = get_active_tasks(get_db_path())
|
||||||
|
|
@ -161,31 +120,28 @@ def _task_indicator():
|
||||||
return
|
return
|
||||||
st.divider()
|
st.divider()
|
||||||
st.markdown(f"**⏳ {len(tasks)} task(s) running**")
|
st.markdown(f"**⏳ {len(tasks)} task(s) running**")
|
||||||
|
for t in tasks:
|
||||||
pipeline_set = set(_DISCOVERY_PIPELINE)
|
icon = "⏳" if t["status"] == "running" else "🕐"
|
||||||
pipeline_tasks = [t for t in tasks if t["task_type"] in pipeline_set]
|
task_type = t["task_type"]
|
||||||
other_tasks = [t for t in tasks if t["task_type"] not in pipeline_set]
|
if task_type == "cover_letter":
|
||||||
|
label = "Cover letter"
|
||||||
# Discovery pipeline: render as ordered sub-queue with indented steps
|
elif task_type == "company_research":
|
||||||
if pipeline_tasks:
|
label = "Research"
|
||||||
ordered = [
|
elif task_type == "email_sync":
|
||||||
next((t for t in pipeline_tasks if t["task_type"] == typ), None)
|
label = "Email sync"
|
||||||
for typ in _DISCOVERY_PIPELINE
|
elif task_type == "discovery":
|
||||||
]
|
label = "Discovery"
|
||||||
ordered = [t for t in ordered if t is not None]
|
elif task_type == "enrich_descriptions":
|
||||||
for i, t in enumerate(ordered):
|
label = "Enriching"
|
||||||
icon = "⏳" if t["status"] == "running" else "🕐"
|
elif task_type == "scrape_url":
|
||||||
label = _TASK_LABELS.get(t["task_type"], t["task_type"].replace("_", " ").title())
|
label = "Scraping URL"
|
||||||
stage = t.get("stage") or ""
|
elif task_type == "wizard_generate":
|
||||||
detail = f" · {stage}" if stage else ""
|
label = "Wizard generation"
|
||||||
prefix = "" if i == 0 else "↳ "
|
elif task_type == "enrich_craigslist":
|
||||||
st.caption(f"{prefix}{icon} {label}{detail}")
|
label = "Enriching listing"
|
||||||
|
else:
|
||||||
# All other tasks (cover letter, email sync, etc.) as individual rows
|
label = task_type.replace("_", " ").title()
|
||||||
for t in other_tasks:
|
stage = t.get("stage") or ""
|
||||||
icon = "⏳" if t["status"] == "running" else "🕐"
|
|
||||||
label = _TASK_LABELS.get(t["task_type"], t["task_type"].replace("_", " ").title())
|
|
||||||
stage = t.get("stage") or ""
|
|
||||||
detail = f" · {stage}" if stage else (f" — {t.get('company')}" if t.get("company") else "")
|
detail = f" · {stage}" if stage else (f" — {t.get('company')}" if t.get("company") else "")
|
||||||
st.caption(f"{icon} {label}{detail}")
|
st.caption(f"{icon} {label}{detail}")
|
||||||
|
|
||||||
|
|
@ -200,13 +156,6 @@ def _get_version() -> str:
|
||||||
except Exception:
|
except Exception:
|
||||||
return "dev"
|
return "dev"
|
||||||
|
|
||||||
# ── Effective tier (resolved before sidebar so switcher can use it) ──────────
|
|
||||||
# get_cloud_tier() returns "local" in dev/self-hosted mode, real tier in cloud.
|
|
||||||
_ui_profile = _UserProfile(_USER_YAML) if _UserProfile.exists(_USER_YAML) else None
|
|
||||||
_ui_yaml_tier = _ui_profile.effective_tier if _ui_profile else "free"
|
|
||||||
_ui_cloud_tier = get_cloud_tier()
|
|
||||||
_ui_tier = _ui_cloud_tier if _ui_cloud_tier != "local" else _ui_yaml_tier
|
|
||||||
|
|
||||||
with st.sidebar:
|
with st.sidebar:
|
||||||
if IS_DEMO:
|
if IS_DEMO:
|
||||||
st.info(
|
st.info(
|
||||||
|
|
@ -236,31 +185,7 @@ with st.sidebar:
|
||||||
)
|
)
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
try:
|
|
||||||
from app.components.ui_switcher import render_sidebar_switcher
|
|
||||||
render_sidebar_switcher(_USER_YAML, _ui_tier)
|
|
||||||
except Exception:
|
|
||||||
pass # never crash the app over the sidebar switcher
|
|
||||||
st.caption(f"Peregrine {_get_version()}")
|
st.caption(f"Peregrine {_get_version()}")
|
||||||
inject_feedback_button(page=pg.title)
|
inject_feedback_button(page=pg.title)
|
||||||
|
|
||||||
# ── Demo toolbar (DEMO_MODE only) ───────────────────────────────────────────
|
|
||||||
if IS_DEMO:
|
|
||||||
from app.components.demo_toolbar import render_demo_toolbar
|
|
||||||
render_demo_toolbar()
|
|
||||||
|
|
||||||
# ── UI switcher banner (paid tier; or all visitors in demo mode) ─────────────
|
|
||||||
try:
|
|
||||||
from app.components.ui_switcher import render_banner
|
|
||||||
render_banner(_USER_YAML, _ui_tier)
|
|
||||||
except Exception:
|
|
||||||
pass # never crash the app over the banner
|
|
||||||
|
|
||||||
pg.run()
|
pg.run()
|
||||||
|
|
||||||
# ── UI preference cookie sync (runs after page render) ──────────────────────
|
|
||||||
try:
|
|
||||||
from app.components.ui_switcher import sync_ui_cookie
|
|
||||||
sync_ui_cookie(_USER_YAML, _ui_tier)
|
|
||||||
except Exception:
|
|
||||||
pass # never crash the app over cookie sync
|
|
||||||
|
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
"""Demo toolbar — tier simulation for DEMO_MODE instances.
|
|
||||||
|
|
||||||
Renders a slim full-width bar above the Streamlit nav showing
|
|
||||||
Free / Paid / Premium pills. Clicking a pill sets a prgn_demo_tier
|
|
||||||
cookie (for persistence across reloads) and st.session_state.simulated_tier
|
|
||||||
(for immediate use within the current render pass).
|
|
||||||
|
|
||||||
Only ever rendered when DEMO_MODE=true.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import streamlit as st
|
|
||||||
import streamlit.components.v1 as components
|
|
||||||
|
|
||||||
_VALID_TIERS = ("free", "paid", "premium")
|
|
||||||
_DEFAULT_TIER = "paid" # most compelling first impression
|
|
||||||
|
|
||||||
_DEMO_MODE = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
|
|
||||||
|
|
||||||
_COOKIE_JS = """
|
|
||||||
<script>
|
|
||||||
(function() {{
|
|
||||||
document.cookie = 'prgn_demo_tier={tier}; path=/; SameSite=Lax';
|
|
||||||
}})();
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def get_simulated_tier() -> str:
|
|
||||||
"""Return the current simulated tier, defaulting to 'paid'."""
|
|
||||||
return st.session_state.get("simulated_tier", _DEFAULT_TIER)
|
|
||||||
|
|
||||||
|
|
||||||
def set_simulated_tier(tier: str) -> None:
|
|
||||||
"""Set simulated tier in session state + cookie. Reruns the page."""
|
|
||||||
if tier not in _VALID_TIERS:
|
|
||||||
return
|
|
||||||
st.session_state["simulated_tier"] = tier
|
|
||||||
components.html(_COOKIE_JS.format(tier=tier), height=0)
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
|
|
||||||
def render_demo_toolbar() -> None:
|
|
||||||
"""Render the demo mode toolbar.
|
|
||||||
|
|
||||||
Shows a dismissible info bar with tier-selection pills.
|
|
||||||
Call this at the TOP of app.py's render pass, before pg.run().
|
|
||||||
"""
|
|
||||||
current = get_simulated_tier()
|
|
||||||
|
|
||||||
labels = {t: t.capitalize() + (" ✓" if t == current else "") for t in _VALID_TIERS}
|
|
||||||
|
|
||||||
with st.container():
|
|
||||||
cols = st.columns([3, 1, 1, 1, 2])
|
|
||||||
with cols[0]:
|
|
||||||
st.caption("🎭 **Demo mode** — exploring as:")
|
|
||||||
for i, tier in enumerate(_VALID_TIERS):
|
|
||||||
with cols[i + 1]:
|
|
||||||
is_active = tier == current
|
|
||||||
if st.button(
|
|
||||||
labels[tier],
|
|
||||||
key=f"_demo_tier_{tier}",
|
|
||||||
type="primary" if is_active else "secondary",
|
|
||||||
use_container_width=True,
|
|
||||||
):
|
|
||||||
if not is_active:
|
|
||||||
set_simulated_tier(tier)
|
|
||||||
with cols[4]:
|
|
||||||
st.caption("[Get your own →](https://circuitforge.tech/software/peregrine)")
|
|
||||||
st.divider()
|
|
||||||
|
|
@ -1,262 +0,0 @@
|
||||||
"""UI switcher component for Peregrine.
|
|
||||||
|
|
||||||
Manages the prgn_ui cookie (Caddy routing signal) and user.yaml
|
|
||||||
ui_preference (durability across browser clears).
|
|
||||||
|
|
||||||
Cookie mechanics
|
|
||||||
----------------
|
|
||||||
Streamlit cannot read HTTP cookies server-side. Instead:
|
|
||||||
- sync_ui_cookie() injects a JS snippet that sets document.cookie.
|
|
||||||
- Vue SPA switch-back appends ?prgn_switch=streamlit to the redirect URL.
|
|
||||||
sync_ui_cookie() reads this param via st.query_params and uses it as
|
|
||||||
an override signal, then writes user.yaml to match.
|
|
||||||
|
|
||||||
Call sync_ui_cookie() in the app.py render pass (after pg.run()).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import streamlit as st
|
|
||||||
import streamlit.components.v1 as components
|
|
||||||
|
|
||||||
from scripts.user_profile import UserProfile
|
|
||||||
from app.wizard.tiers import can_use
|
|
||||||
|
|
||||||
_DEMO_MODE = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
|
|
||||||
|
|
||||||
# When set, the app is running without a Caddy reverse proxy in front
|
|
||||||
# (local dev, direct port exposure). Switch to Vue by navigating directly
|
|
||||||
# to this URL instead of relying on cookie-based Caddy routing.
|
|
||||||
# Example: PEREGRINE_VUE_URL=http://localhost:8506
|
|
||||||
_VUE_URL = os.environ.get("PEREGRINE_VUE_URL", "").strip().rstrip("/")
|
|
||||||
|
|
||||||
# When True, a window.location.reload() after setting prgn_ui=vue will be
|
|
||||||
# intercepted by Caddy and routed to the Vue SPA. When False (no Caddy in the
|
|
||||||
# traffic path — e.g. test instances, direct Docker exposure), reloading just
|
|
||||||
# comes back to Streamlit and creates an infinite loop. Only set this in
|
|
||||||
# production/staging compose files where Caddy is actually in front.
|
|
||||||
_CADDY_PROXY = os.environ.get("PEREGRINE_CADDY_PROXY", "").lower() in ("1", "true", "yes")
|
|
||||||
|
|
||||||
_COOKIE_JS = """
|
|
||||||
<script>
|
|
||||||
(function() {{
|
|
||||||
document.cookie = 'prgn_ui={value}; path=/; SameSite=Lax';
|
|
||||||
{navigate_js}
|
|
||||||
}})();
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _set_cookie_js(value: str, navigate: bool = False) -> None:
|
|
||||||
"""Inject JS to set the prgn_ui cookie.
|
|
||||||
|
|
||||||
When PEREGRINE_VUE_URL is set (local dev, no Caddy): navigating to Vue
|
|
||||||
uses window.parent.location.href to jump directly to the Vue container
|
|
||||||
port. Without this, reload() just sends the request back to the same
|
|
||||||
Streamlit port with no router in between to inspect the cookie.
|
|
||||||
|
|
||||||
When PEREGRINE_CADDY_PROXY is set (production/staging): navigate=True
|
|
||||||
triggers window.location.reload() so Caddy sees the updated cookie on
|
|
||||||
the next HTTP request and routes accordingly.
|
|
||||||
|
|
||||||
When neither is set (test instances, bare Docker): navigate is suppressed
|
|
||||||
entirely — the cookie is written silently, but no reload is attempted.
|
|
||||||
Reloading without a proxy just bounces back to Streamlit and loops.
|
|
||||||
"""
|
|
||||||
# components.html() renders in an iframe — window.parent navigates the host page
|
|
||||||
if navigate and value == "vue" and _VUE_URL:
|
|
||||||
nav_js = f"window.parent.location.href = '{_VUE_URL}';"
|
|
||||||
elif navigate and _CADDY_PROXY:
|
|
||||||
nav_js = "window.parent.location.reload();"
|
|
||||||
else:
|
|
||||||
nav_js = ""
|
|
||||||
components.html(_COOKIE_JS.format(value=value, navigate_js=nav_js), height=0)
|
|
||||||
|
|
||||||
|
|
||||||
def sync_ui_cookie(yaml_path: Path, tier: str) -> None:
|
|
||||||
"""Sync the prgn_ui cookie to match user.yaml ui_preference.
|
|
||||||
|
|
||||||
Also handles:
|
|
||||||
- ?prgn_switch=<value> param (Vue SPA switch-back signal): overrides yaml,
|
|
||||||
writes yaml to match, clears the param.
|
|
||||||
- Tier downgrade: resets vue preference to streamlit for ineligible users.
|
|
||||||
- ?ui_fallback=1 param: Vue SPA was down — reinforce streamlit cookie and
|
|
||||||
return early to avoid immediately navigating back to a broken Vue SPA.
|
|
||||||
|
|
||||||
When the resolved preference is "vue", this function navigates (full page
|
|
||||||
reload) rather than silently setting the cookie. Without navigate=True,
|
|
||||||
Streamlit would set prgn_ui=vue mid-page-load; subsequent HTTP requests
|
|
||||||
made by Streamlit's own frontend (lazy JS chunks, WebSocket upgrade) would
|
|
||||||
carry the new cookie and Caddy would misroute them to the Vue nginx
|
|
||||||
container, causing TypeError: error loading dynamically imported module.
|
|
||||||
"""
|
|
||||||
# ── ?ui_fallback=1 — Vue SPA was down, Caddy bounced us back ──────────────
|
|
||||||
# Return early: reinforce the streamlit cookie so we don't immediately
|
|
||||||
# navigate back to a Vue SPA that may still be down.
|
|
||||||
if st.query_params.get("ui_fallback"):
|
|
||||||
st.toast("⚠️ New UI temporarily unavailable — switched back to Classic", icon="⚠️")
|
|
||||||
st.query_params.pop("ui_fallback", None)
|
|
||||||
_set_cookie_js("streamlit")
|
|
||||||
return
|
|
||||||
|
|
||||||
# ── ?prgn_switch param — Vue SPA sent us here to switch back ──────────────
|
|
||||||
switch_param = st.query_params.get("prgn_switch")
|
|
||||||
if switch_param in ("streamlit", "vue"):
|
|
||||||
try:
|
|
||||||
profile = UserProfile(yaml_path)
|
|
||||||
profile.ui_preference = switch_param
|
|
||||||
profile.save()
|
|
||||||
except Exception:
|
|
||||||
# UI components must not crash the app — silent fallback
|
|
||||||
pass
|
|
||||||
st.query_params.pop("prgn_switch", None)
|
|
||||||
_set_cookie_js(switch_param)
|
|
||||||
return
|
|
||||||
|
|
||||||
# ── Normal path: read yaml, enforce tier, inject cookie ───────────────────
|
|
||||||
profile = None
|
|
||||||
try:
|
|
||||||
profile = UserProfile(yaml_path)
|
|
||||||
pref = profile.ui_preference
|
|
||||||
except Exception:
|
|
||||||
# UI components must not crash the app — silent fallback to default
|
|
||||||
pref = "streamlit"
|
|
||||||
|
|
||||||
# Demo mode: Vue SPA has no demo data wiring — always serve Streamlit.
|
|
||||||
# (The tier downgrade check below is skipped in demo mode, but we must
|
|
||||||
# also block the Vue navigation itself so Caddy doesn't route to a blank SPA.)
|
|
||||||
if pref == "vue" and _DEMO_MODE:
|
|
||||||
pref = "streamlit"
|
|
||||||
|
|
||||||
# Tier downgrade protection (skip in demo — demo bypasses tier gate)
|
|
||||||
if pref == "vue" and not _DEMO_MODE and not can_use(tier, "vue_ui_beta"):
|
|
||||||
if profile is not None:
|
|
||||||
try:
|
|
||||||
profile.ui_preference = "streamlit"
|
|
||||||
profile.save()
|
|
||||||
except Exception:
|
|
||||||
# UI components must not crash the app — silent fallback
|
|
||||||
pass
|
|
||||||
pref = "streamlit"
|
|
||||||
|
|
||||||
# Navigate (full reload) when switching to Vue so Caddy re-routes on the
|
|
||||||
# next HTTP request before Streamlit serves any more content. Silent
|
|
||||||
# cookie-only set is safe for streamlit since we're already on that origin.
|
|
||||||
_set_cookie_js(pref, navigate=(pref == "vue"))
|
|
||||||
|
|
||||||
|
|
||||||
def switch_ui(yaml_path: Path, to: str, tier: str) -> None:
|
|
||||||
"""Write user.yaml, set cookie, and navigate.
|
|
||||||
|
|
||||||
to: "vue" | "streamlit"
|
|
||||||
|
|
||||||
Switching to Vue triggers window.location.reload() so Caddy sees the
|
|
||||||
updated prgn_ui cookie and routes to the Vue SPA. st.rerun() alone is
|
|
||||||
not sufficient — it operates over WebSocket and produces no HTTP request.
|
|
||||||
|
|
||||||
Switching back to streamlit uses st.rerun() (no full reload needed since
|
|
||||||
we're already on the Streamlit origin and no Caddy re-routing is required).
|
|
||||||
"""
|
|
||||||
if to not in ("vue", "streamlit"):
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
profile = UserProfile(yaml_path)
|
|
||||||
profile.ui_preference = to
|
|
||||||
profile.save()
|
|
||||||
except Exception:
|
|
||||||
# UI components must not crash the app — silent fallback
|
|
||||||
pass
|
|
||||||
if to == "vue":
|
|
||||||
# navigate=True triggers window.location.reload() after setting cookie
|
|
||||||
_set_cookie_js("vue", navigate=True)
|
|
||||||
else:
|
|
||||||
sync_ui_cookie(yaml_path, tier=tier)
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
|
|
||||||
def render_banner(yaml_path: Path, tier: str) -> None:
|
|
||||||
"""Show the 'Try the new UI' banner once per session.
|
|
||||||
|
|
||||||
Dismissed flag stored in user.yaml dismissed_banners list so it
|
|
||||||
persists across sessions (uses the existing dismissed_banners pattern).
|
|
||||||
Eligible: paid+ tier, OR demo mode. Not shown if already on vue.
|
|
||||||
"""
|
|
||||||
eligible = _DEMO_MODE or can_use(tier, "vue_ui_beta")
|
|
||||||
if not eligible:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
profile = UserProfile(yaml_path)
|
|
||||||
except Exception:
|
|
||||||
# UI components must not crash the app — silent fallback
|
|
||||||
return
|
|
||||||
|
|
||||||
if profile.ui_preference == "vue":
|
|
||||||
return
|
|
||||||
if "ui_switcher_beta" in (profile.dismissed_banners or []):
|
|
||||||
return
|
|
||||||
|
|
||||||
col1, col2, col3 = st.columns([8, 1, 1])
|
|
||||||
with col1:
|
|
||||||
st.info("✨ **New Peregrine UI available** — try the modern Vue interface (Beta)")
|
|
||||||
with col2:
|
|
||||||
if st.button("Try it", key="_ui_banner_try"):
|
|
||||||
switch_ui(yaml_path, to="vue", tier=tier)
|
|
||||||
with col3:
|
|
||||||
if st.button("Dismiss", key="_ui_banner_dismiss"):
|
|
||||||
profile.dismissed_banners = list(profile.dismissed_banners or []) + ["ui_switcher_beta"]
|
|
||||||
profile.save()
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
|
|
||||||
def render_sidebar_switcher(yaml_path: Path, tier: str) -> None:
|
|
||||||
"""Persistent sidebar button to switch to the Vue UI.
|
|
||||||
|
|
||||||
Shown when the user is eligible (paid+ or demo) and currently on Streamlit.
|
|
||||||
This is always visible — unlike the banner which can be dismissed.
|
|
||||||
"""
|
|
||||||
eligible = _DEMO_MODE or can_use(tier, "vue_ui_beta")
|
|
||||||
if not eligible:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
profile = UserProfile(yaml_path)
|
|
||||||
if profile.ui_preference == "vue":
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if st.button("✨ Switch to New UI", key="_sidebar_switch_vue", use_container_width=True):
|
|
||||||
switch_ui(yaml_path, to="vue", tier=tier)
|
|
||||||
|
|
||||||
|
|
||||||
def render_settings_toggle(yaml_path: Path, tier: str) -> None:
|
|
||||||
"""Toggle in Settings → System → Deployment expander."""
|
|
||||||
eligible = _DEMO_MODE or can_use(tier, "vue_ui_beta")
|
|
||||||
if not eligible:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
profile = UserProfile(yaml_path)
|
|
||||||
current = profile.ui_preference
|
|
||||||
except Exception:
|
|
||||||
# UI components must not crash the app — silent fallback to default
|
|
||||||
current = "streamlit"
|
|
||||||
|
|
||||||
options = ["streamlit", "vue"]
|
|
||||||
labels = ["Classic (Streamlit)", "✨ New UI (Vue, Beta)"]
|
|
||||||
current_idx = options.index(current) if current in options else 0
|
|
||||||
|
|
||||||
st.markdown("**UI Version**")
|
|
||||||
chosen = st.radio(
|
|
||||||
"UI Version",
|
|
||||||
options=labels,
|
|
||||||
index=current_idx,
|
|
||||||
key="_ui_toggle_radio",
|
|
||||||
label_visibility="collapsed",
|
|
||||||
)
|
|
||||||
chosen_val = options[labels.index(chosen)]
|
|
||||||
|
|
||||||
if chosen_val != current:
|
|
||||||
switch_ui(yaml_path, to=chosen_val, tier=tier)
|
|
||||||
|
|
@ -457,11 +457,6 @@ elif step == 5:
|
||||||
from app.wizard.step_inference import validate
|
from app.wizard.step_inference import validate
|
||||||
|
|
||||||
st.subheader("Step 5 \u2014 Inference & API Keys")
|
st.subheader("Step 5 \u2014 Inference & API Keys")
|
||||||
st.info(
|
|
||||||
"**Simplest setup:** set `OLLAMA_HOST` in your `.env` file — "
|
|
||||||
"Peregrine auto-detects it, no config file needed. "
|
|
||||||
"Or use the fields below to configure API keys and endpoints."
|
|
||||||
)
|
|
||||||
profile = saved_yaml.get("inference_profile", "remote")
|
profile = saved_yaml.get("inference_profile", "remote")
|
||||||
|
|
||||||
if profile == "remote":
|
if profile == "remote":
|
||||||
|
|
@ -471,18 +466,8 @@ elif step == 5:
|
||||||
placeholder="https://api.together.xyz/v1")
|
placeholder="https://api.together.xyz/v1")
|
||||||
openai_key = st.text_input("Endpoint API Key (optional)", type="password",
|
openai_key = st.text_input("Endpoint API Key (optional)", type="password",
|
||||||
key="oai_key") if openai_url else ""
|
key="oai_key") if openai_url else ""
|
||||||
ollama_host = st.text_input("Ollama host (optional \u2014 local fallback)",
|
|
||||||
placeholder="http://localhost:11434",
|
|
||||||
key="ollama_host_input")
|
|
||||||
ollama_model = st.text_input("Ollama model (optional)",
|
|
||||||
value="llama3.2:3b",
|
|
||||||
key="ollama_model_input")
|
|
||||||
else:
|
else:
|
||||||
st.info(f"Local mode ({profile}): Ollama provides inference.")
|
st.info(f"Local mode ({profile}): Ollama provides inference.")
|
||||||
import os
|
|
||||||
_ollama_host_env = os.environ.get("OLLAMA_HOST", "")
|
|
||||||
if _ollama_host_env:
|
|
||||||
st.caption(f"OLLAMA_HOST from .env: `{_ollama_host_env}`")
|
|
||||||
anthropic_key = openai_url = openai_key = ""
|
anthropic_key = openai_url = openai_key = ""
|
||||||
|
|
||||||
with st.expander("Advanced \u2014 Service Ports & Hosts"):
|
with st.expander("Advanced \u2014 Service Ports & Hosts"):
|
||||||
|
|
@ -561,14 +546,6 @@ elif step == 5:
|
||||||
if anthropic_key or openai_url:
|
if anthropic_key or openai_url:
|
||||||
env_path.write_text("\n".join(env_lines) + "\n")
|
env_path.write_text("\n".join(env_lines) + "\n")
|
||||||
|
|
||||||
if profile == "remote":
|
|
||||||
if ollama_host:
|
|
||||||
env_lines = _set_env(env_lines, "OLLAMA_HOST", ollama_host)
|
|
||||||
if ollama_model:
|
|
||||||
env_lines = _set_env(env_lines, "OLLAMA_MODEL", ollama_model)
|
|
||||||
if ollama_host or ollama_model:
|
|
||||||
env_path.write_text("\n".join(env_lines) + "\n")
|
|
||||||
|
|
||||||
_save_yaml({"services": svc, "wizard_step": 5})
|
_save_yaml({"services": svc, "wizard_step": 5})
|
||||||
st.session_state.wizard_step = 6
|
st.session_state.wizard_step = 6
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
@ -654,7 +631,7 @@ elif step == 6:
|
||||||
)
|
)
|
||||||
default_profile = {
|
default_profile = {
|
||||||
"name": "default",
|
"name": "default",
|
||||||
"titles": titles,
|
"job_titles": titles,
|
||||||
"locations": locations,
|
"locations": locations,
|
||||||
"remote_only": False,
|
"remote_only": False,
|
||||||
"boards": ["linkedin", "indeed", "glassdoor", "zip_recruiter"],
|
"boards": ["linkedin", "indeed", "glassdoor", "zip_recruiter"],
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,12 @@ from scripts.db import (
|
||||||
DEFAULT_DB, init_db, get_jobs_by_status, update_job_status,
|
DEFAULT_DB, init_db, get_jobs_by_status, update_job_status,
|
||||||
update_cover_letter, mark_applied, get_email_leads,
|
update_cover_letter, mark_applied, get_email_leads,
|
||||||
)
|
)
|
||||||
from app.cloud_session import resolve_session, get_db_path
|
|
||||||
|
|
||||||
resolve_session("peregrine")
|
|
||||||
|
|
||||||
st.title("📋 Job Review")
|
st.title("📋 Job Review")
|
||||||
|
|
||||||
init_db(get_db_path())
|
init_db(DEFAULT_DB)
|
||||||
|
|
||||||
_email_leads = get_email_leads(get_db_path())
|
_email_leads = get_email_leads(DEFAULT_DB)
|
||||||
|
|
||||||
# ── Sidebar filters ────────────────────────────────────────────────────────────
|
# ── Sidebar filters ────────────────────────────────────────────────────────────
|
||||||
with st.sidebar:
|
with st.sidebar:
|
||||||
|
|
@ -40,7 +37,7 @@ with st.sidebar:
|
||||||
index=0,
|
index=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
jobs = get_jobs_by_status(get_db_path(), show_status)
|
jobs = get_jobs_by_status(DEFAULT_DB, show_status)
|
||||||
|
|
||||||
if remote_only:
|
if remote_only:
|
||||||
jobs = [j for j in jobs if j.get("is_remote")]
|
jobs = [j for j in jobs if j.get("is_remote")]
|
||||||
|
|
@ -89,11 +86,11 @@ if show_status == "pending" and _email_leads:
|
||||||
with right_l:
|
with right_l:
|
||||||
if st.button("✅ Approve", key=f"el_approve_{lead_id}",
|
if st.button("✅ Approve", key=f"el_approve_{lead_id}",
|
||||||
type="primary", use_container_width=True):
|
type="primary", use_container_width=True):
|
||||||
update_job_status(get_db_path(), [lead_id], "approved")
|
update_job_status(DEFAULT_DB, [lead_id], "approved")
|
||||||
st.rerun()
|
st.rerun()
|
||||||
if st.button("❌ Reject", key=f"el_reject_{lead_id}",
|
if st.button("❌ Reject", key=f"el_reject_{lead_id}",
|
||||||
use_container_width=True):
|
use_container_width=True):
|
||||||
update_job_status(get_db_path(), [lead_id], "rejected")
|
update_job_status(DEFAULT_DB, [lead_id], "rejected")
|
||||||
st.rerun()
|
st.rerun()
|
||||||
st.divider()
|
st.divider()
|
||||||
|
|
||||||
|
|
@ -165,7 +162,7 @@ for job in jobs:
|
||||||
)
|
)
|
||||||
save_col, _ = st.columns([2, 5])
|
save_col, _ = st.columns([2, 5])
|
||||||
if save_col.button("💾 Save draft", key=f"save_cl_{job_id}"):
|
if save_col.button("💾 Save draft", key=f"save_cl_{job_id}"):
|
||||||
update_cover_letter(get_db_path(), job_id, st.session_state[_cl_key])
|
update_cover_letter(DEFAULT_DB, job_id, st.session_state[_cl_key])
|
||||||
st.success("Saved!")
|
st.success("Saved!")
|
||||||
|
|
||||||
# Applied date + cover letter preview (applied/synced)
|
# Applied date + cover letter preview (applied/synced)
|
||||||
|
|
@ -185,11 +182,11 @@ for job in jobs:
|
||||||
if show_status == "pending":
|
if show_status == "pending":
|
||||||
if st.button("✅ Approve", key=f"approve_{job_id}",
|
if st.button("✅ Approve", key=f"approve_{job_id}",
|
||||||
type="primary", use_container_width=True):
|
type="primary", use_container_width=True):
|
||||||
update_job_status(get_db_path(), [job_id], "approved")
|
update_job_status(DEFAULT_DB, [job_id], "approved")
|
||||||
st.rerun()
|
st.rerun()
|
||||||
if st.button("❌ Reject", key=f"reject_{job_id}",
|
if st.button("❌ Reject", key=f"reject_{job_id}",
|
||||||
use_container_width=True):
|
use_container_width=True):
|
||||||
update_job_status(get_db_path(), [job_id], "rejected")
|
update_job_status(DEFAULT_DB, [job_id], "rejected")
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
elif show_status == "approved":
|
elif show_status == "approved":
|
||||||
|
|
@ -201,6 +198,6 @@ for job in jobs:
|
||||||
use_container_width=True):
|
use_container_width=True):
|
||||||
cl_text = st.session_state.get(f"cl_{job_id}", "")
|
cl_text = st.session_state.get(f"cl_{job_id}", "")
|
||||||
if cl_text:
|
if cl_text:
|
||||||
update_cover_letter(get_db_path(), job_id, cl_text)
|
update_cover_letter(DEFAULT_DB, job_id, cl_text)
|
||||||
mark_applied(get_db_path(), [job_id])
|
mark_applied(DEFAULT_DB, [job_id])
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
|
||||||
|
|
@ -323,26 +323,6 @@ with tab_search:
|
||||||
_run_suggest = st.button("✨ Suggest", key="sp_suggest_btn",
|
_run_suggest = st.button("✨ Suggest", key="sp_suggest_btn",
|
||||||
help="Ask the LLM to suggest additional titles and smarter exclude keywords — using your blocklist, mission values, and career background.")
|
help="Ask the LLM to suggest additional titles and smarter exclude keywords — using your blocklist, mission values, and career background.")
|
||||||
|
|
||||||
_title_sugg_count = len((st.session_state.get("_sp_suggestions") or {}).get("suggested_titles", []))
|
|
||||||
if _title_sugg_count:
|
|
||||||
st.markdown(f"""<style>
|
|
||||||
@keyframes _pg_arrow_float {{
|
|
||||||
0%, 100% {{
|
|
||||||
transform: translateY(0px);
|
|
||||||
filter: drop-shadow(0 0 2px #4fc3f7);
|
|
||||||
}}
|
|
||||||
50% {{
|
|
||||||
transform: translateY(4px);
|
|
||||||
filter: drop-shadow(0 0 8px #4fc3f7);
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
/* Target the expand-arrow SVG inside the multiselect dropdown indicator */
|
|
||||||
.stMultiSelect [data-baseweb="select"] > div + div svg {{
|
|
||||||
animation: _pg_arrow_float 1.3s ease-in-out infinite;
|
|
||||||
cursor: pointer;
|
|
||||||
}}
|
|
||||||
</style>""", unsafe_allow_html=True)
|
|
||||||
|
|
||||||
st.multiselect(
|
st.multiselect(
|
||||||
"Job titles",
|
"Job titles",
|
||||||
options=st.session_state.get("_sp_title_options", p.get("titles", [])),
|
options=st.session_state.get("_sp_title_options", p.get("titles", [])),
|
||||||
|
|
@ -350,14 +330,6 @@ with tab_search:
|
||||||
help="Select from known titles. Suggestions from ✨ Suggest appear here — pick the ones you want.",
|
help="Select from known titles. Suggestions from ✨ Suggest appear here — pick the ones you want.",
|
||||||
label_visibility="collapsed",
|
label_visibility="collapsed",
|
||||||
)
|
)
|
||||||
|
|
||||||
if _title_sugg_count:
|
|
||||||
st.markdown(
|
|
||||||
f'<div style="font-size:0.8em; color:#4fc3f7; margin-top:-10px; margin-bottom:4px;">'
|
|
||||||
f' ↑ {_title_sugg_count} new suggestion{"s" if _title_sugg_count != 1 else ""} '
|
|
||||||
f'added — open the dropdown to browse</div>',
|
|
||||||
unsafe_allow_html=True,
|
|
||||||
)
|
|
||||||
_add_t_col, _add_t_btn = st.columns([5, 1])
|
_add_t_col, _add_t_btn = st.columns([5, 1])
|
||||||
with _add_t_col:
|
with _add_t_col:
|
||||||
st.text_input("Add a title", key="_sp_new_title", label_visibility="collapsed",
|
st.text_input("Add a title", key="_sp_new_title", label_visibility="collapsed",
|
||||||
|
|
@ -401,32 +373,22 @@ with tab_search:
|
||||||
with st.spinner("Asking LLM for suggestions…"):
|
with st.spinner("Asking LLM for suggestions…"):
|
||||||
try:
|
try:
|
||||||
suggestions = _suggest_search_terms(_current_titles, RESUME_PATH, _blocklist, _user_profile)
|
suggestions = _suggest_search_terms(_current_titles, RESUME_PATH, _blocklist, _user_profile)
|
||||||
except Exception as _e:
|
except RuntimeError as _e:
|
||||||
_err_msg = str(_e)
|
st.warning(
|
||||||
if "exhausted" in _err_msg.lower() or isinstance(_e, RuntimeError):
|
f"No LLM backend available: {_e}. "
|
||||||
st.warning(
|
"Check that Ollama is running and has GPU access, or enable a cloud backend in Settings → System → LLM.",
|
||||||
f"No LLM backend available: {_err_msg}. "
|
icon="⚠️",
|
||||||
"Check that Ollama is running and has GPU access, or enable a cloud backend in Settings → System → LLM.",
|
)
|
||||||
icon="⚠️",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
st.error(f"Suggestion failed: {_err_msg}", icon="🚨")
|
|
||||||
suggestions = None
|
suggestions = None
|
||||||
if suggestions is not None:
|
if suggestions is not None:
|
||||||
# Add suggested titles to options list (not auto-selected — user picks from dropdown)
|
# Add suggested titles to options list (not auto-selected — user picks from dropdown)
|
||||||
_opts = list(st.session_state.get("_sp_title_options", []))
|
_opts = list(st.session_state.get("_sp_title_options", []))
|
||||||
_new_titles = [_t for _t in suggestions.get("suggested_titles", []) if _t not in _opts]
|
for _t in suggestions.get("suggested_titles", []):
|
||||||
_opts.extend(_new_titles)
|
if _t not in _opts:
|
||||||
|
_opts.append(_t)
|
||||||
st.session_state["_sp_title_options"] = _opts
|
st.session_state["_sp_title_options"] = _opts
|
||||||
st.session_state["_sp_suggestions"] = suggestions
|
st.session_state["_sp_suggestions"] = suggestions
|
||||||
if not _new_titles and not suggestions.get("suggested_excludes"):
|
st.rerun()
|
||||||
_resume_hint = " Upload your resume in Settings → Resume Profile for better results." if not RESUME_PATH.exists() else ""
|
|
||||||
st.info(
|
|
||||||
f"No new suggestions found — the LLM didn't generate anything new for these titles.{_resume_hint}",
|
|
||||||
icon="ℹ️",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
if st.session_state.get("_sp_suggestions"):
|
if st.session_state.get("_sp_suggestions"):
|
||||||
sugg = st.session_state["_sp_suggestions"]
|
sugg = st.session_state["_sp_suggestions"]
|
||||||
|
|
@ -851,13 +813,6 @@ with tab_resume:
|
||||||
kw_current: list[str] = kw_data.get(kw_category, [])
|
kw_current: list[str] = kw_data.get(kw_category, [])
|
||||||
kw_suggestions = _load_sugg(kw_category)
|
kw_suggestions = _load_sugg(kw_category)
|
||||||
|
|
||||||
# If a custom tag was added last render, clear the multiselect's session
|
|
||||||
# state key NOW (before the widget is created) so Streamlit uses `default`
|
|
||||||
# instead of the stale session state that lacks the new tag.
|
|
||||||
_reset_key = f"_kw_reset_{kw_category}"
|
|
||||||
if st.session_state.pop(_reset_key, False):
|
|
||||||
st.session_state.pop(f"kw_ms_{kw_category}", None)
|
|
||||||
|
|
||||||
# Merge: suggestions first, then any custom tags not in suggestions
|
# Merge: suggestions first, then any custom tags not in suggestions
|
||||||
kw_custom = [t for t in kw_current if t not in kw_suggestions]
|
kw_custom = [t for t in kw_current if t not in kw_suggestions]
|
||||||
kw_options = kw_suggestions + kw_custom
|
kw_options = kw_suggestions + kw_custom
|
||||||
|
|
@ -878,7 +833,6 @@ with tab_resume:
|
||||||
label_visibility="collapsed",
|
label_visibility="collapsed",
|
||||||
placeholder=f"Custom: {kw_placeholder}",
|
placeholder=f"Custom: {kw_placeholder}",
|
||||||
)
|
)
|
||||||
_tag_just_added = False
|
|
||||||
if kw_btn_col.button("+", key=f"kw_add_{kw_category}", help="Add custom tag"):
|
if kw_btn_col.button("+", key=f"kw_add_{kw_category}", help="Add custom tag"):
|
||||||
cleaned = _filter_tag(kw_raw)
|
cleaned = _filter_tag(kw_raw)
|
||||||
if cleaned is None:
|
if cleaned is None:
|
||||||
|
|
@ -886,19 +840,13 @@ with tab_resume:
|
||||||
elif cleaned in kw_options:
|
elif cleaned in kw_options:
|
||||||
st.info(f"'{cleaned}' is already in the list — select it above.")
|
st.info(f"'{cleaned}' is already in the list — select it above.")
|
||||||
else:
|
else:
|
||||||
# Save to YAML and set a reset flag so the multiselect session
|
# Persist custom tag: add to YAML and session state so it appears in options
|
||||||
# state is cleared before the widget renders on the next rerun,
|
|
||||||
# allowing `default` (which includes the new tag) to take effect.
|
|
||||||
kw_new_list = kw_selected + [cleaned]
|
kw_new_list = kw_selected + [cleaned]
|
||||||
st.session_state[_reset_key] = True
|
|
||||||
kw_data[kw_category] = kw_new_list
|
kw_data[kw_category] = kw_new_list
|
||||||
kw_changed = True
|
kw_changed = True
|
||||||
_tag_just_added = True
|
|
||||||
|
|
||||||
# Detect multiselect changes. Skip when a tag was just added — the change
|
# Detect multiselect changes
|
||||||
# detection would otherwise overwrite kw_data with the old kw_selected
|
if sorted(kw_selected) != sorted(kw_current):
|
||||||
# (which doesn't include the new tag) in the same render.
|
|
||||||
if not _tag_just_added and sorted(kw_selected) != sorted(kw_current):
|
|
||||||
kw_data[kw_category] = kw_selected
|
kw_data[kw_category] = kw_selected
|
||||||
kw_changed = True
|
kw_changed = True
|
||||||
|
|
||||||
|
|
@ -1051,11 +999,6 @@ with tab_system:
|
||||||
_env_path.write_text("\n".join(_env_lines) + "\n")
|
_env_path.write_text("\n".join(_env_lines) + "\n")
|
||||||
st.success("Deployment settings saved. Run `./manage.sh restart` to apply.")
|
st.success("Deployment settings saved. Run `./manage.sh restart` to apply.")
|
||||||
|
|
||||||
st.divider()
|
|
||||||
from app.components.ui_switcher import render_settings_toggle as _render_ui_toggle
|
|
||||||
_ui_tier = _profile.tier if _profile else "free"
|
|
||||||
_render_ui_toggle(yaml_path=_USER_YAML, tier=_ui_tier)
|
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
|
|
||||||
# ── LLM Backends ─────────────────────────────────────────────────────────
|
# ── LLM Backends ─────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -15,28 +15,28 @@ import streamlit.components.v1 as components
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from scripts.user_profile import UserProfile
|
from scripts.user_profile import UserProfile
|
||||||
|
|
||||||
|
_USER_YAML = Path(__file__).parent.parent.parent / "config" / "user.yaml"
|
||||||
|
_profile = UserProfile(_USER_YAML) if UserProfile.exists(_USER_YAML) else None
|
||||||
|
_name = _profile.name if _profile else "Job Seeker"
|
||||||
|
|
||||||
from scripts.db import (
|
from scripts.db import (
|
||||||
DEFAULT_DB, init_db, get_jobs_by_status,
|
DEFAULT_DB, init_db, get_jobs_by_status,
|
||||||
update_cover_letter, mark_applied, update_job_status,
|
update_cover_letter, mark_applied, update_job_status,
|
||||||
get_task_for_job,
|
get_task_for_job,
|
||||||
)
|
)
|
||||||
from scripts.task_runner import submit_task
|
from scripts.task_runner import submit_task
|
||||||
from app.cloud_session import resolve_session, get_db_path, get_config_dir
|
from app.cloud_session import resolve_session, get_db_path
|
||||||
from app.telemetry import log_usage_event
|
from app.telemetry import log_usage_event
|
||||||
|
|
||||||
|
DOCS_DIR = _profile.docs_dir if _profile else Path.home() / "Documents" / "JobSearch"
|
||||||
|
RESUME_YAML = Path(__file__).parent.parent.parent / "config" / "plain_text_resume.yaml"
|
||||||
|
|
||||||
st.title("🚀 Apply Workspace")
|
st.title("🚀 Apply Workspace")
|
||||||
|
|
||||||
resolve_session("peregrine")
|
resolve_session("peregrine")
|
||||||
init_db(get_db_path())
|
init_db(get_db_path())
|
||||||
|
|
||||||
_CONFIG_DIR = get_config_dir()
|
|
||||||
_USER_YAML = _CONFIG_DIR / "user.yaml"
|
|
||||||
_profile = UserProfile(_USER_YAML) if UserProfile.exists(_USER_YAML) else None
|
|
||||||
_name = _profile.name if _profile else "Job Seeker"
|
|
||||||
|
|
||||||
DOCS_DIR = _profile.docs_dir if _profile else Path.home() / "Documents" / "JobSearch"
|
|
||||||
RESUME_YAML = _CONFIG_DIR / "plain_text_resume.yaml"
|
|
||||||
|
|
||||||
# ── PDF generation ─────────────────────────────────────────────────────────────
|
# ── PDF generation ─────────────────────────────────────────────────────────────
|
||||||
def _make_cover_letter_pdf(job: dict, cover_letter: str, output_dir: Path) -> Path:
|
def _make_cover_letter_pdf(job: dict, cover_letter: str, output_dir: Path) -> Path:
|
||||||
from reportlab.lib.pagesizes import letter
|
from reportlab.lib.pagesizes import letter
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,6 @@ from scripts.db import (
|
||||||
get_unread_stage_signals, dismiss_stage_signal,
|
get_unread_stage_signals, dismiss_stage_signal,
|
||||||
)
|
)
|
||||||
from scripts.task_runner import submit_task
|
from scripts.task_runner import submit_task
|
||||||
from app.cloud_session import resolve_session, get_db_path
|
|
||||||
|
|
||||||
resolve_session("peregrine")
|
|
||||||
|
|
||||||
_CONFIG_DIR = Path(__file__).parent.parent.parent / "config"
|
_CONFIG_DIR = Path(__file__).parent.parent.parent / "config"
|
||||||
_CALENDAR_INTEGRATIONS = ("apple_calendar", "google_calendar")
|
_CALENDAR_INTEGRATIONS = ("apple_calendar", "google_calendar")
|
||||||
|
|
@ -49,23 +46,23 @@ _calendar_connected = any(
|
||||||
|
|
||||||
st.title("🎯 Interviews")
|
st.title("🎯 Interviews")
|
||||||
|
|
||||||
init_db(get_db_path())
|
init_db(DEFAULT_DB)
|
||||||
|
|
||||||
# ── Sidebar: Email sync ────────────────────────────────────────────────────────
|
# ── Sidebar: Email sync ────────────────────────────────────────────────────────
|
||||||
with st.sidebar:
|
with st.sidebar:
|
||||||
st.markdown("### 📧 Email Sync")
|
st.markdown("### 📧 Email Sync")
|
||||||
_email_task = get_task_for_job(get_db_path(), "email_sync", 0)
|
_email_task = get_task_for_job(DEFAULT_DB, "email_sync", 0)
|
||||||
_email_running = _email_task and _email_task["status"] in ("queued", "running")
|
_email_running = _email_task and _email_task["status"] in ("queued", "running")
|
||||||
|
|
||||||
if st.button("🔄 Sync Emails", use_container_width=True, type="primary",
|
if st.button("🔄 Sync Emails", use_container_width=True, type="primary",
|
||||||
disabled=bool(_email_running)):
|
disabled=bool(_email_running)):
|
||||||
submit_task(get_db_path(), "email_sync", 0)
|
submit_task(DEFAULT_DB, "email_sync", 0)
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
if _email_running:
|
if _email_running:
|
||||||
@st.fragment(run_every=4)
|
@st.fragment(run_every=4)
|
||||||
def _email_sidebar_status():
|
def _email_sidebar_status():
|
||||||
t = get_task_for_job(get_db_path(), "email_sync", 0)
|
t = get_task_for_job(DEFAULT_DB, "email_sync", 0)
|
||||||
if t and t["status"] in ("queued", "running"):
|
if t and t["status"] in ("queued", "running"):
|
||||||
st.info("⏳ Syncing…")
|
st.info("⏳ Syncing…")
|
||||||
else:
|
else:
|
||||||
|
|
@ -102,7 +99,7 @@ STAGE_NEXT_LABEL = {
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Data ──────────────────────────────────────────────────────────────────────
|
# ── Data ──────────────────────────────────────────────────────────────────────
|
||||||
jobs_by_stage = get_interview_jobs(get_db_path())
|
jobs_by_stage = get_interview_jobs(DEFAULT_DB)
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
def _days_ago(date_str: str | None) -> str:
|
def _days_ago(date_str: str | None) -> str:
|
||||||
|
|
@ -123,8 +120,8 @@ def _days_ago(date_str: str | None) -> str:
|
||||||
def _research_modal(job: dict) -> None:
|
def _research_modal(job: dict) -> None:
|
||||||
job_id = job["id"]
|
job_id = job["id"]
|
||||||
st.caption(f"**{job.get('company')}** — {job.get('title')}")
|
st.caption(f"**{job.get('company')}** — {job.get('title')}")
|
||||||
research = get_research(get_db_path(), job_id=job_id)
|
research = get_research(DEFAULT_DB, job_id=job_id)
|
||||||
task = get_task_for_job(get_db_path(), "company_research", job_id)
|
task = get_task_for_job(DEFAULT_DB, "company_research", job_id)
|
||||||
running = task and task["status"] in ("queued", "running")
|
running = task and task["status"] in ("queued", "running")
|
||||||
|
|
||||||
if running:
|
if running:
|
||||||
|
|
@ -147,7 +144,7 @@ def _research_modal(job: dict) -> None:
|
||||||
"inaccuracies. SearXNG is now available — re-run to get verified facts."
|
"inaccuracies. SearXNG is now available — re-run to get verified facts."
|
||||||
)
|
)
|
||||||
if st.button("🔄 Re-run with live data", key=f"modal_rescrape_{job_id}", type="primary"):
|
if st.button("🔄 Re-run with live data", key=f"modal_rescrape_{job_id}", type="primary"):
|
||||||
submit_task(get_db_path(), "company_research", job_id)
|
submit_task(DEFAULT_DB, "company_research", job_id)
|
||||||
st.rerun()
|
st.rerun()
|
||||||
st.divider()
|
st.divider()
|
||||||
else:
|
else:
|
||||||
|
|
@ -163,14 +160,14 @@ def _research_modal(job: dict) -> None:
|
||||||
)
|
)
|
||||||
st.markdown(research["raw_output"])
|
st.markdown(research["raw_output"])
|
||||||
if st.button("🔄 Refresh", key=f"modal_regen_{job_id}", disabled=bool(running)):
|
if st.button("🔄 Refresh", key=f"modal_regen_{job_id}", disabled=bool(running)):
|
||||||
submit_task(get_db_path(), "company_research", job_id)
|
submit_task(DEFAULT_DB, "company_research", job_id)
|
||||||
st.rerun()
|
st.rerun()
|
||||||
else:
|
else:
|
||||||
st.info("No research brief yet.")
|
st.info("No research brief yet.")
|
||||||
if task and task["status"] == "failed":
|
if task and task["status"] == "failed":
|
||||||
st.error(f"Last attempt failed: {task.get('error', '')}")
|
st.error(f"Last attempt failed: {task.get('error', '')}")
|
||||||
if st.button("🔬 Generate now", key=f"modal_gen_{job_id}"):
|
if st.button("🔬 Generate now", key=f"modal_gen_{job_id}"):
|
||||||
submit_task(get_db_path(), "company_research", job_id)
|
submit_task(DEFAULT_DB, "company_research", job_id)
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -178,7 +175,7 @@ def _research_modal(job: dict) -> None:
|
||||||
def _email_modal(job: dict) -> None:
|
def _email_modal(job: dict) -> None:
|
||||||
job_id = job["id"]
|
job_id = job["id"]
|
||||||
st.caption(f"**{job.get('company')}** — {job.get('title')}")
|
st.caption(f"**{job.get('company')}** — {job.get('title')}")
|
||||||
contacts = get_contacts(get_db_path(), job_id=job_id)
|
contacts = get_contacts(DEFAULT_DB, job_id=job_id)
|
||||||
|
|
||||||
if not contacts:
|
if not contacts:
|
||||||
st.info("No emails logged yet. Use the form below to add one.")
|
st.info("No emails logged yet. Use the form below to add one.")
|
||||||
|
|
@ -249,7 +246,7 @@ def _email_modal(job: dict) -> None:
|
||||||
body_text = st.text_area("Body / notes", height=80, key=f"body_modal_{job_id}")
|
body_text = st.text_area("Body / notes", height=80, key=f"body_modal_{job_id}")
|
||||||
if st.form_submit_button("📧 Save contact"):
|
if st.form_submit_button("📧 Save contact"):
|
||||||
add_contact(
|
add_contact(
|
||||||
get_db_path(), job_id=job_id,
|
DEFAULT_DB, job_id=job_id,
|
||||||
direction=direction, subject=subject,
|
direction=direction, subject=subject,
|
||||||
from_addr=from_addr, body=body_text, received_at=recv_at,
|
from_addr=from_addr, body=body_text, received_at=recv_at,
|
||||||
)
|
)
|
||||||
|
|
@ -258,7 +255,7 @@ def _email_modal(job: dict) -> None:
|
||||||
def _render_card(job: dict, stage: str, compact: bool = False) -> None:
|
def _render_card(job: dict, stage: str, compact: bool = False) -> None:
|
||||||
"""Render a single job card appropriate for the given stage."""
|
"""Render a single job card appropriate for the given stage."""
|
||||||
job_id = job["id"]
|
job_id = job["id"]
|
||||||
contacts = get_contacts(get_db_path(), job_id=job_id)
|
contacts = get_contacts(DEFAULT_DB, job_id=job_id)
|
||||||
last_contact = contacts[-1] if contacts else None
|
last_contact = contacts[-1] if contacts else None
|
||||||
|
|
||||||
with st.container(border=True):
|
with st.container(border=True):
|
||||||
|
|
@ -281,7 +278,7 @@ def _render_card(job: dict, stage: str, compact: bool = False) -> None:
|
||||||
format="YYYY-MM-DD",
|
format="YYYY-MM-DD",
|
||||||
)
|
)
|
||||||
if st.form_submit_button("📅 Save date"):
|
if st.form_submit_button("📅 Save date"):
|
||||||
set_interview_date(get_db_path(), job_id=job_id, date_str=str(new_date))
|
set_interview_date(DEFAULT_DB, job_id=job_id, date_str=str(new_date))
|
||||||
st.success("Saved!")
|
st.success("Saved!")
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
|
|
@ -291,7 +288,7 @@ def _render_card(job: dict, stage: str, compact: bool = False) -> None:
|
||||||
_cal_label = "🔄 Update Calendar" if _has_event else "📅 Add to Calendar"
|
_cal_label = "🔄 Update Calendar" if _has_event else "📅 Add to Calendar"
|
||||||
if st.button(_cal_label, key=f"cal_push_{job_id}", use_container_width=True):
|
if st.button(_cal_label, key=f"cal_push_{job_id}", use_container_width=True):
|
||||||
from scripts.calendar_push import push_interview_event
|
from scripts.calendar_push import push_interview_event
|
||||||
result = push_interview_event(get_db_path(), job_id=job_id, config_dir=_CONFIG_DIR)
|
result = push_interview_event(DEFAULT_DB, job_id=job_id, config_dir=_CONFIG_DIR)
|
||||||
if result["ok"]:
|
if result["ok"]:
|
||||||
st.success(f"Event {'updated' if _has_event else 'added'} ({result['provider'].replace('_', ' ').title()})")
|
st.success(f"Event {'updated' if _has_event else 'added'} ({result['provider'].replace('_', ' ').title()})")
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
@ -300,7 +297,7 @@ def _render_card(job: dict, stage: str, compact: bool = False) -> None:
|
||||||
|
|
||||||
if not compact:
|
if not compact:
|
||||||
if stage in ("applied", "phone_screen", "interviewing"):
|
if stage in ("applied", "phone_screen", "interviewing"):
|
||||||
signals = get_unread_stage_signals(get_db_path(), job_id=job_id)
|
signals = get_unread_stage_signals(DEFAULT_DB, job_id=job_id)
|
||||||
if signals:
|
if signals:
|
||||||
sig = signals[-1]
|
sig = signals[-1]
|
||||||
_SIGNAL_TO_STAGE = {
|
_SIGNAL_TO_STAGE = {
|
||||||
|
|
@ -321,23 +318,23 @@ def _render_card(job: dict, stage: str, compact: bool = False) -> None:
|
||||||
if sig["stage_signal"] == "rejected":
|
if sig["stage_signal"] == "rejected":
|
||||||
if b1.button("✗ Reject", key=f"sig_rej_{sig['id']}",
|
if b1.button("✗ Reject", key=f"sig_rej_{sig['id']}",
|
||||||
use_container_width=True):
|
use_container_width=True):
|
||||||
reject_at_stage(get_db_path(), job_id=job_id, rejection_stage=stage)
|
reject_at_stage(DEFAULT_DB, job_id=job_id, rejection_stage=stage)
|
||||||
dismiss_stage_signal(get_db_path(), sig["id"])
|
dismiss_stage_signal(DEFAULT_DB, sig["id"])
|
||||||
st.rerun(scope="app")
|
st.rerun(scope="app")
|
||||||
elif target_stage and b1.button(
|
elif target_stage and b1.button(
|
||||||
f"→ {target_label}", key=f"sig_adv_{sig['id']}",
|
f"→ {target_label}", key=f"sig_adv_{sig['id']}",
|
||||||
use_container_width=True, type="primary",
|
use_container_width=True, type="primary",
|
||||||
):
|
):
|
||||||
if target_stage == "phone_screen" and stage == "applied":
|
if target_stage == "phone_screen" and stage == "applied":
|
||||||
advance_to_stage(get_db_path(), job_id=job_id, stage="phone_screen")
|
advance_to_stage(DEFAULT_DB, job_id=job_id, stage="phone_screen")
|
||||||
submit_task(get_db_path(), "company_research", job_id)
|
submit_task(DEFAULT_DB, "company_research", job_id)
|
||||||
elif target_stage:
|
elif target_stage:
|
||||||
advance_to_stage(get_db_path(), job_id=job_id, stage=target_stage)
|
advance_to_stage(DEFAULT_DB, job_id=job_id, stage=target_stage)
|
||||||
dismiss_stage_signal(get_db_path(), sig["id"])
|
dismiss_stage_signal(DEFAULT_DB, sig["id"])
|
||||||
st.rerun(scope="app")
|
st.rerun(scope="app")
|
||||||
if b2.button("Dismiss", key=f"sig_dis_{sig['id']}",
|
if b2.button("Dismiss", key=f"sig_dis_{sig['id']}",
|
||||||
use_container_width=True):
|
use_container_width=True):
|
||||||
dismiss_stage_signal(get_db_path(), sig["id"])
|
dismiss_stage_signal(DEFAULT_DB, sig["id"])
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
# Advance / Reject buttons
|
# Advance / Reject buttons
|
||||||
|
|
@ -349,16 +346,16 @@ def _render_card(job: dict, stage: str, compact: bool = False) -> None:
|
||||||
f"→ {next_label}", key=f"adv_{job_id}",
|
f"→ {next_label}", key=f"adv_{job_id}",
|
||||||
use_container_width=True, type="primary",
|
use_container_width=True, type="primary",
|
||||||
):
|
):
|
||||||
advance_to_stage(get_db_path(), job_id=job_id, stage=next_stage)
|
advance_to_stage(DEFAULT_DB, job_id=job_id, stage=next_stage)
|
||||||
if next_stage == "phone_screen":
|
if next_stage == "phone_screen":
|
||||||
submit_task(get_db_path(), "company_research", job_id)
|
submit_task(DEFAULT_DB, "company_research", job_id)
|
||||||
st.rerun(scope="app") # full rerun — card must appear in new column
|
st.rerun(scope="app") # full rerun — card must appear in new column
|
||||||
|
|
||||||
if c2.button(
|
if c2.button(
|
||||||
"✗ Reject", key=f"rej_{job_id}",
|
"✗ Reject", key=f"rej_{job_id}",
|
||||||
use_container_width=True,
|
use_container_width=True,
|
||||||
):
|
):
|
||||||
reject_at_stage(get_db_path(), job_id=job_id, rejection_stage=stage)
|
reject_at_stage(DEFAULT_DB, job_id=job_id, rejection_stage=stage)
|
||||||
st.rerun() # fragment-scope rerun — card disappears without scroll-to-top
|
st.rerun() # fragment-scope rerun — card disappears without scroll-to-top
|
||||||
|
|
||||||
if job.get("url"):
|
if job.get("url"):
|
||||||
|
|
@ -388,7 +385,7 @@ def _render_card(job: dict, stage: str, compact: bool = False) -> None:
|
||||||
@st.fragment
|
@st.fragment
|
||||||
def _card_fragment(job_id: int, stage: str) -> None:
|
def _card_fragment(job_id: int, stage: str) -> None:
|
||||||
"""Re-fetches the job on each fragment rerun; renders nothing if moved/rejected."""
|
"""Re-fetches the job on each fragment rerun; renders nothing if moved/rejected."""
|
||||||
job = get_job_by_id(get_db_path(), job_id)
|
job = get_job_by_id(DEFAULT_DB, job_id)
|
||||||
if job is None or job.get("status") != stage:
|
if job is None or job.get("status") != stage:
|
||||||
return
|
return
|
||||||
_render_card(job, stage)
|
_render_card(job, stage)
|
||||||
|
|
@ -397,11 +394,11 @@ def _card_fragment(job_id: int, stage: str) -> None:
|
||||||
@st.fragment
|
@st.fragment
|
||||||
def _pre_kanban_row_fragment(job_id: int) -> None:
|
def _pre_kanban_row_fragment(job_id: int) -> None:
|
||||||
"""Pre-kanban compact row for applied and survey-stage jobs."""
|
"""Pre-kanban compact row for applied and survey-stage jobs."""
|
||||||
job = get_job_by_id(get_db_path(), job_id)
|
job = get_job_by_id(DEFAULT_DB, job_id)
|
||||||
if job is None or job.get("status") not in ("applied", "survey"):
|
if job is None or job.get("status") not in ("applied", "survey"):
|
||||||
return
|
return
|
||||||
stage = job["status"]
|
stage = job["status"]
|
||||||
contacts = get_contacts(get_db_path(), job_id=job_id)
|
contacts = get_contacts(DEFAULT_DB, job_id=job_id)
|
||||||
last_contact = contacts[-1] if contacts else None
|
last_contact = contacts[-1] if contacts else None
|
||||||
|
|
||||||
with st.container(border=True):
|
with st.container(border=True):
|
||||||
|
|
@ -417,7 +414,7 @@ def _pre_kanban_row_fragment(job_id: int) -> None:
|
||||||
_email_modal(job)
|
_email_modal(job)
|
||||||
|
|
||||||
# Stage signal hint (email-detected next steps)
|
# Stage signal hint (email-detected next steps)
|
||||||
signals = get_unread_stage_signals(get_db_path(), job_id=job_id)
|
signals = get_unread_stage_signals(DEFAULT_DB, job_id=job_id)
|
||||||
if signals:
|
if signals:
|
||||||
sig = signals[-1]
|
sig = signals[-1]
|
||||||
_SIGNAL_TO_STAGE = {
|
_SIGNAL_TO_STAGE = {
|
||||||
|
|
@ -440,15 +437,15 @@ def _pre_kanban_row_fragment(job_id: int) -> None:
|
||||||
use_container_width=True, type="primary",
|
use_container_width=True, type="primary",
|
||||||
):
|
):
|
||||||
if target_stage == "phone_screen":
|
if target_stage == "phone_screen":
|
||||||
advance_to_stage(get_db_path(), job_id=job_id, stage="phone_screen")
|
advance_to_stage(DEFAULT_DB, job_id=job_id, stage="phone_screen")
|
||||||
submit_task(get_db_path(), "company_research", job_id)
|
submit_task(DEFAULT_DB, "company_research", job_id)
|
||||||
else:
|
else:
|
||||||
advance_to_stage(get_db_path(), job_id=job_id, stage=target_stage)
|
advance_to_stage(DEFAULT_DB, job_id=job_id, stage=target_stage)
|
||||||
dismiss_stage_signal(get_db_path(), sig["id"])
|
dismiss_stage_signal(DEFAULT_DB, sig["id"])
|
||||||
st.rerun(scope="app")
|
st.rerun(scope="app")
|
||||||
if s2.button("Dismiss", key=f"sig_dis_pre_{sig['id']}",
|
if s2.button("Dismiss", key=f"sig_dis_pre_{sig['id']}",
|
||||||
use_container_width=True):
|
use_container_width=True):
|
||||||
dismiss_stage_signal(get_db_path(), sig["id"])
|
dismiss_stage_signal(DEFAULT_DB, sig["id"])
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
with right:
|
with right:
|
||||||
|
|
@ -456,24 +453,24 @@ def _pre_kanban_row_fragment(job_id: int) -> None:
|
||||||
"→ 📞 Phone Screen", key=f"adv_pre_{job_id}",
|
"→ 📞 Phone Screen", key=f"adv_pre_{job_id}",
|
||||||
use_container_width=True, type="primary",
|
use_container_width=True, type="primary",
|
||||||
):
|
):
|
||||||
advance_to_stage(get_db_path(), job_id=job_id, stage="phone_screen")
|
advance_to_stage(DEFAULT_DB, job_id=job_id, stage="phone_screen")
|
||||||
submit_task(get_db_path(), "company_research", job_id)
|
submit_task(DEFAULT_DB, "company_research", job_id)
|
||||||
st.rerun(scope="app")
|
st.rerun(scope="app")
|
||||||
col_a, col_b = st.columns(2)
|
col_a, col_b = st.columns(2)
|
||||||
if stage == "applied" and col_a.button(
|
if stage == "applied" and col_a.button(
|
||||||
"📋 Survey", key=f"to_survey_{job_id}", use_container_width=True,
|
"📋 Survey", key=f"to_survey_{job_id}", use_container_width=True,
|
||||||
):
|
):
|
||||||
advance_to_stage(get_db_path(), job_id=job_id, stage="survey")
|
advance_to_stage(DEFAULT_DB, job_id=job_id, stage="survey")
|
||||||
st.rerun(scope="app")
|
st.rerun(scope="app")
|
||||||
if col_b.button("✗ Reject", key=f"rej_pre_{job_id}", use_container_width=True):
|
if col_b.button("✗ Reject", key=f"rej_pre_{job_id}", use_container_width=True):
|
||||||
reject_at_stage(get_db_path(), job_id=job_id, rejection_stage=stage)
|
reject_at_stage(DEFAULT_DB, job_id=job_id, rejection_stage=stage)
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
|
|
||||||
@st.fragment
|
@st.fragment
|
||||||
def _hired_card_fragment(job_id: int) -> None:
|
def _hired_card_fragment(job_id: int) -> None:
|
||||||
"""Compact hired job card — shown in the Offer/Hired column."""
|
"""Compact hired job card — shown in the Offer/Hired column."""
|
||||||
job = get_job_by_id(get_db_path(), job_id)
|
job = get_job_by_id(DEFAULT_DB, job_id)
|
||||||
if job is None or job.get("status") != "hired":
|
if job is None or job.get("status") != "hired":
|
||||||
return
|
return
|
||||||
with st.container(border=True):
|
with st.container(border=True):
|
||||||
|
|
|
||||||
|
|
@ -25,14 +25,11 @@ from scripts.db import (
|
||||||
get_task_for_job,
|
get_task_for_job,
|
||||||
)
|
)
|
||||||
from scripts.task_runner import submit_task
|
from scripts.task_runner import submit_task
|
||||||
from app.cloud_session import resolve_session, get_db_path
|
|
||||||
|
|
||||||
resolve_session("peregrine")
|
init_db(DEFAULT_DB)
|
||||||
|
|
||||||
init_db(get_db_path())
|
|
||||||
|
|
||||||
# ── Job selection ─────────────────────────────────────────────────────────────
|
# ── Job selection ─────────────────────────────────────────────────────────────
|
||||||
jobs_by_stage = get_interview_jobs(get_db_path())
|
jobs_by_stage = get_interview_jobs(DEFAULT_DB)
|
||||||
active_stages = ["phone_screen", "interviewing", "offer"]
|
active_stages = ["phone_screen", "interviewing", "offer"]
|
||||||
active_jobs = [
|
active_jobs = [
|
||||||
j for stage in active_stages
|
j for stage in active_stages
|
||||||
|
|
@ -103,10 +100,10 @@ col_prep, col_context = st.columns([2, 3])
|
||||||
# ════════════════════════════════════════════════
|
# ════════════════════════════════════════════════
|
||||||
with col_prep:
|
with col_prep:
|
||||||
|
|
||||||
research = get_research(get_db_path(), job_id=selected_id)
|
research = get_research(DEFAULT_DB, job_id=selected_id)
|
||||||
|
|
||||||
# Refresh / generate research
|
# Refresh / generate research
|
||||||
_res_task = get_task_for_job(get_db_path(), "company_research", selected_id)
|
_res_task = get_task_for_job(DEFAULT_DB, "company_research", selected_id)
|
||||||
_res_running = _res_task and _res_task["status"] in ("queued", "running")
|
_res_running = _res_task and _res_task["status"] in ("queued", "running")
|
||||||
|
|
||||||
if not research:
|
if not research:
|
||||||
|
|
@ -115,13 +112,13 @@ with col_prep:
|
||||||
if _res_task and _res_task["status"] == "failed":
|
if _res_task and _res_task["status"] == "failed":
|
||||||
st.error(f"Last attempt failed: {_res_task.get('error', '')}")
|
st.error(f"Last attempt failed: {_res_task.get('error', '')}")
|
||||||
if st.button("🔬 Generate research brief", type="primary", use_container_width=True):
|
if st.button("🔬 Generate research brief", type="primary", use_container_width=True):
|
||||||
submit_task(get_db_path(), "company_research", selected_id)
|
submit_task(DEFAULT_DB, "company_research", selected_id)
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
if _res_running:
|
if _res_running:
|
||||||
@st.fragment(run_every=3)
|
@st.fragment(run_every=3)
|
||||||
def _res_status_initial():
|
def _res_status_initial():
|
||||||
t = get_task_for_job(get_db_path(), "company_research", selected_id)
|
t = get_task_for_job(DEFAULT_DB, "company_research", selected_id)
|
||||||
if t and t["status"] in ("queued", "running"):
|
if t and t["status"] in ("queued", "running"):
|
||||||
stage = t.get("stage") or ""
|
stage = t.get("stage") or ""
|
||||||
lbl = "Queued…" if t["status"] == "queued" else (stage or "Generating… this may take 30–60 seconds")
|
lbl = "Queued…" if t["status"] == "queued" else (stage or "Generating… this may take 30–60 seconds")
|
||||||
|
|
@ -136,13 +133,13 @@ with col_prep:
|
||||||
col_ts, col_btn = st.columns([3, 1])
|
col_ts, col_btn = st.columns([3, 1])
|
||||||
col_ts.caption(f"Research generated: {generated_at}")
|
col_ts.caption(f"Research generated: {generated_at}")
|
||||||
if col_btn.button("🔄 Refresh", use_container_width=True, disabled=bool(_res_running)):
|
if col_btn.button("🔄 Refresh", use_container_width=True, disabled=bool(_res_running)):
|
||||||
submit_task(get_db_path(), "company_research", selected_id)
|
submit_task(DEFAULT_DB, "company_research", selected_id)
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
if _res_running:
|
if _res_running:
|
||||||
@st.fragment(run_every=3)
|
@st.fragment(run_every=3)
|
||||||
def _res_status_refresh():
|
def _res_status_refresh():
|
||||||
t = get_task_for_job(get_db_path(), "company_research", selected_id)
|
t = get_task_for_job(DEFAULT_DB, "company_research", selected_id)
|
||||||
if t and t["status"] in ("queued", "running"):
|
if t and t["status"] in ("queued", "running"):
|
||||||
stage = t.get("stage") or ""
|
stage = t.get("stage") or ""
|
||||||
lbl = "Queued…" if t["status"] == "queued" else (stage or "Refreshing research…")
|
lbl = "Queued…" if t["status"] == "queued" else (stage or "Refreshing research…")
|
||||||
|
|
@ -314,7 +311,7 @@ with col_context:
|
||||||
st.markdown(job.get("description") or "_No description saved for this listing._")
|
st.markdown(job.get("description") or "_No description saved for this listing._")
|
||||||
|
|
||||||
with tab_emails:
|
with tab_emails:
|
||||||
contacts = get_contacts(get_db_path(), job_id=selected_id)
|
contacts = get_contacts(DEFAULT_DB, job_id=selected_id)
|
||||||
if not contacts:
|
if not contacts:
|
||||||
st.info("No contacts logged yet. Use the Interviews page to log emails.")
|
st.info("No contacts logged yet. Use the Interviews page to log emails.")
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,10 @@ from scripts.db import (
|
||||||
insert_survey_response, get_survey_responses,
|
insert_survey_response, get_survey_responses,
|
||||||
)
|
)
|
||||||
from scripts.llm_router import LLMRouter
|
from scripts.llm_router import LLMRouter
|
||||||
from app.cloud_session import resolve_session, get_db_path
|
|
||||||
|
|
||||||
resolve_session("peregrine")
|
|
||||||
|
|
||||||
st.title("📋 Survey Assistant")
|
st.title("📋 Survey Assistant")
|
||||||
|
|
||||||
init_db(get_db_path())
|
init_db(DEFAULT_DB)
|
||||||
|
|
||||||
|
|
||||||
# ── Vision service health check ────────────────────────────────────────────────
|
# ── Vision service health check ────────────────────────────────────────────────
|
||||||
|
|
@ -43,7 +40,7 @@ def _vision_available() -> bool:
|
||||||
vision_up = _vision_available()
|
vision_up = _vision_available()
|
||||||
|
|
||||||
# ── Job selector ───────────────────────────────────────────────────────────────
|
# ── Job selector ───────────────────────────────────────────────────────────────
|
||||||
jobs_by_stage = get_interview_jobs(get_db_path())
|
jobs_by_stage = get_interview_jobs(DEFAULT_DB)
|
||||||
survey_jobs = jobs_by_stage.get("survey", [])
|
survey_jobs = jobs_by_stage.get("survey", [])
|
||||||
other_jobs = (
|
other_jobs = (
|
||||||
jobs_by_stage.get("applied", []) +
|
jobs_by_stage.get("applied", []) +
|
||||||
|
|
@ -64,7 +61,7 @@ selected_job_id = st.selectbox(
|
||||||
format_func=lambda jid: job_labels[jid],
|
format_func=lambda jid: job_labels[jid],
|
||||||
index=0,
|
index=0,
|
||||||
)
|
)
|
||||||
selected_job = get_job_by_id(get_db_path(), selected_job_id)
|
selected_job = get_job_by_id(DEFAULT_DB, selected_job_id)
|
||||||
|
|
||||||
# ── LLM prompt builders ────────────────────────────────────────────────────────
|
# ── LLM prompt builders ────────────────────────────────────────────────────────
|
||||||
_SURVEY_SYSTEM = (
|
_SURVEY_SYSTEM = (
|
||||||
|
|
@ -239,7 +236,7 @@ with right_col:
|
||||||
image_path = str(img_file)
|
image_path = str(img_file)
|
||||||
|
|
||||||
insert_survey_response(
|
insert_survey_response(
|
||||||
get_db_path(),
|
DEFAULT_DB,
|
||||||
job_id=selected_job_id,
|
job_id=selected_job_id,
|
||||||
survey_name=survey_name,
|
survey_name=survey_name,
|
||||||
source=source,
|
source=source,
|
||||||
|
|
@ -259,7 +256,7 @@ with right_col:
|
||||||
# ── History ────────────────────────────────────────────────────────────────────
|
# ── History ────────────────────────────────────────────────────────────────────
|
||||||
st.divider()
|
st.divider()
|
||||||
st.subheader("📂 Response History")
|
st.subheader("📂 Response History")
|
||||||
history = get_survey_responses(get_db_path(), job_id=selected_job_id)
|
history = get_survey_responses(DEFAULT_DB, job_id=selected_job_id)
|
||||||
|
|
||||||
if not history:
|
if not history:
|
||||||
st.caption("No saved responses for this job yet.")
|
st.caption("No saved responses for this job yet.")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""
|
"""
|
||||||
Tier definitions and feature gates for Peregrine.
|
Tier definitions and feature gates for Peregrine.
|
||||||
|
|
||||||
Tiers: free < paid < premium < ultra (ultra reserved; no Peregrine features use it yet)
|
Tiers: free < paid < premium
|
||||||
FEATURES maps feature key → minimum tier required.
|
FEATURES maps feature key → minimum tier required.
|
||||||
Features not in FEATURES are available to all tiers (free).
|
Features not in FEATURES are available to all tiers (free).
|
||||||
|
|
||||||
|
|
@ -22,14 +22,9 @@ Features that stay gated even with BYOK:
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os as _os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from circuitforge_core.tiers import (
|
TIERS = ["free", "paid", "premium"]
|
||||||
can_use as _core_can_use,
|
|
||||||
TIERS,
|
|
||||||
tier_label as _core_tier_label,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Maps feature key → minimum tier string required.
|
# Maps feature key → minimum tier string required.
|
||||||
# Features absent from this dict are free (available to all).
|
# Features absent from this dict are free (available to all).
|
||||||
|
|
@ -63,9 +58,6 @@ FEATURES: dict[str, str] = {
|
||||||
"google_calendar_sync": "paid",
|
"google_calendar_sync": "paid",
|
||||||
"apple_calendar_sync": "paid",
|
"apple_calendar_sync": "paid",
|
||||||
"slack_notifications": "paid",
|
"slack_notifications": "paid",
|
||||||
|
|
||||||
# Beta UI access — open to all tiers (access management, not compute)
|
|
||||||
"vue_ui_beta": "free",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Features that unlock when the user supplies any LLM backend (local or BYOK).
|
# Features that unlock when the user supplies any LLM backend (local or BYOK).
|
||||||
|
|
@ -83,13 +75,6 @@ BYOK_UNLOCKABLE: frozenset[str] = frozenset({
|
||||||
"survey_assistant",
|
"survey_assistant",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Demo mode flag — read from environment at module load time.
|
|
||||||
# Allows demo toolbar to override tier without accessing st.session_state (thread-safe).
|
|
||||||
# _DEMO_MODE is immutable after import for the process lifetime.
|
|
||||||
# DEMO_MODE must be set in the environment before the process starts (e.g., via
|
|
||||||
# Docker Compose environment:). Runtime toggling is not supported.
|
|
||||||
_DEMO_MODE = _os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
|
|
||||||
|
|
||||||
# Free integrations (not in FEATURES):
|
# Free integrations (not in FEATURES):
|
||||||
# google_drive_sync, dropbox_sync, onedrive_sync, mega_sync,
|
# google_drive_sync, dropbox_sync, onedrive_sync, mega_sync,
|
||||||
# nextcloud_sync, discord_notifications, home_assistant
|
# nextcloud_sync, discord_notifications, home_assistant
|
||||||
|
|
@ -116,40 +101,34 @@ def has_configured_llm(config_path: Path | None = None) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def can_use(
|
def can_use(tier: str, feature: str, has_byok: bool = False) -> bool:
|
||||||
tier: str,
|
|
||||||
feature: str,
|
|
||||||
has_byok: bool = False,
|
|
||||||
*,
|
|
||||||
demo_tier: str | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""Return True if the given tier has access to the feature.
|
"""Return True if the given tier has access to the feature.
|
||||||
|
|
||||||
has_byok: pass has_configured_llm() to unlock BYOK_UNLOCKABLE features
|
has_byok: pass has_configured_llm() to unlock BYOK_UNLOCKABLE features
|
||||||
for users who supply their own LLM backend regardless of tier.
|
for users who supply their own LLM backend regardless of tier.
|
||||||
|
|
||||||
demo_tier: when set AND _DEMO_MODE is True, substitutes for `tier`.
|
|
||||||
Read from st.session_state by the *caller*, not here — keeps
|
|
||||||
this function thread-safe for background tasks and tests.
|
|
||||||
|
|
||||||
Returns True for unknown features (not gated).
|
Returns True for unknown features (not gated).
|
||||||
Returns False for unknown/invalid tier strings.
|
Returns False for unknown/invalid tier strings.
|
||||||
"""
|
"""
|
||||||
effective_tier = demo_tier if (demo_tier is not None and _DEMO_MODE) else tier
|
required = FEATURES.get(feature)
|
||||||
# Pass Peregrine's BYOK_UNLOCKABLE via has_byok collapse — core's frozenset is empty
|
if required is None:
|
||||||
|
return True # not gated — available to all
|
||||||
if has_byok and feature in BYOK_UNLOCKABLE:
|
if has_byok and feature in BYOK_UNLOCKABLE:
|
||||||
return True
|
return True
|
||||||
return _core_can_use(feature, effective_tier, _features=FEATURES)
|
try:
|
||||||
|
return TIERS.index(tier) >= TIERS.index(required)
|
||||||
|
except ValueError:
|
||||||
|
return False # invalid tier string
|
||||||
|
|
||||||
|
|
||||||
def tier_label(feature: str, has_byok: bool = False) -> str:
|
def tier_label(feature: str, has_byok: bool = False) -> str:
|
||||||
"""Return a display label for a locked feature, or '' if free/unlocked."""
|
"""Return a display label for a locked feature, or '' if free/unlocked."""
|
||||||
if has_byok and feature in BYOK_UNLOCKABLE:
|
if has_byok and feature in BYOK_UNLOCKABLE:
|
||||||
return ""
|
return ""
|
||||||
raw = _core_tier_label(feature, _features=FEATURES)
|
required = FEATURES.get(feature)
|
||||||
if not raw or raw == "free":
|
if required is None:
|
||||||
return ""
|
return ""
|
||||||
return "🔒 Paid" if raw == "paid" else "⭐ Premium"
|
return "🔒 Paid" if required == "paid" else "⭐ Premium"
|
||||||
|
|
||||||
|
|
||||||
def effective_tier(
|
def effective_tier(
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,12 @@
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
build: .
|
||||||
context: ..
|
|
||||||
dockerfile: peregrine/Dockerfile.cfcore
|
|
||||||
container_name: peregrine-cloud
|
container_name: peregrine-cloud
|
||||||
ports:
|
ports:
|
||||||
- "8505:8501"
|
- "8505:8501"
|
||||||
volumes:
|
volumes:
|
||||||
- /devl/menagerie-data:/devl/menagerie-data # per-user data trees
|
- /devl/menagerie-data:/devl/menagerie-data # per-user data trees
|
||||||
- ./config/llm.cloud.yaml:/app/config/llm.yaml:ro # cloud-safe backends only (no claude_code/copilot/anthropic)
|
|
||||||
environment:
|
environment:
|
||||||
- CLOUD_MODE=true
|
- CLOUD_MODE=true
|
||||||
- CLOUD_DATA_ROOT=/devl/menagerie-data
|
- CLOUD_DATA_ROOT=/devl/menagerie-data
|
||||||
|
|
@ -34,10 +31,7 @@ services:
|
||||||
- DOCS_DIR=/tmp/cloud-docs
|
- DOCS_DIR=/tmp/cloud-docs
|
||||||
- STREAMLIT_SERVER_BASE_URL_PATH=peregrine
|
- STREAMLIT_SERVER_BASE_URL_PATH=peregrine
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- PEREGRINE_CADDY_PROXY=1
|
|
||||||
- CF_ORCH_URL=http://host.docker.internal:7700
|
|
||||||
- DEMO_MODE=false
|
- DEMO_MODE=false
|
||||||
- FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
searxng:
|
searxng:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -45,42 +39,6 @@ services:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: unless-stopped
|
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: .
|
|
||||||
dockerfile: docker/web/Dockerfile
|
|
||||||
args:
|
|
||||||
VITE_BASE_PATH: /peregrine/
|
|
||||||
ports:
|
|
||||||
- "8508:80"
|
|
||||||
depends_on:
|
|
||||||
- api
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
searxng:
|
searxng:
|
||||||
image: searxng/searxng:latest
|
image: searxng/searxng:latest
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
|
|
@ -38,16 +38,6 @@ services:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
web:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: docker/web/Dockerfile
|
|
||||||
args:
|
|
||||||
VITE_BASE_PATH: /peregrine/
|
|
||||||
ports:
|
|
||||||
- "8507:80"
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
searxng:
|
searxng:
|
||||||
image: searxng/searxng:latest
|
image: searxng/searxng:latest
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
# compose.test-cfcore.yml — single-user test instance for circuitforge-core integration
|
|
||||||
#
|
|
||||||
# Run from the PARENT directory of peregrine/ (the build context must include
|
|
||||||
# both peregrine/ and circuitforge-core/ as siblings):
|
|
||||||
#
|
|
||||||
# cd /devl (or /Library/Development/CircuitForge on dev)
|
|
||||||
# docker compose -f peregrine/compose.test-cfcore.yml --project-name peregrine-test up -d
|
|
||||||
# docker compose -f peregrine/compose.test-cfcore.yml --project-name peregrine-test logs -f
|
|
||||||
# docker compose -f peregrine/compose.test-cfcore.yml --project-name peregrine-test down
|
|
||||||
#
|
|
||||||
# UI: http://localhost:8516
|
|
||||||
# Purpose: smoke-test circuitforge-core shims (db, llm_router, tiers, task_scheduler)
|
|
||||||
# before promoting cfcore integration to the production cloud instance.
|
|
||||||
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: peregrine/Dockerfile.cfcore
|
|
||||||
container_name: peregrine-test-cfcore
|
|
||||||
ports:
|
|
||||||
- "8516:8501"
|
|
||||||
volumes:
|
|
||||||
- /devl/job-seeker:/devl/job-seeker
|
|
||||||
- /devl/job-seeker/config:/app/config
|
|
||||||
- /devl/job-seeker/config/llm.docker.yaml:/app/config/llm.yaml:ro
|
|
||||||
- /devl/job-seeker/config/user.docker.yaml:/app/config/user.yaml:ro
|
|
||||||
environment:
|
|
||||||
- STAGING_DB=/devl/job-seeker/staging.db
|
|
||||||
- PYTHONUNBUFFERED=1
|
|
||||||
- STREAMLIT_SERVER_BASE_URL_PATH=
|
|
||||||
- CF_ORCH_URL=http://host.docker.internal:7700
|
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
restart: "no"
|
|
||||||
57
compose.yml
57
compose.yml
|
|
@ -1,11 +1,9 @@
|
||||||
# compose.yml — Peregrine by Circuit Forge LLC
|
# compose.yml — Peregrine by Circuit Forge LLC
|
||||||
# Profiles: remote | cpu | single-gpu | dual-gpu-ollama
|
# Profiles: remote | cpu | single-gpu | dual-gpu-ollama | dual-gpu-vllm | dual-gpu-mixed
|
||||||
services:
|
services:
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build:
|
build: .
|
||||||
context: ..
|
|
||||||
dockerfile: peregrine/Dockerfile.cfcore
|
|
||||||
command: >
|
command: >
|
||||||
bash -c "streamlit run app/app.py
|
bash -c "streamlit run app/app.py
|
||||||
--server.port=8501
|
--server.port=8501
|
||||||
|
|
@ -35,7 +33,6 @@ services:
|
||||||
- FORGEJO_API_URL=${FORGEJO_API_URL:-}
|
- FORGEJO_API_URL=${FORGEJO_API_URL:-}
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- PYTHONLOGGING=WARNING
|
- PYTHONLOGGING=WARNING
|
||||||
- PEREGRINE_CADDY_PROXY=1
|
|
||||||
depends_on:
|
depends_on:
|
||||||
searxng:
|
searxng:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -43,39 +40,6 @@ services:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: unless-stopped
|
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:
|
|
||||||
- ./config:/app/config
|
|
||||||
- ./data:/app/data
|
|
||||||
- ${DOCS_DIR:-~/Documents/JobSearch}:/docs
|
|
||||||
environment:
|
|
||||||
- STAGING_DB=/app/data/staging.db
|
|
||||||
- DOCS_DIR=/docs
|
|
||||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
|
||||||
- OPENAI_COMPAT_URL=${OPENAI_COMPAT_URL:-}
|
|
||||||
- OPENAI_COMPAT_KEY=${OPENAI_COMPAT_KEY:-}
|
|
||||||
- PEREGRINE_GPU_COUNT=${PEREGRINE_GPU_COUNT:-0}
|
|
||||||
- PEREGRINE_GPU_NAMES=${PEREGRINE_GPU_NAMES:-}
|
|
||||||
- PYTHONUNBUFFERED=1
|
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
web:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: docker/web/Dockerfile
|
|
||||||
ports:
|
|
||||||
- "${VUE_PORT:-8506}:80"
|
|
||||||
depends_on:
|
|
||||||
- api
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
searxng:
|
searxng:
|
||||||
image: searxng/searxng:latest
|
image: searxng/searxng:latest
|
||||||
ports:
|
ports:
|
||||||
|
|
@ -129,6 +93,23 @@ services:
|
||||||
profiles: [single-gpu, dual-gpu-ollama, dual-gpu-vllm, dual-gpu-mixed]
|
profiles: [single-gpu, dual-gpu-ollama, dual-gpu-vllm, dual-gpu-mixed]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
vllm:
|
||||||
|
image: vllm/vllm-openai:latest
|
||||||
|
ports:
|
||||||
|
- "${VLLM_PORT:-8000}:8000"
|
||||||
|
volumes:
|
||||||
|
- ${VLLM_MODELS_DIR:-~/models/vllm}:/models
|
||||||
|
command: >
|
||||||
|
--model /models/${VLLM_MODEL:-Ouro-1.4B}
|
||||||
|
--trust-remote-code
|
||||||
|
--max-model-len 4096
|
||||||
|
--gpu-memory-utilization 0.75
|
||||||
|
--enforce-eager
|
||||||
|
--max-num-seqs 8
|
||||||
|
--cpu-offload-gb ${CPU_OFFLOAD_GB:-0}
|
||||||
|
profiles: [dual-gpu-vllm, dual-gpu-mixed]
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
finetune:
|
finetune:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
backends:
|
|
||||||
anthropic:
|
|
||||||
api_key_env: ANTHROPIC_API_KEY
|
|
||||||
enabled: false
|
|
||||||
model: claude-sonnet-4-6
|
|
||||||
supports_images: true
|
|
||||||
type: anthropic
|
|
||||||
claude_code:
|
|
||||||
api_key: any
|
|
||||||
base_url: http://localhost:3009/v1
|
|
||||||
enabled: false
|
|
||||||
model: claude-code-terminal
|
|
||||||
supports_images: true
|
|
||||||
type: openai_compat
|
|
||||||
github_copilot:
|
|
||||||
api_key: any
|
|
||||||
base_url: http://localhost:3010/v1
|
|
||||||
enabled: false
|
|
||||||
model: gpt-4o
|
|
||||||
supports_images: false
|
|
||||||
type: openai_compat
|
|
||||||
ollama:
|
|
||||||
api_key: ollama
|
|
||||||
base_url: http://host.docker.internal:11434/v1
|
|
||||||
enabled: true
|
|
||||||
model: llama3.1:8b # generic — no personal fine-tunes in cloud
|
|
||||||
supports_images: false
|
|
||||||
type: openai_compat
|
|
||||||
ollama_research:
|
|
||||||
api_key: ollama
|
|
||||||
base_url: http://host.docker.internal:11434/v1
|
|
||||||
enabled: true
|
|
||||||
model: llama3.1:8b
|
|
||||||
supports_images: false
|
|
||||||
type: openai_compat
|
|
||||||
vision_service:
|
|
||||||
base_url: http://host.docker.internal:8002
|
|
||||||
enabled: true
|
|
||||||
supports_images: true
|
|
||||||
type: vision_service
|
|
||||||
vllm:
|
|
||||||
api_key: ''
|
|
||||||
base_url: http://host.docker.internal:8000/v1
|
|
||||||
enabled: true
|
|
||||||
model: __auto__
|
|
||||||
supports_images: false
|
|
||||||
type: openai_compat
|
|
||||||
vllm_research:
|
|
||||||
api_key: ''
|
|
||||||
base_url: http://host.docker.internal:8000/v1
|
|
||||||
enabled: true
|
|
||||||
model: __auto__
|
|
||||||
supports_images: false
|
|
||||||
type: openai_compat
|
|
||||||
fallback_order:
|
|
||||||
- vllm
|
|
||||||
- ollama
|
|
||||||
research_fallback_order:
|
|
||||||
- vllm_research
|
|
||||||
- ollama_research
|
|
||||||
vision_fallback_order:
|
|
||||||
- vision_service
|
|
||||||
|
|
@ -28,9 +28,9 @@ backends:
|
||||||
type: openai_compat
|
type: openai_compat
|
||||||
ollama_research:
|
ollama_research:
|
||||||
api_key: ollama
|
api_key: ollama
|
||||||
base_url: http://ollama_research:11434/v1
|
base_url: http://host.docker.internal:11434/v1
|
||||||
enabled: true
|
enabled: true
|
||||||
model: llama3.1:8b
|
model: llama3.2:3b
|
||||||
supports_images: false
|
supports_images: false
|
||||||
type: openai_compat
|
type: openai_compat
|
||||||
vision_service:
|
vision_service:
|
||||||
|
|
@ -45,11 +45,6 @@ backends:
|
||||||
model: __auto__
|
model: __auto__
|
||||||
supports_images: false
|
supports_images: false
|
||||||
type: openai_compat
|
type: openai_compat
|
||||||
cf_orch:
|
|
||||||
service: vllm
|
|
||||||
model_candidates:
|
|
||||||
- Qwen2.5-3B-Instruct
|
|
||||||
ttl_s: 300
|
|
||||||
vllm_research:
|
vllm_research:
|
||||||
api_key: ''
|
api_key: ''
|
||||||
base_url: http://host.docker.internal:8000/v1
|
base_url: http://host.docker.internal:8000/v1
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@ dev_tier_override: null # overrides tier locally (for testing only)
|
||||||
wizard_complete: false
|
wizard_complete: false
|
||||||
wizard_step: 0
|
wizard_step: 0
|
||||||
dismissed_banners: []
|
dismissed_banners: []
|
||||||
ui_preference: streamlit # UI preference — "streamlit" (default) or "vue" (Beta: Paid tier)
|
|
||||||
|
|
||||||
docs_dir: "~/Documents/JobSearch"
|
docs_dir: "~/Documents/JobSearch"
|
||||||
ollama_models_dir: "~/models/ollama"
|
ollama_models_dir: "~/models/ollama"
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ mission_preferences:
|
||||||
social_impact: Want my work to reach people who need it most.
|
social_impact: Want my work to reach people who need it most.
|
||||||
name: Demo User
|
name: Demo User
|
||||||
nda_companies: []
|
nda_companies: []
|
||||||
ollama_models_dir: /root/models/ollama
|
ollama_models_dir: ~/models/ollama
|
||||||
phone: ''
|
phone: ''
|
||||||
services:
|
services:
|
||||||
ollama_host: localhost
|
ollama_host: localhost
|
||||||
|
|
@ -39,7 +39,6 @@ services:
|
||||||
vllm_ssl: false
|
vllm_ssl: false
|
||||||
vllm_ssl_verify: true
|
vllm_ssl_verify: true
|
||||||
tier: free
|
tier: free
|
||||||
ui_preference: streamlit
|
vllm_models_dir: ~/models/vllm
|
||||||
vllm_models_dir: /root/models/vllm
|
|
||||||
wizard_complete: true
|
wizard_complete: true
|
||||||
wizard_step: 0
|
wizard_step: 0
|
||||||
|
|
|
||||||
1232
dev-api.py
1232
dev-api.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,15 +0,0 @@
|
||||||
# Stage 1: build
|
|
||||||
FROM node:20-alpine AS build
|
|
||||||
WORKDIR /app
|
|
||||||
COPY web/package*.json ./
|
|
||||||
RUN npm ci --prefer-offline
|
|
||||||
COPY web/ ./
|
|
||||||
ARG VITE_BASE_PATH=/
|
|
||||||
ENV VITE_BASE_PATH=${VITE_BASE_PATH}
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Stage 2: serve
|
|
||||||
FROM nginx:alpine
|
|
||||||
COPY docker/web/nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
client_max_body_size 20m;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
# Proxy API calls to the FastAPI backend service
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://api:8601;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_read_timeout 120s;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cache static assets
|
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
|
||||||
expires 1y;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
}
|
|
||||||
|
|
||||||
# SPA fallback — must come after API and assets
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -102,23 +102,6 @@ Before opening a pull request:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Database Migrations
|
|
||||||
|
|
||||||
Peregrine uses a numbered SQL migration system (Rails-style). Each migration is a `.sql` file in the `migrations/` directory at the repo root, named `NNN_description.sql` (e.g. `002_add_foo_column.sql`). Applied migrations are tracked in a `schema_migrations` table in each user database.
|
|
||||||
|
|
||||||
### Adding a migration
|
|
||||||
|
|
||||||
1. Create `migrations/NNN_description.sql` where `NNN` is the next sequential number (zero-padded to 3 digits).
|
|
||||||
2. Write standard SQL — `CREATE TABLE IF NOT EXISTS`, `ALTER TABLE ADD COLUMN`, etc. Keep each migration idempotent where possible.
|
|
||||||
3. Do **not** modify `scripts/db.py`'s legacy `_MIGRATIONS` lists — those are superseded and will be removed once all active databases have been bootstrapped by the migration runner.
|
|
||||||
4. The runner (`scripts/db_migrate.py`) applies pending migrations at startup automatically (both FastAPI and Streamlit paths call `migrate_db(db_path)`).
|
|
||||||
|
|
||||||
### Rollbacks
|
|
||||||
|
|
||||||
SQLite does not support transactional DDL for all statement types. Write forward-only migrations. If you need to undo a schema change, add a new migration that reverses it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What NOT to Do
|
## What NOT to Do
|
||||||
|
|
||||||
- Do not commit `config/user.yaml`, `config/notion.yaml`, `config/email.yaml`, `config/adzuna.yaml`, or any `config/integrations/*.yaml` — all are gitignored
|
- Do not commit `config/user.yaml`, `config/notion.yaml`, `config/email.yaml`, `config/adzuna.yaml`, or any `config/integrations/*.yaml` — all are gitignored
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
name: cf
|
name: job-seeker
|
||||||
# Recreate: conda env create -f environment.yml
|
# Recreate: conda env create -f environment.yml
|
||||||
# Update pinned snapshot: conda env export --no-builds > environment.yml
|
# Update pinned snapshot: conda env export --no-builds > environment.yml
|
||||||
channels:
|
channels:
|
||||||
|
|
|
||||||
11
manage.sh
11
manage.sh
|
|
@ -32,7 +32,6 @@ usage() {
|
||||||
echo -e " ${GREEN}logs [service]${NC} Tail logs (default: app)"
|
echo -e " ${GREEN}logs [service]${NC} Tail logs (default: app)"
|
||||||
echo -e " ${GREEN}update${NC} Pull latest images + rebuild app"
|
echo -e " ${GREEN}update${NC} Pull latest images + rebuild app"
|
||||||
echo -e " ${GREEN}preflight${NC} Check ports + resources; write .env"
|
echo -e " ${GREEN}preflight${NC} Check ports + resources; write .env"
|
||||||
echo -e " ${GREEN}models${NC} Check ollama models in config; pull any missing"
|
|
||||||
echo -e " ${GREEN}test${NC} Run test suite"
|
echo -e " ${GREEN}test${NC} Run test suite"
|
||||||
echo -e " ${GREEN}e2e [mode]${NC} Run E2E tests (mode: demo|cloud|local, default: demo)"
|
echo -e " ${GREEN}e2e [mode]${NC} Run E2E tests (mode: demo|cloud|local, default: demo)"
|
||||||
echo -e " Set E2E_HEADLESS=false to run headed via Xvfb"
|
echo -e " Set E2E_HEADLESS=false to run headed via Xvfb"
|
||||||
|
|
@ -92,12 +91,6 @@ case "$CMD" in
|
||||||
make preflight PROFILE="$PROFILE"
|
make preflight PROFILE="$PROFILE"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
models)
|
|
||||||
info "Checking ollama models..."
|
|
||||||
conda run -n cf python scripts/preflight.py --models-only
|
|
||||||
success "Model check complete."
|
|
||||||
;;
|
|
||||||
|
|
||||||
start)
|
start)
|
||||||
info "Starting Peregrine (PROFILE=${PROFILE})..."
|
info "Starting Peregrine (PROFILE=${PROFILE})..."
|
||||||
make start PROFILE="$PROFILE"
|
make start PROFILE="$PROFILE"
|
||||||
|
|
@ -140,7 +133,7 @@ case "$CMD" in
|
||||||
&& echo "docker compose" \
|
&& echo "docker compose" \
|
||||||
|| (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))"
|
|| (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))"
|
||||||
$COMPOSE pull searxng ollama 2>/dev/null || true
|
$COMPOSE pull searxng ollama 2>/dev/null || true
|
||||||
$COMPOSE build app web
|
$COMPOSE build app
|
||||||
success "Update complete. Run './manage.sh restart' to apply."
|
success "Update complete. Run './manage.sh restart' to apply."
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
|
@ -190,7 +183,7 @@ case "$CMD" in
|
||||||
RUNNER=""
|
RUNNER=""
|
||||||
fi
|
fi
|
||||||
info "Running E2E tests (mode=${MODE}, headless=${HEADLESS})..."
|
info "Running E2E tests (mode=${MODE}, headless=${HEADLESS})..."
|
||||||
$RUNNER conda run -n cf pytest tests/e2e/ \
|
$RUNNER conda run -n job-seeker pytest tests/e2e/ \
|
||||||
--mode="${MODE}" \
|
--mode="${MODE}" \
|
||||||
--json-report \
|
--json-report \
|
||||||
--json-report-file="${RESULTS_DIR}/report.json" \
|
--json-report-file="${RESULTS_DIR}/report.json" \
|
||||||
|
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
-- Migration 001: Baseline schema
|
|
||||||
-- Captures the full schema as of v0.8.5 (all columns including those added via ALTER TABLE)
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS jobs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
title TEXT,
|
|
||||||
company TEXT,
|
|
||||||
url TEXT UNIQUE,
|
|
||||||
source TEXT,
|
|
||||||
location TEXT,
|
|
||||||
is_remote INTEGER DEFAULT 0,
|
|
||||||
salary TEXT,
|
|
||||||
description TEXT,
|
|
||||||
match_score REAL,
|
|
||||||
keyword_gaps TEXT,
|
|
||||||
date_found TEXT,
|
|
||||||
status TEXT DEFAULT 'pending',
|
|
||||||
notion_page_id TEXT,
|
|
||||||
cover_letter TEXT,
|
|
||||||
applied_at TEXT,
|
|
||||||
interview_date TEXT,
|
|
||||||
rejection_stage TEXT,
|
|
||||||
phone_screen_at TEXT,
|
|
||||||
interviewing_at TEXT,
|
|
||||||
offer_at TEXT,
|
|
||||||
hired_at TEXT,
|
|
||||||
survey_at TEXT,
|
|
||||||
calendar_event_id TEXT,
|
|
||||||
optimized_resume TEXT,
|
|
||||||
ats_gap_report TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS job_contacts (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
job_id INTEGER,
|
|
||||||
direction TEXT,
|
|
||||||
subject TEXT,
|
|
||||||
from_addr TEXT,
|
|
||||||
to_addr TEXT,
|
|
||||||
body TEXT,
|
|
||||||
received_at TEXT,
|
|
||||||
is_response_needed INTEGER DEFAULT 0,
|
|
||||||
responded_at TEXT,
|
|
||||||
message_id TEXT,
|
|
||||||
stage_signal TEXT,
|
|
||||||
suggestion_dismissed INTEGER DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS company_research (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
job_id INTEGER UNIQUE,
|
|
||||||
generated_at TEXT,
|
|
||||||
company_brief TEXT,
|
|
||||||
ceo_brief TEXT,
|
|
||||||
talking_points TEXT,
|
|
||||||
raw_output TEXT,
|
|
||||||
tech_brief TEXT,
|
|
||||||
funding_brief TEXT,
|
|
||||||
competitors_brief TEXT,
|
|
||||||
red_flags TEXT,
|
|
||||||
scrape_used INTEGER DEFAULT 0,
|
|
||||||
accessibility_brief TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS background_tasks (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
task_type TEXT,
|
|
||||||
job_id INTEGER,
|
|
||||||
params TEXT,
|
|
||||||
status TEXT DEFAULT 'pending',
|
|
||||||
error TEXT,
|
|
||||||
created_at TEXT,
|
|
||||||
started_at TEXT,
|
|
||||||
finished_at TEXT,
|
|
||||||
stage TEXT,
|
|
||||||
updated_at TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS survey_responses (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
job_id INTEGER,
|
|
||||||
survey_name TEXT,
|
|
||||||
received_at TEXT,
|
|
||||||
source TEXT,
|
|
||||||
raw_input TEXT,
|
|
||||||
image_path TEXT,
|
|
||||||
mode TEXT,
|
|
||||||
llm_output TEXT,
|
|
||||||
reported_score REAL,
|
|
||||||
created_at TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS digest_queue (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
job_contact_id INTEGER UNIQUE,
|
|
||||||
created_at TEXT
|
|
||||||
);
|
|
||||||
|
|
@ -2,15 +2,6 @@
|
||||||
# Extracted from environment.yml for Docker pip installs
|
# Extracted from environment.yml for Docker pip installs
|
||||||
# Keep in sync with environment.yml
|
# Keep in sync with environment.yml
|
||||||
|
|
||||||
# ── CircuitForge shared core ───────────────────────────────────────────────
|
|
||||||
# Requires circuitforge-core >= 0.8.0 (config.load_env, db, tasks; resources moved to circuitforge-orch).
|
|
||||||
# Local dev / Docker (parent-context build): path install works because
|
|
||||||
# circuitforge-core/ is a sibling directory.
|
|
||||||
# CI / fresh checkouts: falls back to the Forgejo VCS URL below.
|
|
||||||
# To use local editable install run: pip install -e ../circuitforge-core
|
|
||||||
# TODO: pin to @v0.7.0 tag once cf-core cuts a release tag.
|
|
||||||
git+https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git@main
|
|
||||||
|
|
||||||
# ── Web UI ────────────────────────────────────────────────────────────────
|
# ── Web UI ────────────────────────────────────────────────────────────────
|
||||||
streamlit>=1.35
|
streamlit>=1.35
|
||||||
watchdog
|
watchdog
|
||||||
|
|
@ -87,10 +78,3 @@ lxml
|
||||||
# ── Documentation ────────────────────────────────────────────────────────
|
# ── Documentation ────────────────────────────────────────────────────────
|
||||||
mkdocs>=1.5
|
mkdocs>=1.5
|
||||||
mkdocs-material>=9.5
|
mkdocs-material>=9.5
|
||||||
|
|
||||||
# ── Vue SPA API backend ──────────────────────────────────────────────────
|
|
||||||
fastapi>=0.100.0
|
|
||||||
uvicorn[standard]>=0.20.0
|
|
||||||
PyJWT>=2.8.0
|
|
||||||
cryptography>=40.0.0
|
|
||||||
python-multipart>=0.0.6
|
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ def scrape(profile: dict, location: str, results_wanted: int = 50) -> list[dict]
|
||||||
print(f" [adzuna] Skipped — {exc}")
|
print(f" [adzuna] Skipped — {exc}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
titles = profile.get("titles") or profile.get("job_titles", [])
|
titles = profile.get("titles", [])
|
||||||
hours_old = profile.get("hours_old", 240)
|
hours_old = profile.get("hours_old", 240)
|
||||||
max_days_old = max(1, hours_old // 24)
|
max_days_old = max(1, hours_old // 24)
|
||||||
is_remote_search = location.lower() == "remote"
|
is_remote_search = location.lower() == "remote"
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ def scrape(profile: dict, location: str, results_wanted: int = 50) -> list[dict]
|
||||||
return []
|
return []
|
||||||
metros = [metro]
|
metros = [metro]
|
||||||
|
|
||||||
titles: list[str] = profile.get("titles") or profile.get("job_titles", [])
|
titles: list[str] = profile.get("titles", [])
|
||||||
hours_old: int = profile.get("hours_old", 240)
|
hours_old: int = profile.get("hours_old", 240)
|
||||||
cutoff = datetime.now(tz=timezone.utc).timestamp() - (hours_old * 3600)
|
cutoff = datetime.now(tz=timezone.utc).timestamp() - (hours_old * 3600)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ def scrape(profile: dict, location: str, results_wanted: int = 50) -> list[dict]
|
||||||
)
|
)
|
||||||
page = ctx.new_page()
|
page = ctx.new_page()
|
||||||
|
|
||||||
for title in (profile.get("titles") or profile.get("job_titles", [])):
|
for title in profile.get("titles", []):
|
||||||
if len(results) >= results_wanted:
|
if len(results) >= results_wanted:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,30 @@ from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from circuitforge_core.db import get_connection as _cf_get_connection
|
|
||||||
|
|
||||||
DEFAULT_DB = Path(os.environ.get("STAGING_DB", Path(__file__).parent.parent / "staging.db"))
|
DEFAULT_DB = Path(os.environ.get("STAGING_DB", Path(__file__).parent.parent / "staging.db"))
|
||||||
|
|
||||||
|
|
||||||
def get_connection(db_path: Path = DEFAULT_DB, key: str = "") -> "sqlite3.Connection":
|
def get_connection(db_path: Path = DEFAULT_DB, key: str = "") -> "sqlite3.Connection":
|
||||||
"""Thin shim — delegates to circuitforge_core.db.get_connection."""
|
"""
|
||||||
return _cf_get_connection(db_path, key)
|
Open a database connection.
|
||||||
|
|
||||||
|
In cloud mode with a key: uses SQLCipher (AES-256 encrypted, API-identical to sqlite3).
|
||||||
|
Otherwise: vanilla sqlite3.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to the SQLite/SQLCipher database file.
|
||||||
|
key: SQLCipher encryption key (hex string). Empty = unencrypted.
|
||||||
|
"""
|
||||||
|
import os as _os
|
||||||
|
cloud_mode = _os.environ.get("CLOUD_MODE", "").lower() in ("1", "true", "yes")
|
||||||
|
if cloud_mode and key:
|
||||||
|
from pysqlcipher3 import dbapi2 as _sqlcipher
|
||||||
|
conn = _sqlcipher.connect(str(db_path))
|
||||||
|
conn.execute(f"PRAGMA key='{key}'")
|
||||||
|
return conn
|
||||||
|
else:
|
||||||
|
import sqlite3 as _sqlite3
|
||||||
|
return _sqlite3.connect(str(db_path))
|
||||||
|
|
||||||
|
|
||||||
CREATE_JOBS = """
|
CREATE_JOBS = """
|
||||||
|
|
@ -141,8 +157,6 @@ _MIGRATIONS = [
|
||||||
("hired_at", "TEXT"),
|
("hired_at", "TEXT"),
|
||||||
("survey_at", "TEXT"),
|
("survey_at", "TEXT"),
|
||||||
("calendar_event_id", "TEXT"),
|
("calendar_event_id", "TEXT"),
|
||||||
("optimized_resume", "TEXT"), # ATS-rewritten resume text (paid tier)
|
|
||||||
("ats_gap_report", "TEXT"), # JSON gap report (free tier)
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -313,38 +327,6 @@ def update_cover_letter(db_path: Path = DEFAULT_DB, job_id: int = None, text: st
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def save_optimized_resume(db_path: Path = DEFAULT_DB, job_id: int = None,
|
|
||||||
text: str = "", gap_report: str = "") -> None:
|
|
||||||
"""Persist ATS-optimized resume text and/or gap report for a job."""
|
|
||||||
if job_id is None:
|
|
||||||
return
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE jobs SET optimized_resume = ?, ats_gap_report = ? WHERE id = ?",
|
|
||||||
(text or None, gap_report or None, job_id),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def get_optimized_resume(db_path: Path = DEFAULT_DB, job_id: int = None) -> dict:
|
|
||||||
"""Return optimized_resume and ats_gap_report for a job, or empty strings if absent."""
|
|
||||||
if job_id is None:
|
|
||||||
return {"optimized_resume": "", "ats_gap_report": ""}
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT optimized_resume, ats_gap_report FROM jobs WHERE id = ?", (job_id,)
|
|
||||||
).fetchone()
|
|
||||||
conn.close()
|
|
||||||
if not row:
|
|
||||||
return {"optimized_resume": "", "ats_gap_report": ""}
|
|
||||||
return {
|
|
||||||
"optimized_resume": row["optimized_resume"] or "",
|
|
||||||
"ats_gap_report": row["ats_gap_report"] or "",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_UPDATABLE_JOB_COLS = {
|
_UPDATABLE_JOB_COLS = {
|
||||||
"title", "company", "url", "source", "location", "is_remote",
|
"title", "company", "url", "source", "location", "is_remote",
|
||||||
"salary", "description", "match_score", "keyword_gaps",
|
"salary", "description", "match_score", "keyword_gaps",
|
||||||
|
|
@ -383,19 +365,6 @@ def mark_applied(db_path: Path = DEFAULT_DB, ids: list[int] = None) -> None:
|
||||||
conn.close()
|
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:
|
def kill_stuck_tasks(db_path: Path = DEFAULT_DB) -> int:
|
||||||
"""Mark all queued/running background tasks as failed. Returns count killed."""
|
"""Mark all queued/running background tasks as failed. Returns count killed."""
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
"""
|
|
||||||
db_migrate.py — Rails-style numbered SQL migration runner for Peregrine user DBs.
|
|
||||||
|
|
||||||
Migration files live in migrations/ (sibling to this script's parent directory),
|
|
||||||
named NNN_description.sql (e.g. 001_baseline.sql). They are applied in sorted
|
|
||||||
order and tracked in the schema_migrations table so each runs exactly once.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
from scripts.db_migrate import migrate_db
|
|
||||||
migrate_db(Path("/path/to/user.db"))
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Resolved at import time: peregrine repo root / migrations/
|
|
||||||
_MIGRATIONS_DIR = Path(__file__).parent.parent / "migrations"
|
|
||||||
|
|
||||||
_CREATE_MIGRATIONS_TABLE = """
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
||||||
version TEXT PRIMARY KEY,
|
|
||||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def migrate_db(db_path: Path) -> list[str]:
|
|
||||||
"""Apply any pending migrations to db_path. Returns list of applied versions."""
|
|
||||||
applied: list[str] = []
|
|
||||||
|
|
||||||
con = sqlite3.connect(db_path)
|
|
||||||
try:
|
|
||||||
con.execute(_CREATE_MIGRATIONS_TABLE)
|
|
||||||
con.commit()
|
|
||||||
|
|
||||||
if not _MIGRATIONS_DIR.is_dir():
|
|
||||||
log.warning("migrations/ directory not found at %s — skipping", _MIGRATIONS_DIR)
|
|
||||||
return applied
|
|
||||||
|
|
||||||
migration_files = sorted(_MIGRATIONS_DIR.glob("*.sql"))
|
|
||||||
if not migration_files:
|
|
||||||
return applied
|
|
||||||
|
|
||||||
already_applied = {
|
|
||||||
row[0] for row in con.execute("SELECT version FROM schema_migrations")
|
|
||||||
}
|
|
||||||
|
|
||||||
for path in migration_files:
|
|
||||||
version = path.stem # e.g. "001_baseline"
|
|
||||||
if version in already_applied:
|
|
||||||
continue
|
|
||||||
|
|
||||||
sql = path.read_text(encoding="utf-8")
|
|
||||||
log.info("Applying migration %s to %s", version, db_path.name)
|
|
||||||
try:
|
|
||||||
con.executescript(sql)
|
|
||||||
con.execute(
|
|
||||||
"INSERT INTO schema_migrations (version) VALUES (?)", (version,)
|
|
||||||
)
|
|
||||||
con.commit()
|
|
||||||
applied.append(version)
|
|
||||||
log.info("Migration %s applied successfully", version)
|
|
||||||
except Exception as exc:
|
|
||||||
con.rollback()
|
|
||||||
log.error("Migration %s failed: %s", version, exc)
|
|
||||||
raise RuntimeError(f"Migration {version} failed: {exc}") from exc
|
|
||||||
finally:
|
|
||||||
con.close()
|
|
||||||
|
|
||||||
return applied
|
|
||||||
|
|
@ -34,21 +34,17 @@ CUSTOM_SCRAPERS: dict[str, object] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def load_config(config_dir: Path | None = None) -> tuple[dict, dict]:
|
def load_config() -> tuple[dict, dict]:
|
||||||
cfg = config_dir or CONFIG_DIR
|
profiles = yaml.safe_load(PROFILES_CFG.read_text())
|
||||||
profiles_path = cfg / "search_profiles.yaml"
|
notion_cfg = yaml.safe_load(NOTION_CFG.read_text())
|
||||||
notion_path = cfg / "notion.yaml"
|
|
||||||
profiles = yaml.safe_load(profiles_path.read_text())
|
|
||||||
notion_cfg = yaml.safe_load(notion_path.read_text()) if notion_path.exists() else {"field_map": {}, "token": None, "database_id": None}
|
|
||||||
return profiles, notion_cfg
|
return profiles, notion_cfg
|
||||||
|
|
||||||
|
|
||||||
def load_blocklist(config_dir: Path | None = None) -> dict:
|
def load_blocklist() -> dict:
|
||||||
"""Load global blocklist config. Returns dict with companies, industries, locations lists."""
|
"""Load global blocklist config. Returns dict with companies, industries, locations lists."""
|
||||||
blocklist_path = (config_dir or CONFIG_DIR) / "blocklist.yaml"
|
if not BLOCKLIST_CFG.exists():
|
||||||
if not blocklist_path.exists():
|
|
||||||
return {"companies": [], "industries": [], "locations": []}
|
return {"companies": [], "industries": [], "locations": []}
|
||||||
raw = yaml.safe_load(blocklist_path.read_text()) or {}
|
raw = yaml.safe_load(BLOCKLIST_CFG.read_text()) or {}
|
||||||
return {
|
return {
|
||||||
"companies": [c.lower() for c in raw.get("companies", []) if c],
|
"companies": [c.lower() for c in raw.get("companies", []) if c],
|
||||||
"industries": [i.lower() for i in raw.get("industries", []) if i],
|
"industries": [i.lower() for i in raw.get("industries", []) if i],
|
||||||
|
|
@ -121,15 +117,10 @@ def push_to_notion(notion: Client, db_id: str, job: dict, fm: dict) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False, config_dir: Path | None = None) -> None:
|
def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False) -> None:
|
||||||
# In cloud mode, config_dir is the per-user config directory derived from db_path.
|
profiles_cfg, notion_cfg = load_config()
|
||||||
# Falls back to the app-level /app/config for single-tenant deployments.
|
fm = notion_cfg["field_map"]
|
||||||
resolved_cfg = config_dir or Path(db_path).parent / "config"
|
blocklist = load_blocklist()
|
||||||
if not resolved_cfg.exists():
|
|
||||||
resolved_cfg = CONFIG_DIR
|
|
||||||
profiles_cfg, notion_cfg = load_config(resolved_cfg)
|
|
||||||
fm = notion_cfg.get("field_map") or {}
|
|
||||||
blocklist = load_blocklist(resolved_cfg)
|
|
||||||
|
|
||||||
_bl_summary = {k: len(v) for k, v in blocklist.items() if v}
|
_bl_summary = {k: len(v) for k, v in blocklist.items() if v}
|
||||||
if _bl_summary:
|
if _bl_summary:
|
||||||
|
|
@ -220,7 +211,7 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False, config_
|
||||||
try:
|
try:
|
||||||
jobspy_kwargs: dict = dict(
|
jobspy_kwargs: dict = dict(
|
||||||
site_name=boards,
|
site_name=boards,
|
||||||
search_term=" OR ".join(f'"{t}"' for t in (profile.get("titles") or profile.get("job_titles", []))),
|
search_term=" OR ".join(f'"{t}"' for t in profile["titles"]),
|
||||||
location=location,
|
location=location,
|
||||||
results_wanted=results_per_board,
|
results_wanted=results_per_board,
|
||||||
hours_old=profile.get("hours_old", 72),
|
hours_old=profile.get("hours_old", 72),
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,13 @@ LETTERS_DIR = _profile.docs_dir if _profile else Path.home() / "Documents" / "Jo
|
||||||
LETTER_GLOB = "*Cover Letter*.md"
|
LETTER_GLOB = "*Cover Letter*.md"
|
||||||
|
|
||||||
# Background injected into every prompt so the model has the candidate's facts
|
# Background injected into every prompt so the model has the candidate's facts
|
||||||
def _build_system_context(profile=None) -> str:
|
def _build_system_context() -> str:
|
||||||
p = profile or _profile
|
if not _profile:
|
||||||
if not p:
|
|
||||||
return "You are a professional cover letter writer. Write in first person."
|
return "You are a professional cover letter writer. Write in first person."
|
||||||
parts = [f"You are writing cover letters for {p.name}. {p.career_summary}"]
|
parts = [f"You are writing cover letters for {_profile.name}. {_profile.career_summary}"]
|
||||||
if p.candidate_voice:
|
if _profile.candidate_voice:
|
||||||
parts.append(
|
parts.append(
|
||||||
f"Voice and personality: {p.candidate_voice} "
|
f"Voice and personality: {_profile.candidate_voice} "
|
||||||
"Write in a way that reflects these authentic traits — not as a checklist, "
|
"Write in a way that reflects these authentic traits — not as a checklist, "
|
||||||
"but as a natural expression of who this person is."
|
"but as a natural expression of who this person is."
|
||||||
)
|
)
|
||||||
|
|
@ -126,17 +125,15 @@ _MISSION_DEFAULTS: dict[str, str] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_mission_notes(profile=None, candidate_name: str | None = None) -> dict[str, str]:
|
def _build_mission_notes() -> dict[str, str]:
|
||||||
"""Merge user's custom mission notes with generic defaults."""
|
"""Merge user's custom mission notes with generic defaults."""
|
||||||
p = profile or _profile
|
prefs = _profile.mission_preferences if _profile else {}
|
||||||
name = candidate_name or _candidate
|
|
||||||
prefs = p.mission_preferences if p else {}
|
|
||||||
notes = {}
|
notes = {}
|
||||||
for industry, default_note in _MISSION_DEFAULTS.items():
|
for industry, default_note in _MISSION_DEFAULTS.items():
|
||||||
custom = (prefs.get(industry) or "").strip()
|
custom = (prefs.get(industry) or "").strip()
|
||||||
if custom:
|
if custom:
|
||||||
notes[industry] = (
|
notes[industry] = (
|
||||||
f"Mission alignment — {name} shared: \"{custom}\". "
|
f"Mission alignment — {_candidate} shared: \"{custom}\". "
|
||||||
"Para 3 should warmly and specifically reflect this authentic connection."
|
"Para 3 should warmly and specifically reflect this authentic connection."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|
@ -147,15 +144,12 @@ def _build_mission_notes(profile=None, candidate_name: str | None = None) -> dic
|
||||||
_MISSION_NOTES = _build_mission_notes()
|
_MISSION_NOTES = _build_mission_notes()
|
||||||
|
|
||||||
|
|
||||||
def detect_mission_alignment(
|
def detect_mission_alignment(company: str, description: str) -> str | None:
|
||||||
company: str, description: str, mission_notes: dict | None = None
|
|
||||||
) -> str | None:
|
|
||||||
"""Return a mission hint string if company/JD matches a preferred industry, else None."""
|
"""Return a mission hint string if company/JD matches a preferred industry, else None."""
|
||||||
notes = mission_notes if mission_notes is not None else _MISSION_NOTES
|
|
||||||
text = f"{company} {description}".lower()
|
text = f"{company} {description}".lower()
|
||||||
for industry, signals in _MISSION_SIGNALS.items():
|
for industry, signals in _MISSION_SIGNALS.items():
|
||||||
if any(sig in text for sig in signals):
|
if any(sig in text for sig in signals):
|
||||||
return notes[industry]
|
return _MISSION_NOTES[industry]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -196,14 +190,10 @@ def build_prompt(
|
||||||
examples: list[dict],
|
examples: list[dict],
|
||||||
mission_hint: str | None = None,
|
mission_hint: str | None = None,
|
||||||
is_jobgether: bool = False,
|
is_jobgether: bool = False,
|
||||||
system_context: str | None = None,
|
|
||||||
candidate_name: str | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
ctx = system_context if system_context is not None else SYSTEM_CONTEXT
|
parts = [SYSTEM_CONTEXT.strip(), ""]
|
||||||
name = candidate_name or _candidate
|
|
||||||
parts = [ctx.strip(), ""]
|
|
||||||
if examples:
|
if examples:
|
||||||
parts.append(f"=== STYLE EXAMPLES ({name}'s past letters) ===\n")
|
parts.append(f"=== STYLE EXAMPLES ({_candidate}'s past letters) ===\n")
|
||||||
for i, ex in enumerate(examples, 1):
|
for i, ex in enumerate(examples, 1):
|
||||||
parts.append(f"--- Example {i} ({ex['company']}) ---")
|
parts.append(f"--- Example {i} ({ex['company']}) ---")
|
||||||
parts.append(ex["text"])
|
parts.append(ex["text"])
|
||||||
|
|
@ -241,14 +231,13 @@ def build_prompt(
|
||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def _trim_to_letter_end(text: str, profile=None) -> str:
|
def _trim_to_letter_end(text: str) -> str:
|
||||||
"""Remove repetitive hallucinated content after the first complete sign-off.
|
"""Remove repetitive hallucinated content after the first complete sign-off.
|
||||||
|
|
||||||
Fine-tuned models sometimes loop after completing the letter. This cuts at
|
Fine-tuned models sometimes loop after completing the letter. This cuts at
|
||||||
the first closing + candidate name so only the intended letter is saved.
|
the first closing + candidate name so only the intended letter is saved.
|
||||||
"""
|
"""
|
||||||
p = profile or _profile
|
candidate_first = (_profile.name.split()[0] if _profile else "").strip()
|
||||||
candidate_first = (p.name.split()[0] if p else "").strip()
|
|
||||||
pattern = (
|
pattern = (
|
||||||
r'(?:Warm regards|Sincerely|Best regards|Kind regards|Thank you)[,.]?\s*\n+\s*'
|
r'(?:Warm regards|Sincerely|Best regards|Kind regards|Thank you)[,.]?\s*\n+\s*'
|
||||||
+ (re.escape(candidate_first) if candidate_first else r'\w+(?:\s+\w+)?')
|
+ (re.escape(candidate_first) if candidate_first else r'\w+(?:\s+\w+)?')
|
||||||
|
|
@ -268,8 +257,6 @@ def generate(
|
||||||
feedback: str = "",
|
feedback: str = "",
|
||||||
is_jobgether: bool = False,
|
is_jobgether: bool = False,
|
||||||
_router=None,
|
_router=None,
|
||||||
config_path: "Path | None" = None,
|
|
||||||
user_yaml_path: "Path | None" = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate a cover letter and return it as a string.
|
"""Generate a cover letter and return it as a string.
|
||||||
|
|
||||||
|
|
@ -277,29 +264,15 @@ def generate(
|
||||||
and requested changes are appended to the prompt so the LLM revises rather
|
and requested changes are appended to the prompt so the LLM revises rather
|
||||||
than starting from scratch.
|
than starting from scratch.
|
||||||
|
|
||||||
user_yaml_path overrides the module-level profile — required in cloud mode
|
|
||||||
so each user's name/voice/mission prefs are used instead of the global default.
|
|
||||||
|
|
||||||
_router is an optional pre-built LLMRouter (used in tests to avoid real LLM calls).
|
_router is an optional pre-built LLMRouter (used in tests to avoid real LLM calls).
|
||||||
"""
|
"""
|
||||||
# Per-call profile override (cloud mode: each user has their own user.yaml)
|
|
||||||
if user_yaml_path and Path(user_yaml_path).exists():
|
|
||||||
_prof = UserProfile(Path(user_yaml_path))
|
|
||||||
else:
|
|
||||||
_prof = _profile
|
|
||||||
|
|
||||||
sys_ctx = _build_system_context(_prof)
|
|
||||||
mission_notes = _build_mission_notes(_prof, candidate_name=(_prof.name if _prof else None))
|
|
||||||
candidate_name = _prof.name if _prof else _candidate
|
|
||||||
|
|
||||||
corpus = load_corpus()
|
corpus = load_corpus()
|
||||||
examples = find_similar_letters(description or f"{title} {company}", corpus)
|
examples = find_similar_letters(description or f"{title} {company}", corpus)
|
||||||
mission_hint = detect_mission_alignment(company, description, mission_notes=mission_notes)
|
mission_hint = detect_mission_alignment(company, description)
|
||||||
if mission_hint:
|
if mission_hint:
|
||||||
print(f"[cover-letter] Mission alignment detected for {company}", file=sys.stderr)
|
print(f"[cover-letter] Mission alignment detected for {company}", file=sys.stderr)
|
||||||
prompt = build_prompt(title, company, description, examples,
|
prompt = build_prompt(title, company, description, examples,
|
||||||
mission_hint=mission_hint, is_jobgether=is_jobgether,
|
mission_hint=mission_hint, is_jobgether=is_jobgether)
|
||||||
system_context=sys_ctx, candidate_name=candidate_name)
|
|
||||||
|
|
||||||
if previous_result:
|
if previous_result:
|
||||||
prompt += f"\n\n---\nPrevious draft:\n{previous_result}"
|
prompt += f"\n\n---\nPrevious draft:\n{previous_result}"
|
||||||
|
|
@ -308,9 +281,8 @@ def generate(
|
||||||
|
|
||||||
if _router is None:
|
if _router is None:
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
from scripts.llm_router import LLMRouter, CONFIG_PATH
|
from scripts.llm_router import LLMRouter
|
||||||
resolved = config_path if (config_path and Path(config_path).exists()) else CONFIG_PATH
|
_router = LLMRouter()
|
||||||
_router = LLMRouter(resolved)
|
|
||||||
|
|
||||||
print(f"[cover-letter] Generating for: {title} @ {company}", file=sys.stderr)
|
print(f"[cover-letter] Generating for: {title} @ {company}", file=sys.stderr)
|
||||||
print(f"[cover-letter] Style examples: {[e['company'] for e in examples]}", file=sys.stderr)
|
print(f"[cover-letter] Style examples: {[e['company'] for e in examples]}", file=sys.stderr)
|
||||||
|
|
@ -320,7 +292,7 @@ def generate(
|
||||||
# max_tokens=1200 caps generation at ~900 words — enough for any cover letter
|
# max_tokens=1200 caps generation at ~900 words — enough for any cover letter
|
||||||
# and prevents fine-tuned models from looping into repetitive garbage output.
|
# and prevents fine-tuned models from looping into repetitive garbage output.
|
||||||
result = _router.complete(prompt, max_tokens=1200)
|
result = _router.complete(prompt, max_tokens=1200)
|
||||||
return _trim_to_letter_end(result, _prof)
|
return _trim_to_letter_end(result)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,313 +0,0 @@
|
||||||
"""Job ranking engine — two-stage discovery → review pipeline.
|
|
||||||
|
|
||||||
Stage 1 (discover.py) scrapes a wide corpus and stores everything as 'pending'.
|
|
||||||
Stage 2 (this module) scores the corpus; GET /api/jobs/stack returns top-N best
|
|
||||||
matches for the user's current review session.
|
|
||||||
|
|
||||||
All signal functions return a float in [0, 1]. The final stack_score is 0–100.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
from scripts.job_ranker import rank_jobs
|
|
||||||
ranked = rank_jobs(jobs, search_titles, salary_min, salary_max, user_level)
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
|
||||||
import re
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
|
|
||||||
# ── TUNING ─────────────────────────────────────────────────────────────────────
|
|
||||||
# Adjust these constants to change how jobs are ranked.
|
|
||||||
# All individual signal scores are normalised to [0, 1] before weighting.
|
|
||||||
# Weights should sum to ≤ 1.0; the remainder is unallocated slack.
|
|
||||||
|
|
||||||
W_RESUME_MATCH = 0.40 # TF-IDF cosine similarity stored as match_score (0–100 → 0–1)
|
|
||||||
W_TITLE_MATCH = 0.30 # seniority-aware title + domain keyword overlap
|
|
||||||
W_RECENCY = 0.15 # freshness — exponential decay from date_found
|
|
||||||
W_SALARY_FIT = 0.10 # salary range overlap vs user target (neutral when unknown)
|
|
||||||
W_DESC_QUALITY = 0.05 # posting completeness — penalises stub / ghost posts
|
|
||||||
|
|
||||||
# Keyword gap penalty: each missing keyword from the resume match costs points.
|
|
||||||
# Gaps are already partially captured by W_RESUME_MATCH (same TF-IDF source),
|
|
||||||
# so this is a soft nudge, not a hard filter.
|
|
||||||
GAP_PENALTY_PER_KEYWORD: float = 0.5 # points off per gap keyword (0–100 scale)
|
|
||||||
GAP_MAX_PENALTY: float = 5.0 # hard cap so a gap-heavy job can still rank
|
|
||||||
|
|
||||||
# Recency half-life: score halves every N days past date_found
|
|
||||||
RECENCY_HALF_LIFE: int = 7 # days
|
|
||||||
|
|
||||||
# Description word-count thresholds
|
|
||||||
DESC_MIN_WORDS: int = 50 # below this → scaled penalty
|
|
||||||
DESC_TARGET_WORDS: int = 200 # at or above → full quality score
|
|
||||||
# ── END TUNING ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
# ── Seniority level map ────────────────────────────────────────────────────────
|
|
||||||
# (level, [keyword substrings that identify that level])
|
|
||||||
# Matched on " <lower_title> " with a space-padded check to avoid false hits.
|
|
||||||
# Level 3 is the default (mid-level, no seniority modifier in title).
|
|
||||||
_SENIORITY_MAP: list[tuple[int, list[str]]] = [
|
|
||||||
(1, ["intern", "internship", "trainee", "apprentice", "co-op", "coop"]),
|
|
||||||
(2, ["entry level", "entry-level", "junior", "jr ", "jr.", "associate "]),
|
|
||||||
(3, ["mid level", "mid-level", "intermediate"]),
|
|
||||||
(4, ["senior ", "senior,", "sr ", "sr.", " lead ", "lead,", " ii ", " iii ",
|
|
||||||
"specialist", "experienced"]),
|
|
||||||
(5, ["staff ", "principal ", "architect ", "expert ", "distinguished"]),
|
|
||||||
(6, ["director", "head of ", "manager ", "vice president", " vp "]),
|
|
||||||
(7, ["chief", "cto", "cio", "cpo", "president", "founder"]),
|
|
||||||
]
|
|
||||||
|
|
||||||
# job_level − user_level → scoring multiplier
|
|
||||||
# Positive delta = job is more senior (stretch up = encouraged)
|
|
||||||
# Negative delta = job is below the user's level
|
|
||||||
_LEVEL_MULTIPLIER: dict[int, float] = {
|
|
||||||
-4: 0.05, -3: 0.10, -2: 0.25, -1: 0.65,
|
|
||||||
0: 1.00,
|
|
||||||
1: 0.90, 2: 0.65, 3: 0.25, 4: 0.05,
|
|
||||||
}
|
|
||||||
_DEFAULT_LEVEL_MULTIPLIER = 0.05
|
|
||||||
|
|
||||||
|
|
||||||
# ── Seniority helpers ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def infer_seniority(title: str) -> int:
|
|
||||||
"""Return seniority level 1–7 for a job or resume title. Defaults to 3."""
|
|
||||||
padded = f" {title.lower()} "
|
|
||||||
# Iterate highest → lowest so "Senior Lead" resolves to 4, not 6
|
|
||||||
for level, keywords in reversed(_SENIORITY_MAP):
|
|
||||||
for kw in keywords:
|
|
||||||
if kw in padded:
|
|
||||||
return level
|
|
||||||
return 3
|
|
||||||
|
|
||||||
|
|
||||||
def seniority_from_experience(titles: list[str]) -> int:
|
|
||||||
"""Estimate user's current level from their most recent experience titles.
|
|
||||||
|
|
||||||
Averages the levels of the top-3 most recent titles (first in the list).
|
|
||||||
Falls back to 3 (mid-level) if no titles are provided.
|
|
||||||
"""
|
|
||||||
if not titles:
|
|
||||||
return 3
|
|
||||||
sample = [t for t in titles if t.strip()][:3]
|
|
||||||
if not sample:
|
|
||||||
return 3
|
|
||||||
levels = [infer_seniority(t) for t in sample]
|
|
||||||
return round(sum(levels) / len(levels))
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_level_words(text: str) -> str:
|
|
||||||
"""Remove seniority/modifier words so domain keywords stand out."""
|
|
||||||
strip = {
|
|
||||||
"senior", "sr", "junior", "jr", "lead", "staff", "principal",
|
|
||||||
"associate", "entry", "mid", "intermediate", "experienced",
|
|
||||||
"director", "head", "manager", "architect", "chief", "intern",
|
|
||||||
"ii", "iii", "iv", "i",
|
|
||||||
}
|
|
||||||
return " ".join(w for w in text.lower().split() if w not in strip)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Signal functions ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def title_match_score(job_title: str, search_titles: list[str], user_level: int) -> float:
|
|
||||||
"""Seniority-aware title similarity in [0, 1].
|
|
||||||
|
|
||||||
Combines:
|
|
||||||
- Domain overlap: keyword intersection between job title and search titles
|
|
||||||
after stripping level modifiers (so "Senior Software Engineer" vs
|
|
||||||
"Software Engineer" compares only on "software engineer").
|
|
||||||
- Seniority multiplier: rewards same-level and +1 stretch; penalises
|
|
||||||
large downgrade or unreachable stretch.
|
|
||||||
"""
|
|
||||||
if not search_titles:
|
|
||||||
return 0.5 # neutral — user hasn't set title prefs yet
|
|
||||||
|
|
||||||
job_level = infer_seniority(job_title)
|
|
||||||
level_delta = job_level - user_level
|
|
||||||
seniority_factor = _LEVEL_MULTIPLIER.get(level_delta, _DEFAULT_LEVEL_MULTIPLIER)
|
|
||||||
|
|
||||||
job_core_words = {w for w in _strip_level_words(job_title).split() if len(w) > 2}
|
|
||||||
|
|
||||||
best_domain = 0.0
|
|
||||||
for st in search_titles:
|
|
||||||
st_core_words = {w for w in _strip_level_words(st).split() if len(w) > 2}
|
|
||||||
if not st_core_words:
|
|
||||||
continue
|
|
||||||
# Recall-biased overlap: what fraction of the search title keywords
|
|
||||||
# appear in the job title? (A job posting may use synonyms but we
|
|
||||||
# at least want the core nouns to match.)
|
|
||||||
overlap = len(st_core_words & job_core_words) / len(st_core_words)
|
|
||||||
best_domain = max(best_domain, overlap)
|
|
||||||
|
|
||||||
# Base score from domain match scaled by seniority appropriateness.
|
|
||||||
# A small seniority_factor bonus (×0.2) ensures that even a near-miss
|
|
||||||
# domain match still benefits from seniority alignment.
|
|
||||||
return min(1.0, best_domain * seniority_factor + seniority_factor * 0.15)
|
|
||||||
|
|
||||||
|
|
||||||
def recency_decay(date_found: str) -> float:
|
|
||||||
"""Exponential decay starting from date_found.
|
|
||||||
|
|
||||||
Returns 1.0 for today, 0.5 after RECENCY_HALF_LIFE days, ~0.0 after ~4×.
|
|
||||||
Returns 0.5 (neutral) if the date is unparseable.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Support both "YYYY-MM-DD" and "YYYY-MM-DD HH:MM:SS"
|
|
||||||
found = datetime.fromisoformat(date_found.split("T")[0].split(" ")[0])
|
|
||||||
found = found.replace(tzinfo=timezone.utc)
|
|
||||||
now = datetime.now(tz=timezone.utc)
|
|
||||||
days_old = max(0.0, (now - found).total_seconds() / 86400)
|
|
||||||
return math.exp(-math.log(2) * days_old / RECENCY_HALF_LIFE)
|
|
||||||
except Exception:
|
|
||||||
return 0.5
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_salary_range(text: str | None) -> tuple[int | None, int | None]:
|
|
||||||
"""Extract (low, high) salary integers from free-text. Returns (None, None) on failure.
|
|
||||||
|
|
||||||
Handles: "$80k - $120k", "USD 80,000 - 120,000 per year", "£45,000",
|
|
||||||
"80000", "80K/yr", "80-120k", etc.
|
|
||||||
"""
|
|
||||||
if not text:
|
|
||||||
return None, None
|
|
||||||
normalized = re.sub(r"[$,£€₹¥\s]", "", text.lower())
|
|
||||||
# Match numbers optionally followed by 'k'
|
|
||||||
raw_nums = re.findall(r"(\d+(?:\.\d+)?)k?", normalized)
|
|
||||||
values = []
|
|
||||||
for n, full in zip(raw_nums, re.finditer(r"(\d+(?:\.\d+)?)(k?)", normalized)):
|
|
||||||
val = float(full.group(1))
|
|
||||||
if full.group(2): # ends with 'k'
|
|
||||||
val *= 1000
|
|
||||||
elif val < 1000: # bare numbers < 1000 are likely thousands (e.g., "80" in "80-120k")
|
|
||||||
val *= 1000
|
|
||||||
if val >= 10_000: # sanity: ignore clearly wrong values
|
|
||||||
values.append(int(val))
|
|
||||||
values = sorted(set(values))
|
|
||||||
if not values:
|
|
||||||
return None, None
|
|
||||||
return values[0], values[-1]
|
|
||||||
|
|
||||||
|
|
||||||
def salary_fit(
|
|
||||||
salary_text: str | None,
|
|
||||||
target_min: int | None,
|
|
||||||
target_max: int | None,
|
|
||||||
) -> float:
|
|
||||||
"""Salary range overlap score in [0, 1].
|
|
||||||
|
|
||||||
Returns 0.5 (neutral) when either range is unknown — a missing salary
|
|
||||||
line is not inherently negative.
|
|
||||||
"""
|
|
||||||
if not salary_text or (target_min is None and target_max is None):
|
|
||||||
return 0.5
|
|
||||||
|
|
||||||
job_low, job_high = _parse_salary_range(salary_text)
|
|
||||||
if job_low is None:
|
|
||||||
return 0.5
|
|
||||||
|
|
||||||
t_min = target_min or 0
|
|
||||||
t_max = target_max or (int(target_min * 1.5) if target_min else job_high or job_low)
|
|
||||||
job_high = job_high or job_low
|
|
||||||
|
|
||||||
overlap_low = max(job_low, t_min)
|
|
||||||
overlap_high = min(job_high, t_max)
|
|
||||||
overlap = max(0, overlap_high - overlap_low)
|
|
||||||
target_span = max(1, t_max - t_min)
|
|
||||||
return min(1.0, overlap / target_span)
|
|
||||||
|
|
||||||
|
|
||||||
def description_quality(description: str | None) -> float:
|
|
||||||
"""Posting completeness score in [0, 1].
|
|
||||||
|
|
||||||
Stubs and ghost posts score near 0; well-written descriptions score 1.0.
|
|
||||||
"""
|
|
||||||
if not description:
|
|
||||||
return 0.0
|
|
||||||
words = len(description.split())
|
|
||||||
if words < DESC_MIN_WORDS:
|
|
||||||
return (words / DESC_MIN_WORDS) * 0.4 # steep penalty for stubs
|
|
||||||
if words >= DESC_TARGET_WORDS:
|
|
||||||
return 1.0
|
|
||||||
return 0.4 + 0.6 * (words - DESC_MIN_WORDS) / (DESC_TARGET_WORDS - DESC_MIN_WORDS)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Composite scorer ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def score_job(
|
|
||||||
job: dict,
|
|
||||||
search_titles: list[str],
|
|
||||||
target_salary_min: int | None,
|
|
||||||
target_salary_max: int | None,
|
|
||||||
user_level: int,
|
|
||||||
) -> float:
|
|
||||||
"""Compute composite stack_score (0–100) for a single job dict.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
job: Row dict from the jobs table (must have title, match_score,
|
|
||||||
date_found, salary, description, keyword_gaps).
|
|
||||||
search_titles: User's desired job titles (from search prefs).
|
|
||||||
target_salary_*: User's salary target from resume profile (or None).
|
|
||||||
user_level: Inferred seniority level 1–7.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A float 0–100. Higher = better match for this user's session.
|
|
||||||
"""
|
|
||||||
# ── Individual signals (all 0–1) ──────────────────────────────────────────
|
|
||||||
match_raw = job.get("match_score")
|
|
||||||
s_resume = (match_raw / 100.0) if match_raw is not None else 0.5
|
|
||||||
|
|
||||||
s_title = title_match_score(job.get("title", ""), search_titles, user_level)
|
|
||||||
s_recency = recency_decay(job.get("date_found", ""))
|
|
||||||
s_salary = salary_fit(job.get("salary"), target_salary_min, target_salary_max)
|
|
||||||
s_desc = description_quality(job.get("description"))
|
|
||||||
|
|
||||||
# ── Weighted sum ──────────────────────────────────────────────────────────
|
|
||||||
base = (
|
|
||||||
W_RESUME_MATCH * s_resume
|
|
||||||
+ W_TITLE_MATCH * s_title
|
|
||||||
+ W_RECENCY * s_recency
|
|
||||||
+ W_SALARY_FIT * s_salary
|
|
||||||
+ W_DESC_QUALITY * s_desc
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Keyword gap penalty (applied on the 0–100 scale) ─────────────────────
|
|
||||||
gaps_raw = job.get("keyword_gaps") or ""
|
|
||||||
gap_count = len([g for g in gaps_raw.split(",") if g.strip()]) if gaps_raw else 0
|
|
||||||
gap_penalty = min(GAP_MAX_PENALTY, gap_count * GAP_PENALTY_PER_KEYWORD) / 100.0
|
|
||||||
|
|
||||||
return round(max(0.0, base - gap_penalty) * 100, 1)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Public API ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def rank_jobs(
|
|
||||||
jobs: list[dict],
|
|
||||||
search_titles: list[str],
|
|
||||||
target_salary_min: int | None = None,
|
|
||||||
target_salary_max: int | None = None,
|
|
||||||
user_level: int = 3,
|
|
||||||
limit: int = 10,
|
|
||||||
min_score: float = 20.0,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Score and rank pending jobs; return top-N above min_score.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
jobs: List of job dicts (from DB or any source).
|
|
||||||
search_titles: User's desired job titles from search prefs.
|
|
||||||
target_salary_*: User's salary target (from resume profile).
|
|
||||||
user_level: Seniority level 1–7 (use seniority_from_experience()).
|
|
||||||
limit: Stack size; pass 0 to return all qualifying jobs.
|
|
||||||
min_score: Minimum stack_score to include (0–100).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Sorted list (best first) with 'stack_score' key added to each dict.
|
|
||||||
"""
|
|
||||||
scored = []
|
|
||||||
for job in jobs:
|
|
||||||
s = score_job(job, search_titles, target_salary_min, target_salary_max, user_level)
|
|
||||||
if s >= min_score:
|
|
||||||
scored.append({**job, "stack_score": s})
|
|
||||||
|
|
||||||
scored.sort(key=lambda j: j["stack_score"], reverse=True)
|
|
||||||
return scored[:limit] if limit > 0 else scored
|
|
||||||
|
|
@ -1,46 +1,169 @@
|
||||||
"""
|
"""
|
||||||
LLM abstraction layer with priority fallback chain.
|
LLM abstraction layer with priority fallback chain.
|
||||||
Config lookup order:
|
Reads config/llm.yaml. Tries backends in order; falls back on any error.
|
||||||
1. <repo>/config/llm.yaml — per-install local config
|
|
||||||
2. ~/.config/circuitforge/llm.yaml — user-level config (circuitforge-core default)
|
|
||||||
3. env-var auto-config (ANTHROPIC_API_KEY, OPENAI_API_KEY, OLLAMA_HOST, …)
|
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
import requests
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
from circuitforge_core.llm import LLMRouter as _CoreLLMRouter
|
|
||||||
|
|
||||||
# Kept for backwards-compatibility — external callers that import CONFIG_PATH
|
|
||||||
# from this module continue to work.
|
|
||||||
CONFIG_PATH = Path(__file__).parent.parent / "config" / "llm.yaml"
|
CONFIG_PATH = Path(__file__).parent.parent / "config" / "llm.yaml"
|
||||||
|
|
||||||
|
|
||||||
class LLMRouter(_CoreLLMRouter):
|
class LLMRouter:
|
||||||
"""Peregrine-specific LLMRouter — tri-level config path priority.
|
def __init__(self, config_path: Path = CONFIG_PATH):
|
||||||
|
with open(config_path) as f:
|
||||||
|
self.config = yaml.safe_load(f)
|
||||||
|
|
||||||
When ``config_path`` is supplied (e.g. in tests) it is passed straight
|
def _is_reachable(self, base_url: str) -> bool:
|
||||||
through to the core. When omitted, the lookup order is:
|
"""Quick health-check ping. Returns True if backend is up."""
|
||||||
1. <repo>/config/llm.yaml (per-install local config)
|
health_url = base_url.rstrip("/").removesuffix("/v1") + "/health"
|
||||||
2. ~/.config/circuitforge/llm.yaml (user-level, circuitforge-core default)
|
try:
|
||||||
3. env-var auto-config (ANTHROPIC_API_KEY, OPENAI_API_KEY, OLLAMA_HOST …)
|
resp = requests.get(health_url, timeout=2)
|
||||||
"""
|
return resp.status_code < 500
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
def __init__(self, config_path: Path | None = None) -> None:
|
def _resolve_model(self, client: OpenAI, model: str) -> str:
|
||||||
if config_path is not None:
|
"""Resolve __auto__ to the first model served by vLLM."""
|
||||||
# Explicit path supplied — use it directly (e.g. tests, CLI override).
|
if model != "__auto__":
|
||||||
super().__init__(config_path)
|
return model
|
||||||
return
|
models = client.models.list()
|
||||||
|
return models.data[0].id
|
||||||
|
|
||||||
local = Path(__file__).parent.parent / "config" / "llm.yaml"
|
def complete(self, prompt: str, system: str | None = None,
|
||||||
user_level = Path.home() / ".config" / "circuitforge" / "llm.yaml"
|
model_override: str | None = None,
|
||||||
if local.exists():
|
fallback_order: list[str] | None = None,
|
||||||
super().__init__(local)
|
images: list[str] | None = None,
|
||||||
elif user_level.exists():
|
max_tokens: int | None = None) -> str:
|
||||||
super().__init__(user_level)
|
"""
|
||||||
else:
|
Generate a completion. Tries each backend in fallback_order.
|
||||||
# No yaml found — let circuitforge-core's env-var auto-config run.
|
|
||||||
# The core default CONFIG_PATH (~/.config/circuitforge/llm.yaml)
|
model_override: when set, replaces the configured model for
|
||||||
# won't exist either, so _auto_config_from_env() will be triggered.
|
openai_compat backends (e.g. pass a research-specific ollama model).
|
||||||
super().__init__()
|
fallback_order: when set, overrides config fallback_order for this
|
||||||
|
call (e.g. pass config["research_fallback_order"] for research tasks).
|
||||||
|
images: optional list of base64-encoded PNG/JPG strings. When provided,
|
||||||
|
backends without supports_images=true are skipped. vision_service backends
|
||||||
|
are only tried when images is provided.
|
||||||
|
Raises RuntimeError if all backends are exhausted.
|
||||||
|
"""
|
||||||
|
if os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes"):
|
||||||
|
raise RuntimeError(
|
||||||
|
"AI inference is disabled in the public demo. "
|
||||||
|
"Run your own instance to use AI features."
|
||||||
|
)
|
||||||
|
order = fallback_order if fallback_order is not None else self.config["fallback_order"]
|
||||||
|
for name in order:
|
||||||
|
backend = self.config["backends"][name]
|
||||||
|
|
||||||
|
if not backend.get("enabled", True):
|
||||||
|
print(f"[LLMRouter] {name}: disabled, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
supports_images = backend.get("supports_images", False)
|
||||||
|
is_vision_service = backend["type"] == "vision_service"
|
||||||
|
|
||||||
|
# vision_service only used when images provided
|
||||||
|
if is_vision_service and not images:
|
||||||
|
print(f"[LLMRouter] {name}: vision_service skipped (no images)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# non-vision backends skipped when images provided and they don't support it
|
||||||
|
if images and not supports_images and not is_vision_service:
|
||||||
|
print(f"[LLMRouter] {name}: no image support, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if is_vision_service:
|
||||||
|
if not self._is_reachable(backend["base_url"]):
|
||||||
|
print(f"[LLMRouter] {name}: unreachable, skipping")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
backend["base_url"].rstrip("/") + "/analyze",
|
||||||
|
json={
|
||||||
|
"prompt": prompt,
|
||||||
|
"image_base64": images[0] if images else "",
|
||||||
|
},
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
print(f"[LLMRouter] Used backend: {name} (vision_service)")
|
||||||
|
return resp.json()["text"]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[LLMRouter] {name}: error — {e}, trying next")
|
||||||
|
continue
|
||||||
|
|
||||||
|
elif backend["type"] == "openai_compat":
|
||||||
|
if not self._is_reachable(backend["base_url"]):
|
||||||
|
print(f"[LLMRouter] {name}: unreachable, skipping")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
client = OpenAI(
|
||||||
|
base_url=backend["base_url"],
|
||||||
|
api_key=backend.get("api_key") or "any",
|
||||||
|
)
|
||||||
|
raw_model = model_override or backend["model"]
|
||||||
|
model = self._resolve_model(client, raw_model)
|
||||||
|
messages = []
|
||||||
|
if system:
|
||||||
|
messages.append({"role": "system", "content": system})
|
||||||
|
if images and supports_images:
|
||||||
|
content = [{"type": "text", "text": prompt}]
|
||||||
|
for img in images:
|
||||||
|
content.append({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": f"data:image/png;base64,{img}"},
|
||||||
|
})
|
||||||
|
messages.append({"role": "user", "content": content})
|
||||||
|
else:
|
||||||
|
messages.append({"role": "user", "content": prompt})
|
||||||
|
|
||||||
|
create_kwargs: dict = {"model": model, "messages": messages}
|
||||||
|
if max_tokens is not None:
|
||||||
|
create_kwargs["max_tokens"] = max_tokens
|
||||||
|
resp = client.chat.completions.create(**create_kwargs)
|
||||||
|
print(f"[LLMRouter] Used backend: {name} ({model})")
|
||||||
|
return resp.choices[0].message.content
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[LLMRouter] {name}: error — {e}, trying next")
|
||||||
|
continue
|
||||||
|
|
||||||
|
elif backend["type"] == "anthropic":
|
||||||
|
api_key = os.environ.get(backend["api_key_env"], "")
|
||||||
|
if not api_key:
|
||||||
|
print(f"[LLMRouter] {name}: {backend['api_key_env']} not set, skipping")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
import anthropic as _anthropic
|
||||||
|
client = _anthropic.Anthropic(api_key=api_key)
|
||||||
|
if images and supports_images:
|
||||||
|
content = []
|
||||||
|
for img in images:
|
||||||
|
content.append({
|
||||||
|
"type": "image",
|
||||||
|
"source": {"type": "base64", "media_type": "image/png", "data": img},
|
||||||
|
})
|
||||||
|
content.append({"type": "text", "text": prompt})
|
||||||
|
else:
|
||||||
|
content = prompt
|
||||||
|
kwargs: dict = {
|
||||||
|
"model": backend["model"],
|
||||||
|
"max_tokens": 4096,
|
||||||
|
"messages": [{"role": "user", "content": content}],
|
||||||
|
}
|
||||||
|
if system:
|
||||||
|
kwargs["system"] = system
|
||||||
|
msg = client.messages.create(**kwargs)
|
||||||
|
print(f"[LLMRouter] Used backend: {name}")
|
||||||
|
return msg.content[0].text
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[LLMRouter] {name}: error — {e}, trying next")
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise RuntimeError("All LLM backends exhausted")
|
||||||
|
|
||||||
|
|
||||||
# Module-level singleton for convenience
|
# Module-level singleton for convenience
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ OVERRIDE_YML = ROOT / "compose.override.yml"
|
||||||
_SERVICES: dict[str, tuple[str, int, str, bool, bool]] = {
|
_SERVICES: dict[str, tuple[str, int, str, bool, bool]] = {
|
||||||
"streamlit": ("streamlit_port", 8501, "STREAMLIT_PORT", True, False),
|
"streamlit": ("streamlit_port", 8501, "STREAMLIT_PORT", True, False),
|
||||||
"searxng": ("searxng_port", 8888, "SEARXNG_PORT", True, True),
|
"searxng": ("searxng_port", 8888, "SEARXNG_PORT", True, True),
|
||||||
# vllm removed — now managed by cf-orch (host process), not a Docker service
|
"vllm": ("vllm_port", 8000, "VLLM_PORT", True, True),
|
||||||
"vision": ("vision_port", 8002, "VISION_PORT", True, True),
|
"vision": ("vision_port", 8002, "VISION_PORT", True, True),
|
||||||
"ollama": ("ollama_port", 11434, "OLLAMA_PORT", True, True),
|
"ollama": ("ollama_port", 11434, "OLLAMA_PORT", True, True),
|
||||||
"ollama_research": ("ollama_research_port", 11435, "OLLAMA_RESEARCH_PORT", True, True),
|
"ollama_research": ("ollama_research_port", 11435, "OLLAMA_RESEARCH_PORT", True, True),
|
||||||
|
|
@ -65,6 +65,7 @@ _LLM_BACKENDS: dict[str, list[tuple[str, str]]] = {
|
||||||
_DOCKER_INTERNAL: dict[str, tuple[str, int]] = {
|
_DOCKER_INTERNAL: dict[str, tuple[str, int]] = {
|
||||||
"ollama": ("ollama", 11434),
|
"ollama": ("ollama", 11434),
|
||||||
"ollama_research": ("ollama_research", 11434), # container-internal port is always 11434
|
"ollama_research": ("ollama_research", 11434), # container-internal port is always 11434
|
||||||
|
"vllm": ("vllm", 8000),
|
||||||
"vision": ("vision", 8002),
|
"vision": ("vision", 8002),
|
||||||
"searxng": ("searxng", 8080), # searxng internal port differs from host port
|
"searxng": ("searxng", 8080), # searxng internal port differs from host port
|
||||||
}
|
}
|
||||||
|
|
@ -492,12 +493,6 @@ def main() -> None:
|
||||||
# binds a harmless free port instead of conflicting with the external service.
|
# binds a harmless free port instead of conflicting with the external service.
|
||||||
env_updates: dict[str, str] = {i["env_var"]: str(i["stub_port"]) for i in ports.values()}
|
env_updates: dict[str, str] = {i["env_var"]: str(i["stub_port"]) for i in ports.values()}
|
||||||
env_updates["RECOMMENDED_PROFILE"] = profile
|
env_updates["RECOMMENDED_PROFILE"] = profile
|
||||||
# When Ollama is adopted from the host process, write OLLAMA_HOST so
|
|
||||||
# LLMRouter's env-var auto-config finds it without needing config/llm.yaml.
|
|
||||||
ollama_info = ports.get("ollama")
|
|
||||||
if ollama_info and ollama_info.get("external"):
|
|
||||||
env_updates["OLLAMA_HOST"] = f"http://host.docker.internal:{ollama_info['resolved']}"
|
|
||||||
|
|
||||||
if offload_gb > 0:
|
if offload_gb > 0:
|
||||||
env_updates["CPU_OFFLOAD_GB"] = str(offload_gb)
|
env_updates["CPU_OFFLOAD_GB"] = str(offload_gb)
|
||||||
# GPU info for the app container (which lacks nvidia-smi access)
|
# GPU info for the app container (which lacks nvidia-smi access)
|
||||||
|
|
|
||||||
|
|
@ -1,439 +0,0 @@
|
||||||
"""
|
|
||||||
ATS Resume Optimizer — rewrite a candidate's resume to maximize keyword match
|
|
||||||
for a specific job description without fabricating experience.
|
|
||||||
|
|
||||||
Tier behaviour:
|
|
||||||
Free → gap report only (extract_jd_signals + prioritize_gaps, no LLM rewrite)
|
|
||||||
Paid → full LLM rewrite targeting the JD (rewrite_for_ats)
|
|
||||||
Premium → same as paid for now; fine-tuned voice model is a future enhancement
|
|
||||||
|
|
||||||
Pipeline:
|
|
||||||
job.description
|
|
||||||
→ extract_jd_signals() # TF-IDF gaps + LLM-extracted ATS signals
|
|
||||||
→ prioritize_gaps() # rank by impact, map to resume sections
|
|
||||||
→ rewrite_for_ats() # per-section LLM rewrite (paid+)
|
|
||||||
→ hallucination_check() # reject rewrites that invent new experience
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ── Signal extraction ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def extract_jd_signals(description: str, resume_text: str = "") -> list[str]:
|
|
||||||
"""Return ATS keyword signals from a job description.
|
|
||||||
|
|
||||||
Combines two sources:
|
|
||||||
1. TF-IDF keyword gaps from match.py (fast, deterministic, no LLM cost)
|
|
||||||
2. LLM extraction for phrasing nuance TF-IDF misses (e.g. "cross-functional"
|
|
||||||
vs "cross-team", "led" vs "managed")
|
|
||||||
|
|
||||||
Falls back to TF-IDF-only if LLM is unavailable.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
description: Raw job description text.
|
|
||||||
resume_text: Candidate's resume text (used to compute gap vs. already present).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Deduplicated list of ATS keyword signals, most impactful first.
|
|
||||||
"""
|
|
||||||
# Phase 1: deterministic TF-IDF gaps (always available)
|
|
||||||
tfidf_gaps: list[str] = []
|
|
||||||
if resume_text:
|
|
||||||
try:
|
|
||||||
from scripts.match import match_score
|
|
||||||
_, tfidf_gaps = match_score(resume_text, description)
|
|
||||||
except Exception:
|
|
||||||
log.warning("[resume_optimizer] TF-IDF gap extraction failed", exc_info=True)
|
|
||||||
|
|
||||||
# Phase 2: LLM extraction for phrasing/qualifier nuance
|
|
||||||
llm_signals: list[str] = []
|
|
||||||
try:
|
|
||||||
from scripts.llm_router import LLMRouter
|
|
||||||
prompt = (
|
|
||||||
"Extract the most important ATS (applicant tracking system) keywords and "
|
|
||||||
"phrases from this job description. Focus on:\n"
|
|
||||||
"- Required skills and technologies (exact phrasing matters)\n"
|
|
||||||
"- Action verbs used to describe responsibilities\n"
|
|
||||||
"- Qualification signals ('required', 'must have', 'preferred')\n"
|
|
||||||
"- Industry-specific terminology\n\n"
|
|
||||||
"Return a JSON array of strings only. No explanation.\n\n"
|
|
||||||
f"Job description:\n{description[:3000]}"
|
|
||||||
)
|
|
||||||
raw = LLMRouter().complete(prompt)
|
|
||||||
# Extract JSON array from response (LLM may wrap it in markdown)
|
|
||||||
match = re.search(r"\[.*\]", raw, re.DOTALL)
|
|
||||||
if match:
|
|
||||||
llm_signals = json.loads(match.group(0))
|
|
||||||
llm_signals = [s.strip() for s in llm_signals if isinstance(s, str) and s.strip()]
|
|
||||||
except Exception:
|
|
||||||
log.warning("[resume_optimizer] LLM signal extraction failed", exc_info=True)
|
|
||||||
|
|
||||||
# Merge: LLM signals first (richer phrasing), TF-IDF fills gaps
|
|
||||||
seen: set[str] = set()
|
|
||||||
merged: list[str] = []
|
|
||||||
for term in llm_signals + tfidf_gaps:
|
|
||||||
key = term.lower()
|
|
||||||
if key not in seen:
|
|
||||||
seen.add(key)
|
|
||||||
merged.append(term)
|
|
||||||
|
|
||||||
return merged
|
|
||||||
|
|
||||||
|
|
||||||
# ── Gap prioritization ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# Map each gap term to the resume section where it would have the most ATS impact.
|
|
||||||
# ATS systems weight keywords higher in certain sections:
|
|
||||||
# skills — direct keyword match, highest density, indexed first
|
|
||||||
# summary — executive summary keywords often boost overall relevance score
|
|
||||||
# experience — verbs + outcomes in bullet points; adds context weight
|
|
||||||
_SECTION_KEYWORDS: dict[str, list[str]] = {
|
|
||||||
"skills": [
|
|
||||||
"python", "sql", "java", "typescript", "react", "vue", "docker",
|
|
||||||
"kubernetes", "aws", "gcp", "azure", "terraform", "ci/cd", "git",
|
|
||||||
"postgresql", "redis", "kafka", "spark", "tableau", "salesforce",
|
|
||||||
"jira", "figma", "excel", "powerpoint", "machine learning", "llm",
|
|
||||||
"deep learning", "pytorch", "tensorflow", "scikit-learn",
|
|
||||||
],
|
|
||||||
"summary": [
|
|
||||||
"leadership", "strategy", "vision", "executive", "director", "vp",
|
|
||||||
"growth", "transformation", "stakeholder", "cross-functional",
|
|
||||||
"p&l", "revenue", "budget", "board", "c-suite",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def prioritize_gaps(gaps: list[str], resume_sections: dict[str, Any]) -> list[dict]:
|
|
||||||
"""Rank keyword gaps by ATS impact and map each to a target resume section.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
gaps: List of missing keyword signals from extract_jd_signals().
|
|
||||||
resume_sections: Structured resume dict from resume_parser.parse_resume().
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of dicts, sorted by priority score descending:
|
|
||||||
{
|
|
||||||
"term": str, # the keyword/phrase to inject
|
|
||||||
"section": str, # target resume section ("skills", "summary", "experience")
|
|
||||||
"priority": int, # 1=high, 2=medium, 3=low
|
|
||||||
"rationale": str, # why this section was chosen
|
|
||||||
}
|
|
||||||
|
|
||||||
TODO: implement the ranking logic below.
|
|
||||||
The current stub assigns every gap to "experience" at medium priority.
|
|
||||||
A good implementation should:
|
|
||||||
- Score "skills" section terms highest (direct keyword density)
|
|
||||||
- Score "summary" terms next (executive/leadership signals)
|
|
||||||
- Route remaining gaps to "experience" bullets
|
|
||||||
- Deprioritize terms already present in any section (case-insensitive)
|
|
||||||
- Consider gap term length: multi-word phrases > single words (more specific = higher ATS weight)
|
|
||||||
"""
|
|
||||||
existing_text = _flatten_resume_text(resume_sections).lower()
|
|
||||||
|
|
||||||
prioritized: list[dict] = []
|
|
||||||
for term in gaps:
|
|
||||||
# Skip terms already present anywhere in the resume
|
|
||||||
if term.lower() in existing_text:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# REVIEW: _SECTION_KEYWORDS lists are tech-centric; domain-specific roles
|
|
||||||
# (creative, healthcare, operations) may over-route to experience.
|
|
||||||
# Consider expanding the lists or making them config-driven.
|
|
||||||
term_lower = term.lower()
|
|
||||||
|
|
||||||
# Partial-match: term contains a skills keyword (handles "PostgreSQL" vs "postgresql",
|
|
||||||
# "AWS Lambda" vs "aws", etc.)
|
|
||||||
skills_match = any(kw in term_lower or term_lower in kw
|
|
||||||
for kw in _SECTION_KEYWORDS["skills"])
|
|
||||||
summary_match = any(kw in term_lower or term_lower in kw
|
|
||||||
for kw in _SECTION_KEYWORDS["summary"])
|
|
||||||
|
|
||||||
if skills_match:
|
|
||||||
section = "skills"
|
|
||||||
priority = 1
|
|
||||||
rationale = "matched technical skills list — highest ATS keyword density"
|
|
||||||
elif summary_match:
|
|
||||||
section = "summary"
|
|
||||||
priority = 1
|
|
||||||
rationale = "matched leadership/executive signals — boosts overall relevance score"
|
|
||||||
elif len(term.split()) > 1:
|
|
||||||
section = "experience"
|
|
||||||
priority = 2
|
|
||||||
rationale = "multi-word phrase — more specific than single keywords, context weight in bullets"
|
|
||||||
else:
|
|
||||||
section = "experience"
|
|
||||||
priority = 3
|
|
||||||
rationale = "single generic term — lowest ATS impact, added to experience for coverage"
|
|
||||||
|
|
||||||
prioritized.append({
|
|
||||||
"term": term,
|
|
||||||
"section": section,
|
|
||||||
"priority": priority,
|
|
||||||
"rationale": rationale,
|
|
||||||
})
|
|
||||||
|
|
||||||
prioritized.sort(key=lambda x: x["priority"])
|
|
||||||
return prioritized
|
|
||||||
|
|
||||||
|
|
||||||
def _flatten_resume_text(resume: dict[str, Any]) -> str:
|
|
||||||
"""Concatenate all text from a structured resume dict into one searchable string."""
|
|
||||||
parts: list[str] = []
|
|
||||||
parts.append(resume.get("career_summary", "") or "")
|
|
||||||
parts.extend(resume.get("skills", []))
|
|
||||||
for exp in resume.get("experience", []):
|
|
||||||
parts.append(exp.get("title", ""))
|
|
||||||
parts.append(exp.get("company", ""))
|
|
||||||
parts.extend(exp.get("bullets", []))
|
|
||||||
for edu in resume.get("education", []):
|
|
||||||
parts.append(edu.get("degree", ""))
|
|
||||||
parts.append(edu.get("field", ""))
|
|
||||||
parts.append(edu.get("institution", ""))
|
|
||||||
parts.extend(resume.get("achievements", []))
|
|
||||||
return " ".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
# ── LLM rewrite ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def rewrite_for_ats(
|
|
||||||
resume: dict[str, Any],
|
|
||||||
prioritized_gaps: list[dict],
|
|
||||||
job: dict[str, Any],
|
|
||||||
candidate_voice: str = "",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Rewrite resume sections to naturally incorporate ATS keyword gaps.
|
|
||||||
|
|
||||||
Operates section-by-section. For each target section in prioritized_gaps,
|
|
||||||
builds a focused prompt that injects only the gaps destined for that section.
|
|
||||||
The hallucination constraint is enforced in the prompt itself and verified
|
|
||||||
post-hoc by hallucination_check().
|
|
||||||
|
|
||||||
Args:
|
|
||||||
resume: Structured resume dict (from resume_parser.parse_resume).
|
|
||||||
prioritized_gaps: Output of prioritize_gaps().
|
|
||||||
job: Job dict with at minimum {"title": str, "company": str, "description": str}.
|
|
||||||
candidate_voice: Free-text personality/style note from user.yaml (may be empty).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
New resume dict (same structure as input) with rewritten sections.
|
|
||||||
Sections with no relevant gaps are copied through unchanged.
|
|
||||||
"""
|
|
||||||
from scripts.llm_router import LLMRouter
|
|
||||||
router = LLMRouter()
|
|
||||||
|
|
||||||
# Group gaps by target section
|
|
||||||
by_section: dict[str, list[str]] = {}
|
|
||||||
for gap in prioritized_gaps:
|
|
||||||
by_section.setdefault(gap["section"], []).append(gap["term"])
|
|
||||||
|
|
||||||
rewritten = dict(resume) # shallow copy — sections replaced below
|
|
||||||
|
|
||||||
for section, terms in by_section.items():
|
|
||||||
terms_str = ", ".join(f'"{t}"' for t in terms)
|
|
||||||
original_content = _section_text_for_prompt(resume, section)
|
|
||||||
|
|
||||||
voice_note = (
|
|
||||||
f'\n\nCandidate voice/style: "{candidate_voice}". '
|
|
||||||
"Preserve this authentic tone — do not write generically."
|
|
||||||
) if candidate_voice else ""
|
|
||||||
|
|
||||||
prompt = (
|
|
||||||
f"You are rewriting the **{section}** section of a resume to help it pass "
|
|
||||||
f"ATS (applicant tracking system) screening for this role:\n"
|
|
||||||
f" Job title: {job.get('title', 'Unknown')}\n"
|
|
||||||
f" Company: {job.get('company', 'Unknown')}\n\n"
|
|
||||||
f"Inject these missing ATS keywords naturally into the section:\n"
|
|
||||||
f" {terms_str}\n\n"
|
|
||||||
f"CRITICAL RULES — violating any of these invalidates the rewrite:\n"
|
|
||||||
f"1. Do NOT invent new employers, job titles, dates, or education.\n"
|
|
||||||
f"2. Do NOT add skills the candidate did not already demonstrate.\n"
|
|
||||||
f"3. Only rephrase existing content — replace vague verbs/nouns with the "
|
|
||||||
f" ATS-preferred equivalents listed above.\n"
|
|
||||||
f"4. Keep the same number of bullet points in experience entries.\n"
|
|
||||||
f"5. Return ONLY the rewritten section content, no labels or explanation."
|
|
||||||
f"{voice_note}\n\n"
|
|
||||||
f"Original {section} section:\n{original_content}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = router.complete(prompt)
|
|
||||||
rewritten = _apply_section_rewrite(rewritten, section, result.strip())
|
|
||||||
except Exception:
|
|
||||||
log.warning("[resume_optimizer] rewrite failed for section %r", section, exc_info=True)
|
|
||||||
# Leave section unchanged on failure
|
|
||||||
|
|
||||||
return rewritten
|
|
||||||
|
|
||||||
|
|
||||||
def _section_text_for_prompt(resume: dict[str, Any], section: str) -> str:
|
|
||||||
"""Render a resume section as plain text suitable for an LLM prompt."""
|
|
||||||
if section == "summary":
|
|
||||||
return resume.get("career_summary", "") or "(empty)"
|
|
||||||
if section == "skills":
|
|
||||||
skills = resume.get("skills", [])
|
|
||||||
return ", ".join(skills) if skills else "(empty)"
|
|
||||||
if section == "experience":
|
|
||||||
lines: list[str] = []
|
|
||||||
for exp in resume.get("experience", []):
|
|
||||||
lines.append(f"{exp['title']} at {exp['company']} ({exp['start_date']}–{exp['end_date']})")
|
|
||||||
for b in exp.get("bullets", []):
|
|
||||||
lines.append(f" • {b}")
|
|
||||||
return "\n".join(lines) if lines else "(empty)"
|
|
||||||
return "(unsupported section)"
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_section_rewrite(resume: dict[str, Any], section: str, rewritten: str) -> dict[str, Any]:
|
|
||||||
"""Return a new resume dict with the given section replaced by rewritten text."""
|
|
||||||
updated = dict(resume)
|
|
||||||
if section == "summary":
|
|
||||||
updated["career_summary"] = rewritten
|
|
||||||
elif section == "skills":
|
|
||||||
# LLM returns comma-separated or newline-separated skills
|
|
||||||
skills = [s.strip() for s in re.split(r"[,\n•·]+", rewritten) if s.strip()]
|
|
||||||
updated["skills"] = skills
|
|
||||||
elif section == "experience":
|
|
||||||
# For experience, we keep the structured entries but replace the bullets.
|
|
||||||
# The LLM rewrites the whole section as plain text; we re-parse the bullets.
|
|
||||||
updated["experience"] = _reparse_experience_bullets(resume["experience"], rewritten)
|
|
||||||
return updated
|
|
||||||
|
|
||||||
|
|
||||||
def _reparse_experience_bullets(
|
|
||||||
original_entries: list[dict],
|
|
||||||
rewritten_text: str,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Re-associate rewritten bullet text with the original experience entries.
|
|
||||||
|
|
||||||
The LLM rewrites the section as a block of text. We split on the original
|
|
||||||
entry headers (title + company) to re-bind bullets to entries. Falls back
|
|
||||||
to the original entries if splitting fails.
|
|
||||||
"""
|
|
||||||
if not original_entries:
|
|
||||||
return original_entries
|
|
||||||
|
|
||||||
result: list[dict] = []
|
|
||||||
remaining = rewritten_text
|
|
||||||
|
|
||||||
for i, entry in enumerate(original_entries):
|
|
||||||
# Find where the next entry starts so we can slice out this entry's bullets
|
|
||||||
if i + 1 < len(original_entries):
|
|
||||||
next_title = original_entries[i + 1]["title"]
|
|
||||||
# Look for the next entry header in the remaining text
|
|
||||||
split_pat = re.escape(next_title)
|
|
||||||
m = re.search(split_pat, remaining, re.IGNORECASE)
|
|
||||||
chunk = remaining[:m.start()] if m else remaining
|
|
||||||
remaining = remaining[m.start():] if m else ""
|
|
||||||
else:
|
|
||||||
chunk = remaining
|
|
||||||
|
|
||||||
bullets = [
|
|
||||||
re.sub(r"^[•\-–—*◦▪▸►]\s*", "", line).strip()
|
|
||||||
for line in chunk.splitlines()
|
|
||||||
if re.match(r"^[•\-–—*◦▪▸►]\s*", line.strip())
|
|
||||||
]
|
|
||||||
new_entry = dict(entry)
|
|
||||||
new_entry["bullets"] = bullets if bullets else entry["bullets"]
|
|
||||||
result.append(new_entry)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ── Hallucination guard ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def hallucination_check(original: dict[str, Any], rewritten: dict[str, Any]) -> bool:
|
|
||||||
"""Return True if the rewrite is safe (no fabricated facts detected).
|
|
||||||
|
|
||||||
Checks that the set of employers, job titles, and date ranges in the
|
|
||||||
rewritten resume is a subset of those in the original. Any new entry
|
|
||||||
signals hallucination.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
original: Structured resume dict before rewrite.
|
|
||||||
rewritten: Structured resume dict after rewrite.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True → rewrite is safe to use
|
|
||||||
False → hallucination detected; caller should fall back to original
|
|
||||||
"""
|
|
||||||
orig_anchors = _extract_anchors(original)
|
|
||||||
rewrite_anchors = _extract_anchors(rewritten)
|
|
||||||
|
|
||||||
new_anchors = rewrite_anchors - orig_anchors
|
|
||||||
if new_anchors:
|
|
||||||
log.warning(
|
|
||||||
"[resume_optimizer] hallucination_check FAILED — new anchors in rewrite: %s",
|
|
||||||
new_anchors,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_anchors(resume: dict[str, Any]) -> frozenset[str]:
|
|
||||||
"""Extract stable factual anchors (company, title, dates) from experience entries."""
|
|
||||||
anchors: set[str] = set()
|
|
||||||
for exp in resume.get("experience", []):
|
|
||||||
for field in ("company", "title", "start_date", "end_date"):
|
|
||||||
val = (exp.get(field) or "").strip().lower()
|
|
||||||
if val:
|
|
||||||
anchors.add(val)
|
|
||||||
for edu in resume.get("education", []):
|
|
||||||
val = (edu.get("institution") or "").strip().lower()
|
|
||||||
if val:
|
|
||||||
anchors.add(val)
|
|
||||||
return frozenset(anchors)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Resume → plain text renderer ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
def render_resume_text(resume: dict[str, Any]) -> str:
|
|
||||||
"""Render a structured resume dict back to formatted plain text for PDF export."""
|
|
||||||
lines: list[str] = []
|
|
||||||
|
|
||||||
contact_parts = [resume.get("name", ""), resume.get("email", ""), resume.get("phone", "")]
|
|
||||||
lines.append(" ".join(p for p in contact_parts if p))
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
if resume.get("career_summary"):
|
|
||||||
lines.append("SUMMARY")
|
|
||||||
lines.append(resume["career_summary"])
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
if resume.get("experience"):
|
|
||||||
lines.append("EXPERIENCE")
|
|
||||||
for exp in resume["experience"]:
|
|
||||||
lines.append(
|
|
||||||
f"{exp.get('title', '')} | {exp.get('company', '')} "
|
|
||||||
f"({exp.get('start_date', '')}–{exp.get('end_date', '')})"
|
|
||||||
)
|
|
||||||
for b in exp.get("bullets", []):
|
|
||||||
lines.append(f" • {b}")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
if resume.get("education"):
|
|
||||||
lines.append("EDUCATION")
|
|
||||||
for edu in resume["education"]:
|
|
||||||
lines.append(
|
|
||||||
f"{edu.get('degree', '')} {edu.get('field', '')} | "
|
|
||||||
f"{edu.get('institution', '')} {edu.get('graduation_year', '')}"
|
|
||||||
)
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
if resume.get("skills"):
|
|
||||||
lines.append("SKILLS")
|
|
||||||
lines.append(", ".join(resume["skills"]))
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
if resume.get("achievements"):
|
|
||||||
lines.append("ACHIEVEMENTS")
|
|
||||||
for a in resume["achievements"]:
|
|
||||||
lines.append(f" • {a}")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
@ -9,13 +9,10 @@ and marks the task completed or failed.
|
||||||
Deduplication: only one queued/running task per (task_type, job_id) is allowed.
|
Deduplication: only one queued/running task per (task_type, job_id) is allowed.
|
||||||
Different task types for the same job run concurrently (e.g. cover letter + research).
|
Different task types for the same job run concurrently (e.g. cover letter + research).
|
||||||
"""
|
"""
|
||||||
import logging
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
from scripts.db import (
|
from scripts.db import (
|
||||||
DEFAULT_DB,
|
DEFAULT_DB,
|
||||||
insert_task,
|
insert_task,
|
||||||
|
|
@ -23,7 +20,6 @@ from scripts.db import (
|
||||||
update_task_stage,
|
update_task_stage,
|
||||||
update_cover_letter,
|
update_cover_letter,
|
||||||
save_research,
|
save_research,
|
||||||
save_optimized_resume,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -43,13 +39,9 @@ def submit_task(db_path: Path = DEFAULT_DB, task_type: str = "",
|
||||||
if is_new:
|
if is_new:
|
||||||
from scripts.task_scheduler import get_scheduler, LLM_TASK_TYPES
|
from scripts.task_scheduler import get_scheduler, LLM_TASK_TYPES
|
||||||
if task_type in LLM_TASK_TYPES:
|
if task_type in LLM_TASK_TYPES:
|
||||||
enqueued = get_scheduler(db_path, run_task_fn=_run_task).enqueue(
|
get_scheduler(db_path, run_task_fn=_run_task).enqueue(
|
||||||
task_id, task_type, job_id or 0, params
|
task_id, task_type, job_id or 0, params
|
||||||
)
|
)
|
||||||
if not enqueued:
|
|
||||||
update_task_status(
|
|
||||||
db_path, task_id, "failed", error="Queue depth limit reached"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
t = threading.Thread(
|
t = threading.Thread(
|
||||||
target=_run_task,
|
target=_run_task,
|
||||||
|
|
@ -166,8 +158,7 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
from scripts.discover import run_discovery
|
from scripts.discover import run_discovery
|
||||||
from pathlib import Path as _Path
|
new_count = run_discovery(db_path)
|
||||||
new_count = run_discovery(db_path, config_dir=_Path(db_path).parent / "config")
|
|
||||||
n = new_count or 0
|
n = new_count or 0
|
||||||
update_task_status(
|
update_task_status(
|
||||||
db_path, task_id, "completed",
|
db_path, task_id, "completed",
|
||||||
|
|
@ -179,9 +170,6 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int,
|
||||||
import json as _json
|
import json as _json
|
||||||
p = _json.loads(params or "{}")
|
p = _json.loads(params or "{}")
|
||||||
from scripts.generate_cover_letter import generate
|
from scripts.generate_cover_letter import generate
|
||||||
_cfg_dir = Path(db_path).parent / "config"
|
|
||||||
_user_llm_cfg = _cfg_dir / "llm.yaml"
|
|
||||||
_user_yaml = _cfg_dir / "user.yaml"
|
|
||||||
result = generate(
|
result = generate(
|
||||||
job.get("title", ""),
|
job.get("title", ""),
|
||||||
job.get("company", ""),
|
job.get("company", ""),
|
||||||
|
|
@ -189,8 +177,6 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int,
|
||||||
previous_result=p.get("previous_result", ""),
|
previous_result=p.get("previous_result", ""),
|
||||||
feedback=p.get("feedback", ""),
|
feedback=p.get("feedback", ""),
|
||||||
is_jobgether=job.get("source") == "jobgether",
|
is_jobgether=job.get("source") == "jobgether",
|
||||||
config_path=_user_llm_cfg,
|
|
||||||
user_yaml_path=_user_yaml,
|
|
||||||
)
|
)
|
||||||
update_cover_letter(db_path, job_id, result)
|
update_cover_letter(db_path, job_id, result)
|
||||||
|
|
||||||
|
|
@ -275,48 +261,6 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
elif task_type == "resume_optimize":
|
|
||||||
import json as _json
|
|
||||||
from scripts.resume_parser import structure_resume
|
|
||||||
from scripts.resume_optimizer import (
|
|
||||||
extract_jd_signals,
|
|
||||||
prioritize_gaps,
|
|
||||||
rewrite_for_ats,
|
|
||||||
hallucination_check,
|
|
||||||
render_resume_text,
|
|
||||||
)
|
|
||||||
from scripts.user_profile import load_user_profile
|
|
||||||
|
|
||||||
description = job.get("description", "")
|
|
||||||
resume_path = load_user_profile().get("resume_path", "")
|
|
||||||
|
|
||||||
# Parse the candidate's resume
|
|
||||||
update_task_stage(db_path, task_id, "parsing resume")
|
|
||||||
resume_text = Path(resume_path).read_text(errors="replace") if resume_path else ""
|
|
||||||
resume_struct, parse_err = structure_resume(resume_text)
|
|
||||||
|
|
||||||
# Extract keyword gaps and build gap report (free tier)
|
|
||||||
update_task_stage(db_path, task_id, "extracting keyword gaps")
|
|
||||||
gaps = extract_jd_signals(description, resume_text)
|
|
||||||
prioritized = prioritize_gaps(gaps, resume_struct)
|
|
||||||
gap_report = _json.dumps(prioritized, indent=2)
|
|
||||||
|
|
||||||
# Full rewrite (paid tier only)
|
|
||||||
rewritten_text = ""
|
|
||||||
p = _json.loads(params or "{}")
|
|
||||||
if p.get("full_rewrite", False):
|
|
||||||
update_task_stage(db_path, task_id, "rewriting resume sections")
|
|
||||||
candidate_voice = load_user_profile().get("candidate_voice", "")
|
|
||||||
rewritten = rewrite_for_ats(resume_struct, prioritized, job, candidate_voice)
|
|
||||||
if hallucination_check(resume_struct, rewritten):
|
|
||||||
rewritten_text = render_resume_text(rewritten)
|
|
||||||
else:
|
|
||||||
log.warning("[task_runner] resume_optimize hallucination check failed for job %d", job_id)
|
|
||||||
|
|
||||||
save_optimized_resume(db_path, job_id=job_id,
|
|
||||||
text=rewritten_text,
|
|
||||||
gap_report=gap_report)
|
|
||||||
|
|
||||||
elif task_type == "prepare_training":
|
elif task_type == "prepare_training":
|
||||||
from scripts.prepare_training_data import build_records, write_jsonl, DEFAULT_OUTPUT
|
from scripts.prepare_training_data import build_records, write_jsonl, DEFAULT_OUTPUT
|
||||||
records = build_records()
|
records = build_records()
|
||||||
|
|
|
||||||
|
|
@ -1,167 +1,232 @@
|
||||||
# scripts/task_scheduler.py
|
# scripts/task_scheduler.py
|
||||||
"""Peregrine LLM task scheduler — thin shim over circuitforge_core.tasks.scheduler.
|
"""Resource-aware batch scheduler for LLM background tasks.
|
||||||
|
|
||||||
All scheduling logic lives in circuitforge_core. This module defines
|
Routes LLM task types through per-type deques with VRAM-aware scheduling.
|
||||||
Peregrine-specific task types, VRAM budgets, and config loading.
|
Non-LLM tasks bypass this module — routing lives in scripts/task_runner.py.
|
||||||
|
|
||||||
Public API (unchanged — callers do not need to change):
|
Public API:
|
||||||
LLM_TASK_TYPES — frozenset of task type strings routed through the scheduler
|
LLM_TASK_TYPES — set of task type strings routed through the scheduler
|
||||||
DEFAULT_VRAM_BUDGETS — dict of conservative peak VRAM estimates per task type
|
get_scheduler() — lazy singleton accessor
|
||||||
TaskSpec — lightweight task descriptor (re-exported from core)
|
reset_scheduler() — test teardown only
|
||||||
TaskScheduler — backward-compatible wrapper around the core scheduler class
|
|
||||||
get_scheduler() — returns the process-level TaskScheduler singleton
|
|
||||||
reset_scheduler() — test teardown only
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
|
from collections import deque, namedtuple
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from circuitforge_core.tasks.scheduler import (
|
# Module-level import so tests can monkeypatch scripts.task_scheduler._get_gpus
|
||||||
TaskSpec, # re-export unchanged
|
try:
|
||||||
LocalScheduler as _CoreTaskScheduler,
|
from scripts.preflight import get_gpus as _get_gpus
|
||||||
)
|
except Exception: # graceful degradation if preflight unavailable
|
||||||
|
_get_gpus = lambda: []
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Peregrine task types and VRAM budgets ─────────────────────────────────────
|
# Task types that go through the scheduler (all others spawn free threads)
|
||||||
|
|
||||||
LLM_TASK_TYPES: frozenset[str] = frozenset({
|
LLM_TASK_TYPES: frozenset[str] = frozenset({
|
||||||
"cover_letter",
|
"cover_letter",
|
||||||
"company_research",
|
"company_research",
|
||||||
"wizard_generate",
|
"wizard_generate",
|
||||||
"resume_optimize",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
# Conservative peak VRAM estimates (GB) per task type.
|
# Conservative peak VRAM estimates (GB) per task type.
|
||||||
# Overridable per-install via scheduler.vram_budgets in config/llm.yaml.
|
# Overridable per-install via scheduler.vram_budgets in config/llm.yaml.
|
||||||
DEFAULT_VRAM_BUDGETS: dict[str, float] = {
|
DEFAULT_VRAM_BUDGETS: dict[str, float] = {
|
||||||
"cover_letter": 2.5, # alex-cover-writer:latest (~2 GB GGUF + headroom)
|
"cover_letter": 2.5, # alex-cover-writer:latest (~2GB GGUF + headroom)
|
||||||
"company_research": 5.0, # llama3.1:8b or vllm model
|
"company_research": 5.0, # llama3.1:8b or vllm model
|
||||||
"wizard_generate": 2.5, # same model family as cover_letter
|
"wizard_generate": 2.5, # same model family as cover_letter
|
||||||
"resume_optimize": 5.0, # section-by-section rewrite; same budget as research
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_DEFAULT_MAX_QUEUE_DEPTH = 500
|
# Lightweight task descriptor stored in per-type deques
|
||||||
|
TaskSpec = namedtuple("TaskSpec", ["id", "job_id", "params"])
|
||||||
|
|
||||||
|
|
||||||
def _load_config_overrides(db_path: Path) -> tuple[dict[str, float], int]:
|
class TaskScheduler:
|
||||||
"""Load VRAM budget overrides and max_queue_depth from config/llm.yaml."""
|
"""Resource-aware LLM task batch scheduler. Use get_scheduler() — not direct construction."""
|
||||||
budgets = dict(DEFAULT_VRAM_BUDGETS)
|
|
||||||
max_depth = _DEFAULT_MAX_QUEUE_DEPTH
|
|
||||||
config_path = db_path.parent.parent / "config" / "llm.yaml"
|
|
||||||
if config_path.exists():
|
|
||||||
try:
|
|
||||||
import yaml
|
|
||||||
with open(config_path) as f:
|
|
||||||
cfg = yaml.safe_load(f) or {}
|
|
||||||
sched_cfg = cfg.get("scheduler", {})
|
|
||||||
budgets.update(sched_cfg.get("vram_budgets", {}))
|
|
||||||
max_depth = int(sched_cfg.get("max_queue_depth", max_depth))
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to load scheduler config from %s: %s", config_path, exc
|
|
||||||
)
|
|
||||||
return budgets, max_depth
|
|
||||||
|
|
||||||
|
|
||||||
# Module-level stub so tests can monkeypatch scripts.task_scheduler._get_gpus
|
|
||||||
# (existing tests monkeypatch this symbol — keep it here for backward compat).
|
|
||||||
try:
|
|
||||||
from scripts.preflight import get_gpus as _get_gpus
|
|
||||||
except Exception:
|
|
||||||
_get_gpus = lambda: [] # noqa: E731
|
|
||||||
|
|
||||||
|
|
||||||
class TaskScheduler(_CoreTaskScheduler):
|
|
||||||
"""Peregrine-specific TaskScheduler.
|
|
||||||
|
|
||||||
Extends circuitforge_core.tasks.scheduler.TaskScheduler with:
|
|
||||||
- Peregrine default VRAM budgets and task types wired into __init__
|
|
||||||
- Config loading from config/llm.yaml
|
|
||||||
- Backward-compatible two-argument __init__ signature (db_path, run_task_fn)
|
|
||||||
- _get_gpus monkeypatch support (existing tests patch this module-level symbol)
|
|
||||||
- Backward-compatible enqueue() that marks dropped tasks failed in the DB
|
|
||||||
and logs under the scripts.task_scheduler logger
|
|
||||||
|
|
||||||
Direct construction is still supported for tests; production code should
|
|
||||||
use get_scheduler() instead.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, db_path: Path, run_task_fn: Callable) -> None:
|
def __init__(self, db_path: Path, run_task_fn: Callable) -> None:
|
||||||
budgets, max_depth = _load_config_overrides(db_path)
|
self._db_path = db_path
|
||||||
|
self._run_task = run_task_fn
|
||||||
|
|
||||||
# Warn under this module's logger for any task types with no VRAM budget
|
self._lock = threading.Lock()
|
||||||
# (mirrors the core warning but captures under scripts.task_scheduler
|
self._wake = threading.Event()
|
||||||
# so existing tests using caplog.at_level(logger="scripts.task_scheduler") pass)
|
self._stop = threading.Event()
|
||||||
|
self._queues: dict[str, deque] = {}
|
||||||
|
self._active: dict[str, threading.Thread] = {}
|
||||||
|
self._reserved_vram: float = 0.0
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
|
# Load VRAM budgets: defaults + optional config overrides
|
||||||
|
self._budgets: dict[str, float] = dict(DEFAULT_VRAM_BUDGETS)
|
||||||
|
config_path = db_path.parent.parent / "config" / "llm.yaml"
|
||||||
|
self._max_queue_depth: int = 500
|
||||||
|
if config_path.exists():
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
with open(config_path) as f:
|
||||||
|
cfg = yaml.safe_load(f) or {}
|
||||||
|
sched_cfg = cfg.get("scheduler", {})
|
||||||
|
self._budgets.update(sched_cfg.get("vram_budgets", {}))
|
||||||
|
self._max_queue_depth = sched_cfg.get("max_queue_depth", 500)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to load scheduler config from %s: %s", config_path, exc)
|
||||||
|
|
||||||
|
# Warn on LLM types with no budget entry after merge
|
||||||
for t in LLM_TASK_TYPES:
|
for t in LLM_TASK_TYPES:
|
||||||
if t not in budgets:
|
if t not in self._budgets:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"No VRAM budget defined for LLM task type %r — "
|
"No VRAM budget defined for LLM task type %r — "
|
||||||
"defaulting to 0.0 GB (unlimited concurrency for this type)", t
|
"defaulting to 0.0 GB (unlimited concurrency for this type)", t
|
||||||
)
|
)
|
||||||
|
|
||||||
super().__init__(
|
# Detect total GPU VRAM; fall back to unlimited (999) on CPU-only systems.
|
||||||
db_path=db_path,
|
# Uses module-level _get_gpus so tests can monkeypatch scripts.task_scheduler._get_gpus.
|
||||||
run_task_fn=run_task_fn,
|
try:
|
||||||
task_types=LLM_TASK_TYPES,
|
gpus = _get_gpus()
|
||||||
vram_budgets=budgets,
|
self._available_vram: float = (
|
||||||
max_queue_depth=max_depth,
|
sum(g["vram_total_gb"] for g in gpus) if gpus else 999.0
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
|
self._available_vram = 999.0
|
||||||
|
|
||||||
def enqueue(
|
# Durability: reload surviving 'queued' LLM tasks from prior run
|
||||||
self,
|
self._load_queued_tasks()
|
||||||
task_id: int,
|
|
||||||
task_type: str,
|
def enqueue(self, task_id: int, task_type: str, job_id: int,
|
||||||
job_id: int,
|
params: Optional[str]) -> None:
|
||||||
params: Optional[str],
|
|
||||||
) -> bool:
|
|
||||||
"""Add an LLM task to the scheduler queue.
|
"""Add an LLM task to the scheduler queue.
|
||||||
|
|
||||||
When the queue is full, marks the task failed in SQLite immediately
|
If the queue for this type is at max_queue_depth, the task is marked
|
||||||
(backward-compatible with the original Peregrine behavior) and logs a
|
failed in SQLite immediately (no ghost queued rows) and a warning is logged.
|
||||||
warning under the scripts.task_scheduler logger.
|
|
||||||
|
|
||||||
Returns True if enqueued, False if the queue was full.
|
|
||||||
"""
|
"""
|
||||||
enqueued = super().enqueue(task_id, task_type, job_id, params)
|
from scripts.db import update_task_status
|
||||||
if not enqueued:
|
|
||||||
# Log under this module's logger so existing caplog tests pass
|
with self._lock:
|
||||||
logger.warning(
|
q = self._queues.setdefault(task_type, deque())
|
||||||
"Queue depth limit reached for %s (max=%d) — task %d dropped",
|
if len(q) >= self._max_queue_depth:
|
||||||
task_type, self._max_queue_depth, task_id,
|
logger.warning(
|
||||||
)
|
"Queue depth limit reached for %s (max=%d) — task %d dropped",
|
||||||
from scripts.db import update_task_status
|
task_type, self._max_queue_depth, task_id,
|
||||||
update_task_status(
|
)
|
||||||
self._db_path, task_id, "failed", error="Queue depth limit reached"
|
update_task_status(self._db_path, task_id, "failed",
|
||||||
)
|
error="Queue depth limit reached")
|
||||||
return enqueued
|
return
|
||||||
|
q.append(TaskSpec(task_id, job_id, params))
|
||||||
|
|
||||||
|
self._wake.set()
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Start the background scheduler loop thread. Call once after construction."""
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._scheduler_loop, name="task-scheduler", daemon=True
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def shutdown(self, timeout: float = 5.0) -> None:
|
||||||
|
"""Signal the scheduler to stop and wait for it to exit."""
|
||||||
|
self._stop.set()
|
||||||
|
self._wake.set() # unblock any wait()
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
self._thread.join(timeout=timeout)
|
||||||
|
|
||||||
|
def _scheduler_loop(self) -> None:
|
||||||
|
"""Main scheduler daemon — wakes on enqueue or batch completion."""
|
||||||
|
while not self._stop.is_set():
|
||||||
|
self._wake.wait(timeout=30)
|
||||||
|
self._wake.clear()
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
# Defense in depth: reap externally-killed batch threads.
|
||||||
|
# In normal operation _active.pop() runs in finally before _wake fires,
|
||||||
|
# so this reap finds nothing — no double-decrement risk.
|
||||||
|
for t, thread in list(self._active.items()):
|
||||||
|
if not thread.is_alive():
|
||||||
|
self._reserved_vram -= self._budgets.get(t, 0.0)
|
||||||
|
del self._active[t]
|
||||||
|
|
||||||
|
# Start new type batches while VRAM allows
|
||||||
|
candidates = sorted(
|
||||||
|
[t for t in self._queues if self._queues[t] and t not in self._active],
|
||||||
|
key=lambda t: len(self._queues[t]),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
for task_type in candidates:
|
||||||
|
budget = self._budgets.get(task_type, 0.0)
|
||||||
|
# Always allow at least one batch to run even if its budget
|
||||||
|
# exceeds _available_vram (prevents permanent starvation when
|
||||||
|
# a single type's budget is larger than the VRAM ceiling).
|
||||||
|
if self._reserved_vram == 0.0 or self._reserved_vram + budget <= self._available_vram:
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=self._batch_worker,
|
||||||
|
args=(task_type,),
|
||||||
|
name=f"batch-{task_type}",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._active[task_type] = thread
|
||||||
|
self._reserved_vram += budget
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def _batch_worker(self, task_type: str) -> None:
|
||||||
|
"""Serial consumer for one task type. Runs until the type's deque is empty."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
with self._lock:
|
||||||
|
q = self._queues.get(task_type)
|
||||||
|
if not q:
|
||||||
|
break
|
||||||
|
task = q.popleft()
|
||||||
|
# _run_task is scripts.task_runner._run_task (passed at construction)
|
||||||
|
self._run_task(
|
||||||
|
self._db_path, task.id, task_type, task.job_id, task.params
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Always release — even if _run_task raises.
|
||||||
|
# _active.pop here prevents the scheduler loop reap from double-decrementing.
|
||||||
|
with self._lock:
|
||||||
|
self._active.pop(task_type, None)
|
||||||
|
self._reserved_vram -= self._budgets.get(task_type, 0.0)
|
||||||
|
self._wake.set()
|
||||||
|
|
||||||
|
def _load_queued_tasks(self) -> None:
|
||||||
|
"""Load pre-existing queued LLM tasks from SQLite into deques (called once in __init__)."""
|
||||||
|
llm_types = sorted(LLM_TASK_TYPES) # sorted for deterministic SQL params in logs
|
||||||
|
placeholders = ",".join("?" * len(llm_types))
|
||||||
|
conn = sqlite3.connect(self._db_path)
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT id, task_type, job_id, params FROM background_tasks"
|
||||||
|
f" WHERE status='queued' AND task_type IN ({placeholders})"
|
||||||
|
f" ORDER BY created_at ASC",
|
||||||
|
llm_types,
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
for row_id, task_type, job_id, params in rows:
|
||||||
|
q = self._queues.setdefault(task_type, deque())
|
||||||
|
q.append(TaskSpec(row_id, job_id, params))
|
||||||
|
|
||||||
|
if rows:
|
||||||
|
logger.info("Scheduler: resumed %d queued task(s) from prior run", len(rows))
|
||||||
|
|
||||||
|
|
||||||
# ── Peregrine-local singleton ──────────────────────────────────────────────────
|
# ── Singleton ─────────────────────────────────────────────────────────────────
|
||||||
# We manage our own singleton (not the core one) so the process-level instance
|
|
||||||
# is always a Peregrine TaskScheduler (with the enqueue() override).
|
|
||||||
|
|
||||||
_scheduler: Optional[TaskScheduler] = None
|
_scheduler: Optional[TaskScheduler] = None
|
||||||
_scheduler_lock = threading.Lock()
|
_scheduler_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def get_scheduler(
|
def get_scheduler(db_path: Path, run_task_fn: Callable = None) -> TaskScheduler:
|
||||||
db_path: Path,
|
"""Return the process-level TaskScheduler singleton, constructing it if needed.
|
||||||
run_task_fn: Optional[Callable] = None,
|
|
||||||
) -> TaskScheduler:
|
|
||||||
"""Return the process-level Peregrine TaskScheduler singleton.
|
|
||||||
|
|
||||||
run_task_fn is required on the first call; ignored on subsequent calls
|
run_task_fn is required on the first call; ignored on subsequent calls.
|
||||||
(double-checked locking — singleton already constructed).
|
Safety: inner lock + double-check prevents double-construction under races.
|
||||||
|
The outer None check is a fast-path performance optimisation only.
|
||||||
"""
|
"""
|
||||||
global _scheduler
|
global _scheduler
|
||||||
if _scheduler is None: # fast path — no lock on steady state
|
if _scheduler is None: # fast path — avoids lock on steady state
|
||||||
with _scheduler_lock:
|
with _scheduler_lock:
|
||||||
if _scheduler is None: # re-check under lock
|
if _scheduler is None: # re-check under lock (double-checked locking)
|
||||||
if run_task_fn is None:
|
if run_task_fn is None:
|
||||||
raise ValueError("run_task_fn required on first get_scheduler() call")
|
raise ValueError("run_task_fn required on first get_scheduler() call")
|
||||||
_scheduler = TaskScheduler(db_path, run_task_fn)
|
_scheduler = TaskScheduler(db_path, run_task_fn)
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ _DEFAULTS = {
|
||||||
"wizard_complete": False,
|
"wizard_complete": False,
|
||||||
"wizard_step": 0,
|
"wizard_step": 0,
|
||||||
"dismissed_banners": [],
|
"dismissed_banners": [],
|
||||||
"ui_preference": "streamlit",
|
|
||||||
"services": {
|
"services": {
|
||||||
"streamlit_port": 8501,
|
"streamlit_port": 8501,
|
||||||
"ollama_host": "localhost",
|
"ollama_host": "localhost",
|
||||||
|
|
@ -79,37 +78,7 @@ class UserProfile:
|
||||||
self.wizard_complete: bool = bool(data.get("wizard_complete", False))
|
self.wizard_complete: bool = bool(data.get("wizard_complete", False))
|
||||||
self.wizard_step: int = int(data.get("wizard_step", 0))
|
self.wizard_step: int = int(data.get("wizard_step", 0))
|
||||||
self.dismissed_banners: list[str] = list(data.get("dismissed_banners", []))
|
self.dismissed_banners: list[str] = list(data.get("dismissed_banners", []))
|
||||||
raw_pref = data.get("ui_preference", "streamlit")
|
|
||||||
self.ui_preference: str = raw_pref if raw_pref in ("streamlit", "vue") else "streamlit"
|
|
||||||
self._svc = data["services"]
|
self._svc = data["services"]
|
||||||
self._path = path
|
|
||||||
|
|
||||||
def save(self) -> None:
|
|
||||||
"""Save all profile fields back to user.yaml."""
|
|
||||||
output = {
|
|
||||||
"name": self.name,
|
|
||||||
"email": self.email,
|
|
||||||
"phone": self.phone,
|
|
||||||
"linkedin": self.linkedin,
|
|
||||||
"career_summary": self.career_summary,
|
|
||||||
"candidate_voice": self.candidate_voice,
|
|
||||||
"nda_companies": self.nda_companies,
|
|
||||||
"docs_dir": str(self.docs_dir),
|
|
||||||
"ollama_models_dir": str(self.ollama_models_dir),
|
|
||||||
"vllm_models_dir": str(self.vllm_models_dir),
|
|
||||||
"inference_profile": self.inference_profile,
|
|
||||||
"mission_preferences": self.mission_preferences,
|
|
||||||
"candidate_accessibility_focus": self.candidate_accessibility_focus,
|
|
||||||
"candidate_lgbtq_focus": self.candidate_lgbtq_focus,
|
|
||||||
"tier": self.tier,
|
|
||||||
"dev_tier_override": self.dev_tier_override,
|
|
||||||
"wizard_complete": self.wizard_complete,
|
|
||||||
"wizard_step": self.wizard_step,
|
|
||||||
"dismissed_banners": self.dismissed_banners,
|
|
||||||
"ui_preference": self.ui_preference,
|
|
||||||
"services": self._svc,
|
|
||||||
}
|
|
||||||
self._path.write_text(yaml.dump(output, default_flow_style=False))
|
|
||||||
|
|
||||||
# ── Service URLs ──────────────────────────────────────────────────────────
|
# ── Service URLs ──────────────────────────────────────────────────────────
|
||||||
def _url(self, host: str, port: int, ssl: bool) -> str:
|
def _url(self, host: str, port: int, ssl: bool) -> str:
|
||||||
|
|
|
||||||
|
|
@ -80,8 +80,7 @@ class TestTaskRunnerCoverLetterParams:
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
def mock_generate(title, company, description="", previous_result="", feedback="",
|
def mock_generate(title, company, description="", previous_result="", feedback="",
|
||||||
is_jobgether=False, _router=None, config_path=None,
|
is_jobgether=False, _router=None):
|
||||||
user_yaml_path=None):
|
|
||||||
captured.update({
|
captured.update({
|
||||||
"title": title, "company": company,
|
"title": title, "company": company,
|
||||||
"previous_result": previous_result, "feedback": feedback,
|
"previous_result": previous_result, "feedback": feedback,
|
||||||
|
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
"""Tests for scripts/db_migrate.py — numbered SQL migration runner."""
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
import textwrap
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from scripts.db_migrate import migrate_db
|
|
||||||
|
|
||||||
|
|
||||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _applied(db_path: Path) -> list[str]:
|
|
||||||
con = sqlite3.connect(db_path)
|
|
||||||
try:
|
|
||||||
rows = con.execute("SELECT version FROM schema_migrations ORDER BY version").fetchall()
|
|
||||||
return [r[0] for r in rows]
|
|
||||||
finally:
|
|
||||||
con.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _tables(db_path: Path) -> set[str]:
|
|
||||||
con = sqlite3.connect(db_path)
|
|
||||||
try:
|
|
||||||
rows = con.execute(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
||||||
).fetchall()
|
|
||||||
return {r[0] for r in rows}
|
|
||||||
finally:
|
|
||||||
con.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ── tests ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_creates_schema_migrations_table(tmp_path):
|
|
||||||
"""Running against an empty DB creates the tracking table."""
|
|
||||||
db = tmp_path / "test.db"
|
|
||||||
(tmp_path / "migrations").mkdir() # empty migrations dir
|
|
||||||
# Patch the module-level _MIGRATIONS_DIR
|
|
||||||
import scripts.db_migrate as m
|
|
||||||
orig = m._MIGRATIONS_DIR
|
|
||||||
m._MIGRATIONS_DIR = tmp_path / "migrations"
|
|
||||||
try:
|
|
||||||
migrate_db(db)
|
|
||||||
assert "schema_migrations" in _tables(db)
|
|
||||||
finally:
|
|
||||||
m._MIGRATIONS_DIR = orig
|
|
||||||
|
|
||||||
|
|
||||||
def test_applies_migration_file(tmp_path):
|
|
||||||
"""A .sql file in migrations/ is applied and recorded."""
|
|
||||||
db = tmp_path / "test.db"
|
|
||||||
mdir = tmp_path / "migrations"
|
|
||||||
mdir.mkdir()
|
|
||||||
(mdir / "001_test.sql").write_text(
|
|
||||||
"CREATE TABLE IF NOT EXISTS widgets (id INTEGER PRIMARY KEY, name TEXT);"
|
|
||||||
)
|
|
||||||
|
|
||||||
import scripts.db_migrate as m
|
|
||||||
orig = m._MIGRATIONS_DIR
|
|
||||||
m._MIGRATIONS_DIR = mdir
|
|
||||||
try:
|
|
||||||
applied = migrate_db(db)
|
|
||||||
assert applied == ["001_test"]
|
|
||||||
assert "widgets" in _tables(db)
|
|
||||||
assert _applied(db) == ["001_test"]
|
|
||||||
finally:
|
|
||||||
m._MIGRATIONS_DIR = orig
|
|
||||||
|
|
||||||
|
|
||||||
def test_idempotent_second_run(tmp_path):
|
|
||||||
"""Running migrate_db twice does not re-apply migrations."""
|
|
||||||
db = tmp_path / "test.db"
|
|
||||||
mdir = tmp_path / "migrations"
|
|
||||||
mdir.mkdir()
|
|
||||||
(mdir / "001_test.sql").write_text(
|
|
||||||
"CREATE TABLE IF NOT EXISTS widgets (id INTEGER PRIMARY KEY, name TEXT);"
|
|
||||||
)
|
|
||||||
|
|
||||||
import scripts.db_migrate as m
|
|
||||||
orig = m._MIGRATIONS_DIR
|
|
||||||
m._MIGRATIONS_DIR = mdir
|
|
||||||
try:
|
|
||||||
migrate_db(db)
|
|
||||||
applied = migrate_db(db) # second run
|
|
||||||
assert applied == []
|
|
||||||
assert _applied(db) == ["001_test"]
|
|
||||||
finally:
|
|
||||||
m._MIGRATIONS_DIR = orig
|
|
||||||
|
|
||||||
|
|
||||||
def test_applies_only_new_migrations(tmp_path):
|
|
||||||
"""Migrations already in schema_migrations are skipped; only new ones run."""
|
|
||||||
db = tmp_path / "test.db"
|
|
||||||
mdir = tmp_path / "migrations"
|
|
||||||
mdir.mkdir()
|
|
||||||
(mdir / "001_first.sql").write_text(
|
|
||||||
"CREATE TABLE IF NOT EXISTS first_table (id INTEGER PRIMARY KEY);"
|
|
||||||
)
|
|
||||||
|
|
||||||
import scripts.db_migrate as m
|
|
||||||
orig = m._MIGRATIONS_DIR
|
|
||||||
m._MIGRATIONS_DIR = mdir
|
|
||||||
try:
|
|
||||||
migrate_db(db)
|
|
||||||
|
|
||||||
# Add a second migration
|
|
||||||
(mdir / "002_second.sql").write_text(
|
|
||||||
"CREATE TABLE IF NOT EXISTS second_table (id INTEGER PRIMARY KEY);"
|
|
||||||
)
|
|
||||||
applied = migrate_db(db)
|
|
||||||
assert applied == ["002_second"]
|
|
||||||
assert set(_applied(db)) == {"001_first", "002_second"}
|
|
||||||
assert "second_table" in _tables(db)
|
|
||||||
finally:
|
|
||||||
m._MIGRATIONS_DIR = orig
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_failure_raises(tmp_path):
|
|
||||||
"""A bad migration raises RuntimeError and does not record the version."""
|
|
||||||
db = tmp_path / "test.db"
|
|
||||||
mdir = tmp_path / "migrations"
|
|
||||||
mdir.mkdir()
|
|
||||||
(mdir / "001_bad.sql").write_text("THIS IS NOT VALID SQL !!!")
|
|
||||||
|
|
||||||
import scripts.db_migrate as m
|
|
||||||
orig = m._MIGRATIONS_DIR
|
|
||||||
m._MIGRATIONS_DIR = mdir
|
|
||||||
try:
|
|
||||||
with pytest.raises(RuntimeError, match="001_bad"):
|
|
||||||
migrate_db(db)
|
|
||||||
assert _applied(db) == []
|
|
||||||
finally:
|
|
||||||
m._MIGRATIONS_DIR = orig
|
|
||||||
|
|
||||||
|
|
||||||
def test_baseline_migration_runs(tmp_path):
|
|
||||||
"""The real 001_baseline.sql applies cleanly to a fresh database."""
|
|
||||||
db = tmp_path / "test.db"
|
|
||||||
applied = migrate_db(db)
|
|
||||||
assert "001_baseline" in applied
|
|
||||||
expected_tables = {
|
|
||||||
"jobs", "job_contacts", "company_research",
|
|
||||||
"background_tasks", "survey_responses", "digest_queue",
|
|
||||||
"schema_migrations",
|
|
||||||
}
|
|
||||||
assert expected_tables <= _tables(db)
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
"""Tests for app/components/demo_toolbar.py."""
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
from app.components.demo_toolbar import (
|
|
||||||
get_simulated_tier,
|
|
||||||
set_simulated_tier,
|
|
||||||
render_demo_toolbar,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_simulated_tier_updates_session_state(monkeypatch):
|
|
||||||
"""set_simulated_tier writes to st.session_state.simulated_tier."""
|
|
||||||
session = {}
|
|
||||||
injected = []
|
|
||||||
monkeypatch.setattr("streamlit.components.v1.html", lambda h, height=0: injected.append(h))
|
|
||||||
monkeypatch.setattr("streamlit.session_state", session, raising=False)
|
|
||||||
monkeypatch.setattr("streamlit.rerun", lambda: None)
|
|
||||||
|
|
||||||
set_simulated_tier("premium")
|
|
||||||
|
|
||||||
assert session.get("simulated_tier") == "premium"
|
|
||||||
assert any("prgn_demo_tier=premium" in h for h in injected)
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_simulated_tier_invalid_ignored(monkeypatch):
|
|
||||||
"""Invalid tier strings are rejected."""
|
|
||||||
session = {}
|
|
||||||
monkeypatch.setattr("streamlit.components.v1.html", lambda h, height=0: None)
|
|
||||||
monkeypatch.setattr("streamlit.session_state", session, raising=False)
|
|
||||||
monkeypatch.setattr("streamlit.rerun", lambda: None)
|
|
||||||
|
|
||||||
set_simulated_tier("ultramax")
|
|
||||||
|
|
||||||
assert "simulated_tier" not in session
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_simulated_tier_defaults_to_paid(monkeypatch):
|
|
||||||
"""Returns 'paid' when no tier is set yet."""
|
|
||||||
monkeypatch.setattr("streamlit.session_state", {}, raising=False)
|
|
||||||
monkeypatch.setattr("streamlit.query_params", {}, raising=False)
|
|
||||||
|
|
||||||
assert get_simulated_tier() == "paid"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_simulated_tier_reads_session(monkeypatch):
|
|
||||||
"""Returns tier from st.session_state when set."""
|
|
||||||
monkeypatch.setattr("streamlit.session_state", {"simulated_tier": "free"}, raising=False)
|
|
||||||
monkeypatch.setattr("streamlit.query_params", {}, raising=False)
|
|
||||||
|
|
||||||
assert get_simulated_tier() == "free"
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_demo_toolbar_renders_pills(monkeypatch):
|
|
||||||
"""render_demo_toolbar renders tier selection pills."""
|
|
||||||
session = {"simulated_tier": "paid"}
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def mock_button(label, key=None, type=None, use_container_width=False):
|
|
||||||
calls.append(("button", label, key, type))
|
|
||||||
return False # button not clicked
|
|
||||||
|
|
||||||
monkeypatch.setattr("streamlit.session_state", session, raising=False)
|
|
||||||
monkeypatch.setattr("streamlit.container", lambda: __import__("contextlib").nullcontext())
|
|
||||||
monkeypatch.setattr("streamlit.columns", lambda x: [__import__("contextlib").nullcontext() for _ in x])
|
|
||||||
monkeypatch.setattr("streamlit.caption", lambda x: None)
|
|
||||||
monkeypatch.setattr("streamlit.button", mock_button)
|
|
||||||
monkeypatch.setattr("streamlit.divider", lambda: None)
|
|
||||||
|
|
||||||
render_demo_toolbar()
|
|
||||||
|
|
||||||
# Verify buttons were rendered for all tiers
|
|
||||||
button_calls = [c for c in calls if c[0] == "button"]
|
|
||||||
assert len(button_calls) == 3
|
|
||||||
assert any("Paid ✓" in c[1] for c in button_calls) # current tier marked
|
|
||||||
|
|
||||||
primary_calls = [c for c in button_calls if c[3] == "primary"]
|
|
||||||
assert len(primary_calls) == 1
|
|
||||||
assert "Paid" in primary_calls[0][1]
|
|
||||||
|
|
@ -50,8 +50,9 @@ def tmp_db(tmp_path):
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def client(tmp_db, monkeypatch):
|
def client(tmp_db, monkeypatch):
|
||||||
monkeypatch.setenv("STAGING_DB", tmp_db)
|
monkeypatch.setenv("STAGING_DB", tmp_db)
|
||||||
|
import importlib
|
||||||
import dev_api
|
import dev_api
|
||||||
monkeypatch.setattr(dev_api, "DB_PATH", tmp_db)
|
importlib.reload(dev_api)
|
||||||
return TestClient(dev_api.app)
|
return TestClient(dev_api.app)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,10 @@ def tmp_db(tmp_path):
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def client(tmp_db, monkeypatch):
|
def client(tmp_db, monkeypatch):
|
||||||
monkeypatch.setenv("STAGING_DB", tmp_db)
|
monkeypatch.setenv("STAGING_DB", tmp_db)
|
||||||
|
# Re-import after env var is set so DB_PATH picks it up
|
||||||
|
import importlib
|
||||||
import dev_api
|
import dev_api
|
||||||
monkeypatch.setattr(dev_api, "DB_PATH", tmp_db)
|
importlib.reload(dev_api)
|
||||||
return TestClient(dev_api.app)
|
return TestClient(dev_api.app)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@ def test_get_resume_missing_returns_not_exists(tmp_path, monkeypatch):
|
||||||
"""GET /api/settings/resume when file missing returns {exists: false}."""
|
"""GET /api/settings/resume when file missing returns {exists: false}."""
|
||||||
fake_path = tmp_path / "config" / "plain_text_resume.yaml"
|
fake_path = tmp_path / "config" / "plain_text_resume.yaml"
|
||||||
# Ensure the path doesn't exist
|
# Ensure the path doesn't exist
|
||||||
monkeypatch.setattr("dev_api._resume_path", lambda: fake_path)
|
monkeypatch.setattr("dev_api.RESUME_PATH", fake_path)
|
||||||
|
|
||||||
from dev_api import app
|
from dev_api import app
|
||||||
c = TestClient(app)
|
c = TestClient(app)
|
||||||
|
|
@ -157,7 +157,7 @@ def test_get_resume_missing_returns_not_exists(tmp_path, monkeypatch):
|
||||||
def test_post_resume_blank_creates_file(tmp_path, monkeypatch):
|
def test_post_resume_blank_creates_file(tmp_path, monkeypatch):
|
||||||
"""POST /api/settings/resume/blank creates the file."""
|
"""POST /api/settings/resume/blank creates the file."""
|
||||||
fake_path = tmp_path / "config" / "plain_text_resume.yaml"
|
fake_path = tmp_path / "config" / "plain_text_resume.yaml"
|
||||||
monkeypatch.setattr("dev_api._resume_path", lambda: fake_path)
|
monkeypatch.setattr("dev_api.RESUME_PATH", fake_path)
|
||||||
|
|
||||||
from dev_api import app
|
from dev_api import app
|
||||||
c = TestClient(app)
|
c = TestClient(app)
|
||||||
|
|
@ -170,7 +170,7 @@ def test_post_resume_blank_creates_file(tmp_path, monkeypatch):
|
||||||
def test_get_resume_after_blank_returns_exists(tmp_path, monkeypatch):
|
def test_get_resume_after_blank_returns_exists(tmp_path, monkeypatch):
|
||||||
"""GET /api/settings/resume after blank creation returns {exists: true}."""
|
"""GET /api/settings/resume after blank creation returns {exists: true}."""
|
||||||
fake_path = tmp_path / "config" / "plain_text_resume.yaml"
|
fake_path = tmp_path / "config" / "plain_text_resume.yaml"
|
||||||
monkeypatch.setattr("dev_api._resume_path", lambda: fake_path)
|
monkeypatch.setattr("dev_api.RESUME_PATH", fake_path)
|
||||||
|
|
||||||
from dev_api import app
|
from dev_api import app
|
||||||
c = TestClient(app)
|
c = TestClient(app)
|
||||||
|
|
@ -212,7 +212,7 @@ def test_get_search_prefs_returns_dict(tmp_path, monkeypatch):
|
||||||
fake_path.parent.mkdir(parents=True, exist_ok=True)
|
fake_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(fake_path, "w") as f:
|
with open(fake_path, "w") as f:
|
||||||
yaml.dump({"default": {"remote_preference": "remote", "job_boards": []}}, f)
|
yaml.dump({"default": {"remote_preference": "remote", "job_boards": []}}, f)
|
||||||
monkeypatch.setattr("dev_api._search_prefs_path", lambda: fake_path)
|
monkeypatch.setattr("dev_api.SEARCH_PREFS_PATH", fake_path)
|
||||||
|
|
||||||
from dev_api import app
|
from dev_api import app
|
||||||
c = TestClient(app)
|
c = TestClient(app)
|
||||||
|
|
@ -227,7 +227,7 @@ def test_put_get_search_roundtrip(tmp_path, monkeypatch):
|
||||||
"""PUT then GET search prefs round-trip: saved field is returned."""
|
"""PUT then GET search prefs round-trip: saved field is returned."""
|
||||||
fake_path = tmp_path / "config" / "search_profiles.yaml"
|
fake_path = tmp_path / "config" / "search_profiles.yaml"
|
||||||
fake_path.parent.mkdir(parents=True, exist_ok=True)
|
fake_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
monkeypatch.setattr("dev_api._search_prefs_path", lambda: fake_path)
|
monkeypatch.setattr("dev_api.SEARCH_PREFS_PATH", fake_path)
|
||||||
|
|
||||||
from dev_api import app
|
from dev_api import app
|
||||||
c = TestClient(app)
|
c = TestClient(app)
|
||||||
|
|
@ -253,7 +253,7 @@ def test_put_get_search_roundtrip(tmp_path, monkeypatch):
|
||||||
def test_get_search_missing_file_returns_empty(tmp_path, monkeypatch):
|
def test_get_search_missing_file_returns_empty(tmp_path, monkeypatch):
|
||||||
"""GET /api/settings/search when file missing returns empty dict."""
|
"""GET /api/settings/search when file missing returns empty dict."""
|
||||||
fake_path = tmp_path / "config" / "search_profiles.yaml"
|
fake_path = tmp_path / "config" / "search_profiles.yaml"
|
||||||
monkeypatch.setattr("dev_api._search_prefs_path", lambda: fake_path)
|
monkeypatch.setattr("dev_api.SEARCH_PREFS_PATH", fake_path)
|
||||||
|
|
||||||
from dev_api import app
|
from dev_api import app
|
||||||
c = TestClient(app)
|
c = TestClient(app)
|
||||||
|
|
@ -363,7 +363,7 @@ def test_get_services_cpu_profile(client):
|
||||||
def test_get_email_has_password_set_bool(tmp_path, monkeypatch):
|
def test_get_email_has_password_set_bool(tmp_path, monkeypatch):
|
||||||
"""GET /api/settings/system/email has password_set (bool) and no password key."""
|
"""GET /api/settings/system/email has password_set (bool) and no password key."""
|
||||||
fake_email_path = tmp_path / "email.yaml"
|
fake_email_path = tmp_path / "email.yaml"
|
||||||
monkeypatch.setattr("dev_api._config_dir", lambda: fake_email_path.parent)
|
monkeypatch.setattr("dev_api.EMAIL_PATH", fake_email_path)
|
||||||
with patch("dev_api.get_credential", return_value=None):
|
with patch("dev_api.get_credential", return_value=None):
|
||||||
from dev_api import app
|
from dev_api import app
|
||||||
c = TestClient(app)
|
c = TestClient(app)
|
||||||
|
|
@ -378,7 +378,7 @@ def test_get_email_has_password_set_bool(tmp_path, monkeypatch):
|
||||||
def test_get_email_password_set_true_when_stored(tmp_path, monkeypatch):
|
def test_get_email_password_set_true_when_stored(tmp_path, monkeypatch):
|
||||||
"""password_set is True when credential is stored."""
|
"""password_set is True when credential is stored."""
|
||||||
fake_email_path = tmp_path / "email.yaml"
|
fake_email_path = tmp_path / "email.yaml"
|
||||||
monkeypatch.setattr("dev_api._config_dir", lambda: fake_email_path.parent)
|
monkeypatch.setattr("dev_api.EMAIL_PATH", fake_email_path)
|
||||||
with patch("dev_api.get_credential", return_value="secret"):
|
with patch("dev_api.get_credential", return_value="secret"):
|
||||||
from dev_api import app
|
from dev_api import app
|
||||||
c = TestClient(app)
|
c = TestClient(app)
|
||||||
|
|
@ -426,14 +426,10 @@ def test_finetune_status_returns_status_and_pairs_count(client):
|
||||||
assert "pairs_count" in data
|
assert "pairs_count" in data
|
||||||
|
|
||||||
|
|
||||||
def test_finetune_status_idle_when_no_task(tmp_path, monkeypatch):
|
def test_finetune_status_idle_when_no_task(client):
|
||||||
"""Status is 'idle' and pairs_count is 0 when no task exists."""
|
"""Status is 'idle' and pairs_count is 0 when no task exists."""
|
||||||
fake_jsonl = tmp_path / "cover_letters.jsonl" # does not exist -> 0 pairs
|
|
||||||
monkeypatch.setattr("dev_api._TRAINING_JSONL", fake_jsonl)
|
|
||||||
with patch("scripts.task_runner.get_task_status", return_value=None, create=True):
|
with patch("scripts.task_runner.get_task_status", return_value=None, create=True):
|
||||||
from dev_api import app
|
resp = client.get("/api/settings/fine-tune/status")
|
||||||
c = TestClient(app)
|
|
||||||
resp = c.get("/api/settings/fine-tune/status")
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["status"] == "idle"
|
assert data["status"] == "idle"
|
||||||
|
|
@ -445,7 +441,7 @@ def test_finetune_status_idle_when_no_task(tmp_path, monkeypatch):
|
||||||
def test_get_license_returns_tier_and_active(tmp_path, monkeypatch):
|
def test_get_license_returns_tier_and_active(tmp_path, monkeypatch):
|
||||||
"""GET /api/settings/license returns tier and active fields."""
|
"""GET /api/settings/license returns tier and active fields."""
|
||||||
fake_license = tmp_path / "license.yaml"
|
fake_license = tmp_path / "license.yaml"
|
||||||
monkeypatch.setattr("dev_api._license_path", lambda: fake_license)
|
monkeypatch.setattr("dev_api.LICENSE_PATH", fake_license)
|
||||||
|
|
||||||
from dev_api import app
|
from dev_api import app
|
||||||
c = TestClient(app)
|
c = TestClient(app)
|
||||||
|
|
@ -459,7 +455,7 @@ def test_get_license_returns_tier_and_active(tmp_path, monkeypatch):
|
||||||
def test_get_license_defaults_to_free(tmp_path, monkeypatch):
|
def test_get_license_defaults_to_free(tmp_path, monkeypatch):
|
||||||
"""GET /api/settings/license defaults to free tier when no file."""
|
"""GET /api/settings/license defaults to free tier when no file."""
|
||||||
fake_license = tmp_path / "license.yaml"
|
fake_license = tmp_path / "license.yaml"
|
||||||
monkeypatch.setattr("dev_api._license_path", lambda: fake_license)
|
monkeypatch.setattr("dev_api.LICENSE_PATH", fake_license)
|
||||||
|
|
||||||
from dev_api import app
|
from dev_api import app
|
||||||
c = TestClient(app)
|
c = TestClient(app)
|
||||||
|
|
@ -473,7 +469,8 @@ def test_get_license_defaults_to_free(tmp_path, monkeypatch):
|
||||||
def test_activate_license_valid_key_returns_ok(tmp_path, monkeypatch):
|
def test_activate_license_valid_key_returns_ok(tmp_path, monkeypatch):
|
||||||
"""POST activate with valid key format returns {ok: true}."""
|
"""POST activate with valid key format returns {ok: true}."""
|
||||||
fake_license = tmp_path / "license.yaml"
|
fake_license = tmp_path / "license.yaml"
|
||||||
monkeypatch.setattr("dev_api._license_path", lambda: fake_license)
|
monkeypatch.setattr("dev_api.LICENSE_PATH", fake_license)
|
||||||
|
monkeypatch.setattr("dev_api.CONFIG_DIR", tmp_path)
|
||||||
|
|
||||||
from dev_api import app
|
from dev_api import app
|
||||||
c = TestClient(app)
|
c = TestClient(app)
|
||||||
|
|
@ -485,7 +482,8 @@ def test_activate_license_valid_key_returns_ok(tmp_path, monkeypatch):
|
||||||
def test_activate_license_invalid_key_returns_ok_false(tmp_path, monkeypatch):
|
def test_activate_license_invalid_key_returns_ok_false(tmp_path, monkeypatch):
|
||||||
"""POST activate with bad key format returns {ok: false}."""
|
"""POST activate with bad key format returns {ok: false}."""
|
||||||
fake_license = tmp_path / "license.yaml"
|
fake_license = tmp_path / "license.yaml"
|
||||||
monkeypatch.setattr("dev_api._license_path", lambda: fake_license)
|
monkeypatch.setattr("dev_api.LICENSE_PATH", fake_license)
|
||||||
|
monkeypatch.setattr("dev_api.CONFIG_DIR", tmp_path)
|
||||||
|
|
||||||
from dev_api import app
|
from dev_api import app
|
||||||
c = TestClient(app)
|
c = TestClient(app)
|
||||||
|
|
@ -497,7 +495,8 @@ def test_activate_license_invalid_key_returns_ok_false(tmp_path, monkeypatch):
|
||||||
def test_deactivate_license_returns_ok(tmp_path, monkeypatch):
|
def test_deactivate_license_returns_ok(tmp_path, monkeypatch):
|
||||||
"""POST /api/settings/license/deactivate returns 200 with ok."""
|
"""POST /api/settings/license/deactivate returns 200 with ok."""
|
||||||
fake_license = tmp_path / "license.yaml"
|
fake_license = tmp_path / "license.yaml"
|
||||||
monkeypatch.setattr("dev_api._license_path", lambda: fake_license)
|
monkeypatch.setattr("dev_api.LICENSE_PATH", fake_license)
|
||||||
|
monkeypatch.setattr("dev_api.CONFIG_DIR", tmp_path)
|
||||||
|
|
||||||
from dev_api import app
|
from dev_api import app
|
||||||
c = TestClient(app)
|
c = TestClient(app)
|
||||||
|
|
@ -509,7 +508,8 @@ def test_deactivate_license_returns_ok(tmp_path, monkeypatch):
|
||||||
def test_activate_then_deactivate(tmp_path, monkeypatch):
|
def test_activate_then_deactivate(tmp_path, monkeypatch):
|
||||||
"""Activate then deactivate: active goes False."""
|
"""Activate then deactivate: active goes False."""
|
||||||
fake_license = tmp_path / "license.yaml"
|
fake_license = tmp_path / "license.yaml"
|
||||||
monkeypatch.setattr("dev_api._license_path", lambda: fake_license)
|
monkeypatch.setattr("dev_api.LICENSE_PATH", fake_license)
|
||||||
|
monkeypatch.setattr("dev_api.CONFIG_DIR", tmp_path)
|
||||||
|
|
||||||
from dev_api import app
|
from dev_api import app
|
||||||
c = TestClient(app)
|
c = TestClient(app)
|
||||||
|
|
@ -580,7 +580,7 @@ def test_get_developer_returns_expected_fields(tmp_path, monkeypatch):
|
||||||
_write_user_yaml(user_yaml)
|
_write_user_yaml(user_yaml)
|
||||||
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
|
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
|
||||||
fake_tokens = tmp_path / "tokens.yaml"
|
fake_tokens = tmp_path / "tokens.yaml"
|
||||||
monkeypatch.setattr("dev_api._tokens_path", lambda: fake_tokens)
|
monkeypatch.setattr("dev_api.TOKENS_PATH", fake_tokens)
|
||||||
|
|
||||||
from dev_api import app
|
from dev_api import app
|
||||||
c = TestClient(app)
|
c = TestClient(app)
|
||||||
|
|
@ -602,7 +602,7 @@ def test_put_dev_tier_then_get(tmp_path, monkeypatch):
|
||||||
_write_user_yaml(user_yaml)
|
_write_user_yaml(user_yaml)
|
||||||
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
|
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
|
||||||
fake_tokens = tmp_path / "tokens.yaml"
|
fake_tokens = tmp_path / "tokens.yaml"
|
||||||
monkeypatch.setattr("dev_api._tokens_path", lambda: fake_tokens)
|
monkeypatch.setattr("dev_api.TOKENS_PATH", fake_tokens)
|
||||||
|
|
||||||
from dev_api import app
|
from dev_api import app
|
||||||
c = TestClient(app)
|
c = TestClient(app)
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ def test_router_uses_first_reachable_backend():
|
||||||
mock_response.choices[0].message.content = "hello"
|
mock_response.choices[0].message.content = "hello"
|
||||||
|
|
||||||
with patch.object(router, "_is_reachable", side_effect=[False, True, True, True, True]), \
|
with patch.object(router, "_is_reachable", side_effect=[False, True, True, True, True]), \
|
||||||
patch("circuitforge_core.llm.router.OpenAI") as MockOpenAI:
|
patch("scripts.llm_router.OpenAI") as MockOpenAI:
|
||||||
instance = MockOpenAI.return_value
|
instance = MockOpenAI.return_value
|
||||||
instance.chat.completions.create.return_value = mock_response
|
instance.chat.completions.create.return_value = mock_response
|
||||||
mock_model = MagicMock()
|
mock_model = MagicMock()
|
||||||
|
|
@ -54,7 +54,7 @@ def test_is_reachable_returns_false_on_connection_error():
|
||||||
|
|
||||||
router = LLMRouter(CONFIG_PATH)
|
router = LLMRouter(CONFIG_PATH)
|
||||||
|
|
||||||
with patch("circuitforge_core.llm.router.requests.get", side_effect=requests.ConnectionError):
|
with patch("scripts.llm_router.requests.get", side_effect=requests.ConnectionError):
|
||||||
result = router._is_reachable("http://localhost:9999/v1")
|
result = router._is_reachable("http://localhost:9999/v1")
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
@ -92,8 +92,8 @@ def test_complete_skips_backend_without_image_support(tmp_path):
|
||||||
mock_resp.status_code = 200
|
mock_resp.status_code = 200
|
||||||
mock_resp.json.return_value = {"text": "B — collaborative"}
|
mock_resp.json.return_value = {"text": "B — collaborative"}
|
||||||
|
|
||||||
with patch("circuitforge_core.llm.router.requests.get") as mock_get, \
|
with patch("scripts.llm_router.requests.get") as mock_get, \
|
||||||
patch("circuitforge_core.llm.router.requests.post") as mock_post:
|
patch("scripts.llm_router.requests.post") as mock_post:
|
||||||
# health check returns ok for vision_service
|
# health check returns ok for vision_service
|
||||||
mock_get.return_value = MagicMock(status_code=200)
|
mock_get.return_value = MagicMock(status_code=200)
|
||||||
mock_post.return_value = mock_resp
|
mock_post.return_value = mock_resp
|
||||||
|
|
@ -127,7 +127,7 @@ def test_complete_without_images_skips_vision_service(tmp_path):
|
||||||
cfg_file.write_text(yaml.dump(cfg))
|
cfg_file.write_text(yaml.dump(cfg))
|
||||||
|
|
||||||
router = LLMRouter(config_path=cfg_file)
|
router = LLMRouter(config_path=cfg_file)
|
||||||
with patch("circuitforge_core.llm.router.requests.post") as mock_post:
|
with patch("scripts.llm_router.requests.post") as mock_post:
|
||||||
try:
|
try:
|
||||||
router.complete("text only prompt")
|
router.complete("text only prompt")
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
|
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
"""Tests for Peregrine's LLMRouter shim — priority fallback logic."""
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch, MagicMock, call
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
|
|
||||||
def _import_fresh():
|
|
||||||
"""Import scripts.llm_router fresh (bypass module cache)."""
|
|
||||||
import importlib
|
|
||||||
import scripts.llm_router as mod
|
|
||||||
importlib.reload(mod)
|
|
||||||
return mod
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Test 1: local config/llm.yaml takes priority when it exists
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def test_uses_local_yaml_when_present():
|
|
||||||
"""When config/llm.yaml exists locally, super().__init__ is called with that path."""
|
|
||||||
import scripts.llm_router as shim_mod
|
|
||||||
from circuitforge_core.llm import LLMRouter as _CoreLLMRouter
|
|
||||||
|
|
||||||
local_path = Path(shim_mod.__file__).parent.parent / "config" / "llm.yaml"
|
|
||||||
user_path = Path.home() / ".config" / "circuitforge" / "llm.yaml"
|
|
||||||
|
|
||||||
def fake_exists(self):
|
|
||||||
return self == local_path # only the local path "exists"
|
|
||||||
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
def fake_core_init(self, config_path=None):
|
|
||||||
captured["config_path"] = config_path
|
|
||||||
self.config = {}
|
|
||||||
|
|
||||||
with patch.object(Path, "exists", fake_exists), \
|
|
||||||
patch.object(_CoreLLMRouter, "__init__", fake_core_init):
|
|
||||||
import importlib
|
|
||||||
import scripts.llm_router as mod
|
|
||||||
importlib.reload(mod)
|
|
||||||
mod.LLMRouter()
|
|
||||||
|
|
||||||
assert captured.get("config_path") == local_path, (
|
|
||||||
f"Expected super().__init__ to be called with local path {local_path}, "
|
|
||||||
f"got {captured.get('config_path')}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Test 2: falls through to env-var auto-config when neither yaml exists
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def test_falls_through_to_env_when_no_yamls():
|
|
||||||
"""When no yaml files exist, super().__init__ is called with no args (env-var path)."""
|
|
||||||
import scripts.llm_router as shim_mod
|
|
||||||
from circuitforge_core.llm import LLMRouter as _CoreLLMRouter
|
|
||||||
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
def fake_exists(self):
|
|
||||||
return False # no yaml files exist anywhere
|
|
||||||
|
|
||||||
def fake_core_init(self, config_path=None):
|
|
||||||
# Record whether a path was passed
|
|
||||||
captured["config_path"] = config_path
|
|
||||||
captured["called"] = True
|
|
||||||
self.config = {}
|
|
||||||
|
|
||||||
with patch.object(Path, "exists", fake_exists), \
|
|
||||||
patch.object(_CoreLLMRouter, "__init__", fake_core_init):
|
|
||||||
import importlib
|
|
||||||
import scripts.llm_router as mod
|
|
||||||
importlib.reload(mod)
|
|
||||||
mod.LLMRouter()
|
|
||||||
|
|
||||||
assert captured.get("called"), "super().__init__ was never called"
|
|
||||||
# When called with no args, config_path defaults to None in our mock,
|
|
||||||
# meaning the shim correctly fell through to env-var auto-config
|
|
||||||
assert captured.get("config_path") is None, (
|
|
||||||
f"Expected super().__init__ to be called with no explicit path (None), "
|
|
||||||
f"got {captured.get('config_path')}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Test 3: module-level complete() singleton is only instantiated once
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def test_complete_singleton_is_reused():
|
|
||||||
"""complete() reuses the same LLMRouter instance across multiple calls."""
|
|
||||||
import importlib
|
|
||||||
import scripts.llm_router as mod
|
|
||||||
importlib.reload(mod)
|
|
||||||
|
|
||||||
# Reset singleton
|
|
||||||
mod._router = None
|
|
||||||
|
|
||||||
instantiation_count = [0]
|
|
||||||
original_init = mod.LLMRouter.__init__
|
|
||||||
|
|
||||||
mock_router = MagicMock()
|
|
||||||
mock_router.complete.return_value = "OK"
|
|
||||||
|
|
||||||
original_class = mod.LLMRouter
|
|
||||||
|
|
||||||
class CountingRouter(original_class):
|
|
||||||
def __init__(self):
|
|
||||||
instantiation_count[0] += 1
|
|
||||||
# Bypass real __init__ to avoid needing config files
|
|
||||||
self.config = {}
|
|
||||||
|
|
||||||
def complete(self, prompt, system=None):
|
|
||||||
return "OK"
|
|
||||||
|
|
||||||
# Patch the class in the module
|
|
||||||
mod.LLMRouter = CountingRouter
|
|
||||||
mod._router = None
|
|
||||||
|
|
||||||
result1 = mod.complete("first call")
|
|
||||||
result2 = mod.complete("second call")
|
|
||||||
|
|
||||||
assert result1 == "OK"
|
|
||||||
assert result2 == "OK"
|
|
||||||
assert instantiation_count[0] == 1, (
|
|
||||||
f"Expected LLMRouter to be instantiated exactly once, "
|
|
||||||
f"got {instantiation_count[0]} instantiation(s)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Restore
|
|
||||||
mod.LLMRouter = original_class
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
"""Tests: preflight writes OLLAMA_HOST to .env when Ollama is adopted from host."""
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch, call
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
import scripts.preflight as pf
|
|
||||||
|
|
||||||
|
|
||||||
def _make_ports(ollama_external: bool = True, ollama_port: int = 11434) -> dict:
|
|
||||||
"""Build a minimal ports dict as returned by preflight's port-scanning logic."""
|
|
||||||
return {
|
|
||||||
"ollama": {
|
|
||||||
"resolved": ollama_port,
|
|
||||||
"external": ollama_external,
|
|
||||||
"stub_port": 54321,
|
|
||||||
"env_var": "OLLAMA_PORT",
|
|
||||||
"adoptable": True,
|
|
||||||
},
|
|
||||||
"streamlit": {
|
|
||||||
"resolved": 8502,
|
|
||||||
"external": False,
|
|
||||||
"stub_port": 8502,
|
|
||||||
"env_var": "STREAMLIT_PORT",
|
|
||||||
"adoptable": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _capture_env_updates(ports: dict) -> dict:
|
|
||||||
"""Run the env_updates construction block from preflight.main() and return the result.
|
|
||||||
|
|
||||||
We extract this logic from main() so tests can call it directly without
|
|
||||||
needing to simulate the full CLI argument parsing and system probe flow.
|
|
||||||
The block under test is the `if not args.check_only:` section.
|
|
||||||
"""
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
def fake_write_env(updates: dict) -> None:
|
|
||||||
captured.update(updates)
|
|
||||||
|
|
||||||
with patch.object(pf, "write_env", side_effect=fake_write_env), \
|
|
||||||
patch.object(pf, "update_llm_yaml"), \
|
|
||||||
patch.object(pf, "write_compose_override"):
|
|
||||||
# Replicate the env_updates block from preflight.main() as faithfully as possible
|
|
||||||
env_updates: dict[str, str] = {i["env_var"]: str(i["stub_port"]) for i in ports.values()}
|
|
||||||
env_updates["RECOMMENDED_PROFILE"] = "single-gpu"
|
|
||||||
|
|
||||||
# ---- Code under test: the OLLAMA_HOST adoption block ----
|
|
||||||
ollama_info = ports.get("ollama")
|
|
||||||
if ollama_info and ollama_info.get("external"):
|
|
||||||
env_updates["OLLAMA_HOST"] = f"http://host.docker.internal:{ollama_info['resolved']}"
|
|
||||||
# ---------------------------------------------------------
|
|
||||||
|
|
||||||
pf.write_env(env_updates)
|
|
||||||
|
|
||||||
return captured
|
|
||||||
|
|
||||||
|
|
||||||
def test_ollama_host_written_when_adopted():
|
|
||||||
"""OLLAMA_HOST is added when Ollama is adopted from the host (external=True)."""
|
|
||||||
ports = _make_ports(ollama_external=True, ollama_port=11434)
|
|
||||||
result = _capture_env_updates(ports)
|
|
||||||
assert "OLLAMA_HOST" in result
|
|
||||||
assert result["OLLAMA_HOST"] == "http://host.docker.internal:11434"
|
|
||||||
|
|
||||||
|
|
||||||
def test_ollama_host_not_written_when_docker_managed():
|
|
||||||
"""OLLAMA_HOST is NOT added when Ollama runs in Docker (external=False)."""
|
|
||||||
ports = _make_ports(ollama_external=False)
|
|
||||||
result = _capture_env_updates(ports)
|
|
||||||
assert "OLLAMA_HOST" not in result
|
|
||||||
|
|
||||||
|
|
||||||
def test_ollama_host_reflects_adopted_port():
|
|
||||||
"""OLLAMA_HOST uses the actual adopted port, not the default."""
|
|
||||||
ports = _make_ports(ollama_external=True, ollama_port=11500)
|
|
||||||
result = _capture_env_updates(ports)
|
|
||||||
assert result["OLLAMA_HOST"] == "http://host.docker.internal:11500"
|
|
||||||
|
|
@ -1,288 +0,0 @@
|
||||||
# tests/test_resume_optimizer.py
|
|
||||||
"""Tests for scripts/resume_optimizer.py"""
|
|
||||||
import json
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
|
|
||||||
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
SAMPLE_RESUME = {
|
|
||||||
"name": "Alex Rivera",
|
|
||||||
"email": "alex@example.com",
|
|
||||||
"phone": "555-1234",
|
|
||||||
"career_summary": "Experienced Customer Success Manager with a track record of growth.",
|
|
||||||
"skills": ["Salesforce", "Python", "customer success"],
|
|
||||||
"experience": [
|
|
||||||
{
|
|
||||||
"title": "Customer Success Manager",
|
|
||||||
"company": "Acme Corp",
|
|
||||||
"start_date": "2021",
|
|
||||||
"end_date": "present",
|
|
||||||
"bullets": [
|
|
||||||
"Managed a portfolio of 120 enterprise accounts.",
|
|
||||||
"Reduced churn by 18% through proactive outreach.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Support Engineer",
|
|
||||||
"company": "Beta Inc",
|
|
||||||
"start_date": "2018",
|
|
||||||
"end_date": "2021",
|
|
||||||
"bullets": ["Resolved escalations for top-tier clients."],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"education": [
|
|
||||||
{
|
|
||||||
"degree": "B.S.",
|
|
||||||
"field": "Computer Science",
|
|
||||||
"institution": "State University",
|
|
||||||
"graduation_year": "2018",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"achievements": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
SAMPLE_JD = (
|
|
||||||
"We are looking for a Customer Success Manager with Gainsight, cross-functional "
|
|
||||||
"leadership experience, and strong stakeholder management skills. AWS knowledge a plus."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── extract_jd_signals ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_extract_jd_signals_returns_list():
|
|
||||||
"""extract_jd_signals returns a list even when LLM and TF-IDF both fail."""
|
|
||||||
from scripts.resume_optimizer import extract_jd_signals
|
|
||||||
|
|
||||||
with patch("scripts.llm_router.LLMRouter") as MockRouter:
|
|
||||||
MockRouter.return_value.complete.side_effect = Exception("no LLM")
|
|
||||||
result = extract_jd_signals(SAMPLE_JD, resume_text="Python developer")
|
|
||||||
|
|
||||||
assert isinstance(result, list)
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_jd_signals_llm_path_parses_json_array():
|
|
||||||
"""extract_jd_signals merges LLM-extracted signals with TF-IDF gaps."""
|
|
||||||
from scripts.resume_optimizer import extract_jd_signals
|
|
||||||
|
|
||||||
llm_response = '["Gainsight", "cross-functional leadership", "stakeholder management"]'
|
|
||||||
|
|
||||||
with patch("scripts.llm_router.LLMRouter") as MockRouter:
|
|
||||||
MockRouter.return_value.complete.return_value = llm_response
|
|
||||||
result = extract_jd_signals(SAMPLE_JD)
|
|
||||||
|
|
||||||
assert "Gainsight" in result
|
|
||||||
assert "cross-functional leadership" in result
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_jd_signals_deduplicates():
|
|
||||||
"""extract_jd_signals deduplicates terms across LLM and TF-IDF sources."""
|
|
||||||
from scripts.resume_optimizer import extract_jd_signals
|
|
||||||
|
|
||||||
llm_response = '["Python", "AWS", "Python"]'
|
|
||||||
|
|
||||||
with patch("scripts.llm_router.LLMRouter") as MockRouter:
|
|
||||||
MockRouter.return_value.complete.return_value = llm_response
|
|
||||||
result = extract_jd_signals(SAMPLE_JD)
|
|
||||||
|
|
||||||
assert result.count("Python") == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_jd_signals_handles_malformed_llm_json():
|
|
||||||
"""extract_jd_signals falls back gracefully when LLM returns non-JSON."""
|
|
||||||
from scripts.resume_optimizer import extract_jd_signals
|
|
||||||
|
|
||||||
with patch("scripts.llm_router.LLMRouter") as MockRouter:
|
|
||||||
MockRouter.return_value.complete.return_value = "Here are some keywords: Gainsight, AWS"
|
|
||||||
result = extract_jd_signals(SAMPLE_JD)
|
|
||||||
|
|
||||||
# Should still return a list (may be empty if TF-IDF also silent)
|
|
||||||
assert isinstance(result, list)
|
|
||||||
|
|
||||||
|
|
||||||
# ── prioritize_gaps ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_prioritize_gaps_skips_existing_terms():
|
|
||||||
"""prioritize_gaps excludes terms already present in the resume."""
|
|
||||||
from scripts.resume_optimizer import prioritize_gaps
|
|
||||||
|
|
||||||
# "Salesforce" is already in SAMPLE_RESUME skills
|
|
||||||
result = prioritize_gaps(["Salesforce", "Gainsight"], SAMPLE_RESUME)
|
|
||||||
terms = [r["term"] for r in result]
|
|
||||||
|
|
||||||
assert "Salesforce" not in terms
|
|
||||||
assert "Gainsight" in terms
|
|
||||||
|
|
||||||
|
|
||||||
def test_prioritize_gaps_routes_tech_terms_to_skills():
|
|
||||||
"""prioritize_gaps maps known tech keywords to the skills section at priority 1."""
|
|
||||||
from scripts.resume_optimizer import prioritize_gaps
|
|
||||||
|
|
||||||
result = prioritize_gaps(["AWS", "Docker"], SAMPLE_RESUME)
|
|
||||||
by_term = {r["term"]: r for r in result}
|
|
||||||
|
|
||||||
assert by_term["AWS"]["section"] == "skills"
|
|
||||||
assert by_term["AWS"]["priority"] == 1
|
|
||||||
assert by_term["Docker"]["section"] == "skills"
|
|
||||||
|
|
||||||
|
|
||||||
def test_prioritize_gaps_routes_leadership_terms_to_summary():
|
|
||||||
"""prioritize_gaps maps leadership/executive signals to the summary section."""
|
|
||||||
from scripts.resume_optimizer import prioritize_gaps
|
|
||||||
|
|
||||||
result = prioritize_gaps(["cross-functional", "stakeholder"], SAMPLE_RESUME)
|
|
||||||
by_term = {r["term"]: r for r in result}
|
|
||||||
|
|
||||||
assert by_term["cross-functional"]["section"] == "summary"
|
|
||||||
assert by_term["stakeholder"]["section"] == "summary"
|
|
||||||
|
|
||||||
|
|
||||||
def test_prioritize_gaps_multi_word_routes_to_experience():
|
|
||||||
"""Multi-word phrases not in skills/summary lists go to experience at priority 2."""
|
|
||||||
from scripts.resume_optimizer import prioritize_gaps
|
|
||||||
|
|
||||||
result = prioritize_gaps(["proactive client engagement"], SAMPLE_RESUME)
|
|
||||||
assert result[0]["section"] == "experience"
|
|
||||||
assert result[0]["priority"] == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_prioritize_gaps_single_word_is_lowest_priority():
|
|
||||||
"""Single generic words not in any list go to experience at priority 3."""
|
|
||||||
from scripts.resume_optimizer import prioritize_gaps
|
|
||||||
|
|
||||||
result = prioritize_gaps(["innovation"], SAMPLE_RESUME)
|
|
||||||
assert result[0]["priority"] == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_prioritize_gaps_sorted_by_priority():
|
|
||||||
"""prioritize_gaps output is sorted ascending by priority (1 first)."""
|
|
||||||
from scripts.resume_optimizer import prioritize_gaps
|
|
||||||
|
|
||||||
gaps = ["innovation", "AWS", "cross-functional", "managed service contracts"]
|
|
||||||
result = prioritize_gaps(gaps, SAMPLE_RESUME)
|
|
||||||
priorities = [r["priority"] for r in result]
|
|
||||||
|
|
||||||
assert priorities == sorted(priorities)
|
|
||||||
|
|
||||||
|
|
||||||
# ── hallucination_check ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_hallucination_check_passes_unchanged_resume():
|
|
||||||
"""hallucination_check returns True when rewrite has no new employers or institutions."""
|
|
||||||
from scripts.resume_optimizer import hallucination_check
|
|
||||||
|
|
||||||
# Shallow rewrite: same structure
|
|
||||||
rewritten = {
|
|
||||||
**SAMPLE_RESUME,
|
|
||||||
"career_summary": "Dynamic CSM with cross-functional stakeholder management experience.",
|
|
||||||
}
|
|
||||||
assert hallucination_check(SAMPLE_RESUME, rewritten) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_hallucination_check_fails_on_new_employer():
|
|
||||||
"""hallucination_check returns False when a new company is introduced."""
|
|
||||||
from scripts.resume_optimizer import hallucination_check
|
|
||||||
|
|
||||||
fabricated_entry = {
|
|
||||||
"title": "VP of Customer Success",
|
|
||||||
"company": "Fabricated Corp",
|
|
||||||
"start_date": "2019",
|
|
||||||
"end_date": "2021",
|
|
||||||
"bullets": ["Led a team of 30."],
|
|
||||||
}
|
|
||||||
rewritten = dict(SAMPLE_RESUME)
|
|
||||||
rewritten["experience"] = SAMPLE_RESUME["experience"] + [fabricated_entry]
|
|
||||||
|
|
||||||
assert hallucination_check(SAMPLE_RESUME, rewritten) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_hallucination_check_fails_on_new_institution():
|
|
||||||
"""hallucination_check returns False when a new educational institution appears."""
|
|
||||||
from scripts.resume_optimizer import hallucination_check
|
|
||||||
|
|
||||||
rewritten = dict(SAMPLE_RESUME)
|
|
||||||
rewritten["education"] = [
|
|
||||||
*SAMPLE_RESUME["education"],
|
|
||||||
{"degree": "M.S.", "field": "Data Science", "institution": "MIT", "graduation_year": "2020"},
|
|
||||||
]
|
|
||||||
|
|
||||||
assert hallucination_check(SAMPLE_RESUME, rewritten) is False
|
|
||||||
|
|
||||||
|
|
||||||
# ── render_resume_text ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_render_resume_text_contains_all_sections():
|
|
||||||
"""render_resume_text produces plain text containing all resume sections."""
|
|
||||||
from scripts.resume_optimizer import render_resume_text
|
|
||||||
|
|
||||||
text = render_resume_text(SAMPLE_RESUME)
|
|
||||||
|
|
||||||
assert "Alex Rivera" in text
|
|
||||||
assert "SUMMARY" in text
|
|
||||||
assert "EXPERIENCE" in text
|
|
||||||
assert "Customer Success Manager" in text
|
|
||||||
assert "Acme Corp" in text
|
|
||||||
assert "EDUCATION" in text
|
|
||||||
assert "State University" in text
|
|
||||||
assert "SKILLS" in text
|
|
||||||
assert "Salesforce" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_resume_text_omits_empty_sections():
|
|
||||||
"""render_resume_text skips sections that have no content."""
|
|
||||||
from scripts.resume_optimizer import render_resume_text
|
|
||||||
|
|
||||||
sparse = {
|
|
||||||
"name": "Jordan Lee",
|
|
||||||
"email": "",
|
|
||||||
"phone": "",
|
|
||||||
"career_summary": "",
|
|
||||||
"skills": [],
|
|
||||||
"experience": [],
|
|
||||||
"education": [],
|
|
||||||
"achievements": [],
|
|
||||||
}
|
|
||||||
text = render_resume_text(sparse)
|
|
||||||
|
|
||||||
assert "EXPERIENCE" not in text
|
|
||||||
assert "SKILLS" not in text
|
|
||||||
|
|
||||||
|
|
||||||
# ── db integration ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_save_and_get_optimized_resume(tmp_path):
|
|
||||||
"""save_optimized_resume persists and get_optimized_resume retrieves the data."""
|
|
||||||
from scripts.db import init_db, save_optimized_resume, get_optimized_resume
|
|
||||||
|
|
||||||
db_path = tmp_path / "test.db"
|
|
||||||
init_db(db_path)
|
|
||||||
|
|
||||||
# Insert a minimal job to satisfy FK
|
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO jobs (id, title, company, url, source, status) VALUES (1, 'CSM', 'Acme', 'http://x.com', 'test', 'approved')"
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
gap_report = json.dumps([{"term": "Gainsight", "section": "skills", "priority": 1, "rationale": "test"}])
|
|
||||||
save_optimized_resume(db_path, job_id=1, text="Rewritten resume text.", gap_report=gap_report)
|
|
||||||
|
|
||||||
result = get_optimized_resume(db_path, job_id=1)
|
|
||||||
assert result["optimized_resume"] == "Rewritten resume text."
|
|
||||||
parsed = json.loads(result["ats_gap_report"])
|
|
||||||
assert parsed[0]["term"] == "Gainsight"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_optimized_resume_returns_empty_for_missing(tmp_path):
|
|
||||||
"""get_optimized_resume returns empty strings when no record exists."""
|
|
||||||
from scripts.db import init_db, get_optimized_resume
|
|
||||||
|
|
||||||
db_path = tmp_path / "test.db"
|
|
||||||
init_db(db_path)
|
|
||||||
|
|
||||||
result = get_optimized_resume(db_path, job_id=999)
|
|
||||||
assert result["optimized_resume"] == ""
|
|
||||||
assert result["ats_gap_report"] == ""
|
|
||||||
|
|
@ -109,33 +109,24 @@ def test_missing_budget_logs_warning(tmp_db, caplog):
|
||||||
ts.LLM_TASK_TYPES = frozenset(original)
|
ts.LLM_TASK_TYPES = frozenset(original)
|
||||||
|
|
||||||
|
|
||||||
def test_cpu_only_system_creates_scheduler(tmp_db, monkeypatch):
|
def test_cpu_only_system_gets_unlimited_vram(tmp_db, monkeypatch):
|
||||||
"""Scheduler constructs without error when _get_gpus() returns empty list.
|
"""_available_vram is 999.0 when _get_gpus() returns empty list."""
|
||||||
|
# Patch the module-level _get_gpus in task_scheduler (not preflight)
|
||||||
LocalScheduler has no VRAM gating — it runs tasks regardless of GPU count.
|
# so __init__'s _ts_mod._get_gpus() call picks up the mock.
|
||||||
VRAM-aware scheduling is handled by circuitforge_orch's coordinator.
|
|
||||||
"""
|
|
||||||
monkeypatch.setattr("scripts.task_scheduler._get_gpus", lambda: [])
|
monkeypatch.setattr("scripts.task_scheduler._get_gpus", lambda: [])
|
||||||
s = TaskScheduler(tmp_db, _noop_run_task)
|
s = TaskScheduler(tmp_db, _noop_run_task)
|
||||||
# Scheduler still has correct budgets configured; no VRAM attribute expected
|
assert s._available_vram == 999.0
|
||||||
# Scheduler constructed successfully; budgets contain all LLM task types.
|
|
||||||
# Does not assert exact values -- a sibling test may write a config override
|
|
||||||
# to the shared pytest tmp dir, causing _load_config_overrides to pick it up.
|
|
||||||
assert set(s._budgets.keys()) >= LLM_TASK_TYPES
|
|
||||||
|
|
||||||
|
|
||||||
def test_gpu_detection_does_not_affect_local_scheduler(tmp_db, monkeypatch):
|
def test_gpu_vram_summed_across_all_gpus(tmp_db, monkeypatch):
|
||||||
"""LocalScheduler ignores GPU VRAM — it has no _available_vram attribute.
|
"""_available_vram sums vram_total_gb across all detected GPUs."""
|
||||||
|
|
||||||
VRAM-gated concurrency requires circuitforge_orch (Paid tier).
|
|
||||||
"""
|
|
||||||
fake_gpus = [
|
fake_gpus = [
|
||||||
{"name": "RTX 3090", "vram_total_gb": 24.0, "vram_free_gb": 20.0},
|
{"name": "RTX 3090", "vram_total_gb": 24.0, "vram_free_gb": 20.0},
|
||||||
{"name": "RTX 3090", "vram_total_gb": 24.0, "vram_free_gb": 18.0},
|
{"name": "RTX 3090", "vram_total_gb": 24.0, "vram_free_gb": 18.0},
|
||||||
]
|
]
|
||||||
monkeypatch.setattr("scripts.task_scheduler._get_gpus", lambda: fake_gpus)
|
monkeypatch.setattr("scripts.task_scheduler._get_gpus", lambda: fake_gpus)
|
||||||
s = TaskScheduler(tmp_db, _noop_run_task)
|
s = TaskScheduler(tmp_db, _noop_run_task)
|
||||||
assert not hasattr(s, "_available_vram")
|
assert s._available_vram == 48.0
|
||||||
|
|
||||||
|
|
||||||
def test_enqueue_adds_taskspec_to_deque(tmp_db):
|
def test_enqueue_adds_taskspec_to_deque(tmp_db):
|
||||||
|
|
@ -215,37 +206,40 @@ def _make_recording_run_task(log: list, done_event: threading.Event, expected: i
|
||||||
return _run
|
return _run
|
||||||
|
|
||||||
|
|
||||||
def _start_scheduler(tmp_db, run_task_fn):
|
def _start_scheduler(tmp_db, run_task_fn, available_vram=999.0):
|
||||||
s = TaskScheduler(tmp_db, run_task_fn)
|
s = TaskScheduler(tmp_db, run_task_fn)
|
||||||
|
s._available_vram = available_vram
|
||||||
s.start()
|
s.start()
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
# ── Tests ─────────────────────────────────────────────────────────────────────
|
# ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_all_task_types_complete(tmp_db):
|
def test_deepest_queue_wins_first_slot(tmp_db):
|
||||||
"""Scheduler runs tasks from multiple types; all complete.
|
"""Type with more queued tasks starts first when VRAM only fits one type."""
|
||||||
|
|
||||||
LocalScheduler runs type batches concurrently (no VRAM gating).
|
|
||||||
VRAM-gated sequential scheduling requires circuitforge_orch.
|
|
||||||
"""
|
|
||||||
log, done = [], threading.Event()
|
log, done = [], threading.Event()
|
||||||
|
|
||||||
|
# Build scheduler but DO NOT start it yet — enqueue all tasks first
|
||||||
|
# so the scheduler sees the full picture on its very first wake.
|
||||||
run_task_fn = _make_recording_run_task(log, done, 4)
|
run_task_fn = _make_recording_run_task(log, done, 4)
|
||||||
s = TaskScheduler(tmp_db, run_task_fn)
|
s = TaskScheduler(tmp_db, run_task_fn)
|
||||||
|
s._available_vram = 3.0 # fits cover_letter (2.5) but not +company_research (5.0)
|
||||||
|
|
||||||
|
# Enqueue cover_letter (3 tasks) and company_research (1 task) before start.
|
||||||
|
# cover_letter has the deeper queue and must win the first batch slot.
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
s.enqueue(i + 1, "cover_letter", i + 1, None)
|
s.enqueue(i + 1, "cover_letter", i + 1, None)
|
||||||
s.enqueue(4, "company_research", 4, None)
|
s.enqueue(4, "company_research", 4, None)
|
||||||
|
|
||||||
s.start()
|
s.start() # scheduler now sees all tasks atomically on its first iteration
|
||||||
assert done.wait(timeout=5.0), "timed out — not all 4 tasks completed"
|
assert done.wait(timeout=5.0), "timed out — not all 4 tasks completed"
|
||||||
s.shutdown()
|
s.shutdown()
|
||||||
|
|
||||||
assert len(log) == 4
|
assert len(log) == 4
|
||||||
cl = [t for _, t in log if t == "cover_letter"]
|
cl = [i for i, (_, t) in enumerate(log) if t == "cover_letter"]
|
||||||
cr = [t for _, t in log if t == "company_research"]
|
cr = [i for i, (_, t) in enumerate(log) if t == "company_research"]
|
||||||
assert len(cl) == 3 and len(cr) == 1
|
assert len(cl) == 3 and len(cr) == 1
|
||||||
|
assert max(cl) < min(cr), "All cover_letter tasks must finish before company_research starts"
|
||||||
|
|
||||||
|
|
||||||
def test_fifo_within_type(tmp_db):
|
def test_fifo_within_type(tmp_db):
|
||||||
|
|
@ -262,8 +256,8 @@ def test_fifo_within_type(tmp_db):
|
||||||
assert [task_id for task_id, _ in log] == [10, 20, 30]
|
assert [task_id for task_id, _ in log] == [10, 20, 30]
|
||||||
|
|
||||||
|
|
||||||
def test_concurrent_batches_different_types(tmp_db):
|
def test_concurrent_batches_when_vram_allows(tmp_db):
|
||||||
"""Two type batches run concurrently (LocalScheduler has no VRAM gating)."""
|
"""Two type batches start simultaneously when VRAM fits both."""
|
||||||
started = {"cover_letter": threading.Event(), "company_research": threading.Event()}
|
started = {"cover_letter": threading.Event(), "company_research": threading.Event()}
|
||||||
all_done = threading.Event()
|
all_done = threading.Event()
|
||||||
log = []
|
log = []
|
||||||
|
|
@ -274,7 +268,8 @@ def test_concurrent_batches_different_types(tmp_db):
|
||||||
if len(log) >= 2:
|
if len(log) >= 2:
|
||||||
all_done.set()
|
all_done.set()
|
||||||
|
|
||||||
s = _start_scheduler(tmp_db, run_task)
|
# VRAM=10.0 fits both cover_letter (2.5) and company_research (5.0) simultaneously
|
||||||
|
s = _start_scheduler(tmp_db, run_task, available_vram=10.0)
|
||||||
s.enqueue(1, "cover_letter", 1, None)
|
s.enqueue(1, "cover_letter", 1, None)
|
||||||
s.enqueue(2, "company_research", 2, None)
|
s.enqueue(2, "company_research", 2, None)
|
||||||
|
|
||||||
|
|
@ -312,15 +307,8 @@ def test_new_tasks_picked_up_mid_batch(tmp_db):
|
||||||
assert log == [1, 2]
|
assert log == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning")
|
def test_worker_crash_releases_vram(tmp_db):
|
||||||
def test_worker_crash_does_not_stall_scheduler(tmp_db):
|
"""If _run_task raises, _reserved_vram returns to 0 and scheduler continues."""
|
||||||
"""If _run_task raises, the scheduler continues processing the next task.
|
|
||||||
|
|
||||||
The batch_worker intentionally lets the RuntimeError propagate to the thread
|
|
||||||
boundary (so LocalScheduler can detect crash vs. normal exit). This produces
|
|
||||||
a PytestUnhandledThreadExceptionWarning -- suppressed here because it is the
|
|
||||||
expected behavior under test.
|
|
||||||
"""
|
|
||||||
log, done = [], threading.Event()
|
log, done = [], threading.Event()
|
||||||
|
|
||||||
def run_task(db_path, task_id, task_type, job_id, params):
|
def run_task(db_path, task_id, task_type, job_id, params):
|
||||||
|
|
@ -329,15 +317,16 @@ def test_worker_crash_does_not_stall_scheduler(tmp_db):
|
||||||
log.append(task_id)
|
log.append(task_id)
|
||||||
done.set()
|
done.set()
|
||||||
|
|
||||||
s = _start_scheduler(tmp_db, run_task)
|
s = _start_scheduler(tmp_db, run_task, available_vram=3.0)
|
||||||
s.enqueue(1, "cover_letter", 1, None)
|
s.enqueue(1, "cover_letter", 1, None)
|
||||||
s.enqueue(2, "cover_letter", 2, None)
|
s.enqueue(2, "cover_letter", 2, None)
|
||||||
|
|
||||||
assert done.wait(timeout=5.0), "timed out — task 2 never completed after task 1 crash"
|
assert done.wait(timeout=5.0), "timed out — task 2 never completed after task 1 crash"
|
||||||
s.shutdown()
|
s.shutdown()
|
||||||
|
|
||||||
# Second task still ran despite first crashing
|
# Second task still ran, VRAM was released
|
||||||
assert 2 in log
|
assert 2 in log
|
||||||
|
assert s._reserved_vram == 0.0
|
||||||
|
|
||||||
|
|
||||||
def test_get_scheduler_returns_singleton(tmp_db):
|
def test_get_scheduler_returns_singleton(tmp_db):
|
||||||
|
|
@ -481,14 +470,3 @@ def test_llm_tasks_routed_to_scheduler(tmp_db):
|
||||||
task_runner.submit_task(tmp_db, "cover_letter", 1)
|
task_runner.submit_task(tmp_db, "cover_letter", 1)
|
||||||
|
|
||||||
assert "cover_letter" in enqueue_calls
|
assert "cover_letter" in enqueue_calls
|
||||||
|
|
||||||
|
|
||||||
def test_shim_exports_unchanged_api():
|
|
||||||
"""Peregrine shim must re-export LLM_TASK_TYPES, get_scheduler, reset_scheduler."""
|
|
||||||
from scripts.task_scheduler import LLM_TASK_TYPES, get_scheduler, reset_scheduler
|
|
||||||
assert "cover_letter" in LLM_TASK_TYPES
|
|
||||||
assert "company_research" in LLM_TASK_TYPES
|
|
||||||
assert "wizard_generate" in LLM_TASK_TYPES
|
|
||||||
assert "resume_optimize" in LLM_TASK_TYPES
|
|
||||||
assert callable(get_scheduler)
|
|
||||||
assert callable(reset_scheduler)
|
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
"""Tests for app/components/ui_switcher.py.
|
|
||||||
|
|
||||||
Streamlit is not running during tests — mock all st.* calls.
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
import pytest
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def profile_yaml(tmp_path):
|
|
||||||
data = {"name": "Test", "ui_preference": "streamlit", "wizard_complete": True}
|
|
||||||
p = tmp_path / "user.yaml"
|
|
||||||
p.write_text(yaml.dump(data))
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
def test_sync_cookie_injects_vue_js(profile_yaml, monkeypatch):
|
|
||||||
"""When ui_preference is vue, JS sets prgn_ui=vue."""
|
|
||||||
import yaml as _yaml
|
|
||||||
profile_yaml.write_text(_yaml.dump({"name": "T", "ui_preference": "vue"}))
|
|
||||||
|
|
||||||
injected = []
|
|
||||||
monkeypatch.setattr("streamlit.components.v1.html", lambda html, height=0: injected.append(html))
|
|
||||||
monkeypatch.setattr("streamlit.query_params", {}, raising=False)
|
|
||||||
|
|
||||||
from app.components.ui_switcher import sync_ui_cookie
|
|
||||||
sync_ui_cookie(profile_yaml, tier="paid")
|
|
||||||
|
|
||||||
assert any("prgn_ui=vue" in s for s in injected)
|
|
||||||
|
|
||||||
|
|
||||||
def test_sync_cookie_injects_streamlit_js(profile_yaml, monkeypatch):
|
|
||||||
"""When ui_preference is streamlit, JS sets prgn_ui=streamlit."""
|
|
||||||
injected = []
|
|
||||||
monkeypatch.setattr("streamlit.components.v1.html", lambda html, height=0: injected.append(html))
|
|
||||||
monkeypatch.setattr("streamlit.query_params", {}, raising=False)
|
|
||||||
|
|
||||||
from app.components.ui_switcher import sync_ui_cookie
|
|
||||||
sync_ui_cookie(profile_yaml, tier="paid")
|
|
||||||
|
|
||||||
assert any("prgn_ui=streamlit" in s for s in injected)
|
|
||||||
|
|
||||||
|
|
||||||
def test_sync_cookie_prgn_switch_param_overrides_yaml(profile_yaml, monkeypatch):
|
|
||||||
"""?prgn_switch=streamlit in query params resets ui_preference to streamlit."""
|
|
||||||
import yaml as _yaml
|
|
||||||
profile_yaml.write_text(_yaml.dump({"name": "T", "ui_preference": "vue"}))
|
|
||||||
|
|
||||||
injected = []
|
|
||||||
monkeypatch.setattr("streamlit.components.v1.html", lambda html, height=0: injected.append(html))
|
|
||||||
monkeypatch.setattr("streamlit.query_params", {"prgn_switch": "streamlit"}, raising=False)
|
|
||||||
|
|
||||||
with patch('app.components.ui_switcher._DEMO_MODE', False):
|
|
||||||
from app.components.ui_switcher import sync_ui_cookie
|
|
||||||
sync_ui_cookie(profile_yaml, tier="paid")
|
|
||||||
|
|
||||||
# user.yaml should now say streamlit
|
|
||||||
saved = _yaml.safe_load(profile_yaml.read_text())
|
|
||||||
assert saved["ui_preference"] == "streamlit"
|
|
||||||
# JS should set cookie to streamlit
|
|
||||||
assert any("prgn_ui=streamlit" in s for s in injected)
|
|
||||||
|
|
||||||
|
|
||||||
def test_sync_cookie_free_tier_keeps_vue(profile_yaml, monkeypatch):
|
|
||||||
"""Free-tier user with vue preference keeps vue (vue_ui_beta is free tier).
|
|
||||||
|
|
||||||
Previously this test verified a downgrade to streamlit. Vue SPA was opened
|
|
||||||
to free tier in issue #20 — the downgrade path no longer triggers.
|
|
||||||
"""
|
|
||||||
import yaml as _yaml
|
|
||||||
profile_yaml.write_text(_yaml.dump({"name": "T", "ui_preference": "vue"}))
|
|
||||||
|
|
||||||
injected = []
|
|
||||||
monkeypatch.setattr("streamlit.components.v1.html", lambda html, height=0: injected.append(html))
|
|
||||||
monkeypatch.setattr("streamlit.query_params", {}, raising=False)
|
|
||||||
|
|
||||||
with patch('app.components.ui_switcher._DEMO_MODE', False):
|
|
||||||
from app.components.ui_switcher import sync_ui_cookie
|
|
||||||
sync_ui_cookie(profile_yaml, tier="free")
|
|
||||||
|
|
||||||
saved = _yaml.safe_load(profile_yaml.read_text())
|
|
||||||
assert saved["ui_preference"] == "vue"
|
|
||||||
assert any("prgn_ui=vue" in s for s in injected)
|
|
||||||
|
|
||||||
|
|
||||||
def test_switch_ui_writes_yaml_and_calls_sync(profile_yaml, monkeypatch):
|
|
||||||
"""switch_ui(to='vue') writes user.yaml and calls sync."""
|
|
||||||
import yaml as _yaml
|
|
||||||
synced = []
|
|
||||||
monkeypatch.setattr("streamlit.components.v1.html", lambda html, height=0: synced.append(html))
|
|
||||||
monkeypatch.setattr("streamlit.query_params", {}, raising=False)
|
|
||||||
monkeypatch.setattr("streamlit.rerun", lambda: None)
|
|
||||||
|
|
||||||
with patch('app.components.ui_switcher._DEMO_MODE', False):
|
|
||||||
from app.components.ui_switcher import switch_ui
|
|
||||||
switch_ui(profile_yaml, to="vue", tier="paid")
|
|
||||||
|
|
||||||
saved = _yaml.safe_load(profile_yaml.read_text())
|
|
||||||
assert saved["ui_preference"] == "vue"
|
|
||||||
assert any("prgn_ui=vue" in s for s in synced)
|
|
||||||
|
|
@ -106,34 +106,3 @@ def test_effective_tier_no_override(tmp_path):
|
||||||
p.write_text("name: T\nemail: t@t.com\ncareer_summary: x\ntier: paid\n")
|
p.write_text("name: T\nemail: t@t.com\ncareer_summary: x\ntier: paid\n")
|
||||||
u = UserProfile(p)
|
u = UserProfile(p)
|
||||||
assert u.effective_tier == "paid"
|
assert u.effective_tier == "paid"
|
||||||
|
|
||||||
def test_ui_preference_default(tmp_path):
|
|
||||||
"""Fresh profile defaults to streamlit."""
|
|
||||||
p = tmp_path / "user.yaml"
|
|
||||||
p.write_text("name: Test User\n")
|
|
||||||
profile = UserProfile(p)
|
|
||||||
assert profile.ui_preference == "streamlit"
|
|
||||||
|
|
||||||
def test_ui_preference_vue(tmp_path):
|
|
||||||
"""Saved vue preference loads correctly."""
|
|
||||||
p = tmp_path / "user.yaml"
|
|
||||||
p.write_text("name: Test\nui_preference: vue\n")
|
|
||||||
profile = UserProfile(p)
|
|
||||||
assert profile.ui_preference == "vue"
|
|
||||||
|
|
||||||
def test_ui_preference_roundtrip(tmp_path):
|
|
||||||
"""Saving ui_preference: vue persists and reloads."""
|
|
||||||
p = tmp_path / "user.yaml"
|
|
||||||
p.write_text("name: Test\n")
|
|
||||||
profile = UserProfile(p)
|
|
||||||
profile.ui_preference = "vue"
|
|
||||||
profile.save()
|
|
||||||
reloaded = UserProfile(p)
|
|
||||||
assert reloaded.ui_preference == "vue"
|
|
||||||
|
|
||||||
def test_ui_preference_invalid_falls_back(tmp_path):
|
|
||||||
"""Unknown value falls back to streamlit."""
|
|
||||||
p = tmp_path / "user.yaml"
|
|
||||||
p.write_text("name: Test\nui_preference: newui\n")
|
|
||||||
profile = UserProfile(p)
|
|
||||||
assert profile.ui_preference == "streamlit"
|
|
||||||
|
|
|
||||||
|
|
@ -1,368 +0,0 @@
|
||||||
"""Tests for wizard API endpoints (GET/POST /api/wizard/*)."""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import yaml
|
|
||||||
import pytest
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch, MagicMock
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
# ── Path bootstrap ────────────────────────────────────────────────────────────
|
|
||||||
_REPO = Path(__file__).parent.parent
|
|
||||||
if str(_REPO) not in sys.path:
|
|
||||||
sys.path.insert(0, str(_REPO))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def client():
|
|
||||||
from dev_api import app
|
|
||||||
return TestClient(app)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _write_user_yaml(path: Path, data: dict | None = None) -> None:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
payload = data if data is not None else {}
|
|
||||||
path.write_text(yaml.dump(payload, allow_unicode=True, default_flow_style=False))
|
|
||||||
|
|
||||||
|
|
||||||
def _read_user_yaml(path: Path) -> dict:
|
|
||||||
if not path.exists():
|
|
||||||
return {}
|
|
||||||
return yaml.safe_load(path.read_text()) or {}
|
|
||||||
|
|
||||||
|
|
||||||
# ── GET /api/config/app — wizardComplete + isDemo ─────────────────────────────
|
|
||||||
|
|
||||||
class TestAppConfigWizardFields:
|
|
||||||
def test_wizard_complete_false_when_missing(self, client, tmp_path):
|
|
||||||
yaml_path = tmp_path / "config" / "user.yaml"
|
|
||||||
# user.yaml does not exist yet
|
|
||||||
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
|
|
||||||
r = client.get("/api/config/app")
|
|
||||||
assert r.status_code == 200
|
|
||||||
assert r.json()["wizardComplete"] is False
|
|
||||||
|
|
||||||
def test_wizard_complete_true_when_set(self, client, tmp_path):
|
|
||||||
yaml_path = tmp_path / "config" / "user.yaml"
|
|
||||||
_write_user_yaml(yaml_path, {"wizard_complete": True})
|
|
||||||
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
|
|
||||||
r = client.get("/api/config/app")
|
|
||||||
assert r.json()["wizardComplete"] is True
|
|
||||||
|
|
||||||
def test_is_demo_false_by_default(self, client, tmp_path):
|
|
||||||
yaml_path = tmp_path / "config" / "user.yaml"
|
|
||||||
_write_user_yaml(yaml_path, {"wizard_complete": True})
|
|
||||||
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
|
|
||||||
with patch.dict(os.environ, {"DEMO_MODE": ""}, clear=False):
|
|
||||||
r = client.get("/api/config/app")
|
|
||||||
assert r.json()["isDemo"] is False
|
|
||||||
|
|
||||||
def test_is_demo_true_when_env_set(self, client, tmp_path):
|
|
||||||
yaml_path = tmp_path / "config" / "user.yaml"
|
|
||||||
_write_user_yaml(yaml_path, {"wizard_complete": True})
|
|
||||||
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
|
|
||||||
with patch.dict(os.environ, {"DEMO_MODE": "true"}, clear=False):
|
|
||||||
r = client.get("/api/config/app")
|
|
||||||
assert r.json()["isDemo"] is True
|
|
||||||
|
|
||||||
|
|
||||||
# ── GET /api/wizard/status ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestWizardStatus:
|
|
||||||
def test_returns_not_complete_when_no_yaml(self, client, tmp_path):
|
|
||||||
yaml_path = tmp_path / "config" / "user.yaml"
|
|
||||||
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
|
|
||||||
r = client.get("/api/wizard/status")
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert body["wizard_complete"] is False
|
|
||||||
assert body["wizard_step"] == 0
|
|
||||||
|
|
||||||
def test_returns_saved_step(self, client, tmp_path):
|
|
||||||
yaml_path = tmp_path / "config" / "user.yaml"
|
|
||||||
_write_user_yaml(yaml_path, {"wizard_step": 3, "name": "Alex"})
|
|
||||||
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
|
|
||||||
r = client.get("/api/wizard/status")
|
|
||||||
body = r.json()
|
|
||||||
assert body["wizard_step"] == 3
|
|
||||||
assert body["saved_data"]["name"] == "Alex"
|
|
||||||
|
|
||||||
def test_returns_complete_true(self, client, tmp_path):
|
|
||||||
yaml_path = tmp_path / "config" / "user.yaml"
|
|
||||||
_write_user_yaml(yaml_path, {"wizard_complete": True})
|
|
||||||
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
|
|
||||||
r = client.get("/api/wizard/status")
|
|
||||||
assert r.json()["wizard_complete"] is True
|
|
||||||
|
|
||||||
|
|
||||||
# ── GET /api/wizard/hardware ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestWizardHardware:
|
|
||||||
def test_returns_profiles_list(self, client):
|
|
||||||
r = client.get("/api/wizard/hardware")
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert set(body["profiles"]) == {"remote", "cpu", "single-gpu", "dual-gpu"}
|
|
||||||
assert "gpus" in body
|
|
||||||
assert "suggested_profile" in body
|
|
||||||
|
|
||||||
def test_gpu_from_env_var(self, client):
|
|
||||||
with patch.dict(os.environ, {"PEREGRINE_GPU_NAMES": "RTX 4090,RTX 3080"}, clear=False):
|
|
||||||
r = client.get("/api/wizard/hardware")
|
|
||||||
body = r.json()
|
|
||||||
assert body["gpus"] == ["RTX 4090", "RTX 3080"]
|
|
||||||
assert body["suggested_profile"] == "dual-gpu"
|
|
||||||
|
|
||||||
def test_single_gpu_suggests_single(self, client):
|
|
||||||
with patch.dict(os.environ, {"PEREGRINE_GPU_NAMES": "RTX 4090"}, clear=False):
|
|
||||||
with patch.dict(os.environ, {"RECOMMENDED_PROFILE": ""}, clear=False):
|
|
||||||
r = client.get("/api/wizard/hardware")
|
|
||||||
assert r.json()["suggested_profile"] == "single-gpu"
|
|
||||||
|
|
||||||
def test_no_gpus_suggests_remote(self, client):
|
|
||||||
with patch.dict(os.environ, {"PEREGRINE_GPU_NAMES": ""}, clear=False):
|
|
||||||
with patch.dict(os.environ, {"RECOMMENDED_PROFILE": ""}, clear=False):
|
|
||||||
with patch("subprocess.check_output", side_effect=FileNotFoundError):
|
|
||||||
r = client.get("/api/wizard/hardware")
|
|
||||||
assert r.json()["suggested_profile"] == "remote"
|
|
||||||
assert r.json()["gpus"] == []
|
|
||||||
|
|
||||||
def test_recommended_profile_env_takes_priority(self, client):
|
|
||||||
with patch.dict(os.environ,
|
|
||||||
{"PEREGRINE_GPU_NAMES": "RTX 4090", "RECOMMENDED_PROFILE": "cpu"},
|
|
||||||
clear=False):
|
|
||||||
r = client.get("/api/wizard/hardware")
|
|
||||||
assert r.json()["suggested_profile"] == "cpu"
|
|
||||||
|
|
||||||
|
|
||||||
# ── POST /api/wizard/step ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestWizardStep:
|
|
||||||
def test_step1_saves_inference_profile(self, client, tmp_path):
|
|
||||||
yaml_path = tmp_path / "config" / "user.yaml"
|
|
||||||
_write_user_yaml(yaml_path, {})
|
|
||||||
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
|
|
||||||
r = client.post("/api/wizard/step",
|
|
||||||
json={"step": 1, "data": {"inference_profile": "single-gpu"}})
|
|
||||||
assert r.status_code == 200
|
|
||||||
saved = _read_user_yaml(yaml_path)
|
|
||||||
assert saved["inference_profile"] == "single-gpu"
|
|
||||||
assert saved["wizard_step"] == 1
|
|
||||||
|
|
||||||
def test_step1_rejects_unknown_profile(self, client, tmp_path):
|
|
||||||
yaml_path = tmp_path / "config" / "user.yaml"
|
|
||||||
_write_user_yaml(yaml_path, {})
|
|
||||||
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
|
|
||||||
r = client.post("/api/wizard/step",
|
|
||||||
json={"step": 1, "data": {"inference_profile": "turbo-gpu"}})
|
|
||||||
assert r.status_code == 400
|
|
||||||
|
|
||||||
def test_step2_saves_tier(self, client, tmp_path):
|
|
||||||
yaml_path = tmp_path / "config" / "user.yaml"
|
|
||||||
_write_user_yaml(yaml_path, {})
|
|
||||||
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
|
|
||||||
r = client.post("/api/wizard/step",
|
|
||||||
json={"step": 2, "data": {"tier": "paid"}})
|
|
||||||
assert r.status_code == 200
|
|
||||||
assert _read_user_yaml(yaml_path)["tier"] == "paid"
|
|
||||||
|
|
||||||
def test_step2_rejects_unknown_tier(self, client, tmp_path):
|
|
||||||
yaml_path = tmp_path / "config" / "user.yaml"
|
|
||||||
_write_user_yaml(yaml_path, {})
|
|
||||||
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
|
|
||||||
r = client.post("/api/wizard/step",
|
|
||||||
json={"step": 2, "data": {"tier": "enterprise"}})
|
|
||||||
assert r.status_code == 400
|
|
||||||
|
|
||||||
def test_step3_writes_resume_yaml(self, client, tmp_path):
|
|
||||||
yaml_path = tmp_path / "config" / "user.yaml"
|
|
||||||
_write_user_yaml(yaml_path, {})
|
|
||||||
resume = {"experience": [{"title": "Engineer", "company": "Acme"}]}
|
|
||||||
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
|
|
||||||
r = client.post("/api/wizard/step",
|
|
||||||
json={"step": 3, "data": {"resume": resume}})
|
|
||||||
assert r.status_code == 200
|
|
||||||
resume_path = yaml_path.parent / "plain_text_resume.yaml"
|
|
||||||
assert resume_path.exists()
|
|
||||||
saved_resume = yaml.safe_load(resume_path.read_text())
|
|
||||||
assert saved_resume["experience"][0]["title"] == "Engineer"
|
|
||||||
|
|
||||||
def test_step4_saves_identity_fields(self, client, tmp_path):
|
|
||||||
yaml_path = tmp_path / "config" / "user.yaml"
|
|
||||||
_write_user_yaml(yaml_path, {})
|
|
||||||
identity = {
|
|
||||||
"name": "Alex Rivera",
|
|
||||||
"email": "alex@example.com",
|
|
||||||
"phone": "555-1234",
|
|
||||||
"linkedin": "https://linkedin.com/in/alex",
|
|
||||||
"career_summary": "Experienced engineer.",
|
|
||||||
}
|
|
||||||
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
|
|
||||||
r = client.post("/api/wizard/step", json={"step": 4, "data": identity})
|
|
||||||
assert r.status_code == 200
|
|
||||||
saved = _read_user_yaml(yaml_path)
|
|
||||||
assert saved["name"] == "Alex Rivera"
|
|
||||||
assert saved["career_summary"] == "Experienced engineer."
|
|
||||||
assert saved["wizard_step"] == 4
|
|
||||||
|
|
||||||
def test_step5_writes_env_keys(self, client, tmp_path):
|
|
||||||
yaml_path = tmp_path / "config" / "user.yaml"
|
|
||||||
env_path = tmp_path / ".env"
|
|
||||||
env_path.write_text("SOME_KEY=existing\n")
|
|
||||||
_write_user_yaml(yaml_path, {})
|
|
||||||
# Patch both _wizard_yaml_path and the Path resolution inside wizard_save_step
|
|
||||||
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
|
|
||||||
with patch("dev_api.Path") as mock_path_cls:
|
|
||||||
# Only intercept the .env path construction; let other Path() calls pass through
|
|
||||||
real_path = Path
|
|
||||||
def path_side_effect(*args):
|
|
||||||
result = real_path(*args)
|
|
||||||
return result
|
|
||||||
mock_path_cls.side_effect = path_side_effect
|
|
||||||
|
|
||||||
# Direct approach: monkeypatch the env path
|
|
||||||
import dev_api as _dev_api
|
|
||||||
original_fn = _dev_api.wizard_save_step
|
|
||||||
|
|
||||||
# Simpler: just test via the real endpoint, verify env not written if no key given
|
|
||||||
r = client.post("/api/wizard/step",
|
|
||||||
json={"step": 5, "data": {"services": {"ollama_host": "localhost"}}})
|
|
||||||
assert r.status_code == 200
|
|
||||||
|
|
||||||
def test_step6_writes_search_profiles(self, client, tmp_path):
|
|
||||||
yaml_path = tmp_path / "config" / "user.yaml"
|
|
||||||
search_path = tmp_path / "config" / "search_profiles.yaml"
|
|
||||||
_write_user_yaml(yaml_path, {})
|
|
||||||
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
|
|
||||||
with patch("dev_api._search_prefs_path", return_value=search_path):
|
|
||||||
r = client.post("/api/wizard/step",
|
|
||||||
json={"step": 6, "data": {
|
|
||||||
"titles": ["Software Engineer", "Backend Developer"],
|
|
||||||
"locations": ["Remote", "Austin, TX"],
|
|
||||||
}})
|
|
||||||
assert r.status_code == 200
|
|
||||||
assert search_path.exists()
|
|
||||||
prefs = yaml.safe_load(search_path.read_text())
|
|
||||||
assert prefs["default"]["job_titles"] == ["Software Engineer", "Backend Developer"]
|
|
||||||
assert "Remote" in prefs["default"]["location"]
|
|
||||||
|
|
||||||
def test_step7_only_advances_counter(self, client, tmp_path):
|
|
||||||
yaml_path = tmp_path / "config" / "user.yaml"
|
|
||||||
_write_user_yaml(yaml_path, {})
|
|
||||||
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
|
|
||||||
r = client.post("/api/wizard/step", json={"step": 7, "data": {}})
|
|
||||||
assert r.status_code == 200
|
|
||||||
assert _read_user_yaml(yaml_path)["wizard_step"] == 7
|
|
||||||
|
|
||||||
def test_invalid_step_number(self, client, tmp_path):
|
|
||||||
yaml_path = tmp_path / "config" / "user.yaml"
|
|
||||||
_write_user_yaml(yaml_path, {})
|
|
||||||
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
|
|
||||||
r = client.post("/api/wizard/step", json={"step": 99, "data": {}})
|
|
||||||
assert r.status_code == 400
|
|
||||||
|
|
||||||
def test_crash_recovery_round_trip(self, client, tmp_path):
|
|
||||||
"""Save steps 1-4 sequentially, then verify status reflects step 4."""
|
|
||||||
yaml_path = tmp_path / "config" / "user.yaml"
|
|
||||||
_write_user_yaml(yaml_path, {})
|
|
||||||
steps = [
|
|
||||||
(1, {"inference_profile": "cpu"}),
|
|
||||||
(2, {"tier": "free"}),
|
|
||||||
(4, {"name": "Alex", "email": "a@b.com", "career_summary": "Eng."}),
|
|
||||||
]
|
|
||||||
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
|
|
||||||
for step, data in steps:
|
|
||||||
r = client.post("/api/wizard/step", json={"step": step, "data": data})
|
|
||||||
assert r.status_code == 200
|
|
||||||
|
|
||||||
r = client.get("/api/wizard/status")
|
|
||||||
|
|
||||||
body = r.json()
|
|
||||||
assert body["wizard_step"] == 4
|
|
||||||
assert body["saved_data"]["name"] == "Alex"
|
|
||||||
assert body["saved_data"]["inference_profile"] == "cpu"
|
|
||||||
|
|
||||||
|
|
||||||
# ── POST /api/wizard/inference/test ──────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestWizardInferenceTest:
|
|
||||||
def test_local_profile_ollama_running(self, client):
|
|
||||||
mock_resp = MagicMock()
|
|
||||||
mock_resp.status_code = 200
|
|
||||||
with patch("dev_api.requests.get", return_value=mock_resp):
|
|
||||||
r = client.post("/api/wizard/inference/test",
|
|
||||||
json={"profile": "cpu", "ollama_host": "localhost",
|
|
||||||
"ollama_port": 11434})
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert body["ok"] is True
|
|
||||||
assert "Ollama" in body["message"]
|
|
||||||
|
|
||||||
def test_local_profile_ollama_down_soft_fail(self, client):
|
|
||||||
import requests as _req
|
|
||||||
with patch("dev_api.requests.get", side_effect=_req.exceptions.ConnectionError):
|
|
||||||
r = client.post("/api/wizard/inference/test",
|
|
||||||
json={"profile": "single-gpu"})
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert body["ok"] is False
|
|
||||||
assert "configure" in body["message"].lower()
|
|
||||||
|
|
||||||
def test_remote_profile_llm_responding(self, client):
|
|
||||||
# LLMRouter is imported inside wizard_test_inference — patch the source module
|
|
||||||
with patch("scripts.llm_router.LLMRouter") as mock_cls:
|
|
||||||
mock_cls.return_value.complete.return_value = "OK"
|
|
||||||
r = client.post("/api/wizard/inference/test",
|
|
||||||
json={"profile": "remote", "anthropic_key": "sk-ant-test"})
|
|
||||||
assert r.status_code == 200
|
|
||||||
assert r.json()["ok"] is True
|
|
||||||
|
|
||||||
def test_remote_profile_llm_error(self, client):
|
|
||||||
with patch("scripts.llm_router.LLMRouter") as mock_cls:
|
|
||||||
mock_cls.return_value.complete.side_effect = RuntimeError("no key")
|
|
||||||
r = client.post("/api/wizard/inference/test",
|
|
||||||
json={"profile": "remote"})
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert body["ok"] is False
|
|
||||||
assert "failed" in body["message"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
# ── POST /api/wizard/complete ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestWizardComplete:
|
|
||||||
def test_sets_wizard_complete_true(self, client, tmp_path):
|
|
||||||
yaml_path = tmp_path / "config" / "user.yaml"
|
|
||||||
_write_user_yaml(yaml_path, {"wizard_step": 6, "name": "Alex"})
|
|
||||||
# apply_service_urls is a local import inside wizard_complete — patch source module
|
|
||||||
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
|
|
||||||
with patch("scripts.generate_llm_config.apply_service_urls",
|
|
||||||
side_effect=Exception("no llm.yaml")):
|
|
||||||
r = client.post("/api/wizard/complete")
|
|
||||||
assert r.status_code == 200
|
|
||||||
assert r.json()["ok"] is True
|
|
||||||
saved = _read_user_yaml(yaml_path)
|
|
||||||
assert saved["wizard_complete"] is True
|
|
||||||
assert "wizard_step" not in saved
|
|
||||||
assert saved["name"] == "Alex" # other fields preserved
|
|
||||||
|
|
||||||
def test_complete_removes_wizard_step(self, client, tmp_path):
|
|
||||||
yaml_path = tmp_path / "config" / "user.yaml"
|
|
||||||
_write_user_yaml(yaml_path, {"wizard_step": 7, "tier": "paid"})
|
|
||||||
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
|
|
||||||
with patch("scripts.generate_llm_config.apply_service_urls", return_value=None):
|
|
||||||
client.post("/api/wizard/complete")
|
|
||||||
saved = _read_user_yaml(yaml_path)
|
|
||||||
assert "wizard_step" not in saved
|
|
||||||
assert saved["tier"] == "paid"
|
|
||||||
|
|
||||||
def test_complete_tolerates_missing_llm_yaml(self, client, tmp_path):
|
|
||||||
yaml_path = tmp_path / "config" / "user.yaml"
|
|
||||||
_write_user_yaml(yaml_path, {})
|
|
||||||
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
|
|
||||||
# llm.yaml doesn't exist → apply_service_urls is never called, no error
|
|
||||||
r = client.post("/api/wizard/complete")
|
|
||||||
assert r.status_code == 200
|
|
||||||
assert r.json()["ok"] is True
|
|
||||||
|
|
@ -1,16 +1,12 @@
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
from app.wizard.tiers import can_use, tier_label, TIERS, FEATURES, BYOK_UNLOCKABLE
|
from app.wizard.tiers import can_use, tier_label, TIERS, FEATURES, BYOK_UNLOCKABLE
|
||||||
|
|
||||||
|
|
||||||
def test_tiers_list():
|
def test_tiers_list():
|
||||||
# Peregrine uses the core tier list; "ultra" is included but no features require it yet
|
assert TIERS == ["free", "paid", "premium"]
|
||||||
assert TIERS[:3] == ["free", "paid", "premium"]
|
|
||||||
assert "ultra" in TIERS
|
|
||||||
|
|
||||||
|
|
||||||
def test_can_use_free_feature_always():
|
def test_can_use_free_feature_always():
|
||||||
|
|
@ -116,42 +112,3 @@ def test_byok_false_preserves_original_gating():
|
||||||
# has_byok=False (default) must not change existing behaviour
|
# has_byok=False (default) must not change existing behaviour
|
||||||
assert can_use("free", "company_research", has_byok=False) is False
|
assert can_use("free", "company_research", has_byok=False) is False
|
||||||
assert can_use("paid", "company_research", has_byok=False) is True
|
assert can_use("paid", "company_research", has_byok=False) is True
|
||||||
|
|
||||||
|
|
||||||
# ── Vue UI Beta & Demo Tier tests ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_vue_ui_beta_free_tier():
|
|
||||||
# Vue SPA is open to all tiers (issue #20 — beta restriction removed)
|
|
||||||
assert can_use("free", "vue_ui_beta") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_vue_ui_beta_paid_tier():
|
|
||||||
assert can_use("paid", "vue_ui_beta") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_vue_ui_beta_premium_tier():
|
|
||||||
assert can_use("premium", "vue_ui_beta") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_can_use_demo_tier_overrides_real_tier():
|
|
||||||
# demo_tier="paid" overrides real tier "free" when DEMO_MODE is active
|
|
||||||
with patch('app.wizard.tiers._DEMO_MODE', True):
|
|
||||||
assert can_use("free", "company_research", demo_tier="paid") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_can_use_demo_tier_free_restricts():
|
|
||||||
# demo_tier="free" restricts access even if real tier is "paid"
|
|
||||||
with patch('app.wizard.tiers._DEMO_MODE', True):
|
|
||||||
assert can_use("paid", "model_fine_tuning", demo_tier="free") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_can_use_demo_tier_none_falls_back_to_real():
|
|
||||||
# demo_tier=None means no override regardless of DEMO_MODE
|
|
||||||
with patch('app.wizard.tiers._DEMO_MODE', True):
|
|
||||||
assert can_use("paid", "company_research", demo_tier=None) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_can_use_demo_tier_does_not_affect_non_demo():
|
|
||||||
# When _DEMO_MODE is False, demo_tier is ignored
|
|
||||||
with patch('app.wizard.tiers._DEMO_MODE', False):
|
|
||||||
assert can_use("free", "company_research", demo_tier="paid") is False
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 106 KiB |
|
|
@ -1,9 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<!-- Root uses .app-root class, NOT id="app" — index.html owns #app.
|
<!-- Root uses .app-root class, NOT id="app" — index.html owns #app.
|
||||||
Nested #app elements cause ambiguous CSS specificity. Gotcha #1. -->
|
Nested #app elements cause ambiguous CSS specificity. Gotcha #1. -->
|
||||||
<div class="app-root" :class="{ 'rich-motion': motion.rich.value, 'app-root--wizard': isWizard }">
|
<div class="app-root" :class="{ 'rich-motion': motion.rich.value }">
|
||||||
<AppNav v-if="!isWizard" />
|
<AppNav />
|
||||||
<main class="app-main" :class="{ 'app-main--wizard': isWizard }" id="main-content" tabindex="-1">
|
<main class="app-main" id="main-content" tabindex="-1">
|
||||||
<!-- Skip to main content link (screen reader / keyboard nav) -->
|
<!-- Skip to main content link (screen reader / keyboard nav) -->
|
||||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
|
@ -12,27 +12,21 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { RouterView, useRoute } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
import { useMotion } from './composables/useMotion'
|
import { useMotion } from './composables/useMotion'
|
||||||
import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
|
import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
|
||||||
import { useTheme } from './composables/useTheme'
|
|
||||||
import AppNav from './components/AppNav.vue'
|
import AppNav from './components/AppNav.vue'
|
||||||
import { useDigestStore } from './stores/digest'
|
import { useDigestStore } from './stores/digest'
|
||||||
|
|
||||||
const motion = useMotion()
|
const motion = useMotion()
|
||||||
const route = useRoute()
|
|
||||||
const { toggle, restore } = useHackerMode()
|
const { toggle, restore } = useHackerMode()
|
||||||
const { initTheme } = useTheme()
|
|
||||||
const digestStore = useDigestStore()
|
const digestStore = useDigestStore()
|
||||||
|
|
||||||
const isWizard = computed(() => route.path.startsWith('/setup'))
|
|
||||||
|
|
||||||
useKonamiCode(toggle)
|
useKonamiCode(toggle)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initTheme() // apply persisted theme (hacker mode takes priority inside initTheme)
|
restore() // re-apply hacker mode from localStorage on hard reload
|
||||||
restore() // kept for hacker mode re-entry on hard reload (initTheme handles it, belt+suspenders)
|
|
||||||
digestStore.fetchAll() // populate badge immediately, before user visits Digest tab
|
digestStore.fetchAll() // populate badge immediately, before user visits Digest tab
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -100,14 +94,4 @@ body {
|
||||||
padding-bottom: calc(56px + env(safe-area-inset-bottom));
|
padding-bottom: calc(56px + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Wizard: full-bleed, no sidebar offset, no tab-bar clearance */
|
|
||||||
.app-root--wizard {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-main--wizard {
|
|
||||||
margin-left: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -73,11 +73,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Accessible Solarpunk — dark (system dark mode) ─
|
/* ── Accessible Solarpunk — dark (system dark mode) ─
|
||||||
Activates when OS/browser is in dark mode AND no
|
Activates when OS/browser is in dark mode.
|
||||||
explicit theme is selected. Explicit [data-theme="*"]
|
Uses :not([data-theme="hacker"]) so the Konami easter
|
||||||
always wins over the system preference. */
|
egg always wins over the system preference. */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:not([data-theme]) {
|
:root:not([data-theme="hacker"]) {
|
||||||
/* Brand — lighter greens readable on dark surfaces */
|
/* Brand — lighter greens readable on dark surfaces */
|
||||||
--color-primary: #6ab870;
|
--color-primary: #6ab870;
|
||||||
--color-primary-hover: #7ecb84;
|
--color-primary-hover: #7ecb84;
|
||||||
|
|
@ -161,153 +161,6 @@
|
||||||
--color-accent-glow-lg: rgba(0, 255, 65, 0.6);
|
--color-accent-glow-lg: rgba(0, 255, 65, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Explicit light — forces light even on dark-OS ─ */
|
|
||||||
[data-theme="light"] {
|
|
||||||
--color-primary: #2d5a27;
|
|
||||||
--color-primary-hover: #234820;
|
|
||||||
--color-primary-light: #e8f2e7;
|
|
||||||
--color-surface: #eaeff8;
|
|
||||||
--color-surface-alt: #dde4f0;
|
|
||||||
--color-surface-raised: #f5f7fc;
|
|
||||||
--color-border: #a8b8d0;
|
|
||||||
--color-border-light: #ccd5e6;
|
|
||||||
--color-text: #1a2338;
|
|
||||||
--color-text-muted: #4a5c7a;
|
|
||||||
--color-text-inverse: #eaeff8;
|
|
||||||
--color-accent: #c4732a;
|
|
||||||
--color-accent-hover: #a85c1f;
|
|
||||||
--color-accent-light: #fdf0e4;
|
|
||||||
--color-success: #3a7a32;
|
|
||||||
--color-error: #c0392b;
|
|
||||||
--color-warning: #d4891a;
|
|
||||||
--color-info: #1e6091;
|
|
||||||
--shadow-sm: 0 1px 3px rgba(26, 35, 56, 0.08), 0 1px 2px rgba(26, 35, 56, 0.04);
|
|
||||||
--shadow-md: 0 4px 12px rgba(26, 35, 56, 0.1), 0 2px 4px rgba(26, 35, 56, 0.06);
|
|
||||||
--shadow-lg: 0 10px 30px rgba(26, 35, 56, 0.12), 0 4px 8px rgba(26, 35, 56, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Explicit dark — forces dark even on light-OS ── */
|
|
||||||
[data-theme="dark"] {
|
|
||||||
--color-primary: #6ab870;
|
|
||||||
--color-primary-hover: #7ecb84;
|
|
||||||
--color-primary-light: #162616;
|
|
||||||
--color-surface: #16202e;
|
|
||||||
--color-surface-alt: #1e2a3a;
|
|
||||||
--color-surface-raised: #263547;
|
|
||||||
--color-border: #2d4060;
|
|
||||||
--color-border-light: #233352;
|
|
||||||
--color-text: #e4eaf5;
|
|
||||||
--color-text-muted: #8da0bc;
|
|
||||||
--color-text-inverse: #16202e;
|
|
||||||
--color-accent: #e8a84a;
|
|
||||||
--color-accent-hover: #f5bc60;
|
|
||||||
--color-accent-light: #2d1e0a;
|
|
||||||
--color-success: #5eb85e;
|
|
||||||
--color-error: #e05252;
|
|
||||||
--color-warning: #e8a84a;
|
|
||||||
--color-info: #4da6e8;
|
|
||||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
|
|
||||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35), 0 2px 4px rgba(0, 0, 0, 0.2);
|
|
||||||
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.4), 0 4px 8px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Solarized Dark ──────────────────────────────── */
|
|
||||||
/* Ethan Schoonover's Solarized palette (dark variant) */
|
|
||||||
[data-theme="solarized-dark"] {
|
|
||||||
--color-primary: #2aa198; /* cyan — used as primary brand color */
|
|
||||||
--color-primary-hover: #35b8ad;
|
|
||||||
--color-primary-light: #002b36;
|
|
||||||
|
|
||||||
--color-surface: #002b36; /* base03 */
|
|
||||||
--color-surface-alt: #073642; /* base02 */
|
|
||||||
--color-surface-raised: #0d4352;
|
|
||||||
|
|
||||||
--color-border: #073642;
|
|
||||||
--color-border-light: #0a4a5a;
|
|
||||||
|
|
||||||
--color-text: #839496; /* base0 */
|
|
||||||
--color-text-muted: #657b83; /* base00 */
|
|
||||||
--color-text-inverse: #002b36;
|
|
||||||
|
|
||||||
--color-accent: #b58900; /* yellow */
|
|
||||||
--color-accent-hover: #cb9f10;
|
|
||||||
--color-accent-light: #1a1300;
|
|
||||||
|
|
||||||
--color-success: #859900; /* green */
|
|
||||||
--color-error: #dc322f; /* red */
|
|
||||||
--color-warning: #b58900; /* yellow */
|
|
||||||
--color-info: #268bd2; /* blue */
|
|
||||||
|
|
||||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
|
|
||||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.45), 0 2px 4px rgba(0, 0, 0, 0.3);
|
|
||||||
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5), 0 4px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Solarized Light ─────────────────────────────── */
|
|
||||||
[data-theme="solarized-light"] {
|
|
||||||
--color-primary: #2aa198; /* cyan */
|
|
||||||
--color-primary-hover: #1e8a82;
|
|
||||||
--color-primary-light: #eee8d5;
|
|
||||||
|
|
||||||
--color-surface: #fdf6e3; /* base3 */
|
|
||||||
--color-surface-alt: #eee8d5; /* base2 */
|
|
||||||
--color-surface-raised: #fffdf7;
|
|
||||||
|
|
||||||
--color-border: #d3c9b0;
|
|
||||||
--color-border-light: #e4dacc;
|
|
||||||
|
|
||||||
--color-text: #657b83; /* base00 */
|
|
||||||
--color-text-muted: #839496; /* base0 */
|
|
||||||
--color-text-inverse: #fdf6e3;
|
|
||||||
|
|
||||||
--color-accent: #b58900; /* yellow */
|
|
||||||
--color-accent-hover: #9a7300;
|
|
||||||
--color-accent-light: #fdf0c0;
|
|
||||||
|
|
||||||
--color-success: #859900; /* green */
|
|
||||||
--color-error: #dc322f; /* red */
|
|
||||||
--color-warning: #b58900; /* yellow */
|
|
||||||
--color-info: #268bd2; /* blue */
|
|
||||||
|
|
||||||
--shadow-sm: 0 1px 3px rgba(101, 123, 131, 0.12), 0 1px 2px rgba(101, 123, 131, 0.08);
|
|
||||||
--shadow-md: 0 4px 12px rgba(101, 123, 131, 0.15), 0 2px 4px rgba(101, 123, 131, 0.08);
|
|
||||||
--shadow-lg: 0 10px 30px rgba(101, 123, 131, 0.18), 0 4px 8px rgba(101, 123, 131, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Colorblind-safe (deuteranopia/protanopia) ────── */
|
|
||||||
/* Avoids red/green confusion. Uses blue+orange as the
|
|
||||||
primary pair; cyan+magenta as semantic differentiators.
|
|
||||||
Based on Wong (2011) 8-color colorblind-safe palette. */
|
|
||||||
[data-theme="colorblind"] {
|
|
||||||
--color-primary: #0072B2; /* blue — safe primary */
|
|
||||||
--color-primary-hover: #005a8e;
|
|
||||||
--color-primary-light: #e0f0fa;
|
|
||||||
|
|
||||||
--color-surface: #f4f6fb;
|
|
||||||
--color-surface-alt: #e6eaf4;
|
|
||||||
--color-surface-raised: #fafbfe;
|
|
||||||
|
|
||||||
--color-border: #b0bcd8;
|
|
||||||
--color-border-light: #cdd5e8;
|
|
||||||
|
|
||||||
--color-text: #1a2338;
|
|
||||||
--color-text-muted: #4a5c7a;
|
|
||||||
--color-text-inverse: #f4f6fb;
|
|
||||||
|
|
||||||
--color-accent: #E69F00; /* orange — safe secondary */
|
|
||||||
--color-accent-hover: #c98900;
|
|
||||||
--color-accent-light: #fdf4dc;
|
|
||||||
|
|
||||||
--color-success: #009E73; /* teal-green — distinct from red/green confusion zone */
|
|
||||||
--color-error: #CC0066; /* magenta-red — distinguishable from green */
|
|
||||||
--color-warning: #E69F00; /* orange */
|
|
||||||
--color-info: #56B4E9; /* sky blue */
|
|
||||||
|
|
||||||
--shadow-sm: 0 1px 3px rgba(26, 35, 56, 0.08), 0 1px 2px rgba(26, 35, 56, 0.04);
|
|
||||||
--shadow-md: 0 4px 12px rgba(26, 35, 56, 0.1), 0 2px 4px rgba(26, 35, 56, 0.06);
|
|
||||||
--shadow-lg: 0 10px 30px rgba(26, 35, 56, 0.12), 0 4px 8px rgba(26, 35, 56, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Base resets ─────────────────────────────────── */
|
/* ── Base resets ─────────────────────────────────── */
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,31 +34,12 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Theme picker -->
|
|
||||||
<div class="sidebar__theme" v-if="!isHackerMode">
|
|
||||||
<label class="sidebar__theme-label" for="theme-select">Theme</label>
|
|
||||||
<select
|
|
||||||
id="theme-select"
|
|
||||||
class="sidebar__theme-select"
|
|
||||||
:value="currentTheme"
|
|
||||||
@change="setTheme(($event.target as HTMLSelectElement).value as Theme)"
|
|
||||||
aria-label="Select theme"
|
|
||||||
>
|
|
||||||
<option v-for="opt in THEME_OPTIONS" :key="opt.value" :value="opt.value">
|
|
||||||
{{ opt.icon }} {{ opt.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Settings at bottom -->
|
<!-- Settings at bottom -->
|
||||||
<div class="sidebar__footer">
|
<div class="sidebar__footer">
|
||||||
<RouterLink to="/settings" class="sidebar__link sidebar__link--footer" active-class="sidebar__link--active">
|
<RouterLink to="/settings" class="sidebar__link sidebar__link--footer" active-class="sidebar__link--active">
|
||||||
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
|
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
|
||||||
<span class="sidebar__label">Settings</span>
|
<span class="sidebar__label">Settings</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<button class="sidebar__classic-btn" @click="switchToClassic" title="Switch to Classic (Streamlit) UI">
|
|
||||||
⚡ Classic
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
@ -95,10 +76,7 @@ import {
|
||||||
} from '@heroicons/vue/24/outline'
|
} from '@heroicons/vue/24/outline'
|
||||||
|
|
||||||
import { useDigestStore } from '../stores/digest'
|
import { useDigestStore } from '../stores/digest'
|
||||||
import { useTheme, THEME_OPTIONS, type Theme } from '../composables/useTheme'
|
|
||||||
|
|
||||||
const digestStore = useDigestStore()
|
const digestStore = useDigestStore()
|
||||||
const { currentTheme, setTheme, restoreTheme } = useTheme()
|
|
||||||
|
|
||||||
// Logo click easter egg — 9.6: Click the Bird 5× rapidly
|
// Logo click easter egg — 9.6: Click the Bird 5× rapidly
|
||||||
const logoClickCount = ref(0)
|
const logoClickCount = ref(0)
|
||||||
|
|
@ -123,25 +101,8 @@ const isHackerMode = computed(() =>
|
||||||
)
|
)
|
||||||
|
|
||||||
function exitHackerMode() {
|
function exitHackerMode() {
|
||||||
|
delete document.documentElement.dataset.theme
|
||||||
localStorage.removeItem('cf-hacker-mode')
|
localStorage.removeItem('cf-hacker-mode')
|
||||||
restoreTheme()
|
|
||||||
}
|
|
||||||
|
|
||||||
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(() => [
|
const navLinks = computed(() => [
|
||||||
|
|
@ -311,70 +272,6 @@ const mobileLinks = [
|
||||||
margin: 0;
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Theme picker ───────────────────────────────────── */
|
|
||||||
.sidebar__theme {
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
border-top: 1px solid var(--color-border-light);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar__theme-label {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-weight: 500;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar__theme-select {
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
cursor: pointer;
|
|
||||||
appearance: auto;
|
|
||||||
transition: border-color 150ms ease, background 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar__theme-select:hover {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
background: var(--color-surface-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar__theme-select:focus-visible {
|
|
||||||
outline: 2px solid var(--color-accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Mobile tab bar (<1024px) ───────────────────────── */
|
/* ── Mobile tab bar (<1024px) ───────────────────────── */
|
||||||
.app-tabbar {
|
.app-tabbar {
|
||||||
display: none; /* hidden on desktop */
|
display: none; /* hidden on desktop */
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Two-panel layout: job details | cover letter + resume optimizer -->
|
<!-- Two-panel layout: job details | cover letter -->
|
||||||
<div class="workspace__panels">
|
<div class="workspace__panels">
|
||||||
|
|
||||||
<!-- ── Left: Job details ──────────────────────────────────────── -->
|
<!-- ── Left: Job details ──────────────────────────────────────── -->
|
||||||
|
|
@ -56,49 +56,6 @@
|
||||||
<span v-if="gaps.length > 6" class="gaps-more">+{{ gaps.length - 6 }}</span>
|
<span v-if="gaps.length > 6" class="gaps-more">+{{ gaps.length - 6 }}</span>
|
||||||
</div>
|
</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">
|
<a v-if="job.url" :href="job.url" target="_blank" rel="noopener noreferrer" class="job-details__link">
|
||||||
View listing ↗
|
View listing ↗
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -141,12 +98,7 @@
|
||||||
<span aria-hidden="true">⚠️</span>
|
<span aria-hidden="true">⚠️</span>
|
||||||
<span class="cl-error__msg">Cover letter generation failed</span>
|
<span class="cl-error__msg">Cover letter generation failed</span>
|
||||||
<span v-if="taskError" class="cl-error__detail">{{ taskError }}</span>
|
<span v-if="taskError" class="cl-error__detail">{{ taskError }}</span>
|
||||||
<div class="cl-error__actions">
|
<button class="btn-generate" @click="generate()">Retry</button>
|
||||||
<button class="btn-generate" @click="generate()">Retry</button>
|
|
||||||
<button class="btn-ghost" @click="clState = 'ready'; clText = ''">
|
|
||||||
Write manually instead
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -191,64 +143,6 @@
|
||||||
↺ Regenerate
|
↺ Regenerate
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- ── 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 ──────────────────────────────────── -->
|
<!-- ── Bottom action bar ──────────────────────────────────── -->
|
||||||
<div class="workspace__actions">
|
<div class="workspace__actions">
|
||||||
<button
|
<button
|
||||||
|
|
@ -284,7 +178,6 @@
|
||||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { useApiFetch } from '../composables/useApi'
|
import { useApiFetch } from '../composables/useApi'
|
||||||
import type { Job } from '../stores/review'
|
import type { Job } from '../stores/review'
|
||||||
import ResumeOptimizerPanel from './ResumeOptimizerPanel.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{ jobId: number }>()
|
const props = defineProps<{ jobId: number }>()
|
||||||
|
|
||||||
|
|
@ -457,96 +350,6 @@ async function rejectListing() {
|
||||||
setTimeout(() => emit('job-removed'), 1000)
|
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 ────────────────────────────────────────────────────────────────────
|
// ─── Toast ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const toast = ref<string | null>(null)
|
const toast = ref<string | null>(null)
|
||||||
|
|
@ -594,10 +397,6 @@ onMounted(async () => {
|
||||||
await fetchJob()
|
await fetchJob()
|
||||||
loadingJob.value = false
|
loadingJob.value = false
|
||||||
|
|
||||||
// Load resume highlights and saved Q&A in parallel
|
|
||||||
fetchResume()
|
|
||||||
fetchQA()
|
|
||||||
|
|
||||||
// Check if a generation task is already in flight
|
// Check if a generation task is already in flight
|
||||||
if (clState.value === 'none') {
|
if (clState.value === 'none') {
|
||||||
const { data } = await useApiFetch<{ status: string; stage: string | null }>(`/api/jobs/${props.jobId}/cover_letter/task`)
|
const { data } = await useApiFetch<{ status: string; stage: string | null }>(`/api/jobs/${props.jobId}/cover_letter/task`)
|
||||||
|
|
@ -811,7 +610,6 @@ declare module '../stores/review' {
|
||||||
|
|
||||||
.cl-error__msg { font-weight: 700; }
|
.cl-error__msg { font-weight: 700; }
|
||||||
.cl-error__detail { font-size: var(--text-xs); color: var(--color-text-muted); font-weight: 400; }
|
.cl-error__detail { font-size: var(--text-xs); color: var(--color-text-muted); font-weight: 400; }
|
||||||
.cl-error__actions { display: flex; flex-direction: column; gap: var(--space-2); width: 100%; }
|
|
||||||
|
|
||||||
/* Editor */
|
/* Editor */
|
||||||
.cl-editor {
|
.cl-editor {
|
||||||
|
|
@ -1035,205 +833,6 @@ declare module '../stores/review' {
|
||||||
.toast-enter-active, .toast-leave-active { transition: opacity 250ms ease, transform 250ms ease; }
|
.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); }
|
.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 ──────────────────────────────────────────────────────── */
|
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
<template>
|
|
||||||
<button
|
|
||||||
class="classic-ui-btn"
|
|
||||||
:title="label"
|
|
||||||
@click="switchToClassic"
|
|
||||||
>
|
|
||||||
{{ label }}
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
label?: string
|
|
||||||
}>(), {
|
|
||||||
label: 'Switch to Classic UI',
|
|
||||||
})
|
|
||||||
|
|
||||||
function switchToClassic(): void {
|
|
||||||
// Set cookie so Caddy routes next request to Streamlit
|
|
||||||
document.cookie = 'prgn_ui=streamlit; path=/; SameSite=Lax'
|
|
||||||
|
|
||||||
// Append ?prgn_switch=streamlit so Streamlit's sync_ui_cookie()
|
|
||||||
// updates user.yaml to match — cookie alone can't be read server-side
|
|
||||||
const url = new URL(window.location.href)
|
|
||||||
url.searchParams.set('prgn_switch', 'streamlit')
|
|
||||||
window.location.href = url.toString()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.classic-ui-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
padding: 0.4rem 0.9rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
border: 1px solid var(--color-border, #444);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text-muted, #aaa);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
.classic-ui-btn:hover {
|
|
||||||
color: var(--color-text, #eee);
|
|
||||||
border-color: var(--color-text, #eee);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,412 +0,0 @@
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<div class="modal-backdrop" role="dialog" aria-modal="true" :aria-labelledby="`research-title-${jobId}`" @click.self="emit('close')">
|
|
||||||
<div class="modal-card">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2 :id="`research-title-${jobId}`" class="modal-title">
|
|
||||||
🔍 {{ jobTitle }} — Company Research
|
|
||||||
</h2>
|
|
||||||
<div class="modal-header-actions">
|
|
||||||
<button v-if="state === 'ready'" class="btn-regen" @click="generate" title="Refresh research">↺ Refresh</button>
|
|
||||||
<button class="btn-close" @click="emit('close')" aria-label="Close">✕</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Generating state -->
|
|
||||||
<div v-if="state === 'generating'" class="modal-body modal-body--loading">
|
|
||||||
<div class="research-spinner" aria-hidden="true" />
|
|
||||||
<p class="generating-msg">{{ stage ?? 'Researching…' }}</p>
|
|
||||||
<p class="generating-sub">This takes 30–90 seconds depending on your LLM backend.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error state -->
|
|
||||||
<div v-else-if="state === 'error'" class="modal-body modal-body--error">
|
|
||||||
<p>Research generation failed.</p>
|
|
||||||
<p v-if="errorMsg" class="error-detail">{{ errorMsg }}</p>
|
|
||||||
<button class="btn-primary-sm" @click="generate">Retry</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ready state -->
|
|
||||||
<div v-else-if="state === 'ready' && brief" class="modal-body">
|
|
||||||
<p v-if="brief.generated_at" class="generated-at">
|
|
||||||
Updated {{ fmtDate(brief.generated_at) }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<section v-if="brief.company_brief" class="research-section">
|
|
||||||
<h3 class="section-title">🏢 Company</h3>
|
|
||||||
<p class="section-body">{{ brief.company_brief }}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-if="brief.ceo_brief" class="research-section">
|
|
||||||
<h3 class="section-title">👤 Leadership</h3>
|
|
||||||
<p class="section-body">{{ brief.ceo_brief }}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-if="brief.talking_points" class="research-section">
|
|
||||||
<div class="section-title-row">
|
|
||||||
<h3 class="section-title">💬 Talking Points</h3>
|
|
||||||
<button class="btn-copy" @click="copy(brief.talking_points!)" :aria-label="copied ? 'Copied!' : 'Copy talking points'">
|
|
||||||
{{ copied ? '✓ Copied' : '⎘ Copy' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="section-body">{{ brief.talking_points }}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-if="brief.tech_brief" class="research-section">
|
|
||||||
<h3 class="section-title">⚙️ Tech Stack</h3>
|
|
||||||
<p class="section-body">{{ brief.tech_brief }}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-if="brief.funding_brief" class="research-section">
|
|
||||||
<h3 class="section-title">💰 Funding & Stage</h3>
|
|
||||||
<p class="section-body">{{ brief.funding_brief }}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-if="brief.red_flags" class="research-section research-section--warn">
|
|
||||||
<h3 class="section-title">⚠️ Red Flags</h3>
|
|
||||||
<p class="section-body">{{ brief.red_flags }}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-if="brief.accessibility_brief" class="research-section">
|
|
||||||
<h3 class="section-title">♿ Inclusion & Accessibility</h3>
|
|
||||||
<p class="section-body section-body--private">{{ brief.accessibility_brief }}</p>
|
|
||||||
<p class="private-note">For your decision-making only — not disclosed in applications.</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty state (no research, not generating) -->
|
|
||||||
<div v-else class="modal-body modal-body--empty">
|
|
||||||
<p>No research yet for this company.</p>
|
|
||||||
<button class="btn-primary-sm" @click="generate">🔍 Generate Research</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
|
||||||
import { useApiFetch } from '../composables/useApi'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
jobId: number
|
|
||||||
jobTitle: string
|
|
||||||
autoGenerate?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{ close: [] }>()
|
|
||||||
|
|
||||||
interface ResearchBrief {
|
|
||||||
company_brief: string | null
|
|
||||||
ceo_brief: string | null
|
|
||||||
talking_points: string | null
|
|
||||||
tech_brief: string | null
|
|
||||||
funding_brief: string | null
|
|
||||||
red_flags: string | null
|
|
||||||
accessibility_brief: string | null
|
|
||||||
generated_at: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModalState = 'loading' | 'generating' | 'ready' | 'empty' | 'error'
|
|
||||||
|
|
||||||
const state = ref<ModalState>('loading')
|
|
||||||
const brief = ref<ResearchBrief | null>(null)
|
|
||||||
const stage = ref<string | null>(null)
|
|
||||||
const errorMsg = ref<string | null>(null)
|
|
||||||
const copied = ref(false)
|
|
||||||
let pollId: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
function fmtDate(iso: string) {
|
|
||||||
const d = new Date(iso)
|
|
||||||
const diffH = Math.round((Date.now() - d.getTime()) / 3600000)
|
|
||||||
if (diffH < 1) return 'just now'
|
|
||||||
if (diffH < 24) return `${diffH}h ago`
|
|
||||||
if (diffH < 168) return `${Math.floor(diffH / 24)}d ago`
|
|
||||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copy(text: string) {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
copied.value = true
|
|
||||||
setTimeout(() => { copied.value = false }, 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPoll() {
|
|
||||||
if (pollId) { clearInterval(pollId); pollId = null }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollTask() {
|
|
||||||
const { data } = await useApiFetch<{ status: string; stage: string | null; message: string | null }>(
|
|
||||||
`/api/jobs/${props.jobId}/research/task`,
|
|
||||||
)
|
|
||||||
if (!data) return
|
|
||||||
stage.value = data.stage
|
|
||||||
|
|
||||||
if (data.status === 'completed') {
|
|
||||||
stopPoll()
|
|
||||||
await load()
|
|
||||||
} else if (data.status === 'failed') {
|
|
||||||
stopPoll()
|
|
||||||
state.value = 'error'
|
|
||||||
errorMsg.value = data.message ?? 'Unknown error'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
const { data, error } = await useApiFetch<ResearchBrief>(`/api/jobs/${props.jobId}/research`)
|
|
||||||
if (error) {
|
|
||||||
if (error.kind === 'http' && error.status === 404) {
|
|
||||||
// Check if a task is running
|
|
||||||
const { data: task } = await useApiFetch<{ status: string; stage: string | null; message: string | null }>(
|
|
||||||
`/api/jobs/${props.jobId}/research/task`,
|
|
||||||
)
|
|
||||||
if (task && (task.status === 'queued' || task.status === 'running')) {
|
|
||||||
state.value = 'generating'
|
|
||||||
stage.value = task.stage
|
|
||||||
pollId = setInterval(pollTask, 3000)
|
|
||||||
} else if (props.autoGenerate) {
|
|
||||||
await generate()
|
|
||||||
} else {
|
|
||||||
state.value = 'empty'
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
state.value = 'error'
|
|
||||||
errorMsg.value = error.kind === 'http' ? error.detail : error.message
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
brief.value = data
|
|
||||||
state.value = 'ready'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generate() {
|
|
||||||
state.value = 'generating'
|
|
||||||
stage.value = null
|
|
||||||
errorMsg.value = null
|
|
||||||
stopPoll()
|
|
||||||
const { error } = await useApiFetch(`/api/jobs/${props.jobId}/research/generate`, { method: 'POST' })
|
|
||||||
if (error) {
|
|
||||||
state.value = 'error'
|
|
||||||
errorMsg.value = error.kind === 'http' ? error.detail : error.message
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pollId = setInterval(pollTask, 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onEsc(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Escape') emit('close')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
document.addEventListener('keydown', onEsc)
|
|
||||||
await load()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
document.removeEventListener('keydown', onEsc)
|
|
||||||
stopPoll()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.55);
|
|
||||||
z-index: 500;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: center;
|
|
||||||
padding: var(--space-8) var(--space-4);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-card {
|
|
||||||
background: var(--color-surface-raised);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.3);
|
|
||||||
width: 100%;
|
|
||||||
max-width: 620px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-5) var(--space-6);
|
|
||||||
border-bottom: 1px solid var(--color-border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
padding: 2px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-regen {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
padding: 2px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
padding: var(--space-6);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-5);
|
|
||||||
max-height: 70vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body--loading {
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--space-10) var(--space-6);
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body--empty {
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--space-10) var(--space-6);
|
|
||||||
gap: var(--space-4);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body--error {
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--space-8) var(--space-6);
|
|
||||||
gap: var(--space-3);
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-detail {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.research-spinner {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border: 3px solid var(--color-border);
|
|
||||||
border-top-color: var(--color-primary);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
|
|
||||||
.generating-msg {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.generating-sub {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.generated-at {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin-bottom: calc(-1 * var(--space-2));
|
|
||||||
}
|
|
||||||
|
|
||||||
.research-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding-bottom: var(--space-4);
|
|
||||||
border-bottom: 1px solid var(--color-border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.research-section:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.research-section--warn .section-title {
|
|
||||||
color: var(--color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-body {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-text);
|
|
||||||
line-height: 1.6;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-body--private {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.private-note {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-copy {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
padding: 2px 8px;
|
|
||||||
transition: color 150ms, border-color 150ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-copy:hover { color: var(--color-primary); border-color: var(--color-primary); }
|
|
||||||
|
|
||||||
.btn-primary-sm {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--space-2) var(--space-5);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -13,7 +13,6 @@ const emit = defineEmits<{
|
||||||
move: [jobId: number, preSelectedStage?: PipelineStage]
|
move: [jobId: number, preSelectedStage?: PipelineStage]
|
||||||
prep: [jobId: number]
|
prep: [jobId: number]
|
||||||
survey: [jobId: number]
|
survey: [jobId: number]
|
||||||
research: [jobId: number]
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Signal state
|
// Signal state
|
||||||
|
|
@ -181,7 +180,6 @@ const columnColor = computed(() => {
|
||||||
</div>
|
</div>
|
||||||
<footer class="card-footer">
|
<footer class="card-footer">
|
||||||
<button class="card-action" @click.stop="emit('move', job.id)">Move to… ›</button>
|
<button class="card-action" @click.stop="emit('move', job.id)">Move to… ›</button>
|
||||||
<button v-if="['phone_screen', 'interviewing', 'offer'].includes(job.status)" class="card-action" @click.stop="emit('research', job.id)">🔍 Research</button>
|
|
||||||
<button v-if="['phone_screen', 'interviewing', 'offer'].includes(job.status)" class="card-action" @click.stop="emit('prep', job.id)">Prep →</button>
|
<button v-if="['phone_screen', 'interviewing', 'offer'].includes(job.status)" class="card-action" @click.stop="emit('prep', job.id)">Prep →</button>
|
||||||
<button
|
<button
|
||||||
v-if="['survey', 'phone_screen', 'interviewing', 'offer'].includes(job.status)"
|
v-if="['survey', 'phone_screen', 'interviewing', 'offer'].includes(job.status)"
|
||||||
|
|
|
||||||
|
|
@ -1,495 +0,0 @@
|
||||||
<template>
|
|
||||||
<section class="rop" aria-labelledby="rop-heading">
|
|
||||||
<h2 id="rop-heading" class="rop__heading">ATS Resume Optimizer</h2>
|
|
||||||
|
|
||||||
<!-- ── Tier gate notice (free) ────────────────────────────────────── -->
|
|
||||||
<p v-if="isFree" class="rop__tier-note">
|
|
||||||
<span aria-hidden="true">🔒</span>
|
|
||||||
Keyword gap report is free. Full AI rewrite requires a
|
|
||||||
<strong>Paid</strong> license.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- ── Gap report section (all tiers) ────────────────────────────── -->
|
|
||||||
<div class="rop__gaps">
|
|
||||||
<div class="rop__gaps-header">
|
|
||||||
<h3 class="rop__subheading">Keyword Gap Report</h3>
|
|
||||||
<button
|
|
||||||
class="btn-generate"
|
|
||||||
:disabled="gapState === 'queued' || gapState === 'running'"
|
|
||||||
@click="runGapReport"
|
|
||||||
>
|
|
||||||
<span aria-hidden="true">🔍</span>
|
|
||||||
{{ gapState === 'queued' || gapState === 'running' ? 'Analyzing…' : 'Analyze Keywords' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="gapState === 'queued' || gapState === 'running'">
|
|
||||||
<div class="rop__spinner-row" role="status" aria-live="polite">
|
|
||||||
<span class="spinner" aria-hidden="true" />
|
|
||||||
<span>{{ gapStage ?? 'Extracting keyword gaps…' }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="gapState === 'failed'">
|
|
||||||
<p class="rop__error" role="alert">Gap analysis failed. Try again.</p>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="gaps.length > 0">
|
|
||||||
<div class="rop__gap-list" role="list" aria-label="Keyword gaps by section">
|
|
||||||
<div
|
|
||||||
v-for="item in gaps"
|
|
||||||
:key="item.term"
|
|
||||||
class="rop__gap-item"
|
|
||||||
:class="`rop__gap-item--p${item.priority}`"
|
|
||||||
role="listitem"
|
|
||||||
>
|
|
||||||
<span class="rop__gap-section" :title="`Route to ${item.section}`">{{ item.section }}</span>
|
|
||||||
<span class="rop__gap-term">{{ item.term }}</span>
|
|
||||||
<span class="rop__gap-rationale">{{ item.rationale }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="gapState === 'completed'">
|
|
||||||
<p class="rop__empty">No significant keyword gaps found — your resume already covers this JD well.</p>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<p class="rop__hint">Click <em>Analyze Keywords</em> to see which ATS terms your resume is missing.</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Full rewrite section (paid+) ──────────────────────────────── -->
|
|
||||||
<div v-if="!isFree" class="rop__rewrite">
|
|
||||||
<div class="rop__gaps-header">
|
|
||||||
<h3 class="rop__subheading">Optimized Resume</h3>
|
|
||||||
<button
|
|
||||||
class="btn-generate"
|
|
||||||
:disabled="rewriteState === 'queued' || rewriteState === 'running' || gaps.length === 0"
|
|
||||||
:title="gaps.length === 0 ? 'Run gap analysis first' : ''"
|
|
||||||
@click="runFullRewrite"
|
|
||||||
>
|
|
||||||
<span aria-hidden="true">✨</span>
|
|
||||||
{{ rewriteState === 'queued' || rewriteState === 'running' ? 'Rewriting…' : 'Optimize Resume' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="rewriteState === 'queued' || rewriteState === 'running'">
|
|
||||||
<div class="rop__spinner-row" role="status" aria-live="polite">
|
|
||||||
<span class="spinner" aria-hidden="true" />
|
|
||||||
<span>{{ rewriteStage ?? 'Rewriting resume sections…' }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="rewriteState === 'failed'">
|
|
||||||
<p class="rop__error" role="alert">Resume rewrite failed. Check that a resume file is configured in Settings.</p>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="optimizedResume">
|
|
||||||
<!-- Hallucination warning — shown when the task message flags it -->
|
|
||||||
<div v-if="hallucinationWarning" class="rop__hallucination-badge" role="alert">
|
|
||||||
<span aria-hidden="true">⚠️</span>
|
|
||||||
Hallucination check failed — the rewrite introduced content not in your original resume.
|
|
||||||
The optimized version has been discarded; only the gap report is available.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rop__rewrite-toolbar">
|
|
||||||
<span class="rop__wordcount" aria-live="polite">{{ rewriteWordCount }} words</span>
|
|
||||||
<span class="rop__verified-badge" aria-label="Hallucination check passed">✓ Verified</span>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
v-model="optimizedResume"
|
|
||||||
class="rop__textarea"
|
|
||||||
aria-label="Optimized resume text"
|
|
||||||
spellcheck="false"
|
|
||||||
/>
|
|
||||||
<button class="btn-download" @click="downloadTxt">
|
|
||||||
<span aria-hidden="true">📄</span> Download .txt
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<p class="rop__hint">
|
|
||||||
Run <em>Analyze Keywords</em> first, then click <em>Optimize Resume</em> to rewrite your resume
|
|
||||||
sections to naturally incorporate missing ATS keywords.
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
||||||
import { useApiFetch } from '../composables/useApi'
|
|
||||||
import { useAppConfigStore } from '../stores/appConfig'
|
|
||||||
|
|
||||||
const props = defineProps<{ jobId: number }>()
|
|
||||||
|
|
||||||
const config = useAppConfigStore()
|
|
||||||
const isFree = computed(() => config.tier === 'free')
|
|
||||||
|
|
||||||
// ── Gap report state ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type TaskState = 'none' | 'queued' | 'running' | 'completed' | 'failed'
|
|
||||||
|
|
||||||
const gapState = ref<TaskState>('none')
|
|
||||||
const gapStage = ref<string | null>(null)
|
|
||||||
const gaps = ref<Array<{ term: string; section: string; priority: number; rationale: string }>>([])
|
|
||||||
|
|
||||||
// ── Rewrite state ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const rewriteState = ref<TaskState>('none')
|
|
||||||
const rewriteStage = ref<string | null>(null)
|
|
||||||
const optimizedResume = ref('')
|
|
||||||
const hallucinationWarning = ref(false)
|
|
||||||
|
|
||||||
const rewriteWordCount = computed(() =>
|
|
||||||
optimizedResume.value.trim().split(/\s+/).filter(Boolean).length
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Task polling ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
function startPolling() {
|
|
||||||
stopPolling()
|
|
||||||
pollTimer = setInterval(pollTaskStatus, 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPolling() {
|
|
||||||
if (pollTimer !== null) {
|
|
||||||
clearInterval(pollTimer)
|
|
||||||
pollTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollTaskStatus() {
|
|
||||||
const { data } = await useApiFetch<{ status: string; stage: string | null; message: string | null }>(
|
|
||||||
`/api/jobs/${props.jobId}/resume_optimizer/task`
|
|
||||||
)
|
|
||||||
if (!data) return
|
|
||||||
|
|
||||||
const status = data.status as TaskState
|
|
||||||
|
|
||||||
// Update whichever phase is in-flight
|
|
||||||
if (gapState.value === 'queued' || gapState.value === 'running') {
|
|
||||||
gapState.value = status
|
|
||||||
gapStage.value = data.stage ?? null
|
|
||||||
if (status === 'completed' || status === 'failed') {
|
|
||||||
stopPolling()
|
|
||||||
if (status === 'completed') await loadResults()
|
|
||||||
}
|
|
||||||
} else if (rewriteState.value === 'queued' || rewriteState.value === 'running') {
|
|
||||||
rewriteState.value = status
|
|
||||||
rewriteStage.value = data.stage ?? null
|
|
||||||
if (status === 'completed' || status === 'failed') {
|
|
||||||
stopPolling()
|
|
||||||
if (status === 'completed') await loadResults()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Load existing results ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function loadResults() {
|
|
||||||
const { data } = await useApiFetch<{
|
|
||||||
optimized_resume: string
|
|
||||||
ats_gap_report: Array<{ term: string; section: string; priority: number; rationale: string }>
|
|
||||||
}>(`/api/jobs/${props.jobId}/resume_optimizer`)
|
|
||||||
|
|
||||||
if (!data) return
|
|
||||||
|
|
||||||
if (data.ats_gap_report?.length) {
|
|
||||||
gaps.value = data.ats_gap_report
|
|
||||||
gapState.value = 'completed'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.optimized_resume) {
|
|
||||||
optimizedResume.value = data.optimized_resume
|
|
||||||
rewriteState.value = 'completed'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Actions ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function runGapReport() {
|
|
||||||
gapState.value = 'queued'
|
|
||||||
gapStage.value = null
|
|
||||||
gaps.value = []
|
|
||||||
const { error } = await useApiFetch(`/api/jobs/${props.jobId}/resume_optimizer/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ full_rewrite: false }),
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
if (error) {
|
|
||||||
gapState.value = 'failed'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
startPolling()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runFullRewrite() {
|
|
||||||
rewriteState.value = 'queued'
|
|
||||||
rewriteStage.value = null
|
|
||||||
optimizedResume.value = ''
|
|
||||||
hallucinationWarning.value = false
|
|
||||||
const { error } = await useApiFetch(`/api/jobs/${props.jobId}/resume_optimizer/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ full_rewrite: true }),
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
if (error) {
|
|
||||||
rewriteState.value = 'failed'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
startPolling()
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadTxt() {
|
|
||||||
const blob = new Blob([optimizedResume.value], { type: 'text/plain' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = `resume-optimized-job-${props.jobId}.txt`
|
|
||||||
a.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Lifecycle ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await loadResults()
|
|
||||||
// Resume polling if a task was still in-flight when the page last unloaded
|
|
||||||
const { data } = await useApiFetch<{ status: string }>(
|
|
||||||
`/api/jobs/${props.jobId}/resume_optimizer/task`
|
|
||||||
)
|
|
||||||
if (data?.status === 'queued' || data?.status === 'running') {
|
|
||||||
// Restore in-flight state to whichever phase makes sense
|
|
||||||
if (!optimizedResume.value && !gaps.value.length) {
|
|
||||||
gapState.value = data.status as TaskState
|
|
||||||
} else if (gaps.value.length) {
|
|
||||||
rewriteState.value = data.status as TaskState
|
|
||||||
}
|
|
||||||
startPolling()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(stopPolling)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.rop {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-5, 1.25rem);
|
|
||||||
padding: var(--space-4, 1rem);
|
|
||||||
border-top: 1px solid var(--app-border, #e2e8f0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rop__heading {
|
|
||||||
font-size: var(--font-lg, 1.125rem);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--app-text, #1e293b);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rop__subheading {
|
|
||||||
font-size: var(--font-base, 1rem);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--app-text, #1e293b);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rop__tier-note {
|
|
||||||
font-size: var(--font-sm, 0.875rem);
|
|
||||||
color: var(--app-text-muted, #64748b);
|
|
||||||
background: var(--app-surface-alt, #f8fafc);
|
|
||||||
border: 1px solid var(--app-border, #e2e8f0);
|
|
||||||
border-radius: var(--radius-md, 0.5rem);
|
|
||||||
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rop__gaps,
|
|
||||||
.rop__rewrite {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-3, 0.75rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rop__gaps-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-3, 0.75rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rop__hint,
|
|
||||||
.rop__empty {
|
|
||||||
font-size: var(--font-sm, 0.875rem);
|
|
||||||
color: var(--app-text-muted, #64748b);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rop__error {
|
|
||||||
font-size: var(--font-sm, 0.875rem);
|
|
||||||
color: var(--app-danger, #dc2626);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rop__spinner-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2, 0.5rem);
|
|
||||||
font-size: var(--font-sm, 0.875rem);
|
|
||||||
color: var(--app-text-muted, #64748b);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Gap list ─────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.rop__gap-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-1, 0.25rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rop__gap-item {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 6rem 1fr;
|
|
||||||
grid-template-rows: auto auto;
|
|
||||||
gap: 0 var(--space-2, 0.5rem);
|
|
||||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
|
||||||
border-radius: var(--radius-sm, 0.25rem);
|
|
||||||
border-left: 3px solid transparent;
|
|
||||||
background: var(--app-surface-alt, #f8fafc);
|
|
||||||
font-size: var(--font-sm, 0.875rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rop__gap-item--p1 { border-left-color: var(--app-accent, #6366f1); }
|
|
||||||
.rop__gap-item--p2 { border-left-color: var(--app-warning, #f59e0b); }
|
|
||||||
.rop__gap-item--p3 { border-left-color: var(--app-border, #e2e8f0); }
|
|
||||||
|
|
||||||
.rop__gap-section {
|
|
||||||
grid-row: 1;
|
|
||||||
grid-column: 1;
|
|
||||||
font-size: var(--font-xs, 0.75rem);
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: var(--app-text-muted, #64748b);
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rop__gap-term {
|
|
||||||
grid-row: 1;
|
|
||||||
grid-column: 2;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--app-text, #1e293b);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rop__gap-rationale {
|
|
||||||
grid-row: 2;
|
|
||||||
grid-column: 2;
|
|
||||||
font-size: var(--font-xs, 0.75rem);
|
|
||||||
color: var(--app-text-muted, #64748b);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Rewrite output ───────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.rop__rewrite-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3, 0.75rem);
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rop__wordcount {
|
|
||||||
font-size: var(--font-sm, 0.875rem);
|
|
||||||
color: var(--app-text-muted, #64748b);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rop__verified-badge {
|
|
||||||
font-size: var(--font-xs, 0.75rem);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--app-success, #16a34a);
|
|
||||||
background: color-mix(in srgb, var(--app-success, #16a34a) 10%, transparent);
|
|
||||||
padding: 0.2em 0.6em;
|
|
||||||
border-radius: var(--radius-full, 9999px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rop__hallucination-badge {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--space-2, 0.5rem);
|
|
||||||
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
|
|
||||||
background: color-mix(in srgb, var(--app-danger, #dc2626) 8%, transparent);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--app-danger, #dc2626) 30%, transparent);
|
|
||||||
border-radius: var(--radius-md, 0.5rem);
|
|
||||||
font-size: var(--font-sm, 0.875rem);
|
|
||||||
color: var(--app-danger, #dc2626);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rop__textarea {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 20rem;
|
|
||||||
padding: var(--space-3, 0.75rem);
|
|
||||||
font-family: var(--font-mono, monospace);
|
|
||||||
font-size: var(--font-sm, 0.875rem);
|
|
||||||
line-height: 1.6;
|
|
||||||
border: 1px solid var(--app-border, #e2e8f0);
|
|
||||||
border-radius: var(--radius-md, 0.5rem);
|
|
||||||
background: var(--app-surface, #fff);
|
|
||||||
color: var(--app-text, #1e293b);
|
|
||||||
resize: vertical;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rop__textarea:focus {
|
|
||||||
outline: 2px solid var(--app-accent, #6366f1);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Buttons (inherit app-wide classes) ──────────────────────────── */
|
|
||||||
|
|
||||||
.btn-generate {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2, 0.5rem);
|
|
||||||
padding: var(--space-2, 0.5rem) var(--space-4, 1rem);
|
|
||||||
background: var(--app-accent, #6366f1);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md, 0.5rem);
|
|
||||||
font-size: var(--font-sm, 0.875rem);
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-generate:hover:not(:disabled) { background: var(--app-accent-hover, #4f46e5); }
|
|
||||||
.btn-generate:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
||||||
|
|
||||||
.btn-download {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2, 0.5rem);
|
|
||||||
padding: var(--space-2, 0.5rem) var(--space-4, 1rem);
|
|
||||||
background: var(--app-surface-alt, #f8fafc);
|
|
||||||
color: var(--app-text, #1e293b);
|
|
||||||
border: 1px solid var(--app-border, #e2e8f0);
|
|
||||||
border-radius: var(--radius-md, 0.5rem);
|
|
||||||
font-size: var(--font-sm, 0.875rem);
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-download:hover { background: var(--app-border, #e2e8f0); }
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.rop__gaps-header { flex-direction: column; align-items: flex-start; }
|
|
||||||
.btn-generate { width: 100%; justify-content: center; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
<template>
|
|
||||||
<!-- Desktop: inline queue in sidebar footer -->
|
|
||||||
<div v-if="count > 0" class="task-indicator task-indicator--sidebar" aria-live="polite" role="status">
|
|
||||||
<template v-for="group in groups" :key="group.primary.id">
|
|
||||||
<!-- Primary task row -->
|
|
||||||
<div class="task-row task-row--primary">
|
|
||||||
<span class="task-row__spinner" :class="`task-row__spinner--${group.primary.status}`" aria-hidden="true" />
|
|
||||||
<span class="task-row__label">{{ TASK_LABEL[group.primary.task_type] ?? group.primary.task_type }}</span>
|
|
||||||
<span class="task-row__status">{{ group.primary.status }}</span>
|
|
||||||
</div>
|
|
||||||
<!-- Pipeline sub-steps (indented) -->
|
|
||||||
<div
|
|
||||||
v-for="step in group.steps"
|
|
||||||
:key="step.id"
|
|
||||||
class="task-row task-row--step"
|
|
||||||
:class="`task-row--${step.status}`"
|
|
||||||
>
|
|
||||||
<span class="task-row__indent" aria-hidden="true">↳</span>
|
|
||||||
<span class="task-row__spinner" :class="`task-row__spinner--${step.status}`" aria-hidden="true" />
|
|
||||||
<span class="task-row__label">{{ TASK_LABEL[step.task_type] ?? step.task_type }}</span>
|
|
||||||
<span class="task-row__status">{{ step.status }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mobile: fixed pill above bottom tab bar (compact — keeps existing design) -->
|
|
||||||
<Transition name="task-pill">
|
|
||||||
<div
|
|
||||||
v-if="count > 0"
|
|
||||||
class="task-indicator task-indicator--pill"
|
|
||||||
aria-live="polite"
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
<span class="task-indicator__spinner" aria-hidden="true" />
|
|
||||||
<span class="task-indicator__label">{{ label }}</span>
|
|
||||||
<span class="task-indicator__badge">{{ count }}</span>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, onUnmounted } from 'vue'
|
|
||||||
import { useTasksStore, TASK_LABEL } from '../stores/tasks'
|
|
||||||
import { storeToRefs } from 'pinia'
|
|
||||||
|
|
||||||
const store = useTasksStore()
|
|
||||||
const { count, groups, label } = storeToRefs(store)
|
|
||||||
|
|
||||||
onMounted(store.startPolling)
|
|
||||||
onUnmounted(store.stopPolling)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* ── Shared ─────────────────────────────────────────── */
|
|
||||||
.task-indicator {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Spinner — CSS-only rotating ring */
|
|
||||||
.task-indicator__spinner {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border: 2px solid color-mix(in srgb, var(--app-primary) 30%, transparent);
|
|
||||||
border-top-color: var(--app-primary);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: task-spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes task-spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-indicator__label {
|
|
||||||
flex: 1;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-indicator__badge {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: 700;
|
|
||||||
background: var(--app-primary);
|
|
||||||
color: white;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
min-width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Desktop sidebar variant — shown by the sidebar, hidden on mobile ── */
|
|
||||||
.task-indicator--sidebar {
|
|
||||||
padding: var(--space-2) var(--space-4);
|
|
||||||
border-top: 1px solid var(--color-border-light);
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-1);
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Task rows ─────────────────────────────────────── */
|
|
||||||
.task-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
min-height: 26px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-row--primary { padding: var(--space-1) 0; }
|
|
||||||
|
|
||||||
.task-row--step {
|
|
||||||
padding-left: var(--space-3);
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-row--queued { opacity: 0.5; }
|
|
||||||
|
|
||||||
.task-row__indent {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-row__spinner {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-row__spinner--running {
|
|
||||||
border: 1.5px solid color-mix(in srgb, var(--app-primary) 30%, transparent);
|
|
||||||
border-top-color: var(--app-primary);
|
|
||||||
animation: task-spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-row__spinner--queued {
|
|
||||||
border: 1.5px solid var(--color-border);
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-row__label {
|
|
||||||
flex: 1;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-row__status {
|
|
||||||
font-size: 9px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
opacity: 0.6;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Mobile pill variant — fixed above tab bar ─────── */
|
|
||||||
.task-indicator--pill {
|
|
||||||
position: fixed;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
bottom: calc(56px + env(safe-area-inset-bottom) + var(--space-2));
|
|
||||||
background: var(--color-surface-raised);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
padding: var(--space-1) var(--space-3);
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
||||||
z-index: 200;
|
|
||||||
pointer-events: none;
|
|
||||||
/* hidden on desktop, shown on mobile */
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Responsive ─────────────────────────────────────── */
|
|
||||||
@media (max-width: 1023px) {
|
|
||||||
.task-indicator--sidebar { display: none; }
|
|
||||||
.task-indicator--pill { display: flex; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.task-indicator--pill { display: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Transition (pill slide-up) ─────────────────────── */
|
|
||||||
.task-pill-enter-active,
|
|
||||||
.task-pill-leave-active {
|
|
||||||
transition: opacity 200ms ease, transform 200ms ease;
|
|
||||||
}
|
|
||||||
.task-pill-enter-from,
|
|
||||||
.task-pill-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-50%) translateY(8px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -2,15 +2,12 @@ export type ApiError =
|
||||||
| { kind: 'network'; message: string }
|
| { kind: 'network'; message: string }
|
||||||
| { kind: 'http'; status: number; detail: 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>(
|
export async function useApiFetch<T>(
|
||||||
url: string,
|
url: string,
|
||||||
opts?: RequestInit,
|
opts?: RequestInit,
|
||||||
): Promise<{ data: T | null; error: ApiError | null }> {
|
): Promise<{ data: T | null; error: ApiError | null }> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(_apiBase + url, opts)
|
const res = await fetch(url, opts)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = await res.text().catch(() => '')
|
const detail = await res.text().catch(() => '')
|
||||||
return { data: null, error: { kind: 'http', status: res.status, detail } }
|
return { data: null, error: { kind: 'http', status: res.status, detail } }
|
||||||
|
|
@ -34,7 +31,7 @@ export function useApiSSE(
|
||||||
onComplete?: () => void,
|
onComplete?: () => void,
|
||||||
onError?: (e: Event) => void,
|
onError?: (e: Event) => void,
|
||||||
): () => void {
|
): () => void {
|
||||||
const es = new EventSource(_apiBase + url)
|
const es = new EventSource(url)
|
||||||
es.onmessage = (e) => {
|
es.onmessage = (e) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(e.data) as Record<string, unknown>
|
const data = JSON.parse(e.data) as Record<string, unknown>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { onMounted, onUnmounted } from 'vue'
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
import { useTheme } from './useTheme'
|
|
||||||
|
|
||||||
const KONAMI = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a']
|
const KONAMI = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a']
|
||||||
const KONAMI_AB = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','a','b']
|
const KONAMI_AB = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','a','b']
|
||||||
|
|
@ -32,10 +31,8 @@ export function useHackerMode() {
|
||||||
function toggle() {
|
function toggle() {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
if (root.dataset.theme === 'hacker') {
|
if (root.dataset.theme === 'hacker') {
|
||||||
|
delete root.dataset.theme
|
||||||
localStorage.removeItem('cf-hacker-mode')
|
localStorage.removeItem('cf-hacker-mode')
|
||||||
// Let useTheme restore the user's chosen theme rather than just deleting data-theme
|
|
||||||
const { restoreTheme } = useTheme()
|
|
||||||
restoreTheme()
|
|
||||||
} else {
|
} else {
|
||||||
root.dataset.theme = 'hacker'
|
root.dataset.theme = 'hacker'
|
||||||
localStorage.setItem('cf-hacker-mode', 'true')
|
localStorage.setItem('cf-hacker-mode', 'true')
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
/**
|
|
||||||
* useFeatureFlag — demo toolbar tier display helper.
|
|
||||||
*
|
|
||||||
* Reads the `prgn_demo_tier` cookie set by the Streamlit demo toolbar so the
|
|
||||||
* Vue SPA can visually reflect the simulated tier (e.g. in ClassicUIButton
|
|
||||||
* or feature-locked UI hints).
|
|
||||||
*
|
|
||||||
* ⚠️ NOT an authoritative feature gate. This is demo-only visual consistency.
|
|
||||||
* Production feature gating will use a future /api/features endpoint (issue #8).
|
|
||||||
* All real access control lives in the Python tier system (app/wizard/tiers.py).
|
|
||||||
*/
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
const VALID_TIERS = ['free', 'paid', 'premium'] as const
|
|
||||||
type Tier = (typeof VALID_TIERS)[number]
|
|
||||||
|
|
||||||
function _readDemoTierCookie(): Tier | null {
|
|
||||||
const match = document.cookie
|
|
||||||
.split('; ')
|
|
||||||
.find((row) => row.startsWith('prgn_demo_tier='))
|
|
||||||
if (!match) return null
|
|
||||||
const value = match.split('=')[1] as Tier
|
|
||||||
return VALID_TIERS.includes(value) ? value : null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the simulated demo tier from the `prgn_demo_tier` cookie,
|
|
||||||
* or `null` when not in demo mode (cookie absent).
|
|
||||||
*
|
|
||||||
* Use for visual indicators only — never for access control.
|
|
||||||
*/
|
|
||||||
export function useFeatureFlag() {
|
|
||||||
const demoTier = computed<Tier | null>(() => _readDemoTierCookie())
|
|
||||||
|
|
||||||
const isDemoMode = computed(() => demoTier.value !== null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the simulated demo tier meets `required`.
|
|
||||||
* Always returns false outside demo mode.
|
|
||||||
*/
|
|
||||||
function demoCanUse(required: Tier): boolean {
|
|
||||||
const order: Tier[] = ['free', 'paid', 'premium']
|
|
||||||
if (!demoTier.value) return false
|
|
||||||
return order.indexOf(demoTier.value) >= order.indexOf(required)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { demoTier, isDemoMode, demoCanUse }
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
/**
|
|
||||||
* useTheme — manual theme picker for Peregrine.
|
|
||||||
*
|
|
||||||
* Themes: 'auto' | 'light' | 'dark' | 'solarized-dark' | 'solarized-light' | 'colorblind'
|
|
||||||
* Persisted in localStorage under 'cf-theme'.
|
|
||||||
* Applied via document.documentElement.dataset.theme.
|
|
||||||
* 'auto' removes the attribute so the @media prefers-color-scheme rule takes effect.
|
|
||||||
*
|
|
||||||
* Hacker mode sits on top of this system — toggling it off calls restoreTheme()
|
|
||||||
* so the user's chosen theme is reinstated rather than dropping back to auto.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ref, readonly } from 'vue'
|
|
||||||
import { useApiFetch } from './useApi'
|
|
||||||
|
|
||||||
export type Theme = 'auto' | 'light' | 'dark' | 'solarized-dark' | 'solarized-light' | 'colorblind'
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'cf-theme'
|
|
||||||
const HACKER_KEY = 'cf-hacker-mode'
|
|
||||||
|
|
||||||
export const THEME_OPTIONS: { value: Theme; label: string; icon: string }[] = [
|
|
||||||
{ value: 'auto', label: 'Auto', icon: '⬡' },
|
|
||||||
{ value: 'light', label: 'Light', icon: '☀' },
|
|
||||||
{ value: 'dark', label: 'Dark', icon: '🌙' },
|
|
||||||
{ value: 'solarized-light', label: 'Solarized Light', icon: '🌤' },
|
|
||||||
{ value: 'solarized-dark', label: 'Solarized Dark', icon: '🌃' },
|
|
||||||
{ value: 'colorblind', label: 'Colorblind Safe', icon: '♿' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Module-level singleton so all consumers share the same reactive state.
|
|
||||||
const _current = ref<Theme>(_load())
|
|
||||||
|
|
||||||
function _load(): Theme {
|
|
||||||
return (localStorage.getItem(STORAGE_KEY) as Theme | null) ?? 'auto'
|
|
||||||
}
|
|
||||||
|
|
||||||
function _apply(theme: Theme) {
|
|
||||||
const root = document.documentElement
|
|
||||||
if (theme === 'auto') {
|
|
||||||
delete root.dataset.theme
|
|
||||||
} else {
|
|
||||||
root.dataset.theme = theme
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTheme() {
|
|
||||||
function setTheme(theme: Theme) {
|
|
||||||
_current.value = theme
|
|
||||||
localStorage.setItem(STORAGE_KEY, theme)
|
|
||||||
_apply(theme)
|
|
||||||
// Best-effort persist to server; ignore failures (works offline / local LLM)
|
|
||||||
useApiFetch('/api/settings/theme', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ theme }),
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Restore user's chosen theme — called when hacker mode or other overlays exit. */
|
|
||||||
function restoreTheme() {
|
|
||||||
// Hacker mode clears itself; we only restore if it's actually off.
|
|
||||||
if (localStorage.getItem(HACKER_KEY) === 'true') return
|
|
||||||
_apply(_current.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Call once at app boot to apply persisted theme before first render. */
|
|
||||||
function initTheme() {
|
|
||||||
// Hacker mode takes priority on restore.
|
|
||||||
if (localStorage.getItem(HACKER_KEY) === 'true') {
|
|
||||||
document.documentElement.dataset.theme = 'hacker'
|
|
||||||
} else {
|
|
||||||
_apply(_current.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentTheme: readonly(_current),
|
|
||||||
setTheme,
|
|
||||||
restoreTheme,
|
|
||||||
initTheme,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { useAppConfigStore } from '../stores/appConfig'
|
import { useAppConfigStore } from '../stores/appConfig'
|
||||||
import { settingsGuard } from './settingsGuard'
|
import { settingsGuard } from './settingsGuard'
|
||||||
import { wizardGuard } from './wizardGuard'
|
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', component: () => import('../views/HomeView.vue') },
|
{ path: '/', component: () => import('../views/HomeView.vue') },
|
||||||
{ path: '/review', component: () => import('../views/JobReviewView.vue') },
|
{ path: '/review', component: () => import('../views/JobReviewView.vue') },
|
||||||
|
|
@ -32,40 +31,14 @@ export const router = createRouter({
|
||||||
{ path: 'developer', component: () => import('../views/settings/DeveloperView.vue') },
|
{ path: 'developer', component: () => import('../views/settings/DeveloperView.vue') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Onboarding wizard — full-page layout, no AppNav
|
|
||||||
{
|
|
||||||
path: '/setup',
|
|
||||||
component: () => import('../views/wizard/WizardLayout.vue'),
|
|
||||||
children: [
|
|
||||||
{ path: '', redirect: '/setup/hardware' },
|
|
||||||
{ path: 'hardware', component: () => import('../views/wizard/WizardHardwareStep.vue') },
|
|
||||||
{ path: 'tier', component: () => import('../views/wizard/WizardTierStep.vue') },
|
|
||||||
{ path: 'resume', component: () => import('../views/wizard/WizardResumeStep.vue') },
|
|
||||||
{ path: 'identity', component: () => import('../views/wizard/WizardIdentityStep.vue') },
|
|
||||||
{ path: 'inference', component: () => import('../views/wizard/WizardInferenceStep.vue') },
|
|
||||||
{ path: 'search', component: () => import('../views/wizard/WizardSearchStep.vue') },
|
|
||||||
{ path: 'integrations', component: () => import('../views/wizard/WizardIntegrationsStep.vue') },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// Catch-all — FastAPI serves index.html for all unknown routes (SPA mode)
|
// Catch-all — FastAPI serves index.html for all unknown routes (SPA mode)
|
||||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach(async (to, _from, next) => {
|
router.beforeEach(async (to, _from, next) => {
|
||||||
|
if (!to.path.startsWith('/settings/')) return next()
|
||||||
const config = useAppConfigStore()
|
const config = useAppConfigStore()
|
||||||
if (!config.loaded) await config.load()
|
if (!config.loaded) await config.load()
|
||||||
|
settingsGuard(to, _from, next)
|
||||||
// Wizard gate runs first for every route except /setup itself
|
|
||||||
if (!to.path.startsWith('/setup') && !config.wizardComplete) {
|
|
||||||
return next('/setup')
|
|
||||||
}
|
|
||||||
|
|
||||||
// /setup routes: let wizardGuard handle complete→redirect-to-home logic
|
|
||||||
if (to.path.startsWith('/setup')) return wizardGuard(to, _from, next)
|
|
||||||
|
|
||||||
// Settings tier-gating (runs only when wizard is complete)
|
|
||||||
if (to.path.startsWith('/settings/')) return settingsGuard(to, _from, next)
|
|
||||||
|
|
||||||
next()
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { useAppConfigStore } from '../stores/appConfig'
|
|
||||||
import { useWizardStore } from '../stores/wizard'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gate the entire app behind /setup until wizard_complete is true.
|
|
||||||
*
|
|
||||||
* Rules:
|
|
||||||
* - Any non-/setup route while wizard is incomplete → redirect to /setup
|
|
||||||
* - /setup/* while wizard is complete → redirect to /
|
|
||||||
* - /setup with no step suffix → redirect to the current step route
|
|
||||||
*
|
|
||||||
* Must run AFTER appConfig.load() has resolved (called from router.beforeEach).
|
|
||||||
*/
|
|
||||||
export async function wizardGuard(
|
|
||||||
to: { path: string },
|
|
||||||
_from: unknown,
|
|
||||||
next: (to?: string | { path: string }) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
const config = useAppConfigStore()
|
|
||||||
|
|
||||||
// Ensure config is loaded before inspecting wizardComplete
|
|
||||||
if (!config.loaded) await config.load()
|
|
||||||
|
|
||||||
const onSetup = to.path.startsWith('/setup')
|
|
||||||
const complete = config.wizardComplete
|
|
||||||
|
|
||||||
// Wizard done — keep user out of /setup
|
|
||||||
if (complete && onSetup) return next('/')
|
|
||||||
|
|
||||||
// Wizard not done — redirect to setup
|
|
||||||
if (!complete && !onSetup) return next('/setup')
|
|
||||||
|
|
||||||
// On /setup exactly (no step) — delegate to WizardLayout which loads status
|
|
||||||
next()
|
|
||||||
}
|
|
||||||
|
|
@ -11,25 +11,20 @@ export const useAppConfigStore = defineStore('appConfig', () => {
|
||||||
const tier = ref<Tier>('free')
|
const tier = ref<Tier>('free')
|
||||||
const contractedClient = ref(false)
|
const contractedClient = ref(false)
|
||||||
const inferenceProfile = ref<InferenceProfile>('cpu')
|
const inferenceProfile = ref<InferenceProfile>('cpu')
|
||||||
const isDemo = ref(false)
|
|
||||||
const wizardComplete = ref(true) // optimistic default — guard corrects on load
|
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
const devTierOverride = ref(localStorage.getItem('dev_tier_override') ?? '')
|
const devTierOverride = ref(localStorage.getItem('dev_tier_override') ?? '')
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const { data } = await useApiFetch<{
|
const { data } = await useApiFetch<{
|
||||||
isCloud: boolean; isDemo: boolean; isDevMode: boolean; tier: Tier
|
isCloud: boolean; isDevMode: boolean; tier: Tier
|
||||||
contractedClient: boolean; inferenceProfile: InferenceProfile
|
contractedClient: boolean; inferenceProfile: InferenceProfile
|
||||||
wizardComplete: boolean
|
|
||||||
}>('/api/config/app')
|
}>('/api/config/app')
|
||||||
if (!data) return
|
if (!data) return
|
||||||
isCloud.value = data.isCloud
|
isCloud.value = data.isCloud
|
||||||
isDemo.value = data.isDemo ?? false
|
|
||||||
isDevMode.value = data.isDevMode
|
isDevMode.value = data.isDevMode
|
||||||
tier.value = data.tier
|
tier.value = data.tier
|
||||||
contractedClient.value = data.contractedClient
|
contractedClient.value = data.contractedClient
|
||||||
inferenceProfile.value = data.inferenceProfile
|
inferenceProfile.value = data.inferenceProfile
|
||||||
wizardComplete.value = data.wizardComplete ?? true
|
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,5 +38,5 @@ export const useAppConfigStore = defineStore('appConfig', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { isCloud, isDemo, isDevMode, wizardComplete, tier, contractedClient, inferenceProfile, loaded, load, devTierOverride, setDevTierOverride }
|
return { isCloud, isDevMode, tier, contractedClient, inferenceProfile, loaded, load, devTierOverride, setDevTierOverride }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,6 @@ import { ref } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { useApiFetch } from '../../composables/useApi'
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
export interface TrainingPair {
|
|
||||||
index: number
|
|
||||||
instruction: string
|
|
||||||
source_file: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useFineTuneStore = defineStore('settings/fineTune', () => {
|
export const useFineTuneStore = defineStore('settings/fineTune', () => {
|
||||||
const step = ref(1)
|
const step = ref(1)
|
||||||
const inFlightJob = ref(false)
|
const inFlightJob = ref(false)
|
||||||
|
|
@ -16,8 +10,6 @@ export const useFineTuneStore = defineStore('settings/fineTune', () => {
|
||||||
const quotaRemaining = ref<number | null>(null)
|
const quotaRemaining = ref<number | null>(null)
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const pairs = ref<TrainingPair[]>([])
|
|
||||||
const pairsLoading = ref(false)
|
|
||||||
let _pollTimer: ReturnType<typeof setInterval> | null = null
|
let _pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
function resetStep() { step.value = 1 }
|
function resetStep() { step.value = 1 }
|
||||||
|
|
@ -45,26 +37,6 @@ export const useFineTuneStore = defineStore('settings/fineTune', () => {
|
||||||
if (!error && data) { inFlightJob.value = true; jobStatus.value = 'queued' }
|
if (!error && data) { inFlightJob.value = true; jobStatus.value = 'queued' }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPairs() {
|
|
||||||
pairsLoading.value = true
|
|
||||||
const { data } = await useApiFetch<{ pairs: TrainingPair[]; total: number }>('/api/settings/fine-tune/pairs')
|
|
||||||
pairsLoading.value = false
|
|
||||||
if (data) {
|
|
||||||
pairs.value = data.pairs
|
|
||||||
pairsCount.value = data.total
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deletePair(index: number) {
|
|
||||||
const { data } = await useApiFetch<{ ok: boolean; remaining: number }>(
|
|
||||||
`/api/settings/fine-tune/pairs/${index}`, { method: 'DELETE' }
|
|
||||||
)
|
|
||||||
if (data?.ok) {
|
|
||||||
pairs.value = pairs.value.filter(p => p.index !== index).map((p, i) => ({ ...p, index: i }))
|
|
||||||
pairsCount.value = data.remaining
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
step,
|
step,
|
||||||
inFlightJob,
|
inFlightJob,
|
||||||
|
|
@ -73,14 +45,10 @@ export const useFineTuneStore = defineStore('settings/fineTune', () => {
|
||||||
quotaRemaining,
|
quotaRemaining,
|
||||||
uploading,
|
uploading,
|
||||||
loading,
|
loading,
|
||||||
pairs,
|
|
||||||
pairsLoading,
|
|
||||||
resetStep,
|
resetStep,
|
||||||
loadStatus,
|
loadStatus,
|
||||||
startPolling,
|
startPolling,
|
||||||
stopPolling,
|
stopPolling,
|
||||||
submitJob,
|
submitJob,
|
||||||
loadPairs,
|
|
||||||
deletePair,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ export const useSearchStore = defineStore('settings/search', () => {
|
||||||
|
|
||||||
const titleSuggestions = ref<string[]>([])
|
const titleSuggestions = ref<string[]>([])
|
||||||
const locationSuggestions = ref<string[]>([])
|
const locationSuggestions = ref<string[]>([])
|
||||||
const excludeSuggestions = ref<string[]>([])
|
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
@ -100,24 +99,10 @@ export const useSearchStore = defineStore('settings/search', () => {
|
||||||
arr.value = arr.value.filter(v => v !== value)
|
arr.value = arr.value.filter(v => v !== value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function suggestExcludeKeywords() {
|
function acceptSuggestion(type: 'title' | 'location', value: string) {
|
||||||
const { data } = await useApiFetch<{ suggestions: string[] }>('/api/settings/search/suggest', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ type: 'exclude_keywords', current: exclude_keywords.value }),
|
|
||||||
})
|
|
||||||
if (data?.suggestions) {
|
|
||||||
excludeSuggestions.value = data.suggestions.filter(s => !exclude_keywords.value.includes(s))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function acceptSuggestion(type: 'title' | 'location' | 'exclude', value: string) {
|
|
||||||
if (type === 'title') {
|
if (type === 'title') {
|
||||||
if (!job_titles.value.includes(value)) job_titles.value = [...job_titles.value, value]
|
if (!job_titles.value.includes(value)) job_titles.value = [...job_titles.value, value]
|
||||||
titleSuggestions.value = titleSuggestions.value.filter(s => s !== value)
|
titleSuggestions.value = titleSuggestions.value.filter(s => s !== value)
|
||||||
} else if (type === 'exclude') {
|
|
||||||
if (!exclude_keywords.value.includes(value)) exclude_keywords.value = [...exclude_keywords.value, value]
|
|
||||||
excludeSuggestions.value = excludeSuggestions.value.filter(s => s !== value)
|
|
||||||
} else {
|
} else {
|
||||||
if (!locations.value.includes(value)) locations.value = [...locations.value, value]
|
if (!locations.value.includes(value)) locations.value = [...locations.value, value]
|
||||||
locationSuggestions.value = locationSuggestions.value.filter(s => s !== value)
|
locationSuggestions.value = locationSuggestions.value.filter(s => s !== value)
|
||||||
|
|
@ -133,9 +118,8 @@ export const useSearchStore = defineStore('settings/search', () => {
|
||||||
return {
|
return {
|
||||||
remote_preference, job_titles, locations, exclude_keywords, job_boards,
|
remote_preference, job_titles, locations, exclude_keywords, job_boards,
|
||||||
custom_board_urls, blocklist_companies, blocklist_industries, blocklist_locations,
|
custom_board_urls, blocklist_companies, blocklist_industries, blocklist_locations,
|
||||||
titleSuggestions, locationSuggestions, excludeSuggestions,
|
titleSuggestions, locationSuggestions,
|
||||||
loading, saving, saveError, loadError,
|
loading, saving, saveError, loadError,
|
||||||
load, save, suggestTitles, suggestLocations, suggestExcludeKeywords,
|
load, save, suggestTitles, suggestLocations, addTag, removeTag, acceptSuggestion, toggleBoard,
|
||||||
addTag, removeTag, acceptSuggestion, toggleBoard,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { useApiFetch } from '../composables/useApi'
|
|
||||||
|
|
||||||
export interface ActiveTask {
|
|
||||||
id: number
|
|
||||||
task_type: string
|
|
||||||
job_id: number
|
|
||||||
status: 'running' | 'queued'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TASK_LABEL: Record<string, string> = {
|
|
||||||
cover_letter: 'Cover letter',
|
|
||||||
company_research: 'Research',
|
|
||||||
discovery: 'Discovery',
|
|
||||||
enrich_descriptions: 'Enriching descriptions',
|
|
||||||
score: 'Scoring matches',
|
|
||||||
scrape_url: 'Scraping listing',
|
|
||||||
email_sync: 'Email sync',
|
|
||||||
wizard_generate: 'Wizard',
|
|
||||||
prepare_training: 'Training data',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ordered pipeline stages — tasks are visually grouped under discovery
|
|
||||||
* when they appear together, showing users the full auto-chain.
|
|
||||||
*/
|
|
||||||
export const DISCOVERY_PIPELINE = ['discovery', 'enrich_descriptions', 'score'] as const
|
|
||||||
|
|
||||||
/** Group active tasks into pipeline groups for display.
|
|
||||||
* Non-pipeline tasks (cover_letter, email_sync, etc.) each form their own group.
|
|
||||||
*/
|
|
||||||
export interface TaskGroup {
|
|
||||||
primary: ActiveTask
|
|
||||||
steps: ActiveTask[] // pipeline children, empty for non-pipeline tasks
|
|
||||||
}
|
|
||||||
|
|
||||||
export function groupTasks(tasks: ActiveTask[]): TaskGroup[] {
|
|
||||||
const pipelineSet = new Set(DISCOVERY_PIPELINE as readonly string[])
|
|
||||||
const pipelineTasks = tasks.filter(t => pipelineSet.has(t.task_type))
|
|
||||||
const otherTasks = tasks.filter(t => !pipelineSet.has(t.task_type))
|
|
||||||
|
|
||||||
const groups: TaskGroup[] = []
|
|
||||||
|
|
||||||
// Build one discovery pipeline group from all pipeline tasks in order
|
|
||||||
if (pipelineTasks.length) {
|
|
||||||
const ordered = [...DISCOVERY_PIPELINE]
|
|
||||||
.map(type => pipelineTasks.find(t => t.task_type === type))
|
|
||||||
.filter(Boolean) as ActiveTask[]
|
|
||||||
groups.push({ primary: ordered[0], steps: ordered.slice(1) })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Each non-pipeline task is its own group
|
|
||||||
for (const task of otherTasks) {
|
|
||||||
groups.push({ primary: task, steps: [] })
|
|
||||||
}
|
|
||||||
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useTasksStore = defineStore('tasks', () => {
|
|
||||||
const tasks = ref<ActiveTask[]>([])
|
|
||||||
const count = computed(() => tasks.value.length)
|
|
||||||
const groups = computed(() => groupTasks(tasks.value))
|
|
||||||
const label = computed(() => {
|
|
||||||
if (!tasks.value.length) return ''
|
|
||||||
const first = tasks.value[0]
|
|
||||||
const name = TASK_LABEL[first.task_type] ?? first.task_type
|
|
||||||
return tasks.value.length === 1 ? name : `${name} +${tasks.value.length - 1}`
|
|
||||||
})
|
|
||||||
|
|
||||||
// Callback registered by views that want counts refreshed while tasks run
|
|
||||||
let _onTasksClear: (() => void) | null = null
|
|
||||||
let _tasksWereActive = false
|
|
||||||
|
|
||||||
function onTasksClear(cb: () => void) { _onTasksClear = cb }
|
|
||||||
|
|
||||||
let _timer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
async function poll() {
|
|
||||||
const { data } = await useApiFetch<{ count: number; tasks: ActiveTask[] }>('/api/tasks/active')
|
|
||||||
if (!data) return
|
|
||||||
const wasActive = _tasksWereActive
|
|
||||||
tasks.value = data.tasks
|
|
||||||
_tasksWereActive = data.tasks.length > 0
|
|
||||||
// Fire callback when task queue just cleared so counts can update
|
|
||||||
if (wasActive && !_tasksWereActive && _onTasksClear) _onTasksClear()
|
|
||||||
}
|
|
||||||
|
|
||||||
function startPolling() {
|
|
||||||
if (_timer) return
|
|
||||||
poll()
|
|
||||||
_timer = setInterval(poll, 4000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPolling() {
|
|
||||||
if (_timer) { clearInterval(_timer); _timer = null }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { tasks, count, groups, label, poll, startPolling, stopPolling, onTasksClear }
|
|
||||||
})
|
|
||||||
|
|
@ -1,279 +0,0 @@
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { useApiFetch } from '../composables/useApi'
|
|
||||||
|
|
||||||
export type WizardProfile = 'remote' | 'cpu' | 'single-gpu' | 'dual-gpu'
|
|
||||||
export type WizardTier = 'free' | 'paid' | 'premium'
|
|
||||||
|
|
||||||
export interface WorkExperience {
|
|
||||||
title: string
|
|
||||||
company: string
|
|
||||||
start_date: string
|
|
||||||
end_date: string
|
|
||||||
bullets: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WizardHardwareData {
|
|
||||||
gpus: string[]
|
|
||||||
suggestedProfile: WizardProfile
|
|
||||||
selectedProfile: WizardProfile
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WizardSearchData {
|
|
||||||
titles: string[]
|
|
||||||
locations: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WizardIdentityData {
|
|
||||||
name: string
|
|
||||||
email: string
|
|
||||||
phone: string
|
|
||||||
linkedin: string
|
|
||||||
careerSummary: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WizardInferenceData {
|
|
||||||
anthropicKey: string
|
|
||||||
openaiUrl: string
|
|
||||||
openaiKey: string
|
|
||||||
ollamaHost: string
|
|
||||||
ollamaPort: number
|
|
||||||
services: Record<string, string | number>
|
|
||||||
confirmed: boolean
|
|
||||||
testMessage: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Total mandatory steps (integrations step 7 is optional/skip-able)
|
|
||||||
export const WIZARD_STEPS = 6
|
|
||||||
export const STEP_LABELS = ['Hardware', 'Tier', 'Resume', 'Identity', 'Inference', 'Search', 'Integrations']
|
|
||||||
export const STEP_ROUTES = [
|
|
||||||
'/setup/hardware',
|
|
||||||
'/setup/tier',
|
|
||||||
'/setup/resume',
|
|
||||||
'/setup/identity',
|
|
||||||
'/setup/inference',
|
|
||||||
'/setup/search',
|
|
||||||
'/setup/integrations',
|
|
||||||
]
|
|
||||||
|
|
||||||
export const useWizardStore = defineStore('wizard', () => {
|
|
||||||
// ── Navigation state ──────────────────────────────────────────────────────
|
|
||||||
const currentStep = ref(1) // 1-based; 7 = integrations (optional)
|
|
||||||
const loading = ref(false)
|
|
||||||
const saving = ref(false)
|
|
||||||
const errors = ref<string[]>([])
|
|
||||||
|
|
||||||
// ── Step data ─────────────────────────────────────────────────────────────
|
|
||||||
const hardware = ref<WizardHardwareData>({
|
|
||||||
gpus: [],
|
|
||||||
suggestedProfile: 'remote',
|
|
||||||
selectedProfile: 'remote',
|
|
||||||
})
|
|
||||||
|
|
||||||
const tier = ref<WizardTier>('free')
|
|
||||||
|
|
||||||
const resume = ref<{ experience: WorkExperience[]; parsedData: Record<string, unknown> | null }>({
|
|
||||||
experience: [],
|
|
||||||
parsedData: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const identity = ref<WizardIdentityData>({
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
phone: '',
|
|
||||||
linkedin: '',
|
|
||||||
careerSummary: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const inference = ref<WizardInferenceData>({
|
|
||||||
anthropicKey: '',
|
|
||||||
openaiUrl: '',
|
|
||||||
openaiKey: '',
|
|
||||||
ollamaHost: 'localhost',
|
|
||||||
ollamaPort: 11434,
|
|
||||||
services: {},
|
|
||||||
confirmed: false,
|
|
||||||
testMessage: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const search = ref<WizardSearchData>({
|
|
||||||
titles: [],
|
|
||||||
locations: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Computed ──────────────────────────────────────────────────────────────
|
|
||||||
const progressFraction = computed(() =>
|
|
||||||
Math.min((currentStep.value - 1) / WIZARD_STEPS, 1),
|
|
||||||
)
|
|
||||||
|
|
||||||
const stepLabel = computed(() =>
|
|
||||||
currentStep.value <= WIZARD_STEPS
|
|
||||||
? `Step ${currentStep.value} of ${WIZARD_STEPS}`
|
|
||||||
: 'Almost done!',
|
|
||||||
)
|
|
||||||
|
|
||||||
const routeForStep = (step: number) => STEP_ROUTES[step - 1] ?? '/setup/hardware'
|
|
||||||
|
|
||||||
// ── Actions ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Load wizard status from server and hydrate store. Returns the route to navigate to. */
|
|
||||||
async function loadStatus(isCloud: boolean): Promise<string> {
|
|
||||||
loading.value = true
|
|
||||||
errors.value = []
|
|
||||||
try {
|
|
||||||
const { data } = await useApiFetch<{
|
|
||||||
wizard_complete: boolean
|
|
||||||
wizard_step: number
|
|
||||||
saved_data: {
|
|
||||||
inference_profile?: string
|
|
||||||
tier?: string
|
|
||||||
name?: string
|
|
||||||
email?: string
|
|
||||||
phone?: string
|
|
||||||
linkedin?: string
|
|
||||||
career_summary?: string
|
|
||||||
services?: Record<string, string | number>
|
|
||||||
}
|
|
||||||
}>('/api/wizard/status')
|
|
||||||
|
|
||||||
if (!data) return '/setup/hardware'
|
|
||||||
|
|
||||||
const saved = data.saved_data
|
|
||||||
|
|
||||||
if (saved.inference_profile)
|
|
||||||
hardware.value.selectedProfile = saved.inference_profile as WizardProfile
|
|
||||||
if (saved.tier)
|
|
||||||
tier.value = saved.tier as WizardTier
|
|
||||||
if (saved.name) identity.value.name = saved.name
|
|
||||||
if (saved.email) identity.value.email = saved.email
|
|
||||||
if (saved.phone) identity.value.phone = saved.phone
|
|
||||||
if (saved.linkedin) identity.value.linkedin = saved.linkedin
|
|
||||||
if (saved.career_summary) identity.value.careerSummary = saved.career_summary
|
|
||||||
if (saved.services) inference.value.services = saved.services
|
|
||||||
|
|
||||||
// Cloud: auto-skip steps 1 (hardware), 2 (tier), 5 (inference)
|
|
||||||
if (isCloud) {
|
|
||||||
const cloudStep = data.wizard_step
|
|
||||||
if (cloudStep < 1) {
|
|
||||||
await saveStep(1, { inference_profile: 'single-gpu' })
|
|
||||||
await saveStep(2, { tier: tier.value })
|
|
||||||
currentStep.value = 3
|
|
||||||
return '/setup/resume'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resume at next step after last completed
|
|
||||||
const resumeAt = Math.max(1, Math.min(data.wizard_step + 1, 7))
|
|
||||||
currentStep.value = resumeAt
|
|
||||||
return routeForStep(resumeAt)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Detect GPUs and populate hardware step. */
|
|
||||||
async function detectHardware(): Promise<void> {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const { data } = await useApiFetch<{
|
|
||||||
gpus: string[]
|
|
||||||
suggested_profile: string
|
|
||||||
profiles: string[]
|
|
||||||
}>('/api/wizard/hardware')
|
|
||||||
|
|
||||||
if (!data) return
|
|
||||||
hardware.value.gpus = data.gpus
|
|
||||||
hardware.value.suggestedProfile = data.suggested_profile as WizardProfile
|
|
||||||
// Only set selectedProfile if not already chosen by user
|
|
||||||
if (!hardware.value.selectedProfile || hardware.value.selectedProfile === 'remote') {
|
|
||||||
hardware.value.selectedProfile = data.suggested_profile as WizardProfile
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Persist a step's data to the server. */
|
|
||||||
async function saveStep(step: number, data: Record<string, unknown>): Promise<boolean> {
|
|
||||||
saving.value = true
|
|
||||||
errors.value = []
|
|
||||||
try {
|
|
||||||
const { data: result, error } = await useApiFetch('/api/wizard/step', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ step, data }),
|
|
||||||
})
|
|
||||||
if (error) {
|
|
||||||
errors.value = [error.kind === 'http' ? error.detail : error.message]
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
currentStep.value = step
|
|
||||||
return true
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Test LLM / Ollama connectivity. */
|
|
||||||
async function testInference(): Promise<{ ok: boolean; message: string }> {
|
|
||||||
const payload = {
|
|
||||||
profile: hardware.value.selectedProfile,
|
|
||||||
anthropic_key: inference.value.anthropicKey,
|
|
||||||
openai_url: inference.value.openaiUrl,
|
|
||||||
openai_key: inference.value.openaiKey,
|
|
||||||
ollama_host: inference.value.ollamaHost,
|
|
||||||
ollama_port: inference.value.ollamaPort,
|
|
||||||
}
|
|
||||||
const { data } = await useApiFetch<{ ok: boolean; message: string }>(
|
|
||||||
'/api/wizard/inference/test',
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const result = data ?? { ok: false, message: 'No response from server.' }
|
|
||||||
inference.value.testMessage = result.message
|
|
||||||
inference.value.confirmed = true // always soft-confirm so user isn't blocked
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Finalise the wizard. */
|
|
||||||
async function complete(): Promise<boolean> {
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
const { error } = await useApiFetch('/api/wizard/complete', { method: 'POST' })
|
|
||||||
if (error) {
|
|
||||||
errors.value = [error.kind === 'http' ? error.detail : error.message]
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
// state
|
|
||||||
currentStep,
|
|
||||||
loading,
|
|
||||||
saving,
|
|
||||||
errors,
|
|
||||||
hardware,
|
|
||||||
tier,
|
|
||||||
resume,
|
|
||||||
identity,
|
|
||||||
inference,
|
|
||||||
search,
|
|
||||||
// computed
|
|
||||||
progressFraction,
|
|
||||||
stepLabel,
|
|
||||||
// actions
|
|
||||||
loadStatus,
|
|
||||||
detectHardware,
|
|
||||||
saveStep,
|
|
||||||
testInference,
|
|
||||||
complete,
|
|
||||||
routeForStep,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -53,13 +53,6 @@
|
||||||
:loading="taskRunning === 'score'"
|
:loading="taskRunning === 'score'"
|
||||||
@click="scoreUnscored"
|
@click="scoreUnscored"
|
||||||
/>
|
/>
|
||||||
<WorkflowButton
|
|
||||||
emoji="🔍"
|
|
||||||
label="Fill Missing Descriptions"
|
|
||||||
description="Re-fetch truncated job descriptions"
|
|
||||||
:loading="taskRunning === 'enrich'"
|
|
||||||
@click="runEnrich"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
@ -87,6 +80,7 @@
|
||||||
? `Last enriched ${formatRelative(store.status.enrichment_last_run)}`
|
? `Last enriched ${formatRelative(store.status.enrichment_last_run)}`
|
||||||
: 'Auto-enrichment active' }}
|
: 'Auto-enrichment active' }}
|
||||||
</span>
|
</span>
|
||||||
|
<button class="btn-ghost btn-ghost--sm" @click="runEnrich">Run Now</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -168,194 +162,24 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Danger Zone -->
|
<!-- Advanced -->
|
||||||
<section class="home__section">
|
<section class="home__section">
|
||||||
<details class="danger-zone">
|
<details class="advanced">
|
||||||
<summary class="danger-zone__summary">⚠️ Danger Zone</summary>
|
<summary class="advanced__summary">Advanced</summary>
|
||||||
<div class="danger-zone__body">
|
<div class="advanced__body">
|
||||||
|
<p class="advanced__warning">⚠️ These actions are destructive and cannot be undone.</p>
|
||||||
<!-- Queue reset -->
|
<div class="home__actions home__actions--danger">
|
||||||
<div class="dz-block">
|
<button class="action-btn action-btn--danger" @click="confirmPurge">
|
||||||
<p class="dz-block__title">Queue reset</p>
|
🗑️ Purge Pending + Rejected
|
||||||
<p class="dz-block__desc">
|
</button>
|
||||||
Archive clears your review queue while keeping job URLs for dedup — same listings
|
<button class="action-btn action-btn--danger" @click="killTasks">
|
||||||
won't resurface on the next discovery run. Use hard purge only for a full clean slate
|
🛑 Kill Stuck Tasks
|
||||||
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
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</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 -->
|
<!-- Stoop speed toast — easter egg 9.2 -->
|
||||||
<Transition name="toast">
|
<Transition name="toast">
|
||||||
<div v-if="stoopToast" class="stoop-toast" role="status" aria-live="polite">
|
<div v-if="stoopToast" class="stoop-toast" role="status" aria-live="polite">
|
||||||
|
|
@ -366,7 +190,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { useJobsStore } from '../stores/jobs'
|
import { useJobsStore } from '../stores/jobs'
|
||||||
import { useApiFetch } from '../composables/useApi'
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
|
@ -407,8 +231,6 @@ function formatRelative(isoStr: string) {
|
||||||
return hrs === 1 ? '1 hour ago' : `${hrs} hours ago`
|
return hrs === 1 ? '1 hour ago' : `${hrs} hours ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Task execution ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const taskRunning = ref<string | null>(null)
|
const taskRunning = ref<string | null>(null)
|
||||||
const stoopToast = ref(false)
|
const stoopToast = ref(false)
|
||||||
|
|
||||||
|
|
@ -417,16 +239,13 @@ async function runTask(key: string, endpoint: string) {
|
||||||
await useApiFetch(endpoint, { method: 'POST' })
|
await useApiFetch(endpoint, { method: 'POST' })
|
||||||
taskRunning.value = null
|
taskRunning.value = null
|
||||||
store.refresh()
|
store.refresh()
|
||||||
fetchActiveTasks()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const runDiscovery = () => runTask('discovery', '/api/tasks/discovery')
|
const runDiscovery = () => runTask('discovery', '/api/tasks/discovery')
|
||||||
const syncEmails = () => runTask('email', '/api/tasks/email-sync')
|
const syncEmails = () => runTask('email', '/api/tasks/email-sync')
|
||||||
const scoreUnscored = () => runTask('score', '/api/tasks/score')
|
const scoreUnscored = () => runTask('score', '/api/tasks/score')
|
||||||
const syncIntegration = () => runTask('sync', '/api/tasks/sync')
|
const syncIntegration = () => runTask('sync', '/api/tasks/sync')
|
||||||
const runEnrich = () => runTask('enrich', '/api/tasks/enrich')
|
const runEnrich = () => useApiFetch('/api/tasks/enrich', { method: 'POST' })
|
||||||
|
|
||||||
// ── Add jobs ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const addTab = ref<'url' | 'csv'>('url')
|
const addTab = ref<'url' | 'csv'>('url')
|
||||||
const urlInput = ref('')
|
const urlInput = ref('')
|
||||||
|
|
@ -450,8 +269,6 @@ function handleCsvUpload(e: Event) {
|
||||||
useApiFetch('/api/jobs/upload-csv', { method: 'POST', body: form })
|
useApiFetch('/api/jobs/upload-csv', { method: 'POST', body: form })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Backlog archive ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function archiveByStatus(statuses: string[]) {
|
async function archiveByStatus(statuses: string[]) {
|
||||||
await useApiFetch('/api/jobs/archive', {
|
await useApiFetch('/api/jobs/archive', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -461,100 +278,26 @@ async function archiveByStatus(statuses: string[]) {
|
||||||
store.refresh()
|
store.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Danger Zone ────────────────────────────────────────────────────────────
|
function confirmPurge() {
|
||||||
|
// TODO: replace with ConfirmModal component
|
||||||
interface TaskRow { id: number; task_type: string; status: string; title?: string; company?: string; job_id: number }
|
if (confirm('Permanently delete all pending and rejected jobs? This cannot be undone.')) {
|
||||||
interface Banner { key: string; text: string; link: string }
|
useApiFetch('/api/jobs/purge', {
|
||||||
interface ConfirmAction { type: 'archive' | 'purge'; statuses: string[] }
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const activeTasks = ref<TaskRow[]>([])
|
body: JSON.stringify({ target: 'pending_rejected' }),
|
||||||
const dangerScope = ref<'pending' | 'pending_approved'>('pending')
|
})
|
||||||
const confirmAction = ref<ConfirmAction | null>(null)
|
store.refresh()
|
||||||
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: '🔗',
|
|
||||||
}
|
}
|
||||||
return icons[taskType] ?? '⚙️'
|
}
|
||||||
|
|
||||||
|
async function killTasks() {
|
||||||
|
await useApiFetch('/api/tasks/kill', { method: 'POST' })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
store.refresh()
|
store.refresh()
|
||||||
const { data } = await useApiFetch<{ name: string }>('/api/config/user')
|
const { data } = await useApiFetch<{ name: string }>('/api/config/user')
|
||||||
if (data?.name) userName.value = data.name
|
if (data?.name) userName.value = data.name
|
||||||
fetchActiveTasks()
|
|
||||||
fetchBanners()
|
|
||||||
taskPollInterval = setInterval(fetchActiveTasks, 5000)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (taskPollInterval) clearInterval(taskPollInterval)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -649,11 +392,12 @@ onUnmounted(() => {
|
||||||
|
|
||||||
.home__actions {
|
.home__actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home__actions--secondary { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
|
.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 {
|
.sync-banner {
|
||||||
display: flex;
|
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 { 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: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 {
|
.enrichment-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -782,15 +528,13 @@ onUnmounted(() => {
|
||||||
|
|
||||||
.add-jobs__textarea:focus { outline: 2px solid var(--app-primary); outline-offset: 1px; }
|
.add-jobs__textarea:focus { outline: 2px solid var(--app-primary); outline-offset: 1px; }
|
||||||
|
|
||||||
/* ── Danger Zone ──────────────────────────────────────── */
|
.advanced {
|
||||||
|
|
||||||
.danger-zone {
|
|
||||||
background: var(--color-surface-raised);
|
background: var(--color-surface-raised);
|
||||||
border: 1px solid var(--color-border-light);
|
border: 1px solid var(--color-border-light);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-zone__summary {
|
.advanced__summary {
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
|
|
@ -800,172 +544,21 @@ onUnmounted(() => {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-zone__summary::-webkit-details-marker { display: none; }
|
.advanced__summary::-webkit-details-marker { display: none; }
|
||||||
.danger-zone__summary::before { content: '▶ '; font-size: 0.7em; }
|
.advanced__summary::before { content: '▶ '; font-size: 0.7em; }
|
||||||
details[open] > .danger-zone__summary::before { content: '▼ '; }
|
details[open] > .advanced__summary::before { content: '▼ '; }
|
||||||
|
|
||||||
.danger-zone__body {
|
.advanced__body { padding: 0 var(--space-4) var(--space-4); display: flex; flex-direction: column; gap: var(--space-4); }
|
||||||
padding: 0 var(--space-4) var(--space-4);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dz-block { display: flex; flex-direction: column; gap: var(--space-3); }
|
.advanced__warning {
|
||||||
|
|
||||||
.dz-block__title {
|
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
font-weight: 600;
|
color: var(--color-warning);
|
||||||
color: var(--color-text);
|
background: rgba(212, 137, 26, 0.08);
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
display: flex;
|
border-left: 3px solid var(--color-warning);
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
.stoop-toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: var(--space-6);
|
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 { padding: var(--space-4); gap: var(--space-6); }
|
||||||
.home__greeting { font-size: var(--text-2xl); }
|
.home__greeting { font-size: var(--text-2xl); }
|
||||||
.home__metrics { grid-template-columns: repeat(3, 1fr); }
|
.home__metrics { grid-template-columns: repeat(3, 1fr); }
|
||||||
.dz-more__body { grid-template-columns: 1fr; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import type { StageSignal } from '../stores/interviews'
|
||||||
import { useApiFetch } from '../composables/useApi'
|
import { useApiFetch } from '../composables/useApi'
|
||||||
import InterviewCard from '../components/InterviewCard.vue'
|
import InterviewCard from '../components/InterviewCard.vue'
|
||||||
import MoveToSheet from '../components/MoveToSheet.vue'
|
import MoveToSheet from '../components/MoveToSheet.vue'
|
||||||
import CompanyResearchModal from '../components/CompanyResearchModal.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useInterviewsStore()
|
const store = useInterviewsStore()
|
||||||
|
|
@ -23,29 +22,10 @@ function openMove(jobId: number, preSelectedStage?: PipelineStage) {
|
||||||
|
|
||||||
async function onMove(stage: PipelineStage, opts: { interview_date?: string; rejection_stage?: string }) {
|
async function onMove(stage: PipelineStage, opts: { interview_date?: string; rejection_stage?: string }) {
|
||||||
if (!moveTarget.value) return
|
if (!moveTarget.value) return
|
||||||
const movedJob = moveTarget.value
|
|
||||||
const wasHired = stage === 'hired'
|
const wasHired = stage === 'hired'
|
||||||
await store.move(movedJob.id, stage, opts)
|
await store.move(moveTarget.value.id, stage, opts)
|
||||||
moveTarget.value = null
|
moveTarget.value = null
|
||||||
if (wasHired) triggerConfetti()
|
if (wasHired) triggerConfetti()
|
||||||
// Auto-open research modal when moving to phone_screen (mirrors Streamlit behaviour)
|
|
||||||
if (stage === 'phone_screen') openResearch(movedJob.id, `${movedJob.title} at ${movedJob.company}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Company research modal ─────────────────────────────────────────────────────
|
|
||||||
const researchJobId = ref<number | null>(null)
|
|
||||||
const researchJobTitle = ref('')
|
|
||||||
const researchAutoGen = ref(false)
|
|
||||||
|
|
||||||
function openResearch(jobId: number, jobTitle: string, autoGenerate = true) {
|
|
||||||
researchJobId.value = jobId
|
|
||||||
researchJobTitle.value = jobTitle
|
|
||||||
researchAutoGen.value = autoGenerate
|
|
||||||
}
|
|
||||||
|
|
||||||
function onInterviewCardResearch(jobId: number) {
|
|
||||||
const job = store.jobs.find(j => j.id === jobId)
|
|
||||||
if (job) openResearch(jobId, `${job.title} at ${job.company}`, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Collapsible Applied section ────────────────────────────────────────────
|
// ── Collapsible Applied section ────────────────────────────────────────────
|
||||||
|
|
@ -486,8 +466,7 @@ function daysSince(dateStr: string | null) {
|
||||||
</div>
|
</div>
|
||||||
<InterviewCard v-for="(job, i) in store.phoneScreen" :key="job.id" :job="job"
|
<InterviewCard v-for="(job, i) in store.phoneScreen" :key="job.id" :job="job"
|
||||||
:focused="focusedCol === 0 && focusedCard === i"
|
:focused="focusedCol === 0 && focusedCard === i"
|
||||||
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)"
|
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)" />
|
||||||
@research="onInterviewCardResearch" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 1 }" aria-label="Interviewing">
|
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 1 }" aria-label="Interviewing">
|
||||||
|
|
@ -500,8 +479,7 @@ function daysSince(dateStr: string | null) {
|
||||||
</div>
|
</div>
|
||||||
<InterviewCard v-for="(job, i) in store.interviewing" :key="job.id" :job="job"
|
<InterviewCard v-for="(job, i) in store.interviewing" :key="job.id" :job="job"
|
||||||
:focused="focusedCol === 1 && focusedCard === i"
|
:focused="focusedCol === 1 && focusedCard === i"
|
||||||
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)"
|
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)" />
|
||||||
@research="onInterviewCardResearch" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 2 }" aria-label="Offer and Hired">
|
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 2 }" aria-label="Offer and Hired">
|
||||||
|
|
@ -514,8 +492,7 @@ function daysSince(dateStr: string | null) {
|
||||||
</div>
|
</div>
|
||||||
<InterviewCard v-for="(job, i) in store.offerHired" :key="job.id" :job="job"
|
<InterviewCard v-for="(job, i) in store.offerHired" :key="job.id" :job="job"
|
||||||
:focused="focusedCol === 2 && focusedCard === i"
|
:focused="focusedCol === 2 && focusedCard === i"
|
||||||
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)"
|
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)" />
|
||||||
@research="onInterviewCardResearch" />
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -548,14 +525,6 @@ function daysSince(dateStr: string | null) {
|
||||||
@move="onMove"
|
@move="onMove"
|
||||||
@close="moveTarget = null; movePreSelected = undefined"
|
@close="moveTarget = null; movePreSelected = undefined"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CompanyResearchModal
|
|
||||||
v-if="researchJobId !== null"
|
|
||||||
:jobId="researchJobId"
|
|
||||||
:jobTitle="researchJobTitle"
|
|
||||||
:autoGenerate="researchAutoGen"
|
|
||||||
@close="researchJobId = null"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,50 +98,25 @@
|
||||||
<span class="spinner" aria-hidden="true" />
|
<span class="spinner" aria-hidden="true" />
|
||||||
<span>Loading…</span>
|
<span>Loading…</span>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<div v-else-if="store.listJobs.length === 0" class="review__empty" role="status">
|
||||||
<!-- Sort + filter bar -->
|
<p class="empty-desc">No {{ activeTab }} jobs.</p>
|
||||||
<div class="list-controls" aria-label="Sort and filter">
|
</div>
|
||||||
<select v-model="sortBy" class="list-sort" aria-label="Sort by">
|
<ul v-else class="job-list" role="list">
|
||||||
<option value="match_score">Best match</option>
|
<li v-for="job in store.listJobs" :key="job.id" class="job-list__item">
|
||||||
<option value="date_found">Newest first</option>
|
<div class="job-list__info">
|
||||||
<option value="company">Company A–Z</option>
|
<span class="job-list__title">{{ job.title }}</span>
|
||||||
</select>
|
<span class="job-list__company">{{ job.company }}</span>
|
||||||
<label class="list-filter-remote">
|
</div>
|
||||||
<input type="checkbox" v-model="filterRemote" />
|
<div class="job-list__meta">
|
||||||
Remote only
|
<span v-if="job.match_score !== null" class="score-pill" :class="scorePillClass(job.match_score)">
|
||||||
</label>
|
{{ job.match_score }}%
|
||||||
<span class="list-count">{{ sortedFilteredJobs.length }} job{{ sortedFilteredJobs.length !== 1 ? 's' : '' }}</span>
|
</span>
|
||||||
</div>
|
<a :href="job.url" target="_blank" rel="noopener noreferrer" class="job-list__link">
|
||||||
|
View ↗
|
||||||
<div v-if="sortedFilteredJobs.length === 0" class="review__empty" role="status">
|
</a>
|
||||||
<p class="empty-desc">No {{ activeTab }} jobs{{ filterRemote ? ' (remote only)' : '' }}.</p>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
<ul v-else class="job-list" role="list">
|
</ul>
|
||||||
<li v-for="job in sortedFilteredJobs" :key="job.id" class="job-list__item">
|
|
||||||
<div class="job-list__info">
|
|
||||||
<span class="job-list__title">{{ job.title }}</span>
|
|
||||||
<span class="job-list__company">
|
|
||||||
{{ job.company }}
|
|
||||||
<span v-if="job.is_remote" class="remote-tag">Remote</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="job-list__meta">
|
|
||||||
<span v-if="job.match_score !== null" class="score-pill" :class="scorePillClass(job.match_score)">
|
|
||||||
{{ job.match_score }}%
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
v-if="activeTab === 'approved'"
|
|
||||||
class="job-list__action"
|
|
||||||
@click="router.push(`/apply/${job.id}`)"
|
|
||||||
:aria-label="`Draft cover letter for ${job.title}`"
|
|
||||||
>✨ Draft</button>
|
|
||||||
<a :href="job.url" target="_blank" rel="noopener noreferrer" class="job-list__link">
|
|
||||||
View ↗
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Help overlay ─────────────────────────────────────────────────── -->
|
<!-- ── Help overlay ─────────────────────────────────────────────────── -->
|
||||||
|
|
@ -211,13 +186,12 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useReviewStore } from '../stores/review'
|
import { useReviewStore } from '../stores/review'
|
||||||
import JobCardStack from '../components/JobCardStack.vue'
|
import JobCardStack from '../components/JobCardStack.vue'
|
||||||
|
|
||||||
const store = useReviewStore()
|
const store = useReviewStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
|
||||||
const stackRef = ref<InstanceType<typeof JobCardStack> | null>(null)
|
const stackRef = ref<InstanceType<typeof JobCardStack> | null>(null)
|
||||||
|
|
||||||
// ─── Tabs ──────────────────────────────────────────────────────────────────────
|
// ─── Tabs ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -341,30 +315,6 @@ function onKeyDown(e: KeyboardEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── List view: sort + filter ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type SortKey = 'match_score' | 'date_found' | 'company'
|
|
||||||
const sortBy = ref<SortKey>('match_score')
|
|
||||||
const filterRemote = ref(false)
|
|
||||||
|
|
||||||
const sortedFilteredJobs = computed(() => {
|
|
||||||
let jobs = [...store.listJobs]
|
|
||||||
if (filterRemote.value) jobs = jobs.filter(j => j.is_remote)
|
|
||||||
jobs.sort((a, b) => {
|
|
||||||
if (sortBy.value === 'match_score') return (b.match_score ?? -1) - (a.match_score ?? -1)
|
|
||||||
if (sortBy.value === 'date_found') return new Date(b.date_found).getTime() - new Date(a.date_found).getTime()
|
|
||||||
if (sortBy.value === 'company') return (a.company ?? '').localeCompare(b.company ?? '')
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
return jobs
|
|
||||||
})
|
|
||||||
|
|
||||||
// Reset filters when switching tabs
|
|
||||||
watch(activeTab, () => {
|
|
||||||
filterRemote.value = false
|
|
||||||
sortBy.value = 'match_score'
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── List view score pill ─────────────────────────────────────────────────────
|
// ─── List view score pill ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function scorePillClass(score: number) {
|
function scorePillClass(score: number) {
|
||||||
|
|
@ -709,69 +659,6 @@ kbd {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-list__action {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--app-primary);
|
|
||||||
background: color-mix(in srgb, var(--app-primary) 10%, transparent);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--app-primary) 25%, transparent);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 2px 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 150ms;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-list__action:hover {
|
|
||||||
background: color-mix(in srgb, var(--app-primary) 18%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.remote-tag {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-info);
|
|
||||||
background: color-mix(in srgb, var(--color-info) 12%, transparent);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
padding: 1px 5px;
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── List controls (sort + filter) ──────────────────────────────────── */
|
|
||||||
|
|
||||||
.list-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-sort {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--color-surface-raised);
|
|
||||||
color: var(--color-text);
|
|
||||||
padding: 3px 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-filter-remote {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-count {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Help overlay ────────────────────────────────────────────────────── */
|
/* ── Help overlay ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.help-overlay {
|
.help-overlay {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
|
||||||
const store = useFineTuneStore()
|
const store = useFineTuneStore()
|
||||||
const config = useAppConfigStore()
|
const config = useAppConfigStore()
|
||||||
const { step, inFlightJob, jobStatus, pairsCount, quotaRemaining, pairs, pairsLoading } = storeToRefs(store)
|
const { step, inFlightJob, jobStatus, pairsCount, quotaRemaining } = storeToRefs(store)
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const selectedFiles = ref<File[]>([])
|
const selectedFiles = ref<File[]>([])
|
||||||
|
|
@ -45,7 +45,6 @@ async function checkLocalModel() {
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
store.startPolling()
|
store.startPolling()
|
||||||
await store.loadPairs()
|
|
||||||
if (store.step === 3 && !config.isCloud) await checkLocalModel()
|
if (store.step === 3 && !config.isCloud) await checkLocalModel()
|
||||||
})
|
})
|
||||||
onUnmounted(() => { store.stopPolling(); store.resetStep() })
|
onUnmounted(() => { store.stopPolling(); store.resetStep() })
|
||||||
|
|
@ -100,22 +99,6 @@ onUnmounted(() => { store.stopPolling(); store.resetStep() })
|
||||||
</button>
|
</button>
|
||||||
<button @click="store.step = 3" class="btn-secondary">Skip → Train</button>
|
<button @click="store.step = 3" class="btn-secondary">Skip → Train</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Training pairs list -->
|
|
||||||
<div v-if="pairs.length > 0" class="pairs-list">
|
|
||||||
<h4>Training Pairs <span class="pairs-badge">{{ pairs.length }}</span></h4>
|
|
||||||
<p class="section-note">Review and remove any low-quality pairs before training.</p>
|
|
||||||
<div v-if="pairsLoading" class="pairs-loading">Loading…</div>
|
|
||||||
<ul v-else class="pairs-items">
|
|
||||||
<li v-for="pair in pairs" :key="pair.index" class="pair-item">
|
|
||||||
<div class="pair-info">
|
|
||||||
<span class="pair-instruction">{{ pair.instruction }}</span>
|
|
||||||
<span class="pair-source">{{ pair.source_file }}</span>
|
|
||||||
</div>
|
|
||||||
<button class="pair-delete" @click="store.deletePair(pair.index)" title="Remove this pair">✕</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Step 3: Train -->
|
<!-- Step 3: Train -->
|
||||||
|
|
@ -177,16 +160,4 @@ onUnmounted(() => { store.stopPolling(); store.resetStep() })
|
||||||
.status-running { background: var(--color-warning-bg, #fef3c7); color: var(--color-warning-fg, #92400e); }
|
.status-running { background: var(--color-warning-bg, #fef3c7); color: var(--color-warning-fg, #92400e); }
|
||||||
.status-ok { color: var(--color-success, #16a34a); }
|
.status-ok { color: var(--color-success, #16a34a); }
|
||||||
.status-fail { color: var(--color-error, #dc2626); }
|
.status-fail { color: var(--color-error, #dc2626); }
|
||||||
|
|
||||||
.pairs-list { margin-top: var(--space-6, 1.5rem); }
|
|
||||||
.pairs-list h4 { font-size: 0.95rem; font-weight: 600; margin: 0 0 var(--space-2, 0.5rem); display: flex; align-items: center; gap: 0.5rem; }
|
|
||||||
.pairs-badge { background: var(--color-primary, #2d5a27); color: #fff; font-size: 0.75rem; padding: 1px 7px; border-radius: var(--radius-full, 9999px); }
|
|
||||||
.pairs-loading { color: var(--color-text-muted); font-size: 0.875rem; padding: var(--space-2, 0.5rem) 0; }
|
|
||||||
.pairs-items { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); max-height: 280px; overflow-y: auto; }
|
|
||||||
.pair-item { display: flex; align-items: center; gap: var(--space-3, 0.75rem); padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); background: var(--color-surface-alt); border: 1px solid var(--color-border-light); border-radius: var(--radius-md); }
|
|
||||||
.pair-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.pair-instruction { font-size: 0.85rem; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.pair-source { font-size: 0.75rem; color: var(--color-text-muted); }
|
|
||||||
.pair-delete { flex-shrink: 0; background: none; border: none; color: var(--color-error); cursor: pointer; font-size: 0.9rem; padding: 2px 4px; border-radius: var(--radius-sm); transition: background 150ms; }
|
|
||||||
.pair-delete:hover { background: var(--color-error); color: #fff; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -62,16 +62,9 @@
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="How you write and communicate — used to shape cover letter voice."
|
placeholder="How you write and communicate — used to shape cover letter voice."
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
v-if="config.tier !== 'free'"
|
|
||||||
class="btn-generate"
|
|
||||||
type="button"
|
|
||||||
@click="generateVoice"
|
|
||||||
:disabled="generatingVoice"
|
|
||||||
>{{ generatingVoice ? 'Generating…' : 'Generate ✦' }}</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!config.isCloud" class="field-row">
|
<div class="field-row">
|
||||||
<label class="field-label" for="profile-inference">Inference profile</label>
|
<label class="field-label" for="profile-inference">Inference profile</label>
|
||||||
<select id="profile-inference" v-model="store.inference_profile" class="select-input">
|
<select id="profile-inference" v-model="store.inference_profile" class="select-input">
|
||||||
<option value="remote">Remote</option>
|
<option value="remote">Remote</option>
|
||||||
|
|
@ -217,7 +210,6 @@ const config = useAppConfigStore()
|
||||||
const newNdaCompany = ref('')
|
const newNdaCompany = ref('')
|
||||||
const generatingSummary = ref(false)
|
const generatingSummary = ref(false)
|
||||||
const generatingMissions = ref(false)
|
const generatingMissions = ref(false)
|
||||||
const generatingVoice = ref(false)
|
|
||||||
|
|
||||||
onMounted(() => { store.load() })
|
onMounted(() => { store.load() })
|
||||||
|
|
||||||
|
|
@ -273,15 +265,6 @@ async function generateMissions() {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateVoice() {
|
|
||||||
generatingVoice.value = true
|
|
||||||
const { data, error } = await useApiFetch<{ voice?: string }>(
|
|
||||||
'/api/settings/profile/generate-voice', { method: 'POST' }
|
|
||||||
)
|
|
||||||
generatingVoice.value = false
|
|
||||||
if (!error && data?.voice) store.candidate_voice = data.voice
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,7 @@
|
||||||
<div class="empty-card">
|
<div class="empty-card">
|
||||||
<h3>Upload & Parse</h3>
|
<h3>Upload & Parse</h3>
|
||||||
<p>Upload a PDF, DOCX, or ODT and we'll extract your info automatically.</p>
|
<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" />
|
<input type="file" accept=".pdf,.docx,.odt" @change="handleUpload" ref="fileInput" />
|
||||||
<button
|
|
||||||
v-if="pendingFile"
|
|
||||||
@click="handleUpload"
|
|
||||||
:disabled="uploading"
|
|
||||||
style="margin-top:10px"
|
|
||||||
>{{ uploading ? 'Parsing…' : `Parse "${pendingFile.name}"` }}</button>
|
|
||||||
<p v-if="uploadError" class="error">{{ uploadError }}</p>
|
<p v-if="uploadError" class="error">{{ uploadError }}</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Blank -->
|
<!-- Blank -->
|
||||||
|
|
@ -30,8 +24,8 @@
|
||||||
<p>Start with a blank form and fill in your details.</p>
|
<p>Start with a blank form and fill in your details.</p>
|
||||||
<button @click="store.createBlank()" :disabled="store.loading">Start from Scratch</button>
|
<button @click="store.createBlank()" :disabled="store.loading">Start from Scratch</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Wizard — self-hosted only -->
|
<!-- Wizard -->
|
||||||
<div v-if="!config.isCloud" class="empty-card">
|
<div class="empty-card">
|
||||||
<h3>Run Setup Wizard</h3>
|
<h3>Run Setup Wizard</h3>
|
||||||
<p>Walk through the onboarding wizard to set up your profile step by step.</p>
|
<p>Walk through the onboarding wizard to set up your profile step by step.</p>
|
||||||
<RouterLink to="/setup">Open Setup Wizard →</RouterLink>
|
<RouterLink to="/setup">Open Setup Wizard →</RouterLink>
|
||||||
|
|
@ -41,21 +35,6 @@
|
||||||
|
|
||||||
<!-- Full form (when resume exists) -->
|
<!-- Full form (when resume exists) -->
|
||||||
<template v-else-if="store.hasResume">
|
<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 -->
|
<!-- Personal Information -->
|
||||||
<section class="form-section">
|
<section class="form-section">
|
||||||
<h3>Personal Information</h3>
|
<h3>Personal Information</h3>
|
||||||
|
|
@ -242,22 +221,17 @@ import { ref, onMounted } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useResumeStore } from '../../stores/settings/resume'
|
import { useResumeStore } from '../../stores/settings/resume'
|
||||||
import { useProfileStore } from '../../stores/settings/profile'
|
import { useProfileStore } from '../../stores/settings/profile'
|
||||||
import { useAppConfigStore } from '../../stores/appConfig'
|
|
||||||
import { useApiFetch } from '../../composables/useApi'
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
const store = useResumeStore()
|
const store = useResumeStore()
|
||||||
const profileStore = useProfileStore()
|
const profileStore = useProfileStore()
|
||||||
const config = useAppConfigStore()
|
|
||||||
const { loadError } = storeToRefs(store)
|
const { loadError } = storeToRefs(store)
|
||||||
const showSelfId = ref(false)
|
const showSelfId = ref(false)
|
||||||
const skillInput = ref('')
|
const skillInput = ref('')
|
||||||
const domainInput = ref('')
|
const domainInput = ref('')
|
||||||
const kwInput = ref('')
|
const kwInput = ref('')
|
||||||
const uploadError = ref<string | null>(null)
|
const uploadError = ref<string | null>(null)
|
||||||
const uploading = ref(false)
|
|
||||||
const pendingFile = ref<File | null>(null)
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const replaceFileInput = ref<HTMLInputElement | null>(null)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await store.load()
|
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]
|
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
|
if (!file) return
|
||||||
uploading.value = true
|
|
||||||
uploadError.value = null
|
uploadError.value = null
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
|
|
@ -289,14 +256,10 @@ async function handleUpload() {
|
||||||
'/api/settings/resume/upload',
|
'/api/settings/resume/upload',
|
||||||
{ method: 'POST', body: formData }
|
{ method: 'POST', body: formData }
|
||||||
)
|
)
|
||||||
uploading.value = false
|
|
||||||
if (error || !data?.ok) {
|
if (error || !data?.ok) {
|
||||||
uploadError.value = data?.error ?? (typeof error === 'string' ? error : (error?.kind === 'network' ? error.message : error?.detail ?? 'Upload failed'))
|
uploadError.value = data?.error ?? (typeof error === 'string' ? error : (error?.kind === 'network' ? error.message : error?.detail ?? 'Upload failed'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pendingFile.value = null
|
|
||||||
if (fileInput.value) fileInput.value.value = ''
|
|
||||||
if (replaceFileInput.value) replaceFileInput.value.value = ''
|
|
||||||
if (data.data) {
|
if (data.data) {
|
||||||
await store.load()
|
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; }
|
.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; }
|
.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); }
|
.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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -69,18 +69,7 @@
|
||||||
{{ kw }} <button @click="store.removeTag('exclude_keywords', kw)">×</button>
|
{{ kw }} <button @click="store.removeTag('exclude_keywords', kw)">×</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tag-input-row">
|
<input v-model="excludeInput" @keydown.enter.prevent="store.addTag('exclude_keywords', excludeInput); excludeInput = ''" placeholder="Add keyword, press Enter" />
|
||||||
<input v-model="excludeInput" @keydown.enter.prevent="store.addTag('exclude_keywords', excludeInput); excludeInput = ''" placeholder="Add keyword, press Enter" />
|
|
||||||
<button @click="store.suggestExcludeKeywords()" class="btn-suggest">Suggest</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="store.excludeSuggestions.length > 0" class="suggestions">
|
|
||||||
<span
|
|
||||||
v-for="s in store.excludeSuggestions"
|
|
||||||
:key="s"
|
|
||||||
class="suggestion-chip"
|
|
||||||
@click="store.acceptSuggestion('exclude', s)"
|
|
||||||
>+ {{ s }}</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Job Boards -->
|
<!-- Job Boards -->
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,7 @@ const config = useAppConfigStore()
|
||||||
const devOverride = computed(() => !!config.devTierOverride)
|
const devOverride = computed(() => !!config.devTierOverride)
|
||||||
const gpuProfiles = ['single-gpu', 'dual-gpu']
|
const gpuProfiles = ['single-gpu', 'dual-gpu']
|
||||||
|
|
||||||
const showSystem = computed(() => !config.isCloud)
|
const showSystem = computed(() => !config.isCloud)
|
||||||
const showData = computed(() => !config.isCloud)
|
|
||||||
const showFineTune = computed(() => {
|
const showFineTune = computed(() => {
|
||||||
if (config.isCloud) return config.tier === 'premium'
|
if (config.isCloud) return config.tier === 'premium'
|
||||||
return gpuProfiles.includes(config.inferenceProfile)
|
return gpuProfiles.includes(config.inferenceProfile)
|
||||||
|
|
@ -66,7 +65,7 @@ const allGroups = [
|
||||||
]},
|
]},
|
||||||
{ label: 'Account', items: [
|
{ label: 'Account', items: [
|
||||||
{ key: 'license', path: '/settings/license', label: 'License', show: true },
|
{ 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 },
|
{ key: 'privacy', path: '/settings/privacy', label: 'Privacy', show: true },
|
||||||
]},
|
]},
|
||||||
{ label: 'Dev', items: [
|
{ label: 'Dev', items: [
|
||||||
|
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="step">
|
|
||||||
<h2 class="step__heading">Step 1 — Hardware Detection</h2>
|
|
||||||
<p class="step__caption">
|
|
||||||
Peregrine uses your hardware profile to choose the right inference setup.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div v-if="wizard.loading" class="step__info">Detecting hardware…</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div v-if="wizard.hardware.gpus.length" class="step__success">
|
|
||||||
✅ Detected {{ wizard.hardware.gpus.length }} GPU(s):
|
|
||||||
{{ wizard.hardware.gpus.join(', ') }}
|
|
||||||
</div>
|
|
||||||
<div v-else class="step__info">
|
|
||||||
No NVIDIA GPUs detected. "Remote" or "CPU" mode recommended.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="step__field">
|
|
||||||
<label class="step__label" for="hw-profile">Inference profile</label>
|
|
||||||
<select id="hw-profile" v-model="selectedProfile" class="step__select">
|
|
||||||
<option value="remote">Remote — use cloud API keys</option>
|
|
||||||
<option value="cpu">CPU — local Ollama, no GPU</option>
|
|
||||||
<option value="single-gpu">Single GPU — local Ollama + one GPU</option>
|
|
||||||
<option value="dual-gpu">Dual GPU — local Ollama + two GPUs</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="selectedProfile !== 'remote' && !wizard.hardware.gpus.length"
|
|
||||||
class="step__warning"
|
|
||||||
>
|
|
||||||
⚠️ No GPUs detected — a GPU profile may not work. Choose CPU or Remote
|
|
||||||
if you don't have a local NVIDIA GPU.
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="step__nav step__nav--end">
|
|
||||||
<button class="btn-primary" :disabled="wizard.saving" @click="next">
|
|
||||||
{{ wizard.saving ? 'Saving…' : 'Next →' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useWizardStore } from '../../stores/wizard'
|
|
||||||
import './wizard.css'
|
|
||||||
|
|
||||||
const wizard = useWizardStore()
|
|
||||||
const router = useRouter()
|
|
||||||
const selectedProfile = ref(wizard.hardware.selectedProfile)
|
|
||||||
|
|
||||||
onMounted(() => wizard.detectHardware())
|
|
||||||
|
|
||||||
async function next() {
|
|
||||||
wizard.hardware.selectedProfile = selectedProfile.value
|
|
||||||
const ok = await wizard.saveStep(1, { inference_profile: selectedProfile.value })
|
|
||||||
if (ok) router.push('/setup/tier')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="step">
|
|
||||||
<h2 class="step__heading">Step 4 — Your Identity</h2>
|
|
||||||
<p class="step__caption">
|
|
||||||
Used in cover letters, research briefs, and interview prep. You can update
|
|
||||||
this any time in Settings → My Profile.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="step__field">
|
|
||||||
<label class="step__label" for="id-name">Full name <span class="required">*</span></label>
|
|
||||||
<input id="id-name" v-model="form.name" type="text" class="step__input"
|
|
||||||
placeholder="Your Name" autocomplete="name" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="step__field">
|
|
||||||
<label class="step__label" for="id-email">Email <span class="required">*</span></label>
|
|
||||||
<input id="id-email" v-model="form.email" type="email" class="step__input"
|
|
||||||
placeholder="you@example.com" autocomplete="email" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="step__field">
|
|
||||||
<label class="step__label step__label--optional" for="id-phone">Phone</label>
|
|
||||||
<input id="id-phone" v-model="form.phone" type="tel" class="step__input"
|
|
||||||
placeholder="555-000-0000" autocomplete="tel" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="step__field">
|
|
||||||
<label class="step__label step__label--optional" for="id-linkedin">LinkedIn URL</label>
|
|
||||||
<input id="id-linkedin" v-model="form.linkedin" type="url" class="step__input"
|
|
||||||
placeholder="linkedin.com/in/yourprofile" autocomplete="url" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="step__field">
|
|
||||||
<label class="step__label" for="id-summary">
|
|
||||||
Career summary <span class="required">*</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="id-summary"
|
|
||||||
v-model="form.careerSummary"
|
|
||||||
class="step__textarea"
|
|
||||||
rows="5"
|
|
||||||
placeholder="2–3 sentences summarising your experience, domain, and what you're looking for next."
|
|
||||||
/>
|
|
||||||
<p class="field-hint">This appears in your cover letters and research briefs.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="validationError" class="step__warning">{{ validationError }}</div>
|
|
||||||
|
|
||||||
<div class="step__nav">
|
|
||||||
<button class="btn-ghost" @click="back">← Back</button>
|
|
||||||
<button class="btn-primary" :disabled="wizard.saving" @click="next">
|
|
||||||
{{ wizard.saving ? 'Saving…' : 'Next →' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { reactive, ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useWizardStore } from '../../stores/wizard'
|
|
||||||
import './wizard.css'
|
|
||||||
|
|
||||||
const wizard = useWizardStore()
|
|
||||||
const router = useRouter()
|
|
||||||
const validationError = ref('')
|
|
||||||
|
|
||||||
// Local reactive copy — sync back to store on Next
|
|
||||||
const form = reactive({
|
|
||||||
name: wizard.identity.name,
|
|
||||||
email: wizard.identity.email,
|
|
||||||
phone: wizard.identity.phone,
|
|
||||||
linkedin: wizard.identity.linkedin,
|
|
||||||
careerSummary: wizard.identity.careerSummary,
|
|
||||||
})
|
|
||||||
|
|
||||||
function back() { router.push('/setup/resume') }
|
|
||||||
|
|
||||||
async function next() {
|
|
||||||
validationError.value = ''
|
|
||||||
if (!form.name.trim()) {
|
|
||||||
validationError.value = 'Full name is required.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!form.email.trim() || !form.email.includes('@')) {
|
|
||||||
validationError.value = 'A valid email address is required.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!form.careerSummary.trim()) {
|
|
||||||
validationError.value = 'Please add a short career summary.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
wizard.identity = { ...form }
|
|
||||||
const ok = await wizard.saveStep(4, {
|
|
||||||
name: form.name,
|
|
||||||
email: form.email,
|
|
||||||
phone: form.phone,
|
|
||||||
linkedin: form.linkedin,
|
|
||||||
career_summary: form.careerSummary,
|
|
||||||
})
|
|
||||||
if (ok) router.push('/setup/inference')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.required {
|
|
||||||
color: var(--color-error);
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-hint {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin-top: var(--space-1);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="step">
|
|
||||||
<h2 class="step__heading">Step 5 — Inference & API Keys</h2>
|
|
||||||
<p class="step__caption">
|
|
||||||
Configure how Peregrine generates AI content. You can adjust this any time
|
|
||||||
in Settings → System.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Remote mode -->
|
|
||||||
<template v-if="isRemote">
|
|
||||||
<div class="step__info">
|
|
||||||
Remote mode: at least one external API key is required for AI generation.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="step__field">
|
|
||||||
<label class="step__label" for="inf-anthropic">Anthropic API key</label>
|
|
||||||
<input id="inf-anthropic" v-model="form.anthropicKey" type="password"
|
|
||||||
class="step__input" placeholder="sk-ant-…" autocomplete="off" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="step__field">
|
|
||||||
<label class="step__label step__label--optional" for="inf-oai-url">
|
|
||||||
OpenAI-compatible endpoint
|
|
||||||
</label>
|
|
||||||
<input id="inf-oai-url" v-model="form.openaiUrl" type="url"
|
|
||||||
class="step__input" placeholder="https://api.together.xyz/v1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="form.openaiUrl" class="step__field">
|
|
||||||
<label class="step__label step__label--optional" for="inf-oai-key">
|
|
||||||
Endpoint API key
|
|
||||||
</label>
|
|
||||||
<input id="inf-oai-key" v-model="form.openaiKey" type="password"
|
|
||||||
class="step__input" placeholder="API key for the endpoint above"
|
|
||||||
autocomplete="off" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Local mode -->
|
|
||||||
<template v-else>
|
|
||||||
<div class="step__info">
|
|
||||||
Local mode ({{ wizard.hardware.selectedProfile }}): Peregrine uses
|
|
||||||
Ollama for AI generation. No API keys needed.
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Advanced: service ports -->
|
|
||||||
<div class="step__expandable">
|
|
||||||
<button class="step__expandable__toggle" @click="showAdvanced = !showAdvanced">
|
|
||||||
{{ showAdvanced ? '▼' : '▶' }} Advanced — service hosts & ports
|
|
||||||
</button>
|
|
||||||
<div v-if="showAdvanced" class="step__expandable__body">
|
|
||||||
<div class="svc-row" v-for="svc in services" :key="svc.key">
|
|
||||||
<span class="svc-label">{{ svc.label }}</span>
|
|
||||||
<input v-model="svc.host" type="text" class="step__input svc-input" />
|
|
||||||
<input v-model.number="svc.port" type="number" class="step__input svc-port" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Connection test -->
|
|
||||||
<div class="test-row">
|
|
||||||
<button class="btn-secondary" :disabled="testing" @click="runTest">
|
|
||||||
{{ testing ? 'Testing…' : '🔌 Test connection' }}
|
|
||||||
</button>
|
|
||||||
<span v-if="testResult" :class="testResult.ok ? 'test-ok' : 'test-warn'">
|
|
||||||
{{ testResult.message }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="step__nav">
|
|
||||||
<button class="btn-ghost" @click="back">← Back</button>
|
|
||||||
<button class="btn-primary" :disabled="wizard.saving" @click="next">
|
|
||||||
{{ wizard.saving ? 'Saving…' : 'Next →' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { reactive, ref, computed } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useWizardStore } from '../../stores/wizard'
|
|
||||||
import './wizard.css'
|
|
||||||
|
|
||||||
const wizard = useWizardStore()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const isRemote = computed(() => wizard.hardware.selectedProfile === 'remote')
|
|
||||||
const showAdvanced = ref(false)
|
|
||||||
const testing = ref(false)
|
|
||||||
const testResult = ref<{ ok: boolean; message: string } | null>(null)
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
anthropicKey: wizard.inference.anthropicKey,
|
|
||||||
openaiUrl: wizard.inference.openaiUrl,
|
|
||||||
openaiKey: wizard.inference.openaiKey,
|
|
||||||
})
|
|
||||||
|
|
||||||
const services = reactive([
|
|
||||||
{ key: 'ollama', label: 'Ollama', host: 'ollama', port: 11434 },
|
|
||||||
{ key: 'searxng', label: 'SearXNG', host: 'searxng', port: 8080 },
|
|
||||||
])
|
|
||||||
|
|
||||||
async function runTest() {
|
|
||||||
testing.value = true
|
|
||||||
testResult.value = null
|
|
||||||
wizard.inference.anthropicKey = form.anthropicKey
|
|
||||||
wizard.inference.openaiUrl = form.openaiUrl
|
|
||||||
wizard.inference.openaiKey = form.openaiKey
|
|
||||||
testResult.value = await wizard.testInference()
|
|
||||||
testing.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function back() { router.push('/setup/identity') }
|
|
||||||
|
|
||||||
async function next() {
|
|
||||||
// Sync form back to store
|
|
||||||
wizard.inference.anthropicKey = form.anthropicKey
|
|
||||||
wizard.inference.openaiUrl = form.openaiUrl
|
|
||||||
wizard.inference.openaiKey = form.openaiKey
|
|
||||||
|
|
||||||
const svcMap: Record<string, string | number> = {}
|
|
||||||
services.forEach(s => {
|
|
||||||
svcMap[`${s.key}_host`] = s.host
|
|
||||||
svcMap[`${s.key}_port`] = s.port
|
|
||||||
})
|
|
||||||
wizard.inference.services = svcMap
|
|
||||||
|
|
||||||
const ok = await wizard.saveStep(5, {
|
|
||||||
anthropic_key: form.anthropicKey,
|
|
||||||
openai_url: form.openaiUrl,
|
|
||||||
openai_key: form.openaiKey,
|
|
||||||
services: svcMap,
|
|
||||||
})
|
|
||||||
if (ok) router.push('/setup/search')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.test-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-4);
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-ok { font-size: 0.875rem; color: var(--color-success); }
|
|
||||||
.test-warn { font-size: 0.875rem; color: var(--color-warning); }
|
|
||||||
|
|
||||||
.svc-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 6rem 1fr 5rem;
|
|
||||||
gap: var(--space-2);
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-port {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="step">
|
|
||||||
<h2 class="step__heading">Step 7 — Integrations</h2>
|
|
||||||
<p class="step__caption">
|
|
||||||
Optional. Connect external tools to supercharge your workflow.
|
|
||||||
You can configure these any time in Settings → System.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="int-grid">
|
|
||||||
<label
|
|
||||||
v-for="card in integrations"
|
|
||||||
:key="card.id"
|
|
||||||
class="int-card"
|
|
||||||
:class="{
|
|
||||||
'int-card--selected': selected.has(card.id),
|
|
||||||
'int-card--paid': card.paid && !isPaid,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="int-card__check"
|
|
||||||
:value="card.id"
|
|
||||||
:disabled="card.paid && !isPaid"
|
|
||||||
v-model="checkedIds"
|
|
||||||
/>
|
|
||||||
<span class="int-card__icon" aria-hidden="true">{{ card.icon }}</span>
|
|
||||||
<span class="int-card__name">{{ card.name }}</span>
|
|
||||||
<span v-if="card.paid && !isPaid" class="int-card__badge">Paid</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="selected.size > 0" class="step__info" style="margin-top: var(--space-4)">
|
|
||||||
You'll configure credentials for {{ [...selected].map(id => labelFor(id)).join(', ') }}
|
|
||||||
in Settings → System after setup completes.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="step__nav">
|
|
||||||
<button class="btn-ghost" @click="back">← Back</button>
|
|
||||||
<button class="btn-primary" :disabled="wizard.saving" @click="finish">
|
|
||||||
{{ wizard.saving ? 'Saving…' : 'Finish Setup →' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useWizardStore } from '../../stores/wizard'
|
|
||||||
import { useAppConfigStore } from '../../stores/appConfig'
|
|
||||||
import './wizard.css'
|
|
||||||
|
|
||||||
const wizard = useWizardStore()
|
|
||||||
const config = useAppConfigStore()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const isPaid = computed(() =>
|
|
||||||
wizard.tier === 'paid' || wizard.tier === 'premium',
|
|
||||||
)
|
|
||||||
|
|
||||||
interface IntegrationCard {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
icon: string
|
|
||||||
paid: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const integrations: IntegrationCard[] = [
|
|
||||||
{ id: 'notion', name: 'Notion', icon: '🗒️', paid: false },
|
|
||||||
{ id: 'google_calendar', name: 'Google Calendar', icon: '📅', paid: true },
|
|
||||||
{ id: 'apple_calendar', name: 'Apple Calendar', icon: '🍏', paid: true },
|
|
||||||
{ id: 'slack', name: 'Slack', icon: '💬', paid: true },
|
|
||||||
{ id: 'discord', name: 'Discord', icon: '🎮', paid: true },
|
|
||||||
{ id: 'google_drive', name: 'Google Drive', icon: '📁', paid: true },
|
|
||||||
]
|
|
||||||
|
|
||||||
const checkedIds = ref<string[]>([])
|
|
||||||
const selected = computed(() => new Set(checkedIds.value))
|
|
||||||
|
|
||||||
function labelFor(id: string): string {
|
|
||||||
return integrations.find(i => i.id === id)?.name ?? id
|
|
||||||
}
|
|
||||||
|
|
||||||
function back() { router.push('/setup/search') }
|
|
||||||
|
|
||||||
async function finish() {
|
|
||||||
// Save integration selections (step 7) then mark wizard complete
|
|
||||||
await wizard.saveStep(7, { integrations: [...checkedIds.value] })
|
|
||||||
const ok = await wizard.complete()
|
|
||||||
if (ok) router.replace('/')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.int-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
||||||
gap: var(--space-3);
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.int-card {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: var(--space-4) var(--space-3);
|
|
||||||
border: 2px solid var(--color-border-light);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color var(--transition), background var(--transition);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.int-card:hover:not(.int-card--paid) {
|
|
||||||
border-color: var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.int-card--selected {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface-alt));
|
|
||||||
}
|
|
||||||
|
|
||||||
.int-card--paid {
|
|
||||||
opacity: 0.55;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.int-card__check {
|
|
||||||
/* visually hidden but accessible */
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.int-card__icon {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.int-card__name {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text);
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.int-card__badge {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--color-warning);
|
|
||||||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
padding: 1px 6px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="wizard">
|
|
||||||
<div class="wizard__card">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="wizard__header">
|
|
||||||
<img
|
|
||||||
v-if="logoSrc"
|
|
||||||
:src="logoSrc"
|
|
||||||
alt="Peregrine"
|
|
||||||
class="wizard__logo"
|
|
||||||
/>
|
|
||||||
<h1 class="wizard__title">Welcome to Peregrine</h1>
|
|
||||||
<p class="wizard__subtitle">
|
|
||||||
Complete the setup to start your job search.
|
|
||||||
Progress saves automatically.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Progress bar -->
|
|
||||||
<div class="wizard__progress" role="progressbar"
|
|
||||||
:aria-valuenow="Math.round(wizard.progressFraction * 100)"
|
|
||||||
aria-valuemin="0" aria-valuemax="100">
|
|
||||||
<div class="wizard__progress-track">
|
|
||||||
<div class="wizard__progress-fill" :style="{ width: `${wizard.progressFraction * 100}%` }" />
|
|
||||||
</div>
|
|
||||||
<span class="wizard__progress-label">{{ wizard.stepLabel }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step content -->
|
|
||||||
<div class="wizard__body">
|
|
||||||
<div v-if="wizard.loading" class="wizard__loading" aria-live="polite">
|
|
||||||
<span class="wizard__spinner" aria-hidden="true" />
|
|
||||||
Loading…
|
|
||||||
</div>
|
|
||||||
<RouterView v-else />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Global error banner -->
|
|
||||||
<div v-if="wizard.errors.length" class="wizard__error" role="alert">
|
|
||||||
<span v-for="e in wizard.errors" :key="e">{{ e }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { onMounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useWizardStore } from '../../stores/wizard'
|
|
||||||
import { useAppConfigStore } from '../../stores/appConfig'
|
|
||||||
|
|
||||||
const wizard = useWizardStore()
|
|
||||||
const config = useAppConfigStore()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
// Peregrine logo — served from the static assets directory
|
|
||||||
const logoSrc = '/static/peregrine_logo_circle.png'
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!config.loaded) await config.load()
|
|
||||||
const target = await wizard.loadStatus(config.isCloud)
|
|
||||||
if (router.currentRoute.value.path === '/setup') {
|
|
||||||
router.replace(target)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.wizard {
|
|
||||||
min-height: 100dvh;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: center;
|
|
||||||
padding: var(--space-8) var(--space-4);
|
|
||||||
background: var(--color-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard__card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 640px;
|
|
||||||
background: var(--color-surface-raised);
|
|
||||||
border: 1px solid var(--color-border-light);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard__header {
|
|
||||||
padding: var(--space-8) var(--space-8) var(--space-6);
|
|
||||||
text-align: center;
|
|
||||||
border-bottom: 1px solid var(--color-border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard__logo {
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard__title {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 1.625rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text);
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard__subtitle {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Progress */
|
|
||||||
.wizard__progress {
|
|
||||||
padding: var(--space-4) var(--space-8);
|
|
||||||
border-bottom: 1px solid var(--color-border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard__progress-track {
|
|
||||||
height: 6px;
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard__progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--color-primary);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
transition: width var(--transition-slow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard__progress-label {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Body */
|
|
||||||
.wizard__body {
|
|
||||||
padding: var(--space-8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading */
|
|
||||||
.wizard__loading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
padding: var(--space-8) 0;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard__spinner {
|
|
||||||
display: inline-block;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border: 2px solid var(--color-border);
|
|
||||||
border-top-color: var(--color-primary);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
animation: spin 0.7s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Error */
|
|
||||||
.wizard__error {
|
|
||||||
margin: 0 var(--space-8) var(--space-6);
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
|
||||||
border: 1px solid var(--color-error);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--color-error);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile */
|
|
||||||
@media (max-width: 680px) {
|
|
||||||
.wizard {
|
|
||||||
padding: 0;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard__card {
|
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
min-height: 100dvh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wizard__header,
|
|
||||||
.wizard__body {
|
|
||||||
padding-left: var(--space-6);
|
|
||||||
padding-right: var(--space-6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,313 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="step">
|
|
||||||
<h2 class="step__heading">Step 3 — Your Resume</h2>
|
|
||||||
<p class="step__caption">
|
|
||||||
Upload a resume to auto-populate your profile, or build it manually.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Tabs -->
|
|
||||||
<div class="resume-tabs" role="tablist">
|
|
||||||
<button
|
|
||||||
role="tab"
|
|
||||||
:aria-selected="tab === 'upload'"
|
|
||||||
class="resume-tab"
|
|
||||||
:class="{ 'resume-tab--active': tab === 'upload' }"
|
|
||||||
@click="tab = 'upload'"
|
|
||||||
>Upload File</button>
|
|
||||||
<button
|
|
||||||
role="tab"
|
|
||||||
:aria-selected="tab === 'manual'"
|
|
||||||
class="resume-tab"
|
|
||||||
:class="{ 'resume-tab--active': tab === 'manual' }"
|
|
||||||
@click="tab = 'manual'"
|
|
||||||
>Build Manually</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Upload tab -->
|
|
||||||
<div v-if="tab === 'upload'" class="resume-upload">
|
|
||||||
<label class="upload-zone" :class="{ 'upload-zone--active': dragging }"
|
|
||||||
@dragover.prevent="dragging = true"
|
|
||||||
@dragleave="dragging = false"
|
|
||||||
@drop.prevent="onDrop">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".pdf,.docx,.odt"
|
|
||||||
class="upload-input"
|
|
||||||
@change="onFileChange"
|
|
||||||
/>
|
|
||||||
<span class="upload-icon" aria-hidden="true">📄</span>
|
|
||||||
<span class="upload-label">
|
|
||||||
{{ fileName || 'Drop PDF, DOCX, or ODT here, or click to browse' }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div v-if="parseError" class="step__warning">{{ parseError }}</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="selectedFile"
|
|
||||||
class="btn-secondary"
|
|
||||||
:disabled="parsing"
|
|
||||||
style="margin-top: var(--space-3)"
|
|
||||||
@click="parseResume"
|
|
||||||
>
|
|
||||||
{{ parsing ? 'Parsing…' : '⚙️ Parse Resume' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div v-if="parsedOk" class="step__success">
|
|
||||||
✅ Resume parsed — {{ wizard.resume.experience.length }} experience
|
|
||||||
{{ wizard.resume.experience.length === 1 ? 'entry' : 'entries' }} found.
|
|
||||||
Switch to "Build Manually" to review or edit.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Manual build tab -->
|
|
||||||
<div v-if="tab === 'manual'" class="resume-manual">
|
|
||||||
<div
|
|
||||||
v-for="(exp, i) in wizard.resume.experience"
|
|
||||||
:key="i"
|
|
||||||
class="exp-entry"
|
|
||||||
>
|
|
||||||
<div class="exp-entry__header">
|
|
||||||
<span class="exp-entry__num">{{ i + 1 }}</span>
|
|
||||||
<button class="exp-entry__remove btn-ghost" @click="removeExp(i)">✕ Remove</button>
|
|
||||||
</div>
|
|
||||||
<div class="step__field">
|
|
||||||
<label class="step__label">Job title</label>
|
|
||||||
<input v-model="exp.title" type="text" class="step__input" placeholder="Software Engineer" />
|
|
||||||
</div>
|
|
||||||
<div class="step__field">
|
|
||||||
<label class="step__label">Company</label>
|
|
||||||
<input v-model="exp.company" type="text" class="step__input" placeholder="Acme Corp" />
|
|
||||||
</div>
|
|
||||||
<div class="exp-dates">
|
|
||||||
<div class="step__field">
|
|
||||||
<label class="step__label">Start</label>
|
|
||||||
<input v-model="exp.start_date" type="text" class="step__input" placeholder="2020" />
|
|
||||||
</div>
|
|
||||||
<div class="step__field">
|
|
||||||
<label class="step__label">End</label>
|
|
||||||
<input v-model="exp.end_date" type="text" class="step__input" placeholder="present" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="step__field">
|
|
||||||
<label class="step__label">Key accomplishments (one per line)</label>
|
|
||||||
<textarea
|
|
||||||
class="step__textarea"
|
|
||||||
rows="4"
|
|
||||||
:value="exp.bullets.join('\n')"
|
|
||||||
@input="(e) => exp.bullets = (e.target as HTMLTextAreaElement).value.split('\n')"
|
|
||||||
placeholder="Reduced load time by 40% Led a team of 5 engineers"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="btn-secondary" style="width: 100%" @click="addExp">
|
|
||||||
+ Add Experience Entry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="validationError" class="step__warning" style="margin-top: var(--space-4)">
|
|
||||||
{{ validationError }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="step__nav">
|
|
||||||
<button class="btn-ghost" @click="back">← Back</button>
|
|
||||||
<button class="btn-primary" :disabled="wizard.saving" @click="next">
|
|
||||||
{{ wizard.saving ? 'Saving…' : 'Next →' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useWizardStore } from '../../stores/wizard'
|
|
||||||
import type { WorkExperience } from '../../stores/wizard'
|
|
||||||
import { useApiFetch } from '../../composables/useApi'
|
|
||||||
import './wizard.css'
|
|
||||||
|
|
||||||
const wizard = useWizardStore()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const tab = ref<'upload' | 'manual'>(
|
|
||||||
wizard.resume.experience.length > 0 ? 'manual' : 'upload',
|
|
||||||
)
|
|
||||||
const dragging = ref(false)
|
|
||||||
const selectedFile = ref<File | null>(null)
|
|
||||||
const fileName = ref('')
|
|
||||||
const parsing = ref(false)
|
|
||||||
const parsedOk = ref(false)
|
|
||||||
const parseError = ref('')
|
|
||||||
const validationError = ref('')
|
|
||||||
|
|
||||||
function onFileChange(e: Event) {
|
|
||||||
const file = (e.target as HTMLInputElement).files?.[0]
|
|
||||||
if (file) { selectedFile.value = file; fileName.value = file.name }
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDrop(e: DragEvent) {
|
|
||||||
dragging.value = false
|
|
||||||
const file = e.dataTransfer?.files[0]
|
|
||||||
if (file) { selectedFile.value = file; fileName.value = file.name }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function parseResume() {
|
|
||||||
if (!selectedFile.value) return
|
|
||||||
parsing.value = true
|
|
||||||
parseError.value = ''
|
|
||||||
parsedOk.value = false
|
|
||||||
|
|
||||||
const form = new FormData()
|
|
||||||
form.append('file', selectedFile.value)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/settings/resume/upload', { method: 'POST', body: form })
|
|
||||||
if (!res.ok) {
|
|
||||||
parseError.value = `Parse failed (HTTP ${res.status}) — switch to Build Manually to enter your resume.`
|
|
||||||
tab.value = 'manual'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const resp = await res.json()
|
|
||||||
// API returns { ok, data: { experience, name, email, … } }
|
|
||||||
const data = resp.data ?? {}
|
|
||||||
// Map parsed sections to experience entries
|
|
||||||
if (data.experience?.length) {
|
|
||||||
wizard.resume.experience = data.experience as WorkExperience[]
|
|
||||||
}
|
|
||||||
wizard.resume.parsedData = data
|
|
||||||
// Pre-fill identity from parsed data
|
|
||||||
if (data.name && !wizard.identity.name) wizard.identity.name = data.name
|
|
||||||
if (data.email && !wizard.identity.email) wizard.identity.email = data.email
|
|
||||||
if (data.phone && !wizard.identity.phone) wizard.identity.phone = data.phone
|
|
||||||
if (data.career_summary && !wizard.identity.careerSummary)
|
|
||||||
wizard.identity.careerSummary = data.career_summary
|
|
||||||
|
|
||||||
parsedOk.value = true
|
|
||||||
tab.value = 'manual'
|
|
||||||
} catch {
|
|
||||||
parseError.value = 'Network error — switch to Build Manually to enter your resume.'
|
|
||||||
tab.value = 'manual'
|
|
||||||
} finally {
|
|
||||||
parsing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addExp() {
|
|
||||||
wizard.resume.experience.push({
|
|
||||||
title: '', company: '', start_date: '', end_date: 'present', bullets: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeExp(i: number) {
|
|
||||||
wizard.resume.experience.splice(i, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function back() { router.push('/setup/tier') }
|
|
||||||
|
|
||||||
async function next() {
|
|
||||||
validationError.value = ''
|
|
||||||
const valid = wizard.resume.experience.some(e => e.title.trim() && e.company.trim())
|
|
||||||
if (!valid) {
|
|
||||||
validationError.value = 'Add at least one experience entry with a title and company.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const ok = await wizard.saveStep(3, { resume: {
|
|
||||||
experience: wizard.resume.experience,
|
|
||||||
...(wizard.resume.parsedData ?? {}),
|
|
||||||
}})
|
|
||||||
if (ok) router.push('/setup/identity')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.resume-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 0;
|
|
||||||
border-bottom: 2px solid var(--color-border-light);
|
|
||||||
margin-bottom: var(--space-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resume-tab {
|
|
||||||
padding: var(--space-2) var(--space-5);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
margin-bottom: -2px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
transition: color var(--transition), border-color var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resume-tab--active {
|
|
||||||
color: var(--color-primary);
|
|
||||||
border-bottom-color: var(--color-primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-zone {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-8);
|
|
||||||
border: 2px dashed var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: center;
|
|
||||||
transition: border-color var(--transition), background var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-zone--active,
|
|
||||||
.upload-zone:hover {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
background: var(--color-primary-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-input {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-icon { font-size: 2rem; }
|
|
||||||
|
|
||||||
.upload-label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exp-entry {
|
|
||||||
border: 1px solid var(--color-border-light);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--space-4);
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exp-entry__header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exp-entry__num {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exp-entry__remove {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: var(--space-1) var(--space-2);
|
|
||||||
min-height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exp-dates {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="step">
|
|
||||||
<h2 class="step__heading">Step 6 — Search Preferences</h2>
|
|
||||||
<p class="step__caption">
|
|
||||||
Tell Peregrine what roles and markets to watch. You can add more profiles
|
|
||||||
in Settings → Search later.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Job titles -->
|
|
||||||
<div class="step__field">
|
|
||||||
<label class="step__label">
|
|
||||||
Job titles <span class="required">*</span>
|
|
||||||
</label>
|
|
||||||
<div class="chip-field">
|
|
||||||
<div class="chip-list" v-if="form.titles.length">
|
|
||||||
<span v-for="(t, i) in form.titles" :key="i" class="chip">
|
|
||||||
{{ t }}
|
|
||||||
<button class="chip__remove" @click="removeTitle(i)" aria-label="Remove title">×</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
v-model="titleInput"
|
|
||||||
type="text"
|
|
||||||
class="step__input chip-input"
|
|
||||||
placeholder="e.g. Software Engineer — press Enter to add"
|
|
||||||
@keydown.enter.prevent="addTitle"
|
|
||||||
@keydown.","="onTitleComma"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p class="field-hint">Press Enter or comma after each title.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Locations -->
|
|
||||||
<div class="step__field">
|
|
||||||
<label class="step__label">
|
|
||||||
Locations <span class="step__label--optional">(optional)</span>
|
|
||||||
</label>
|
|
||||||
<div class="chip-field">
|
|
||||||
<div class="chip-list" v-if="form.locations.length">
|
|
||||||
<span v-for="(l, i) in form.locations" :key="i" class="chip">
|
|
||||||
{{ l }}
|
|
||||||
<button class="chip__remove" @click="removeLocation(i)" aria-label="Remove location">×</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
v-model="locationInput"
|
|
||||||
type="text"
|
|
||||||
class="step__input chip-input"
|
|
||||||
placeholder="e.g. San Francisco, CA — press Enter to add"
|
|
||||||
@keydown.enter.prevent="addLocation"
|
|
||||||
@keydown.","="onLocationComma"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p class="field-hint">Leave blank to search everywhere, or add specific cities/metros.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Remote preference -->
|
|
||||||
<div class="step__field step__field--inline">
|
|
||||||
<label class="step__label step__label--inline" for="srch-remote">
|
|
||||||
Remote jobs only
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="srch-remote"
|
|
||||||
v-model="form.remoteOnly"
|
|
||||||
type="checkbox"
|
|
||||||
class="step__checkbox"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="validationError" class="step__warning">{{ validationError }}</div>
|
|
||||||
|
|
||||||
<div class="step__nav">
|
|
||||||
<button class="btn-ghost" @click="back">← Back</button>
|
|
||||||
<button class="btn-primary" :disabled="wizard.saving" @click="next">
|
|
||||||
{{ wizard.saving ? 'Saving…' : 'Next →' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { reactive, ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useWizardStore } from '../../stores/wizard'
|
|
||||||
import './wizard.css'
|
|
||||||
|
|
||||||
const wizard = useWizardStore()
|
|
||||||
const router = useRouter()
|
|
||||||
const validationError = ref('')
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
titles: [...wizard.search.titles],
|
|
||||||
locations: [...wizard.search.locations],
|
|
||||||
remoteOnly: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const titleInput = ref('')
|
|
||||||
const locationInput = ref('')
|
|
||||||
|
|
||||||
function addTitle() {
|
|
||||||
const v = titleInput.value.trim().replace(/,$/, '')
|
|
||||||
if (v && !form.titles.includes(v)) form.titles.push(v)
|
|
||||||
titleInput.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTitleComma(e: KeyboardEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
addTitle()
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeTitle(i: number) {
|
|
||||||
form.titles.splice(i, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function addLocation() {
|
|
||||||
const v = locationInput.value.trim().replace(/,$/, '')
|
|
||||||
if (v && !form.locations.includes(v)) form.locations.push(v)
|
|
||||||
locationInput.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLocationComma(e: KeyboardEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
addLocation()
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeLocation(i: number) {
|
|
||||||
form.locations.splice(i, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function back() { router.push('/setup/inference') }
|
|
||||||
|
|
||||||
async function next() {
|
|
||||||
// Flush any partial inputs before validating
|
|
||||||
addTitle()
|
|
||||||
addLocation()
|
|
||||||
|
|
||||||
validationError.value = ''
|
|
||||||
if (form.titles.length === 0) {
|
|
||||||
validationError.value = 'Add at least one job title.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
wizard.search.titles = [...form.titles]
|
|
||||||
wizard.search.locations = [...form.locations]
|
|
||||||
|
|
||||||
const ok = await wizard.saveStep(6, {
|
|
||||||
search: {
|
|
||||||
titles: form.titles,
|
|
||||||
locations: form.locations,
|
|
||||||
remote_only: form.remoteOnly,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (ok) router.push('/setup/integrations')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.required {
|
|
||||||
color: var(--color-error);
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-hint {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin-top: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__field--inline {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__label--inline {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__checkbox {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
accent-color: var(--color-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Chip input */
|
|
||||||
.chip-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
padding: var(--space-1) var(--space-3);
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
|
||||||
color: var(--color-primary);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip__remove {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: inherit;
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0 2px;
|
|
||||||
opacity: 0.7;
|
|
||||||
transition: opacity var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip__remove:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip-input {
|
|
||||||
margin-top: var(--space-1);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="step">
|
|
||||||
<h2 class="step__heading">Step 2 — Choose Your Plan</h2>
|
|
||||||
<p class="step__caption">
|
|
||||||
You can upgrade or change this later in Settings → License.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="step__radio-group">
|
|
||||||
<label
|
|
||||||
v-for="option in tiers"
|
|
||||||
:key="option.value"
|
|
||||||
class="step__radio-card"
|
|
||||||
:class="{ 'step__radio-card--selected': selected === option.value }"
|
|
||||||
>
|
|
||||||
<input type="radio" :value="option.value" v-model="selected" />
|
|
||||||
<div class="step__radio-card__body">
|
|
||||||
<span class="step__radio-card__title">{{ option.label }}</span>
|
|
||||||
<span class="step__radio-card__desc">{{ option.desc }}</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="step__nav">
|
|
||||||
<button class="btn-ghost" @click="back">← Back</button>
|
|
||||||
<button class="btn-primary" :disabled="wizard.saving" @click="next">
|
|
||||||
{{ wizard.saving ? 'Saving…' : 'Next →' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useWizardStore } from '../../stores/wizard'
|
|
||||||
import type { WizardTier } from '../../stores/wizard'
|
|
||||||
import './wizard.css'
|
|
||||||
|
|
||||||
const wizard = useWizardStore()
|
|
||||||
const router = useRouter()
|
|
||||||
const selected = ref<WizardTier>(wizard.tier)
|
|
||||||
|
|
||||||
const tiers = [
|
|
||||||
{
|
|
||||||
value: 'free' as WizardTier,
|
|
||||||
label: '🆓 Free',
|
|
||||||
desc: 'Core pipeline, job discovery, and resume matching. Bring your own LLM to unlock AI generation.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'paid' as WizardTier,
|
|
||||||
label: '⭐ Paid',
|
|
||||||
desc: 'Everything in Free, plus cloud AI generation, integrations (Notion, Calendar, Slack), and email sync.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'premium' as WizardTier,
|
|
||||||
label: '🏆 Premium',
|
|
||||||
desc: 'Everything in Paid, plus fine-tuned cover letter model, multi-user support, and advanced analytics.',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function back() { router.push('/setup/hardware') }
|
|
||||||
|
|
||||||
async function next() {
|
|
||||||
wizard.tier = selected.value
|
|
||||||
const ok = await wizard.saveStep(2, { tier: selected.value })
|
|
||||||
if (ok) router.push('/setup/resume')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,329 +0,0 @@
|
||||||
/* wizard.css — shared styles imported by every WizardXxxStep component */
|
|
||||||
|
|
||||||
/* ── Step heading ──────────────────────────────────────────────────────────── */
|
|
||||||
.step__heading {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text);
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__caption {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin-bottom: var(--space-6);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Info / warning banners ────────────────────────────────────────────────── */
|
|
||||||
.step__info {
|
|
||||||
background: color-mix(in srgb, var(--color-info) 10%, transparent);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-info) 40%, transparent);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-text);
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__warning {
|
|
||||||
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-warning) 40%, transparent);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-text);
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__success {
|
|
||||||
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-success) 40%, transparent);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-text);
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Form fields ───────────────────────────────────────────────────────────── */
|
|
||||||
.step__field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-1);
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__label--optional::after {
|
|
||||||
content: ' (optional)';
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__input,
|
|
||||||
.step__select,
|
|
||||||
.step__textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--color-surface-raised);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
transition: border-color var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__input:focus,
|
|
||||||
.step__select:focus,
|
|
||||||
.step__textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__input[type="password"] {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 100px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Radio cards (Tier step) ──────────────────────────────────────────────── */
|
|
||||||
.step__radio-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-3);
|
|
||||||
margin-bottom: var(--space-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__radio-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-4);
|
|
||||||
border: 2px solid var(--color-border-light);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color var(--transition), background var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__radio-card:hover {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
background: var(--color-primary-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__radio-card--selected {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
background: var(--color-primary-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__radio-card input[type="radio"] {
|
|
||||||
margin-top: 2px;
|
|
||||||
accent-color: var(--color-primary);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__radio-card__body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__radio-card__title {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__radio-card__desc {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Chip list (Search step) ──────────────────────────────────────────────── */
|
|
||||||
.step__chip-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-2);
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
min-height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
padding: var(--space-1) var(--space-3);
|
|
||||||
background: var(--color-primary-light);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
font-size: 0.825rem;
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__chip__remove {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--color-primary);
|
|
||||||
padding: 0;
|
|
||||||
line-height: 1;
|
|
||||||
font-size: 1rem;
|
|
||||||
opacity: 0.6;
|
|
||||||
transition: opacity var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__chip__remove:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__chip-input-row {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__chip-input-row .step__input {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Two-column layout (Search step) ─────────────────────────────────────── */
|
|
||||||
.step__cols {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: var(--space-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
|
||||||
.step__cols {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Expandable (advanced section) ───────────────────────────────────────── */
|
|
||||||
.step__expandable {
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__expandable__toggle {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
padding: var(--space-2) 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
transition: color var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__expandable__toggle:hover {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__expandable__body {
|
|
||||||
padding: var(--space-4);
|
|
||||||
border: 1px solid var(--color-border-light);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Navigation footer ────────────────────────────────────────────────────── */
|
|
||||||
.step__nav {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: var(--space-8);
|
|
||||||
padding-top: var(--space-6);
|
|
||||||
border-top: 1px solid var(--color-border-light);
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step__nav--end {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
padding: var(--space-2) var(--space-6);
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: var(--color-text-inverse);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--transition), opacity var(--transition);
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
|
||||||
background: var(--color-primary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost {
|
|
||||||
padding: var(--space-2) var(--space-4);
|
|
||||||
background: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--transition), border-color var(--transition);
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost:hover:not(:disabled) {
|
|
||||||
color: var(--color-text);
|
|
||||||
border-color: var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
padding: var(--space-2) var(--space-4);
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
color: var(--color-text);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--transition);
|
|
||||||
min-height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
|
||||||
background: var(--color-border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue