Compare commits

...

27 commits

Author SHA1 Message Date
e225346d23 ci: retrigger after Docker network pool fix
Some checks failed
CI / Backend (Python) (push) Failing after 23s
CI / Frontend (Vue) (push) Successful in 21s
2026-06-26 20:41:18 -07:00
bfb6de0dfe ci: add freeze/** branches to CI trigger
Some checks failed
CI / Backend (Python) (push) Failing after 15s
CI / Frontend (Vue) (push) Failing after 1s
freeze/rc branches were not covered by the push trigger, leaving
RC-stage work untested. Adds 'freeze/**' alongside existing patterns.
2026-06-26 19:24:40 -07:00
82c26074d8 fix: search prefs wizard data loss, resume sync link, docs + GUI help links
Bug fixes (filed as #125–#128):
- Wizard step 7 read data.titles instead of data.search.titles — user-entered
  job titles and locations were silently dropped on every wizard run (#125)
- GET /api/settings/search returned "titles" key but store expected "job_titles" —
  Settings → Search Prefs always showed empty even when data existed (#126)
- remote_only preference not persisted during wizard setup (#127)
- apply-to-profile didn't set default_resume_id in user.yaml, so future
  Resume Profile saves never synced back to the library entry (#128)

Also:
- Wizard step headings corrected (off-by-one after Training step was inserted)
- Ollama host in wizard inference step now reads from saved wizard state
- Resume upload during wizard now creates a library entry and sets it as default

Docs:
- New: docs/user-guide/daily-workflow.md — end-to-end daily usage guide
- Updated: docs/user-guide/settings.md — rewritten for Vue SPA (was Streamlit)
- mkdocs.yml nav: Daily Workflow added as first User Guide entry

GUI help links:
- web/src/composables/useDocsUrl.ts — shared docs base URL composable
- Home: "Daily Workflow guide ↗" link in subtitle
- Job Review: "? Docs" link in title row
- Resume Library: "? Help" link in header
- Settings → Resume Profile: "? Help" link in page header
- Settings → Search Prefs: "? Help" link in page header
2026-06-15 16:52:56 -07:00
f799aff4e0 fix: CPU as default inference profile, remote last in list
- Reorder PROFILES in step_hardware.py, _WIZARD_PROFILES in dev-api.py,
  and <option> elements in WizardHardwareStep.vue: cpu → single-gpu → dual-gpu → cf-orch → remote
- _suggest_profile() now defaults to "cpu" instead of "remote" when no local GPUs detected
- Update no-GPU hint text to remove "Remote" from suggested options
- Add nvidia GPU device reservation to compose.wizard-test.yml so the
  wizard test instance can run nvidia-smi and detect host GPUs
- Switch wizard-test compose to use ghcr.io/circuitforgellc/peregrine:latest
  (same image as main compose, avoids stale peregrine-api tag drift)
2026-06-15 09:11:14 -07:00
7e361aa6d1 chore: release Dockerfile and GHCR publish workflow for RC1
- Replace stale Streamlit Dockerfile with self-contained release build
  (uvicorn/FastAPI; Streamlit removed in #104)
- cf-orch BSL client installed via BuildKit secret in release CI;
  community builds skip it gracefully and fall back to local backends
- compose.yml api build now uses single-repo context (context: .)
  so self-hosters can build without sibling repo setup
- Add image: tags to api + web services in compose.yml and compose.demo.yml
  so docker compose pull works for pre-built images
- Enable Docker push in release.yml: api + web to GHCR on v* tags
  (was disabled pending BSL registry policy — cf-agents#3 resolved)
- cloud image (compose.cloud.yml / Dockerfile.cfcore) unchanged:
  never published, built on Heimdall with sibling repos available
- .dockerignore: add plain_text_resume.yaml and adzuna.yaml
2026-06-14 20:03:40 -07:00
80041d1dd9 feat: wire cf-orch allocate flow for LLM routing
- Fix cf_text base_url (was port 8006/cf-musicgen, corrected to 8008/cf-text)
- Add cf_orch blocks to cf_text, ollama, ollama_research, vllm_research backends
- Fix ollama_research base_url to host.docker.internal:11435 (was Docker service name)
- Promote cf_text to top of research_fallback_order
- Add cf_text backend to llm.cloud.yaml with cf_orch block
- Wire _RL_WIZARD rate limit to wizard_ai_interview endpoint (closes TODO from #122)

Closes: #122
2026-06-14 15:21:53 -07:00
b3435a8bd8 fix: add slowapi to requirements.txt for Docker image
slowapi was only in environment.yml (conda env) but missing from
requirements.txt, causing ModuleNotFoundError in the Docker container.
2026-06-14 14:13:12 -07:00
e85fb9bba3 test: fix rate limiter cross-test contamination
Each importlib.reload(dev_api) re-applies @limiter.limit() decorators to
the shared slowapi Limiter singleton, accumulating stale registrations in
_route_limits. One real HTTP request then triggered N limit-checks (N =
reload count), exhausting per-hour budgets prematurely.

Fix: conftest.py autouse fixture resets both _storage and _route_limits
before each test, giving a clean slate regardless of prior reloads.

Also updates test_dev_api_prep.py client fixture to use monkeypatch to
clear DEMO_MODE + importlib.reload to get a fresh IS_DEMO module state
(prevents 403 bleed from test_demo_guard.py tests running first).

All 842 tests passing.
2026-06-14 14:00:31 -07:00
88b6943527 merge: feat/122-rate-limiting into freeze/rc-1
Per-user LLM rate limiting via slowapi: cloud-aware key function,
4 endpoint limits, demo bypass, SSRF and path traversal already in
fix/ci-ruff-lint merge.

Closes: #122
2026-06-14 12:41:18 -07:00
71e8eeb090 merge: feat/77-ai-wizard into freeze/rc-1
AI profile wizard full implementation: backend interview endpoints, BYOK
tier flag, chat UI, store actions (skip/keepChatting), settings CTA,
quality review fixes.

Closes: #77
2026-06-14 12:16:49 -07:00
6db1fe1546 merge: fix/ci-ruff-lint into freeze/rc-1
CI lint fixes, CVE security mitigations, sync status UI (#120),
bugbot Forgejo token fallback (#118), npm audit, mnemo compose stub.
2026-06-14 12:16:40 -07:00
b13abb1118 feat(settings): sync status UI (#120) + bugbot Forgejo token fallback (#118)
Issue #120 — sync status panel in DataView:
- Add SyncStore (web/src/stores/settings/sync.ts) to track last-sync
  timestamp, in-progress state, and error message for profile/preferences
- Extend DataView with a sync status section: last synced time, refresh
  button, error display, and per-section progress indicators

Issue #118 — bugbot Forgejo token fallback:
- scripts/feedback_api.py: try FORGEJO_BOT_TOKEN first, then fall back to
  FORGEJO_TOKEN so ops can provision a dedicated cf-bugbot account without
  breaking existing single-token installs

Add FORGEJO_BOT_TOKEN and LLM_RATE_* env var documentation to .env.example

Closes: #120
Closes: #118
2026-06-14 12:16:16 -07:00
3cdd14c345 fix(security): CVE mitigations — path traversal, SSRF, dep upgrades, npm audit
Path traversal (cloud middleware):
- Add _VALID_USER_ID_RE UUID regex; reject non-UUID user_id before
  constructing db path from CLOUD_DATA_ROOT / user_id / ...
- Non-UUID values log a warning and fall through to unauthenticated path

SSRF (test_email IMAP endpoint):
- Add _is_ssrf_host() using ipaddress + socket.gethostbyname()
- Checks resolved IP against RFC-1918, loopback, and link-local ranges
- Fails closed on DNS resolution errors (returns True = blocked)

Dependency security pins in environment.yml (transitive CVEs):
- starlette>=1.0.1 (PYSEC-2026-161), python-multipart>=0.0.27 (CVE-2026-40347),
  aiohttp>=3.14.0, tornado>=6.5.5, cryptography>=46.0.7, langsmith>=0.8.0,
  gitpython>=3.1.50, lxml>=6.1.0, idna>=3.15, markdownify>=0.14.1
- Direct dep upgrades: requests>=2.33.0, pypdf>=6.12.0, python-dotenv>=1.2.2,
  PyJWT>=2.13.0, curl_cffi>=0.15.0

npm audit (web/package-lock.json):
- Resolved 7 of 9 CVEs; 2 remaining esbuild CVEs require vite 8 upgrade
  (tracked as issue #123 — breaking change, deferred)
2026-06-14 12:16:00 -07:00
ad27467026 chore(infra): add mnemo service stub to compose.yml
Pre-existing local development addition — mnemo vector memory service
placeholder for future integration work.
2026-06-14 12:15:16 -07:00
d801650db1 feat(api): per-user LLM rate limiting via slowapi
Add scripts/rate_limit.py with cloud-aware key function:
- In cloud mode, extracts user_id from _request_db ContextVar path (part[-3])
  so each cloud user has their own rate limit bucket
- In demo mode, returns unique per-request key to disable limiting entirely
  (_demo_guard handles write-blocking; rate limiting would block the demo UX)
- Falls back to client IP for local/self-hosted installs

Wire limiter to 4 endpoints with conservative per-user limits:
- POST /generate/cover-letter: 20/hour
- POST /research/run: 10/hour
- POST /qa/suggest: 60/hour
- POST /survey/analyze: 30/hour

Add _demo_guard() to generate_research and suggest_qa_answer (was missing).
Fix pre-existing silent except in suggest_qa_answer: was bare except pass,
now logs warning with exc_info.

Add _RL_WIZARD placeholder constant with TODO to wire to wizard/ai/interview
after feat/77 merges (declared but intentionally not applied yet to avoid
false sense of security — comment makes the gap explicit).

18 tests covering cloud user isolation, demo bypass, IP fallback, all 4
endpoints returning 429 on excess, retry_after header, and demo guard.

Closes: #122
2026-06-14 12:14:21 -07:00
eebfc84a80 fix(wizard): quality review fixes — store encapsulation + skip action + settings CTA
- Add keepChatting() action to aiInterview store; replace direct store.complete = false
  mutation in WizardAIView template with store.keepChatting()
- Add skip() action wrapping SKIP_SIGNAL constant; replace magic string store.send('skip')
  with store.skip()
- Fix skip button disabled condition to include || store.complete (was always enabled
  when wizard was complete, allowing spurious skip after finalize)
- Add _persist() call after user bubble append in send() so localStorage draft is
  written before the async fetch — prevents stale draft on browser refresh during
  slow LLM call
- Fix @click="store.startOver" → @click="store.startOver()" (missing parentheses)
- Add 2 tests: skip() sends SKIP_SIGNAL, keepChatting() clears complete without reset
- Remove 'ultra' from Tier type in appConfig.ts (violates no-ultra-tier policy)
- Add MyProfileView wizard callout banner with tier-aware unlock/upgrade CTAs
- Add clarifying comment on wizard route guard in router/index.ts

Closes: #77
2026-06-14 12:13:58 -07:00
cecf85de02 feat(wizard): AI interview store, WizardAIView chat UI, byokUnlocked in appConfig 2026-06-13 20:10:38 -07:00
e9943908c6 fix(wizard): 503 on LLM error, sanitize history content, typed HistoryMessage model 2026-06-13 20:04:14 -07:00
6d1edff1b9 fix(wizard): inject profile_so_far context into AI interview LLM prompt 2026-06-13 19:59:58 -07:00
6327a4cdd9 feat(wizard): backend AI interview endpoints + BYOK tier flag 2026-06-13 19:57:00 -07:00
3048d8e2f4 docs: add LLM development disclosure to README
All checks were successful
CI / Backend (Python) (push) Successful in 1m35s
CI / Frontend (Vue) (push) Successful in 23s
Humans own design, architecture, code review, testing, and
verification. LLMs are part of our development workflow.
Links to circuitforge.tech/positions for our full position.
2026-05-28 08:20:16 -07:00
02d79e6727 fix(ci): install ruff before lint step
All checks were successful
CI / Backend (Python) (push) Successful in 1m33s
CI / Frontend (Vue) (push) Successful in 19s
CI / Backend (Python) (pull_request) Successful in 1m21s
CI / Frontend (Vue) (pull_request) Successful in 19s
ruff is not in requirements.txt (dev-only tool) so the CI runner
couldn't find it. Install explicitly in the workflow.
2026-05-21 12:03:46 -07:00
e4c5744d87 fix(ci): restore TaskSpec re-export in task_scheduler.py
Some checks failed
CI / Backend (Python) (push) Failing after 31s
CI / Frontend (Vue) (push) Successful in 22s
CI / Backend (Python) (pull_request) Failing after 23s
CI / Frontend (Vue) (pull_request) Successful in 20s
ruff --fix removed the TaskSpec import as unused within the module,
but it is part of the public API — tests import it from scripts.task_scheduler
rather than reaching into circuitforge_core directly.
Add # noqa: F401 to protect intentional re-exports from future auto-fix.
2026-05-21 11:51:40 -07:00
46bae7db1c fix(ci): rename GITHUB_MIRROR_TOKEN secret to GH_MIRROR_TOKEN
Some checks failed
CI / Backend (Python) (push) Failing after 26s
CI / Frontend (Vue) (push) Successful in 22s
CI / Backend (Python) (pull_request) Failing after 22s
CI / Frontend (Vue) (pull_request) Successful in 20s
Forgejo reserves the GITHUB_* prefix for secret names — creating a secret
called GITHUB_MIRROR_TOKEN returns 'invalid secret name'.
Also rename the GITHUB_TOKEN step env var to GH_MIRROR_PAT to avoid
collision with the built-in Forgejo Actions context variable.
2026-05-21 11:41:11 -07:00
e87c707dd9 chore(lint): ruff auto-fix unused imports in tests/
Some checks failed
CI / Backend (Python) (push) Failing after 30s
CI / Frontend (Vue) (push) Successful in 22s
CI / Backend (Python) (pull_request) Failing after 27s
CI / Frontend (Vue) (pull_request) Successful in 20s
Removes unused imports flagged by ruff F401 across 47 test files.
Auto-fix only — imports verified unused by static analysis.
2026-05-20 23:07:52 -07:00
7dcdf551fc chore(lint): ruff auto-fix unused imports in scripts/ and scrapers/
Removes unused imports flagged by ruff F401 across 12 scripts.
All removals are safe — ruff only auto-fixes imports that are verifiably unused.
2026-05-20 23:07:26 -07:00
544a6aeeb3 fix(ci): add ruff config, clean lint in dev-api.py + scripts
- Add pyproject.toml with ruff per-file-ignores:
  - Exclude deprecated app/ Streamlit dir entirely
  - Suppress E702 in dev-api.py (intentional compact Pydantic models)
  - Suppress E402 in finetune_local.py (conditional ML imports after CUDA check)
  - Suppress F841/E741/E702 in tests/ (mock-patch capture pattern)
- Remove unused db_path_obj assignment in dev-api.py:760
- Add # noqa: E402 to documented mid-file imports in dev-api.py
- Rename ambiguous l variable to line/lbl in finetune_local.py + label_tool.py
2026-05-20 23:06:49 -07:00
117 changed files with 4129 additions and 628 deletions

View file

@ -3,17 +3,20 @@ __pycache__
*.pyc *.pyc
*.pyo *.pyo
staging.db staging.db
# gitignored secrets — belt-and-suspenders with the RUN rm -f in Dockerfile
config/user.yaml config/user.yaml
config/plain_text_resume.yaml
config/notion.yaml config/notion.yaml
config/email.yaml config/email.yaml
config/tokens.yaml config/tokens.yaml
config/craigslist.yaml config/craigslist.yaml
config/adzuna.yaml
.env
.streamlit.pid .streamlit.pid
.streamlit.log .streamlit.log
aihawk/ aihawk/
docs/ docs/
tests/ tests/
.env
data/ data/
log/ log/
unsloth_compiled_cache/ unsloth_compiled_cache/

View file

@ -2,10 +2,10 @@
# Auto-generated by the setup wizard, or fill in manually. # Auto-generated by the setup wizard, or fill in manually.
# NEVER commit .env to git. # NEVER commit .env to git.
STREAMLIT_PORT=8502 VUE_PORT=8506
OLLAMA_PORT=11434 OLLAMA_PORT=11434
VLLM_PORT=8000 VLLM_PORT=8000
CF_TEXT_PORT=8006 CF_TEXT_PORT=8008
SEARXNG_PORT=8888 SEARXNG_PORT=8888
VISION_PORT=8002 VISION_PORT=8002
VISION_MODEL=vikhyatk/moondream2 VISION_MODEL=vikhyatk/moondream2
@ -35,7 +35,8 @@ OPENAI_COMPAT_URL=
OPENAI_COMPAT_KEY= OPENAI_COMPAT_KEY=
# Feedback button — Forgejo issue filing # Feedback button — Forgejo issue filing
FORGEJO_API_TOKEN= FORGEJO_API_TOKEN= # dev/admin token (your personal account)
FORGEJO_BOT_TOKEN= # cf-bugbot bot token — used for in-app feedback; falls back to FORGEJO_API_TOKEN
FORGEJO_REPO=pyr0ball/peregrine FORGEJO_REPO=pyr0ball/peregrine
FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1 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
@ -64,8 +65,28 @@ CF_ORCH_AGENT_PORT=7701
# 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
SYNC_DB_PATH= # optional; defaults to CLOUD_DATA_ROOT/sync.db
SYNC_DB_KEY= # optional; SQLCipher key for at-rest encryption
DIRECTUS_JWT_SECRET= # must match website/.env DIRECTUS_SECRET value DIRECTUS_JWT_SECRET= # must match website/.env DIRECTUS_SECRET value
CF_SERVER_SECRET= # random 64-char hex — generate: openssl rand -hex 32 CF_SERVER_SECRET= # random 64-char hex — generate: openssl rand -hex 32
PLATFORM_DB_URL=postgresql://cf_platform:<password>@host.docker.internal:5433/circuitforge_platform PLATFORM_DB_URL=postgresql://cf_platform:<password>@host.docker.internal:5433/circuitforge_platform
HEIMDALL_URL=http://cf-license:8000 # internal Docker URL; override for external access HEIMDALL_URL=http://cf-license:8000 # internal Docker URL; override for external access
HEIMDALL_ADMIN_TOKEN= # must match ADMIN_TOKEN in circuitforge-license .env HEIMDALL_ADMIN_TOKEN= # must match ADMIN_TOKEN in circuitforge-license .env
# ── Memory (mnemo sidecar) — opt-in, requires --profile memory ───────────────
# Launch with: docker compose --profile memory --profile <gpu-profile> up -d
# Mnemo builds a persistent knowledge graph from conversations and injects
# relevant context back into LLM prompts. Uses the ollama service as its LLM.
MNEMO_HOST=mnemo # internal service name; change for external sidecar
MNEMO_PORT=8080
MNEMO_LLM_PROVIDER=ollama # ollama | openai | anthropic | custom
MNEMO_LLM_BASE_URL=http://ollama:11434/v1 # override for external LLM
MNEMO_LLM_API_KEY=ollama # "ollama" is a dummy value for local Ollama
MNEMO_LLM_MODEL=llama3.2:3b # must be pulled in the ollama container
# ── Rate limiting (LLM generation endpoints) ─────────────────────────────────
LLM_RATE_COVER_LETTER=20/hour
LLM_RATE_RESEARCH=10/hour
LLM_RATE_QA_SUGGEST=60/hour
LLM_RATE_SURVEY=30/hour
LLM_RATE_WIZARD=60/hour

View file

@ -7,7 +7,7 @@ name: CI
on: on:
push: push:
branches: [main, 'feature/**', 'fix/**'] branches: [main, 'feature/**', 'fix/**', 'freeze/**']
pull_request: pull_request:
branches: [main] branches: [main]
@ -29,6 +29,9 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pip install -r requirements.txt run: pip install -r requirements.txt
- name: Install lint tools
run: pip install ruff
- name: Lint - name: Lint
run: ruff check . run: ruff check .

View file

@ -1,6 +1,7 @@
# Mirror push to GitHub and Codeberg on every push to main or tag. # Mirror push to GitHub and Codeberg on every push to main or tag.
# Copied from Circuit-Forge/cf-agents workflows/mirror.yml # Copied from Circuit-Forge/cf-agents workflows/mirror.yml
# Required secrets: GITHUB_MIRROR_TOKEN, CODEBERG_MIRROR_TOKEN # Required secrets: GH_MIRROR_TOKEN, CODEBERG_MIRROR_TOKEN
# Note: Forgejo reserves the GITHUB_* prefix for secret names — use GH_* instead.
name: Mirror name: Mirror
@ -19,10 +20,10 @@ jobs:
- name: Mirror to GitHub - name: Mirror to GitHub
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_MIRROR_TOKEN }} GH_MIRROR_PAT: ${{ secrets.GH_MIRROR_TOKEN }}
REPO: ${{ github.event.repository.name }} REPO: ${{ github.event.repository.name }}
run: | run: |
git remote add github "https://x-access-token:${GITHUB_TOKEN}@github.com/CircuitForgeLLC/${REPO}.git" git remote add github "https://x-access-token:${GH_MIRROR_PAT}@github.com/CircuitForgeLLC/${REPO}.git"
git push github --mirror git push github --mirror
- name: Mirror to Codeberg - name: Mirror to Codeberg

View file

@ -1,12 +1,20 @@
# Tag-triggered release workflow. # Tag-triggered release workflow.
# Generates changelog and creates Forgejo release on v* tags. # Generates changelog, publishes Docker images to GHCR, and creates Forgejo release.
# Copied from Circuit-Forge/cf-agents workflows/release.yml
# #
# Docker push is intentionally disabled — BSL 1.1 registry policy not yet resolved. # Images published on v* tags:
# Tracked in Circuit-Forge/cf-agents#3. Re-enable the Docker steps when that lands. # ghcr.io/circuitforgellc/peregrine:latest — FastAPI API (includes cf-orch)
# ghcr.io/circuitforgellc/peregrine:<tag>
# ghcr.io/circuitforgellc/peregrine-web:latest — Vue SPA (base path /)
# ghcr.io/circuitforgellc/peregrine-web:<tag>
# #
# Required secrets: FORGEJO_RELEASE_TOKEN # The cloud image (compose.cloud.yml) is never published — it is built and
# (GHCR_TOKEN not needed until Docker push is enabled) # deployed directly on Heimdall from Dockerfile.cfcore with sibling repos.
#
# Required secrets:
# FORGEJO_RELEASE_TOKEN — Forgejo API token for creating releases
# GH_GHCR_TOKEN — GitHub PAT with packages:write for GHCR push
# FORGEJO_CF_ORCH_TOKEN — Forgejo token to install private circuitforge-orch
# during the API image build (BSL client for paid tier)
name: Release name: Release
@ -32,28 +40,56 @@ jobs:
env: env:
OUTPUT: CHANGES.md OUTPUT: CHANGES.md
# ── Docker (disabled — BSL registry policy pending cf-agents#3) ────────── # ── Docker setup ─────────────────────────────────────────────────────────
# - name: Set up QEMU - name: Set up QEMU
# uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
# - name: Set up Buildx
# uses: docker/setup-buildx-action@v3 - name: Set up Buildx
# - name: Log in to GHCR uses: docker/setup-buildx-action@v3
# uses: docker/login-action@v3
# with: - name: Log in to GHCR
# registry: ghcr.io uses: docker/login-action@v3
# username: ${{ github.actor }} with:
# password: ${{ secrets.GHCR_TOKEN }} registry: ghcr.io
# - name: Build and push Docker image username: ${{ github.actor }}
# uses: docker/build-push-action@v6 password: ${{ secrets.GH_GHCR_TOKEN }}
# with:
# context: . # ── API image ─────────────────────────────────────────────────────────────
# push: true # cf-orch (BSL, private) is installed via BuildKit secret — token never
# platforms: linux/amd64,linux/arm64 # appears in any image layer. Community builds without the secret fall back
# tags: | # to local backends automatically.
# ghcr.io/circuitforgellc/peregrine:${{ github.ref_name }} - name: Build and push API image
# ghcr.io/circuitforgellc/peregrine:latest uses: docker/build-push-action@v6
# cache-from: type=gha with:
# cache-to: type=gha,mode=max context: .
dockerfile: Dockerfile
push: true
platforms: linux/amd64,linux/arm64
secrets: |
forgejo_token=${{ secrets.FORGEJO_CF_ORCH_TOKEN }}
tags: |
ghcr.io/circuitforgellc/peregrine:${{ github.ref_name }}
ghcr.io/circuitforgellc/peregrine:latest
cache-from: type=gha,scope=api
cache-to: type=gha,mode=max,scope=api
# ── Web image ─────────────────────────────────────────────────────────────
# Published with VITE_BASE_PATH=/ (self-hosted default).
# Cloud and demo deployments build locally with VITE_BASE_PATH=/peregrine/.
- name: Build and push web image
uses: docker/build-push-action@v6
with:
context: .
dockerfile: docker/web/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
build-args: |
VITE_BASE_PATH=/
tags: |
ghcr.io/circuitforgellc/peregrine-web:${{ github.ref_name }}
ghcr.io/circuitforgellc/peregrine-web:latest
cache-from: type=gha,scope=web
cache-to: type=gha,mode=max,scope=web
# ── Forgejo Release ─────────────────────────────────────────────────────── # ── Forgejo Release ───────────────────────────────────────────────────────
- name: Create Forgejo release - name: Create Forgejo release

1
.gitignore vendored
View file

@ -60,3 +60,4 @@ demo/seed_demo.py
tests/e2e/results/demo/ tests/e2e/results/demo/
tests/e2e/results/cloud/ tests/e2e/results/cloud/
tests/e2e/results/local/ tests/e2e/results/local/
config/wizard-test/

View file

@ -1,30 +1,59 @@
# Dockerfile # Dockerfile — Peregrine release build
# Self-contained single-repo context. Used for published images and community builds.
#
# cf-core: installed from public Forgejo via requirements.txt
# cf-orch: BSL-licensed cloud inference client; installed only when the
# forgejo_token BuildKit secret is present (release CI).
# Community builds skip it gracefully — local Ollama/vllm still work.
#
# Release CI (Forgejo):
# docker buildx build --secret id=forgejo_token,env=FORGEJO_TOKEN -t peregrine:latest .
#
# Community / source build:
# docker buildx build -t peregrine:latest .
#
# Previously this file ran Streamlit (app/app.py). Streamlit was removed in
# peregrine#104. The runtime is now uvicorn (FastAPI). Dockerfile.cfcore remains
# for the cloud deployment on Heimdall, where sibling repos are available.
FROM python:3.11-slim FROM python:3.11-slim
WORKDIR /app 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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libffi-dev curl libsqlcipher-dev git \ gcc libffi-dev curl libsqlcipher-dev git \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Install Playwright browser (cached separately from Python deps so requirements # cf-orch BSL client — cloud inference routing for paid/premium tier.
# changes don't bust the ~600900 MB Chromium layer and vice versa) # The --mount=type=secret keeps the token out of all image layers.
# If no secret is provided the pip install is skipped; the app falls back to
# local backends (Ollama, vllm) and tier gating blocks cloud-orch features.
RUN --mount=type=secret,id=forgejo_token \
TOKEN=$(cat /run/secrets/forgejo_token 2>/dev/null || true) && \
if [ -n "$TOKEN" ]; then \
pip install --no-cache-dir \
"git+https://x-access-token:${TOKEN}@git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-orch.git@main" \
&& echo "cf-orch installed"; \
else \
echo "cf-orch skipped (community build — local backends available)"; \
fi
# Chromium for Playwright-based scrapers (companyScraper, job board scraping)
RUN playwright install chromium && playwright install-deps chromium RUN playwright install chromium && playwright install-deps chromium
# Bundle companyScraper (company research web scraper)
COPY scrapers/ /app/scrapers/ COPY scrapers/ /app/scrapers/
COPY . . COPY . .
EXPOSE 8501 # Strip gitignored secrets that may exist in a local checkout.
# Defense-in-depth: .dockerignore already excludes these, but an explicit rm
# guarantees they never appear in the image even if .dockerignore is misconfigured.
RUN rm -f config/user.yaml config/plain_text_resume.yaml config/notion.yaml \
config/email.yaml config/tokens.yaml config/craigslist.yaml \
config/adzuna.yaml .env
CMD ["streamlit", "run", "app/app.py", \ EXPOSE 8601
"--server.port=8501", \
"--server.headless=true", \ CMD ["uvicorn", "dev_api:app", "--host", "0.0.0.0", "--port", "8601"]
"--server.fileWatcherType=none"]

View file

@ -70,7 +70,7 @@ cd peregrine
./manage.sh start ./manage.sh start
``` ```
Open **http://localhost:8502** — the setup wizard walks you through the rest. Open **http://localhost:8506** — the setup wizard walks you through the rest.
> **macOS / Apple Silicon:** install Ollama natively via Homebrew before starting for Metal GPU-accelerated inference. `install.sh` handles this automatically. > **macOS / Apple Silicon:** install Ollama natively via Homebrew before starting for Metal GPU-accelerated inference. `install.sh` handles this automatically.
> **Windows:** use WSL2 with Ubuntu. > **Windows:** use WSL2 with Ubuntu.
@ -78,10 +78,11 @@ Open **http://localhost:8502** — the setup wizard walks you through the rest.
### Inference profiles ### Inference profiles
```bash ```bash
./manage.sh start # remote — no GPU; LLM calls go to Anthropic / OpenAI ./manage.sh start # cpu — local Ollama on CPU (recommended default)
./manage.sh start --profile cpu # local Ollama on CPU (or Metal via native Ollama on macOS)
./manage.sh start --profile single-gpu # Ollama + vision on GPU 0 (NVIDIA only) ./manage.sh start --profile single-gpu # Ollama + vision on GPU 0 (NVIDIA only)
./manage.sh start --profile dual-gpu # Ollama + vLLM on two NVIDIA GPUs ./manage.sh start --profile dual-gpu # Ollama + vLLM on two NVIDIA GPUs
./manage.sh start --profile cf-orch # no local LLM — route to CircuitForge GPU cluster
./manage.sh start --profile remote # no local LLM — use cloud API keys
``` ```
--- ---
@ -109,7 +110,7 @@ Open **http://localhost:8502** — the setup wizard walks you through the rest.
| **Voice guidelines** (custom writing style and tone) | Premium with LLM ¹ | | **Voice guidelines** (custom writing style and 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 |
| Human-in-the-loop operator (CAPTCHAs, phone calls, wet signatures) | Ultra | | Human-in-the-loop operator (CAPTCHAs, phone calls, wet signatures) | Premium |
¹ **BYOK (bring your own key) unlock:** configure any LLM backend — a local [Ollama](https://ollama.com) or vLLM instance, or your own API key (Anthropic, OpenAI-compatible) — and all "Free with LLM" and "Premium with LLM" features unlock at no charge. ¹ **BYOK (bring your own key) unlock:** configure any LLM backend — a local [Ollama](https://ollama.com) or vLLM instance, or your own API key (Anthropic, OpenAI-compatible) — and all "Free with LLM" and "Premium with LLM" features unlock at no charge.
@ -180,4 +181,8 @@ Peregrine uses a split license:
Fine-tuned model weights are proprietary and per-user — not redistributable. Fine-tuned model weights are proprietary and per-user — not redistributable.
---
Humans own design, architecture, code review, testing, and verification. LLMs are part of our development workflow. [Our positions on LLM use →](https://circuitforge.tech/positions)
© 2026 Circuit Forge LLC © 2026 Circuit Forge LLC

View file

@ -1,6 +1,6 @@
"""Step 1 — Hardware detection and inference profile selection.""" """Step 1 — Hardware detection and inference profile selection."""
PROFILES = ["remote", "cpu", "single-gpu", "dual-gpu"] PROFILES = ["cpu", "single-gpu", "dual-gpu", "cf-orch", "remote"]
def validate(data: dict) -> list[str]: def validate(data: dict) -> list[str]:

View file

@ -41,6 +41,7 @@ FEATURES: dict[str, str] = {
"llm_voice_guidelines": "premium", "llm_voice_guidelines": "premium",
"llm_job_titles": "paid", "llm_job_titles": "paid",
"llm_mission_notes": "paid", "llm_mission_notes": "paid",
"llm_ai_wizard": "paid",
# Orchestration — stays gated (background data pipeline, not just an LLM call) # Orchestration — stays gated (background data pipeline, not just an LLM call)
"llm_keywords_blocklist": "paid", "llm_keywords_blocklist": "paid",
@ -79,6 +80,7 @@ BYOK_UNLOCKABLE: frozenset[str] = frozenset({
"llm_voice_guidelines", "llm_voice_guidelines",
"llm_job_titles", "llm_job_titles",
"llm_mission_notes", "llm_mission_notes",
"llm_ai_wizard",
"company_research", "company_research",
"interview_prep", "interview_prep",
"survey_assistant", "survey_assistant",

View file

@ -16,6 +16,7 @@
services: services:
api: api:
image: ghcr.io/circuitforgellc/peregrine:latest
build: . build: .
command: > command: >
bash -c "uvicorn dev_api:app --host 0.0.0.0 --port 8601" bash -c "uvicorn dev_api:app --host 0.0.0.0 --port 8601"
@ -42,6 +43,8 @@ services:
# No host port — nginx proxies /api/ → api:8601 internally # No host port — nginx proxies /api/ → api:8601 internally
web: web:
# Built with VITE_BASE_PATH=/peregrine/ — not the same as the published
# peregrine-web:latest image (which uses base path /). Always build locally.
build: build:
context: . context: .
dockerfile: docker/web/Dockerfile dockerfile: docker/web/Dockerfile

62
compose.wizard-test.yml Normal file
View file

@ -0,0 +1,62 @@
# compose.wizard-test.yml — Fresh first-run instance for testing wizard/onboarding flows
#
# Spins up on port 8507 with ephemeral storage so every `docker compose restart`
# gives a completely clean slate. Perfect for exercising the onboarding wizard,
# AI interview, and first-run UX without touching the real data.
#
# Usage:
# docker compose -f compose.wizard-test.yml --project-name peregrine-wizard up -d
# docker compose -f compose.wizard-test.yml --project-name peregrine-wizard restart api
# docker compose -f compose.wizard-test.yml --project-name peregrine-wizard down
services:
api:
image: ghcr.io/circuitforgellc/peregrine:latest # same image as main compose
command: >
bash -c "uvicorn dev_api:app --host 0.0.0.0 --port 8601"
volumes:
- ./config/wizard-test:/app/config # LLM config only — no user.yaml triggers wizard
tmpfs:
- /app/data # ephemeral DB; wipes on restart → clean first-run every time
environment:
- STAGING_DB=/app/data/staging.db
- DOCS_DIR=/tmp/wizard-test-docs
- PYTHONUNBUFFERED=1
- CF_ORCH_URL=http://host.docker.internal:7700
- CF_APP_NAME=peregrine
- GPU_SERVER_URL=http://host.docker.internal:7700
- HEIMDALL_URL=http://host.docker.internal:8000 # license check — skip for local testing
extra_hosts:
- "host.docker.internal:host-gateway"
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
depends_on:
searxng:
condition: service_healthy
restart: unless-stopped
# No host port — nginx in web proxies /api/ → api:8601
web:
image: ghcr.io/circuitforgellc/peregrine-web:latest # same image as main compose
ports:
- "8507:80"
depends_on:
- api
restart: unless-stopped
searxng:
image: searxng/searxng:latest
volumes:
- ./docker/searxng:/etc/searxng:ro
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/"]
interval: 10s
timeout: 5s
retries: 3
restart: unless-stopped

View file

@ -3,9 +3,10 @@
services: services:
api: api:
image: ghcr.io/circuitforgellc/peregrine:latest
build: build:
context: .. context: .
dockerfile: peregrine/Dockerfile.cfcore dockerfile: Dockerfile
command: > command: >
bash -c "uvicorn dev_api:app --host 0.0.0.0 --port 8601" bash -c "uvicorn dev_api:app --host 0.0.0.0 --port 8601"
volumes: volumes:
@ -23,12 +24,15 @@ services:
- GPU_SERVER_URL=${GPU_SERVER_URL:-${CF_ORCH_URL:-http://host.docker.internal:7700}} - GPU_SERVER_URL=${GPU_SERVER_URL:-${CF_ORCH_URL:-http://host.docker.internal:7700}}
- CF_ORCH_URL=${CF_ORCH_URL:-${GPU_SERVER_URL:-http://host.docker.internal:7700}} - CF_ORCH_URL=${CF_ORCH_URL:-${GPU_SERVER_URL:-http://host.docker.internal:7700}}
- CF_APP_NAME=peregrine - CF_APP_NAME=peregrine
- MNEMO_HOST=${MNEMO_HOST:-mnemo}
- MNEMO_PORT=${MNEMO_PORT:-8080}
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
restart: unless-stopped restart: unless-stopped
web: web:
image: ghcr.io/circuitforgellc/peregrine-web:latest
build: build:
context: . context: .
dockerfile: docker/web/Dockerfile dockerfile: docker/web/Dockerfile
@ -116,6 +120,28 @@ 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
mnemo:
image: ghcr.io/zaydmulani09/mnemo:latest
ports:
- "${MNEMO_PORT:-8080}:8080"
volumes:
- mnemo-data:/data
environment:
- MNEMO_DB_PATH=/data/mnemo.db
- MNEMO_LLM_PROVIDER=${MNEMO_LLM_PROVIDER:-ollama}
- MNEMO_LLM_BASE_URL=${MNEMO_LLM_BASE_URL:-http://ollama:11434/v1}
- MNEMO_LLM_API_KEY=${MNEMO_LLM_API_KEY:-ollama}
- MNEMO_LLM_MODEL=${MNEMO_LLM_MODEL:-llama3.2:3b}
depends_on:
- ollama
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
interval: 15s
timeout: 5s
retries: 3
profiles: [memory]
restart: unless-stopped
finetune: finetune:
build: build:
context: . context: .
@ -131,3 +157,6 @@ services:
- OLLAMA_MODELS_OLLAMA_PATH=/root/.ollama - OLLAMA_MODELS_OLLAMA_PATH=/root/.ollama
profiles: [finetune] profiles: [finetune]
restart: "no" restart: "no"
volumes:
mnemo-data:

View file

@ -1,4 +1,14 @@
backends: backends:
cf_text:
api_key: any
base_url: http://host.docker.internal:8008/v1
enabled: true
model: cf-text
supports_images: false
type: openai_compat
cf_orch:
service: cf-text
ttl_s: 300
anthropic: anthropic:
api_key_env: ANTHROPIC_API_KEY api_key_env: ANTHROPIC_API_KEY
enabled: false enabled: false
@ -26,6 +36,9 @@ backends:
model: llama3.1:8b # generic — no personal fine-tunes in cloud model: llama3.1:8b # generic — no personal fine-tunes in cloud
supports_images: false supports_images: false
type: openai_compat type: openai_compat
cf_orch:
service: ollama
ttl_s: 300
ollama_research: ollama_research:
api_key: ollama api_key: ollama
base_url: http://host.docker.internal:11434/v1 base_url: http://host.docker.internal:11434/v1
@ -33,6 +46,9 @@ backends:
model: llama3.1:8b model: llama3.1:8b
supports_images: false supports_images: false
type: openai_compat type: openai_compat
cf_orch:
service: ollama
ttl_s: 300
vision_service: vision_service:
base_url: http://host.docker.internal:8002 base_url: http://host.docker.internal:8002
enabled: true enabled: true
@ -63,9 +79,11 @@ backends:
- Qwen2.5-3B-Instruct - Qwen2.5-3B-Instruct
ttl_s: 300 ttl_s: 300
fallback_order: fallback_order:
- cf_text
- vllm - vllm
- ollama - ollama
research_fallback_order: research_fallback_order:
- cf_text
- vllm_research - vllm_research
- ollama_research - ollama_research
vision_fallback_order: vision_fallback_order:

View file

@ -1,11 +1,14 @@
backends: backends:
cf_text: cf_text:
api_key: any api_key: any
base_url: http://host.docker.internal:8006/v1 base_url: http://host.docker.internal:8008/v1
enabled: true enabled: true
model: cf-text model: cf-text
supports_images: false supports_images: false
type: openai_compat type: openai_compat
cf_orch:
service: cf-text
ttl_s: 300
anthropic: anthropic:
api_key_env: ANTHROPIC_API_KEY api_key_env: ANTHROPIC_API_KEY
enabled: false enabled: false
@ -33,13 +36,19 @@ backends:
model: llama3.2:3b model: llama3.2:3b
supports_images: false supports_images: false
type: openai_compat type: openai_compat
cf_orch:
service: ollama
ttl_s: 300
ollama_research: ollama_research:
api_key: ollama api_key: ollama
base_url: http://ollama_research:11434/v1 base_url: http://host.docker.internal:11435/v1
enabled: true enabled: true
model: llama3.1:8b model: llama3.1:8b
supports_images: false supports_images: false
type: openai_compat type: openai_compat
cf_orch:
service: ollama
ttl_s: 300
vision_service: vision_service:
base_url: http://vision:8002 base_url: http://vision:8002
enabled: true enabled: true
@ -64,6 +73,11 @@ 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
fallback_order: fallback_order:
- cf_text - cf_text
- ollama - ollama
@ -72,10 +86,10 @@ fallback_order:
- github_copilot - github_copilot
- anthropic - anthropic
research_fallback_order: research_fallback_order:
- claude_code - cf_text
- vllm_research - vllm_research
- ollama_research - ollama_research
- cf_text - claude_code
- github_copilot - github_copilot
- anthropic - anthropic
vision_fallback_order: vision_fallback_order:

View file

@ -1,17 +1,47 @@
domains: domains:
- B2B SaaS - B2B SaaS
- enterprise software - enterprise software
- cybersecurity
- security - security
- compliance - compliance
- post-sale lifecycle - post-sale lifecycle
- SaaS metrics - SaaS metrics
- web security - web security
- risk management
- Fortune 500
- enterprise accounts
- consulting
- CS advisory
- startup
keywords: keywords:
- churn reduction - churn reduction
- escalation management - escalation management
- cross-functional - cross-functional
- product feedback loop - product feedback loop
- customer advocacy - customer advocacy
- NPS
- net promoter score
- QBR
- quarterly business review
- executive relationships
- EBR
- renewal
- expansion
- upsell
- health score
- time-to-value
- TTV
- onboarding
- playbook
- success plan
- stakeholder management
- executive sponsor
- risk identification
- at-risk accounts
- forecasting
- GRR
- NRR
- ARR
skills: skills:
- Customer Success - Customer Success
- Technical Account Management - Technical Account Management
@ -21,3 +51,19 @@ skills:
- project management - project management
- onboarding - onboarding
- renewal management - renewal management
- executive communication
- CS leadership
- team building
- cross-functional collaboration
- customer segmentation
- success planning
- account management
- risk management
- Salesforce
- Gainsight
- ChurnZero
- Zendesk
- Jira
- Notion
- Slack
- Looker

View file

@ -8,13 +8,13 @@ import imaplib
import json import json
import logging import logging
import os import os
import ipaddress
import re import re
import socket import socket
import sqlite3 import sqlite3
import ssl as ssl_mod import ssl as ssl_mod
import subprocess import subprocess
import sys import sys
import threading
from contextvars import ContextVar from contextvars import ContextVar
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -26,7 +26,7 @@ import yaml
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Query, Request, Response, UploadFile from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
@ -39,15 +39,49 @@ if str(PEREGRINE_ROOT) not in sys.path:
from circuitforge_core.api import make_feedback_router as _make_feedback_router # noqa: E402 from circuitforge_core.api import make_feedback_router as _make_feedback_router # noqa: E402
from circuitforge_core.config.settings import load_env as _load_env # noqa: E402 from circuitforge_core.config.settings import load_env as _load_env # noqa: E402
from scripts.credential_store import get_credential, set_credential, delete_credential # noqa: E402 from circuitforge_core.sync import SyncConfig, make_sync_router # noqa: E402
from scripts.credential_store import get_credential, set_credential # noqa: E402
from scripts.rate_limit import limiter, rate_limit_exceeded_handler # noqa: E402
from slowapi.errors import RateLimitExceeded # noqa: E402
DB_PATH = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db") DB_PATH = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db")
_CLOUD_MODE = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true") _CLOUD_MODE = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true")
_CLOUD_DATA_ROOT = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/menagerie-data")) _CLOUD_DATA_ROOT = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/menagerie-data"))
_DIRECTUS_SECRET = os.environ.get("DIRECTUS_JWT_SECRET", "") _DIRECTUS_SECRET = os.environ.get("DIRECTUS_JWT_SECRET", "")
# Allowlist for cloud user_id values — UUID format only (prevents path traversal)
_VALID_USER_ID_RE = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE)
# RFC-1918 + loopback + link-local blocks blocked from IMAP SSRF
_PRIVATE_NETS = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
]
def _is_ssrf_host(host: str) -> bool:
"""Return True if host resolves to a private/loopback address (SSRF guard)."""
try:
addr = ipaddress.ip_address(socket.gethostbyname(host))
return any(addr in net for net in _PRIVATE_NETS)
except Exception:
return True # fail closed on resolution errors
IS_DEMO: bool = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes") IS_DEMO: bool = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
# ── Rate limiting (LLM generation endpoints) ──────────────────────────────────
_RL_COVER_LETTER = os.environ.get("LLM_RATE_COVER_LETTER", "20/hour")
_RL_RESEARCH = os.environ.get("LLM_RATE_RESEARCH", "10/hour")
_RL_QA_SUGGEST = os.environ.get("LLM_RATE_QA_SUGGEST", "60/hour")
_RL_SURVEY = os.environ.get("LLM_RATE_SURVEY", "30/hour")
_RL_WIZARD = os.environ.get("LLM_RATE_WIZARD", "60/hour")
# Resolve GPU inference server URL. # Resolve GPU inference server URL.
# Priority: GPU_SERVER_URL → CF_ORCH_URL (backward compat) → cloud default when licensed. # Priority: GPU_SERVER_URL → CF_ORCH_URL (backward compat) → cloud default when licensed.
# Result is written back to CF_ORCH_URL so all downstream callers need no changes. # Result is written back to CF_ORCH_URL so all downstream callers need no changes.
@ -82,12 +116,35 @@ def _load_demo_seed(db_path: str, seed_file: str) -> None:
con.close() con.close()
def _load_data_env() -> None:
"""Load API keys written by the wizard into the running process.
The wizard saves keys to <data_dir>/.env (next to staging.db). The main
_load_env() call targets the image-baked /app/.env, which is a different
path. This helper bridges the gap by force-overriding env vars that are
unset or empty (compose injects empty strings for optional vars).
"""
data_env = Path(DB_PATH).parent / ".env"
if not data_env.exists():
return
for line in data_env.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key, value = key.strip(), value.strip()
if value and not os.environ.get(key):
os.environ[key] = value
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Load .env, run migrations, and (in demo mode) seed the demo DB.""" """Load .env, run migrations, and (in demo mode) seed the demo DB."""
# Load .env before any runtime env reads — safe because lifespan doesn't run # Load .env before any runtime env reads — safe because lifespan doesn't run
# when dev_api is imported by tests (only when uvicorn actually starts). # when dev_api is imported by tests (only when uvicorn actually starts).
_load_env(PEREGRINE_ROOT / ".env") _load_env(PEREGRINE_ROOT / ".env")
# Also load wizard-saved keys from the data directory (overrides empty compose vars).
_load_data_env()
from scripts.db_migrate import migrate_db from scripts.db_migrate import migrate_db
migrate_db(Path(DB_PATH)) migrate_db(Path(DB_PATH))
@ -110,6 +167,8 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Peregrine Dev API", lifespan=lifespan) app = FastAPI(title="Peregrine Dev API", lifespan=lifespan)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@ -127,6 +186,43 @@ _feedback_router = _make_feedback_router(
) )
app.include_router(_feedback_router, prefix="/api/feedback") app.include_router(_feedback_router, prefix="/api/feedback")
# ── Cross-device sync (cf-core sync module, Paid+ only) ──────────────────────
class _SyncUser:
"""Minimal user object expected by the cf-core sync router."""
def __init__(self, user_id: str) -> None:
self.user_id = user_id
def _get_sync_session() -> _SyncUser:
"""FastAPI dependency: resolves user_id from the per-request DB ContextVar.
Returns a fixed 'local' user in single-tenant mode so the prefs/delete
endpoints still work for self-hosted users.
"""
db_path = _request_db.get()
if db_path:
try:
user_id = Path(db_path).parts[-3]
except IndexError:
raise HTTPException(status_code=401, detail="Invalid session")
else:
user_id = "local"
return _SyncUser(user_id)
def _require_paid_sync() -> _SyncUser:
"""FastAPI dependency: raises 403 unless the resolved tier is paid or premium."""
tier = _resolve_cloud_tier()
if tier not in ("paid", "premium"):
raise HTTPException(status_code=403, detail="Cross-device sync requires a Paid or Premium subscription.")
return _get_sync_session()
_sync_router = make_sync_router(
product="peregrine",
get_session=_get_sync_session,
require_paid=_require_paid_sync,
config=SyncConfig.from_env("peregrine"),
)
app.include_router(_sync_router, prefix="/sync", tags=["sync"])
_log = logging.getLogger("peregrine.session") _log = logging.getLogger("peregrine.session")
# ── Structured auth logging ─────────────────────────────────────────────────── # ── Structured auth logging ───────────────────────────────────────────────────
@ -221,6 +317,10 @@ async def cloud_session_middleware(request: Request, call_next):
if _CLOUD_MODE and _DIRECTUS_SECRET: if _CLOUD_MODE and _DIRECTUS_SECRET:
cookie_header = request.headers.get("X-CF-Session", "") cookie_header = request.headers.get("X-CF-Session", "")
user_id = _resolve_cf_user_id(cookie_header) user_id = _resolve_cf_user_id(cookie_header)
if user_id:
if not _VALID_USER_ID_RE.match(user_id):
_log.warning("cloud_session_middleware: rejected non-UUID user_id: %s", user_id[:40])
user_id = None
if user_id: if user_id:
first_access = user_id not in _seen_users first_access = user_id not in _seen_users
if first_access: if first_access:
@ -513,7 +613,8 @@ def save_cover_letter(job_id: int, body: CoverLetterBody):
# ── POST /api/jobs/:id/cover_letter/generate ───────────────────────────────── # ── POST /api/jobs/:id/cover_letter/generate ─────────────────────────────────
@app.post("/api/jobs/{job_id}/cover_letter/generate") @app.post("/api/jobs/{job_id}/cover_letter/generate")
def generate_cover_letter(job_id: int): @limiter.limit(_RL_COVER_LETTER)
def generate_cover_letter(job_id: int, request: Request):
_demo_guard() _demo_guard()
try: try:
from scripts.task_runner import submit_task from scripts.task_runner import submit_task
@ -566,7 +667,9 @@ def get_research_brief(job_id: int):
@app.post("/api/jobs/{job_id}/research/generate") @app.post("/api/jobs/{job_id}/research/generate")
def generate_research(job_id: int): @limiter.limit(_RL_RESEARCH)
def generate_research(job_id: int, request: Request):
_demo_guard()
try: try:
from scripts.task_runner import submit_task from scripts.task_runner import submit_task
task_id, is_new = submit_task(db_path=Path(_request_db.get() or DB_PATH), task_type="company_research", job_id=job_id) task_id, is_new = submit_task(db_path=Path(_request_db.get() or DB_PATH), task_type="company_research", job_id=job_id)
@ -738,7 +841,6 @@ def preview_resume_review(job_id: int, body: ResumeReviewBody):
3. render_resume_text() renders to plain text for the preview panel 3. render_resume_text() renders to plain text for the preview panel
Returns: {preview_text, preview_struct} struct preserved for the approve step. Returns: {preview_text, preview_struct} struct preserved for the approve step.
""" """
import json as _json
from scripts.db import get_resume_draft as _get_draft from scripts.db import get_resume_draft as _get_draft
from scripts.resume_optimizer import ( from scripts.resume_optimizer import (
apply_review_decisions, frame_skill_gaps, render_resume_text, apply_review_decisions, frame_skill_gaps, render_resume_text,
@ -759,7 +861,6 @@ def preview_resume_review(job_id: int, body: ResumeReviewBody):
# Step 2: inject gap framing for rejected skills (adjacent / learning) # Step 2: inject gap framing for rejected skills (adjacent / learning)
framings = [f.model_dump() for f in body.gap_framings if f.mode in ("adjacent", "learning")] framings = [f.model_dump() for f in body.gap_framings if f.mode in ("adjacent", "learning")]
if framings: if framings:
db_path_obj = Path(_request_db.get() or DB_PATH)
job_row = _get_db().execute( job_row = _get_db().execute(
"SELECT title, company FROM jobs WHERE id=?", (job_id,) "SELECT title, company FROM jobs WHERE id=?", (job_id,)
).fetchone() ).fetchone()
@ -829,7 +930,6 @@ def approve_resume(job_id: int, body: dict):
saved_resume_id: int | None = None saved_resume_id: int | None = None
if body.get("save_to_library"): if body.get("save_to_library"):
from scripts.db import create_resume as _create_r from scripts.db import create_resume as _create_r
import json as _json2
resume_name = (body.get("resume_name") or "").strip() or f"Optimized for job {job_id}" resume_name = (body.get("resume_name") or "").strip() or f"Optimized for job {job_id}"
saved = _create_r( saved = _create_r(
db_path, db_path,
@ -926,7 +1026,7 @@ def create_resume_endpoint(body: dict):
@app.post("/api/resumes/import") @app.post("/api/resumes/import")
async def import_resume_endpoint(file: UploadFile, name: str = ""): async def import_resume_endpoint(file: UploadFile, name: str = ""):
import os, tempfile, json as _json import json as _json
from scripts.db import create_resume as _create from scripts.db import create_resume as _create
db_path = Path(_request_db.get() or DB_PATH) db_path = Path(_request_db.get() or DB_PATH)
content = await file.read() content = await file.read()
@ -1089,9 +1189,17 @@ def apply_resume_to_profile(resume_id: int):
with open(resume_path, "w", encoding="utf-8") as f: with open(resume_path, "w", encoding="utf-8") as f:
yaml.dump(current_profile, f, allow_unicode=True, default_flow_style=False) yaml.dump(current_profile, f, allow_unicode=True, default_flow_style=False)
from scripts.db import update_resume_synced_at as _mark_synced from scripts.db import update_resume_synced_at as _mark_synced, set_default_resume as _set_default
_mark_synced(db_path, resume_id) _mark_synced(db_path, resume_id)
# Establish this entry as the default so future Profile saves sync back to it
_set_default(db_path, resume_id)
_user_yaml = db_path.parent / "config" / "user.yaml"
if _user_yaml.exists():
_prof = yaml.safe_load(_user_yaml.read_text(encoding="utf-8")) or {}
_prof["default_resume_id"] = resume_id
_user_yaml.write_text(yaml.dump(_prof, default_flow_style=False, allow_unicode=True))
return { return {
"ok": True, "ok": True,
"backup_id": backup["id"], "backup_id": backup["id"],
@ -1128,6 +1236,35 @@ def set_job_resume_endpoint(job_id: int, body: dict):
# context. Avocet then routes these prompts through different local models to # context. Avocet then routes these prompts through different local models to
# compare generation quality against the real Peregrine pipeline. # compare generation quality against the real Peregrine pipeline.
_SYNTHETIC_JOB = {
"id": 0,
"title": "Senior Software Engineer",
"company": "Acme Corp",
"description": (
"We are looking for a Senior Software Engineer to join our platform team. "
"You will design and build scalable backend services in Python and Go, "
"contribute to our event-driven architecture using Kafka and Redis, and "
"mentor junior engineers. We value clear communication, strong code review "
"practices, and an ownership mindset.\n\n"
"Requirements:\n"
"- 5+ years of backend engineering experience\n"
"- Proficiency in Python or Go; experience with both is a plus\n"
"- Solid understanding of distributed systems and API design (REST/gRPC)\n"
"- Experience with containerization (Docker/Kubernetes)\n"
"- Comfort working in a remote-first, async team environment\n\n"
"Nice to have:\n"
"- Experience with Kafka or other message-queue systems\n"
"- Open-source contributions\n"
"- Familiarity with observability tooling (Prometheus, Grafana)\n"
),
"status": "applied",
"cover_letter": "",
"raw_output": "",
"company_brief": "",
"ats_gap_report": "",
"talking_points": "",
}
def _imitate_load_profile(): def _imitate_load_profile():
"""Load UserProfile from config/user.yaml, or None if missing.""" """Load UserProfile from config/user.yaml, or None if missing."""
try: try:
@ -1157,6 +1294,9 @@ def _imitate_cover_letter(db, profile, limit: int) -> dict:
except Exception: except Exception:
corpus = [] corpus = []
if not rows:
rows = [_SYNTHETIC_JOB]
samples = [] samples = []
for r in rows: for r in rows:
desc = r["description"] or "" desc = r["description"] or ""
@ -1213,6 +1353,9 @@ def _imitate_company_research(db, profile, limit: int) -> dict:
except Exception: except Exception:
pass pass
if not rows:
rows = [_SYNTHETIC_JOB]
samples = [] samples = []
for r in rows: for r in rows:
jd = (r["description"] or "")[:1500].strip() jd = (r["description"] or "")[:1500].strip()
@ -1270,6 +1413,10 @@ def _imitate_interview_prep(db, profile, limit: int) -> dict:
).fetchall() ).fetchall()
name = profile.name if profile else "the candidate" name = profile.name if profile else "the candidate"
if not rows:
rows = [_SYNTHETIC_JOB]
samples = [] samples = []
for r in rows: for r in rows:
system_prompt = ( system_prompt = (
@ -1324,6 +1471,9 @@ def _imitate_ats_resume(db, profile, limit: int) -> dict:
pass pass
resume_block = f"\n## Current Resume\n{resume_text}" if resume_text else "" resume_block = f"\n## Current Resume\n{resume_text}" if resume_text else ""
if not rows:
rows = [_SYNTHETIC_JOB]
samples = [] samples = []
for r in rows: for r in rows:
desc = (r["description"] or "")[:1500].strip() desc = (r["description"] or "")[:1500].strip()
@ -1462,14 +1612,8 @@ def calendar_push(job_id: int):
# ── Survey endpoints ───────────────────────────────────────────────────────── # ── Survey endpoints ─────────────────────────────────────────────────────────
# Module-level imports so tests can patch dev_api.LLMRouter etc. # Module-level imports so tests can patch dev_api.LLMRouter etc.
from scripts.llm_router import LLMRouter from scripts.db import insert_survey_response, get_survey_responses # noqa: E402
from scripts.db import insert_survey_response, get_survey_responses
from scripts.survey_assistant import (
SURVEY_SYSTEM as _SURVEY_SYSTEM,
build_text_prompt as _build_text_prompt,
build_image_prompt as _build_image_prompt,
)
@app.get("/api/vision/health") @app.get("/api/vision/health")
@ -1488,7 +1632,8 @@ class SurveyAnalyzeBody(BaseModel):
@app.post("/api/jobs/{job_id}/survey/analyze") @app.post("/api/jobs/{job_id}/survey/analyze")
def survey_analyze(job_id: int, body: SurveyAnalyzeBody): @limiter.limit(_RL_SURVEY)
def survey_analyze(job_id: int, body: SurveyAnalyzeBody, request: Request):
if body.mode not in ("quick", "detailed"): if body.mode not in ("quick", "detailed"):
raise HTTPException(400, f"Invalid mode: {body.mode!r}") raise HTTPException(400, f"Invalid mode: {body.mode!r}")
import json as _json import json as _json
@ -1703,8 +1848,10 @@ def save_qa(job_id: int, payload: QAPayload):
@app.post("/api/jobs/{job_id}/qa/suggest") @app.post("/api/jobs/{job_id}/qa/suggest")
def suggest_qa_answer(job_id: int, payload: QASuggestPayload): @limiter.limit(_RL_QA_SUGGEST)
def suggest_qa_answer(job_id: int, payload: QASuggestPayload, request: Request):
"""Synchronously generate an LLM answer for an application Q&A question.""" """Synchronously generate an LLM answer for an application Q&A question."""
_demo_guard()
db = _get_db() db = _get_db()
job_row = db.execute( job_row = db.execute(
"SELECT title, company, description FROM jobs WHERE id = ?", (job_id,) "SELECT title, company, description FROM jobs WHERE id = ?", (job_id,)
@ -1735,7 +1882,7 @@ def suggest_qa_answer(job_id: int, payload: QASuggestPayload):
parts.append(f"Summary: {resume_data['career_summary'][:400]}") parts.append(f"Summary: {resume_data['career_summary'][:400]}")
resume_context = "\n".join(parts) resume_context = "\n".join(parts)
except Exception: except Exception:
pass _log.warning("suggest_qa_answer: failed to load resume context", exc_info=True)
prompt = ( prompt = (
f"You are helping a job applicant answer an application question.\n\n" f"You are helping a job applicant answer an application question.\n\n"
@ -2662,6 +2809,9 @@ def get_app_config():
except Exception: except Exception:
wizard_complete = False wizard_complete = False
from app.wizard.tiers import has_configured_llm
byok_unlocked = has_configured_llm()
return { return {
"isCloud": os.environ.get("CLOUD_MODE", "").lower() in ("1", "true"), "isCloud": os.environ.get("CLOUD_MODE", "").lower() in ("1", "true"),
"isDemo": os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes"), "isDemo": os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes"),
@ -2670,6 +2820,7 @@ def get_app_config():
"contractedClient": os.environ.get("CONTRACTED_CLIENT", "").lower() in ("1", "true"), "contractedClient": os.environ.get("CONTRACTED_CLIENT", "").lower() in ("1", "true"),
"inferenceProfile": profile if profile in valid_profiles else "cpu", "inferenceProfile": profile if profile in valid_profiles else "cpu",
"wizardComplete": wizard_complete, "wizardComplete": wizard_complete,
"byokUnlocked": byok_unlocked,
} }
@ -2690,7 +2841,7 @@ def config_user():
# ── Settings: My Profile endpoints ─────────────────────────────────────────── # ── Settings: My Profile endpoints ───────────────────────────────────────────
from scripts.user_profile import load_user_profile, save_user_profile from scripts.user_profile import load_user_profile, save_user_profile # noqa: E402
def _user_yaml_path() -> str: def _user_yaml_path() -> str:
@ -3130,7 +3281,23 @@ async def upload_resume(file: UploadFile):
resume_path.parent.mkdir(parents=True, exist_ok=True) resume_path.parent.mkdir(parents=True, exist_ok=True)
with open(resume_path, "w") as f: with open(resume_path, "w") as f:
yaml.dump(result, f, allow_unicode=True, default_flow_style=False) yaml.dump(result, f, allow_unicode=True, default_flow_style=False)
# Also add to resume library and mark as default
import json as _json
from scripts.db import create_resume as _create_r, set_default_resume as _set_default
db_path = Path(_request_db.get() or DB_PATH)
resume_name = Path(file.filename).stem or "Uploaded Resume"
library_entry = _create_r(
db_path,
name=resume_name,
text=raw_text,
source="upload",
struct_json=_json.dumps(result),
)
_set_default(db_path, library_entry["id"])
result["exists"] = True result["exists"] = True
result["library_id"] = library_entry["id"]
return {"ok": True, "data": result} return {"ok": True, "data": result}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@ -3193,6 +3360,10 @@ def get_search_prefs():
for b in boards for b in boards
] ]
# Normalize title key — wizard saved "titles", settings canonical is "job_titles"
if "titles" in profile and "job_titles" not in profile:
profile["job_titles"] = profile.pop("titles")
return profile return profile
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@ -3564,6 +3735,8 @@ def test_email(payload: dict):
username = payload.get("username", "") username = payload.get("username", "")
if not all([host, username, password]): if not all([host, username, password]):
return {"ok": False, "error": "Missing host, username, or password"} return {"ok": False, "error": "Missing host, username, or password"}
if _is_ssrf_host(host):
return {"ok": False, "error": "IMAP host must be a public address"}
if use_ssl: if use_ssl:
ctx = ssl_mod.create_default_context() ctx = ssl_mod.create_default_context()
conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx) conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx)
@ -3695,6 +3868,26 @@ def save_deploy_config(payload: dict):
return {"ok": True, "note": "Restart required to apply changes"} return {"ok": True, "note": "Restart required to apply changes"}
class OrchUrlPayload(BaseModel):
orch_url: str = ""
@app.get("/api/settings/system/orch-url")
def get_orch_url():
"""Return the saved Orchard coordinator URL."""
cfg = _load_wizard_yaml()
return {"orch_url": cfg.get("cf_orch_url", "")}
@app.post("/api/settings/system/orch-url")
def save_orch_url(payload: OrchUrlPayload):
"""Persist the Orchard coordinator URL to user.yaml."""
cfg = _load_wizard_yaml()
cfg["cf_orch_url"] = payload.orch_url.strip()
_save_wizard_yaml(cfg)
return {"ok": True}
# ── Settings: Fine-Tune ─────────────────────────────────────────────────────── # ── Settings: Fine-Tune ───────────────────────────────────────────────────────
_TRAINING_JSONL = Path("/Library/Documents/JobSearch/training_data/cover_letters.jsonl") _TRAINING_JSONL = Path("/Library/Documents/JobSearch/training_data/cover_letters.jsonl")
@ -4158,7 +4351,7 @@ def export_classifier():
# State is persisted to user.yaml on every step so the wizard can resume # State is persisted to user.yaml on every step so the wizard can resume
# after a browser refresh or crash (mirrors the Streamlit wizard behaviour). # after a browser refresh or crash (mirrors the Streamlit wizard behaviour).
_WIZARD_PROFILES = ("remote", "cpu", "single-gpu", "dual-gpu", "cf-orch") _WIZARD_PROFILES = ("cpu", "single-gpu", "dual-gpu", "cf-orch", "remote")
_WIZARD_TIERS = ("free", "paid", "premium") _WIZARD_TIERS = ("free", "paid", "premium")
@ -4204,7 +4397,7 @@ def _suggest_profile(gpus: list[str]) -> str:
return "dual-gpu" return "dual-gpu"
if len(gpus) == 1: if len(gpus) == 1:
return "single-gpu" return "single-gpu"
return "remote" return "cpu"
@app.get("/api/wizard/status") @app.get("/api/wizard/status")
@ -4228,6 +4421,7 @@ def wizard_status():
"linkedin": cfg.get("linkedin", ""), "linkedin": cfg.get("linkedin", ""),
"career_summary": cfg.get("career_summary", ""), "career_summary": cfg.get("career_summary", ""),
"services": cfg.get("services", {}), "services": cfg.get("services", {}),
"cf_orch_url": cfg.get("cf_orch_url", ""),
}, },
} }
@ -4249,8 +4443,8 @@ def wizard_save_step(payload: WizardStepPayload):
step = payload.step step = payload.step
data = payload.data data = payload.data
if step < 1 or step > 7: if step < 1 or step > 8:
raise HTTPException(status_code=400, detail="step must be 17") raise HTTPException(status_code=400, detail="step must be 18")
updates: dict = {"wizard_step": step} updates: dict = {"wizard_step": step}
@ -4276,13 +4470,16 @@ def wizard_save_step(payload: WizardStepPayload):
with open(resume_path, "w") as f: with open(resume_path, "w") as f:
yaml.dump(resume, f, allow_unicode=True, default_flow_style=False) yaml.dump(resume, f, allow_unicode=True, default_flow_style=False)
elif step == 4: elif step in (4, 5):
# Step 4 (legacy) or step 5 (current) — identity fields.
# Step 4 was the original numbering before the training step was inserted
# between resume and identity; both are accepted for backward compat.
for field in ("name", "email", "phone", "linkedin", "career_summary"): for field in ("name", "email", "phone", "linkedin", "career_summary"):
if field in data: if field in data:
updates[field] = data[field] updates[field] = data[field]
elif step == 5: elif step == 6:
# Write API keys to .env (never store in user.yaml) # Step 6 — inference: API keys + optional Orchard coordinator URL.
env_path = Path(_wizard_yaml_path()).parent.parent / ".env" env_path = Path(_wizard_yaml_path()).parent.parent / ".env"
env_lines = env_path.read_text().splitlines() if env_path.exists() else [] env_lines = env_path.read_text().splitlines() if env_path.exists() else []
@ -4300,18 +4497,24 @@ def wizard_save_step(payload: WizardStepPayload):
env_lines = _set_env_key(env_lines, "OPENAI_COMPAT_URL", data["openai_url"]) env_lines = _set_env_key(env_lines, "OPENAI_COMPAT_URL", data["openai_url"])
if data.get("openai_key"): if data.get("openai_key"):
env_lines = _set_env_key(env_lines, "OPENAI_COMPAT_KEY", data["openai_key"]) env_lines = _set_env_key(env_lines, "OPENAI_COMPAT_KEY", data["openai_key"])
if any(data.get(k) for k in ("anthropic_key", "openai_url", "openai_key")): if data.get("orch_url"):
env_lines = _set_env_key(env_lines, "GPU_SERVER_URL", data["orch_url"])
updates["cf_orch_url"] = data["orch_url"]
if any(data.get(k) for k in ("anthropic_key", "openai_url", "openai_key", "orch_url")):
env_path.parent.mkdir(parents=True, exist_ok=True) env_path.parent.mkdir(parents=True, exist_ok=True)
env_path.write_text("\n".join(env_lines) + "\n") env_path.write_text("\n".join(env_lines) + "\n")
if "services" in data: if "services" in data:
updates["services"] = data["services"] updates["services"] = data["services"]
elif step == 6: elif step == 7:
# Persist search preferences to search_profiles.yaml in canonical format: # Step 7 — search preferences.
# profiles: [{name, titles, locations, boards, ...}] # Wizard sends { search: { titles, locations, remote_only } }; fall back to
titles = data.get("titles", []) # top-level keys for direct API callers that omit the "search" wrapper.
locations = data.get("locations", []) search = data.get("search", {})
titles = search.get("titles", data.get("titles", data.get("job_titles", [])))
locations = search.get("locations", data.get("locations", []))
remote_only = search.get("remote_only", data.get("remote_only", False))
search_path = _search_prefs_path() search_path = _search_prefs_path()
existing_search: dict = {} existing_search: dict = {}
if search_path.exists(): if search_path.exists():
@ -4328,14 +4531,15 @@ def wizard_save_step(payload: WizardStepPayload):
if default_profile is None: if default_profile is None:
default_profile = {"name": "default"} default_profile = {"name": "default"}
profiles_list.append(default_profile) profiles_list.append(default_profile)
default_profile["titles"] = titles default_profile["job_titles"] = titles
default_profile["locations"] = locations default_profile["locations"] = locations
default_profile["remote_only"] = remote_only
existing_search["profiles"] = profiles_list existing_search["profiles"] = profiles_list
search_path.parent.mkdir(parents=True, exist_ok=True) search_path.parent.mkdir(parents=True, exist_ok=True)
with open(search_path, "w") as f: with open(search_path, "w") as f:
yaml.dump(existing_search, f, allow_unicode=True, default_flow_style=False) yaml.dump(existing_search, f, allow_unicode=True, default_flow_style=False)
# Step 7 (integrations) has no extra side effects here — connections are # Step 8 (integrations) has no extra side effects here — connections are
# handled by the existing /api/settings/system/integrations/{id}/connect. # handled by the existing /api/settings/system/integrations/{id}/connect.
try: try:
@ -4352,7 +4556,8 @@ def _fetch_cforch_nodes() -> list[dict]:
if not url: if not url:
return [] return []
try: try:
import urllib.request, json as _json import urllib.request
import json as _json
req = urllib.request.Request(f"{url}/api/nodes", headers={"Accept": "application/json"}) req = urllib.request.Request(f"{url}/api/nodes", headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=3) as resp: with urllib.request.urlopen(req, timeout=3) as resp:
data = _json.loads(resp.read()) data = _json.loads(resp.read())
@ -4361,6 +4566,39 @@ def _fetch_cforch_nodes() -> list[dict]:
return [] return []
def _probe_ollama() -> bool:
"""Return True if Ollama is reachable from inside the container."""
candidates = [
"http://host.docker.internal:11434/api/tags",
"http://ollama:11434/api/tags",
]
for url in candidates:
try:
r = requests.get(url, timeout=2)
if r.status_code == 200:
return True
except Exception:
pass
return False
def _probe_searxng() -> bool:
"""Return True if SearXNG is reachable from inside the container."""
candidates = [
"http://searxng:8080/",
"http://host.docker.internal:8888/",
"http://host.docker.internal:8080/",
]
for url in candidates:
try:
r = requests.get(url, timeout=2)
if r.status_code < 500:
return True
except Exception:
pass
return False
@app.get("/api/wizard/hardware") @app.get("/api/wizard/hardware")
def wizard_hardware(): def wizard_hardware():
"""Detect local GPUs, suggest an inference profile, and report cf-orch nodes.""" """Detect local GPUs, suggest an inference profile, and report cf-orch nodes."""
@ -4379,35 +4617,71 @@ def wizard_hardware():
"vram_free_mb": gpu["vram_free_mb"], "vram_free_mb": gpu["vram_free_mb"],
}) })
ollama_running = _probe_ollama()
searxng_running = _probe_searxng()
# If no GPU but Ollama is already running, default to cpu rather than remote
if suggested == "cpu" and not gpus and not ollama_running:
suggested = "remote"
return { return {
"gpus": gpus, "gpus": gpus,
"suggested_profile": suggested, "suggested_profile": suggested,
"profiles": list(_WIZARD_PROFILES), "profiles": list(_WIZARD_PROFILES),
"cf_orch_available": len(orch_nodes) > 0, "cf_orch_available": len(orch_nodes) > 0,
"cf_orch_gpus": orch_summary, "cf_orch_gpus": orch_summary,
"ollama_running": ollama_running,
"searxng_running": searxng_running,
} }
def _container_safe_url(url: str) -> str:
"""Replace localhost/127.0.0.1 with host.docker.internal so tests reach the host."""
import re as _re
return _re.sub(r"(https?://)(?:localhost|127\.0\.0\.1)\b", r"\1host.docker.internal", url)
class WizardInferenceTestPayload(BaseModel): class WizardInferenceTestPayload(BaseModel):
profile: str = "remote" profile: str = "remote"
anthropic_key: str = "" anthropic_key: str = ""
openai_url: str = "" openai_url: str = ""
openai_key: str = "" openai_key: str = ""
orch_url: str = ""
ollama_host: str = "localhost" ollama_host: str = "localhost"
ollama_port: int = 11434 ollama_port: int = 11434
@app.post("/api/wizard/inference/test") @app.post("/api/wizard/inference/test")
def wizard_test_inference(payload: WizardInferenceTestPayload): def wizard_test_inference(payload: WizardInferenceTestPayload):
"""Test LLM or Ollama connectivity. """Test LLM, Ollama, or Orchard coordinator connectivity.
Always returns {ok, message} a connection failure is reported as a Always returns {ok, message} a connection failure is a soft warning so
soft warning (message), not an HTTP error, so the wizard can let the the wizard lets the user continue past a temporarily-unreachable service.
user continue past a temporarily-down Ollama instance.
""" """
if payload.profile == "remote": if payload.profile == "cf-orch":
orch_url = _container_safe_url(payload.orch_url.rstrip("/")) if payload.orch_url else ""
if not orch_url:
return {"ok": False, "message": "Enter the Orchard coordinator URL first."}
try:
resp = requests.get(f"{orch_url}/api/nodes", timeout=5,
headers={"Accept": "application/json"})
if resp.status_code == 200:
nodes = resp.json().get("nodes", [])
n = len(nodes)
return {"ok": True, "message": f"Orchard reachable — {n} node(s) online."}
return {"ok": False, "message": f"Orchard returned HTTP {resp.status_code}."}
except Exception as exc:
return {
"ok": False,
"message": (
f"Cannot reach Orchard at {payload.orch_url}"
"check the URL and that the coordinator is running. "
f"({exc})"
),
}
elif payload.profile == "remote":
try: try:
# Temporarily inject key if provided (don't persist yet)
env_override = {} env_override = {}
if payload.anthropic_key: if payload.anthropic_key:
env_override["ANTHROPIC_API_KEY"] = payload.anthropic_key env_override["ANTHROPIC_API_KEY"] = payload.anthropic_key
@ -4431,15 +4705,16 @@ def wizard_test_inference(payload: WizardInferenceTestPayload):
os.environ[k] = v os.environ[k] = v
except Exception as exc: except Exception as exc:
return {"ok": False, "message": f"LLM test failed: {exc}"} return {"ok": False, "message": f"LLM test failed: {exc}"}
else: else:
# Local profile — ping Ollama # Local profiles (cpu, single-gpu, dual-gpu) — ping Ollama
ollama_url = f"http://{payload.ollama_host}:{payload.ollama_port}" host = payload.ollama_host or "localhost"
ollama_url = _container_safe_url(f"http://{host}:{payload.ollama_port}")
try: try:
resp = requests.get(f"{ollama_url}/api/tags", timeout=5) resp = requests.get(f"{ollama_url}/api/tags", timeout=5)
ok = resp.status_code == 200 ok = resp.status_code == 200
message = "Ollama is running." if ok else f"Ollama returned HTTP {resp.status_code}." message = "Ollama is running." if ok else f"Ollama returned HTTP {resp.status_code}."
except Exception: except Exception:
# Soft-fail: user can skip and configure later
return { return {
"ok": False, "ok": False,
"message": ( "message": (
@ -4478,6 +4753,125 @@ def wizard_complete():
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ── AI Interview Wizard (BSL 1.1) ─────────────────────────────────────────────
_AI_WIZARD_SYSTEM_PROMPT = """You are a friendly, patient assistant helping someone set up their job search profile. Your goal is to gather the following information through natural conversation:
- name (string): their full name
- email (string): their preferred contact email
- career_summary (string): 1-2 sentence background summary
- candidate_voice (string): their preferred writing voice/tone for cover letters
- mission_preferences (list of strings): industries or causes they care about
- candidate_accessibility_focus (bool): whether to include accessibility culture in company research
- candidate_lgbtq_focus (bool): whether to include LGBTQIA+ inclusion signals in company research
- linkedin (string, optional): their LinkedIn URL
Rules:
1. Ask one or two questions at a time never overwhelm
2. Always remind them they can skip any question
3. For candidate_voice, offer these options if they struggle: "professional and direct", "warm and conversational", "concise and clear", "enthusiastic and personable"
4. For candidate_accessibility_focus and candidate_lgbtq_focus, use plain language: "Would you like me to look into whether companies actively support employees with disabilities or neurodivergent needs?" and "Would you like me to check whether companies have strong LGBTQIA+ inclusion policies?"
5. When you have gathered enough information or the user says they are done, set complete to true
You must ALWAYS respond with valid JSON in this exact format:
{"reply": "your conversational message here", "extracted_fields": {"name": "...", ...}, "complete": false}
Only include fields in extracted_fields that you are confident about from the conversation. Do not include fields the user hasn't mentioned. Infer complete=true when all required fields (name, email, career_summary) are gathered or when user explicitly says done."""
class HistoryMessage(BaseModel):
role: str # "user" or "assistant"
content: str
class WizardInterviewRequest(BaseModel):
history: list[HistoryMessage] = []
profile_so_far: dict = {}
class WizardFinalizeRequest(BaseModel):
profile: dict
_WIZARD_ALLOWED_FIELDS: frozenset[str] = frozenset({
"name",
"email",
"career_summary",
"candidate_voice",
"mission_preferences",
"candidate_accessibility_focus",
"candidate_lgbtq_focus",
"linkedin",
})
@app.post("/api/wizard/ai/interview")
@limiter.limit(_RL_WIZARD)
def wizard_ai_interview(request: Request, body: WizardInterviewRequest):
"""Conduct one turn of the AI-guided profile interview. Tier-gated (BYOK-unlockable)."""
from app.wizard.tiers import can_use, has_configured_llm
tier = _get_effective_tier()
if not can_use(tier, "llm_ai_wizard", has_byok=has_configured_llm()):
raise HTTPException(402, detail={"error": "tier_required"})
# Build conversation prompt from history
conversation_lines = []
for msg in body.history:
role = msg.role
content = msg.content.replace("\n", " ").replace("\r", "")
if role == "user":
conversation_lines.append(f"User: {content}")
else:
conversation_lines.append(f"Assistant: {content}")
history_block = "\n".join(conversation_lines) if conversation_lines else "User: (starting conversation)"
# Build profile summary to give LLM context about what's already known
if body.profile_so_far:
gathered = ", ".join(
f"{k}={repr(v)}"
for k, v in body.profile_so_far.items()
if v not in (None, "", [], {})
)
profile_context = f"\n\n[Already gathered: {gathered}]" if gathered else ""
else:
profile_context = ""
prompt = history_block + profile_context
try:
from scripts.llm_router import LLMRouter
response_text = LLMRouter().complete(prompt, system=_AI_WIZARD_SYSTEM_PROMPT)
except Exception as exc:
raise HTTPException(503, detail={"error": "llm_error", "message": str(exc)})
try:
parsed = json.loads(response_text)
return {
"reply": parsed.get("reply", ""),
"extracted_fields": parsed.get("extracted_fields", {}),
"complete": bool(parsed.get("complete", False)),
}
except (json.JSONDecodeError, AttributeError):
return {"reply": response_text, "extracted_fields": {}, "complete": False}
@app.post("/api/wizard/ai/finalize")
def wizard_ai_finalize(request: WizardFinalizeRequest):
"""Merge AI-collected wizard fields into user.yaml. Only allowed fields are written."""
yaml_path = _user_yaml_path()
try:
current = load_user_profile(yaml_path)
updates = {k: v for k, v in request.profile.items() if k in _WIZARD_ALLOWED_FIELDS}
merged = {**current, **updates}
save_user_profile(yaml_path, merged)
except Exception as exc:
raise HTTPException(500, detail={"error": "write_error", "message": str(exc)})
merged_keys = list(updates.keys())
return {"saved": True, "fields": merged_keys}
# ── Messaging models ────────────────────────────────────────────────────────── # ── Messaging models ──────────────────────────────────────────────────────────
class MessageCreateBody(BaseModel): class MessageCreateBody(BaseModel):

View file

@ -1,69 +1,129 @@
# Docker Profiles # Docker Profiles
Peregrine uses Docker Compose profiles to start only the services your hardware can support. Choose a profile with `make start PROFILE=<name>`. Peregrine uses Docker Compose profiles to start only the services your hardware supports. Choose a profile with `./manage.sh start --profile <name>`.
`manage.sh` delegates to `make`, which auto-detects Docker vs Podman and applies the correct GPU overlay — `compose.gpu.yml` for Docker, `compose.podman-gpu.yml` for Podman (CDI-based). You do not need to specify the overlay manually.
--- ---
## Profile Reference ## Profile Reference
| Profile | Services started | Use case | | Profile | Services started | Use case |
|---------|----------------|----------| |---------|-----------------|----------|
| `remote` | `app`, `searxng` | No GPU. LLM calls go to an external API (Anthropic, OpenAI-compatible). | | `cpu` | `web`, `api`, `ollama`, `searxng` | No GPU. Local models on CPU. Recommended default for new installs. |
| `cpu` | `app`, `ollama`, `searxng` | No GPU. Runs local models on CPU — functional but slow. | | `single-gpu` | `web`, `api`, `ollama`, `vision`, `searxng` | One NVIDIA GPU. Covers cover letters, research, and vision. |
| `single-gpu` | `app`, `ollama`, `vision`, `searxng` | One NVIDIA GPU. Covers cover letters, research, and vision (survey screenshots). | | `dual-gpu` | `web`, `api`, `ollama`, `vllm`, `vision`, `searxng` | Two NVIDIA GPUs. GPU split controlled by `DUAL_GPU_MODE`. |
| `dual-gpu` | `app`, `ollama`, `vllm`, `vision`, `searxng` | Two NVIDIA GPUs. GPU 0 = Ollama (cover letters), GPU 1 = vLLM (research). | | `cf-orch` | `web`, `api`, `searxng` | No local LLM. Inference routed to CircuitForge GPU cluster. Requires Paid license. |
| `remote` | `web`, `api`, `searxng` | No local LLM. Inference goes to cloud API keys (Anthropic, OpenAI-compatible). |
| `memory` | (any + memory flag) | Enables RAM-optimised container limits for low-RAM machines. Combine with another profile. |
--- ---
## Service Descriptions ## Service Descriptions
| Service | Image / Source | Port | Purpose | | Service | Image / Source | Host Port | Purpose |
|---------|---------------|------|---------| |---------|---------------|-----------|---------|
| `app` | `Dockerfile` (Streamlit) | 8501 | The main Peregrine UI | | `web` | `Dockerfile.web` (Nginx + Vue SPA) | `VUE_PORT` (default 8506) | Main UI — serves the Vue frontend and proxies `/api/` to `api` |
| `api` | `Dockerfile` (FastAPI) | Internal only (proxied through `web`) | REST API — all backend logic |
| `ollama` | `ollama/ollama` | 11434 | Local model inference — cover letters and general tasks | | `ollama` | `ollama/ollama` | 11434 | Local model inference — cover letters and general tasks |
| `vllm` | `vllm/vllm-openai` | 8000 | High-throughput local inference — research tasks | | `vllm` | `vllm/vllm-openai` | 8000 | High-throughput inference — research tasks |
| `vision` | `scripts/vision_service/` | 8002 | Moondream2 — survey screenshot analysis | | `vision` | `scripts/vision_service/` | 8002 | Moondream2 — survey screenshot analysis |
| `searxng` | `searxng/searxng` | 8888 | Private meta-search engine — company research web scraping | | `searxng` | `searxng/searxng` | 8888 | Private meta-search — company research web scraping |
The `web` container runs Nginx internally on port 80, mapped to `VUE_PORT` on the host. The Nginx config proxies `/api/` requests to `api:8601` — the FastAPI container is not exposed directly.
--- ---
## Choosing a Profile ## Choosing a Profile
### remote
Use `remote` if:
- You have no NVIDIA GPU
- You plan to use Anthropic Claude or another API-hosted model exclusively
- You want the fastest startup (only two containers)
You must configure at least one external LLM backend in **Settings → LLM Backends**.
### cpu ### cpu
Use `cpu` if: Use `cpu` if:
- You have no GPU but want to run models locally (e.g. for privacy) - You have no GPU but want local inference (good for privacy)
- Acceptable for light use; cover letter generation may take several minutes per request - Acceptable for light use; cover letter generation may take several minutes per request
Pull a model after the container starts: Pull a model after starting:
```bash ```bash
docker exec -it peregrine-ollama-1 ollama pull llama3.1:8b docker exec -it peregrine-ollama-1 ollama pull llama3.2:3b
``` ```
`llama3.2:3b` is the recommended CPU model — it runs on machines with 8 GB of system RAM.
### single-gpu ### single-gpu
Use `single-gpu` if: Use `single-gpu` if:
- You have one NVIDIA GPU with at least 8 GB VRAM - You have one NVIDIA GPU with at least 8 GB VRAM
- Recommended for most single-user installs - Recommended for most single-user installs
- The vision service (Moondream2) starts on the same GPU using 4-bit quantisation (~1.5 GB VRAM)
The vision service (Moondream2) starts on the same GPU using 4-bit quantisation (~1.5 GB VRAM). Pull a model after starting:
```bash
docker exec -it peregrine-ollama-1 ollama pull llama3.1:8b
```
### dual-gpu ### dual-gpu
Use `dual-gpu` if: Use `dual-gpu` if:
- You have two or more NVIDIA GPUs - You have two or more NVIDIA GPUs
- GPU 0 handles Ollama (cover letters, quick tasks) - Default: GPU 0 handles Ollama (cover letters), GPU 1 handles vLLM (research)
- GPU 1 handles vLLM (research, long-context tasks)
- The vision service shares GPU 0 with Ollama See [Dual-GPU Modes](#dual-gpu-modes) below to configure how the two GPUs are split.
### cf-orch
Use `cf-orch` if:
- You have access to a CircuitForge GPU cluster running the cf-orch coordinator
- No local GPU required — inference is handled by the cluster
- Requires a Paid or higher license
Set `CF_ORCH_URL` in `.env` to your coordinator address:
```bash
CF_ORCH_URL=http://10.1.10.71:7700
```
The wizard hardware step lets you enter the URL interactively and verifies the connection before saving.
### remote
Use `remote` if:
- You have no local GPU and no cf-orch cluster
- You are using Anthropic Claude, OpenAI, or another cloud API exclusively
Configure at least one external LLM backend in **Settings → LLM Backends** after first login.
### memory (add-on)
Use the `memory` add-on alongside any profile for machines with limited RAM:
```bash
./manage.sh start --profile single-gpu --profile memory
```
This applies conservative container memory limits to prevent the OOM (out-of-memory) killer from terminating containers.
---
## Dual-GPU Modes
When using `dual-gpu`, `DUAL_GPU_MODE` in `.env` controls how the second GPU is used:
| Mode | GPU 0 | GPU 1 | Use case |
|------|-------|-------|----------|
| `mixed` (default) | Ollama | vLLM | Best overall: fast cover letters + high-throughput research |
| `ollama` | Ollama | Ollama | Both GPUs run Ollama; no vLLM; useful if vLLM models are too large for one card |
| `vllm` | vLLM | vLLM | Both GPUs run vLLM (tensor parallel); maximum research throughput |
Set in `.env`:
```bash
DUAL_GPU_MODE=mixed # default
# DUAL_GPU_MODE=ollama
# DUAL_GPU_MODE=vllm
```
The Makefile expands `dual-gpu` into `--profile dual-gpu-$(DUAL_GPU_MODE)` before passing it to `docker compose`. The `compose.gpu.yml` overlay defines the `dual-gpu-mixed`, `dual-gpu-ollama`, and `dual-gpu-vllm` profile variants.
--- ---
@ -75,40 +135,69 @@ Use `dual-gpu` if:
| 48 GB | `single-gpu` | Run smaller models (3B8B parameters) | | 48 GB | `single-gpu` | Run smaller models (3B8B parameters) |
| 816 GB | `single-gpu` | Run 8B13B models comfortably | | 816 GB | `single-gpu` | Run 8B13B models comfortably |
| 1624 GB | `single-gpu` | Run 13B34B models | | 1624 GB | `single-gpu` | Run 13B34B models |
| 24 GB+ | `single-gpu` or `dual-gpu` | 70B models with quantisation | | 24 GB+ (one card) | `single-gpu` | 70B models with quantisation |
| 16+ GB (two cards) | `dual-gpu` | Parallel cover letters + research |
--- ---
## How preflight.py Works ## How preflight.py Works
`make start` calls `scripts/preflight.py` before launching Docker. Preflight does the following: `./manage.sh start` calls `scripts/preflight.py` before launching Docker. Preflight does the following:
1. **Port conflict detection** — checks whether `STREAMLIT_PORT`, `OLLAMA_PORT`, `VLLM_PORT`, `SEARXNG_PORT`, and `VISION_PORT` are already in use. Reports any conflicts and suggests alternatives. 1. **Port conflict detection** — checks whether `VUE_PORT`, `OLLAMA_PORT`, `VLLM_PORT`, `SEARXNG_PORT`, and `VISION_PORT` are already in use. Reports any conflicts and suggests alternatives.
2. **GPU enumeration** — queries `nvidia-smi` for GPU count and VRAM per card. 2. **External service adoption** — if Ollama or SearXNG are already running on their configured ports (common when using native Ollama on macOS, or a shared SearXNG instance), preflight writes a `compose.override.yml` that stubs out the duplicate containers. The running process is adopted rather than replaced.
3. **RAM check** — reads `/proc/meminfo` (Linux) or `vm_stat` (macOS) to determine available system RAM. 3. **GPU enumeration** — queries `nvidia-smi` for GPU count and VRAM per card. On Apple Silicon Macs, falls back to `system_profiler SPDisplaysDataType` and returns unified memory as the VRAM figure.
4. **KV cache offload** — if GPU VRAM is less than 10 GB, preflight calculates `CPU_OFFLOAD_GB` (the amount of KV cache to spill to system RAM) and writes it to `.env`. The vLLM container picks this up via `--cpu-offload-gb`. 4. **RAM check** — reads `/proc/meminfo` (Linux) or `vm_stat` (macOS) for available system RAM.
5. **Profile recommendation** — writes `RECOMMENDED_PROFILE` to `.env`. This is informational; `make start` uses the `PROFILE` variable you specify (defaulting to `remote`). 5. **KV cache offload** — if GPU VRAM is less than 10 GB, preflight calculates `CPU_OFFLOAD_GB` and writes it to `.env`. The vLLM container picks this up via `--cpu-offload-gb` to overflow the KV cache to system RAM.
You can run preflight independently: 6. **Profile recommendation** — writes `RECOMMENDED_PROFILE` to `.env`. This is informational only; `./manage.sh start --profile <name>` uses the profile you specify.
Run preflight independently at any time:
```bash ```bash
make preflight ./manage.sh preflight
# or # or
python scripts/preflight.py conda run -n cf python scripts/preflight.py
``` ```
--- ---
## Podman Support
Podman is fully supported as a Docker drop-in. `install.sh` detects whether Podman or Docker is available, and `manage.sh`/`make` use it automatically.
### GPU setup for Podman (CDI)
Podman uses the CDI (Container Device Interface) standard for GPU passthrough, rather than Docker's `--gpus all` flag. Generate the CDI spec once after driver installation:
```bash
sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml
```
Without this step, GPU profiles start but containers have no GPU access.
### Rootless Podman
Rootless Podman is supported. If you encounter permission errors on the Docker socket, ensure `podman.socket` is running for your user:
```bash
systemctl --user enable --now podman.socket
```
The `make` layer auto-detects rootless Podman and uses `XDG_RUNTIME_DIR/podman/podman.sock` instead of `/var/run/docker.sock`.
---
## Customising Ports ## Customising Ports
Edit `.env` before running `make start`: Edit `.env` before running `./manage.sh start`:
```bash ```bash
STREAMLIT_PORT=8501 VUE_PORT=8506 # main UI (Vue SPA)
OLLAMA_PORT=11434 OLLAMA_PORT=11434
VLLM_PORT=8000 VLLM_PORT=8000
SEARXNG_PORT=8888 SEARXNG_PORT=8888
@ -116,3 +205,15 @@ VISION_PORT=8002
``` ```
All containers read from `.env` via the `env_file` directive in `compose.yml`. All containers read from `.env` via the `env_file` directive in `compose.yml`.
---
## Wizard Test Instance
A separate compose file is available for testing first-run and onboarding wizard flows without touching your main data:
```bash
docker compose -f compose.wizard-test.yml --project-name peregrine-wizard up -d
```
The wizard test instance runs on port **8507** with ephemeral storage — every `docker compose restart` wipes the database back to a clean slate. Uses the same images as the main instance but mounts a minimal LLM config so the wizard detection endpoints work correctly.

View file

@ -7,7 +7,7 @@ This page walks through a full Peregrine installation from scratch.
## Prerequisites ## Prerequisites
- **Git** — to clone the repository - **Git** — to clone the repository
- **Internet connection**`install.sh` downloads Docker and other dependencies - **Internet connection**`install.sh` downloads Docker/Podman and other dependencies
- **Operating system**: Ubuntu/Debian, Fedora/RHEL, Arch Linux, or macOS (with Docker Desktop) - **Operating system**: Ubuntu/Debian, Fedora/RHEL, Arch Linux, or macOS (with Docker Desktop)
!!! warning "Windows" !!! warning "Windows"
@ -34,16 +34,28 @@ bash install.sh
1. **Detects your platform** (Ubuntu/Debian, Fedora/RHEL, Arch, macOS) 1. **Detects your platform** (Ubuntu/Debian, Fedora/RHEL, Arch, macOS)
2. **Installs Git** if not already present 2. **Installs Git** if not already present
3. **Installs Docker Engine** and the Docker Compose v2 plugin via the official Docker repositories 3. **Installs Docker Engine** (or Podman if Docker is not available) via official repositories
4. **Adds your user to the `docker` group** so you do not need `sudo` for docker commands (Linux only — log out and back in after this) 4. **Adds your user to the `docker` group** so you do not need `sudo` for docker commands (Linux only — log out and back in after this)
5. **Detects NVIDIA GPUs** — if `nvidia-smi` is present and working, installs the NVIDIA Container Toolkit and configures Docker to use it 5. **Detects NVIDIA GPUs** — if `nvidia-smi` is present and working, installs the NVIDIA Container Toolkit and configures Docker/Podman to use it
6. **Creates `.env` from `.env.example`** — edit `.env` to customise ports and model storage paths before starting 6. **Creates `.env` from `.env.example`** — edit `.env` to customise ports and model storage paths before starting
!!! note "macOS" !!! note "macOS"
`install.sh` installs Docker Desktop via Homebrew (`brew install --cask docker`) then exits. Open Docker Desktop, start it, then re-run the script. `install.sh` installs Docker Desktop via Homebrew (`brew install --cask docker`) then exits. Open Docker Desktop, start it, then re-run the script. Ollama can also run natively for Metal GPU-accelerated inference — see the macOS note in Step 4.
!!! note "GPU requirement" !!! note "GPU requirement"
For GPU support, `nvidia-smi` must return output before you run `install.sh`. Install your NVIDIA driver first. The Container Toolkit installation will fail silently if the driver is not present. For GPU support, `nvidia-smi` must return output before you run `install.sh`. Install your NVIDIA driver first.
---
## Step 2a — Podman users: GPU CDI setup
If you prefer rootless Podman over Docker, `install.sh` detects it and manages.sh/make use it automatically. For GPU profiles to work with Podman you must generate a CDI spec first:
```bash
sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml
```
This needs to be done once after driver installation. Without it, GPU profiles will start but containers will not have GPU access. Docker users can skip this step — Docker uses `--gpus all` instead of CDI.
--- ---
@ -52,15 +64,21 @@ bash install.sh
The `.env` file controls ports and volume mount paths. The defaults work for most single-user installs: The `.env` file controls ports and volume mount paths. The defaults work for most single-user installs:
```bash ```bash
# Default ports # Main UI port
STREAMLIT_PORT=8501 VUE_PORT=8506
OLLAMA_PORT=11434
VLLM_PORT=8000 # Model paths — use full absolute paths, not ~ (tilde does not expand inside containers)
SEARXNG_PORT=8888 DOCS_DIR=/home/yourname/Documents/JobSearch
VISION_PORT=8002 OLLAMA_MODELS_DIR=/home/yourname/models/ollama
# Inference model defaults
OLLAMA_DEFAULT_MODEL=llama3.2:3b
# External API keys — only needed for the "remote" profile or BYOK unlock
ANTHROPIC_API_KEY=
``` ```
Change `STREAMLIT_PORT` if 8501 is taken on your machine. Change `VUE_PORT` if 8506 is taken on your machine. See [Docker Profiles](docker-profiles.md) for a full port reference.
--- ---
@ -69,21 +87,24 @@ Change `STREAMLIT_PORT` if 8501 is taken on your machine.
Choose a profile based on your hardware: Choose a profile based on your hardware:
```bash ```bash
make start # remote — no GPU, use API-only LLMs ./manage.sh start # cpu — local Ollama on CPU (recommended default)
make start PROFILE=cpu # cpu — local models on CPU (slow) ./manage.sh start --profile single-gpu # one NVIDIA GPU
make start PROFILE=single-gpu # single-gpu — one NVIDIA GPU ./manage.sh start --profile dual-gpu # two NVIDIA GPUs
make start PROFILE=dual-gpu # dual-gpu — GPU 0 = Ollama, GPU 1 = vLLM ./manage.sh start --profile remote # no local LLM — use cloud API keys only
``` ```
`make start` runs `preflight.py` first, which checks for port conflicts and writes GPU/RAM recommendations back to `.env`. Then it calls `docker compose --profile <PROFILE> up -d`. `manage.sh start` runs `preflight.py` first, which checks for port conflicts and writes GPU/RAM recommendations to `.env`. Then it calls `docker compose` (or `podman compose`) with the right compose file overlay for your hardware.
!!! tip "macOS with native Ollama"
If you installed Ollama natively via Homebrew for Metal GPU inference, start with `--profile cpu`. The container API on port 8506 connects to your host's Ollama at `localhost:11434` automatically.
--- ---
## Step 5 — Open the UI ## Step 5 — Open the UI
Navigate to **http://localhost:8501** (or whatever `STREAMLIT_PORT` you set). Navigate to **http://localhost:8506** (or whatever `VUE_PORT` you set).
The first-run wizard launches automatically. See [First-Run Wizard](first-run-wizard.md) for a step-by-step guide through all seven steps. The first-run wizard launches automatically. See [First-Run Wizard](first-run-wizard.md) for a step-by-step guide.
--- ---
@ -96,7 +117,7 @@ The first-run wizard launches automatically. See [First-Run Wizard](first-run-wi
| Fedora 39/40 | Yes | | | Fedora 39/40 | Yes | |
| RHEL / Rocky / AlmaLinux | Yes | | | RHEL / Rocky / AlmaLinux | Yes | |
| Arch Linux / Manjaro | Yes | | | Arch Linux / Manjaro | Yes | |
| macOS (Apple Silicon) | Yes | Docker Desktop required; no GPU support | | macOS (Apple Silicon) | Yes | Docker Desktop required; GPU via native Ollama (Metal) |
| macOS (Intel) | Yes | Docker Desktop required; no GPU support | | macOS (Intel) | Yes | Docker Desktop required; no GPU support |
| Windows | No | Use WSL2 with Ubuntu | | Windows | No | Use WSL2 with Ubuntu |
@ -107,20 +128,23 @@ The first-run wizard launches automatically. See [First-Run Wizard](first-run-wi
Only NVIDIA GPUs are supported. AMD ROCm is not currently supported. Only NVIDIA GPUs are supported. AMD ROCm is not currently supported.
Requirements: Requirements:
- NVIDIA driver installed and `nvidia-smi` working before running `install.sh` - NVIDIA driver installed and `nvidia-smi` working before running `install.sh`
- CUDA 12.x recommended (CUDA 11.x may work but is untested) - CUDA 12.x recommended (CUDA 11.x may work but is untested)
- Minimum 8 GB VRAM for `single-gpu` profile with default models - Minimum 8 GB VRAM for `single-gpu` profile with default models
- For `dual-gpu`: GPU 0 is assigned to Ollama, GPU 1 to vLLM - **Podman users:** CDI spec required — see Step 2a above
If your GPU has less than 10 GB VRAM, `preflight.py` will calculate a `CPU_OFFLOAD_GB` value and write it to `.env`. The vLLM container picks this up via `--cpu-offload-gb` to overflow KV cache to system RAM. For `dual-gpu`, both cards must be NVIDIA. GPU 0 handles Ollama (cover letters, general tasks) and GPU 1 handles the research workload. The exact behaviour is controlled by `DUAL_GPU_MODE` — see [Docker Profiles](docker-profiles.md#dual-gpu-modes).
If your GPU has less than 10 GB VRAM, `preflight.py` calculates a `CPU_OFFLOAD_GB` value and writes it to `.env`. The vLLM container picks this up via `--cpu-offload-gb` to overflow KV cache to system RAM.
--- ---
## Stopping Peregrine ## Stopping Peregrine
```bash ```bash
make stop # stop all containers ./manage.sh stop # stop all containers
make restart # stop then start again (runs preflight first) ./manage.sh restart # stop then start again (runs preflight first)
``` ```
--- ---
@ -128,7 +152,7 @@ make restart # stop then start again (runs preflight first)
## Reinstalling / Clean State ## Reinstalling / Clean State
```bash ```bash
make clean # removes containers, images, and data volumes (destructive) ./manage.sh clean # removes containers, images, and data volumes (destructive)
``` ```
You will be prompted to type `yes` to confirm. You will be prompted to type `yes` to confirm.

View file

@ -0,0 +1,142 @@
# Daily Workflow
This page describes how Peregrine fits into a typical active job search. The core loop is short: find jobs, triage them, generate and send applications, track what happens next.
---
## The Core Loop
```
Run Discovery → Review Jobs → Apply Workspace → Track in Interviews
```
Each stage feeds the next. You can run the full loop in under ten minutes on a good day, or spend longer editing cover letters and doing interview prep when you need to.
---
## Starting Your Day
### 1. Run Discovery
Open the **Home** page and click **Run Discovery**. Peregrine queries all your configured job boards simultaneously and stores results in the local database.
- Discovery runs one search profile at a time. Each profile produces results per board, then moves to the next.
- A summary at the end shows how many new jobs were found vs. already known.
- Jobs you have already seen (by URL) are skipped automatically.
If some jobs came back with short descriptions, click **Fill Missing Descriptions** to enrich them in the background while you work.
See [Job Discovery](job-discovery.md) for search profile configuration and board details.
---
### 2. Review the Queue
Navigate to **Job Review**. New jobs arrive with status `pending` and appear in the review queue.
For each job you can:
- **Approve** — sends it into the application pipeline
- **Reject** — archives it out of the queue
Sort by **Match Score** (high to low) to see the best keyword matches first. The match score compares the job description against your resume keywords — a rough signal, not a hard filter.
Jobs with incoming email leads (a recruiter contacted you about this role) sort to the top automatically.
See [Job Review](job-review.md) for sorting, keyword gaps, and bulk actions.
---
### 3. Write and Send Applications
Navigate to **Apply Workspace**. All approved jobs appear here.
For each job:
1. Click **Generate Cover Letter** — runs as a background task using your resume and career summary.
2. Read and edit the result. The generator uses your mission alignment notes when it detects company fit.
3. Click **Export PDF** to save a formatted PDF to your documents directory.
4. Apply externally (via the company site or board).
5. Click **Mark Applied** to move the job into the Interviews kanban.
See [Apply Workspace](apply-workspace.md) for cover letter configuration, PDF formatting, and ATS optimization.
---
### 4. Track Interviews
The **Interviews** page is a kanban board. Jobs move through stages as your search progresses:
```
applied → phone_screen → interviewing → offer → hired
```
When a job moves to **phone_screen**, Peregrine automatically kicks off a company research brief in the background — a one-page summary of the company, recent news, leadership, and accessibility signals.
Use **Interview Prep** to review talking points, practice Q&A, and get live reference cards during calls.
See [Interviews](interviews.md) for stage transitions, research briefs, and prep tools.
---
## Managing Your Resume
Peregrine has two resume views that work together:
### Resume Library (`/resumes`)
An archive of every resume version — uploaded originals, AI-optimised variants, and auto-backups. The starred entry is your **active default**.
- **Import** a PDF, DOCX, ODT, or plain text file to add a version to the library.
- **★ Set as Default** marks the entry as the active resume used for cover letter generation and keyword matching.
- **⇩ Apply to profile** pushes a library entry into the structured Resume Profile (see below), and links it so future profile edits sync back automatically.
### Resume Profile (`Settings → Resume Profile`)
A structured editor for personal details, work experience, education, and skills. This is the data the cover letter generator reads directly.
- When content was applied from the library, the view shows a sync status and date.
- Saving the Resume Profile automatically updates the linked library entry — keeping them in sync without manual effort.
- You can replace the current profile by uploading a new file directly from this view.
**Recommended flow:** upload to the library → set as default → "Apply to profile" → edit in Resume Profile as needed. Your library stays current automatically.
---
## Keeping Search Preferences Fresh
Go to **Settings → Search Prefs** to update what Peregrine searches for.
Key fields:
| Field | What it does |
|-------|-------------|
| Job Titles | The roles searched across all boards |
| Locations | Geographic scope (leave blank for unrestricted) |
| Remote only | Filter to remote positions only |
| Exclude Keywords | Drop any job title containing these words before it enters the database |
| Job Boards | Enable or disable specific sources |
| Blocklists | Companies, industries, or locations to always skip |
Click **Suggest** next to any field to get AI-generated suggestions based on your resume profile.
Changes take effect on the next discovery run — no restart needed.
---
## Weekly Habits
**Clean up the queue** — reject stale pending jobs at least once a week so the queue stays scannable.
**Update your search prefs** — if you are getting too many mismatches, add more terms to Exclude Keywords. If the queue is thin, broaden Locations or add boards.
**Check Interviews** — move any stalled jobs to the right stage so the kanban reflects reality. The research brief appears in Interview Prep once a job reaches `phone_screen`.
**Tune your resume keywords** — go to **Settings → Skills** if you want to add or reweight keywords used for match scoring.
---
## Tips
- **Match score is a triage signal, not a gate.** A score of 40 might be a perfect cultural fit that uses different terminology. Read the description.
- **Cover letters improve with context.** The richer your career summary and mission alignment notes (Settings → My Profile), the more specific and accurate the generated letters.
- **Company research auto-runs.** You do not need to request it manually — it starts the moment a job hits `phone_screen`.
- **Everything is local.** Your database, resume, and application history live in `data/staging.db` and `data/config/`. Back them up like any other important file.

View file

@ -1,6 +1,8 @@
# Settings # Settings
The Settings page is accessible from the sidebar. It contains all configuration for Peregrine, organised into tabs. Access Settings from the sidebar. The page has a navigation panel on the left (desktop) or a chip bar at the top (mobile). Each section is described below.
For an overview of how settings fit into your daily use, see [Daily Workflow](daily-workflow.md).
--- ---
@ -10,143 +12,177 @@ Personal information used in cover letters, research briefs, and interview prep.
| Field | Description | | Field | Description |
|-------|-------------| |-------|-------------|
| Name | Your full name | | Full name | Your name as it appears in generated documents |
| Email | Contact email address | | Email | Contact email |
| Phone | Contact phone number | | Phone | Contact phone number |
| LinkedIn | LinkedIn profile URL | | LinkedIn URL | Used in cover letter headers |
| Career summary | 24 sentence professional summary | | Career summary | 24 sentences that anchor all LLM-generated content |
| NDA companies | Companies you cannot mention in research briefs (previous employers under NDA) |
| Docs directory | Where PDFs and exported documents are saved (default: `~/Documents/JobSearch`) |
### Mission Preferences ### Mission Preferences
Optional notes about industries you genuinely care about. When the cover letter generator detects alignment with one of these industries, it injects your note into paragraph 3 of the cover letter. Optional notes about industries you genuinely care about. When the cover letter generator detects alignment with one of these industries, it injects your note into the generated letter.
| Field | Tag | Example | | Field | Tag |
|-------|-----|---------| |-------|-----|
| Music industry note | `music` | "I've played in bands for 15 years and care deeply about how artists get paid" | | Music industry note | `music` |
| Animal welfare note | `animal_welfare` | "I volunteer at my local shelter every weekend" | | Animal welfare note | `animal_welfare` |
| Education note | `education` | "I tutored underserved kids and care deeply about literacy" | | Education note | `education` |
Leave a field blank to use a generic default when alignment is detected. Leave a field blank to use a generic default when alignment is detected.
### Research Brief Preferences ### Research Brief Preferences
Controls optional sections in company research briefs. Both are for personal decision-making only and are never included in applications. Controls optional sections in company research briefs. Both are for personal decision-making only and never appear in applications.
| Setting | Section added | | Setting | Section added to brief |
|---------|--------------| |---------|----------------------|
| Candidate accessibility focus | Disability inclusion and accessibility signals (ADA, ERGs, WCAG) | | Accessibility focus | Disability inclusion signals (ADA, ERGs, WCAG) |
| Candidate LGBTQIA+ focus | LGBTQIA+ inclusion signals (ERGs, non-discrimination policies, culture) | | LGBTQIA+ focus | Inclusion signals (ERGs, non-discrimination policies) |
---
## Search
Manage search profiles. Equivalent to editing `config/search_profiles.yaml` directly, but with a form UI.
- Add, edit, and delete profiles
- Configure titles, locations, boards, custom boards, exclude keywords, and mission tags
- Changes are saved to `config/search_profiles.yaml`
---
## LLM Backends
Configure which LLM backends Peregrine uses and in what order.
| Setting | Description |
|---------|-------------|
| Enabled toggle | Whether a backend is considered in the fallback chain |
| Base URL | API endpoint (for `openai_compat` backends) |
| Model | Model name or `__auto__` (vLLM auto-detects the loaded model) |
| API key | API key if required |
| Test button | Sends a short ping to verify the backend is reachable |
### Fallback chains
Three independent fallback chains are configured:
| Chain | Used for |
|-------|---------|
| `fallback_order` | Cover letter generation and general tasks |
| `research_fallback_order` | Company research briefs |
| `vision_fallback_order` | Survey screenshot analysis |
---
## Notion
Configure Notion integration credentials. Requires:
- Notion integration token (from [notion.so/my-integrations](https://www.notion.so/my-integrations))
- Database ID (from the Notion database URL)
The field map controls which Notion properties correspond to which Peregrine fields. Edit `config/notion.yaml` directly for advanced field mapping.
---
## Services
Connection settings for local services:
| Service | Default host:port |
|---------|-----------------|
| Ollama | localhost:11434 |
| vLLM | localhost:8000 |
| SearXNG | localhost:8888 |
Each service has SSL and SSL-verify toggles for reverse-proxy setups.
--- ---
## Resume Profile ## Resume Profile
Edit your parsed resume data (work experience, education, skills, certifications). This is the same data extracted during the first-run wizard Resume step. A structured editor for your work experience, education, skills, and personal details. This is the primary data source for cover letter generation.
Changes here affect all future cover letter generations. ### Resume vs. Library
The Resume Profile is backed by a structured YAML file (`plain_text_resume.yaml`). The **Resume Library** (`/resumes`, accessible from the sidebar) is a versioned archive of full resume texts. They stay in sync automatically when you use the "Apply to profile" flow — see [Daily Workflow — Managing Your Resume](daily-workflow.md#managing-your-resume).
### Uploading a resume
If no profile exists yet, you can:
- **Upload & Parse** — upload a PDF, DOCX, or ODT. Peregrine extracts structured data automatically.
- **Fill in Manually** — start from a blank form.
- **Run Setup Wizard** — re-enter the first-run wizard (self-hosted only).
### Editing the profile
When a resume exists, the full form is shown. Sections:
- **Career Summary** — used in every cover letter and research brief
- **Personal Information** — name, email, phone, LinkedIn; synced from My Profile
- **Work Experience** — title, company, period, location, industry, responsibilities, skills
- **Education** — institution, degree, field, dates
- **Skills, Domains, Keywords** — tags used for keyword matching; click **Suggest** for AI recommendations
- **Certifications and Achievements** — optional; included in cover letter context
Click **Save** to write changes. If a default library entry is linked, it updates automatically.
--- ---
## Email ## Search Prefs
Configure IMAP email sync. See [Email Sync](email-sync.md) for full setup instructions. Manage what Peregrine searches for across all job boards. Changes take effect on the next discovery run — no restart needed.
| Field | Description |
|-------|-------------|
| Remote preference | Remote only, on-site only, or both |
| Job Titles | Roles searched on every board |
| Locations | Geographic scope; leave blank for unrestricted |
| Exclude Keywords | Drop any job title containing these words before it enters the database |
| Job Boards | Enable or disable specific sources; boards marked "coming soon" are tracked in the backlog |
| Custom Board URLs | Additional job board URLs to include |
| Blocklists | Companies, industries, or locations to always skip |
Click **Suggest** next to Job Titles, Locations, or Exclude Keywords to get AI-generated suggestions based on your resume.
--- ---
## Skills ## Connections
Manage your `config/resume_keywords.yaml` — the list of skills and keywords used for match scoring. API credentials and authentication for external services.
Add or remove keywords. Higher-weighted keywords count more toward the match score. | Service | What it enables |
|---------|----------------|
| Notion | Sync approved/applied jobs to a Notion database |
| Airtable | Alternative sync target |
| Google Drive | Document export |
| Slack / Discord | Status notifications |
| Google Calendar / Apple Calendar | Interview scheduling (Paid) |
See [Integrations](integrations.md) for per-service setup instructions.
--- ---
## Integrations ## System
Connection cards for all 13 integrations. See [Integrations](integrations.md) for per-service details. *Not available in cloud mode.*
LLM backend configuration and service connection settings.
### LLM Backends
| Setting | Description |
|---------|-------------|
| Enabled toggle | Whether a backend is considered in the fallback chain |
| Base URL | API endpoint for OpenAI-compatible backends |
| Model | Model name or `__auto__` (vLLM auto-detects the loaded model) |
| API key | Required for hosted APIs |
| Test button | Sends a ping to verify the backend is reachable |
Three independent fallback chains:
| Chain | Used for |
|-------|---------|
| Cover letter chain | Cover letter generation and general tasks |
| Research chain | Company research briefs |
| Vision chain | Survey screenshot analysis |
### Service Hosts and Ports
Connection settings for Ollama, vLLM, and SearXNG. Each service has an SSL toggle and SSL-verify toggle for reverse-proxy setups.
--- ---
## Fine-Tune ## Fine-Tune
**Tier: Premium** *Tier: Premium only.*
Tools for fine-tuning a cover letter model on your personal writing style. Tools for fine-tuning a cover letter model on your personal writing style.
- Export cover letter training data as JSONL 1. **Export Training Data** — produces a JSONL file from your saved cover letters
- Configure training parameters (rank, epochs, learning rate) 2. **Configure training** — rank, epochs, learning rate
- Start a fine-tuning run (requires `ogma` conda environment with Unsloth) 3. **Start fine-tune** — runs via the `ogma` conda environment with Unsloth
- Register the output model with Ollama 4. **Register model** — adds the output to Ollama as `alex-cover-writer:latest`
---
## License
View your current license key, tier, and entitlements. Paste a new key here if you are upgrading or replacing a key.
---
## Data
*Not available in cloud mode.*
Export or delete your local data.
| Action | What it does |
|--------|-------------|
| Export | Downloads `staging.db` and config files as a zip |
| Purge pending jobs | Deletes all jobs with status `pending` |
| Purge rejected jobs | Deletes all jobs with status `rejected` |
| Factory reset | Removes all data and config; returns to first-run wizard |
---
## Privacy
Controls for data collection and diagnostic logging. All collection is opt-in.
--- ---
## Developer ## Developer
Developer and debugging tools. Developer and debugging tools. Only visible when dev mode is enabled or a `dev_tier_override` is set.
| Option | Description | | Option | Description |
|--------|-------------| |--------|-------------|
| Reset wizard | Sets `wizard_complete: false` and `wizard_step: 0`; resumes at step 1 on next page load | | Reset wizard | Sets `wizard_complete: false`; wizard restarts on next page load |
| Dev tier override | Set `dev_tier_override` to `paid` or `premium` to test tier-gated features locally | | Dev tier override | Set tier to `paid` or `premium` to test tier-gated features locally |
| Clear stuck tasks | Manually sets any `running` or `queued` background tasks to `failed` (also runs on app startup) | | Clear stuck tasks | Manually fails any `running` or `queued` background tasks |
| View raw config | Shows the current `config/user.yaml` contents | | View raw config | Shows current `user.yaml` contents |

View file

@ -23,8 +23,8 @@ dependencies:
- undetected-chromedriver - undetected-chromedriver
- webdriver-manager - webdriver-manager
- beautifulsoup4 - beautifulsoup4
- requests - requests>=2.33.0 # CVE-2026-25645
- curl_cffi # Chrome TLS fingerprint — bypasses Cloudflare on The Ladders - curl_cffi>=0.15.0 # CVE-2026-33752
- fake-useragent # company scraper rotation - fake-useragent # company scraper rotation
# ── LLM / AI backends ───────────────────────────────────────────────────── # ── LLM / AI backends ─────────────────────────────────────────────────────
@ -55,13 +55,16 @@ dependencies:
- google-auth>=2.0 - google-auth>=2.0
# ── Document handling ───────────────────────────────────────────────────── # ── Document handling ─────────────────────────────────────────────────────
- pypdf - pypdf>=6.12.0 # 12 CVEs in 6.7.x (CVE-2026-27628 through CVE-2026-48156)
- pdfminer-six - pdfminer-six
- pyyaml>=6.0 - pyyaml>=6.0
- python-dotenv - python-dotenv>=1.2.2 # CVE-2026-28684
# ── Auth / licensing ────────────────────────────────────────────────────── # ── Auth / licensing ──────────────────────────────────────────────────────
- PyJWT>=2.8 - PyJWT>=2.13.0 # 2.11 has sig bypass CVEs (PYSEC-2026-120/175-179); used for cloud session routing
# ── Rate limiting ─────────────────────────────────────────────────────────
- slowapi>=0.1.9 # per-user rate limiting on LLM endpoints
# ── Utilities ───────────────────────────────────────────────────────────── # ── Utilities ─────────────────────────────────────────────────────────────
- sqlalchemy - sqlalchemy
@ -71,6 +74,18 @@ dependencies:
- tenacity - tenacity
- httpx - httpx
# ── Security pins (transitive deps with known CVEs) ───────────────────────
- starlette>=1.0.1 # PYSEC-2026-161 (FastAPI foundation)
- python-multipart>=0.0.27 # CVE-2026-40347/42561 file upload parsing
- aiohttp>=3.14.0 # 12 CVEs (CVE-2026-34513 through CVE-2026-34993)
- tornado>=6.5.5 # CVE-2026-35536
- cryptography>=46.0.7 # PYSEC-2026-35/36
- langsmith>=0.8.0 # CVE-2026-41182/45134
- gitpython>=3.1.50 # CVE-2026-42215/42284/44244
- lxml>=6.1.0 # PYSEC-2026-87 (XXE)
- idna>=3.15 # CVE-2026-45409
- markdownify>=0.14.1 # CVE-2025-46656
# ── Testing ─────────────────────────────────────────────────────────────── # ── Testing ───────────────────────────────────────────────────────────────
- pytest>=9.0 - pytest>=9.0
- pytest-cov - pytest-cov

View file

@ -52,6 +52,7 @@ nav:
- First-Run Wizard: getting-started/first-run-wizard.md - First-Run Wizard: getting-started/first-run-wizard.md
- Docker Profiles: getting-started/docker-profiles.md - Docker Profiles: getting-started/docker-profiles.md
- User Guide: - User Guide:
- Daily Workflow: user-guide/daily-workflow.md
- Job Discovery: user-guide/job-discovery.md - Job Discovery: user-guide/job-discovery.md
- Job Review: user-guide/job-review.md - Job Review: user-guide/job-review.md
- Apply Workspace: user-guide/apply-workspace.md - Apply Workspace: user-guide/apply-workspace.md

32
pyproject.toml Normal file
View file

@ -0,0 +1,32 @@
[tool.ruff]
# app/ is the deprecated Streamlit UI (replaced by Vue+FastAPI).
# No new work goes there; exclude from linting rather than accumulate suppressions.
exclude = ["app/"]
[tool.ruff.lint.per-file-ignores]
# dev-api.py / dev_api.py (symlink): E702 semicolons in compact Pydantic model
# definitions — intentional style for dense data models with many simple fields.
"dev-api.py" = ["E702"]
"dev_api.py" = ["E702"]
# finetune_local.py: E402 ML libs (torch, datasets, trl) are imported after
# runtime CUDA / Unsloth availability checks — conditional import pattern.
"scripts/finetune_local.py" = ["E402", "E741"]
# scripts/: E402 mid-file imports used for lazy loading or post-env-setup imports.
"scripts/task_runner.py" = ["E402"]
"scripts/migrate.py" = ["E741"]
# scrapers/: third-party script; minimal changes policy.
"scrapers/companyScraper.py" = ["E722"]
# tools/: deprecated label tool copy (canonical in avocet); suppress style warnings.
"tools/label_tool.py" = ["E741"]
# tests/: F841 unused variables are the standard mock-patch capture pattern
# (e.g., `original_fn = obj.method` before monkeypatching).
# E741 ambiguous `l` names and E402 conditional imports are common in test fixtures.
# E702 compact `con.commit(); con.close()` is a common SQLite test helper idiom.
"tests/**" = ["F841", "E741", "E402", "E702"]
"tests/test_wizard_steps.py" = ["F841", "E741", "E402", "E702"]
"scripts/test_email_classify.py" = ["E402", "F841"]

View file

@ -91,6 +91,7 @@ mkdocs-material>=9.5
# ── Vue SPA API backend ────────────────────────────────────────────────── # ── Vue SPA API backend ──────────────────────────────────────────────────
fastapi>=0.100.0 fastapi>=0.100.0
uvicorn[standard]>=0.20.0 uvicorn[standard]>=0.20.0
slowapi>=0.1.9
PyJWT>=2.8.0 PyJWT>=2.8.0
cryptography>=40.0.0 cryptography>=40.0.0
python-multipart>=0.0.6 python-multipart>=0.0.6

View file

@ -10,23 +10,15 @@ Usage — add to main.py once:
from app.cloud_session import session_middleware_dep from app.cloud_session import session_middleware_dep
app = FastAPI(..., dependencies=[Depends(session_middleware_dep)]) app = FastAPI(..., dependencies=[Depends(session_middleware_dep)])
From that point, any route (and every service/llm function it calls) Writing model is resolved from Heimdall's resolve response (user_preferences
has access to the current user context via llm.get_request_*() helpers. JSON column, projected as custom_writing_model in the response). Assign models
via the admin UI at /account/admin/model-assignments.
Writing model resolution order (first match wins):
1. USER_WRITING_MODELS env var JSON dict mapping Directus UUID model name
e.g. USER_WRITING_MODELS={"5b99ca9f-...": "meghan-letter-writer:latest"}
Use this for Monday; no Heimdall changes required.
2. session.meta["custom_writing_model"] returned by Heimdall resolve endpoint
once Heimdall is updated to expose user_preferences fields.
""" """
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import os
from fastapi import Depends, Request, Response from fastapi import Request, Response
from circuitforge_core.cloud_session import CloudSessionFactory, CloudUser, detect_byok from circuitforge_core.cloud_session import CloudSessionFactory, CloudUser, detect_byok
@ -34,21 +26,6 @@ log = logging.getLogger(__name__)
__all__ = ["CloudUser", "get_session", "require_tier", "session_middleware_dep"] __all__ = ["CloudUser", "get_session", "require_tier", "session_middleware_dep"]
# JSON dict mapping Directus user UUID → custom writing model name.
# Used until Heimdall's resolve endpoint exposes user_preferences.
def _load_user_writing_models() -> dict[str, str]:
raw = os.environ.get("USER_WRITING_MODELS", "").strip()
if not raw:
return {}
try:
return json.loads(raw)
except json.JSONDecodeError:
log.warning("USER_WRITING_MODELS is not valid JSON — ignoring")
return {}
_USER_WRITING_MODELS: dict[str, str] = _load_user_writing_models()
_factory = CloudSessionFactory( _factory = CloudSessionFactory(
product="peregrine", product="peregrine",
byok_detector=detect_byok, byok_detector=detect_byok,
@ -81,9 +58,4 @@ def session_middleware_dep(request: Request, response: Response) -> None:
set_request_user_id(user_id) set_request_user_id(user_id)
set_request_tier(session.tier) set_request_tier(session.tier)
# Resolution order: env-var map (Monday path) → Heimdall meta (future path) set_request_writing_model(session.meta.get("custom_writing_model") or None)
writing_model = (
_USER_WRITING_MODELS.get(session.user_id)
or session.meta.get("custom_writing_model")
)
set_request_writing_model(writing_model)

View file

@ -152,6 +152,62 @@ async def _allocate_orch_async(
logging.debug("cf-orch release failed (non-fatal): %s", exc) logging.debug("cf-orch release failed (non-fatal): %s", exc)
@asynccontextmanager
async def _allocate_by_task(
coordinator_url: str,
product: str,
task: str,
ttl_s: float,
caller: str,
):
"""Allocate via the task-model assignment layer (POST /api/inference/task).
Resolves product+task model_id service+node automatically.
Falls back gracefully: if the coordinator returns 404 (no assignment),
raises RuntimeError so the caller can fall back to model_candidates routing.
"""
async with httpx.AsyncClient(timeout=120.0) as client:
payload: dict[str, Any] = {
"product": product,
"task": task,
"payload": {"ttl_s": ttl_s, "caller": caller},
}
uid = get_request_user_id()
if uid:
payload["payload"]["user_id"] = uid
resp = await client.post(
f"{coordinator_url.rstrip('/')}/api/inference/task",
json=payload,
)
if resp.status_code == 404:
raise RuntimeError(
f"No task assignment for product={product!r} task={task!r}; "
"falling back to model_candidates routing"
)
if not resp.is_success:
raise RuntimeError(
f"cf-orch task allocation failed for {product}/{task}: "
f"HTTP {resp.status_code}{resp.text[:200]}"
)
data = resp.json()
service = data.get("service_type", "vllm")
alloc = _OrchAllocation(
allocation_id=data["allocation_id"],
url=data["url"],
service=service,
)
try:
yield alloc
finally:
try:
await client.delete(
f"{coordinator_url.rstrip('/')}/api/services/{service}/allocations/{alloc.allocation_id}",
timeout=10.0,
)
except Exception as exc:
logging.debug("cf-orch task release failed (non-fatal): %s", exc)
def _normalize_api_base(provider: str, api_base: str | None) -> str | None: def _normalize_api_base(provider: str, api_base: str | None) -> str | None:
"""Normalize api_base for LiteLLM provider-specific expectations. """Normalize api_base for LiteLLM provider-specific expectations.
@ -497,11 +553,41 @@ async def complete(
config: LLMConfig | None = None, config: LLMConfig | None = None,
max_tokens: int = 4096, max_tokens: int = 4096,
temperature: float = 0.7, temperature: float = 0.7,
task_name: str | None = None,
) -> str: ) -> str:
"""Make a completion request to the LLM.""" """Make a completion request to the LLM.
When task_name is provided and CF_ORCH_URL is set, routing is resolved via
the task-model assignment layer (POST /api/inference/task) instead of using
hardcoded model_candidates. Falls back to model_candidates routing if the
assignment is missing, then to the default config if cf-orch is unavailable.
"""
if config is None: if config is None:
cf_orch_url = os.environ.get("CF_ORCH_URL", "").strip() cf_orch_url = os.environ.get("CF_ORCH_URL", "").strip()
if cf_orch_url: if cf_orch_url:
# Task-routing path: preferred when a task name is known.
if task_name:
try:
async with _allocate_by_task(
cf_orch_url,
product="peregrine",
task=task_name,
ttl_s=300.0,
caller="peregrine-resume-matcher",
) as alloc:
orch_config = LLMConfig(
provider="openai",
model="__auto__",
api_key="any",
api_base=alloc.url.rstrip("/") + "/v1",
)
return await complete(prompt, system_prompt, orch_config, max_tokens, temperature)
except RuntimeError as exc:
logging.warning(
"cf-orch task routing failed for %r, falling back to model_candidates: %s",
task_name, exc,
)
# Model-candidates path: legacy routing or task fallback.
try: try:
# Premium/ultra users get their personal fine-tuned writing model as the # Premium/ultra users get their personal fine-tuned writing model as the
# first candidate; the base model is the fallback so cf-orch can # first candidate; the base model is the fallback so cf-orch can

View file

@ -14,7 +14,6 @@ Enhanced features:
import argparse import argparse
import csv import csv
import json
import os import os
import random import random
import re import re

View file

@ -31,7 +31,6 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from scripts.classifier_adapters import ( from scripts.classifier_adapters import (
LABELS, LABELS,
LABEL_DESCRIPTIONS,
ClassifierAdapter, ClassifierAdapter,
GLiClassAdapter, GLiClassAdapter,
RerankerAdapter, RerankerAdapter,

View file

@ -5,7 +5,6 @@ push updates the existing event rather than creating a duplicate.
""" """
from __future__ import annotations from __future__ import annotations
import uuid
import yaml import yaml
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path

View file

@ -121,6 +121,17 @@ CREATE TABLE IF NOT EXISTS survey_responses (
); );
""" """
CREATE_RESUME_CORRECTIONS = """
CREATE TABLE IF NOT EXISTS resume_optimizer_corrections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL REFERENCES jobs(id),
section TEXT NOT NULL,
proposed_json TEXT NOT NULL,
accepted_json TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
"""
CREATE_DIGEST_QUEUE = """ CREATE_DIGEST_QUEUE = """
CREATE TABLE IF NOT EXISTS digest_queue ( CREATE TABLE IF NOT EXISTS digest_queue (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
@ -205,9 +216,10 @@ def _migrate_db(db_path: Path) -> None:
conn.execute("ALTER TABLE background_tasks ADD COLUMN params TEXT") conn.execute("ALTER TABLE background_tasks ADD COLUMN params TEXT")
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass # column already exists pass # column already exists
# Ensure references tables exist (CREATE IF NOT EXISTS is idempotent) # Ensure tables that can't be added via ALTER TABLE exist (all idempotent).
conn.execute(CREATE_REFERENCES) conn.execute(CREATE_REFERENCES)
conn.execute(CREATE_JOB_REFERENCES) conn.execute(CREATE_JOB_REFERENCES)
conn.execute(CREATE_RESUME_CORRECTIONS)
conn.commit() conn.commit()
conn.close() conn.close()
@ -223,6 +235,7 @@ def init_db(db_path: Path = DEFAULT_DB) -> None:
conn.execute(CREATE_DIGEST_QUEUE) conn.execute(CREATE_DIGEST_QUEUE)
conn.execute(CREATE_REFERENCES) conn.execute(CREATE_REFERENCES)
conn.execute(CREATE_JOB_REFERENCES) conn.execute(CREATE_JOB_REFERENCES)
conn.execute(CREATE_RESUME_CORRECTIONS)
conn.commit() conn.commit()
conn.close() conn.close()
_migrate_db(db_path) _migrate_db(db_path)
@ -1241,3 +1254,76 @@ def set_training_exclusion(db_path: Path, job_id: int, excluded: bool) -> None:
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()
# ── Resume optimizer corrections ──────────────────────────────────────────────
def save_resume_correction(
db_path: Path,
job_id: int,
section: str,
proposed: object,
accepted: object,
) -> None:
"""Persist a (proposed, accepted) correction pair from the resume review UI.
Called when a user edits an LLM-proposed value and accepts it. The pair
becomes a supervised fine-tuning (SFT) candidate routed through Avocet.
Args:
section: 'summary' or 'experience:<title>|<company>'
proposed: Original LLM output (string for summary, list for bullets).
accepted: User-edited value (same type as proposed).
"""
import json as _json
conn = sqlite3.connect(db_path)
try:
conn.execute(
"""INSERT INTO resume_optimizer_corrections
(job_id, section, proposed_json, accepted_json)
VALUES (?, ?, ?, ?)""",
(job_id, section, _json.dumps(proposed), _json.dumps(accepted)),
)
conn.commit()
finally:
conn.close()
def get_resume_corrections(
db_path: Path,
limit: int = 200,
job_id: int | None = None,
) -> list[dict]:
"""Return pending resume corrections for Avocet export.
Args:
limit: Maximum rows to return.
job_id: If set, filter to corrections for a specific job.
"""
import json as _json
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
if job_id is not None:
rows = conn.execute(
"SELECT * FROM resume_optimizer_corrections WHERE job_id=? ORDER BY created_at DESC LIMIT ?",
(job_id, limit),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM resume_optimizer_corrections ORDER BY created_at DESC LIMIT ?",
(limit,),
).fetchall()
finally:
conn.close()
return [
{
"id": r["id"],
"job_id": r["job_id"],
"section": r["section"],
"proposed": _json.loads(r["proposed_json"]),
"accepted": _json.loads(r["accepted_json"]),
"created_at": r["created_at"],
}
for r in rows
]

View file

@ -163,7 +163,8 @@ def _ensure_labels(
def create_forgejo_issue(title: str, body: str, labels: list[str]) -> dict: def create_forgejo_issue(title: str, body: str, labels: list[str]) -> dict:
"""Create a Forgejo issue. Returns {"number": int, "url": str}.""" """Create a Forgejo issue. Returns {"number": int, "url": str}."""
token = os.environ.get("FORGEJO_API_TOKEN", "") # Use the bot token when set; fall back to the main API token for dev/self-hosted.
token = os.environ.get("FORGEJO_BOT_TOKEN") or os.environ.get("FORGEJO_API_TOKEN", "")
repo = os.environ.get("FORGEJO_REPO", "pyr0ball/peregrine") repo = os.environ.get("FORGEJO_REPO", "pyr0ball/peregrine")
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1") base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"} headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
@ -183,7 +184,7 @@ def upload_attachment(
issue_number: int, image_bytes: bytes, filename: str = "screenshot.png" issue_number: int, image_bytes: bytes, filename: str = "screenshot.png"
) -> str: ) -> str:
"""Upload a screenshot to an existing Forgejo issue. Returns attachment URL.""" """Upload a screenshot to an existing Forgejo issue. Returns attachment URL."""
token = os.environ.get("FORGEJO_API_TOKEN", "") token = os.environ.get("FORGEJO_BOT_TOKEN") or os.environ.get("FORGEJO_API_TOKEN", "")
repo = os.environ.get("FORGEJO_REPO", "pyr0ball/peregrine") repo = os.environ.get("FORGEJO_REPO", "pyr0ball/peregrine")
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1") base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
headers = {"Authorization": f"token {token}"} headers = {"Authorization": f"token {token}"}

View file

@ -73,7 +73,7 @@ if not LETTERS_JSONL.exists():
sys.exit(f"ERROR: Dataset not found at {LETTERS_JSONL}\n" sys.exit(f"ERROR: Dataset not found at {LETTERS_JSONL}\n"
"Run: make prepare-training (or: python scripts/prepare_training_data.py)") "Run: make prepare-training (or: python scripts/prepare_training_data.py)")
records = [json.loads(l) for l in LETTERS_JSONL.read_text().splitlines() if l.strip()] records = [json.loads(line) for line in LETTERS_JSONL.read_text().splitlines() if line.strip()]
print(f"Loaded {len(records)} training examples.") print(f"Loaded {len(records)} training examples.")
# Convert to chat format expected by SFTTrainer # Convert to chat format expected by SFTTrainer
@ -323,6 +323,6 @@ if gguf_path and gguf_path.exists():
else: else:
print(f"\n{'='*60}") print(f"\n{'='*60}")
print(" Adapter saved (no GGUF produced).") print(" Adapter saved (no GGUF produced).")
print(f" Re-run without --no-gguf to generate a GGUF for Ollama registration.") print(" Re-run without --no-gguf to generate a GGUF for Ollama registration.")
print(f" Adapter path: {adapter_path}") print(f" Adapter path: {adapter_path}")
print(f"{'='*60}\n") print(f"{'='*60}\n")

View file

@ -186,7 +186,7 @@ def build_prompt(
) )
parts.append(f"{recruiter_note}\n") parts.append(f"{recruiter_note}\n")
parts.append(f"Now write a new cover letter for:") parts.append("Now write a new cover letter for:")
parts.append(f" Role: {title}") parts.append(f" Role: {title}")
parts.append(f" Company: {company}") parts.append(f" Company: {company}")
if description: if description:

View file

@ -1,5 +1,5 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime
from scripts.integrations.base import IntegrationBase from scripts.integrations.base import IntegrationBase

View file

@ -25,7 +25,6 @@ import argparse
import shutil import shutil
import sys import sys
from pathlib import Path from pathlib import Path
from textwrap import dedent
import yaml import yaml

View file

@ -348,14 +348,14 @@ def write_compose_override(ports: dict[str, dict]) -> None:
for name, info in to_disable.items(): for name, info in to_disable.items():
lines += [ lines += [
f" {name}: # adopted — host service on :{info['resolved']}", f" {name}: # adopted — host service on :{info['resolved']}",
f" entrypoint: [\"/bin/sh\", \"-c\", \"sleep infinity\"]", " entrypoint: [\"/bin/sh\", \"-c\", \"sleep infinity\"]",
f" ports: []", " ports: []",
f" healthcheck:", " healthcheck:",
f" test: [\"CMD\", \"true\"]", " test: [\"CMD\", \"true\"]",
f" interval: 1s", " interval: 1s",
f" timeout: 1s", " timeout: 1s",
f" start_period: 0s", " start_period: 0s",
f" retries: 1", " retries: 1",
] ]
OVERRIDE_YML.write_text("\n".join(lines) + "\n") OVERRIDE_YML.write_text("\n".join(lines) + "\n")

32
scripts/rate_limit.py Normal file
View file

@ -0,0 +1,32 @@
"""Per-user rate limiting for Peregrine LLM generation endpoints."""
from pathlib import Path
from slowapi import Limiter
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from starlette.requests import Request
from starlette.responses import JSONResponse
def _rate_key(request: Request) -> str:
"""Cloud mode: user_id from DB path. Local mode: client IP. Demo: unique key (no rate limit)."""
from dev_api import IS_DEMO, _CLOUD_MODE, _request_db # lazy import avoids circular
if IS_DEMO:
return f"demo-{id(request)}" # unique per request — effectively no rate limiting
db_path = _request_db.get()
if _CLOUD_MODE and db_path:
return Path(db_path).parts[-3] # user_id segment
return get_remote_address(request)
limiter = Limiter(key_func=_rate_key)
def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse:
"""Return 429 with Retry-After header."""
retry_after = getattr(exc, "retry_after", 60)
return JSONResponse(
status_code=429,
content={"error": "rate_limit_exceeded", "retry_after": retry_after},
headers={"Retry-After": str(retry_after)},
)

View file

@ -19,7 +19,6 @@ from __future__ import annotations
import json import json
import logging import logging
import re import re
from pathlib import Path
from typing import Any from typing import Any
log = logging.getLogger(__name__) log = logging.getLogger(__name__)

View file

@ -9,11 +9,9 @@ Falls back to empty dict on unrecoverable errors — caller shows the form build
from __future__ import annotations from __future__ import annotations
import io import io
import json
import logging import logging
import re import re
import zipfile import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
import pdfplumber import pdfplumber
@ -21,6 +19,14 @@ from docx import Document
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Browser print artifact patterns — lines injected when a PDF is printed from a browser
# (print header "MM/DD/YY, H:MM AM/PM <title>" and print footer "file:///... N/N")
_BROWSER_ARTIFACT_RE = re.compile(
r"^file:///" # file:// URL footer
r"|^\d{1,2}/\d{1,2}/\d{2,4},\s+\d{1,2}:\d{2}\s+[AP]M\b", # MM/DD/YY, H:MM AM/PM header
re.I,
)
# ── Section header detection ────────────────────────────────────────────────── # ── Section header detection ──────────────────────────────────────────────────
_SECTION_NAMES = { _SECTION_NAMES = {
@ -29,6 +35,8 @@ _SECTION_NAMES = {
"education": re.compile(r"^(education|academic|qualifications|degrees?|educational background|academic background)\s*:?\s*$", re.I), "education": re.compile(r"^(education|academic|qualifications|degrees?|educational background|academic background)\s*:?\s*$", re.I),
"skills": re.compile(r"^(skills?|technical skills?|core competencies|competencies|expertise|areas? of expertise|key skills?|proficiencies|tools? & technologies)\s*:?\s*$", re.I), "skills": re.compile(r"^(skills?|technical skills?|core competencies|competencies|expertise|areas? of expertise|key skills?|proficiencies|tools? & technologies)\s*:?\s*$", re.I),
"achievements": re.compile(r"^(achievements?|accomplishments?|awards?|honors?|certifications?|publications?|volunteer)\s*:?\s*$", re.I), "achievements": re.compile(r"^(achievements?|accomplishments?|awards?|honors?|certifications?|publications?|volunteer)\s*:?\s*$", re.I),
"projects": re.compile(r"^(projects?|independent development|independent projects?|side projects?|personal projects?|open.?source|portfolio)\s*:?\s*$", re.I),
"references": re.compile(r"^references?\s*:?\s*$", re.I),
} }
# Degrees — used to detect education lines # Degrees — used to detect education lines
@ -165,6 +173,8 @@ def _split_sections(text: str) -> dict[str, list[str]]:
stripped = line.strip() stripped = line.strip()
if not stripped: if not stripped:
continue continue
if _BROWSER_ARTIFACT_RE.match(stripped):
continue
matched = False matched = False
for section, pattern in _SECTION_NAMES.items(): for section, pattern in _SECTION_NAMES.items():
# Match if the line IS a section header (short + matches pattern) # Match if the line IS a section header (short + matches pattern)
@ -234,10 +244,14 @@ def _parse_experience(lines: list[str]) -> list[dict]:
(A) Title | Company (B) Title | Company | Dates (A) Title | Company (B) Title | Company | Dates
Dates bullet Dates bullet
bullet bullet
(C) Title\tDates (tab-separated, common in DOCX exports)
Company | Location
bullet
""" """
entries: list[dict] = [] entries: list[dict] = []
current: dict | None = None current: dict | None = None
prev_line = "" prev_line = ""
seen_bullets = False # True once we've appended the first bullet to current
for line in lines: for line in lines:
date_match = _DATE_RANGE_RE.search(line) date_match = _DATE_RANGE_RE.search(line)
@ -245,12 +259,13 @@ def _parse_experience(lines: list[str]) -> list[dict]:
if current: if current:
entries.append(current) entries.append(current)
# Title/company extraction — three layouts: # Title/company extraction — three layouts:
# (A) Title on prev_line, "Company | Location | Dates" on date line # (A) Title on prev_line (not a bullet), "Company | Location | Dates" on date line
# (B) "Title | Company" on prev_line, dates on date line (same_line empty) # (B) "Title | Company" on prev_line, dates on date line (same_line empty)
# (C) "Title | Company | Dates" all on one line # (C) "Title | Company | Dates" all on one line
same_line = _DATE_RANGE_RE.sub("", line) same_line = _DATE_RANGE_RE.sub("", line)
# Remove residual punctuation-only fragments like "()" left after date removal # Remove residual punctuation-only fragments like "()" left after date removal
same_line = re.sub(r"[()[\]{}\s]+$", "", same_line).strip(" –—|-•") same_line = re.sub(r"[()[\]{}\s]+$", "", same_line).strip(" –—|-•")
# Only use prev_line as title if it isn't bullet text (cleared after bullets)
if prev_line and same_line.strip(): if prev_line and same_line.strip():
# Layout A: title = prev_line, company = first segment of same_line # Layout A: title = prev_line, company = first segment of same_line
title = prev_line.strip() title = prev_line.strip()
@ -270,8 +285,19 @@ def _parse_experience(lines: list[str]) -> list[dict]:
"bullets": [], "bullets": [],
} }
prev_line = "" prev_line = ""
seen_bullets = False
elif current is not None: elif current is not None:
is_bullet = bool(re.match(r"^[•\-–—*◦▪▸►]\s*", line)) is_bullet = bool(re.match(r"^[•\-–—*◦▪▸►]\s*", line))
# Layout C: company/location on the line immediately after the date line,
# before any bullets. Short non-date line = company, not a next-job header.
if (not is_bullet and not seen_bullets and not current["company"]
and not _DATE_RE.search(line) and len(line.strip()) < 80):
co_part = re.split(r"\s{2,}|[|,]\s*", line.strip(), maxsplit=1)[0]
current["company"] = co_part.strip()
prev_line = ""
continue
looks_like_header = ( looks_like_header = (
not is_bullet not is_bullet
and " | " in line and " | " in line
@ -284,7 +310,10 @@ def _parse_experience(lines: list[str]) -> list[dict]:
clean = re.sub(r"^[•\-–—*◦▪▸►]\s*", "", line).strip() clean = re.sub(r"^[•\-–—*◦▪▸►]\s*", "", line).strip()
if clean: if clean:
current["bullets"].append(clean) current["bullets"].append(clean)
prev_line = line seen_bullets = True
# Clear prev_line after non-header content so the next date match
# doesn't mistake a bullet as a job title (Layout A false-positive).
prev_line = ""
else: else:
prev_line = line prev_line = line
@ -296,39 +325,77 @@ def _parse_experience(lines: list[str]) -> list[dict]:
# ── Education ───────────────────────────────────────────────────────────────── # ── Education ─────────────────────────────────────────────────────────────────
_INSTITUTION_RE = re.compile(r"\b(university|college|institute|school|academy)\b", re.I)
def _parse_education(lines: list[str]) -> list[dict]: def _parse_education(lines: list[str]) -> list[dict]:
"""Parse education entries.
Primary path: degree keyword detected (B.S., Master, etc.)
Fallback path: year range detected without a degree keyword handles resumes
with courses, programmes, or non-degree study (e.g. "San Jose State University 2005-2006").
"""
entries: list[dict] = [] entries: list[dict] = []
current: dict | None = None current: dict | None = None
prev_line = "" prev_line = ""
for line in lines: for line in lines:
if _DEGREE_RE.search(line): has_degree = bool(_DEGREE_RE.search(line))
date_range = _DATE_RANGE_RE.search(line)
has_year = bool(re.search(r"\b(19|20)\d{2}\b", line))
if has_degree or (has_year and date_range):
if current: if current:
entries.append(current) entries.append(current)
current = { current = {"institution": "", "degree": "", "field": "", "graduation_year": ""}
"institution": "",
"degree": "",
"field": "",
"graduation_year": "",
}
year_m = re.search(r"\b(19|20)\d{2}\b", line) year_m = re.search(r"\b(19|20)\d{2}\b", line)
if year_m: if year_m:
current["graduation_year"] = year_m.group(0) current["graduation_year"] = year_m.group(0)
degree_m = _DEGREE_RE.search(line)
if degree_m: if has_degree:
current["degree"] = degree_m.group(0).upper() degree_m = _DEGREE_RE.search(line)
remainder = _DEGREE_RE.sub("", _DATE_RE.sub("", line)) if degree_m:
remainder = re.sub(r"\b(19|20)\d{2}\b", "", remainder) current["degree"] = degree_m.group(0).upper()
current["field"] = remainder.strip(" ,–—|•.") remainder = _DEGREE_RE.sub("", _DATE_RE.sub("", line))
# Layout A: institution was on the line before the degree line remainder = re.sub(r"\b(19|20)\d{2}\b", "", remainder)
if prev_line and not _DEGREE_RE.search(prev_line): current["field"] = remainder.strip(" ,–—|•.")
current["institution"] = prev_line.strip(" ,–—|•") if prev_line and not _DEGREE_RE.search(prev_line) and not _DATE_RE.search(prev_line):
elif current is not None and not current["institution"]: current["institution"] = prev_line.strip(" ,–—|•")
# Layout B: institution follows the degree line else:
clean = line.strip(" ,–—|•") # Fallback: year-range line without a degree keyword.
# Two layouts:
# (A) PDF: "Graphic Design, 20052006" with institution on prev_line
# (B) DOCX: "San Jose State University\t2005-2006" — institution on same line
same = _DATE_RANGE_RE.sub("", line)
same = re.sub(r"\b(19|20)\d{2}\b", "", same).strip(" ,–—|•\t")
prev_clean = prev_line.strip(" ,–—|•") if prev_line else ""
if same and _INSTITUTION_RE.search(prev_clean):
# Layout A: institution on prev_line (e.g. "San Jose State University")
current["institution"] = prev_clean
current["field"] = same
elif same:
# Layout B: institution embedded on same line as year
current["institution"] = same
elif prev_clean:
current["institution"] = prev_clean
prev_line = "" # consumed; prevent leaking into the next entry
elif current is not None:
clean = line.strip(" ,–—|•\t")
if clean: if clean:
current["institution"] = clean if not current["institution"]:
prev_line = line.strip() current["institution"] = clean
elif not current["field"]:
current["field"] = clean
prev_line = "" # field consumed — don't seed the next entry
continue
prev_line = line.strip()
else:
prev_line = line.strip()
if current: if current:
entries.append(current) entries.append(current)
@ -338,13 +405,39 @@ def _parse_education(lines: list[str]) -> list[dict]:
# ── Skills ──────────────────────────────────────────────────────────────────── # ── Skills ────────────────────────────────────────────────────────────────────
def _split_skill_tokens(line: str) -> list[str]:
"""Split a skills line on delimiters, but not on commas inside parentheses.
Splits on |, , ·, tab first (always separators), then on comma only when
paren depth is zero so "CRM Ticketing (Jira, Salesforce)" stays intact.
"""
tokens: list[str] = []
for part in re.split(r"[|•·\t]+", line):
depth, buf = 0, ""
for ch in part:
if ch == "(":
depth += 1
buf += ch
elif ch == ")":
depth -= 1
buf += ch
elif ch == "," and depth == 0:
tokens.append(buf)
buf = ""
else:
buf += ch
tokens.append(buf)
return tokens
def _parse_skills(lines: list[str]) -> list[str]: def _parse_skills(lines: list[str]) -> list[str]:
skills: list[str] = [] skills: list[str] = []
for line in lines: for line in lines:
# Split on common delimiters for item in _split_skill_tokens(line):
for item in re.split(r"[,|•·/]+", line): # Strip only bullet/dash markers and whitespace, NOT parentheses —
clean = item.strip(" -–—*◦▪▸►()") # many skills contain parens, e.g. "C++ (Arduino / Embedded)"
if 1 < len(clean) <= 50: clean = item.strip(" -–—*◦▪▸►")
if 1 < len(clean) <= 60:
skills.append(clean) skills.append(clean)
return skills return skills

View file

@ -7,7 +7,6 @@ FastAPI application. Callable directly or via the survey_analyze background task
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional

View file

@ -341,7 +341,6 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int,
prioritize_gaps, prioritize_gaps,
rewrite_for_ats, rewrite_for_ats,
hallucination_check, hallucination_check,
render_resume_text,
) )
from scripts.user_profile import load_user_profile from scripts.user_profile import load_user_profile

View file

@ -15,14 +15,13 @@ Public API (unchanged — callers do not need to change):
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
from circuitforge_core.tasks.scheduler import ( from circuitforge_core.tasks.scheduler import (
TaskSpec, # re-export unchanged
LocalScheduler as _CoreTaskScheduler, LocalScheduler as _CoreTaskScheduler,
TaskSpec, # noqa: F401 — re-exported as part of public API; tests import from here
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

18
tests/conftest.py Normal file
View file

@ -0,0 +1,18 @@
"""Shared pytest fixtures for the Peregrine test suite."""
import pytest
@pytest.fixture(autouse=True)
def reset_rate_limiter():
"""Reset slowapi state before each test.
Each importlib.reload(dev_api) re-applies @limiter.limit() decorators,
accumulating stale registrations in _route_limits on the shared limiter
singleton. One real request then triggers N limit-checks (N = reload count),
exhausting per-hour budgets prematurely. Clearing both _storage and
_route_limits before each test gives each test a clean slate.
"""
from scripts.rate_limit import limiter
limiter._storage.reset()
limiter._route_limits.clear()
yield

View file

@ -12,7 +12,7 @@ import pytest
from dotenv import load_dotenv from dotenv import load_dotenv
from playwright.sync_api import Page, BrowserContext from playwright.sync_api import Page, BrowserContext
from tests.e2e.models import ErrorRecord, ModeConfig, diff_errors from tests.e2e.models import ErrorRecord, ModeConfig
from tests.e2e.modes.demo import DEMO from tests.e2e.modes.demo import DEMO
from tests.e2e.modes.cloud import CLOUD from tests.e2e.modes.cloud import CLOUD
from tests.e2e.modes.local import LOCAL from tests.e2e.modes.local import LOCAL

View file

@ -9,9 +9,9 @@ from __future__ import annotations
import pytest import pytest
from tests.e2e.conftest import ( from tests.e2e.conftest import (
wait_for_streamlit, get_page_errors, screenshot_on_fail, wait_for_streamlit, screenshot_on_fail,
) )
from tests.e2e.models import ModeConfig, diff_errors from tests.e2e.models import diff_errors
from tests.e2e.pages.home_page import HomePage from tests.e2e.pages.home_page import HomePage
from tests.e2e.pages.job_review_page import JobReviewPage from tests.e2e.pages.job_review_page import JobReviewPage
from tests.e2e.pages.apply_page import ApplyPage from tests.e2e.pages.apply_page import ApplyPage

View file

@ -7,8 +7,7 @@ Run: pytest tests/e2e/test_smoke.py --mode=demo
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from tests.e2e.conftest import wait_for_streamlit, get_page_errors, get_console_errors, screenshot_on_fail from tests.e2e.conftest import wait_for_streamlit, screenshot_on_fail
from tests.e2e.models import ModeConfig
from tests.e2e.pages.home_page import HomePage from tests.e2e.pages.home_page import HomePage
from tests.e2e.pages.job_review_page import JobReviewPage from tests.e2e.pages.job_review_page import JobReviewPage
from tests.e2e.pages.apply_page import ApplyPage from tests.e2e.pages.apply_page import ApplyPage

View file

@ -1,4 +1,3 @@
from pathlib import Path
import yaml import yaml
from scripts.user_profile import UserProfile from scripts.user_profile import UserProfile

View file

@ -1,12 +1,10 @@
"""Tests for scripts/backup.py — create, list, restore, and multi-instance support.""" """Tests for scripts/backup.py — create, list, restore, and multi-instance support."""
from __future__ import annotations from __future__ import annotations
import json
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest
from scripts.backup import ( from scripts.backup import (
_decrypt_db_to_bytes, _decrypt_db_to_bytes,

View file

@ -1,5 +1,4 @@
"""Tests for BYOK cloud backend detection.""" """Tests for BYOK cloud backend detection."""
import pytest
from scripts.byok_guard import is_cloud_backend, cloud_backends from scripts.byok_guard import is_cloud_backend, cloud_backends

View file

@ -8,7 +8,6 @@ from datetime import timezone
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))

View file

@ -1,7 +1,4 @@
import pytest from unittest.mock import patch
import os
from unittest.mock import patch, MagicMock
from pathlib import Path
def test_resolve_session_is_noop_in_local_mode(monkeypatch): def test_resolve_session_is_noop_in_local_mode(monkeypatch):

View file

@ -1,6 +1,4 @@
# tests/test_cover_letter.py # tests/test_cover_letter.py
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@ -90,7 +88,7 @@ def test_find_similar_letters_returns_top_k():
def test_load_corpus_returns_list(): def test_load_corpus_returns_list():
"""load_corpus returns a list (empty if LETTERS_DIR absent) without crashing.""" """load_corpus returns a list (empty if LETTERS_DIR absent) without crashing."""
from scripts.generate_cover_letter import load_corpus, LETTERS_DIR from scripts.generate_cover_letter import load_corpus
corpus = load_corpus() corpus = load_corpus()
assert isinstance(corpus, list) assert isinstance(corpus, list)

View file

@ -95,7 +95,6 @@ class TestTaskRunnerCoverLetterParams:
patch("sqlite3.connect") as mock_conn, \ patch("sqlite3.connect") as mock_conn, \
patch("scripts.task_runner.generate_cover_letter_fn", mock_generate, create=True): patch("scripts.task_runner.generate_cover_letter_fn", mock_generate, create=True):
import sqlite3
mock_row = MagicMock() mock_row = MagicMock()
mock_row.__iter__ = lambda s: iter(job.items()) mock_row.__iter__ = lambda s: iter(job.items())
mock_row.keys = lambda: job.keys() mock_row.keys = lambda: job.keys()

View file

@ -4,7 +4,6 @@ from email.utils import format_datetime
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
import pytest
import requests import requests

View file

@ -1,7 +1,4 @@
import pytest
import sqlite3 import sqlite3
from pathlib import Path
from unittest.mock import patch
def test_init_db_creates_jobs_table(tmp_path): def test_init_db_creates_jobs_table(tmp_path):

View file

@ -1,7 +1,6 @@
"""Tests for scripts/db_migrate.py — numbered SQL migration runner.""" """Tests for scripts/db_migrate.py — numbered SQL migration runner."""
import sqlite3 import sqlite3
import textwrap
from pathlib import Path from pathlib import Path
import pytest import pytest

View file

@ -1,7 +1,5 @@
"""Tests for resume library db helpers.""" """Tests for resume library db helpers."""
import sqlite3 import sqlite3
import tempfile
from pathlib import Path
import pytest import pytest

View file

@ -1,6 +1,5 @@
"""IS_DEMO write-block guard tests.""" """IS_DEMO write-block guard tests."""
import importlib import importlib
import os
import sqlite3 import sqlite3
import pytest import pytest

View file

@ -1,7 +1,6 @@
"""Tests for app/components/demo_toolbar.py.""" """Tests for app/components/demo_toolbar.py."""
import sys import sys
from pathlib import Path from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))

View file

@ -1,6 +1,5 @@
"""Tests for digest queue API endpoints.""" """Tests for digest queue API endpoints."""
import sqlite3 import sqlite3
import os
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient

View file

@ -1,7 +1,5 @@
"""Tests for new dev-api.py endpoints: stage signals, email sync, signal dismiss.""" """Tests for new dev-api.py endpoints: stage signals, email sync, signal dismiss."""
import sqlite3 import sqlite3
import tempfile
import os
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient

View file

@ -1,23 +1,22 @@
"""Tests for interview prep endpoints: research GET/generate/task, contacts GET.""" """Tests for interview prep endpoints: research GET/generate/task, contacts GET."""
import json
import pytest import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@pytest.fixture @pytest.fixture
def client(): def client(monkeypatch):
import sys import importlib
sys.path.insert(0, "/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa") monkeypatch.delenv("DEMO_MODE", raising=False)
from dev_api import app import dev_api
return TestClient(app) importlib.reload(dev_api)
return TestClient(dev_api.app)
# ── /api/jobs/{id}/research ───────────────────────────────────────────────── # ── /api/jobs/{id}/research ─────────────────────────────────────────────────
def test_get_research_found(client): def test_get_research_found(client):
"""Returns research row (minus raw_output) when present.""" """Returns research row (minus raw_output) when present."""
import sqlite3
mock_row = { mock_row = {
"job_id": 1, "job_id": 1,
"company_brief": "Acme Corp makes anvils.", "company_brief": "Acme Corp makes anvils.",

View file

@ -1,10 +1,9 @@
"""Tests for all settings API endpoints added in Tasks 18.""" """Tests for all settings API endpoints added in Tasks 18."""
import os import os
import sys
import yaml import yaml
import pytest import pytest
from pathlib import Path from pathlib import Path
from unittest.mock import patch, MagicMock from unittest.mock import patch
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
# credential_store.py was merged to main repo — no worktree path manipulation needed # credential_store.py was merged to main repo — no worktree path manipulation needed

View file

@ -1,6 +1,5 @@
import sys import sys
from pathlib import Path from pathlib import Path
import yaml
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))

View file

@ -1,8 +1,6 @@
# tests/test_discover.py # tests/test_discover.py
import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
import pandas as pd import pandas as pd
from pathlib import Path
SAMPLE_JOB = { SAMPLE_JOB = {
"title": "Customer Success Manager", "title": "Customer Success Manager",

View file

@ -1,5 +1,4 @@
"""Unit tests for E2E harness models and helper utilities.""" """Unit tests for E2E harness models and helper utilities."""
import fnmatch
import pytest import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
import time import time

View file

@ -1,7 +1,5 @@
"""Tests for the feedback API backend.""" """Tests for the feedback API backend."""
import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from pathlib import Path
# ── mask_pii ────────────────────────────────────────────────────────────────── # ── mask_pii ──────────────────────────────────────────────────────────────────

View file

@ -1,5 +1,4 @@
"""Tests for imap_sync helpers (no live IMAP connection required).""" """Tests for imap_sync helpers (no live IMAP connection required)."""
import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@ -510,7 +509,7 @@ def test_search_folder_special_gmail_name():
def test_get_existing_message_ids_excludes_null(tmp_path): def test_get_existing_message_ids_excludes_null(tmp_path):
"""NULL message_id rows are excluded from the returned set.""" """NULL message_id rows are excluded from the returned set."""
import sqlite3 import sqlite3
from scripts.db import init_db, insert_job, add_contact from scripts.db import init_db, insert_job
from scripts.imap_sync import _get_existing_message_ids from scripts.imap_sync import _get_existing_message_ids
db_path = tmp_path / "test.db" db_path = tmp_path / "test.db"
@ -980,7 +979,6 @@ def test_scan_todo_label_stage_signal_set_for_non_neutral(tmp_path):
def test_scan_todo_label_body_fallback_matches(tmp_path): def test_scan_todo_label_body_fallback_matches(tmp_path):
"""Company name only in body[:300] still triggers a match (body fallback).""" """Company name only in body[:300] still triggers a match (body fallback)."""
from scripts.db import get_contacts
from scripts.imap_sync import _scan_todo_label from scripts.imap_sync import _scan_todo_label
db_path = tmp_path / "test.db" db_path = tmp_path / "test.db"
@ -1110,7 +1108,6 @@ def test_parse_message_large_body_not_truncated():
def test_parse_message_binary_attachment_no_crash(): def test_parse_message_binary_attachment_no_crash():
"""Email with binary attachment returns a valid dict without crashing.""" """Email with binary attachment returns a valid dict without crashing."""
from scripts.imap_sync import _parse_message from scripts.imap_sync import _parse_message
import email as _email
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.application import MIMEApplication from email.mime.application import MIMEApplication

View file

@ -72,7 +72,6 @@ def test_fields_returns_list_of_dicts():
def test_save_and_load_config(tmp_path): def test_save_and_load_config(tmp_path):
"""save_config writes yaml; load_config reads it back.""" """save_config writes yaml; load_config reads it back."""
from scripts.integrations.base import IntegrationBase from scripts.integrations.base import IntegrationBase
import yaml
class TestIntegration(IntegrationBase): class TestIntegration(IntegrationBase):
name = "savetest" name = "savetest"

View file

@ -1,7 +1,6 @@
import json import json
import pytest import pytest
from pathlib import Path from pathlib import Path
from unittest.mock import patch, MagicMock
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
import jwt as pyjwt import jwt as pyjwt

View file

@ -1,8 +1,6 @@
import json import json
import pytest import pytest
from pathlib import Path
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
import jwt as pyjwt import jwt as pyjwt

View file

@ -1,4 +1,3 @@
from pathlib import Path
import yaml import yaml
from scripts.user_profile import UserProfile from scripts.user_profile import UserProfile
from scripts.generate_llm_config import apply_service_urls from scripts.generate_llm_config import apply_service_urls

View file

@ -110,7 +110,7 @@ def test_complete_without_images_skips_vision_service(tmp_path):
"""When images=None, vision_service backend is skipped.""" """When images=None, vision_service backend is skipped."""
import yaml import yaml
from scripts.llm_router import LLMRouter from scripts.llm_router import LLMRouter
from unittest.mock import patch, MagicMock from unittest.mock import patch
cfg = { cfg = {
"fallback_order": ["vision_service"], "fallback_order": ["vision_service"],

View file

@ -1,7 +1,7 @@
"""Tests for Peregrine's LLMRouter shim — priority fallback logic.""" """Tests for Peregrine's LLMRouter shim — priority fallback logic."""
import sys import sys
from pathlib import Path from pathlib import Path
from unittest.mock import patch, MagicMock, call from unittest.mock import patch, MagicMock
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
@ -54,7 +54,6 @@ def test_uses_local_yaml_when_present():
def test_falls_through_to_env_when_no_yamls(): def test_falls_through_to_env_when_no_yamls():
"""When no yaml files exist, super().__init__ is called with no args (env-var path).""" """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 from circuitforge_core.llm import LLMRouter as _CoreLLMRouter
captured = {} captured = {}

View file

@ -1,4 +1,3 @@
import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock

View file

@ -1,6 +1,4 @@
"""Integration tests for messaging endpoints.""" """Integration tests for messaging endpoints."""
import os
from pathlib import Path
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient

View file

@ -4,7 +4,6 @@ import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
import yaml
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))

View file

@ -1,10 +1,8 @@
"""Tests for scripts/preflight.py additions: dual-GPU service table, size warning, VRAM check.""" """Tests for scripts/preflight.py additions: dual-GPU service table, size warning, VRAM check."""
import pytest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import yaml import yaml
import tempfile import tempfile
import os
# ── Service table ────────────────────────────────────────────────────────────── # ── Service table ──────────────────────────────────────────────────────────────

View file

@ -1,7 +1,7 @@
"""Tests: preflight writes OLLAMA_HOST to .env when Ollama is adopted from host.""" """Tests: preflight writes OLLAMA_HOST to .env when Ollama is adopted from host."""
import sys import sys
from pathlib import Path from pathlib import Path
from unittest.mock import patch, call from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))

283
tests/test_rate_limiting.py Normal file
View file

@ -0,0 +1,283 @@
"""Tests for per-user rate limiting on LLM generation endpoints.
Covers:
- _rate_key() in demo mode returns unique per-request key (no rate limiting)
- _rate_key() in cloud mode returns user_id segment from DB path
- _rate_key() in local mode falls back to client IP address
- rate_limit_exceeded_handler() returns 429 with Retry-After header
- Integration: hitting rate limit on a decorated endpoint returns 429
"""
import json
import sqlite3
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from limits import parse as _limits_parse
from slowapi.errors import RateLimitExceeded
from slowapi.wrappers import Limit as _LimitWrapper
from starlette.requests import Request
from scripts.rate_limit import _rate_key, rate_limit_exceeded_handler
# ── Helpers ───────────────────────────────────────────────────────────────────
def _make_request(client_ip: str = "1.2.3.4") -> MagicMock:
"""Return a minimal mock Request with a client IP."""
req = MagicMock(spec=Request)
req.client = MagicMock()
req.client.host = client_ip
req.headers = {}
req.scope = {"type": "http"}
return req
def _make_rate_limit_exceeded(spec: str = "20/hour") -> RateLimitExceeded:
"""Construct a valid RateLimitExceeded (slowapi 0.1.9+ requires a Limit wrapper)."""
limit_item = _limits_parse(spec)
wrapper = _LimitWrapper(
limit=limit_item,
key_func=lambda r: "test",
scope=None,
per_method=False,
methods=None,
error_message=None,
exempt_when=None,
cost=1,
override_defaults=False,
)
return RateLimitExceeded(wrapper)
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture()
def tmp_db(tmp_path):
"""Create a minimal staging.db in tmp_path and return its string path."""
db_path = tmp_path / "staging.db"
con = sqlite3.connect(str(db_path))
con.executescript("""
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY,
title TEXT, company TEXT, url TEXT, location TEXT,
is_remote INTEGER DEFAULT 0, salary TEXT,
match_score REAL, keyword_gaps TEXT, status TEXT,
interview_date TEXT, rejection_stage TEXT,
applied_at TEXT, phone_screen_at TEXT, interviewing_at TEXT,
offer_at TEXT, hired_at TEXT, survey_at TEXT
);
CREATE TABLE IF NOT EXISTS background_tasks (
id INTEGER PRIMARY KEY,
task_type TEXT,
job_id INTEGER,
status TEXT DEFAULT 'queued',
stage TEXT,
error TEXT,
params TEXT,
finished_at TEXT
);
""")
con.close()
return str(db_path)
@pytest.fixture()
def client(tmp_db, monkeypatch):
"""TestClient wired to a fresh isolated DB."""
monkeypatch.setenv("STAGING_DB", tmp_db)
import dev_api
monkeypatch.setattr(dev_api, "DB_PATH", tmp_db)
monkeypatch.setattr(
dev_api,
"_request_db",
type("CV", (), {"get": lambda self: tmp_db, "set": lambda *a: None})(),
)
return TestClient(dev_api.app)
# ── _rate_key(): demo mode ────────────────────────────────────────────────────
class TestRateKeyDemoMode:
def test_returns_unique_key_per_request(self):
"""In demo mode each request gets a unique key so no limiting occurs."""
req1 = _make_request()
req2 = _make_request()
with patch("dev_api.IS_DEMO", True), patch("dev_api._CLOUD_MODE", False):
key1 = _rate_key(req1)
key2 = _rate_key(req2)
assert key1.startswith("demo-")
assert key2.startswith("demo-")
assert key1 != key2 # unique per request object
def test_key_does_not_use_client_ip(self):
"""Demo key must not equal the client IP."""
req = _make_request(client_ip="9.9.9.9")
with patch("dev_api.IS_DEMO", True), patch("dev_api._CLOUD_MODE", False):
key = _rate_key(req)
assert "9.9.9.9" not in key
# ── _rate_key(): cloud mode ───────────────────────────────────────────────────
class TestRateKeyCloudMode:
def test_returns_user_id_from_db_path(self, tmp_path):
"""Cloud mode extracts user_id (3rd-from-end path segment)."""
cloud_db = str(tmp_path / "abc-user-123" / "peregrine" / "staging.db")
req = _make_request()
with (
patch("dev_api.IS_DEMO", False),
patch("dev_api._CLOUD_MODE", True),
patch("dev_api._request_db") as mock_cv,
):
mock_cv.get.return_value = cloud_db
key = _rate_key(req)
assert key == "abc-user-123"
def test_falls_back_to_ip_when_db_path_is_none(self):
"""Cloud mode without a DB path (unauthenticated) falls back to IP."""
req = _make_request(client_ip="10.0.0.1")
with (
patch("dev_api.IS_DEMO", False),
patch("dev_api._CLOUD_MODE", True),
patch("dev_api._request_db") as mock_cv,
):
mock_cv.get.return_value = None
key = _rate_key(req)
assert key == "10.0.0.1"
# ── _rate_key(): local mode ───────────────────────────────────────────────────
class TestRateKeyLocalMode:
def test_returns_client_ip(self):
"""Local (non-cloud, non-demo) mode uses the remote client IP."""
req = _make_request(client_ip="192.168.1.50")
with patch("dev_api.IS_DEMO", False), patch("dev_api._CLOUD_MODE", False):
key = _rate_key(req)
assert key == "192.168.1.50"
def test_different_ips_produce_different_keys(self):
"""Two distinct client IPs produce distinct rate limit keys."""
req_a = _make_request(client_ip="10.0.0.1")
req_b = _make_request(client_ip="10.0.0.2")
with patch("dev_api.IS_DEMO", False), patch("dev_api._CLOUD_MODE", False):
key_a = _rate_key(req_a)
key_b = _rate_key(req_b)
assert key_a != key_b
# ── rate_limit_exceeded_handler() ─────────────────────────────────────────────
class TestRateLimitExceededHandler:
def test_returns_429_status(self):
"""Handler always returns HTTP 429."""
req = _make_request()
exc = _make_rate_limit_exceeded("20/hour")
response = rate_limit_exceeded_handler(req, exc)
assert response.status_code == 429
def test_body_has_error_field(self):
"""Response body includes error: rate_limit_exceeded."""
req = _make_request()
exc = _make_rate_limit_exceeded("20/hour")
response = rate_limit_exceeded_handler(req, exc)
body = json.loads(response.body)
assert body["error"] == "rate_limit_exceeded"
def test_body_has_retry_after_field(self):
"""Response body includes retry_after value."""
req = _make_request()
exc = _make_rate_limit_exceeded("20/hour")
response = rate_limit_exceeded_handler(req, exc)
body = json.loads(response.body)
assert "retry_after" in body
def test_retry_after_header_present(self):
"""Retry-After HTTP header is set on the response."""
req = _make_request()
exc = _make_rate_limit_exceeded("20/hour")
response = rate_limit_exceeded_handler(req, exc)
assert "Retry-After" in response.headers
def test_retry_after_header_matches_body(self):
"""Retry-After header value matches the retry_after field in the body."""
req = _make_request()
exc = _make_rate_limit_exceeded("20/hour")
response = rate_limit_exceeded_handler(req, exc)
body = json.loads(response.body)
assert response.headers["Retry-After"] == str(body["retry_after"])
# ── Integration: 429 on rate-limited endpoints ────────────────────────────────
def _patch_limiter_to_raise(exc: RateLimitExceeded):
"""Context manager: make the slowapi limiter fire for any request."""
return patch(
"slowapi.extension.Limiter._check_request_limit",
side_effect=exc,
)
class TestRateLimitIntegration:
"""Verify that when the limiter fires, the app returns 429 via the exception handler."""
def test_cover_letter_generate_returns_429_on_limit(self, client):
"""When the rate limiter triggers, the cover letter endpoint returns 429."""
exc = _make_rate_limit_exceeded("20/hour")
with _patch_limiter_to_raise(exc):
resp = client.post("/api/jobs/1/cover_letter/generate")
assert resp.status_code == 429
def test_research_generate_returns_429_on_limit(self, client):
"""When the rate limiter triggers, the research endpoint returns 429."""
exc = _make_rate_limit_exceeded("10/hour")
with _patch_limiter_to_raise(exc):
resp = client.post("/api/jobs/1/research/generate")
assert resp.status_code == 429
def test_qa_suggest_returns_429_on_limit(self, client):
"""When the rate limiter triggers, the QA suggest endpoint returns 429."""
exc = _make_rate_limit_exceeded("60/hour")
with _patch_limiter_to_raise(exc):
resp = client.post(
"/api/jobs/1/qa/suggest",
json={"question": "Why do you want this job?", "items": []},
)
assert resp.status_code == 429
def test_survey_analyze_returns_429_on_limit(self, client):
"""When the rate limiter triggers, the survey analyze endpoint returns 429."""
exc = _make_rate_limit_exceeded("30/hour")
with _patch_limiter_to_raise(exc):
resp = client.post(
"/api/jobs/1/survey/analyze",
json={"text": "Q: ...", "mode": "quick"},
)
assert resp.status_code == 429
def test_cover_letter_generate_succeeds_when_not_limited(self, client):
"""Cover letter generate endpoint works normally when not rate-limited."""
with patch("scripts.task_runner.submit_task", return_value=(1, True)):
resp = client.post("/api/jobs/1/cover_letter/generate")
# 200 = task queued; 403 = demo/cloud guard; 404/422 = DB/payload issue
# Any non-5xx, non-429 response means the limiter did NOT block the request
assert resp.status_code in (200, 403, 404, 422)
def test_429_response_body_has_error_key(self, client):
"""429 responses from rate-limited endpoints include the error key."""
exc = _make_rate_limit_exceeded("20/hour")
with _patch_limiter_to_raise(exc):
resp = client.post("/api/jobs/1/cover_letter/generate")
assert resp.status_code == 429
body = resp.json()
assert body.get("error") == "rate_limit_exceeded"
def test_429_response_has_retry_after_header(self, client):
"""429 responses include a Retry-After header."""
exc = _make_rate_limit_exceeded("20/hour")
with _patch_limiter_to_raise(exc):
resp = client.post("/api/jobs/1/cover_letter/generate")
assert resp.status_code == 429
assert "retry-after" in resp.headers or "Retry-After" in resp.headers

View file

@ -4,7 +4,6 @@
Set CF_RERANKER_MOCK=1 to avoid loading real model weights during tests. Set CF_RERANKER_MOCK=1 to avoid loading real model weights during tests.
""" """
import os import os
import pytest
from unittest.mock import patch from unittest.mock import patch
os.environ["CF_RERANKER_MOCK"] = "1" os.environ["CF_RERANKER_MOCK"] = "1"

View file

@ -1,8 +1,7 @@
# tests/test_resume_optimizer.py # tests/test_resume_optimizer.py
"""Tests for scripts/resume_optimizer.py""" """Tests for scripts/resume_optimizer.py"""
import json import json
import pytest from unittest.mock import patch
from unittest.mock import MagicMock, patch
# ── Fixtures ───────────────────────────────────────────────────────────────── # ── Fixtures ─────────────────────────────────────────────────────────────────

View file

@ -1,6 +1,4 @@
"""Unit tests for scripts.resume_sync — format transform between library and profile.""" """Unit tests for scripts.resume_sync — format transform between library and profile."""
import json
import pytest
from scripts.resume_sync import ( from scripts.resume_sync import (
library_to_profile_content, library_to_profile_content,
profile_to_library, profile_to_library,

View file

@ -1,7 +1,5 @@
"""Integration tests for resume library<->profile sync endpoints.""" """Integration tests for resume library<->profile sync endpoints."""
import json import json
import os
from pathlib import Path
import pytest import pytest
import yaml import yaml

View file

@ -1,9 +1,6 @@
"""Tests for /api/resumes/* endpoints.""" """Tests for /api/resumes/* endpoints."""
import json
import io import io
import sqlite3 import sqlite3
import tempfile
from pathlib import Path
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient

View file

@ -1,7 +1,5 @@
# tests/test_sync.py # tests/test_sync.py
import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from pathlib import Path
SAMPLE_FM = { SAMPLE_FM = {

View file

@ -1,7 +1,5 @@
import threading
import time import time
import pytest import pytest
from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import sqlite3 import sqlite3
@ -178,7 +176,6 @@ def test_submit_task_actually_completes(tmp_path):
def test_run_task_enrich_craigslist_success(tmp_path): def test_run_task_enrich_craigslist_success(tmp_path):
"""enrich_craigslist task calls enrich_craigslist_fields and marks completed.""" """enrich_craigslist task calls enrich_craigslist_fields and marks completed."""
from scripts.db import init_db, insert_job, insert_task, get_task_for_job from scripts.db import init_db, insert_job, insert_task, get_task_for_job
from unittest.mock import MagicMock
db = tmp_path / "test.db" db = tmp_path / "test.db"
init_db(db) init_db(db)
job_id = insert_job(db, { job_id = insert_job(db, {
@ -200,7 +197,7 @@ def test_run_task_enrich_craigslist_success(tmp_path):
def test_scrape_url_submits_enrich_craigslist_for_craigslist_job(tmp_path): def test_scrape_url_submits_enrich_craigslist_for_craigslist_job(tmp_path):
"""After scrape_url completes for a craigslist job with empty company, enrich_craigslist is queued.""" """After scrape_url completes for a craigslist job with empty company, enrich_craigslist is queued."""
from scripts.db import init_db, insert_job, insert_task, get_task_for_job from scripts.db import init_db, insert_job, insert_task
db = tmp_path / "test.db" db = tmp_path / "test.db"
init_db(db) init_db(db)
job_id = insert_job(db, { job_id = insert_job(db, {
@ -285,7 +282,7 @@ def test_wizard_generate_null_params_fails(tmp_path):
def test_wizard_generate_stores_result_as_json(tmp_path): def test_wizard_generate_stores_result_as_json(tmp_path):
"""wizard_generate stores result JSON in error field on success.""" """wizard_generate stores result JSON in error field on success."""
from unittest.mock import patch, MagicMock from unittest.mock import patch
db = tmp_path / "t.db" db = tmp_path / "t.db"
from scripts.db import init_db, insert_task from scripts.db import init_db, insert_task
init_db(db) init_db(db)
@ -311,7 +308,7 @@ def test_wizard_generate_stores_result_as_json(tmp_path):
def test_wizard_generate_feedback_appended_to_prompt(tmp_path): def test_wizard_generate_feedback_appended_to_prompt(tmp_path):
"""feedback and previous_result fields in input_data are appended to the prompt.""" """feedback and previous_result fields in input_data are appended to the prompt."""
from unittest.mock import patch, MagicMock from unittest.mock import patch
db = tmp_path / "t.db" db = tmp_path / "t.db"
from scripts.db import init_db, insert_task from scripts.db import init_db, insert_task
init_db(db) init_db(db)

View file

@ -3,7 +3,6 @@
import sqlite3 import sqlite3
import threading import threading
from collections import deque from collections import deque
from pathlib import Path
import pytest import pytest
@ -192,7 +191,6 @@ def test_max_queue_depth_logs_warning(tmp_db, caplog):
"""Queue depth overflow logs a WARNING.""" """Queue depth overflow logs a WARNING."""
import logging import logging
from scripts.db import insert_task from scripts.db import insert_task
from scripts.task_scheduler import TaskSpec
s = TaskScheduler(tmp_db, _noop_run_task) s = TaskScheduler(tmp_db, _noop_run_task)
s._max_queue_depth = 0 # immediately at limit s._max_queue_depth = 0 # immediately at limit

View file

@ -1,6 +1,4 @@
import pytest from unittest.mock import patch, MagicMock
import os
from unittest.mock import patch, MagicMock, call
def test_no_op_in_local_mode(monkeypatch): def test_no_op_in_local_mode(monkeypatch):

View file

@ -1,7 +1,7 @@
# tests/test_user_profile.py # tests/test_user_profile.py
import pytest import pytest
from pathlib import Path from pathlib import Path
import tempfile, yaml import yaml
from scripts.user_profile import UserProfile from scripts.user_profile import UserProfile
@pytest.fixture @pytest.fixture

361
tests/test_wizard_ai.py Normal file
View file

@ -0,0 +1,361 @@
"""Tests for AI interview wizard endpoints (POST /api/wizard/ai/*)."""
import json
import sys
import yaml
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock
# ── 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
from fastapi.testclient import TestClient
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 — byokUnlocked field ──────────────────────────────────
class TestAppConfigByokField:
def test_byok_unlocked_false_when_no_llm_configured(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("app.wizard.tiers.has_configured_llm", return_value=False):
r = client.get("/api/config/app")
assert r.status_code == 200
assert r.json()["byokUnlocked"] is False
def test_byok_unlocked_true_when_llm_configured(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("app.wizard.tiers.has_configured_llm", return_value=True):
r = client.get("/api/config/app")
assert r.status_code == 200
assert r.json()["byokUnlocked"] is True
# ── POST /api/wizard/ai/interview — tier gate ─────────────────────────────────
class TestWizardAIInterviewTierGate:
def test_returns_402_when_tier_blocked(self, client):
"""Free tier with no BYOK: expect 402."""
with patch("dev_api._get_effective_tier", return_value="free"):
with patch("app.wizard.tiers.has_configured_llm", return_value=False):
r = client.post(
"/api/wizard/ai/interview",
json={"history": [{"role": "user", "content": "Hello"}]},
)
assert r.status_code == 402
assert r.json()["detail"]["error"] == "tier_required"
def test_returns_402_for_free_tier_without_byok(self, client):
"""Explicit check that free tier without LLM configured is gated."""
with patch("dev_api._get_effective_tier", return_value="free"):
with patch("app.wizard.tiers.has_configured_llm", return_value=False):
r = client.post(
"/api/wizard/ai/interview",
json={"history": [], "profile_so_far": {}},
)
assert r.status_code == 402
def test_free_tier_with_byok_is_allowed(self, client):
"""Free tier with BYOK configured: tier gate passes (mocked LLM response)."""
llm_reply = json.dumps({
"reply": "Hello! What's your name?",
"extracted_fields": {},
"complete": False,
})
with patch("dev_api._get_effective_tier", return_value="free"):
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
with patch("scripts.llm_router.LLMRouter") as mock_cls:
mock_cls.return_value.complete.return_value = llm_reply
r = client.post(
"/api/wizard/ai/interview",
json={"history": [], "profile_so_far": {}},
)
assert r.status_code == 200
# ── POST /api/wizard/ai/interview — LLM mocked responses ─────────────────────
class TestWizardAIInterviewLLM:
def _paid_byok_patches(self):
"""Context managers for paid tier + BYOK."""
return (
patch("dev_api._get_effective_tier", return_value="paid"),
patch("app.wizard.tiers.has_configured_llm", return_value=True),
)
def test_returns_valid_reply_structure(self, client):
llm_reply = json.dumps({
"reply": "Great to meet you! What's your preferred contact email?",
"extracted_fields": {"name": "Alex Rivera"},
"complete": False,
})
with patch("dev_api._get_effective_tier", return_value="paid"):
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
with patch("scripts.llm_router.LLMRouter") as mock_cls:
mock_cls.return_value.complete.return_value = llm_reply
r = client.post(
"/api/wizard/ai/interview",
json={
"history": [
{"role": "user", "content": "My name is Alex Rivera"},
],
},
)
assert r.status_code == 200
body = r.json()
assert body["reply"] == "Great to meet you! What's your preferred contact email?"
assert body["extracted_fields"] == {"name": "Alex Rivera"}
assert body["complete"] is False
def test_returns_complete_true_when_llm_signals_done(self, client):
llm_reply = json.dumps({
"reply": "You're all set! Your profile is complete.",
"extracted_fields": {
"name": "Alex",
"email": "alex@example.com",
"career_summary": "Backend engineer with 5 years experience.",
},
"complete": True,
})
with patch("dev_api._get_effective_tier", return_value="paid"):
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
with patch("scripts.llm_router.LLMRouter") as mock_cls:
mock_cls.return_value.complete.return_value = llm_reply
r = client.post(
"/api/wizard/ai/interview",
json={
"history": [
{"role": "user", "content": "I'm done"},
],
},
)
assert r.status_code == 200
body = r.json()
assert body["complete"] is True
assert "name" in body["extracted_fields"]
def test_fallback_when_llm_returns_non_json(self, client):
"""If LLM returns non-JSON, the endpoint still returns 200 with raw reply."""
with patch("dev_api._get_effective_tier", return_value="paid"):
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
with patch("scripts.llm_router.LLMRouter") as mock_cls:
mock_cls.return_value.complete.return_value = "Hello, what is your name?"
r = client.post(
"/api/wizard/ai/interview",
json={"history": []},
)
assert r.status_code == 200
body = r.json()
assert body["reply"] == "Hello, what is your name?"
assert body["extracted_fields"] == {}
assert body["complete"] is False
def test_history_passed_to_llm(self, client):
"""Verify the history turns are included in the prompt sent to the LLM."""
llm_reply = json.dumps({"reply": "OK", "extracted_fields": {}, "complete": False})
captured_calls = []
with patch("dev_api._get_effective_tier", return_value="paid"):
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
with patch("scripts.llm_router.LLMRouter") as mock_cls:
mock_cls.return_value.complete.side_effect = (
lambda prompt, system=None: (captured_calls.append(prompt) or llm_reply)
)
client.post(
"/api/wizard/ai/interview",
json={
"history": [
{"role": "user", "content": "I am Alex"},
{"role": "assistant", "content": "Nice to meet you Alex!"},
{"role": "user", "content": "My email is alex@test.com"},
],
},
)
assert len(captured_calls) == 1
prompt = captured_calls[0]
assert "I am Alex" in prompt
assert "alex@test.com" in prompt
def test_profile_so_far_injected_into_prompt(self, client):
"""profile_so_far fields must appear in the prompt sent to the LLM."""
llm_reply = json.dumps({"reply": "Got it!", "extracted_fields": {}, "complete": False})
captured_calls = []
with patch("dev_api._get_effective_tier", return_value="paid"):
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
with patch("scripts.llm_router.LLMRouter") as mock_cls:
mock_cls.return_value.complete.side_effect = (
lambda prompt, system=None: (captured_calls.append(prompt) or llm_reply)
)
client.post(
"/api/wizard/ai/interview",
json={
"history": [
{"role": "user", "content": "I am Alex"},
],
"profile_so_far": {
"name": "Alex Rivera",
"email": "alex@example.com",
},
},
)
assert len(captured_calls) == 1
prompt = captured_calls[0]
assert "Alex Rivera" in prompt
assert "alex@example.com" in prompt
def test_llm_error_returns_503(self, client):
"""If LLM raises, the endpoint returns 503."""
with patch("dev_api._get_effective_tier", return_value="paid"):
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
with patch("scripts.llm_router.LLMRouter") as mock_cls:
mock_cls.return_value.complete.side_effect = RuntimeError("no backends")
r = client.post(
"/api/wizard/ai/interview",
json={"history": [{"role": "user", "content": "hi"}]},
)
assert r.status_code == 503
# ── POST /api/wizard/ai/finalize ──────────────────────────────────────────────
class TestWizardAIFinalize:
def test_merges_allowed_fields_into_user_yaml(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {"tier": "paid", "wizard_complete": True})
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
r = client.post(
"/api/wizard/ai/finalize",
json={
"profile": {
"name": "Jordan Lee",
"email": "jordan@example.com",
"career_summary": "Full-stack developer with 8 years experience.",
"candidate_voice": "warm and conversational",
}
},
)
assert r.status_code == 200
body = r.json()
assert body["saved"] is True
assert set(body["fields"]) == {"name", "email", "career_summary", "candidate_voice"}
saved = _read_user_yaml(yaml_path)
assert saved["name"] == "Jordan Lee"
assert saved["email"] == "jordan@example.com"
assert saved["career_summary"] == "Full-stack developer with 8 years experience."
assert saved["candidate_voice"] == "warm and conversational"
def test_does_not_clobber_existing_non_wizard_keys(self, client, tmp_path):
"""Keys like tier, wizard_complete must not be overwritten by finalize."""
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {
"tier": "premium",
"wizard_complete": True,
"inference_profile": "single-gpu",
})
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
r = client.post(
"/api/wizard/ai/finalize",
json={
"profile": {
"name": "Sam Park",
"tier": "free", # attempt to downgrade — must be blocked
"wizard_complete": False, # attempt to reset — must be blocked
}
},
)
assert r.status_code == 200
saved = _read_user_yaml(yaml_path)
# Non-wizard keys are preserved
assert saved["tier"] == "premium"
assert saved["wizard_complete"] is True
assert saved["inference_profile"] == "single-gpu"
# Allowed wizard key is written
assert saved["name"] == "Sam Park"
def test_unknown_keys_are_silently_ignored(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
r = client.post(
"/api/wizard/ai/finalize",
json={
"profile": {
"email": "test@example.com",
"injected_field": "should be ignored",
"admin": True,
}
},
)
assert r.status_code == 200
saved = _read_user_yaml(yaml_path)
assert saved["email"] == "test@example.com"
assert "injected_field" not in saved
assert "admin" not in saved
def test_all_allowed_fields_are_written(self, client, tmp_path):
"""All allowed wizard fields are accepted when provided."""
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
full_profile = {
"name": "Casey Morgan",
"email": "casey@example.com",
"career_summary": "Designer turned product manager.",
"candidate_voice": "professional and direct",
"mission_preferences": ["education", "social_impact"],
"candidate_accessibility_focus": True,
"candidate_lgbtq_focus": True,
"linkedin": "https://linkedin.com/in/casey",
}
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
r = client.post("/api/wizard/ai/finalize", json={"profile": full_profile})
assert r.status_code == 200
saved = _read_user_yaml(yaml_path)
for key, value in full_profile.items():
assert saved[key] == value, f"Expected {key}={value!r}, got {saved.get(key)!r}"
def test_empty_profile_returns_saved_true(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {"name": "Existing"})
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
r = client.post("/api/wizard/ai/finalize", json={"profile": {}})
assert r.status_code == 200
assert r.json()["saved"] is True
assert r.json()["fields"] == []
# Existing data is preserved
assert _read_user_yaml(yaml_path)["name"] == "Existing"
def test_mission_preferences_list_written_correctly(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
r = client.post(
"/api/wizard/ai/finalize",
json={"profile": {"mission_preferences": ["music", "animal_welfare"]}},
)
assert r.status_code == 200
saved = _read_user_yaml(yaml_path)
assert saved["mission_preferences"] == ["music", "animal_welfare"]

View file

@ -4,7 +4,7 @@ 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, BYOK_UNLOCKABLE
def test_tiers_list(): def test_tiers_list():

View file

@ -352,8 +352,8 @@ with tab_fetch:
if not accounts: if not accounts:
st.warning( st.warning(
f"No accounts configured. Copy `config/label_tool.yaml.example` → " "No accounts configured. Copy `config/label_tool.yaml.example` → "
f"`config/label_tool.yaml` and add your IMAP accounts.", "`config/label_tool.yaml` and add your IMAP accounts.",
icon="⚠️", icon="⚠️",
) )
else: else:
@ -625,7 +625,7 @@ with tab_stats:
st.markdown(f"**{len(labeled)} labeled emails total**") st.markdown(f"**{len(labeled)} labeled emails total**")
# Show known labels first, then any custom labels # Show known labels first, then any custom labels
all_display_labels = list(LABELS) + [l for l in counts if l not in LABELS] all_display_labels = list(LABELS) + [lbl for lbl in counts if lbl not in LABELS]
max_count = max(counts.values()) if counts else 1 max_count = max(counts.values()) if counts else 1
for lbl in all_display_labels: for lbl in all_display_labels:
if lbl not in counts: if lbl not in counts:

275
web/package-lock.json generated
View file

@ -346,9 +346,9 @@
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -363,9 +363,9 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -380,9 +380,9 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -397,9 +397,9 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -414,9 +414,9 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -431,9 +431,9 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -448,9 +448,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -465,9 +465,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -482,9 +482,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -499,9 +499,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -516,9 +516,9 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -533,9 +533,9 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -550,9 +550,9 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
@ -567,9 +567,9 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -584,9 +584,9 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -601,9 +601,9 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -618,9 +618,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -635,9 +635,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -652,9 +652,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -669,9 +669,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -686,9 +686,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -703,9 +703,9 @@
} }
}, },
"node_modules/@esbuild/openharmony-arm64": { "node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -720,9 +720,9 @@
} }
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -737,9 +737,9 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -754,9 +754,9 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -771,9 +771,9 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2728,9 +2728,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2949,9 +2949,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/defu": { "node_modules/defu": {
"version": "6.1.4", "version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -3032,9 +3032,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.4", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
@ -3045,32 +3045,32 @@
"node": ">=18" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.4", "@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.4", "@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.4", "@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.4", "@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.4", "@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.4", "@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.4", "@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.4", "@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.4", "@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.4", "@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.4" "@esbuild/win32-x64": "0.27.7"
} }
}, },
"node_modules/estree-walker": { "node_modules/estree-walker": {
@ -3325,14 +3325,11 @@
} }
}, },
"node_modules/js-cookie": { "node_modules/js-cookie": {
"version": "3.0.5", "version": "3.0.8",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"engines": {
"node": ">=14"
}
}, },
"node_modules/jsdom": { "node_modules/jsdom": {
"version": "28.1.0", "version": "28.1.0",
@ -3500,9 +3497,9 @@
} }
}, },
"node_modules/marked": { "node_modules/marked": {
"version": "18.0.0", "version": "18.0.5",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz", "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz",
"integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==", "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"marked": "bin/marked.js" "marked": "bin/marked.js"
@ -3586,9 +3583,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -3787,9 +3784,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@ -3831,9 +3828,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.8", "version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@ -3850,7 +3847,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.12",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@ -4484,9 +4481,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -4987,9 +4984,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.8.2", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"

View file

@ -0,0 +1,5 @@
const DOCS_BASE = 'https://docs.circuitforge.tech/peregrine'
export function useDocsUrl(path: string): string {
return `${DOCS_BASE}/${path}`
}

View file

@ -37,6 +37,8 @@ export const router = createRouter({
{ path: 'developer', component: () => import('../views/settings/DeveloperView.vue') }, { path: 'developer', component: () => import('../views/settings/DeveloperView.vue') },
], ],
}, },
// AI profile wizard — post-setup settings entry point (correctly blocked by wizard gate during onboarding)
{ path: '/wizard/ai-profile', component: () => import('../views/wizard/WizardAIView.vue') },
// Onboarding wizard — full-page layout, no AppNav // Onboarding wizard — full-page layout, no AppNav
{ {
path: '/setup', path: '/setup',

View file

@ -2,7 +2,7 @@ import { ref } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { useApiFetch } from '../composables/useApi' import { useApiFetch } from '../composables/useApi'
export type Tier = 'free' | 'paid' | 'premium' | 'ultra' export type Tier = 'free' | 'paid' | 'premium'
export type InferenceProfile = 'remote' | 'cpu' | 'single-gpu' | 'dual-gpu' export type InferenceProfile = 'remote' | 'cpu' | 'single-gpu' | 'dual-gpu'
export const useAppConfigStore = defineStore('appConfig', () => { export const useAppConfigStore = defineStore('appConfig', () => {
@ -13,6 +13,7 @@ export const useAppConfigStore = defineStore('appConfig', () => {
const inferenceProfile = ref<InferenceProfile>('cpu') const inferenceProfile = ref<InferenceProfile>('cpu')
const isDemo = ref(false) const isDemo = ref(false)
const wizardComplete = ref(true) // optimistic default — guard corrects on load const wizardComplete = ref(true) // optimistic default — guard corrects on load
const byokUnlocked = ref(false)
const loaded = ref(false) const loaded = ref(false)
const devTierOverride = ref(localStorage.getItem('dev_tier_override') ?? '') const devTierOverride = ref(localStorage.getItem('dev_tier_override') ?? '')
@ -20,7 +21,7 @@ export const useAppConfigStore = defineStore('appConfig', () => {
const { data } = await useApiFetch<{ const { data } = await useApiFetch<{
isCloud: boolean; isDemo: boolean; isDevMode: boolean; tier: Tier isCloud: boolean; isDemo: boolean; isDevMode: boolean; tier: Tier
contractedClient: boolean; inferenceProfile: InferenceProfile contractedClient: boolean; inferenceProfile: InferenceProfile
wizardComplete: boolean wizardComplete: boolean; byokUnlocked: boolean
}>('/api/config/app') }>('/api/config/app')
if (!data) return if (!data) return
isCloud.value = data.isCloud isCloud.value = data.isCloud
@ -30,6 +31,7 @@ export const useAppConfigStore = defineStore('appConfig', () => {
contractedClient.value = data.contractedClient contractedClient.value = data.contractedClient
inferenceProfile.value = data.inferenceProfile inferenceProfile.value = data.inferenceProfile
wizardComplete.value = data.wizardComplete ?? true wizardComplete.value = data.wizardComplete ?? true
byokUnlocked.value = data.byokUnlocked ?? false
loaded.value = true loaded.value = true
} }
@ -43,5 +45,5 @@ export const useAppConfigStore = defineStore('appConfig', () => {
} }
} }
return { isCloud, isDemo, isDevMode, wizardComplete, tier, contractedClient, inferenceProfile, loaded, load, devTierOverride, setDevTierOverride } return { isCloud, isDemo, isDevMode, wizardComplete, byokUnlocked, tier, contractedClient, inferenceProfile, loaded, load, devTierOverride, setDevTierOverride }
}) })

View file

@ -0,0 +1,57 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApiFetch } from '../../composables/useApi'
export const SYNC_DATA_CLASSES = [
{ key: 'peregrine:dismissed', label: 'Dismissed job IDs', description: 'Hides jobs you have already reviewed across devices.' },
{ key: 'peregrine:drafts', label: 'Cover letter drafts', description: 'Saves in-progress drafts so you can continue on another device.' },
] as const
export type SyncDataClass = typeof SYNC_DATA_CLASSES[number]['key']
export interface SyncPref {
data_class: string
enabled: boolean
}
export const useSyncStore = defineStore('sync', () => {
const prefs = ref<Record<string, boolean>>({})
const loading = ref(false)
const saving = ref<string | null>(null)
const wiping = ref(false)
const error = ref<string | null>(null)
async function loadPrefs() {
loading.value = true
error.value = null
const { data, error: err } = await useApiFetch<SyncPref[]>('/sync/prefs')
loading.value = false
if (err) { error.value = 'Failed to load sync preferences.'; return }
const map: Record<string, boolean> = {}
for (const p of data ?? []) map[p.data_class] = p.enabled
prefs.value = map
}
async function setPref(dataClass: string, enabled: boolean) {
saving.value = dataClass
error.value = null
const { error: err } = await useApiFetch('/sync/prefs', {
method: 'PATCH',
body: JSON.stringify({ data_class: dataClass, enabled }),
})
saving.value = null
if (err) { error.value = `Failed to update sync preference for ${dataClass}.`; return }
prefs.value = { ...prefs.value, [dataClass]: enabled }
}
async function wipeAll() {
wiping.value = true
error.value = null
const { error: err } = await useApiFetch('/sync/all', { method: 'DELETE' })
wiping.value = false
if (err) { error.value = 'Failed to delete sync data.'; return }
prefs.value = {}
}
return { prefs, loading, saving, wiping, error, loadPrefs, setPref, wipeAll }
})

View file

@ -2,7 +2,7 @@ import { ref, computed } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { useApiFetch } from '../composables/useApi' import { useApiFetch } from '../composables/useApi'
export type WizardProfile = 'remote' | 'cpu' | 'single-gpu' | 'dual-gpu' export type WizardProfile = 'remote' | 'cpu' | 'single-gpu' | 'dual-gpu' | 'cf-orch'
export type WizardTier = 'free' | 'paid' | 'premium' export type WizardTier = 'free' | 'paid' | 'premium'
export interface WorkExperience { export interface WorkExperience {
@ -36,6 +36,7 @@ export interface WizardInferenceData {
anthropicKey: string anthropicKey: string
openaiUrl: string openaiUrl: string
openaiKey: string openaiKey: string
orchUrl: string
ollamaHost: string ollamaHost: string
ollamaPort: number ollamaPort: number
services: Record<string, string | number> services: Record<string, string | number>
@ -90,7 +91,8 @@ export const useWizardStore = defineStore('wizard', () => {
anthropicKey: '', anthropicKey: '',
openaiUrl: '', openaiUrl: '',
openaiKey: '', openaiKey: '',
ollamaHost: 'localhost', orchUrl: '',
ollamaHost: '',
ollamaPort: 11434, ollamaPort: 11434,
services: {}, services: {},
confirmed: false, confirmed: false,
@ -127,6 +129,7 @@ export const useWizardStore = defineStore('wizard', () => {
wizard_step: number wizard_step: number
saved_data: { saved_data: {
inference_profile?: string inference_profile?: string
cf_orch_url?: string
tier?: string tier?: string
name?: string name?: string
email?: string email?: string
@ -143,6 +146,8 @@ export const useWizardStore = defineStore('wizard', () => {
if (saved.inference_profile) if (saved.inference_profile)
hardware.value.selectedProfile = saved.inference_profile as WizardProfile hardware.value.selectedProfile = saved.inference_profile as WizardProfile
if (saved.cf_orch_url)
inference.value.orchUrl = saved.cf_orch_url as string
if (saved.tier) if (saved.tier)
tier.value = saved.tier as WizardTier tier.value = saved.tier as WizardTier
if (saved.name) identity.value.name = saved.name if (saved.name) identity.value.name = saved.name
@ -222,6 +227,7 @@ export const useWizardStore = defineStore('wizard', () => {
anthropic_key: inference.value.anthropicKey, anthropic_key: inference.value.anthropicKey,
openai_url: inference.value.openaiUrl, openai_url: inference.value.openaiUrl,
openai_key: inference.value.openaiKey, openai_key: inference.value.openaiKey,
orch_url: inference.value.orchUrl,
ollama_host: inference.value.ollamaHost, ollama_host: inference.value.ollamaHost,
ollama_port: inference.value.ollamaPort, ollama_port: inference.value.ollamaPort,
} }

Some files were not shown because too many files have changed in this diff Show more