Compare commits

...

26 commits
v0.9.1 ... main

Author SHA1 Message Date
2051880d73 fix: dark mode CSS token gaps, interview score display, undefined CSS vars
Some checks failed
CI / Backend (Python) (push) Failing after 24s
CI / Frontend (Vue) (push) Successful in 23s
Mirror / mirror (push) Failing after 8s
InterviewCard: remove erroneous *100 multiplier from scoreClass and
scoreLabel — match_score is stored as 0-100 in the DB, not 0-1. This
was producing scores like '1490%' for jobs with a 14.9 raw score.

peregrine.css: define --color-hover token for light (rgba(0,0,0,0.06))
and dark (rgba(255,255,255,0.07)). Was undefined, leaving hover states
on InterviewCard, InterviewsView, ReferencesView, ContactsView silent.

InterviewCard + InterviewsView: replace var(--color-primary-muted,#e8f0ff)
with var(--app-primary-light). The hardcoded #e8f0ff fallback is a bright
light-blue that renders on dark backgrounds when the variable is undefined.

WizardTrainingStep: --font-sans → --font-body (correct token name).

ResumeSyncConfirmModal, ResumeLibraryCard, ResumeOptimizerPanel,
resume-review sub-pages: --font-sm → --text-sm across all occurrences.
--font-sm was never defined; most had a 0.875rem fallback (which matches
--text-sm) but the correct token should be referenced directly.
2026-05-18 13:37:08 -07:00
7d1b1319be docs: fix repo URLs and clarify BYOK tier unlocking in feature matrix
Replace git.circuitforge.io/circuitforge/peregrine with the correct
Forgejo URL (git.opensourcesolarpunk.com/Circuit-Forge/peregrine) in
Quick Start, License, and Contributing clone instructions.

Update feature matrix in docs/index.md to show BYOK‡ for AI features
(cover letter, company research, interview prep, survey assistant) with
a footnote explaining the free-with-own-backend model. Previously the
table showed these as Paid-only, which would mislead self-hosters.
2026-05-18 12:05:03 -07:00
b44a7975bc fix(ci): restore green CI — libsqlcipher-dev, prep/survey test drift
Some checks failed
CI / Backend (Python) (push) Failing after 2m25s
CI / Frontend (Vue) (push) Successful in 24s
Mirror / mirror (push) Failing after 8s
Release / release (push) Failing after 4s
Backend: add apt-get install libsqlcipher-dev before pip install so
pysqlcipher3 builds in the runner image.

Frontend: prep.test.ts was missing a qa mock (fetchFor now calls 5
endpoints in parallel; tests only mocked 4 — 5th returned undefined,
threw in catch, research.value never set). survey.test.ts: analyze()
was refactored from sync-result to async-task+poll; update test to
mock POST then poll completion.

Also remove Classic UI (Streamlit) button from AppNav — Streamlit is
deprecated and the button caused an unrecoverable redirect loop.
2026-05-17 21:50:35 -07:00
0d6ddd35cf feat(config): GPU_SERVER_URL + cf-orch task-routed backends
- Rename user-facing env var CF_ORCH_URL → GPU_SERVER_URL with full
  backward-compat alias (closes #116). Priority chain: GPU_SERVER_URL
  → CF_ORCH_URL → orch.circuitforge.tech when CF_LICENSE_KEY present.
  Write-back to os.environ[CF_ORCH_URL] keeps all downstream callers
  unchanged.
- Add four task-routed llm.yaml backends (cf_cover_letter, cf_ats_rewrite,
  cf_job_research, cf_interview_prep) using cf_orch.product + cf_orch.task.
  Coordinator resolves model/node from assignments.yaml (closes #115).
- Update compose.yml, compose.cloud.yml, compose.test-cfcore.yml,
  .env.example to use GPU_SERVER_URL as primary documented var.
2026-05-17 20:16:40 -07:00
5c4992dbeb docs: bump version badge to match latest Forgejo release
Some checks failed
CI / Backend (Python) (push) Failing after 1m24s
CI / Frontend (Vue) (push) Failing after 21s
Mirror / mirror (push) Failing after 7s
2026-05-17 11:19:13 -07:00
fc3bd8859e docs: document Gotcha #14 (body over html), retake screenshots for light theme
Some checks failed
CI / Backend (Python) (push) Failing after 1m11s
CI / Frontend (Vue) (push) Failing after 20s
Mirror / mirror (push) Failing after 8s
2026-05-08 15:49:47 -07:00
73132222a2 fix: dark/explicit themes now show correct page background
Some checks failed
CI / Backend (Python) (push) Failing after 1m14s
CI / Frontend (Vue) (push) Failing after 19s
Mirror / mirror (push) Failing after 7s
Release / release (push) Failing after 4s
index.html set 'html, body { background: #eaeff8 }' hardcoded.
body paints on top of html — even with html correctly going dark
via CSS variable resolution, the hardcoded body background covered it.

Fix:
- Remove body background from inline style (body is now transparent)
- Add blocking script to read cf-theme/cf-hacker-mode from localStorage
  and set data-theme on <html> before first paint (FOUT prevention)
- Add html[data-theme='dark'|'solarized-dark'|'hacker'] rules so the
  correct background fires immediately on initial load for all themes
2026-05-08 15:44:33 -07:00
293f0aba53 chore(release): v0.9.4
Some checks failed
CI / Backend (Python) (push) Failing after 2m10s
CI / Frontend (Vue) (push) Failing after 57s
Mirror / mirror (push) Failing after 8s
Release / release (push) Failing after 3s
Messaging overhaul: expandable email timeline with lazy body loading,
sticky compose bar replacing always-visible action buttons, layout height
fixed to 100dvh. Accessibility fixes for contrast failures on orange/amber
backgrounds. Theme-aware replacements for hardcoded colors in Interviews,
References, and JobReview. Indeed alert parser, Oracle HCM scraper,
manage.sh compose engine detection.
2026-05-08 13:32:10 -07:00
5d185650d9 docs(screenshots): retake all four views after CSS theme fix — consistent light theme throughout
Some checks failed
CI / Backend (Python) (push) Failing after 1m11s
CI / Frontend (Vue) (push) Failing after 22s
Mirror / mirror (push) Failing after 7s
2026-05-06 09:58:33 -07:00
6ae9fa62fe docs(readme): landing page rewrite — centered logo, feature table, BYOK explanation, Forgejo-primary note, split license table
Some checks failed
CI / Backend (Python) (push) Failing after 1m11s
CI / Frontend (Vue) (push) Failing after 19s
Mirror / mirror (push) Failing after 7s
2026-05-06 08:51:28 -07:00
5d8018ef40 fix(theme): scope dark media override to auto mode only
Some checks failed
CI / Backend (Python) (push) Failing after 1m32s
CI / Frontend (Vue) (push) Failing after 25s
Mirror / mirror (push) Failing after 7s
Release / release (push) Failing after 6s
peregrine.css used :root:not([data-theme="hacker"]) in the
prefers-color-scheme:dark block, causing --app-primary-light and
--app-accent-light to resolve to dark navy/brown in every explicit
light theme (light, solarized-light, colorblind) on dark-OS machines.

Changed to :root:not([data-theme]) to match theme.css's pattern,
so the media query only fires in auto mode. Explicit [data-theme="dark"]
block handles the dark-theme-on-light-OS case unchanged.

Also fixed incorrect fallback values in HintChip.vue (#0d1829 → #eaeff8)
and App.vue global toast (#2a3650/#eaeff8 → light-mode values).

Closes: dark elements in light themes on dark-OS machines
2026-05-05 14:22:59 -07:00
312631a5d9 fix(resume-optimizer): strip double bullets and markdown formatting in rewrites
Three root causes fixed:
- _section_text_for_prompt: strip existing bullet chars from bullet text before
  adding the prompt's own marker (prevents • • text entering the LLM prompt)
- _reparse_experience_bullets: use + quantifier to strip all leading bullet chars,
  not just the first (handles • • text from LLM output)
- _apply_section_rewrite (summary): run _clean_summary_markup to remove
  markdown * bullets from career_summary before storing in struct

Also adds 'no markdown formatting' to the LLM rewrite prompt CRITICAL RULES.
2026-05-05 14:11:52 -07:00
f4a524ba0b feat(resume-optimizer): make proposed text editable in review modal and preview
Some checks failed
CI / Backend (Python) (push) Failing after 1m45s
CI / Frontend (Vue) (push) Failing after 22s
Mirror / mirror (push) Failing after 11s
Summary and experience bullet fields in the review modal are now
editable textareas. Edited values flow through decisions to
apply_review_decisions(), which uses edited_text/edited_bullets when
the section is accepted. Clearing unwanted LLM-added bullets (empty
lines filtered server-side) addresses the extra-bullets issue.

The preview textarea in the apply workspace is also now editable;
approveResume() passes preview_text_override so manual edits survive
the approve step without re-rendering from struct.
2026-05-05 13:35:01 -07:00
77e49db4e9 Merge pull request 'feat: cover letter training export (Phase 1)' (#111) from feat/cover-letter-training-export into main
Some checks failed
CI / Backend (Python) (push) Failing after 1m17s
CI / Frontend (Vue) (push) Failing after 19s
Mirror / mirror (push) Failing after 7s
Release / release (push) Failing after 3s
2026-05-04 08:55:15 -07:00
424a946ca0 chore(release): v0.9.2 changelog entry
Some checks failed
CI / Backend (Python) (pull_request) Failing after 1m0s
CI / Frontend (Vue) (pull_request) Failing after 21s
2026-05-03 07:27:13 -07:00
f42a515629 fix: wizard step numbering and loadDbPairs race on mount
Some checks failed
CI / Backend (Python) (pull_request) Failing after 1m51s
CI / Frontend (Vue) (pull_request) Failing after 21s
Insert Training at step 4 in WIZARD_STEPS (7→8), STEP_LABELS, and
STEP_ROUTES. Bump Identity→5, Inference→6, Search→7, Integrations→8 in
their respective saveStep calls. Cap resumeAt at 8. Await loadStatus()
before loadDbPairs() in FineTuneView onMounted so optedIn is set before
the early-exit guard runs.
2026-05-03 01:34:06 -07:00
0e40750450 feat: add WizardTrainingStep opt-in consent step to onboarding
Inserts a new optional Training Export step between Resume and Identity
in the setup wizard. Users can opt in to saving cover letters for
fine-tuning dataset export. Consent copy distinguishes local vs. cloud
storage. WIZARD_STEPS bumped to 7; router, and adjacent step
back/next navigation updated accordingly.
2026-05-03 01:11:06 -07:00
6bfb2bf3f7 feat: add Training Export and From Applied Jobs sections to FineTuneView (a11y-correct) 2026-05-03 01:04:43 -07:00
8e6cc02295 feat: add training export state and actions to fineTune store 2026-05-03 00:14:22 -07:00
25473aef77 feat: add training export API endpoints to dev_api.py
- PATCH /api/settings/fine-tune/opt-in — toggle training_export_opt_in in user.yaml
- GET /api/settings/fine-tune/db-pairs — list DB jobs with exclusion flags (403 without opt-in)
- PATCH /api/settings/fine-tune/db-pairs/{id}/exclude|include — per-job exclusion toggle
- GET /api/settings/fine-tune/export — NDJSON streaming download of all training pairs (DB + file)
- POST/GET /api/settings/fine-tune/cloud-request|cloud-status — Phase 2 stubs (501)
- finetune_status now includes opted_in field
- 6 new API tests; all 17 tests pass
2026-05-02 23:40:44 -07:00
3b52844382 feat: add training_export_opt_in field to UserProfile 2026-05-02 23:32:34 -07:00
148aaf00cb feat: add training export DB migration and db.py helpers
Add excluded_from_training column to jobs table (migration 009 + _MIGRATIONS
entry for existing DBs). Add get_db_pairs(), get_training_pairs(), and
set_training_exclusion() helpers for the cover letter training export pipeline.
Add test_training_export.py with 8 tests covering all helpers (all passing).
2026-05-02 23:21:34 -07:00
b03add8663 feat(resume-matcher): tier-aware writing model routing via cf-orch
Premium/ultra users with a custom_writing_model in their session are
routed to that model as the first cf-orch candidate; all other tiers
use the shared Qwen2.5-3B-Instruct base. complete_json() is unchanged
since fine-tuned writing models aren't trained for structured output.

Adds _request_tier and _request_writing_model ContextVars. Resolution
order: USER_WRITING_MODELS env var (Monday path) then Heimdall meta
(future path via peregrine#110).
2026-04-26 09:18:55 -07:00
5e63faba0c refactor: import detect_byok from cf-core in resume_matcher, remove local copy
Some checks failed
CI / Backend (Python) (push) Failing after 2m26s
CI / Frontend (Vue) (push) Failing after 22s
Mirror / mirror (push) Failing after 7s
Force-adds file from gitignored resume_matcher/ subtree — only CF-specific
patch files are force-added; upstream resume_matcher files remain excluded.
2026-04-25 16:46:25 -07:00
673fb84c23 chore: remove deprecated Streamlit app service from cloud compose (closes #104)
Some checks failed
CI / Backend (Python) (push) Failing after 1m14s
CI / Frontend (Vue) (push) Failing after 21s
Mirror / mirror (push) Failing after 6s
Vue+FastAPI (api+web services) is the only frontend. The peregrine-cloud
Streamlit container was still running alongside the new stack and is now
removed. Port 8505 freed.
2026-04-24 20:11:14 -07:00
1d9ee9cff0 feat(resume-matcher): wire cloud session + user_id routing to cf-orch
Some checks failed
CI / Backend (Python) (push) Failing after 1m11s
CI / Frontend (Vue) (push) Failing after 21s
Mirror / mirror (push) Failing after 9s
- app/cloud_session.py: CloudSessionFactory(product="peregrine") from
  cf-core v0.16.0; get_session / require_tier FastAPI dependencies;
  session_middleware_dep sets request-scoped user_id ContextVar
- app/llm.py: _request_user_id ContextVar + set/get helpers;
  _allocate_orch_async includes user_id in payload when present so
  premium users get their custom model path from cf-orch UserModelRegistry
- app/main.py: session_middleware_dep wired as global FastAPI dependency;
  runs on every request, zero function-signature changes needed

Force-added to bypass resume_matcher/ gitignore (CF-specific patch files).
2026-04-24 17:31:43 -07:00
62 changed files with 3109 additions and 496 deletions

View file

@ -45,7 +45,8 @@ FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
# Set CF_LICENSE_KEY to authenticate with the hosted coordinator. # Set CF_LICENSE_KEY to authenticate with the hosted coordinator.
# Leave both blank for local self-hosted cf-orch or bare-metal inference. # Leave both blank for local self-hosted cf-orch or bare-metal inference.
CF_LICENSE_KEY= CF_LICENSE_KEY=
CF_ORCH_URL=https://orch.circuitforge.tech GPU_SERVER_URL=https://orch.circuitforge.tech
# CF_ORCH_URL is also accepted as a backward-compat alias for GPU_SERVER_URL
# cf-orch agent — GPU profiles only (single-gpu, dual-gpu-*) # cf-orch agent — GPU profiles only (single-gpu, dual-gpu-*)
# The agent registers this node with the cf-orch coordinator and reports VRAM stats. # The agent registers this node with the cf-orch coordinator and reports VRAM stats.

View file

@ -23,6 +23,9 @@ jobs:
python-version: '3.12' python-version: '3.12'
cache: pip cache: pip
- name: Install system dependencies
run: sudo apt-get update -q && sudo apt-get install -y libsqlcipher-dev
- name: Install dependencies - name: Install dependencies
run: pip install -r requirements.txt run: pip install -r requirements.txt

View file

@ -9,6 +9,109 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
--- ---
## [0.9.5] — 2026-05-08
### Fixed
- **Theme: dark/explicit themes show correct page background**`index.html` inline style
set `html, body { background: #eaeff8 }` hardcoded. `body` paints on top of `html`, so
even when `html { background: var(--color-surface) }` correctly resolved to `#16202e` in
dark mode, the body's hardcoded light background covered it — producing dark cards on a
light page. Fixed by: (1) removing body background from the inline style; (2) adding a
tiny blocking `<script>` that reads `cf-theme` / `cf-hacker-mode` from localStorage and
sets `data-theme` on `<html>` before first paint; (3) adding
`html[data-theme="dark"|"solarized-dark"|"hacker"]` rules so FOUT prevention fires the
right background immediately on load.
---
## [0.9.4] — 2026-05-08
### Added
- **Messages view — expandable email timeline** — click any email item to lazy-load
and read the full body inline (HTML stripped to plain text via `DOMParser`).
Bodies are fetched on-demand via the new `GET /api/contacts/{id}` endpoint to avoid
loading 50KB+ email bodies on every page view.
- **Messages view — compose bar** — action buttons (Log call, Log note, Use template,
Draft reply with LLM, Call via Osprey) moved from the always-visible header into a
sticky bottom compose bar triggered by a New toggle. Reduces visual clutter when
just reading the thread.
- **Home view — "Skip review" checkbox** — when adding jobs by URL, a checkbox (default
on) sends them directly to the Apply queue, bypassing Job Review.
- **ContactsView — sync status** — shows last completed sync time and a spinner when
an email sync is running.
- **imap_sync: Indeed alert parser**`parse_indeed_alert()` extracts job title,
company, location, salary, and canonical URL from Indeed Job Alert digest emails.
- **scrape_url: Oracle HCM support** — Playwright-based scraper for Oracle HCM
CandidateExperience portals (React SPAs requiring JS execution).
- **manage.sh** — compose engine auto-detection (docker compose / podman compose /
podman-compose), `build` command, and cloud/demo stack shortcuts.
- **theme.css**`--color-overlay` token for modal/dialog backdrops.
### Fixed
- **Messages view layout** — changed `height: 100%` to `height: 100dvh` with a mobile
override for the 56px tab bar. `height: 100%` was resolving to "shrink-wrap" because
`.app-main` has no explicit height; compose bar is now correctly pinned to the bottom.
- **Accessibility: danger button contrast**`btn--danger` used `color: white` on
`--app-accent` (Talon Orange), yielding 2.8:1 contrast (fails WCAG AA 4.5:1 for
normal text). Fixed to `color: var(--app-accent-text)` (dark navy, 5.5:1).
- **Accessibility: warning badge contrast**`tab-badge` in Job Review used `color: white`
on `--color-warning` (amber). Same fix applied.
- **Theme: Interviews signal banners** — hardcoded `rgba(245,158,11,…)` / `rgba(39,174,…)`
/ `rgba(192,57,…)` replaced with `color-mix()` against `--color-warning/success/error`.
- **Theme: Interviews signal count**`color: #e67e22` hardcode replaced with
`var(--app-accent)`.
- **Theme: References academic tag chip**`color: #7c3aed` hardcode replaced with
`var(--status-synced)`; background uses `color-mix()` with the same token.
- **Theme: Interviews signal-move button**`color: #fff` on `--color-primary` fails
in dark mode (light green bg); fixed to `var(--color-text-inverse)`.
- **Modal backdrops**`rgba(0,0,0,0.5)` replaced with `var(--color-overlay)` for
theme consistency.
---
## [0.9.3] — 2026-05-05
### Added
- **Editable resume review** — proposed summary and experience bullets in the review modal
are now editable text areas. Edits flow through `apply_review_decisions()` and override
the LLM output in the final resume struct. Preview textarea in Apply Workspace is also
editable, with manual changes preserved through the approve step via `preview_text_override`.
### Fixed
- **Double bullets in resume optimizer**`_section_text_for_prompt` now strips existing
bullet characters before prefixing with `•`, and `_reparse_experience_bullets` uses a
greedy strip regex so `• •` patterns can no longer survive parsing.
- **Asterisk markup in summary** — added `_clean_summary_markup()` to strip LLM-generated
markdown bullet chars (`*`, `-`, etc.) from career summary output; injected no-markdown
rule into the LLM prompt's CRITICAL RULES list.
- **Light theme dark CSS bleed**`peregrine.css` media dark override now scoped to
`:root:not([data-theme])` (auto mode only) instead of `:root:not([data-theme="hacker"])`.
Fixes dark navy `--app-primary-light`/`--app-accent-light` bleeding into light themes
(light, solarized-light, colorblind) on dark-OS machines.
---
## [0.9.2] — 2026-05-02
### Added
- **Cover letter training export** (#111) — opt-in consent gate (`training_export_opt_in`
in `user.yaml`, default off) lets users export applied-job cover letters as Alpaca-format
JSONL for local fine-tuning. Per-job exclude/restore curation in Settings → Fine-Tune.
Streaming JSONL download merges DB pairs with any previously uploaded file pairs.
Cloud fine-tune Phase 2 stub (501) reserved for cf-orch integration.
- **WizardTrainingStep** — new onboarding consent step inserted between Resume and Identity;
skippable, opt-in default off, cloud-aware privacy copy.
- **a11y:** confirmed-state toggle (no optimistic DOM divergence), visible Premium tier gate
with upgrade link, `aria-live` region on pairs list, cloud-aware consent copy.
---
## [0.9.0] — 2026-04-20 ## [0.9.0] — 2026-04-20
### Added ### Added

255
README.md
View file

@ -1,213 +1,143 @@
# Peregrine <div align="center">
<img src="web/public/peregrine.svg" alt="Peregrine" width="120" />
> **Primary development** happens at [git.opensourcesolarpunk.com](https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine) — GitHub and Codeberg are push mirrors. Issues and PRs are welcome on either platform. <h1>Peregrine</h1>
[![License: BSL 1.1](https://img.shields.io/badge/License-BSL_1.1-blue.svg)](./LICENSE-BSL) <p><strong>Job search pipeline — by <a href="https://circuitforge.tech">Circuit Forge LLC</a></strong></p>
[![CI](https://github.com/CircuitForge/peregrine/actions/workflows/ci.yml/badge.svg)](https://github.com/CircuitForge/peregrine/actions/workflows/ci.yml)
<p><em>AI for the tasks the system made hard on purpose.</em></p>
[![License: MIT / BSL 1.1](https://img.shields.io/badge/License-MIT%20%2F%20BSL%201.1-blue.svg)](#license)
[![CI](https://github.com/CircuitForgeLLC/peregrine/actions/workflows/ci.yml/badge.svg)](https://github.com/CircuitForgeLLC/peregrine/actions/workflows/ci.yml)
[![Docs](https://img.shields.io/badge/docs-docs.circuitforge.tech-orange)](https://docs.circuitforge.tech/peregrine/) [![Docs](https://img.shields.io/badge/docs-docs.circuitforge.tech-orange)](https://docs.circuitforge.tech/peregrine/)
[![Version](https://img.shields.io/badge/version-0.9.0-green)](https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine/releases)
**Job search pipeline — by [Circuit Forge LLC](https://circuitforge.tech)** <p>
<a href="https://demo.circuitforge.tech/peregrine"><strong>Live Demo</strong></a>
no account required, nothing saved &nbsp;|&nbsp;
<a href="https://docs.circuitforge.tech/peregrine/">Docs</a> &nbsp;|&nbsp;
<a href="https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine/issues">Issues</a>
</p>
> *"Tools for the jobs that the system made hard on purpose."* <blockquote>
<strong>Primary development</strong> happens at
**[Try the live demo](https://demo.circuitforge.tech/peregrine)** — no account required, nothing saved. <a href="https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine">git.opensourcesolarpunk.com/Circuit-Forge/peregrine</a>.
GitHub and Codeberg are push mirrors. Issues and PRs are welcome on any platform.
</blockquote>
</div>
--- ---
![Job review — swipe right to approve, left to skip](docs/screenshots/02-review-swipe.gif)
<table> <table>
<tr> <tr>
<td><img src="docs/screenshots/01-dashboard.png" alt="Dashboard with pipeline stats"/></td> <td><img src="docs/screenshots/01-dashboard.png" alt="Dashboard with pipeline stats and discovery controls"/></td>
<td><img src="docs/screenshots/04-interviews.png" alt="Interview kanban with recruiter emails attached"/></td> <td><img src="docs/screenshots/02-review.png" alt="Job review — approve, skip, or reject with keyboard shortcuts"/></td>
</tr> </tr>
<tr> <tr>
<td><img src="docs/screenshots/03-apply.png" alt="Apply workspace with AI cover letter draft"/></td> <td><img src="docs/screenshots/03-apply.png" alt="Apply workspace with LLM-drafted cover letter"/></td>
<td><img src="docs/screenshots/02-review.png" alt="Job review card with match score and ghost-post detection"/></td> <td><img src="docs/screenshots/04-interviews.png" alt="Interview kanban with company research and recruiter emails"/></td>
</tr> </tr>
</table> </table>
--- ---
Job search is a second job nobody hired you for. ## Why Peregrine?
ATS filters designed to reject. Job boards that show the same listing eight times. Cover letter number forty-seven for a role that might already be filled. Hours of prep for a phone screen that lasts twelve minutes. Job search is a second job nobody hired you for. ATS (applicant tracking system) filters designed to reject. Boards that show the same listing eight times. Cover letter number forty-seven for a role that might already be filled. Hours of prep for a phone screen that lasts twelve minutes.
Peregrine handles the pipeline — discovery, matching, tracking, drafting, and prep — so you can spend your time doing the work you actually want to be doing. - **Handles the full pipeline.** Discover, filter, match, draft, track — one tool, one database, no duct tape.
- **LLM is optional and local-first.** Discovery and tracking work with no LLM at all. When you do configure one, it runs on your hardware by default. Cloud inference is a fallback, not the default path.
**LLM support is optional.** The full discovery and tracking pipeline works without one. When you do configure a backend, the LLM drafts the parts that are genuinely miserable — cover letters, company research briefs, interview prep sheets — and waits for your approval before anything goes anywhere. - **Ghost-post detection baked in.** Listings that have been open too long or look like sourcing traps get flagged before you spend time on them.
- **Human approval at every step.** LLM drafts cover letters and research briefs; you approve before anything goes anywhere. Peregrine never submits an application on your behalf.
### What Peregrine does not do - **Privacy · Safety · Accessibility** are architectural constraints, not aspirational copy. No PII (personally identifiable information) logging, no behavioral profiling, no dark patterns.
Peregrine does **not** submit job applications for you. You still have to go to each employer's site and click apply yourself.
This is intentional. Automated mass-applying is a bad experience for everyone — it's also a trust violation with employers who took the time to post a real role. Peregrine is a preparation and organization tool, not a bot.
What it *does* cover is everything before and after that click: finding the jobs, matching them against your resume, generating cover letters and prep materials, and once you've applied — tracking where you stand, classifying the emails that come back, and surfacing company research when an interview lands on your calendar. The submit button is yours. The rest of the grind is ours.
> **Exception:** [AIHawk](https://github.com/nicolomantini/LinkedIn-Easy-Apply) is a separate, optional tool that handles LinkedIn Easy Apply automation. Peregrine integrates with it for AIHawk-compatible profiles, but it is not part of Peregrine's core pipeline.
--- ---
## Quick Start ## Quick Start
**1. Clone and install dependencies** (Docker, NVIDIA toolkit if needed): One-line install:
```bash
bash <(curl -fsSL https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine/raw/branch/main/install.sh)
```
Or clone and run manually:
```bash ```bash
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine git clone https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine
cd peregrine cd peregrine
./manage.sh setup ./manage.sh setup
./manage.sh start
``` ```
**2. Start Peregrine:** Open **http://localhost:8502** — 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.
> **Windows:** use WSL2 with Ubuntu.
### Inference profiles
```bash ```bash
./manage.sh start # remote profile (API-only, no GPU) ./manage.sh start # remote — no GPU; LLM calls go to Anthropic / OpenAI
./manage.sh start --profile cpu # local Ollama (CPU, or Metal GPU on Apple Silicon — see below) ./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 + Vision + vLLM (GPU 0 + 1) (NVIDIA only) ./manage.sh start --profile dual-gpu # Ollama + vLLM on two NVIDIA GPUs
``` ```
Or use `make` directly:
```bash
make start # remote profile
make start PROFILE=single-gpu
```
**3.** Open http://localhost:8501 — the setup wizard guides you through the rest.
> **macOS / Apple Silicon:** Docker Desktop must be running. For Metal GPU-accelerated inference, install Ollama natively before starting — `install.sh` will prompt you to do this. See [Apple Silicon GPU](#apple-silicon-gpu) below.
> **Windows:** Not supported — use WSL2 with Ubuntu.
### Installing to `/opt` or other system directories
If you clone into a root-owned directory (e.g. `sudo git clone ... /opt/peregrine`), two things need fixing:
**1. Git ownership warning** (`fatal: detected dubious ownership`) — `./manage.sh setup` fixes this automatically. If you need git to work *before* running setup:
```bash
git config --global --add safe.directory /opt/peregrine
```
**2. Preflight write access** — preflight writes `.env` and `compose.override.yml` into the repo directory. Fix ownership once:
```bash
sudo chown -R $USER:$USER /opt/peregrine
```
After that, run everything without `sudo`.
### Podman
Podman is rootless by default — **no `sudo` needed.** `./manage.sh setup` will configure `podman-compose` if it isn't already present.
### Docker
After `./manage.sh setup`, log out and back in for docker group membership to take effect. Until then, prefix commands with `sudo`. After re-login, `sudo` is no longer required.
---
## Inference Profiles
| Profile | Services started | Use case |
|---------|-----------------|----------|
| `remote` | app + searxng | No GPU; LLM calls go to Anthropic / OpenAI |
| `cpu` | app + ollama + searxng | No GPU; local models on CPU. On Apple Silicon, use with native Ollama for Metal acceleration — see below. |
| `single-gpu` | app + ollama + vision + searxng | One **NVIDIA** GPU: cover letters, research, vision |
| `dual-gpu` | app + ollama + vllm + vision + searxng | Two **NVIDIA** GPUs: GPU 0 = Ollama, GPU 1 = vLLM |
### Apple Silicon GPU
Docker Desktop on macOS runs in a Linux VM — it cannot access the Apple GPU. Metal-accelerated inference requires Ollama to run **natively** on the host.
`install.sh` handles this automatically: it offers to install Ollama via Homebrew, starts it as a background service, and explains what happens next. If Ollama is running on port 11434 when you start Peregrine, preflight detects it, stubs out the Docker Ollama container, and routes inference through the native process — which uses Metal automatically.
To do it manually:
```bash
brew install ollama
brew services start ollama # starts at login, uses Metal GPU
./manage.sh start --profile cpu # preflight adopts native Ollama; Docker container is skipped
```
The `cpu` profile label is a slight misnomer in this context — Ollama will be running on the GPU. `single-gpu` and `dual-gpu` profiles are NVIDIA-specific and not applicable on Mac.
---
## First-Run Wizard
On first launch the setup wizard walks through seven steps:
1. **Hardware** — detects NVIDIA GPUs (Linux) or Apple Silicon GPU (macOS) and recommends a profile
2. **Tier** — choose free, paid, or premium (or use `dev_tier_override` for local testing)
3. **Identity** — name, email, phone, LinkedIn, career summary
4. **Resume** — upload a PDF/DOCX for LLM parsing, or use the guided form builder
5. **Inference** — configure LLM backends and API keys
6. **Search** — job titles, locations, boards, keywords, blocklist
7. **Integrations** — optional cloud storage, calendar, and notification services
Wizard state is saved after each step — a crash or browser close resumes where you left off.
Re-enter the wizard any time via **Settings → Developer → Reset wizard**.
--- ---
## Features ## Features
| Feature | Tier | | Feature | Tier |
|---------|------| |---------|------|
| Job discovery (JobSpy + custom boards) | Free | | Job discovery — LinkedIn, Indeed, Glassdoor, Adzuna, The Ladders | Free |
| Resume keyword matching & gap analysis | Free | | Ghost-post detection | Free |
| Document storage sync (Google Drive, Dropbox, OneDrive, MEGA, Nextcloud) | Free | | Resume keyword matching and gap analysis | Free |
| Document storage sync (Google Drive, Dropbox, OneDrive, Nextcloud) | Free |
| Webhook notifications (Discord, Home Assistant) | Free | | Webhook notifications (Discord, Home Assistant) | Free |
| Vue 3 SPA — full UI with onboarding wizard, job board, apply workspace, interview kanban | Free |
| **Cover letter generation** | Free with LLM ¹ | | **Cover letter generation** | Free with LLM ¹ |
| **Company research briefs** | Free with LLM ¹ | | **Company research briefs** | Free with LLM ¹ |
| **Interview prep & practice Q&A** | Free with LLM¹ | | **Interview prep and practice Q&A** | Free with LLM ¹ |
| **Survey assistant** (culture-fit Q&A, screenshot analysis) | Free with LLM ¹ | | **Survey assistant** (culture-fit Q&A, screenshot analysis) | Free with LLM ¹ |
| **Wizard helpers** (career summary, bullet expansion, skill suggestions, job title suggestions, mission notes) | Free with LLM¹ |
| Managed cloud LLM (no API key needed) | Paid | | Managed cloud LLM (no API key needed) | Paid |
| Email sync & auto-classification | Paid | | Email sync and auto-classification | Paid |
| LLM-powered keyword blocklist | Paid |
| Job tracking integrations (Notion, Airtable, Google Sheets) | Paid | | Job tracking integrations (Notion, Airtable, Google Sheets) | Paid |
| Calendar sync (Google, Apple) | Paid | | Calendar sync (Google, Apple) | Paid |
| Slack notifications | Paid | | Slack notifications | Paid |
| CircuitForge shared cover-letter model | Paid | | CircuitForge shared cover-letter model | Paid |
| Vue 3 SPA — full UI with onboarding wizard, job board, apply workspace, sort/filter, research modal, draft cover letter | Free | | **Voice guidelines** (custom writing style and tone) | Premium with LLM ¹ |
| **Voice guidelines** (custom writing style & tone) | Premium with LLM¹ ² | | Cover letter model fine-tuning — your writing, your model | Premium |
| Cover letter model fine-tuning (your writing, your model) | Premium |
| Multi-user support | Premium | | Multi-user support | Premium |
| Human-in-the-loop operator (CAPTCHAs, phone calls, wet signatures) | Ultra |
¹ **BYOK (bring your own key/backend) unlock:** configure any LLM backend — a local [Ollama](https://ollama.com) or vLLM instance, ¹ **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.
or your own API key (Anthropic, OpenAI-compatible) — and all features marked **Free with LLM** or **Premium with LLM**
unlock at no charge. The paid tier earns its price by providing managed cloud inference so you
don't need a key at all, plus integrations and email sync.
² **Voice guidelines** requires Premium tier without a configured LLM backend. With BYOK, it unlocks at any tier.
--- ---
## Email Sync ## What Peregrine does not do
Monitors your inbox for job-related emails and automatically updates job stages (interview requests, rejections, survey links, offers). Peregrine does **not** submit job applications for you. You still click apply on the employer's site.
Configure in **Settings → Email**. Requires IMAP access and, for Gmail, an App Password. This is intentional. Automated mass-applying is a bad experience for everyone and a trust violation with employers who posted a real role. The submit button is yours. The rest of the grind is ours.
--- ---
## Integrations ## Stack
Connect external services in **Settings → Integrations**: | Layer | Technology |
|-------|-----------|
- **Job tracking:** Notion, Airtable, Google Sheets | Frontend | Vue 3 SPA (Vite) |
- **Document storage:** Google Drive, Dropbox, OneDrive, MEGA, Nextcloud | Backend | FastAPI + Python |
- **Calendar:** Google Calendar, Apple Calendar (CalDAV) | Database | SQLite (local, per-user) |
- **Notifications:** Slack, Discord (webhook), Home Assistant | Job scraping | [JobSpy](https://github.com/Bunsly/JobSpy) + custom board scrapers |
| LLM inference | Ollama, vLLM, Anthropic, OpenAI-compatible — configurable fallback chain |
| Vision | moondream2 (survey screenshot analysis) |
| Container | Docker / Podman |
--- ---
## CLI Reference (`manage.sh`) ## manage.sh reference
`manage.sh` is the single entry point for all common operations — no need to remember Make targets or Docker commands.
``` ```
./manage.sh setup Install Docker/Podman + NVIDIA toolkit ./manage.sh setup Install Docker/Podman + NVIDIA toolkit
@ -216,31 +146,38 @@ Connect external services in **Settings → Integrations**:
./manage.sh restart Restart all services ./manage.sh restart Restart all services
./manage.sh status Show running containers ./manage.sh status Show running containers
./manage.sh logs [service] Tail logs (default: app) ./manage.sh logs [service] Tail logs (default: app)
./manage.sh update Pull latest images + rebuild app container ./manage.sh update Pull latest images and rebuild app container
./manage.sh preflight Check ports + resources; write .env
./manage.sh test Run test suite ./manage.sh test Run test suite
./manage.sh prepare-training Scan docs for cover letters training JSONL ./manage.sh prepare-training Scan docs for cover letters — outputs training JSONL
./manage.sh finetune Run LoRA fine-tune (needs --profile single-gpu+) ./manage.sh finetune Run LoRA fine-tune (requires single-gpu profile or higher)
./manage.sh open Open the web UI in your browser ./manage.sh open Open the web UI in your browser
./manage.sh clean Remove containers, images, volumes (asks to confirm)
``` ```
--- ---
## Developer Docs ## Documentation
Full documentation at: https://docs.circuitforge.tech/peregrine Full docs at **[docs.circuitforge.tech/peregrine](https://docs.circuitforge.tech/peregrine)**
- [Installation guide](https://docs.circuitforge.tech/peregrine/getting-started/installation/) Bug reports and feature requests: [Forgejo issues](https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine/issues)
- [Adding a custom job board scraper](https://docs.circuitforge.tech/peregrine/developer-guide/adding-scrapers/)
- [Adding an integration](https://docs.circuitforge.tech/peregrine/developer-guide/adding-integrations/) ---
- [Contributing](https://docs.circuitforge.tech/peregrine/developer-guide/contributing/)
## Contributing
Contributions are welcome. The discovery pipeline — scrapers, board integrations, matching logic — is MIT-licensed. Fork it, extend it, send PRs. AI features are BSL 1.1. See the [contributing guide](https://docs.circuitforge.tech/peregrine/developer-guide/contributing/) for conventions.
--- ---
## License ## License
Core discovery pipeline: [MIT](LICENSE-MIT) Peregrine uses a split license:
LLM features (cover letter generation, company research, interview prep, UI): [BSL 1.1](LICENSE-BSL)
| Component | License |
|-----------|---------|
| Discovery pipeline — scrapers, matching, tracking | [MIT](LICENSE-MIT) |
| LLM features — cover letter generation, company research, interview prep, survey assistant, fine-tuning | [BSL 1.1](LICENSE-BSL) — free for personal non-commercial self-hosting; commercial use or SaaS re-hosting requires a paid license; converts to MIT after four years |
Fine-tuned model weights are proprietary and per-user — not redistributable.
© 2026 Circuit Forge LLC © 2026 Circuit Forge LLC

View file

@ -6,46 +6,15 @@
# Caddy injects the Directus session cookie as X-CF-Session header before forwarding. # Caddy injects the Directus session cookie as X-CF-Session header before forwarding.
# cloud_session.py resolves user_id → per-user db_path at session init. # cloud_session.py resolves user_id → per-user db_path at session init.
# #
# Services: api (FastAPI :8601), web (Vue :8508), searxng (internal)
# Streamlit app service removed — Vue+FastAPI is the only frontend (peregrine#104).
#
# Usage: # Usage:
# docker compose -f compose.cloud.yml --project-name peregrine-cloud up -d # docker compose -f compose.cloud.yml --project-name peregrine-cloud up -d
# docker compose -f compose.cloud.yml --project-name peregrine-cloud down # docker compose -f compose.cloud.yml --project-name peregrine-cloud down
# docker compose -f compose.cloud.yml --project-name peregrine-cloud logs app -f # docker compose -f compose.cloud.yml --project-name peregrine-cloud logs api -f
services: services:
app:
build:
context: ..
dockerfile: peregrine/Dockerfile.cfcore
container_name: peregrine-cloud
ports:
- "8505:8501"
volumes:
- /devl/menagerie-data:/devl/menagerie-data # per-user data trees
- ./config/llm.cloud.yaml:/app/config/llm.yaml:ro # cloud-safe backends only (no claude_code/copilot/anthropic)
environment:
- CLOUD_MODE=true
- CLOUD_DATA_ROOT=/devl/menagerie-data
- DIRECTUS_JWT_SECRET=${DIRECTUS_JWT_SECRET}
- CF_SERVER_SECRET=${CF_SERVER_SECRET}
- PLATFORM_DB_URL=${PLATFORM_DB_URL}
- HEIMDALL_URL=${HEIMDALL_URL:-http://cf-license:8000}
- HEIMDALL_ADMIN_TOKEN=${HEIMDALL_ADMIN_TOKEN}
- STAGING_DB=/devl/menagerie-data/cloud-default.db # fallback only — never used
- DOCS_DIR=/tmp/cloud-docs
- STREAMLIT_SERVER_BASE_URL_PATH=peregrine
- PYTHONUNBUFFERED=1
- PEREGRINE_CADDY_PROXY=1
- CF_ORCH_URL=http://host.docker.internal:7700
- CF_APP_NAME=peregrine
- DEMO_MODE=false
- FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-}
depends_on:
searxng:
condition: service_healthy
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
api: api:
build: build:
context: .. context: ..
@ -68,7 +37,8 @@ services:
- HEIMDALL_ADMIN_TOKEN=${HEIMDALL_ADMIN_TOKEN} - HEIMDALL_ADMIN_TOKEN=${HEIMDALL_ADMIN_TOKEN}
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-} - FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-}
- CF_ORCH_URL=http://host.docker.internal:7700 - GPU_SERVER_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
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"

View file

@ -29,7 +29,8 @@ services:
- STAGING_DB=/devl/job-seeker/staging.db - STAGING_DB=/devl/job-seeker/staging.db
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- STREAMLIT_SERVER_BASE_URL_PATH= - STREAMLIT_SERVER_BASE_URL_PATH=
- CF_ORCH_URL=http://host.docker.internal:7700 - GPU_SERVER_URL=${GPU_SERVER_URL:-http://host.docker.internal:7700}
- CF_ORCH_URL=${CF_ORCH_URL:-${GPU_SERVER_URL:-http://host.docker.internal:7700}}
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
restart: "no" restart: "no"

View file

@ -20,7 +20,8 @@ services:
- OPENAI_COMPAT_KEY=${OPENAI_COMPAT_KEY:-} - OPENAI_COMPAT_KEY=${OPENAI_COMPAT_KEY:-}
- PEREGRINE_GPU_COUNT=${PEREGRINE_GPU_COUNT:-0} - PEREGRINE_GPU_COUNT=${PEREGRINE_GPU_COUNT:-0}
- PEREGRINE_GPU_NAMES=${PEREGRINE_GPU_NAMES:-} - PEREGRINE_GPU_NAMES=${PEREGRINE_GPU_NAMES:-}
- CF_ORCH_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_APP_NAME=peregrine - CF_APP_NAME=peregrine
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
extra_hosts: extra_hosts:

View file

@ -46,11 +46,61 @@ backends:
type: vision_service type: vision_service
supports_images: true supports_images: true
# ── cf-orch trunk services ───────────────────────────────────────────────── # ── cf-orch task-routed backends (preferred for GPU inference) ────────────
# These backends allocate via cf-orch rather than connecting to a static URL. # Use these when GPU_SERVER_URL is configured. The coordinator resolves
# cf-orch starts the service on-demand and returns its URL; the router then # product+task → model_id → node via assignments.yaml; no model IDs needed here.
# calls it directly using the openai_compat path. # Set enabled: true once GPU_SERVER_URL is configured.
# Set CF_ORCH_URL (env) or url below; leave enabled: false if cf-orch is cf_cover_letter:
type: openai_compat
enabled: false
base_url: http://localhost:8008/v1 # fallback when cf-orch is unavailable
model: __auto__
api_key: any
supports_images: false
cf_orch:
product: peregrine
task: cover_letter
ttl_s: 3600
cf_ats_rewrite:
type: openai_compat
enabled: false
base_url: http://localhost:8008/v1
model: __auto__
api_key: any
supports_images: false
cf_orch:
product: peregrine
task: ats_rewrite
ttl_s: 3600
cf_job_research:
type: openai_compat
enabled: false
base_url: http://localhost:8008/v1
model: __auto__
api_key: any
supports_images: false
cf_orch:
product: peregrine
task: job_research
ttl_s: 3600
cf_interview_prep:
type: openai_compat
enabled: false
base_url: http://localhost:8008/v1
model: __auto__
api_key: any
supports_images: false
cf_orch:
product: peregrine
task: interview_prep
ttl_s: 3600
# ── cf-orch trunk services (service-based, legacy) ─────────────────────────
# Generic service allocation — use the task-routed backends above when possible.
# Set GPU_SERVER_URL (env) or url below; leave enabled: false if cf-orch is
# not deployed in your environment. # not deployed in your environment.
cf_text: cf_text:
type: openai_compat type: openai_compat

View file

@ -48,6 +48,21 @@ _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", "")
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")
# Resolve GPU inference server URL.
# 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.
_GPU_SERVER_URL: str | None = (
os.environ.get("GPU_SERVER_URL")
or os.environ.get("CF_ORCH_URL")
or (
"https://orch.circuitforge.tech"
if os.environ.get("CF_LICENSE_KEY")
else None
)
)
if _GPU_SERVER_URL:
os.environ["CF_ORCH_URL"] = _GPU_SERVER_URL
# Per-request DB path — set by cloud_session_middleware; falls back to DB_PATH # Per-request DB path — set by cloud_session_middleware; falls back to DB_PATH
_request_db: ContextVar[str | None] = ContextVar("_request_db", default=None) _request_db: ContextVar[str | None] = ContextVar("_request_db", default=None)
@ -114,6 +129,38 @@ app.include_router(_feedback_router, prefix="/api/feedback")
_log = logging.getLogger("peregrine.session") _log = logging.getLogger("peregrine.session")
# ── Structured auth logging ───────────────────────────────────────────────────
# Writes one JSON line per request to /devl/peregrine-logs/auth.log when in
# cloud mode. Rotates at 10 MB, keeps 5 files. Also logs to stdout in dev.
_AUTH_LOG_DIR = Path(os.environ.get("PEREGRINE_LOG_DIR", "/devl/peregrine-logs"))
class _JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": datetime.now(timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
}
if hasattr(record, "auth_event"):
payload.update(record.auth_event)
return json.dumps(payload)
def _setup_auth_logging() -> None:
from logging.handlers import RotatingFileHandler
_AUTH_LOG_DIR.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(
_AUTH_LOG_DIR / "auth.log", maxBytes=10 * 1024 * 1024, backupCount=5
)
handler.setFormatter(_JsonFormatter())
handler.setLevel(logging.INFO)
_log.addHandler(handler)
_log.setLevel(logging.DEBUG)
_setup_auth_logging()
_seen_users: set[str] = set() # track first-access events within this process lifetime
def _demo_guard() -> None: def _demo_guard() -> None:
"""Raise 403 if running in demo mode. Call at the top of any write endpoint.""" """Raise 403 if running in demo mode. Call at the top of any write endpoint."""
@ -158,6 +205,16 @@ def _resolve_cf_user_id(cookie_str: str) -> str | None:
return None return None
def _auth_log(event: str, **kwargs) -> None:
"""Emit a structured INFO log line to the auth logger."""
record = logging.LogRecord(
name="peregrine.session", level=logging.INFO,
pathname="", lineno=0, msg=event, args=(), exc_info=None,
)
record.auth_event = {"event": event, **kwargs}
_log.handle(record)
@app.middleware("http") @app.middleware("http")
async def cloud_session_middleware(request: Request, call_next): async def cloud_session_middleware(request: Request, call_next):
"""In cloud mode, resolve per-user staging.db from the X-CF-Session header.""" """In cloud mode, resolve per-user staging.db from the X-CF-Session header."""
@ -165,16 +222,36 @@ async def cloud_session_middleware(request: Request, call_next):
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 user_id:
first_access = user_id not in _seen_users
if first_access:
_seen_users.add(user_id)
user_db = str(_CLOUD_DATA_ROOT / user_id / "peregrine" / "staging.db") user_db = str(_CLOUD_DATA_ROOT / user_id / "peregrine" / "staging.db")
if user_db not in _migrated_db_paths: if user_db not in _migrated_db_paths:
from scripts.db_migrate import migrate_db from scripts.db_migrate import migrate_db
migrate_db(Path(user_db)) migrate_db(Path(user_db))
_migrated_db_paths.add(user_db) _migrated_db_paths.add(user_db)
_auth_log(
"session_resolved",
user_id=user_id,
method=request.method,
path=request.url.path,
first_access=first_access,
)
token = _request_db.set(user_db) token = _request_db.set(user_db)
try: try:
return await call_next(request) return await call_next(request)
finally: finally:
_request_db.reset(token) _request_db.reset(token)
else:
# Only log failures on non-trivial paths (skip health checks / static assets)
if request.url.path.startswith("/api/"):
_auth_log(
"session_failed",
method=request.method,
path=request.url.path,
reason="no_user_id",
has_cookie=bool(cookie_header),
)
return await call_next(request) return await call_next(request)
@ -574,6 +651,51 @@ def resume_optimizer_task_status(job_id: int):
return {"status": row["status"], "stage": row["stage"], "message": row["error"]} return {"status": row["status"], "stage": row["stage"], "message": row["error"]}
def _capture_review_corrections(
db_path: Path,
job_id: int,
draft: dict,
decisions: dict,
) -> None:
"""Persist (proposed, accepted) pairs when the user edits LLM output in the review UI.
Only saves corrections where accepted=True AND the user actually modified the
proposed text (proposed != accepted). Rejections carry no training signal.
"""
from scripts.db import save_resume_correction as _save_correction
sections = {s["section"]: s for s in (draft.get("sections") or [])}
# ── Summary correction ────────────────────────────────────────────────────
summary_dec = decisions.get("summary", {})
if summary_dec.get("accepted", True):
edited_text = summary_dec.get("edited_text")
proposed_summary = sections.get("summary", {}).get("proposed", "")
if edited_text is not None and edited_text.strip() != proposed_summary.strip():
_save_correction(db_path, job_id, "summary", proposed_summary, edited_text.strip())
# ── Experience bullet corrections ─────────────────────────────────────────
exp_sec = sections.get("experience", {})
entry_diffs = {
f"{e['title']}|{e['company']}": e
for e in (exp_sec.get("entries") or [])
}
for entry_dec in (decisions.get("experience", {}).get("accepted_entries") or []):
if not entry_dec.get("accepted", True):
continue
edited_bullets = entry_dec.get("edited_bullets")
if edited_bullets is None:
continue
key = f"{entry_dec.get('title', '')}|{entry_dec.get('company', '')}"
diff = entry_diffs.get(key)
if diff is None:
continue
proposed_bullets = diff.get("proposed_bullets") or []
cleaned = [b for b in edited_bullets if b.strip()]
if cleaned != proposed_bullets:
_save_correction(db_path, job_id, f"experience:{key}", proposed_bullets, cleaned)
@app.get("/api/jobs/{job_id}/resume_optimizer/review") @app.get("/api/jobs/{job_id}/resume_optimizer/review")
def get_resume_review(job_id: int): def get_resume_review(job_id: int):
"""Return the pending review draft for this job (populated when task is awaiting_review).""" """Return the pending review draft for this job (populated when task is awaiting_review)."""
@ -630,6 +752,10 @@ def preview_resume_review(job_id: int, body: ResumeReviewBody):
# Step 1: apply section-level decisions # Step 1: apply section-level decisions
struct = apply_review_decisions(draft, body.decisions) struct = apply_review_decisions(draft, body.decisions)
# Step 1b: capture (proposed, accepted) correction pairs for Avocet fine-tuning.
# Only fires when accepted=True and the user actually edited the LLM output.
_capture_review_corrections(db_path, job_id, draft, body.decisions)
# 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:
@ -651,6 +777,19 @@ def preview_resume_review(job_id: int, body: ResumeReviewBody):
return {"preview_text": preview_text, "preview_struct": struct} return {"preview_text": preview_text, "preview_struct": struct}
@app.get("/api/resume_optimizer/corrections")
def list_resume_corrections(job_id: int | None = None, limit: int = 200):
"""Return resume review correction pairs for Avocet import.
Each record is a (proposed, accepted) pair from the review UI where the
user edited the LLM output before accepting. These are SFT (supervised
fine-tuning) candidates that flow through Avocet for human review.
"""
from scripts.db import get_resume_corrections as _get_corrections
db_path = Path(_request_db.get() or DB_PATH)
return {"corrections": _get_corrections(db_path, limit=limit, job_id=job_id)}
@app.post("/api/jobs/{job_id}/resume_optimizer/approve") @app.post("/api/jobs/{job_id}/resume_optimizer/approve")
def approve_resume(job_id: int, body: dict): def approve_resume(job_id: int, body: dict):
"""Save the user-approved assembled resume struct and mark the task complete. """Save the user-approved assembled resume struct and mark the task complete.
@ -667,7 +806,8 @@ def approve_resume(job_id: int, body: dict):
raise HTTPException(400, "preview_struct is required") raise HTTPException(400, "preview_struct is required")
from scripts.resume_optimizer import render_resume_text from scripts.resume_optimizer import render_resume_text
final_text = render_resume_text(struct) override = (body.get("preview_text_override") or "").strip()
final_text = override if override else render_resume_text(struct)
# Persist plain text + struct (struct enables YAML export later) # Persist plain text + struct (struct enables YAML export later)
_finalize(db_path=db_path, job_id=job_id, final_text=final_text) _finalize(db_path=db_path, job_id=job_id, final_text=final_text)
@ -1676,6 +1816,16 @@ def list_contacts(job_id: Optional[int] = None, direction: Optional[str] = None,
return {"total": total, "contacts": [dict(r) for r in rows]} return {"total": total, "contacts": [dict(r) for r in rows]}
@app.get("/api/contacts/{contact_id}")
def get_contact(contact_id: int):
db = _get_db()
row = db.execute("SELECT * FROM job_contacts WHERE id = ?", (contact_id,)).fetchone()
db.close()
if not row:
raise HTTPException(status_code=404, detail="Contact not found")
return dict(row)
# ── References ───────────────────────────────────────────────────────────────── # ── References ─────────────────────────────────────────────────────────────────
class ReferencePayload(BaseModel): class ReferencePayload(BaseModel):
@ -2111,6 +2261,7 @@ def bulk_purge_jobs(body: BulkPurgeBody):
class AddJobsBody(BaseModel): class AddJobsBody(BaseModel):
urls: List[str] urls: List[str]
skip_review: bool = True
@app.post("/api/jobs/add", status_code=202) @app.post("/api/jobs/add", status_code=202)
@ -2122,6 +2273,7 @@ def add_jobs_by_url(body: AddJobsBody):
from scripts.task_runner import submit_task from scripts.task_runner import submit_task
db_path = _db_path() db_path = _db_path()
existing = get_existing_urls(db_path) existing = get_existing_urls(db_path)
status = "approved" if body.skip_review else "pending"
queued = 0 queued = 0
for raw_url in body.urls: for raw_url in body.urls:
url = canonicalize_url(raw_url.strip()) url = canonicalize_url(raw_url.strip())
@ -2131,6 +2283,7 @@ def add_jobs_by_url(body: AddJobsBody):
"title": "Importing...", "company": "", "url": url, "title": "Importing...", "company": "", "url": url,
"source": "manual", "location": "", "description": "", "source": "manual", "location": "", "description": "",
"date_found": _dt.now().isoformat()[:10], "date_found": _dt.now().isoformat()[:10],
"status": status,
}) })
if job_id: if job_id:
submit_task(db_path, "scrape_url", job_id) submit_task(db_path, "scrape_url", job_id)
@ -3581,8 +3734,13 @@ def finetune_status():
db_count = task.get("result_count", 0) or 0 db_count = task.get("result_count", 0) or 0
pairs_count = max(pairs_count, db_count) pairs_count = max(pairs_count, db_count)
status = task.get("status", "idle") if task else "idle" status = task.get("status", "idle") if task else "idle"
try:
from scripts.user_profile import UserProfile
_opted_in = UserProfile(Path(_user_yaml_path())).training_export_opt_in
except Exception:
_opted_in = False
# Stub quota for self-hosted; cloud overrides via its own middleware # Stub quota for self-hosted; cloud overrides via its own middleware
return {"status": status, "pairs_count": pairs_count, "quota_remaining": None} return {"status": status, "pairs_count": pairs_count, "quota_remaining": None, "opted_in": _opted_in}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@ -3663,6 +3821,117 @@ def finetune_local_status():
return {"model_ready": False} return {"model_ready": False}
# ── Settings: Fine-Tune — Training Export ─────────────────────────────────────
class TrainingOptInBody(BaseModel):
enabled: bool
def _training_opt_in_required() -> None:
"""Raise 403 if training_export_opt_in is not enabled in user profile."""
try:
from scripts.user_profile import UserProfile
profile = UserProfile(Path(_user_yaml_path()))
if not profile.training_export_opt_in:
raise HTTPException(
status_code=403,
detail="Training export is not enabled. Enable it in Settings → Fine-Tune.",
)
except FileNotFoundError:
raise HTTPException(
status_code=403,
detail="Training export is not enabled. Enable it in Settings → Fine-Tune.",
)
@app.patch("/api/settings/fine-tune/opt-in")
def set_training_opt_in(body: TrainingOptInBody):
try:
from scripts.user_profile import UserProfile
profile = UserProfile(Path(_user_yaml_path()))
profile.training_export_opt_in = body.enabled
profile.save()
return {"ok": True, "enabled": profile.training_export_opt_in}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/settings/fine-tune/db-pairs")
def list_db_pairs():
_training_opt_in_required()
try:
from scripts.db import get_db_pairs
db_path = Path(_request_db.get() or DB_PATH)
pairs = get_db_pairs(db_path)
excluded_count = sum(1 for p in pairs if p["excluded"])
return {
"pairs": pairs,
"total": len(pairs),
"excluded_count": excluded_count,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.patch("/api/settings/fine-tune/db-pairs/{job_id}/exclude")
def exclude_db_pair(job_id: int):
_training_opt_in_required()
try:
from scripts.db import set_training_exclusion
set_training_exclusion(Path(_request_db.get() or DB_PATH), job_id, excluded=True)
return {"ok": True, "job_id": job_id}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.patch("/api/settings/fine-tune/db-pairs/{job_id}/include")
def include_db_pair(job_id: int):
_training_opt_in_required()
try:
from scripts.db import set_training_exclusion
set_training_exclusion(Path(_request_db.get() or DB_PATH), job_id, excluded=False)
return {"ok": True, "job_id": job_id}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/settings/fine-tune/export")
def export_training_jsonl():
_training_opt_in_required()
import json as _json
from fastapi.responses import StreamingResponse
from scripts.db import get_training_pairs
db_path = Path(_request_db.get() or DB_PATH)
db_pairs = get_training_pairs(db_path)
file_pairs = _load_training_pairs()
def _generate():
for pair in db_pairs:
yield _json.dumps(pair, ensure_ascii=False) + "\n"
for pair in file_pairs:
record = dict(pair)
record.setdefault("source", "file")
yield _json.dumps(record, ensure_ascii=False) + "\n"
return StreamingResponse(
_generate(),
media_type="application/x-ndjson",
headers={"Content-Disposition": 'attachment; filename="peregrine_training_pairs.jsonl"'},
)
# Phase 2 stubs — reserved, not yet implemented
@app.post("/api/settings/fine-tune/cloud-request")
def cloud_finetune_request():
raise HTTPException(status_code=501, detail="Cloud fine-tune is not yet available.")
@app.get("/api/settings/fine-tune/cloud-status")
def cloud_finetune_status():
raise HTTPException(status_code=501, detail="Cloud fine-tune is not yet available.")
# ── Settings: License ───────────────────────────────────────────────────────── # ── Settings: License ─────────────────────────────────────────────────────────
# _config_dir() / _license_path() / _tokens_path() are per-request (see helpers above) # _config_dir() / _license_path() / _tokens_path() are per-request (see helpers above)

View file

@ -11,7 +11,7 @@ Thank you for your interest in contributing to Peregrine. This guide covers the
## Fork and Clone ## Fork and Clone
```bash ```bash
git clone https://git.circuitforge.io/circuitforge/peregrine git clone https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine
cd peregrine cd peregrine
``` ```

View file

@ -12,7 +12,7 @@ Peregrine automates the full job search lifecycle: discovery, matching, cover le
```bash ```bash
# 1. Clone and install dependencies # 1. Clone and install dependencies
git clone https://git.circuitforge.io/circuitforge/peregrine git clone https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine
cd peregrine cd peregrine
bash install.sh bash install.sh
@ -31,20 +31,23 @@ The first-run wizard guides you through hardware detection, tier selection, iden
## Feature Overview ## Feature Overview
| Feature | Free | Paid | Premium | | Feature | Free | Paid | Premium |
|---------|------|------|---------| |---------|------|-------|---------|
| Job discovery (JobSpy + custom boards) | Yes | Yes | Yes | | Job discovery (JobSpy + custom boards) | Yes | Yes | Yes |
| Resume keyword matching | Yes | Yes | Yes | | Resume keyword matching | Yes | Yes | Yes |
| Cover letter generation | - | Yes | Yes | | Cover letter generation | BYOK‡ | Yes | Yes |
| Company research briefs | - | Yes | Yes | | Company research briefs | BYOK‡ | Yes | Yes |
| Interview prep & practice Q&A | - | Yes | Yes | | Interview prep & practice Q&A | BYOK‡ | Yes | Yes |
| Email sync & auto-classification | - | Yes | Yes | | Email sync & auto-classification | - | Yes | Yes |
| Survey assistant (culture-fit Q&A) | - | Yes | Yes | | Survey assistant (culture-fit Q&A) | BYOK‡ | Yes | Yes |
| Integration connectors (Notion, Airtable, etc.) | Partial | Yes | Yes | | Integration connectors (Notion, Airtable, etc.) | Partial | Yes | Yes |
| Calendar sync (Google, Apple) | - | Yes | Yes | | Calendar sync (Google, Apple) | - | Yes | Yes |
| Cover letter model fine-tuning | - | - | Yes | | Cover letter model fine-tuning | - | - | Yes |
| Multi-user support | - | - | Yes | | Multi-user support | - | - | Yes |
**Paid** gives access to CircuitForge's hosted inference — no API key required.
**BYOK** — configure any LLM backend in `config/llm.yaml` (local Ollama/vLLM or an API key) and these features unlock at no charge, regardless of tier.
See [Tier System](reference/tier-system.md) for the full feature gate table. See [Tier System](reference/tier-system.md) for the full feature gate table.
--- ---
@ -60,8 +63,8 @@ See [Tier System](reference/tier-system.md) for the full feature gate table.
## License ## License
Core discovery pipeline: [MIT](https://git.circuitforge.io/circuitforge/peregrine/src/branch/main/LICENSE-MIT) Core discovery pipeline: [MIT](https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine/src/branch/main/LICENSE-MIT)
AI features (cover letter generation, company research, interview prep, UI): [BSL 1.1](https://git.circuitforge.io/circuitforge/peregrine/src/branch/main/LICENSE-BSL) AI features (cover letter generation, company research, interview prep, UI): [BSL 1.1](https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine/src/branch/main/LICENSE-BSL)
© 2026 Circuit Forge LLC © 2026 Circuit Forge LLC

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 73 KiB

View file

@ -15,6 +15,11 @@ cd "$SCRIPT_DIR"
PROFILE="${PROFILE:-remote}" PROFILE="${PROFILE:-remote}"
# ── Compose engine detection ──────────────────────────────────────────────────
COMPOSE="$(command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 \
&& echo "docker compose" \
|| (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))"
# ── Usage ──────────────────────────────────────────────────────────────────── # ── Usage ────────────────────────────────────────────────────────────────────
usage() { usage() {
echo "" echo ""
@ -28,9 +33,10 @@ usage() {
echo -e " ${GREEN}start${NC} Start Peregrine (preflight → up)" echo -e " ${GREEN}start${NC} Start Peregrine (preflight → up)"
echo -e " ${GREEN}stop${NC} Stop all services" echo -e " ${GREEN}stop${NC} Stop all services"
echo -e " ${GREEN}restart${NC} Restart all services" echo -e " ${GREEN}restart${NC} Restart all services"
echo -e " ${GREEN}build [service]${NC} Rebuild image(s) without restarting (default: api web)"
echo -e " ${GREEN}status${NC} Show running containers" echo -e " ${GREEN}status${NC} Show running containers"
echo -e " ${GREEN}logs [service]${NC} Tail logs (default: app)" echo -e " ${GREEN}logs [service]${NC} Tail logs (default: api)"
echo -e " ${GREEN}update${NC} Pull latest images + rebuild app" echo -e " ${GREEN}update${NC} Pull latest images + rebuild"
echo -e " ${GREEN}preflight${NC} Check ports + resources; write .env" echo -e " ${GREEN}preflight${NC} Check ports + resources; write .env"
echo -e " ${GREEN}models${NC} Check ollama models in config; pull any missing" echo -e " ${GREEN}models${NC} Check ollama models in config; pull any missing"
echo -e " ${GREEN}test${NC} Run test suite" echo -e " ${GREEN}test${NC} Run test suite"
@ -41,6 +47,12 @@ usage() {
echo -e " ${GREEN}clean${NC} Remove containers, images, volumes (DESTRUCTIVE)" echo -e " ${GREEN}clean${NC} Remove containers, images, volumes (DESTRUCTIVE)"
echo -e " ${GREEN}open${NC} Open the web UI in your browser" echo -e " ${GREEN}open${NC} Open the web UI in your browser"
echo "" echo ""
echo -e " Cloud / demo commands:"
echo -e " ${GREEN}cloud-start${NC} Start the cloud stack (peregrine-cloud)"
echo -e " ${GREEN}cloud-restart${NC} Rebuild + restart the cloud stack"
echo -e " ${GREEN}demo-start${NC} Start the demo stack (peregrine-demo)"
echo -e " ${GREEN}demo-restart${NC} Rebuild + restart the demo stack"
echo ""
echo " Profiles (set via --profile or PROFILE env var):" echo " Profiles (set via --profile or PROFILE env var):"
echo " remote API-only, no local inference (default)" echo " remote API-only, no local inference (default)"
echo " cpu Local Ollama inference on CPU" echo " cpu Local Ollama inference on CPU"
@ -70,7 +82,7 @@ while [[ $# -gt 0 ]]; do
esac esac
done done
SERVICE="${1:-app}" # used by `logs` command SERVICE="${1:-api}" # used by `logs` command
# ── Dependency guard ────────────────────────────────────────────────────────── # ── Dependency guard ──────────────────────────────────────────────────────────
# Commands that delegate to make; others (status, logs, update, open, setup) run fine without it. # Commands that delegate to make; others (status, logs, update, open, setup) run fine without it.
@ -101,7 +113,7 @@ case "$CMD" in
start) start)
info "Starting Peregrine (PROFILE=${PROFILE})..." info "Starting Peregrine (PROFILE=${PROFILE})..."
make start PROFILE="$PROFILE" make start PROFILE="$PROFILE"
PORT="$(grep -m1 '^STREAMLIT_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8501)" PORT="$(grep -m1 '^VUE_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8506)"
success "Peregrine is up → http://localhost:${PORT}" success "Peregrine is up → http://localhost:${PORT}"
;; ;;
@ -114,33 +126,30 @@ case "$CMD" in
restart) restart)
info "Restarting (PROFILE=${PROFILE})..." info "Restarting (PROFILE=${PROFILE})..."
make restart PROFILE="$PROFILE" make restart PROFILE="$PROFILE"
PORT="$(grep -m1 '^STREAMLIT_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8501)" PORT="$(grep -m1 '^VUE_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8506)"
success "Peregrine restarted → http://localhost:${PORT}" success "Peregrine restarted → http://localhost:${PORT}"
;; ;;
status) status)
# Auto-detect compose engine same way Makefile does
COMPOSE="$(command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 \
&& echo "docker compose" \
|| (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))"
$COMPOSE ps $COMPOSE ps
;; ;;
logs) logs)
COMPOSE="$(command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 \
&& echo "docker compose" \
|| (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))"
info "Tailing logs for: ${SERVICE}" info "Tailing logs for: ${SERVICE}"
$COMPOSE logs -f "$SERVICE" $COMPOSE logs -f "$SERVICE"
;; ;;
build)
BUILD_SVC="$([[ "${SERVICE}" == "api" ]] && echo "api web" || echo "${SERVICE}")"
info "Building ${BUILD_SVC}..."
$COMPOSE build $BUILD_SVC
success "Build complete. Run './manage.sh restart' to apply."
;;
update) update)
info "Pulling latest images and rebuilding app..." info "Pulling latest images and rebuilding..."
COMPOSE="$(command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 \
&& echo "docker compose" \
|| (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))"
$COMPOSE pull searxng ollama 2>/dev/null || true $COMPOSE pull searxng ollama 2>/dev/null || true
$COMPOSE build app web $COMPOSE build api web
success "Update complete. Run './manage.sh restart' to apply." success "Update complete. Run './manage.sh restart' to apply."
;; ;;
@ -167,7 +176,7 @@ case "$CMD" in
;; ;;
open) open)
PORT="$(grep -m1 '^STREAMLIT_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8501)" PORT="$(grep -m1 '^VUE_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8506)"
URL="http://localhost:${PORT}" URL="http://localhost:${PORT}"
info "Opening ${URL}" info "Opening ${URL}"
if command -v xdg-open &>/dev/null; then if command -v xdg-open &>/dev/null; then
@ -197,6 +206,32 @@ case "$CMD" in
-v "${@:3}" -v "${@:3}"
;; ;;
cloud-start)
info "Starting cloud stack (peregrine-cloud)..."
$COMPOSE -f compose.cloud.yml --project-name peregrine-cloud up -d
success "Cloud stack up → http://localhost:8508"
;;
cloud-restart)
info "Rebuilding + restarting cloud stack (peregrine-cloud)..."
$COMPOSE -f compose.cloud.yml --project-name peregrine-cloud build api web
$COMPOSE -f compose.cloud.yml --project-name peregrine-cloud up -d
success "Cloud stack restarted → http://localhost:8508"
;;
demo-start)
info "Starting demo stack (peregrine-demo)..."
$COMPOSE -f compose.demo.yml --project-name peregrine-demo up -d
success "Demo stack up → http://localhost:8504"
;;
demo-restart)
info "Rebuilding + restarting demo stack (peregrine-demo)..."
$COMPOSE -f compose.demo.yml --project-name peregrine-demo build api web
$COMPOSE -f compose.demo.yml --project-name peregrine-demo up -d
success "Demo stack restarted → http://localhost:8504"
;;
help|--help|-h) help|--help|-h)
usage usage
;; ;;

View file

@ -0,0 +1 @@
ALTER TABLE jobs ADD COLUMN excluded_from_training INTEGER DEFAULT 0;

View file

@ -0,0 +1,89 @@
"""
Peregrine cloud session thin wrapper around cf_core.cloud_session.
Sets request-scoped ContextVars with the authenticated user_id, tier, and
custom writing model so that _allocate_orch_async in llm.py can forward them
to cf-orch without any service function signature changes.
Usage add to main.py once:
from app.cloud_session import session_middleware_dep
app = FastAPI(..., dependencies=[Depends(session_middleware_dep)])
From that point, any route (and every service/llm function it calls)
has access to the current user context via llm.get_request_*() helpers.
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
import json
import logging
import os
from fastapi import Depends, Request, Response
from circuitforge_core.cloud_session import CloudSessionFactory, CloudUser, detect_byok
log = logging.getLogger(__name__)
__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(
product="peregrine",
byok_detector=detect_byok,
)
get_session = _factory.dependency()
require_tier = _factory.require_tier
def session_middleware_dep(request: Request, response: Response) -> None:
"""Global FastAPI dependency — resolves the session and sets request-scoped
ContextVars so llm._allocate_orch_async can forward them to cf-orch.
Sets:
- user_id: real cloud UUID, or None for local/anon sessions
- tier: the resolved tier string (free/paid/premium/ultra/local)
- writing_model: custom fine-tuned model from Heimdall meta, or None
Add as a global dependency in main.py:
app = FastAPI(..., dependencies=[Depends(session_middleware_dep)])
"""
from app.llm import set_request_tier, set_request_user_id, set_request_writing_model
session = _factory.resolve(request, response)
user_id = session.user_id
# Only forward real cloud UUIDs — local/dev/anon sessions use the shared catalog
if user_id in (None, "local", "local-dev") or (user_id or "").startswith("anon-"):
user_id = None
set_request_user_id(user_id)
set_request_tier(session.tier)
# Resolution order: env-var map (Monday path) → Heimdall meta (future path)
writing_model = (
_USER_WRITING_MODELS.get(session.user_id)
or session.meta.get("custom_writing_model")
)
set_request_writing_model(writing_model)

View file

@ -0,0 +1,843 @@
"""LiteLLM wrapper for multi-provider AI support."""
import json
import logging
import os
import re
from contextlib import asynccontextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Any
import httpx
import litellm
from pydantic import BaseModel
from app.config import settings
# LLM timeout configuration (seconds) - base values
LLM_TIMEOUT_HEALTH_CHECK = 30
LLM_TIMEOUT_COMPLETION = 120
LLM_TIMEOUT_JSON = 180 # JSON completions may take longer
# LLM-004: OpenRouter JSON-capable models (explicit allowlist)
OPENROUTER_JSON_CAPABLE_MODELS = {
# Anthropic models
"anthropic/claude-3-opus",
"anthropic/claude-3-sonnet",
"anthropic/claude-3-haiku",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3.5-haiku",
"anthropic/claude-haiku-4-5-20251001",
"anthropic/claude-sonnet-4-20250514",
"anthropic/claude-opus-4-20250514",
# OpenAI models
"openai/gpt-4-turbo",
"openai/gpt-4",
"openai/gpt-4o",
"openai/gpt-4o-mini",
"openai/gpt-3.5-turbo",
"openai/gpt-5-nano-2025-08-07",
# Google models
"google/gemini-pro",
"google/gemini-1.5-pro",
"google/gemini-1.5-flash",
"google/gemini-2.0-flash",
"google/gemini-3-flash-preview",
# DeepSeek models
"deepseek/deepseek-chat",
"deepseek/deepseek-reasoner",
# Mistral models
"mistralai/mistral-large",
"mistralai/mistral-medium",
}
# JSON-010: JSON extraction safety limits
MAX_JSON_EXTRACTION_RECURSION = 10
MAX_JSON_CONTENT_SIZE = 1024 * 1024 # 1MB
# Request-scoped user_id — set once by session_middleware_dep, read inside _allocate_orch_async.
# ContextVar is safe for concurrent async requests: each request task gets its own copy.
_request_user_id: ContextVar[str | None] = ContextVar("request_user_id", default=None)
_request_tier: ContextVar[str | None] = ContextVar("request_tier", default=None)
# Custom writing model for premium/ultra users — populated from Heimdall license key meta.
# Set to None for all other tiers; complete() falls back to the shared base model.
_request_writing_model: ContextVar[str | None] = ContextVar("request_writing_model", default=None)
_PREMIUM_TIERS: frozenset[str] = frozenset({"premium", "ultra"})
def set_request_user_id(user_id: str | None) -> None:
_request_user_id.set(user_id)
def get_request_user_id() -> str | None:
return _request_user_id.get()
def set_request_tier(tier: str | None) -> None:
_request_tier.set(tier)
def get_request_tier() -> str | None:
return _request_tier.get()
def set_request_writing_model(model: str | None) -> None:
_request_writing_model.set(model)
def get_request_writing_model() -> str | None:
return _request_writing_model.get()
class LLMConfig(BaseModel):
"""LLM configuration model."""
provider: str
model: str
api_key: str
api_base: str | None = None
@dataclass
class _OrchAllocation:
allocation_id: str
url: str
service: str
@asynccontextmanager
async def _allocate_orch_async(
coordinator_url: str,
service: str,
model_candidates: list[str],
ttl_s: float,
caller: str,
):
"""Async context manager that allocates a cf-orch service and releases on exit."""
async with httpx.AsyncClient(timeout=120.0) as client:
payload: dict[str, Any] = {
"model_candidates": model_candidates,
"ttl_s": ttl_s,
"caller": caller,
}
uid = get_request_user_id()
if uid:
payload["user_id"] = uid
resp = await client.post(
f"{coordinator_url.rstrip('/')}/api/services/{service}/allocate",
json=payload,
)
if not resp.is_success:
raise RuntimeError(
f"cf-orch allocation failed for {service!r}: "
f"HTTP {resp.status_code}{resp.text[:200]}"
)
data = resp.json()
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 release failed (non-fatal): %s", exc)
def _normalize_api_base(provider: str, api_base: str | None) -> str | None:
"""Normalize api_base for LiteLLM provider-specific expectations.
When using proxies/aggregators, users often paste a base URL that already
includes a version segment (e.g., `/v1`). Some LiteLLM provider handlers
append those segments internally, which can lead to duplicated paths like
`/v1/v1/...` and cause 404s.
"""
if not api_base:
return None
base = api_base.strip()
if not base:
return None
base = base.rstrip("/")
# Anthropic handler appends '/v1/messages'. If base already ends with '/v1',
# strip it to avoid '/v1/v1/messages'.
if provider == "anthropic" and base.endswith("/v1"):
base = base[: -len("/v1")].rstrip("/")
# Gemini handler appends '/v1/models/...'. If base already ends with '/v1',
# strip it to avoid '/v1/v1/models/...'.
if provider == "gemini" and base.endswith("/v1"):
base = base[: -len("/v1")].rstrip("/")
return base or None
def _extract_text_parts(value: Any, depth: int = 0, max_depth: int = 10) -> list[str]:
"""Recursively extract text segments from nested response structures.
Handles strings, lists, dicts with 'text'/'content'/'value' keys, and objects
with text/content attributes. Limits recursion depth to avoid cycles.
Args:
value: Input value that may contain text in strings, lists, dicts, or objects.
depth: Current recursion depth.
max_depth: Maximum recursion depth before returning no content.
Returns:
A list of extracted text segments.
"""
if depth >= max_depth:
return []
if value is None:
return []
if isinstance(value, str):
return [value]
if isinstance(value, list):
parts: list[str] = []
next_depth = depth + 1
for item in value:
parts.extend(_extract_text_parts(item, next_depth, max_depth))
return parts
if isinstance(value, dict):
next_depth = depth + 1
if "text" in value:
return _extract_text_parts(value.get("text"), next_depth, max_depth)
if "content" in value:
return _extract_text_parts(value.get("content"), next_depth, max_depth)
if "value" in value:
return _extract_text_parts(value.get("value"), next_depth, max_depth)
return []
next_depth = depth + 1
if hasattr(value, "text"):
return _extract_text_parts(getattr(value, "text"), next_depth, max_depth)
if hasattr(value, "content"):
return _extract_text_parts(getattr(value, "content"), next_depth, max_depth)
return []
def _join_text_parts(parts: list[str]) -> str | None:
"""Join text parts with newlines, filtering empty strings.
Args:
parts: Candidate text segments.
Returns:
Joined string or None if the result is empty.
"""
joined = "\n".join(part for part in parts if part).strip()
return joined or None
def _extract_message_text(message: Any) -> str | None:
"""Extract plain text from a LiteLLM message object across providers."""
content: Any = None
if hasattr(message, "content"):
content = message.content
elif isinstance(message, dict):
content = message.get("content")
return _join_text_parts(_extract_text_parts(content))
def _extract_choice_text(choice: Any) -> str | None:
"""Extract plain text from a LiteLLM choice object.
Tries message.content first, then choice.text, then choice.delta. Handles both
object attributes and dict keys.
Args:
choice: LiteLLM choice object or dict.
Returns:
Extracted text or None if no content is found.
"""
message: Any = None
if hasattr(choice, "message"):
message = choice.message
elif isinstance(choice, dict):
message = choice.get("message")
content = _extract_message_text(message)
if content:
return content
if hasattr(choice, "text"):
content = _join_text_parts(_extract_text_parts(getattr(choice, "text")))
if content:
return content
if isinstance(choice, dict) and "text" in choice:
content = _join_text_parts(_extract_text_parts(choice.get("text")))
if content:
return content
if hasattr(choice, "delta"):
content = _join_text_parts(_extract_text_parts(getattr(choice, "delta")))
if content:
return content
if isinstance(choice, dict) and "delta" in choice:
content = _join_text_parts(_extract_text_parts(choice.get("delta")))
if content:
return content
return None
def _to_code_block(content: str | None, language: str = "text") -> str:
"""Wrap content in a markdown code block for client display."""
text = (content or "").strip()
if not text:
text = "<empty>"
return f"```{language}\n{text}\n```"
def _load_stored_config() -> dict:
"""Load config from config.json file."""
config_path = settings.config_path
if config_path.exists():
try:
return json.loads(config_path.read_text())
except (json.JSONDecodeError, OSError):
return {}
return {}
def get_llm_config() -> LLMConfig:
"""Get current LLM configuration.
Priority: config.json file > environment variables/settings
"""
stored = _load_stored_config()
return LLMConfig(
provider=stored.get("provider", settings.llm_provider),
model=stored.get("model", settings.llm_model),
api_key=stored.get("api_key", settings.llm_api_key),
api_base=stored.get("api_base", settings.llm_api_base),
)
def get_model_name(config: LLMConfig) -> str:
"""Convert provider/model to LiteLLM format.
For most providers, adds the provider prefix if not already present.
For OpenRouter, always adds 'openrouter/' prefix since OpenRouter models
use nested prefixes like 'openrouter/anthropic/claude-3.5-sonnet'.
"""
provider_prefixes = {
"openai": "", # OpenAI models don't need prefix
"anthropic": "anthropic/",
"openrouter": "openrouter/",
"gemini": "gemini/",
"deepseek": "deepseek/",
"ollama": "ollama/",
}
prefix = provider_prefixes.get(config.provider, "")
# OpenRouter is special: always add openrouter/ prefix unless already present
# OpenRouter models use nested format: openrouter/anthropic/claude-3.5-sonnet
if config.provider == "openrouter":
if config.model.startswith("openrouter/"):
return config.model
return f"openrouter/{config.model}"
# For other providers, don't add prefix if model already has a known prefix
known_prefixes = ["openrouter/", "anthropic/", "gemini/", "deepseek/", "ollama/"]
if any(config.model.startswith(p) for p in known_prefixes):
return config.model
# Add provider prefix for models that need it
return f"{prefix}{config.model}" if prefix else config.model
def _supports_temperature(provider: str, model: str) -> bool:
"""Return whether passing `temperature` is supported for this model/provider combo.
Some models (e.g., OpenAI gpt-5 family) reject temperature values other than 1,
and LiteLLM may error when temperature is passed.
"""
_ = provider
model_lower = model.lower()
if "gpt-5" in model_lower:
return False
return True
def _get_reasoning_effort(provider: str, model: str) -> str | None:
"""Return a default reasoning_effort for models that require it.
Some OpenAI gpt-5 models may return empty message.content unless a supported
`reasoning_effort` is explicitly set. This keeps downstream JSON parsing reliable.
"""
_ = provider
model_lower = model.lower()
if "gpt-5" in model_lower:
return "minimal"
return None
async def check_llm_health(
config: LLMConfig | None = None,
*,
include_details: bool = False,
test_prompt: str | None = None,
) -> dict[str, Any]:
"""Check if the LLM provider is accessible and working."""
if config is None:
config = get_llm_config()
# Check if API key is configured (except for Ollama)
if config.provider != "ollama" and not config.api_key:
return {
"healthy": False,
"provider": config.provider,
"model": config.model,
"error_code": "api_key_missing",
}
model_name = get_model_name(config)
prompt = test_prompt or "Hi"
try:
# Make a minimal test call with timeout
# Pass API key directly to avoid race conditions with global os.environ
kwargs: dict[str, Any] = {
"model": model_name,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 16,
"api_key": config.api_key,
"api_base": _normalize_api_base(config.provider, config.api_base),
"timeout": LLM_TIMEOUT_HEALTH_CHECK,
}
reasoning_effort = _get_reasoning_effort(config.provider, model_name)
if reasoning_effort:
kwargs["reasoning_effort"] = reasoning_effort
response = await litellm.acompletion(**kwargs)
content = _extract_choice_text(response.choices[0])
if not content:
# LLM-003: Empty response should mark health check as unhealthy
logging.warning(
"LLM health check returned empty content",
extra={"provider": config.provider, "model": config.model},
)
result: dict[str, Any] = {
"healthy": False, # Fixed: empty content means unhealthy
"provider": config.provider,
"model": config.model,
"response_model": response.model if response else None,
"error_code": "empty_content", # Changed from warning_code
"message": "LLM returned empty response",
}
if include_details:
result["test_prompt"] = _to_code_block(prompt)
result["model_output"] = _to_code_block(None)
return result
result = {
"healthy": True,
"provider": config.provider,
"model": config.model,
"response_model": response.model if response else None,
}
if include_details:
result["test_prompt"] = _to_code_block(prompt)
result["model_output"] = _to_code_block(content)
return result
except Exception as e:
# Log full exception details server-side, but do not expose them to clients
logging.exception(
"LLM health check failed",
extra={"provider": config.provider, "model": config.model},
)
# Provide a minimal, actionable client-facing hint without leaking secrets.
error_code = "health_check_failed"
message = str(e)
if "404" in message and "/v1/v1/" in message:
error_code = "duplicate_v1_path"
elif "404" in message:
error_code = "not_found_404"
elif "<!doctype html" in message.lower() or "<html" in message.lower():
error_code = "html_response"
result = {
"healthy": False,
"provider": config.provider,
"model": config.model,
"error_code": error_code,
}
if include_details:
result["test_prompt"] = _to_code_block(prompt)
result["model_output"] = _to_code_block(None)
result["error_detail"] = _to_code_block(message)
return result
async def complete(
prompt: str,
system_prompt: str | None = None,
config: LLMConfig | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
) -> str:
"""Make a completion request to the LLM."""
if config is None:
cf_orch_url = os.environ.get("CF_ORCH_URL", "").strip()
if cf_orch_url:
try:
# Premium/ultra users get their personal fine-tuned writing model as the
# first candidate; the base model is the fallback so cf-orch can
# degrade gracefully if the personal model isn't loaded yet.
tier = get_request_tier()
writing_model = get_request_writing_model()
model_candidates: list[str] = (
[writing_model, "Qwen2.5-3B-Instruct"]
if writing_model and tier in _PREMIUM_TIERS
else ["Qwen2.5-3B-Instruct"]
)
async with _allocate_orch_async(
cf_orch_url,
"vllm",
model_candidates=model_candidates,
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 Exception as exc:
logging.warning("cf-orch allocation failed, falling back to default config: %s", exc)
config = get_llm_config()
model_name = get_model_name(config)
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
try:
# Pass API key directly to avoid race conditions with global os.environ
kwargs: dict[str, Any] = {
"model": model_name,
"messages": messages,
"max_tokens": max_tokens,
"api_key": config.api_key,
"api_base": _normalize_api_base(config.provider, config.api_base),
"timeout": LLM_TIMEOUT_COMPLETION,
}
if _supports_temperature(config.provider, model_name):
kwargs["temperature"] = temperature
reasoning_effort = _get_reasoning_effort(config.provider, model_name)
if reasoning_effort:
kwargs["reasoning_effort"] = reasoning_effort
response = await litellm.acompletion(**kwargs)
content = _extract_choice_text(response.choices[0])
if not content:
raise ValueError("Empty response from LLM")
return content
except Exception as e:
# Log the actual error server-side for debugging
logging.error(f"LLM completion failed: {e}", extra={"model": model_name})
raise ValueError(
"LLM completion failed. Please check your API configuration and try again."
) from e
def _supports_json_mode(provider: str, model: str) -> bool:
"""Check if the model supports JSON mode."""
# Models that support response_format={"type": "json_object"}
json_mode_providers = ["openai", "anthropic", "gemini", "deepseek"]
if provider in json_mode_providers:
return True
# LLM-004: OpenRouter models - use explicit allowlist instead of substring matching
if provider == "openrouter":
return model in OPENROUTER_JSON_CAPABLE_MODELS
return False
def _appears_truncated(data: dict) -> bool:
"""LLM-001: Check if JSON data appears to be truncated.
Detects suspicious patterns indicating incomplete responses.
"""
if not isinstance(data, dict):
return False
# Check for empty arrays that should typically have content
suspicious_empty_arrays = ["workExperience", "education", "skills"]
for key in suspicious_empty_arrays:
if key in data and data[key] == []:
# Log warning - these are rarely empty in real resumes
logging.warning(
"Possible truncation detected: '%s' is empty",
key,
)
return True
# Check for missing critical sections
required_top_level = ["personalInfo"]
for key in required_top_level:
if key not in data:
logging.warning(
"Possible truncation detected: missing required section '%s'",
key,
)
return True
return False
def _get_retry_temperature(attempt: int, base_temp: float = 0.1) -> float:
"""LLM-002: Get temperature for retry attempt - increases with each retry.
Higher temperature on retries gives the model more variation to produce
different (hopefully valid) output.
"""
temperatures = [base_temp, 0.3, 0.5, 0.7]
return temperatures[min(attempt, len(temperatures) - 1)]
def _calculate_timeout(
operation: str,
max_tokens: int = 4096,
provider: str = "openai",
) -> int:
"""LLM-005: Calculate adaptive timeout based on operation and parameters."""
base_timeouts = {
"health_check": LLM_TIMEOUT_HEALTH_CHECK,
"completion": LLM_TIMEOUT_COMPLETION,
"json": LLM_TIMEOUT_JSON,
}
base = base_timeouts.get(operation, LLM_TIMEOUT_COMPLETION)
# Scale by token count (relative to 4096 baseline)
token_factor = max(1.0, max_tokens / 4096)
# Provider-specific latency adjustments
provider_factors = {
"openai": 1.0,
"anthropic": 1.2,
"openrouter": 1.5, # More variable latency
"ollama": 2.0, # Local models can be slower
}
provider_factor = provider_factors.get(provider, 1.0)
return int(base * token_factor * provider_factor)
def _extract_json(content: str, _depth: int = 0) -> str:
"""Extract JSON from LLM response, handling various formats.
LLM-001: Improved to detect and reject likely truncated JSON.
LLM-007: Improved error messages for debugging.
JSON-010: Added recursion depth and size limits.
"""
# JSON-010: Safety limits
if _depth > MAX_JSON_EXTRACTION_RECURSION:
raise ValueError(f"JSON extraction exceeded max recursion depth: {_depth}")
if len(content) > MAX_JSON_CONTENT_SIZE:
raise ValueError(f"Content too large for JSON extraction: {len(content)} bytes")
original = content
# Remove markdown code blocks
if "```json" in content:
content = content.split("```json")[1].split("```")[0]
elif "```" in content:
parts = content.split("```")
if len(parts) >= 2:
content = parts[1]
# Remove language identifier if present (e.g., "json\n{...")
if content.startswith(("json", "JSON")):
content = content[4:]
content = content.strip()
# If content starts with {, find the matching }
if content.startswith("{"):
depth = 0
end_idx = -1
in_string = False
escape_next = False
for i, char in enumerate(content):
if escape_next:
escape_next = False
continue
if char == "\\":
escape_next = True
continue
if char == '"' and not escape_next:
in_string = not in_string
continue
if in_string:
continue
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
end_idx = i
break
# LLM-001: Check for unbalanced braces - loop ended without depth reaching 0
if end_idx == -1 and depth != 0:
logging.warning(
"JSON extraction found unbalanced braces (depth=%d), possible truncation",
depth,
)
if end_idx != -1:
return content[: end_idx + 1]
# Try to find JSON object in the content (only if not already at start)
start_idx = content.find("{")
if start_idx > 0:
# Only recurse if { is found after position 0 to avoid infinite recursion
return _extract_json(content[start_idx:], _depth + 1)
# LLM-007: Log unrecognized format for debugging
logging.error(
"Could not extract JSON from response format. Content preview: %s",
content[:200] if content else "<empty>",
)
raise ValueError(f"No JSON found in response: {original[:200]}")
async def complete_json(
prompt: str,
system_prompt: str | None = None,
config: LLMConfig | None = None,
max_tokens: int = 4096,
retries: int = 2,
) -> dict[str, Any]:
"""Make a completion request expecting JSON response.
Uses JSON mode when available, with retry logic for reliability.
"""
if config is None:
cf_orch_url = os.environ.get("CF_ORCH_URL", "").strip()
if cf_orch_url:
try:
async with _allocate_orch_async(
cf_orch_url,
"vllm",
model_candidates=["Qwen2.5-3B-Instruct"],
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_json(prompt, system_prompt, orch_config, max_tokens, retries)
except Exception as exc:
logging.warning("cf-orch allocation failed, falling back to default config: %s", exc)
config = get_llm_config()
model_name = get_model_name(config)
# Build messages
json_system = (
system_prompt or ""
) + "\n\nYou must respond with valid JSON only. No explanations, no markdown."
messages = [
{"role": "system", "content": json_system},
{"role": "user", "content": prompt},
]
# Check if we can use JSON mode
use_json_mode = _supports_json_mode(config.provider, config.model)
last_error = None
for attempt in range(retries + 1):
try:
# Build request kwargs
# Pass API key directly to avoid race conditions with global os.environ
kwargs: dict[str, Any] = {
"model": model_name,
"messages": messages,
"max_tokens": max_tokens,
"api_key": config.api_key,
"api_base": _normalize_api_base(config.provider, config.api_base),
"timeout": _calculate_timeout("json", max_tokens, config.provider),
}
if _supports_temperature(config.provider, model_name):
# LLM-002: Increase temperature on retry for variation
kwargs["temperature"] = _get_retry_temperature(attempt)
reasoning_effort = _get_reasoning_effort(config.provider, model_name)
if reasoning_effort:
kwargs["reasoning_effort"] = reasoning_effort
# Add JSON mode if supported
if use_json_mode:
kwargs["response_format"] = {"type": "json_object"}
response = await litellm.acompletion(**kwargs)
content = _extract_choice_text(response.choices[0])
if not content:
raise ValueError("Empty response from LLM")
logging.debug(f"LLM response (attempt {attempt + 1}): {content[:300]}")
# Extract and parse JSON
json_str = _extract_json(content)
result = json.loads(json_str)
# LLM-001: Check if parsed result appears truncated
if isinstance(result, dict) and _appears_truncated(result):
logging.warning(
"Parsed JSON appears truncated, but proceeding with result"
)
return result
except json.JSONDecodeError as e:
last_error = e
logging.warning(f"JSON parse failed (attempt {attempt + 1}): {e}")
if attempt < retries:
# Add hint to prompt for retry
messages[-1]["content"] = (
prompt
+ "\n\nIMPORTANT: Output ONLY a valid JSON object. Start with { and end with }."
)
continue
raise ValueError(f"Failed to parse JSON after {retries + 1} attempts: {e}")
except Exception as e:
last_error = e
logging.warning(f"LLM call failed (attempt {attempt + 1}): {e}")
if attempt < retries:
continue
raise
raise ValueError(f"Failed after {retries + 1} attempts: {last_error}")

View file

@ -0,0 +1,88 @@
"""FastAPI application entry point."""
import asyncio
import logging
import sys
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
# Fix for Windows: Use ProactorEventLoop for subprocess support (Playwright)
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
logger = logging.getLogger(__name__)
from fastapi.middleware.cors import CORSMiddleware
from app import __version__
from app.cloud_session import session_middleware_dep
from app.config import settings
from app.database import db
from app.pdf import close_pdf_renderer, init_pdf_renderer
from app.routers import config_router, enrichment_router, health_router, jobs_router, resumes_router
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager."""
# Startup
settings.data_dir.mkdir(parents=True, exist_ok=True)
# PDF renderer uses lazy initialization - will initialize on first use
# await init_pdf_renderer()
yield
# Shutdown - wrap each cleanup in try-except to ensure all resources are released
try:
await close_pdf_renderer()
except Exception as e:
logger.error(f"Error closing PDF renderer: {e}")
try:
db.close()
except Exception as e:
logger.error(f"Error closing database: {e}")
app = FastAPI(
title="Resume Matcher API",
description="AI-powered resume tailoring for job descriptions",
version=__version__,
lifespan=lifespan,
dependencies=[Depends(session_middleware_dep)],
)
# CORS middleware - origins configurable via CORS_ORIGINS env var
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(health_router, prefix="/api/v1")
app.include_router(config_router, prefix="/api/v1")
app.include_router(resumes_router, prefix="/api/v1")
app.include_router(jobs_router, prefix="/api/v1")
app.include_router(enrichment_router, prefix="/api/v1")
@app.get("/")
async def root():
"""Root endpoint."""
return {
"name": "Resume Matcher API",
"version": __version__,
"docs": "/docs",
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host=settings.host,
port=settings.port,
reload=True,
)

View file

@ -171,6 +171,7 @@ _MIGRATIONS = [
("ats_gap_report", "TEXT"), # JSON gap report (free tier) ("ats_gap_report", "TEXT"), # JSON gap report (free tier)
("date_posted", "TEXT"), # Original posting date from job board (shadow listing detection) ("date_posted", "TEXT"), # Original posting date from job board (shadow listing detection)
("hired_feedback", "TEXT"), # JSON: optional post-hire "what helped" response ("hired_feedback", "TEXT"), # JSON: optional post-hire "what helped" response
("excluded_from_training", "INTEGER DEFAULT 0"), # opt-out of training export
] ]
@ -233,10 +234,11 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]:
return None return None
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
try: try:
status = job.get("status", "pending")
cursor = conn.execute( cursor = conn.execute(
"""INSERT INTO jobs """INSERT INTO jobs
(title, company, url, source, location, is_remote, salary, description, date_found, date_posted) (title, company, url, source, location, is_remote, salary, description, date_found, date_posted, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
( (
job.get("title", ""), job.get("title", ""),
job.get("company", ""), job.get("company", ""),
@ -248,6 +250,7 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]:
job.get("description", ""), job.get("description", ""),
job.get("date_found", ""), job.get("date_found", ""),
job.get("date_posted", "") or "", job.get("date_posted", "") or "",
status,
), ),
) )
conn.commit() conn.commit()
@ -1142,3 +1145,99 @@ def set_job_resume(db_path: Path = DEFAULT_DB, job_id: int = 0, resume_id: int =
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()
# ── Training export helpers ───────────────────────────────────────────────────
def _strip_greeting(text: str) -> str:
"""Remove 'Dear X,' greeting line from cover letter text."""
lines = text.splitlines()
for i, line in enumerate(lines):
stripped_line = line.strip()
if stripped_line.lower().startswith("dear ") and stripped_line.endswith((",", ":")):
rest = lines[i + 1:]
while rest and not rest[0].strip():
rest = rest[1:]
result = "\n".join(rest).strip()
return result if result else text.strip()
return text.strip()
def get_db_pairs(db_path: Path) -> list[dict]:
"""Return curation metadata for ALL qualifying jobs (included and excluded).
Used by the curation UI. Includes excluded=True rows so users can restore them.
"""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(
"SELECT id, title, company, description, status, "
" excluded_from_training "
"FROM jobs "
"WHERE status IN ('applied','phone_screen','interviewing','offer','hired') "
" AND cover_letter IS NOT NULL AND cover_letter != '' "
"ORDER BY applied_at DESC",
).fetchall()
finally:
conn.close()
return [
{
"job_id": row["id"],
"title": row["title"] or "",
"company": row["company"] or "",
"status": row["status"],
"instruction": (
f"Write a cover letter for the {row['title'] or 'unknown'} "
f"position at {row['company'] or 'unknown'}."
),
"input_preview": (row["description"] or "")[:200],
"excluded": bool(row["excluded_from_training"]),
}
for row in rows
]
def get_training_pairs(db_path: Path) -> list[dict]:
"""Return Alpaca-format training pairs for non-excluded qualifying jobs.
Used by the JSONL export endpoint.
"""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(
"SELECT id, title, company, description, cover_letter "
"FROM jobs "
"WHERE status IN ('applied','phone_screen','interviewing','offer','hired') "
" AND cover_letter IS NOT NULL AND cover_letter != '' "
" AND excluded_from_training = 0 "
"ORDER BY applied_at DESC",
).fetchall()
finally:
conn.close()
return [
{
"instruction": (
f"Write a cover letter for the {row['title'] or 'unknown'} "
f"position at {row['company'] or 'unknown'}."
),
"input": row["description"] or "",
"output": _strip_greeting(row["cover_letter"]),
"source": "db",
"job_id": row["id"],
}
for row in rows
]
def set_training_exclusion(db_path: Path, job_id: int, excluded: bool) -> None:
"""Set excluded_from_training flag on a job."""
conn = sqlite3.connect(db_path)
try:
conn.execute(
"UPDATE jobs SET excluded_from_training = ? WHERE id = ?",
(1 if excluded else 0, job_id),
)
conn.commit()
finally:
conn.close()

View file

@ -392,6 +392,7 @@ def _has_todo_keyword(subject: str) -> bool:
_LINKEDIN_ALERT_SENDER = "jobalerts-noreply@linkedin.com" _LINKEDIN_ALERT_SENDER = "jobalerts-noreply@linkedin.com"
_INDEED_ALERT_SENDER = "jobalerts@indeed.com"
# Social-proof / nav lines to skip when parsing alert blocks # Social-proof / nav lines to skip when parsing alert blocks
_ALERT_SKIP_PHRASES = { _ALERT_SKIP_PHRASES = {
@ -447,6 +448,75 @@ def parse_linkedin_alert(body: str) -> list[dict]:
return jobs return jobs
def parse_indeed_alert(body: str) -> list[dict]:
"""
Parse the HTML body of an Indeed Job Alert email.
Returns a list of dicts: {title, company, location, salary, url}.
URL is canonicalised to https://www.indeed.com/viewjob?jk=<id>
(tracking parameters stripped).
"""
try:
from bs4 import BeautifulSoup as _BS
except ImportError:
return []
jobs: list[dict] = []
soup = _BS(body, "html.parser")
# Each job card is an <a> wrapping a job title — Indeed uses several layouts
# across their email templates. We try two strategies:
#
# Strategy A (2023+ layout): <td> blocks containing an <a> with /viewjob?jk=
# Strategy B (older layout): <tr class="job"> blocks
#
# Both extract the canonical jk= key from the href.
seen_jks: set[str] = set()
for anchor in soup.find_all("a", href=True):
href: str = anchor["href"]
jk_m = re.search(r"[?&]jk=([a-z0-9]+)", href, re.IGNORECASE)
if not jk_m:
continue
jk = jk_m.group(1)
if jk in seen_jks:
continue
seen_jks.add(jk)
title = anchor.get_text(separator=" ", strip=True)
if not title or len(title) < 3:
continue
# Walk up to find the container cell/row and extract company + location
container = anchor.find_parent(["td", "tr", "div"])
company = location = salary = ""
if container:
text_lines = [
t.strip() for t in container.get_text(separator="\n").splitlines()
if t.strip() and t.strip().lower() != title.lower()
]
if text_lines:
company = text_lines[0]
if len(text_lines) > 1:
location = text_lines[1]
# salary line often contains "$" or "/yr"
for line in text_lines[2:]:
if "$" in line or "/yr" in line.lower() or "/hour" in line.lower():
salary = line
break
jobs.append({
"title": title,
"company": company,
"location": location,
"salary": salary,
"url": f"https://www.indeed.com/viewjob?jk={jk}",
})
return jobs
def _scan_todo_label(conn: imaplib.IMAP4, cfg: dict, db_path: Path, def _scan_todo_label(conn: imaplib.IMAP4, cfg: dict, db_path: Path,
active_jobs: list[dict], active_jobs: list[dict],
known_message_ids: set) -> int: known_message_ids: set) -> int:
@ -558,20 +628,29 @@ def _scan_unmatched_leads(conn: imaplib.IMAP4, cfg: dict,
if mid in known_message_ids: if mid in known_message_ids:
continue continue
# ── LinkedIn Job Alert digest — parse each card individually ────── # ── Job alert digests — parse each card deterministically ───────
if _LINKEDIN_ALERT_SENDER in parsed["from_addr"].lower(): from_lower = parsed["from_addr"].lower()
cards = parse_linkedin_alert(parsed["body"]) alert_cards: list[dict] = []
for card in cards: alert_source = ""
if _LINKEDIN_ALERT_SENDER in from_lower:
alert_cards = parse_linkedin_alert(parsed["body"])
alert_source = "linkedin"
elif _INDEED_ALERT_SENDER in from_lower:
alert_cards = parse_indeed_alert(parsed["body"])
alert_source = "indeed"
if alert_cards:
for card in alert_cards:
if card["url"] in existing_urls: if card["url"] in existing_urls:
continue continue
job_id = insert_job(db_path, { job_id = insert_job(db_path, {
"title": card["title"], "title": card["title"],
"company": card["company"], "company": card["company"],
"url": card["url"], "url": card["url"],
"source": "linkedin", "source": alert_source,
"location": card["location"], "location": card.get("location", ""),
"is_remote": 0, "is_remote": 0,
"salary": "", "salary": card.get("salary", ""),
"description": "", "description": "",
"date_found": datetime.now().isoformat()[:10], "date_found": datetime.now().isoformat()[:10],
}) })
@ -580,7 +659,7 @@ def _scan_unmatched_leads(conn: imaplib.IMAP4, cfg: dict,
submit_task(db_path, "scrape_url", job_id) submit_task(db_path, "scrape_url", job_id)
existing_urls.add(card["url"]) existing_urls.add(card["url"])
new_leads += 1 new_leads += 1
print(f"[imap] LinkedIn alert → {card['company']}{card['title']}") print(f"[imap] {alert_source} alert → {card['company']}{card['title']}")
known_message_ids.add(mid) known_message_ids.add(mid)
continue # skip normal LLM extraction path continue # skip normal LLM extraction path

View file

@ -278,7 +278,8 @@ def rewrite_for_ats(
f"3. Only rephrase existing content — replace vague verbs/nouns with the " f"3. Only rephrase existing content — replace vague verbs/nouns with the "
f" ATS-preferred equivalents listed above.\n" f" ATS-preferred equivalents listed above.\n"
f"4. Keep the same number of bullet points in experience entries.\n" f"4. Keep the same number of bullet points in experience entries.\n"
f"5. Return ONLY the rewritten section content, no labels or explanation." f"5. Do NOT use markdown formatting — no **, __, or * for bullets.\n"
f"6. Return ONLY the rewritten section content, no labels or explanation."
f"{voice_note}\n\n" f"{voice_note}\n\n"
f"Original {section} section:\n{original_content}" f"Original {section} section:\n{original_content}"
) )
@ -305,7 +306,8 @@ def _section_text_for_prompt(resume: dict[str, Any], section: str) -> str:
for exp in resume.get("experience", []): for exp in resume.get("experience", []):
lines.append(f"{exp['title']} at {exp['company']} ({exp['start_date']}{exp['end_date']})") lines.append(f"{exp['title']} at {exp['company']} ({exp['start_date']}{exp['end_date']})")
for b in exp.get("bullets", []): for b in exp.get("bullets", []):
lines.append(f"{b}") clean_b = re.sub(r"^[•\-–—*◦▪▸►\s]+", "", b).strip()
lines.append(f"{clean_b}")
return "\n".join(lines) if lines else "(empty)" return "\n".join(lines) if lines else "(empty)"
return "(unsupported section)" return "(unsupported section)"
@ -314,7 +316,7 @@ def _apply_section_rewrite(resume: dict[str, Any], section: str, rewritten: str)
"""Return a new resume dict with the given section replaced by rewritten text.""" """Return a new resume dict with the given section replaced by rewritten text."""
updated = dict(resume) updated = dict(resume)
if section == "summary": if section == "summary":
updated["career_summary"] = rewritten updated["career_summary"] = _clean_summary_markup(rewritten)
elif section == "skills": elif section == "skills":
# LLM returns comma-separated or newline-separated skills # LLM returns comma-separated or newline-separated skills
skills = [s.strip() for s in re.split(r"[,\n•·]+", rewritten) if s.strip()] skills = [s.strip() for s in re.split(r"[,\n•·]+", rewritten) if s.strip()]
@ -326,6 +328,19 @@ def _apply_section_rewrite(resume: dict[str, Any], section: str, rewritten: str)
return updated return updated
def _clean_summary_markup(text: str) -> str:
"""Strip markdown/plain-text bullet markers from career summary lines.
LLMs sometimes format summary content with '* item' or '• item' markdown.
This converts those lines to unmarked text so the summary renders cleanly.
"""
lines = []
for line in text.splitlines():
cleaned = re.sub(r"^[•*\-–—◦▪▸►]\s+", "", line.lstrip())
lines.append(cleaned)
return "\n".join(lines).strip()
def _reparse_experience_bullets( def _reparse_experience_bullets(
original_entries: list[dict], original_entries: list[dict],
rewritten_text: str, rewritten_text: str,
@ -355,9 +370,9 @@ def _reparse_experience_bullets(
chunk = remaining chunk = remaining
bullets = [ bullets = [
re.sub(r"^[•\-–—*◦▪▸►]\s*", "", line).strip() re.sub(r"^([•\-–—*◦▪▸►]\s*)+", "", line.strip()).strip()
for line in chunk.splitlines() for line in chunk.splitlines()
if re.match(r"^[•\-–—*◦▪▸►]\s*", line.strip()) if re.match(r"^\s*[•\-–—*◦▪▸►]", line)
] ]
new_entry = dict(entry) new_entry = dict(entry)
new_entry["bullets"] = bullets if bullets else entry["bullets"] new_entry["bullets"] = bullets if bullets else entry["bullets"]
@ -532,27 +547,37 @@ def apply_review_decisions(
struct["skills"] = sorted(original_kept | approved_additions) struct["skills"] = sorted(original_kept | approved_additions)
break break
# ── Summary: accept proposed or revert to original ────────────────────── # ── Summary: accept/reject + optional user-edited text ─────────────────
if not decisions.get("summary", {}).get("accepted", True): summary_dec = decisions.get("summary", {})
if not summary_dec.get("accepted", True):
for sec in sections: for sec in sections:
if sec["section"] == "summary": if sec["section"] == "summary":
struct["career_summary"] = sec.get("original", struct.get("career_summary", "")) struct["career_summary"] = sec.get("original", struct.get("career_summary", ""))
break break
else:
edited_text = summary_dec.get("edited_text")
if edited_text is not None:
struct["career_summary"] = edited_text.strip()
# ── Experience: per-entry accept/reject ───────────────────────────────── # ── Experience: per-entry accept/reject + optional user-edited bullets ──
exp_decisions: dict[str, bool] = { exp_entry_map: dict[str, dict] = {
f"{ed.get('title', '')}|{ed.get('company', '')}": ed.get("accepted", True) f"{ed.get('title', '')}|{ed.get('company', '')}": ed
for ed in (decisions.get("experience", {}).get("accepted_entries") or []) for ed in (decisions.get("experience", {}).get("accepted_entries") or [])
} }
for sec in sections: for sec in sections:
if sec["section"] == "experience": if sec["section"] == "experience":
for entry_diff in (sec.get("entries") or []): for entry_diff in (sec.get("entries") or []):
key = f"{entry_diff['title']}|{entry_diff['company']}" key = f"{entry_diff['title']}|{entry_diff['company']}"
if not exp_decisions.get(key, True): entry_dec = exp_entry_map.get(key, {})
accepted = entry_dec.get("accepted", True)
edited_bullets = entry_dec.get("edited_bullets")
for exp_entry in (struct.get("experience") or []): for exp_entry in (struct.get("experience") or []):
if (exp_entry.get("title") == entry_diff["title"] and if (exp_entry.get("title") == entry_diff["title"] and
exp_entry.get("company") == entry_diff["company"]): exp_entry.get("company") == entry_diff["company"]):
if not accepted:
exp_entry["bullets"] = entry_diff["original_bullets"] exp_entry["bullets"] = entry_diff["original_bullets"]
elif edited_bullets is not None:
exp_entry["bullets"] = [b for b in edited_bullets if b.strip()]
break break
return struct return struct

View file

@ -57,7 +57,7 @@ _TIMEOUT = 12
def _detect_board(url: str) -> str: def _detect_board(url: str) -> str:
"""Return 'linkedin', 'indeed', 'glassdoor', or 'generic'.""" """Return 'linkedin', 'indeed', 'glassdoor', 'jobgether', 'oracle_hcm', or 'generic'."""
url_lower = url.lower() url_lower = url.lower()
if "linkedin.com" in url_lower: if "linkedin.com" in url_lower:
return "linkedin" return "linkedin"
@ -67,6 +67,8 @@ def _detect_board(url: str) -> str:
return "glassdoor" return "glassdoor"
if "jobgether.com" in url_lower: if "jobgether.com" in url_lower:
return "jobgether" return "jobgether"
if "oraclecloud.com" in url_lower and "hcmui" in url_lower:
return "oracle_hcm"
return "generic" return "generic"
@ -201,6 +203,70 @@ def _scrape_jobgether(url: str) -> dict:
return {"company": company, "source": "jobgether"} if company else {} return {"company": company, "source": "jobgether"} if company else {}
def _scrape_oracle_hcm(url: str) -> dict:
"""Scrape an Oracle HCM CandidateExperience job page via Playwright.
Oracle HCM portals are React SPAs that require JS execution. The prospect
token in the URL path grants public access no auth needed.
"""
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("[scrape_url] Oracle HCM: Playwright not installed, falling back to generic")
return _scrape_generic(url)
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
try:
ctx = browser.new_context(user_agent=_HEADERS["User-Agent"])
page = ctx.new_page()
page.goto(url, timeout=30_000)
page.wait_for_load_state("networkidle", timeout=20_000)
result = page.evaluate("""() => {
const sel = (s) => document.querySelector(s)?.textContent?.trim() || '';
const selInner = (s) => document.querySelector(s)?.innerText?.trim() || '';
// Title: try known HCM selectors then fall back to first h1
const title = sel('[class*="requisition-title"]')
|| sel('[class*="JobTitle"]')
|| sel('.job-title')
|| sel('h1');
// Company: page header logo alt text, meta, or site-name span
const companyMeta = document.querySelector('meta[property="og:site_name"]')
?.getAttribute('content') || '';
const company = sel('[class*="company-name"]')
|| sel('[class*="siteName"]')
|| sel('[class*="site-name"]')
|| companyMeta;
// Location: job detail list items
const location = sel('[class*="job-location"]')
|| sel('[data-testid*="location"]')
|| sel('[class*="location"]');
// Description: main content div
const description = selInner('[class*="job-description"]')
|| selInner('[class*="requisition-description"]')
|| selInner('[class*="JobDescription"]')
|| selInner('main article')
|| selInner('main');
return { title, company, location, description };
}""")
finally:
browser.close()
result["source"] = "oracle_hcm"
return {k: v for k, v in result.items() if v}
except Exception as exc:
print(f"[scrape_url] Oracle HCM Playwright error for {url}: {exc}")
return {}
def _parse_json_ld_or_og(html: str) -> dict: def _parse_json_ld_or_og(html: str) -> dict:
"""Extract job fields from JSON-LD structured data, then og: meta tags.""" """Extract job fields from JSON-LD structured data, then og: meta tags."""
soup = BeautifulSoup(html, "html.parser") soup = BeautifulSoup(html, "html.parser")
@ -278,6 +344,8 @@ def scrape_job_url(db_path: Path = DEFAULT_DB, job_id: int = None) -> dict:
fields = _scrape_glassdoor(url) fields = _scrape_glassdoor(url)
elif board == "jobgether": elif board == "jobgether":
fields = _scrape_jobgether(url) fields = _scrape_jobgether(url)
elif board == "oracle_hcm":
fields = _scrape_oracle_hcm(url)
else: else:
fields = _scrape_generic(url) fields = _scrape_generic(url)
except requests.RequestException as exc: except requests.RequestException as exc:

View file

@ -29,6 +29,7 @@ _DEFAULTS = {
"tier": "free", "tier": "free",
"dev_tier_override": None, "dev_tier_override": None,
"wizard_complete": False, "wizard_complete": False,
"training_export_opt_in": False,
"wizard_step": 0, "wizard_step": 0,
"dismissed_banners": [], "dismissed_banners": [],
"ui_preference": "streamlit", "ui_preference": "streamlit",
@ -77,6 +78,7 @@ class UserProfile:
self.tier: str = data.get("tier", "free") self.tier: str = data.get("tier", "free")
self.dev_tier_override: str | None = data.get("dev_tier_override") or None self.dev_tier_override: str | None = data.get("dev_tier_override") or None
self.wizard_complete: bool = bool(data.get("wizard_complete", False)) self.wizard_complete: bool = bool(data.get("wizard_complete", False))
self.training_export_opt_in: bool = bool(data.get("training_export_opt_in", False))
self.wizard_step: int = int(data.get("wizard_step", 0)) self.wizard_step: int = int(data.get("wizard_step", 0))
self.dismissed_banners: list[str] = list(data.get("dismissed_banners", [])) self.dismissed_banners: list[str] = list(data.get("dismissed_banners", []))
raw_pref = data.get("ui_preference", "streamlit") raw_pref = data.get("ui_preference", "streamlit")
@ -104,6 +106,7 @@ class UserProfile:
"tier": self.tier, "tier": self.tier,
"dev_tier_override": self.dev_tier_override, "dev_tier_override": self.dev_tier_override,
"wizard_complete": self.wizard_complete, "wizard_complete": self.wizard_complete,
"training_export_opt_in": self.training_export_opt_in,
"wizard_step": self.wizard_step, "wizard_step": self.wizard_step,
"dismissed_banners": self.dismissed_banners, "dismissed_banners": self.dismissed_banners,
"ui_preference": self.ui_preference, "ui_preference": self.ui_preference,

View file

@ -203,6 +203,73 @@ def test_parse_linkedin_alert_empty_body():
assert parse_linkedin_alert("No jobs here.") == [] assert parse_linkedin_alert("No jobs here.") == []
# ── Indeed alert parser ───────────────────────────────────────────────────────
_INDEED_ALERT_HTML = """
<html><body>
<table>
<tr>
<td>
<a href="https://www.indeed.com/viewjob?jk=abc123def456&utm_source=jobseeker_email">
Senior Python Engineer
</a>
<br/>Acme Corp<br/>San Francisco, CA<br/>$130,000 - $160,000 a year
</td>
</tr>
<tr>
<td>
<a href="https://www.indeed.com/viewjob?jk=999zzzqqq111&trk=email_alert">
Staff Backend Engineer
</a>
<br/>Widgets Inc<br/>Remote
</td>
</tr>
<tr>
<td>
<a href="https://www.indeed.com/rc/clk?jk=abc123def456&pos=0">Duplicate link</a>
</td>
</tr>
</table>
</body></html>
"""
def test_parse_indeed_alert_extracts_jobs():
from scripts.imap_sync import parse_indeed_alert
jobs = parse_indeed_alert(_INDEED_ALERT_HTML)
assert len(jobs) == 2
assert jobs[0]["title"] == "Senior Python Engineer"
assert jobs[0]["url"] == "https://www.indeed.com/viewjob?jk=abc123def456"
assert jobs[1]["title"] == "Staff Backend Engineer"
assert jobs[1]["url"] == "https://www.indeed.com/viewjob?jk=999zzzqqq111"
def test_parse_indeed_alert_strips_tracking_params():
from scripts.imap_sync import parse_indeed_alert
jobs = parse_indeed_alert(_INDEED_ALERT_HTML)
for job in jobs:
assert "utm_source" not in job["url"]
assert "trk=" not in job["url"]
def test_parse_indeed_alert_deduplicates_jk():
from scripts.imap_sync import parse_indeed_alert
jobs = parse_indeed_alert(_INDEED_ALERT_HTML)
urls = [j["url"] for j in jobs]
assert len(urls) == len(set(urls))
def test_parse_indeed_alert_empty_body():
from scripts.imap_sync import parse_indeed_alert
assert parse_indeed_alert("") == []
assert parse_indeed_alert("<html><body>No jobs here</body></html>") == []
def test_parse_indeed_alert_extracts_salary():
from scripts.imap_sync import parse_indeed_alert
jobs = parse_indeed_alert(_INDEED_ALERT_HTML)
assert "$130,000" in jobs[0]["salary"]
# ── _scan_unmatched_leads integration ───────────────────────────────────────── # ── _scan_unmatched_leads integration ─────────────────────────────────────────
_ALERT_BODY = """\ _ALERT_BODY = """\

View file

@ -0,0 +1,224 @@
"""Tests for cover letter training export helpers."""
import json
import sqlite3
import pytest
from pathlib import Path
def _make_db(tmp_path: Path) -> Path:
from scripts.db import init_db
db = tmp_path / "test.db"
init_db(db)
# excluded_from_training column is added by _migrate_db via _MIGRATIONS — no manual ALTER needed
return db
def _insert_job(db: Path, *, title="Engineer", company="Acme", status="applied",
cover_letter="Dear Hiring Manager,\n\nI am excited.", description="Build stuff.",
excluded=0) -> int:
conn = sqlite3.connect(db)
cur = conn.execute(
"INSERT INTO jobs (title, company, url, source, location, is_remote, salary, "
"description, date_found, status, cover_letter, excluded_from_training) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(title, company, f"https://example.com/{title}", "test", "Remote", 1, "",
description, "2026-01-01", status, cover_letter, excluded),
)
conn.commit()
job_id = cur.lastrowid
conn.close()
return job_id
def test_get_training_pairs_returns_applied_jobs(tmp_path):
from scripts.db import get_training_pairs
db = _make_db(tmp_path)
_insert_job(db, title="Engineer", company="Acme", status="applied")
pairs = get_training_pairs(db)
assert len(pairs) == 1
assert pairs[0]["source"] == "db"
assert pairs[0]["instruction"] == "Write a cover letter for the Engineer position at Acme."
assert "job_id" in pairs[0]
def test_get_training_pairs_strips_greeting(tmp_path):
from scripts.db import get_training_pairs
db = _make_db(tmp_path)
_insert_job(db, cover_letter="Dear Hiring Manager,\n\nI am excited to apply.\n\nSincerely, Me")
pairs = get_training_pairs(db)
assert not pairs[0]["output"].startswith("Dear")
assert "I am excited" in pairs[0]["output"]
def test_get_training_pairs_excludes_non_applied(tmp_path):
from scripts.db import get_training_pairs
db = _make_db(tmp_path)
_insert_job(db, title="PendingJob", status="pending")
_insert_job(db, title="ApprovedJob", status="approved")
pairs = get_training_pairs(db)
assert len(pairs) == 0
def test_get_training_pairs_excludes_opted_out(tmp_path):
from scripts.db import get_training_pairs
db = _make_db(tmp_path)
_insert_job(db, excluded=1)
pairs = get_training_pairs(db)
assert len(pairs) == 0
def test_get_training_pairs_null_description_gives_empty_input(tmp_path):
from scripts.db import get_training_pairs
db = _make_db(tmp_path)
conn = sqlite3.connect(db)
conn.execute(
"INSERT INTO jobs (title, company, url, source, location, is_remote, salary, "
"date_found, status, cover_letter, excluded_from_training) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
("Dev", "Corp", "https://x.com/1", "test", "Remote", 1, "",
"2026-01-01", "applied", "Great letter body", 0),
)
conn.commit()
conn.close()
pairs = get_training_pairs(db)
assert pairs[0]["input"] == ""
def test_get_db_pairs_includes_excluded_with_flag(tmp_path):
from scripts.db import get_db_pairs
db = _make_db(tmp_path)
_insert_job(db, excluded=0)
_insert_job(db, title="Other", excluded=1)
pairs = get_db_pairs(db)
assert len(pairs) == 2
excluded = [p for p in pairs if p["excluded"]]
included = [p for p in pairs if not p["excluded"]]
assert len(excluded) == 1
assert len(included) == 1
def test_set_training_exclusion_excludes(tmp_path):
from scripts.db import get_training_pairs, set_training_exclusion
db = _make_db(tmp_path)
job_id = _insert_job(db)
assert len(get_training_pairs(db)) == 1
set_training_exclusion(db, job_id, excluded=True)
assert len(get_training_pairs(db)) == 0
def test_set_training_exclusion_restores(tmp_path):
from scripts.db import get_training_pairs, set_training_exclusion
db = _make_db(tmp_path)
job_id = _insert_job(db, excluded=1)
assert len(get_training_pairs(db)) == 0
set_training_exclusion(db, job_id, excluded=False)
assert len(get_training_pairs(db)) == 1
def test_strip_greeting_returns_original_when_no_body(tmp_path):
from scripts.db import _strip_greeting
# A letter that is only a salutation with no body should return the original text
result = _strip_greeting("Dear Hiring Manager,")
assert result == "Dear Hiring Manager,"
def test_user_profile_training_opt_in_defaults_false(tmp_path):
from scripts.user_profile import UserProfile
yaml_path = tmp_path / "user.yaml"
yaml_path.write_text("name: Test\nemail: test@example.com\n")
profile = UserProfile(yaml_path)
assert profile.training_export_opt_in is False
def test_user_profile_training_opt_in_roundtrip(tmp_path):
from scripts.user_profile import UserProfile
yaml_path = tmp_path / "user.yaml"
yaml_path.write_text("name: Test\nemail: test@example.com\n")
profile = UserProfile(yaml_path)
profile.training_export_opt_in = True
profile.save()
reloaded = UserProfile(yaml_path)
assert reloaded.training_export_opt_in is True
# ── API tests ─────────────────────────────────────────────────────────────────
@pytest.fixture()
def api_client(tmp_path, monkeypatch):
"""TestClient with a fresh DB and user.yaml for training export endpoints."""
import yaml
from fastapi.testclient import TestClient
db = _make_db(tmp_path)
yaml_path = tmp_path / "config" / "user.yaml"
yaml_path.parent.mkdir(parents=True)
yaml_path.write_text(yaml.dump({"name": "Test", "email": "t@t.com"}))
monkeypatch.setenv("STAGING_DB", str(db))
monkeypatch.setattr("dev_api.DB_PATH", str(db))
monkeypatch.setattr("dev_api._user_yaml_path", lambda: str(yaml_path))
from dev_api import app
return TestClient(app), db, yaml_path
def test_opt_in_toggle(api_client):
client, db, yaml_path = api_client
resp = client.patch("/api/settings/fine-tune/opt-in", json={"enabled": True})
assert resp.status_code == 200
assert resp.json()["enabled"] is True
import yaml as _yaml
data = _yaml.safe_load(yaml_path.read_text())
assert data["training_export_opt_in"] is True
def test_db_pairs_blocked_without_opt_in(api_client):
client, db, yaml_path = api_client
resp = client.get("/api/settings/fine-tune/db-pairs")
assert resp.status_code == 403
def test_db_pairs_returns_jobs_when_opted_in(api_client):
client, db, yaml_path = api_client
_insert_job(db, title="Engineer", company="Acme")
client.patch("/api/settings/fine-tune/opt-in", json={"enabled": True})
resp = client.get("/api/settings/fine-tune/db-pairs")
assert resp.status_code == 200
data = resp.json()
assert data["total"] >= 1
assert data["pairs"][0]["title"] == "Engineer"
def test_exclude_and_restore(api_client):
client, db, yaml_path = api_client
job_id = _insert_job(db)
client.patch("/api/settings/fine-tune/opt-in", json={"enabled": True})
resp = client.patch(f"/api/settings/fine-tune/db-pairs/{job_id}/exclude")
assert resp.status_code == 200
pairs = client.get("/api/settings/fine-tune/db-pairs").json()["pairs"]
assert any(p["job_id"] == job_id and p["excluded"] for p in pairs)
client.patch(f"/api/settings/fine-tune/db-pairs/{job_id}/include")
pairs = client.get("/api/settings/fine-tune/db-pairs").json()["pairs"]
assert any(p["job_id"] == job_id and not p["excluded"] for p in pairs)
def test_export_jsonl_blocked_without_opt_in(api_client):
client, db, yaml_path = api_client
resp = client.get("/api/settings/fine-tune/export")
assert resp.status_code == 403
def test_export_jsonl_streams_valid_records(api_client):
client, db, yaml_path = api_client
_insert_job(db, cover_letter="Dear Sir,\n\nGreat role body.", description="Build things.")
client.patch("/api/settings/fine-tune/opt-in", json={"enabled": True})
resp = client.get("/api/settings/fine-tune/export")
assert resp.status_code == 200
assert "attachment" in resp.headers.get("content-disposition", "")
lines = [l for l in resp.text.strip().splitlines() if l]
assert len(lines) >= 1
record = json.loads(lines[0])
assert "instruction" in record
assert "input" in record
assert "output" in record
assert record["source"] == "db"

View file

@ -5,11 +5,20 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Peregrine — Job Search Assistant</title> <title>Peregrine — Job Search Assistant</title>
<!-- Inline background prevents blank flash before CSS bundle loads --> <!-- Apply stored theme before first paint — prevents FOUT flash on dark/hacker themes.
<!-- Matches --color-surface light / dark from theme.css --> Mirrors the logic in useTheme.initTheme(). Must run before the <style> below. -->
<script>try{if(localStorage.getItem('cf-hacker-mode')==='true'){document.documentElement.dataset.theme='hacker';}else{var t=localStorage.getItem('cf-theme');if(t&&t!=='auto')document.documentElement.dataset.theme=t;}}catch(e){}</script>
<!-- FOUT prevention: background only on html (body is transparent). Gotcha #14.
body paints on top of html — a hardcoded body background covers html's CSS-
variable-driven color even when it resolves correctly. Keep background off body.
Covers auto mode (media query) and all explicit theme choices. -->
<style> <style>
html, body { margin: 0; background: #eaeff8; min-height: 100vh; } html, body { margin: 0; min-height: 100vh; }
@media (prefers-color-scheme: dark) { html, body { background: #16202e; } } html { background: #eaeff8; }
@media (prefers-color-scheme: dark) { html:not([data-theme]) { background: #16202e; } }
html[data-theme="dark"] { background: #16202e; }
html[data-theme="solarized-dark"] { background: #002b36; }
html[data-theme="hacker"] { background: #0a0c0a; }
</style> </style>
<!-- Plausible analytics: cookie-free, GDPR-compliant, self-hosted. <!-- Plausible analytics: cookie-free, GDPR-compliant, self-hosted.
Skips localhost/127.0.0.1. Reports to hostname + circuitforge.tech rollup. --> Skips localhost/127.0.0.1. Reports to hostname + circuitforge.tech rollup. -->

View file

@ -71,6 +71,10 @@ html {
} }
body { body {
/* Gotcha #14: do NOT set background on body. body paints on top of html
a hardcoded body background will cover html's CSS-variable-driven color
even when html { background: var(--color-surface) } resolves correctly.
FOUT prevention lives in index.html on html only, not body. */
min-height: 100dvh; /* dynamic viewport — mobile chrome-aware. Gotcha #13. */ min-height: 100dvh; /* dynamic viewport — mobile chrome-aware. Gotcha #13. */
overflow-x: hidden; overflow-x: hidden;
} }
@ -135,8 +139,8 @@ body {
bottom: calc(72px + env(safe-area-inset-bottom)); bottom: calc(72px + env(safe-area-inset-bottom));
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
background: var(--color-surface-raised, #2a3650); background: var(--color-surface-raised, #f5f7fc);
color: var(--color-text, #eaeff8); color: var(--color-text, #1a2338);
padding: 10px 20px; padding: 10px 20px;
border-radius: var(--radius-md, 8px); border-radius: var(--radius-md, 8px);
font-size: 0.9rem; font-size: 0.9rem;

View file

@ -58,6 +58,9 @@ body {
--score-low: var(--color-error); /* < 30% */ --score-low: var(--color-error); /* < 30% */
--score-none: var(--color-text-muted); --score-none: var(--color-text-muted);
/* ── Hover overlay ── */
--color-hover: rgba(0, 0, 0, 0.06); /* subtle darkening on light surfaces */
/* ── Motion tokens ── */ /* ── Motion tokens ── */
--swipe-exit: 300ms; --swipe-exit: 300ms;
--swipe-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1); --swipe-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
@ -79,7 +82,7 @@ body {
/* ── Dark mode ─────────────────────────────────────── */ /* ── Dark mode ─────────────────────────────────────── */
/* Covers both: OS-level dark preference AND explicit dark theme selection in UI */ /* Covers both: OS-level dark preference AND explicit dark theme selection in UI */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not([data-theme="hacker"]) { :root:not([data-theme]) {
--app-primary: #68A8D8; /* Falcon Blue (dark) — 6.54:1 on #16202e ✅ AA */ --app-primary: #68A8D8; /* Falcon Blue (dark) — 6.54:1 on #16202e ✅ AA */
--app-primary-hover: #7BBDE6; --app-primary-hover: #7BBDE6;
--app-primary-light: #0D1F35; --app-primary-light: #0D1F35;
@ -91,6 +94,8 @@ body {
--score-mid-high: #5ba3d9; /* lighter blue for dark bg */ --score-mid-high: #5ba3d9; /* lighter blue for dark bg */
--color-hover: rgba(255, 255, 255, 0.07); /* subtle lightening on dark surfaces */
--status-synced: #9b8fea; --status-synced: #9b8fea;
--status-survey: #b08fea; --status-survey: #b08fea;
--status-phone: #4ec9be; --status-phone: #4ec9be;
@ -112,6 +117,8 @@ body {
--score-mid-high: #5ba3d9; --score-mid-high: #5ba3d9;
--color-hover: rgba(255, 255, 255, 0.07);
--status-synced: #9b8fea; --status-synced: #9b8fea;
--status-survey: #b08fea; --status-survey: #b08fea;
--status-phone: #4ec9be; --status-phone: #4ec9be;

View file

@ -63,6 +63,9 @@
--shadow-md: 0 4px 12px rgba(26, 35, 56, 0.1), 0 2px 4px rgba(26, 35, 56, 0.06); --shadow-md: 0 4px 12px rgba(26, 35, 56, 0.1), 0 2px 4px rgba(26, 35, 56, 0.06);
--shadow-lg: 0 10px 30px rgba(26, 35, 56, 0.12), 0 4px 8px rgba(26, 35, 56, 0.06); --shadow-lg: 0 10px 30px rgba(26, 35, 56, 0.12), 0 4px 8px rgba(26, 35, 56, 0.06);
/* Overlay — modal/dialog scrim */
--color-overlay: rgba(0, 0, 0, 0.5);
/* Transitions */ /* Transitions */
--transition: 200ms ease; --transition: 200ms ease;
--transition-slow: 400ms ease; --transition-slow: 400ms ease;

View file

@ -59,9 +59,6 @@
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" /> <Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
<span class="sidebar__label">Settings</span> <span class="sidebar__label">Settings</span>
</RouterLink> </RouterLink>
<button class="sidebar__classic-btn" @click="switchToClassic" title="Switch to Classic (Streamlit) UI">
Classic
</button>
</div> </div>
</nav> </nav>
@ -134,23 +131,6 @@ function exitHackerMode() {
restoreTheme() restoreTheme()
} }
const _apiBase = import.meta.env.BASE_URL.replace(/\/$/, '')
async function switchToClassic() {
// Persist preference via API so Streamlit reads streamlit from user.yaml
// and won't re-set the cookie back to vue (avoids the ?prgn_switch rerun cycle)
try {
await fetch(_apiBase + '/api/settings/ui-preference', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preference: 'streamlit' }),
})
} catch { /* non-fatal — cookie below is enough for immediate redirect */ }
document.cookie = 'prgn_ui=streamlit; path=/; SameSite=Lax'
// Navigate to root (no query params) Caddy routes to Streamlit based on cookie
window.location.href = window.location.origin + '/'
}
const navLinks = computed(() => [ const navLinks = computed(() => [
{ to: '/', icon: HomeIcon, label: 'Home' }, { to: '/', icon: HomeIcon, label: 'Home' },
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' }, { to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
@ -321,29 +301,6 @@ const mobileLinks = [
margin: 0; margin: 0;
} }
.sidebar__classic-btn {
display: flex;
align-items: center;
width: 100%;
padding: var(--space-2) var(--space-3);
margin-top: var(--space-1);
background: none;
border: none;
border-radius: var(--radius-md);
color: var(--color-text-muted);
font-size: var(--text-xs);
font-weight: 500;
cursor: pointer;
opacity: 0.6;
transition: opacity 150ms, background 150ms;
white-space: nowrap;
}
.sidebar__classic-btn:hover {
opacity: 1;
background: var(--color-surface-alt);
}
/* ── Theme picker ───────────────────────────────────── */ /* ── Theme picker ───────────────────────────────────── */
.sidebar__theme { .sidebar__theme {
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);

View file

@ -32,7 +32,7 @@ function dismiss(): void {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: var(--space-2, 8px); gap: var(--space-2, 8px);
background: var(--color-surface, #0d1829); background: var(--color-surface, #eaeff8);
border: 1px solid var(--app-primary, #2B6CB0); border: 1px solid var(--app-primary, #2B6CB0);
border-radius: var(--radius-md, 8px); border-radius: var(--radius-md, 8px);
padding: var(--space-2, 8px) var(--space-3, 12px); padding: var(--space-2, 8px) var(--space-3, 12px);
@ -59,5 +59,5 @@ function dismiss(): void {
line-height: 1; line-height: 1;
} }
.hint-chip__dismiss:hover { color: var(--color-text, #eaeff8); } .hint-chip__dismiss:hover { color: var(--color-text, #1a2338); }
</style> </style>

View file

@ -151,7 +151,7 @@ async function reclassifySignal(sig: StageSignal, newLabel: StageSignal['stage_s
} }
const scoreClass = computed(() => { const scoreClass = computed(() => {
const s = (props.job.match_score ?? 0) * 100 const s = props.job.match_score ?? 0
if (s >= 85) return 'score--high' if (s >= 85) return 'score--high'
if (s >= 65) return 'score--mid' if (s >= 65) return 'score--mid'
return 'score--low' return 'score--low'
@ -159,7 +159,7 @@ const scoreClass = computed(() => {
const scoreLabel = computed(() => const scoreLabel = computed(() =>
props.job.match_score != null props.job.match_score != null
? `${Math.round(props.job.match_score * 100)}%` ? `${Math.round(props.job.match_score)}%`
: '—' : '—'
) )
@ -588,7 +588,7 @@ async function saveFeedback() {
background: var(--color-hover); background: var(--color-hover);
} }
.btn-chip-active { .btn-chip-active {
background: var(--color-primary-muted, #e8f0ff); background: var(--app-primary-light);
color: var(--color-primary); border-color: var(--color-primary); color: var(--color-primary); border-color: var(--color-primary);
font-weight: 600; font-weight: 600;
} }

View file

@ -106,7 +106,7 @@ onMounted(load)
} }
.rlc__title { .rlc__title {
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
font-weight: 600; font-weight: 600;
margin: 0; margin: 0;
display: flex; display: flex;
@ -128,7 +128,7 @@ onMounted(load)
.rlc__name { .rlc__name {
font-weight: 500; font-weight: 500;
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
} }
.rlc__meta { .rlc__meta {
@ -143,7 +143,7 @@ onMounted(load)
} }
.rlc__empty { .rlc__empty {
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
color: var(--color-text-muted, #64748b); color: var(--color-text-muted, #64748b);
} }
@ -153,7 +153,7 @@ onMounted(load)
} }
.rlc__loading { .rlc__loading {
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
color: var(--color-text-muted, #64748b); color: var(--color-text-muted, #64748b);
} }
@ -183,7 +183,7 @@ onMounted(load)
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
cursor: pointer; cursor: pointer;
border-radius: var(--radius-sm, 0.25rem); border-radius: var(--radius-sm, 0.25rem);
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
} }
.rlc__picker-item:hover, .rlc__picker-item:hover,

View file

@ -112,16 +112,15 @@
<span class="rop__preview-badge">Preview not yet saved</span> <span class="rop__preview-badge">Preview not yet saved</span>
</div> </div>
<textarea <textarea
:value="previewText" v-model="previewText"
class="rop__textarea rop__textarea--preview" class="rop__textarea"
aria-label="Resume preview text" aria-label="Resume preview — editable before approving"
spellcheck="false" spellcheck="true"
readonly
/> />
<p class="rop__preview-hint"> <p class="rop__preview-hint">
Review the assembled resume above. If it looks right, click Review and edit the assembled resume above. Click
<strong>Approve &amp; Save</strong> to lock it in. You can also go back and adjust <strong>Approve &amp; Save</strong> to lock it in, or go back to adjust
your review decisions. your section-level decisions.
</p> </p>
<div class="rop__save-to-library"> <div class="rop__save-to-library">
<label class="rop__save-toggle"> <label class="rop__save-toggle">
@ -492,7 +491,10 @@ async function approveResume() {
if (!previewStruct.value) return if (!previewStruct.value) return
approvingResume.value = true approvingResume.value = true
const body: Record<string, unknown> = { preview_struct: previewStruct.value } const body: Record<string, unknown> = {
preview_struct: previewStruct.value,
preview_text_override: previewText.value,
}
if (saveToLibrary.value) { if (saveToLibrary.value) {
body.save_to_library = true body.save_to_library = true
body.resume_name = savedResumeName.value.trim() || `Optimized for job ${props.jobId}` body.resume_name = savedResumeName.value.trim() || `Optimized for job ${props.jobId}`
@ -576,7 +578,7 @@ onUnmounted(stopPolling)
} }
.rop__tier-note { .rop__tier-note {
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
color: var(--app-text-muted, #64748b); color: var(--app-text-muted, #64748b);
background: var(--app-surface-alt, #f8fafc); background: var(--app-surface-alt, #f8fafc);
border: 1px solid var(--app-border, #e2e8f0); border: 1px solid var(--app-border, #e2e8f0);
@ -601,13 +603,13 @@ onUnmounted(stopPolling)
.rop__hint, .rop__hint,
.rop__empty { .rop__empty {
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
color: var(--app-text-muted, #64748b); color: var(--app-text-muted, #64748b);
margin: 0; margin: 0;
} }
.rop__error { .rop__error {
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
color: var(--app-danger, #dc2626); color: var(--app-danger, #dc2626);
margin: 0; margin: 0;
} }
@ -616,7 +618,7 @@ onUnmounted(stopPolling)
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2, 0.5rem); gap: var(--space-2, 0.5rem);
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
color: var(--app-text-muted, #64748b); color: var(--app-text-muted, #64748b);
} }
@ -641,7 +643,7 @@ onUnmounted(stopPolling)
border-radius: var(--radius-sm, 0.25rem); border-radius: var(--radius-sm, 0.25rem);
border-left: 3px solid transparent; border-left: 3px solid transparent;
background: var(--app-surface-alt, #f8fafc); background: var(--app-surface-alt, #f8fafc);
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
} }
@ -704,7 +706,7 @@ onUnmounted(stopPolling)
} }
.rop__wordcount { .rop__wordcount {
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
color: var(--app-text-muted, #64748b); color: var(--app-text-muted, #64748b);
} }
@ -725,7 +727,7 @@ onUnmounted(stopPolling)
background: color-mix(in srgb, var(--app-danger, #dc2626) 8%, transparent); background: color-mix(in srgb, var(--app-danger, #dc2626) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--app-danger, #dc2626) 30%, transparent); border: 1px solid color-mix(in srgb, var(--app-danger, #dc2626) 30%, transparent);
border-radius: var(--radius-md, 0.5rem); border-radius: var(--radius-md, 0.5rem);
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
color: var(--app-danger, #dc2626); color: var(--app-danger, #dc2626);
} }
@ -734,7 +736,7 @@ onUnmounted(stopPolling)
min-height: 20rem; min-height: 20rem;
padding: var(--space-3, 0.75rem); padding: var(--space-3, 0.75rem);
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
line-height: 1.6; line-height: 1.6;
border: 1px solid var(--app-border, #e2e8f0); border: 1px solid var(--app-border, #e2e8f0);
border-radius: var(--radius-md, 0.5rem); border-radius: var(--radius-md, 0.5rem);
@ -760,7 +762,7 @@ onUnmounted(stopPolling)
color: #fff; color: #fff;
border: none; border: none;
border-radius: var(--radius-md, 0.5rem); border-radius: var(--radius-md, 0.5rem);
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: background 0.15s; transition: background 0.15s;
@ -779,7 +781,7 @@ onUnmounted(stopPolling)
color: var(--app-text, #1e293b); color: var(--app-text, #1e293b);
border: 1px solid var(--app-border, #e2e8f0); border: 1px solid var(--app-border, #e2e8f0);
border-radius: var(--radius-md, 0.5rem); border-radius: var(--radius-md, 0.5rem);
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: background 0.15s; transition: background 0.15s;
@ -797,7 +799,7 @@ onUnmounted(stopPolling)
} }
.rop__review-intro { .rop__review-intro {
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
color: var(--app-text-muted, #64748b); color: var(--app-text-muted, #64748b);
margin: 0; margin: 0;
padding: var(--space-3, 0.75rem) var(--space-4, 1rem); padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
@ -817,7 +819,7 @@ onUnmounted(stopPolling)
} }
.rop__review-section-title { .rop__review-section-title {
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
@ -850,7 +852,7 @@ onUnmounted(stopPolling)
gap: var(--space-1, 0.25rem); gap: var(--space-1, 0.25rem);
padding: 0.3em 0.75em; padding: 0.3em 0.75em;
border-radius: var(--radius-full, 9999px); border-radius: var(--radius-full, 9999px);
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
border: 1.5px solid var(--app-border, #e2e8f0); border: 1.5px solid var(--app-border, #e2e8f0);
background: var(--app-surface, #fff); background: var(--app-surface, #fff);
cursor: pointer; cursor: pointer;
@ -885,7 +887,7 @@ onUnmounted(stopPolling)
gap: var(--space-1, 0.25rem); gap: var(--space-1, 0.25rem);
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
border-radius: var(--radius-sm, 0.25rem); border-radius: var(--radius-sm, 0.25rem);
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
} }
.rop__diff-col--original { .rop__diff-col--original {
@ -934,7 +936,7 @@ onUnmounted(stopPolling)
} }
.rop__exp-company { .rop__exp-company {
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
color: var(--app-text-muted, #64748b); color: var(--app-text-muted, #64748b);
} }
@ -943,7 +945,7 @@ onUnmounted(stopPolling)
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-1, 0.25rem); gap: var(--space-1, 0.25rem);
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
cursor: pointer; cursor: pointer;
color: var(--app-text, #1e293b); color: var(--app-text, #1e293b);
} }
@ -973,7 +975,7 @@ onUnmounted(stopPolling)
background: none; background: none;
border: none; border: none;
color: var(--app-accent, #6366f1); color: var(--app-accent, #6366f1);
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
} }
@ -992,7 +994,7 @@ onUnmounted(stopPolling)
background: var(--app-surface-alt, #f8fafc); background: var(--app-surface-alt, #f8fafc);
border: 1px solid var(--app-border, #e2e8f0); border: 1px solid var(--app-border, #e2e8f0);
border-radius: var(--radius-sm, 0.25rem); border-radius: var(--radius-sm, 0.25rem);
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
} }
.rop__history-date { .rop__history-date {
@ -1058,7 +1060,7 @@ onUnmounted(stopPolling)
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-1, 0.25rem); gap: var(--space-1, 0.25rem);
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
cursor: pointer; cursor: pointer;
color: var(--app-text, #1e293b); color: var(--app-text, #1e293b);
} }
@ -1066,7 +1068,7 @@ onUnmounted(stopPolling)
.rop__framing-context { .rop__framing-context {
width: 100%; width: 100%;
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
font-family: inherit; font-family: inherit;
line-height: 1.5; line-height: 1.5;
border: 1px solid var(--app-border, #e2e8f0); border: 1px solid var(--app-border, #e2e8f0);
@ -1099,13 +1101,9 @@ onUnmounted(stopPolling)
border-radius: var(--radius-full, 9999px); border-radius: var(--radius-full, 9999px);
} }
.rop__textarea--preview {
background: color-mix(in srgb, var(--app-accent, #6366f1) 3%, var(--app-surface, #fff));
cursor: default;
}
.rop__preview-hint { .rop__preview-hint {
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
color: var(--app-text-muted, #64748b); color: var(--app-text-muted, #64748b);
margin: 0; margin: 0;
} }
@ -1134,7 +1132,7 @@ onUnmounted(stopPolling)
color: var(--app-text-muted, #64748b); color: var(--app-text-muted, #64748b);
border: 1px solid var(--app-border, #e2e8f0); border: 1px solid var(--app-border, #e2e8f0);
border-radius: var(--radius-md, 0.5rem); border-radius: var(--radius-md, 0.5rem);
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
cursor: pointer; cursor: pointer;
transition: background 0.15s; transition: background 0.15s;
} }
@ -1165,7 +1163,7 @@ onUnmounted(stopPolling)
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-2, 0.5rem); gap: var(--space-2, 0.5rem);
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
color: var(--app-text, #1e293b); color: var(--app-text, #1e293b);
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
@ -1173,7 +1171,7 @@ onUnmounted(stopPolling)
.rop__resume-name-input { .rop__resume-name-input {
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
font-family: inherit; font-family: inherit;
border: 1px solid var(--app-border, #e2e8f0); border: 1px solid var(--app-border, #e2e8f0);
border-radius: var(--radius-sm, 0.25rem); border-radius: var(--radius-sm, 0.25rem);

View file

@ -63,7 +63,9 @@
<SummaryPage <SummaryPage
:section="summarySection!" :section="summarySection!"
:accepted="summaryAccepted" :accepted="summaryAccepted"
:edited-proposed="summaryEdited"
@update:accepted="summaryAccepted = $event" @update:accepted="summaryAccepted = $event"
@update:editedProposed="summaryEdited = $event"
/> />
</template> </template>
@ -72,7 +74,9 @@
<ExperiencePage <ExperiencePage
:entry="currentEntry!" :entry="currentEntry!"
:accepted="expAccepted[currentPage.entryKey!] ?? true" :accepted="expAccepted[currentPage.entryKey!] ?? true"
:edited-bullets="expEdited[currentPage.entryKey!] ?? currentEntry!.proposed_bullets"
@update:accepted="expAccepted[currentPage.entryKey!] = $event" @update:accepted="expAccepted[currentPage.entryKey!] = $event"
@update:editedBullets="expEdited[currentPage.entryKey!] = $event"
/> />
</template> </template>
@ -255,11 +259,17 @@ function goTo(idx: number) {
const approvedSkills = ref<Set<string>>(new Set(skillsSection.value?.added ?? [])) const approvedSkills = ref<Set<string>>(new Set(skillsSection.value?.added ?? []))
const skillFramings = ref<Map<string, GapFraming>>(new Map()) const skillFramings = ref<Map<string, GapFraming>>(new Map())
const summaryAccepted = ref(true) const summaryAccepted = ref(true)
const summaryEdited = ref<string>(summarySection.value?.proposed ?? '')
const expAccepted = ref<Record<string, boolean>>( const expAccepted = ref<Record<string, boolean>>(
Object.fromEntries( Object.fromEntries(
(expSection.value?.entries ?? []).map(e => [`${e.title}|${e.company}`, true]) (expSection.value?.entries ?? []).map(e => [`${e.title}|${e.company}`, true])
) )
) )
const expEdited = ref<Record<string, string[]>>(
Object.fromEntries(
(expSection.value?.entries ?? []).map(e => [`${e.title}|${e.company}`, [...e.proposed_bullets]])
)
)
function toggleSkill(skill: string) { function toggleSkill(skill: string) {
interactedPages.value = new Set([...interactedPages.value, 'skills']) interactedPages.value = new Set([...interactedPages.value, 'skills'])
@ -322,15 +332,22 @@ function emitSubmit() {
decisions.skills = { approved_additions: [...approvedSkills.value] } decisions.skills = { approved_additions: [...approvedSkills.value] }
} }
if (summarySection.value) { if (summarySection.value) {
decisions.summary = { accepted: summaryAccepted.value } decisions.summary = {
accepted: summaryAccepted.value,
edited_text: summaryEdited.value,
}
} }
if (expSection.value) { if (expSection.value) {
decisions.experience = { decisions.experience = {
accepted_entries: expSection.value.entries.map(e => ({ accepted_entries: expSection.value.entries.map(e => {
const key = `${e.title}|${e.company}`
return {
title: e.title, title: e.title,
company: e.company, company: e.company,
accepted: expAccepted.value[`${e.title}|${e.company}`] ?? true, accepted: expAccepted.value[key] ?? true,
})), edited_bullets: expEdited.value[key] ?? e.proposed_bullets,
}
}),
} }
} }

View file

@ -131,7 +131,7 @@ defineEmits<{
background: var(--color-error, #dc2626); background: var(--color-error, #dc2626);
color: #fff; border: none; color: #fff; border: none;
border-radius: var(--radius-md); cursor: pointer; border-radius: var(--radius-md); cursor: pointer;
font-size: var(--font-sm); font-weight: 600; font-size: var(--text-sm); font-weight: 600;
} }
.btn-danger:hover { filter: brightness(1.1); } .btn-danger:hover { filter: brightness(1.1); }
.btn-secondary { .btn-secondary {
@ -140,7 +140,7 @@ defineEmits<{
color: var(--color-text); color: var(--color-text);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-md); cursor: pointer; border-radius: var(--radius-md); cursor: pointer;
font-size: var(--font-sm); font-size: var(--text-sm);
} }
.btn-secondary:hover { background: var(--color-surface-alt); } .btn-secondary:hover { background: var(--color-surface-alt); }
</style> </style>

View file

@ -46,11 +46,11 @@ const emit = defineEmits<{
<style scoped> <style scoped>
.rp-confirm { display: flex; flex-direction: column; gap: var(--space-4, 1rem); } .rp-confirm { display: flex; flex-direction: column; gap: var(--space-4, 1rem); }
.rp__heading { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; color: var(--color-text, #1a2338); } .rp__heading { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; color: var(--color-text, #1a2338); }
.rp__hint { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #4a5c7a); margin: 0; } .rp__hint { font-size: var(--text-sm); color: var(--color-text-muted, #4a5c7a); margin: 0; }
.rp-confirm__list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); } .rp-confirm__list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
.rp-confirm__item { display: flex; align-items: center; gap: var(--space-3, 0.75rem); padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); font-size: var(--font-sm, 0.875rem); } .rp-confirm__item { display: flex; align-items: center; gap: var(--space-3, 0.75rem); padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); font-size: var(--text-sm); }
.rp-confirm__status { margin-left: auto; font-size: var(--font-xs, 0.75rem); color: var(--color-text-muted, #4a5c7a); text-transform: capitalize; } .rp-confirm__status { margin-left: auto; font-size: var(--font-xs, 0.75rem); color: var(--color-text-muted, #4a5c7a); text-transform: capitalize; }
.rp__error { color: var(--color-error, #c0392b); font-size: var(--font-sm, 0.875rem); margin: 0; } .rp__error { color: var(--color-error, #c0392b); font-size: var(--text-sm); margin: 0; }
.rp-confirm__actions { display: flex; gap: var(--space-3, 0.75rem); flex-wrap: wrap; } .rp-confirm__actions { display: flex; gap: var(--space-3, 0.75rem); flex-wrap: wrap; }
.tab__dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: var(--tab-color, #94a3b8); } .tab__dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: var(--tab-color, #94a3b8); }
.tab__dot--unvisited { --tab-color: var(--color-text-muted, #94a3b8); } .tab__dot--unvisited { --tab-color: var(--color-text-muted, #94a3b8); }
@ -64,7 +64,7 @@ const emit = defineEmits<{
padding: var(--space-3, 0.75rem) var(--space-4, 1rem); padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
background: var(--color-accent, #c4732a); color: #fff; background: var(--color-accent, #c4732a); color: #fff;
border: none; border-radius: var(--radius-md, 0.5rem); border: none; border-radius: var(--radius-md, 0.5rem);
font-size: var(--font-sm, 0.875rem); font-weight: 600; cursor: pointer; font-size: var(--text-sm); font-weight: 600; cursor: pointer;
} }
.btn-generate:disabled { opacity: 0.6; cursor: not-allowed; } .btn-generate:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-secondary { .btn-secondary {
@ -72,6 +72,6 @@ const emit = defineEmits<{
padding: var(--space-3, 0.75rem) var(--space-4, 1rem); padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
background: var(--color-surface-alt, #dde4f0); color: var(--color-text, #1a2338); background: var(--color-surface-alt, #dde4f0); color: var(--color-text, #1a2338);
border: 1px solid var(--color-border, #a8b8d0); border-radius: var(--radius-md, 0.5rem); border: 1px solid var(--color-border, #a8b8d0); border-radius: var(--radius-md, 0.5rem);
font-size: var(--font-sm, 0.875rem); font-weight: 600; cursor: pointer; font-size: var(--text-sm); font-weight: 600; cursor: pointer;
} }
</style> </style>

View file

@ -9,11 +9,25 @@
<li v-for="b in entry.original_bullets" :key="b">{{ b }}</li> <li v-for="b in entry.original_bullets" :key="b">{{ b }}</li>
</ul> </ul>
</div> </div>
<div class="rp__diff-col"> <div class="rp__diff-col rp__diff-col--editable">
<span class="rp__diff-label">Proposed</span> <span class="rp__diff-label">Proposed edit below</span>
<ul class="rp__bullet-list"> <div class="rp__bullet-edit-list" role="list" :aria-label="`Edit proposed bullets for ${entry.title}`">
<li v-for="b in entry.proposed_bullets" :key="b">{{ b }}</li> <div
</ul> v-for="(bullet, idx) in editedBullets"
:key="idx"
class="rp__bullet-edit-row"
role="listitem"
>
<textarea
class="rp__bullet-textarea"
:value="bullet"
:aria-label="`Bullet ${idx + 1}`"
rows="2"
spellcheck="true"
@input="updateBullet(idx, ($event.target as HTMLTextAreaElement).value)"
/>
</div>
</div>
</div> </div>
</div> </div>
<label class="rp__accept-toggle"> <label class="rp__accept-toggle">
@ -28,7 +42,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ const props = defineProps<{
entry: { entry: {
title: string title: string
company: string company: string
@ -36,21 +50,46 @@ defineProps<{
proposed_bullets: string[] proposed_bullets: string[]
} }
accepted: boolean accepted: boolean
editedBullets: string[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:accepted': [v: boolean] 'update:accepted': [v: boolean]
'update:editedBullets': [v: string[]]
}>() }>()
function updateBullet(idx: number, value: string) {
const next = props.editedBullets.map((b, i) => (i === idx ? value : b))
emit('update:editedBullets', next)
}
</script> </script>
<style scoped> <style scoped>
.rp-exp { display: flex; flex-direction: column; gap: var(--space-4, 1rem); } .rp-exp { display: flex; flex-direction: column; gap: var(--space-4, 1rem); }
.rp__heading { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; color: var(--color-text, #1a2338); } .rp__heading { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; color: var(--color-text, #1a2338); }
.rp__company { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #4a5c7a); margin: 0; } .rp__company { font-size: var(--text-sm); color: var(--color-text-muted, #4a5c7a); margin: 0; }
.rp__diff-pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4, 1rem); } .rp__diff-pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4, 1rem); }
@media (max-width: 600px) { .rp__diff-pair { grid-template-columns: 1fr; } } @media (max-width: 600px) { .rp__diff-pair { grid-template-columns: 1fr; } }
.rp__diff-col { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); } .rp__diff-col { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
.rp__diff-col--editable { gap: var(--space-2, 0.5rem); }
.rp__diff-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted, #4a5c7a); } .rp__diff-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted, #4a5c7a); }
.rp__bullet-list { margin: 0; padding-left: var(--space-4, 1rem); font-size: var(--font-sm, 0.875rem); line-height: 1.6; background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); padding: var(--space-3, 0.75rem) var(--space-3, 0.75rem) var(--space-3, 0.75rem) var(--space-6, 1.5rem); } .rp__bullet-list { margin: 0; padding-left: var(--space-4, 1rem); font-size: var(--text-sm); line-height: 1.6; background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); padding: var(--space-3, 0.75rem) var(--space-3, 0.75rem) var(--space-3, 0.75rem) var(--space-6, 1.5rem); }
.rp__accept-toggle { display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem); cursor: pointer; font-size: var(--font-sm, 0.875rem); } .rp__bullet-edit-list { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
.rp__bullet-edit-row { display: flex; align-items: flex-start; gap: var(--space-1, 0.25rem); }
.rp__bullet-textarea {
flex: 1;
font-size: var(--text-sm);
line-height: 1.5;
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
background: var(--color-surface, #eaeff8);
border: 1.5px solid var(--color-accent, #c4732a);
border-radius: var(--radius-sm, 0.25rem);
color: var(--color-text, #1a2338);
resize: vertical;
width: 100%;
box-sizing: border-box;
font-family: inherit;
}
.rp__bullet-textarea:focus { outline: 2px solid var(--color-accent, #c4732a); outline-offset: 2px; }
.rp__accept-toggle { display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem); cursor: pointer; font-size: var(--text-sm); }
</style> </style>

View file

@ -58,7 +58,7 @@ const emit = defineEmits<{
<style scoped> <style scoped>
.rp-skills { display: flex; flex-direction: column; gap: var(--space-4, 1rem); } .rp-skills { display: flex; flex-direction: column; gap: var(--space-4, 1rem); }
.rp__heading { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; color: var(--color-text, #1a2338); } .rp__heading { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; color: var(--color-text, #1a2338); }
.rp__hint { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #4a5c7a); margin: 0; } .rp__hint { font-size: var(--text-sm); color: var(--color-text-muted, #4a5c7a); margin: 0; }
.rp__skill-list { display: flex; flex-direction: column; gap: var(--space-3, 0.75rem); } .rp__skill-list { display: flex; flex-direction: column; gap: var(--space-3, 0.75rem); }
.rp__skill-group { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); } .rp__skill-group { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
.rp__skill-chip { .rp__skill-chip {
@ -66,13 +66,13 @@ const emit = defineEmits<{
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
border: 1px solid var(--color-border, #a8b8d0); border: 1px solid var(--color-border, #a8b8d0);
border-radius: var(--radius-md, 0.5rem); border-radius: var(--radius-md, 0.5rem);
cursor: pointer; font-size: var(--font-sm, 0.875rem); cursor: pointer; font-size: var(--text-sm);
background: var(--color-surface-raised, #f5f7fc); background: var(--color-surface-raised, #f5f7fc);
transition: background var(--transition, 200ms ease); transition: background var(--transition, 200ms ease);
} }
.rp__skill-chip--approved { background: var(--color-primary-light, #e8f2e7); border-color: var(--color-primary, #2d5a27); } .rp__skill-chip--approved { background: var(--color-primary-light, #e8f2e7); border-color: var(--color-primary, #2d5a27); }
.rp__framing { padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); } .rp__framing { padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); }
.rp__framing-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; color: var(--color-text-muted, #4a5c7a); } .rp__framing-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; color: var(--color-text-muted, #4a5c7a); }
.rp__framing-context { border: 1px solid var(--color-border, #a8b8d0); border-radius: var(--radius-sm, 0.25rem); padding: var(--space-2, 0.5rem); font-size: var(--font-sm, 0.875rem); resize: vertical; } .rp__framing-context { border: 1px solid var(--color-border, #a8b8d0); border-radius: var(--radius-sm, 0.25rem); padding: var(--space-2, 0.5rem); font-size: var(--text-sm); resize: vertical; }
.rp__removed { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #4a5c7a); font-style: italic; } .rp__removed { font-size: var(--text-sm); color: var(--color-text-muted, #4a5c7a); font-style: italic; }
</style> </style>

View file

@ -6,9 +6,15 @@
<span class="rp__diff-label" aria-label="Original">Original</span> <span class="rp__diff-label" aria-label="Original">Original</span>
<p class="rp__diff-text">{{ section.original || '(empty)' }}</p> <p class="rp__diff-text">{{ section.original || '(empty)' }}</p>
</div> </div>
<div class="rp__diff-col"> <div class="rp__diff-col rp__diff-col--editable">
<span class="rp__diff-label" aria-label="Proposed">Proposed</span> <span class="rp__diff-label" aria-label="Proposed editable">Proposed</span>
<p class="rp__diff-text">{{ section.proposed }}</p> <textarea
class="rp__edit-textarea"
:value="editedProposed"
:aria-label="`Edit proposed summary`"
spellcheck="true"
@input="emit('update:editedProposed', ($event.target as HTMLTextAreaElement).value)"
/>
</div> </div>
</div> </div>
<label class="rp__accept-toggle"> <label class="rp__accept-toggle">
@ -28,10 +34,12 @@ import type { TextDiff } from '../ResumeReviewModal.vue'
defineProps<{ defineProps<{
section: TextDiff section: TextDiff
accepted: boolean accepted: boolean
editedProposed: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:accepted': [v: boolean] 'update:accepted': [v: boolean]
'update:editedProposed': [v: string]
}>() }>()
</script> </script>
@ -41,7 +49,23 @@ const emit = defineEmits<{
.rp__diff-pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4, 1rem); } .rp__diff-pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4, 1rem); }
@media (max-width: 600px) { .rp__diff-pair { grid-template-columns: 1fr; } } @media (max-width: 600px) { .rp__diff-pair { grid-template-columns: 1fr; } }
.rp__diff-col { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); } .rp__diff-col { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
.rp__diff-col--editable { gap: var(--space-2, 0.5rem); }
.rp__diff-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted, #4a5c7a); } .rp__diff-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted, #4a5c7a); }
.rp__diff-text { font-size: var(--font-sm, 0.875rem); line-height: 1.6; padding: var(--space-3, 0.75rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); margin: 0; } .rp__diff-text { font-size: var(--text-sm); line-height: 1.6; padding: var(--space-3, 0.75rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); margin: 0; }
.rp__accept-toggle { display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem); cursor: pointer; font-size: var(--font-sm, 0.875rem); } .rp__edit-textarea {
font-size: var(--text-sm);
line-height: 1.6;
padding: var(--space-3, 0.75rem);
background: var(--color-surface, #eaeff8);
border: 1.5px solid var(--color-accent, #c4732a);
border-radius: var(--radius-sm, 0.25rem);
color: var(--color-text, #1a2338);
resize: vertical;
min-height: 7rem;
width: 100%;
box-sizing: border-box;
font-family: inherit;
}
.rp__edit-textarea:focus { outline: 2px solid var(--color-accent, #c4732a); outline-offset: 2px; }
.rp__accept-toggle { display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem); cursor: pointer; font-size: var(--text-sm); }
</style> </style>

View file

@ -46,6 +46,7 @@ export const router = createRouter({
{ path: 'hardware', component: () => import('../views/wizard/WizardHardwareStep.vue') }, { path: 'hardware', component: () => import('../views/wizard/WizardHardwareStep.vue') },
{ path: 'tier', component: () => import('../views/wizard/WizardTierStep.vue') }, { path: 'tier', component: () => import('../views/wizard/WizardTierStep.vue') },
{ path: 'resume', component: () => import('../views/wizard/WizardResumeStep.vue') }, { path: 'resume', component: () => import('../views/wizard/WizardResumeStep.vue') },
{ path: 'training', component: () => import('../views/wizard/WizardTrainingStep.vue') },
{ path: 'identity', component: () => import('../views/wizard/WizardIdentityStep.vue') }, { path: 'identity', component: () => import('../views/wizard/WizardIdentityStep.vue') },
{ path: 'inference', component: () => import('../views/wizard/WizardInferenceStep.vue') }, { path: 'inference', component: () => import('../views/wizard/WizardInferenceStep.vue') },
{ path: 'search', component: () => import('../views/wizard/WizardSearchStep.vue') }, { path: 'search', component: () => import('../views/wizard/WizardSearchStep.vue') },

View file

@ -27,6 +27,7 @@ describe('usePrepStore', () => {
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null, tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: '2026-03-20T12:00:00' }, error: null }) // research generated_at: '2026-03-20T12:00:00' }, error: null }) // research
.mockResolvedValueOnce({ data: [], error: null }) // contacts .mockResolvedValueOnce({ data: [], error: null }) // contacts
.mockResolvedValueOnce({ data: [], error: null }) // qa
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task .mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null, .mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
description: 'Build things.', cover_letter: null, match_score: 80, description: 'Build things.', cover_letter: null, match_score: 80,
@ -50,6 +51,7 @@ describe('usePrepStore', () => {
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null, tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: null }, error: null }) generated_at: null }, error: null })
.mockResolvedValueOnce({ data: [], error: null }) .mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: [], error: null }) // qa
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) .mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 1, title: 'Old Job', company: 'OldCo', url: null, .mockResolvedValueOnce({ data: { id: 1, title: 'Old Job', company: 'OldCo', url: null,
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null }) description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
@ -62,6 +64,7 @@ describe('usePrepStore', () => {
mockApiFetch mockApiFetch
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 404, detail: '' } }) // 404 → null .mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 404, detail: '' } }) // 404 → null
.mockResolvedValueOnce({ data: [], error: null }) .mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: [], error: null }) // qa
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) .mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 2, title: 'New Job', company: 'NewCo', url: null, .mockResolvedValueOnce({ data: { id: 2, title: 'New Job', company: 'NewCo', url: null,
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null }) description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
@ -102,6 +105,7 @@ describe('usePrepStore', () => {
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null, tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: null }, error: null }) generated_at: null }, error: null })
.mockResolvedValueOnce({ data: [], error: null }) .mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: [], error: null }) // qa
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) .mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null, .mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null }) description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
@ -112,11 +116,12 @@ describe('usePrepStore', () => {
// Mock first poll → completed // Mock first poll → completed
mockApiFetch mockApiFetch
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null }) .mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null })
// re-fetch on completed: research, contacts, task, fullJob // re-fetch on completed: research, contacts, qa, task, fullJob
.mockResolvedValueOnce({ data: { company_brief: 'Updated!', ceo_brief: null, talking_points: null, .mockResolvedValueOnce({ data: { company_brief: 'Updated!', ceo_brief: null, talking_points: null,
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null, tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: '2026-03-20T13:00:00' }, error: null }) generated_at: '2026-03-20T13:00:00' }, error: null })
.mockResolvedValueOnce({ data: [], error: null }) .mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: [], error: null }) // qa
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null }) .mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null, .mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
description: 'Now with content', cover_letter: null, match_score: null, keyword_gaps: null }, error: null }) description: 'Now with content', cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
@ -134,6 +139,7 @@ describe('usePrepStore', () => {
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null, tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: null }, error: null }) generated_at: null }, error: null })
.mockResolvedValueOnce({ data: [], error: null }) .mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: [], error: null }) // qa
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) .mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null, .mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null }) description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
@ -162,6 +168,7 @@ describe('usePrepStore', () => {
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null, tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: '2026-03-20T12:00:00' }, error: null }) // research OK generated_at: '2026-03-20T12:00:00' }, error: null }) // research OK
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 500, detail: 'DB error' } }) // contacts fail .mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 500, detail: 'DB error' } }) // contacts fail
.mockResolvedValueOnce({ data: [], error: null }) // qa
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task OK .mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task OK
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null, .mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
description: 'Build things.', cover_letter: null, match_score: 80, description: 'Build things.', cover_letter: null, match_score: 80,

View file

@ -1,8 +1,11 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
import { useFineTuneStore } from './fineTune' import { useFineTuneStore } from './fineTune'
import type { DbPair } from './fineTune'
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() })) vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
vi.mock('../appConfig', () => ({ useAppConfigStore: vi.fn(() => ({ isDemo: false })) }))
vi.mock('../../composables/useToast', () => ({ showToast: vi.fn() }))
import { useApiFetch } from '../../composables/useApi' import { useApiFetch } from '../../composables/useApi'
const mockFetch = vi.mocked(useApiFetch) const mockFetch = vi.mocked(useApiFetch)
@ -36,4 +39,47 @@ describe('useFineTuneStore', () => {
expect(mockFetch).toHaveBeenCalledWith('/api/settings/fine-tune/status') expect(mockFetch).toHaveBeenCalledWith('/api/settings/fine-tune/status')
store.stopPolling() store.stopPolling()
}) })
it('toggleOptIn updates optedIn state', async () => {
mockFetch.mockResolvedValue({ data: { ok: true, enabled: true }, error: null })
const store = useFineTuneStore()
await store.toggleOptIn(true)
expect(store.optedIn).toBe(true)
})
it('loadDbPairs no-ops when not opted in', async () => {
const store = useFineTuneStore()
store.optedIn = false
await store.loadDbPairs()
expect(store.dbPairs).toEqual([])
expect(mockFetch).not.toHaveBeenCalledWith('/api/settings/fine-tune/db-pairs')
})
it('loadDbPairs fetches when opted in', async () => {
const pairs: DbPair[] = [{ job_id: 1, title: 'Eng', company: 'Acme', status: 'applied', instruction: 'Write...', input_preview: 'Build', excluded: false }]
mockFetch.mockResolvedValue({ data: { pairs, total: 1, excluded_count: 0 }, error: null })
const store = useFineTuneStore()
store.optedIn = true
await store.loadDbPairs()
expect(store.dbPairs).toHaveLength(1)
})
it('excludeDbPair marks pair excluded and increments count', async () => {
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
const store = useFineTuneStore()
store.dbPairs = [{ job_id: 1, title: 'Eng', company: 'Acme', status: 'applied', instruction: 'Write...', input_preview: 'Build', excluded: false }]
await store.excludeDbPair(1)
expect(store.dbPairs[0].excluded).toBe(true)
expect(store.dbExcludedCount).toBe(1)
})
it('includeDbPair marks pair included and decrements excludedCount', async () => {
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
const store = useFineTuneStore()
store.dbPairs = [{ job_id: 1, title: 'Eng', company: 'Acme', status: 'applied', instruction: 'Write...', input_preview: 'Build', excluded: true }]
store.dbExcludedCount = 1
await store.includeDbPair(1)
expect(store.dbPairs[0].excluded).toBe(false)
expect(store.dbExcludedCount).toBe(0)
})
}) })

View file

@ -10,6 +10,16 @@ export interface TrainingPair {
source_file: string source_file: string
} }
export interface DbPair {
job_id: number
title: string
company: string
status: string
instruction: string
input_preview: string
excluded: boolean
}
export const useFineTuneStore = defineStore('settings/fineTune', () => { export const useFineTuneStore = defineStore('settings/fineTune', () => {
const step = ref(1) const step = ref(1)
const inFlightJob = ref(false) const inFlightJob = ref(false)
@ -22,6 +32,11 @@ export const useFineTuneStore = defineStore('settings/fineTune', () => {
const pairsLoading = ref(false) const pairsLoading = ref(false)
let _pollTimer: ReturnType<typeof setInterval> | null = null let _pollTimer: ReturnType<typeof setInterval> | null = null
const optedIn = ref(false)
const dbPairs = ref<DbPair[]>([])
const dbPairsLoading = ref(false)
const dbExcludedCount = ref(0)
function resetStep() { step.value = 1 } function resetStep() { step.value = 1 }
async function loadStatus() { async function loadStatus() {
@ -31,6 +46,7 @@ export const useFineTuneStore = defineStore('settings/fineTune', () => {
pairsCount.value = data.pairs_count ?? 0 pairsCount.value = data.pairs_count ?? 0
quotaRemaining.value = data.quota_remaining ?? null quotaRemaining.value = data.quota_remaining ?? null
inFlightJob.value = ['queued', 'running'].includes(data.status) inFlightJob.value = ['queued', 'running'].includes(data.status)
optedIn.value = (data as any).opted_in ?? false
} }
function startPolling() { function startPolling() {
@ -68,6 +84,60 @@ export const useFineTuneStore = defineStore('settings/fineTune', () => {
} }
} }
async function toggleOptIn(enabled: boolean) {
const { data } = await useApiFetch<{ ok: boolean; enabled: boolean }>(
'/api/settings/fine-tune/opt-in',
{ method: 'PATCH', body: JSON.stringify({ enabled }), headers: { 'Content-Type': 'application/json' } },
)
if (data) optedIn.value = data.enabled
}
async function loadDbPairs() {
if (!optedIn.value) { dbPairs.value = []; return }
dbPairsLoading.value = true
const { data } = await useApiFetch<{ pairs: DbPair[]; total: number; excluded_count: number }>(
'/api/settings/fine-tune/db-pairs',
)
dbPairsLoading.value = false
if (data) {
dbPairs.value = data.pairs
dbExcludedCount.value = data.excluded_count
}
}
async function excludeDbPair(jobId: number) {
const { data } = await useApiFetch<{ ok: boolean }>(
`/api/settings/fine-tune/db-pairs/${jobId}/exclude`,
{ method: 'PATCH' },
)
if (data?.ok) {
dbPairs.value = dbPairs.value.map(p =>
p.job_id === jobId ? { ...p, excluded: true } : p,
)
dbExcludedCount.value += 1
}
}
async function includeDbPair(jobId: number) {
const { data } = await useApiFetch<{ ok: boolean }>(
`/api/settings/fine-tune/db-pairs/${jobId}/include`,
{ method: 'PATCH' },
)
if (data?.ok) {
dbPairs.value = dbPairs.value.map(p =>
p.job_id === jobId ? { ...p, excluded: false } : p,
)
dbExcludedCount.value = Math.max(0, dbExcludedCount.value - 1)
}
}
function downloadExport() {
const a = document.createElement('a')
a.href = '/api/settings/fine-tune/export'
a.download = 'peregrine_training_pairs.jsonl'
a.click()
}
return { return {
step, step,
inFlightJob, inFlightJob,
@ -85,5 +155,14 @@ export const useFineTuneStore = defineStore('settings/fineTune', () => {
submitJob, submitJob,
loadPairs, loadPairs,
deletePair, deletePair,
optedIn,
dbPairs,
dbPairsLoading,
dbExcludedCount,
toggleOptIn,
loadDbPairs,
excludeDbPair,
includeDbPair,
downloadExport,
} }
}) })

View file

@ -54,14 +54,20 @@ describe('useSurveyStore', () => {
}) })
it('analyze stores result including mode and rawInput', async () => { it('analyze stores result including mode and rawInput', async () => {
vi.useFakeTimers()
const mockApiFetch = vi.mocked(useApiFetch) const mockApiFetch = vi.mocked(useApiFetch)
// POST → task accepted
mockApiFetch.mockResolvedValueOnce({ data: { task_id: 7, is_new: true }, error: null })
// Poll → completed with result
mockApiFetch.mockResolvedValueOnce({ mockApiFetch.mockResolvedValueOnce({
data: { output: '1. B — reason', source: 'text_paste' }, data: { status: 'completed', stage: null, message: null,
result: { output: '1. B — reason', source: 'text_paste' } },
error: null, error: null,
}) })
const store = useSurveyStore() const store = useSurveyStore()
await store.analyze(1, { text: 'Q1: test', mode: 'quick' }) await store.analyze(1, { text: 'Q1: test', mode: 'quick' })
await vi.advanceTimersByTimeAsync(3000)
expect(store.analysis).not.toBeNull() expect(store.analysis).not.toBeNull()
expect(store.analysis!.output).toBe('1. B — reason') expect(store.analysis!.output).toBe('1. B — reason')
@ -69,6 +75,7 @@ describe('useSurveyStore', () => {
expect(store.analysis!.mode).toBe('quick') expect(store.analysis!.mode).toBe('quick')
expect(store.analysis!.rawInput).toBe('Q1: test') expect(store.analysis!.rawInput).toBe('Q1: test')
expect(store.loading).toBe(false) expect(store.loading).toBe(false)
vi.useRealTimers()
}) })
it('analyze sets error on failure', async () => { it('analyze sets error on failure', async () => {

View file

@ -43,13 +43,14 @@ export interface WizardInferenceData {
testMessage: string testMessage: string
} }
// Total mandatory steps (integrations step 7 is optional/skip-able) // Total mandatory steps (integrations step 8 is optional/skip-able)
export const WIZARD_STEPS = 6 export const WIZARD_STEPS = 8
export const STEP_LABELS = ['Hardware', 'Tier', 'Resume', 'Identity', 'Inference', 'Search', 'Integrations'] export const STEP_LABELS = ['Hardware', 'Tier', 'Resume', 'Training', 'Identity', 'Inference', 'Search', 'Integrations']
export const STEP_ROUTES = [ export const STEP_ROUTES = [
'/setup/hardware', '/setup/hardware',
'/setup/tier', '/setup/tier',
'/setup/resume', '/setup/resume',
'/setup/training',
'/setup/identity', '/setup/identity',
'/setup/inference', '/setup/inference',
'/setup/search', '/setup/search',
@ -163,7 +164,7 @@ export const useWizardStore = defineStore('wizard', () => {
} }
// Resume at next step after last completed // Resume at next step after last completed
const resumeAt = Math.max(1, Math.min(data.wizard_step + 1, 7)) const resumeAt = Math.max(1, Math.min(data.wizard_step + 1, 8))
currentStep.value = resumeAt currentStep.value = resumeAt
return routeForStep(resumeAt) return routeForStep(resumeAt)
} finally { } finally {

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useApiFetch } from '../composables/useApi' import { useApiFetch } from '../composables/useApi'
import HintChip from '../components/HintChip.vue' import HintChip from '../components/HintChip.vue'
import { useAppConfigStore } from '../stores/appConfig' import { useAppConfigStore } from '../stores/appConfig'
@ -26,6 +26,8 @@ const error = ref<string | null>(null)
const search = ref('') const search = ref('')
const direction = ref<'all' | 'inbound' | 'outbound'>('all') const direction = ref<'all' | 'inbound' | 'outbound'>('all')
const searchInput = ref('') const searchInput = ref('')
const syncing = ref(false)
const syncStatus = ref<{ status: string; last_completed_at: string | null } | null>(null)
let debounceTimer: ReturnType<typeof setTimeout> | null = null let debounceTimer: ReturnType<typeof setTimeout> | null = null
async function fetchContacts() { async function fetchContacts() {
@ -76,9 +78,45 @@ const signalLabel: Record<string, string> = {
rejected: '✖ Rejected', rejected: '✖ Rejected',
positive_response: '✅ Positive', positive_response: '✅ Positive',
survey_received: '📋 Survey', survey_received: '📋 Survey',
event_rescheduled: '🔄 Rescheduled',
neutral: '— Neutral',
} }
onMounted(fetchContacts) async function fetchSyncStatus() {
const { data } = await useApiFetch<{ status: string; last_completed_at: string | null }>(
'/api/email/sync/status'
)
if (data) syncStatus.value = data
}
async function triggerSync() {
syncing.value = true
await useApiFetch('/api/tasks/email-sync', { method: 'POST' })
// Poll until the task finishes or we give up after 60 s
const deadline = Date.now() + 60_000
const poll = setInterval(async () => {
await fetchSyncStatus()
if (syncStatus.value?.status === 'completed' || Date.now() > deadline) {
clearInterval(poll)
syncing.value = false
fetchContacts()
}
}, 2000)
}
function formatSyncTime(iso: string | null): string {
if (!iso) return 'never'
const d = new Date(iso)
const diff = Date.now() - d.getTime()
if (diff < 60_000) return 'just now'
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
}
onMounted(async () => {
await Promise.all([fetchContacts(), fetchSyncStatus()])
})
</script> </script>
<template> <template>
@ -91,6 +129,20 @@ onMounted(fetchContacts)
<header class="contacts-header"> <header class="contacts-header">
<h1 class="contacts-title">Contacts</h1> <h1 class="contacts-title">Contacts</h1>
<span class="contacts-count" v-if="total > 0">{{ total }} total</span> <span class="contacts-count" v-if="total > 0">{{ total }} total</span>
<div class="contacts-sync">
<span v-if="syncStatus" class="sync-last">
Last sync: {{ formatSyncTime(syncStatus.last_completed_at) }}
</span>
<button
class="btn-sync"
:disabled="syncing"
@click="triggerSync"
:aria-label="syncing ? 'Email sync running' : 'Sync email now'"
>
<span :class="['sync-icon', { 'sync-icon--spinning': syncing }]"></span>
{{ syncing ? 'Syncing…' : 'Sync email' }}
</button>
</div>
</header> </header>
<div class="contacts-toolbar"> <div class="contacts-toolbar">
@ -115,8 +167,16 @@ onMounted(fetchContacts)
<div v-if="loading" class="contacts-empty">Loading</div> <div v-if="loading" class="contacts-empty">Loading</div>
<div v-else-if="error" class="contacts-empty contacts-empty--error">{{ error }}</div> <div v-else-if="error" class="contacts-empty contacts-empty--error">{{ error }}</div>
<div v-else-if="contacts.length === 0 && !search" class="contacts-empty contacts-empty--setup">
<p>No contacts yet.</p>
<p class="contacts-empty-hint">
Connect your inbox in
<a href="/settings?tab=connections" class="setup-link">Settings Connections</a>
then hit <strong>Sync email</strong> to import recruiter emails automatically.
</p>
</div>
<div v-else-if="contacts.length === 0" class="contacts-empty"> <div v-else-if="contacts.length === 0" class="contacts-empty">
No contacts found{{ search ? ' for that search' : '' }}. No contacts found for that search.
</div> </div>
<div v-else class="contacts-table-wrap"> <div v-else class="contacts-table-wrap">
@ -339,4 +399,69 @@ onMounted(fetchContacts)
.text-muted { .text-muted {
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.contacts-sync {
display: flex;
align-items: center;
gap: var(--space-3);
margin-left: auto;
}
.sync-last {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.btn-sync {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: transparent;
border: 1px solid var(--color-border);
border-radius: 7px;
color: var(--color-text-muted);
font-size: var(--text-sm);
cursor: pointer;
white-space: nowrap;
}
.btn-sync:hover:not(:disabled) {
border-color: var(--app-primary);
color: var(--app-primary);
}
.btn-sync:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.sync-icon {
font-size: 1rem;
line-height: 1;
display: inline-block;
}
.sync-icon--spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.contacts-empty--setup {
padding: var(--space-10) var(--space-4);
}
.contacts-empty-hint {
margin-top: var(--space-2);
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.setup-link {
color: var(--app-primary);
text-decoration: underline;
}
</style> </style>

View file

@ -159,6 +159,10 @@
rows="4" rows="4"
aria-label="Job URLs to add" aria-label="Job URLs to add"
/> />
<label class="add-jobs__skip-review">
<input type="checkbox" v-model="skipReview" />
Skip review add directly to Apply queue
</label>
<button <button
class="action-btn action-btn--primary" class="action-btn action-btn--primary"
:disabled="!urlInput.trim()" :disabled="!urlInput.trim()"
@ -439,13 +443,14 @@ const runEnrich = () => runTask('enrich', '/api/tasks/enrich')
const addTab = ref<'url' | 'csv'>('url') const addTab = ref<'url' | 'csv'>('url')
const urlInput = ref('') const urlInput = ref('')
const skipReview = ref(true)
async function addByUrl() { async function addByUrl() {
const urls = urlInput.value.split('\n').map(u => u.trim()).filter(Boolean) const urls = urlInput.value.split('\n').map(u => u.trim()).filter(Boolean)
await useApiFetch('/api/jobs/add', { await useApiFetch('/api/jobs/add', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ urls }), body: JSON.stringify({ urls, skip_review: skipReview.value }),
}) })
urlInput.value = '' urlInput.value = ''
store.refresh() store.refresh()
@ -791,6 +796,16 @@ onUnmounted(() => {
.add-jobs__textarea:focus { outline: 2px solid var(--app-primary); outline-offset: 1px; } .add-jobs__textarea:focus { outline: 2px solid var(--app-primary); outline-offset: 1px; }
.add-jobs__skip-review {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--color-text-muted);
cursor: pointer;
user-select: none;
}
/* ── Danger Zone ──────────────────────────────────────── */ /* ── Danger Zone ──────────────────────────────────────── */
.danger-zone { .danger-zone {

View file

@ -682,7 +682,7 @@ function formatRejectionDate(job: PipelineJob): string {
padding: 1px 8px; font-size: 0.75em; font-weight: 700; margin-left: var(--space-1); padding: 1px 8px; font-size: 0.75em; font-weight: 700; margin-left: var(--space-1);
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.pre-list-signal-count { margin-left: auto; font-size: 0.75em; font-weight: 700; color: #e67e22; } .pre-list-signal-count { margin-left: auto; font-size: 0.75em; font-weight: 700; color: var(--app-accent); }
/* Collapsible pre-list body */ /* Collapsible pre-list body */
.pre-list-body { .pre-list-body {
@ -713,15 +713,15 @@ function formatRejectionDate(job: PipelineJob): string {
border-top: 1px solid transparent; border-top: 1px solid transparent;
display: flex; flex-direction: column; gap: 4px; display: flex; flex-direction: column; gap: 4px;
} }
.pre-signal-banner[data-color="amber"] { background: rgba(245,158,11,0.08); border-top-color: rgba(245,158,11,0.4); } .pre-signal-banner[data-color="amber"] { background: color-mix(in srgb, var(--color-warning) 8%, var(--color-surface)); border-top-color: color-mix(in srgb, var(--color-warning) 40%, transparent); }
.pre-signal-banner[data-color="green"] { background: rgba(39,174,96,0.08); border-top-color: rgba(39,174,96,0.4); } .pre-signal-banner[data-color="green"] { background: color-mix(in srgb, var(--color-success) 8%, var(--color-surface)); border-top-color: color-mix(in srgb, var(--color-success) 40%, transparent); }
.pre-signal-banner[data-color="red"] { background: rgba(192,57,43,0.08); border-top-color: rgba(192,57,43,0.4); } .pre-signal-banner[data-color="red"] { background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface)); border-top-color: color-mix(in srgb, var(--color-error) 40%, transparent); }
.signal-label { font-size: 0.82em; } .signal-label { font-size: 0.82em; }
.signal-subject { font-size: 0.78em; color: var(--color-text-muted); } .signal-subject { font-size: 0.78em; color: var(--color-text-muted); }
.signal-actions { display: flex; gap: 6px; align-items: center; } .signal-actions { display: flex; gap: 6px; align-items: center; }
.btn-signal-move { .btn-signal-move {
background: var(--color-primary); color: #fff; background: var(--color-primary); color: var(--color-text-inverse);
border: none; border-radius: 4px; padding: 2px 8px; font-size: 0.78em; cursor: pointer; border: none; border-radius: 4px; padding: 2px 8px; font-size: 0.78em; cursor: pointer;
} }
.btn-signal-dismiss { .btn-signal-dismiss {
@ -767,7 +767,7 @@ function formatRejectionDate(job: PipelineJob): string {
background: var(--color-hover); background: var(--color-hover);
} }
.btn-chip-active { .btn-chip-active {
background: var(--color-primary-muted, #e8f0ff); background: var(--app-primary-light);
color: var(--color-primary); border-color: var(--color-primary); color: var(--color-primary); border-color: var(--color-primary);
font-weight: 600; font-weight: 600;
} }

View file

@ -496,7 +496,7 @@ onUnmounted(() => {
.tab-badge { .tab-badge {
background: var(--color-warning); background: var(--color-warning);
color: white; color: var(--app-accent-text);
font-size: 0.65rem; font-size: 0.65rem;
font-weight: 700; font-weight: 700;
border-radius: 999px; border-radius: 999px;

View file

@ -34,35 +34,8 @@
</div> </div>
<template v-else> <template v-else>
<!-- Action bar -->
<div class="action-bar" role="toolbar" aria-label="Message actions">
<button class="btn btn--ghost" @click="openLogModal('call_note')">Log call</button>
<button class="btn btn--ghost" @click="openLogModal('in_person')">Log note</button>
<button class="btn btn--ghost" @click="openTemplateModal('apply')">Use template</button>
<button
class="btn btn--primary"
:disabled="store.loading"
@click="requestDraft"
>
{{ store.loading ? 'Drafting…' : 'Draft reply with LLM' }}
</button>
<!-- Osprey (Phase 2 stub) aria-disabled, never hidden -->
<button
class="btn btn--osprey"
aria-disabled="true"
:title="ospreyTitle"
@mouseenter="handleOspreyHover"
@focus="handleOspreyHover"
>
📞 Call via Osprey
</button>
</div>
<!-- Draft pending announcement (screen reader) --> <!-- Draft pending announcement (screen reader) -->
<div aria-live="polite" aria-atomic="true" class="sr-only"> <div aria-live="polite" aria-atomic="true" class="sr-only">{{ draftAnnouncement }}</div>
{{ draftAnnouncement }}
</div>
<!-- Error banner --> <!-- Error banner -->
<p v-if="store.error" class="thread-error" role="alert">{{ store.error }}</p> <p v-if="store.error" class="thread-error" role="alert">{{ store.error }}</p>
@ -76,9 +49,15 @@
v-for="item in timeline" v-for="item in timeline"
:key="item._key" :key="item._key"
class="timeline__item" class="timeline__item"
:class="[`timeline__item--${item.type}`, item.approved_at === null && item.type === 'draft' ? 'timeline__item--draft-pending' : '']" :class="[
`timeline__item--${item.type}`,
item.approved_at === null && item.type === 'draft' ? 'timeline__item--draft-pending' : '',
item.type !== 'draft' ? 'timeline__item--expandable' : '',
expandedKeys.has(item._key) ? 'timeline__item--open' : '',
]"
role="listitem" role="listitem"
:aria-label="`${typeLabel(item.type)}, ${item.direction || ''}, ${item.logged_at}`" :aria-label="`${typeLabel(item.type)}, ${item.direction || ''}, ${item.logged_at}`"
@click="item.type !== 'draft' && toggleExpand(item)"
> >
<span class="timeline__icon" aria-hidden="true">{{ typeIcon(item.type) }}</span> <span class="timeline__icon" aria-hidden="true">{{ typeIcon(item.type) }}</span>
<div class="timeline__content"> <div class="timeline__content">
@ -89,19 +68,29 @@
<span <span
v-if="item.type === 'draft' && item.approved_at === null" v-if="item.type === 'draft' && item.approved_at === null"
class="timeline__badge timeline__badge--pending" class="timeline__badge timeline__badge--pending"
> >Pending approval</span>
Pending approval
</span>
<span <span
v-if="item.type === 'draft' && item.approved_at !== null" v-if="item.type === 'draft' && item.approved_at !== null"
class="timeline__badge timeline__badge--approved" class="timeline__badge timeline__badge--approved"
> >Approved</span>
Approved <span v-if="item.type !== 'draft'" class="timeline__expand-hint" aria-hidden="true">
{{ expandedKeys.has(item._key) ? '▲' : '▼' }}
</span> </span>
</div> </div>
<p v-if="item.subject" class="timeline__subject">{{ item.subject }}</p> <p v-if="item.subject" class="timeline__subject">{{ item.subject }}</p>
<!-- Draft body is editable before approval --> <!-- Expandable body for non-draft items -->
<template v-if="item.type !== 'draft' && expandedKeys.has(item._key)">
<div class="timeline__body-wrap" @click.stop>
<div v-if="bodyCache[item.id] === null" class="timeline__body-loading">
Loading
</div>
<pre v-else-if="bodyCache[item.id]" class="timeline__body">{{ bodyCache[item.id] }}</pre>
<p v-else class="timeline__body-empty">No body content.</p>
</div>
</template>
<!-- Draft: editable textarea + actions -->
<template v-if="item.type === 'draft' && item.approved_at === null"> <template v-if="item.type === 'draft' && item.approved_at === null">
<textarea <textarea
:ref="el => setDraftRef(item.id, el)" :ref="el => setDraftRef(item.id, el)"
@ -119,25 +108,50 @@
v-if="item.to_addr" v-if="item.to_addr"
:href="`mailto:${item.to_addr}?subject=${encodeURIComponent(item.subject ?? '')}&body=${encodeURIComponent(item.body ?? '')}`" :href="`mailto:${item.to_addr}?subject=${encodeURIComponent(item.subject ?? '')}&body=${encodeURIComponent(item.body ?? '')}`"
class="btn btn--ghost btn--sm" class="btn btn--ghost btn--sm"
target="_blank" target="_blank" rel="noopener"
rel="noopener" >Open in email client</a>
>
Open in email client
</a>
<button class="btn btn--ghost btn--sm btn--danger" @click="confirmDelete(item.id)"> <button class="btn btn--ghost btn--sm btn--danger" @click="confirmDelete(item.id)">
Discard Discard
</button> </button>
</div> </div>
</template> </template>
<template v-else>
<p class="timeline__body">{{ item.body }}</p>
</template>
</div> </div>
</li> </li>
<li v-if="timeline.length === 0" class="timeline__empty"> <li v-if="timeline.length === 0" class="timeline__empty">
No messages logged yet for this job. No messages logged yet for this job.
</li> </li>
</ul> </ul>
<!-- Compose bar (sticky footer) -->
<div class="compose-bar" role="toolbar" aria-label="Compose actions">
<div v-if="composing" class="compose-bar__actions">
<button class="btn btn--ghost btn--sm" @click="triggerAction(() => openLogModal('call_note'))">Log call</button>
<button class="btn btn--ghost btn--sm" @click="triggerAction(() => openLogModal('in_person'))">Log note</button>
<button class="btn btn--ghost btn--sm" @click="triggerAction(() => openTemplateModal('apply'))">Use template</button>
<button
class="btn btn--primary btn--sm"
:disabled="store.loading"
@click="triggerAction(requestDraft)"
>
<span v-if="store.loading" class="btn__spinner" aria-hidden="true"></span>
{{ store.loading ? 'Drafting…' : 'Draft reply with LLM' }}
</button>
<button
class="btn btn--osprey btn--sm"
aria-disabled="true"
:title="ospreyTitle"
@mouseenter="handleOspreyHover"
@focus="handleOspreyHover"
>📞 Call via Osprey</button>
</div>
<button
class="btn compose-bar__toggle"
:class="composing ? 'btn--ghost' : 'btn--primary'"
@click="composing = !composing"
:aria-expanded="composing"
aria-controls="compose-actions"
>{{ composing ? '✕ Close' : ' New' }}</button>
</div>
</template> </template>
</main> </main>
@ -230,8 +244,8 @@ const jobContacts = ref<JobContact[]>([])
watch(selectedJobId, async (id) => { watch(selectedJobId, async (id) => {
if (id === null) { jobContacts.value = []; return } if (id === null) { jobContacts.value = []; return }
const { data } = await useApiFetch<JobContact[]>(`/api/contacts?job_id=${id}`) const { data } = await useApiFetch<{ total: number; contacts: JobContact[] }>(`/api/contacts?job_id=${id}`)
jobContacts.value = data ?? [] jobContacts.value = data?.contacts ?? []
}) })
const timeline = computed<TimelineItem[]>(() => { const timeline = computed<TimelineItem[]>(() => {
@ -262,6 +276,31 @@ const timeline = computed<TimelineItem[]>(() => {
) )
}) })
// Body expansion
const expandedKeys = ref(new Set<string>())
const bodyCache = ref<Record<number, string | null>>({}) // null = still loading
async function toggleExpand(item: TimelineItem) {
const key = item._key
const next = new Set(expandedKeys.value)
if (next.has(key)) { next.delete(key); expandedKeys.value = next; return }
next.add(key)
expandedKeys.value = next
if (key.startsWith('jc-') && !(item.id in bodyCache.value)) {
bodyCache.value = { ...bodyCache.value, [item.id]: null }
const { data } = await useApiFetch<{ body: string | null }>(`/api/contacts/${item.id}`)
const raw = data?.body ?? ''
const text = raw.trimStart().startsWith('<')
? (new DOMParser().parseFromString(raw, 'text/html').body.textContent ?? '').trim()
: raw.trim()
bodyCache.value = { ...bodyCache.value, [item.id]: text }
}
}
// Compose bar
const composing = ref(false)
function triggerAction(fn: () => void) { composing.value = false; fn() }
// Draft body edits (local, before approve) // Draft body edits (local, before approve)
const draftBodyEdits = ref<Record<number, string>>({}) const draftBodyEdits = ref<Record<number, string>>({})
@ -415,8 +454,15 @@ onUnmounted(() => {
<style scoped> <style scoped>
.messaging-layout { .messaging-layout {
display: flex; display: flex;
height: 100%; height: 100dvh;
min-height: 0; min-height: 0;
overflow: hidden;
}
@media (max-width: 1023px) {
.messaging-layout {
height: calc(100dvh - 56px - env(safe-area-inset-bottom, 0px));
}
} }
/* ── Left panel ─────────────────────── */ /* ── Left panel ─────────────────────── */
@ -465,11 +511,6 @@ onUnmounted(() => {
flex: 1; display: flex; align-items: center; justify-content: center; flex: 1; display: flex; align-items: center; justify-content: center;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.action-bar {
display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-light);
}
.btn--osprey { .btn--osprey {
opacity: 0.5; cursor: not-allowed; opacity: 0.5; cursor: not-allowed;
background: none; border: 1px dashed var(--color-border); background: none; border: 1px dashed var(--color-border);
@ -477,6 +518,21 @@ onUnmounted(() => {
color: var(--color-text-muted); font-size: var(--text-sm); color: var(--color-text-muted); font-size: var(--text-sm);
padding: var(--space-2) var(--space-3); min-height: 36px; padding: var(--space-2) var(--space-3); min-height: 36px;
} }
/* Compose bar */
.compose-bar {
flex-shrink: 0;
display: flex; flex-direction: column; align-items: flex-end;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--color-border-light);
background: var(--color-surface);
}
.compose-bar__actions {
display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center;
width: 100%; justify-content: flex-start;
}
.compose-bar__toggle { align-self: flex-end; min-width: 90px; justify-content: center; }
.thread-error { .thread-error {
margin: var(--space-2) var(--space-4); margin: var(--space-2) var(--space-4);
color: var(--app-accent); font-size: var(--text-sm); color: var(--app-accent); font-size: var(--text-sm);
@ -507,10 +563,27 @@ onUnmounted(() => {
font-size: var(--text-xs); font-weight: 700; font-size: var(--text-xs); font-weight: 700;
padding: 1px 6px; border-radius: var(--radius-full); padding: 1px 6px; border-radius: var(--radius-full);
} }
.timeline__badge--pending { background: #fef3c7; color: #d97706; } .timeline__badge--pending { background: var(--color-accent-light); color: var(--color-accent); }
.timeline__badge--approved { background: #d1fae5; color: #065f46; } .timeline__badge--approved { background: var(--color-primary-light); color: var(--color-primary); }
.timeline__subject { font-size: var(--text-sm); font-weight: 500; margin: 0; } .timeline__subject { font-size: var(--text-sm); font-weight: 500; margin: 0; }
.timeline__body { font-size: var(--text-sm); white-space: pre-wrap; margin: 0; color: var(--color-text); } .timeline__expand-hint {
font-size: var(--text-xs); color: var(--color-text-muted); margin-left: auto;
transition: transform 150ms ease;
}
.timeline__item--expandable { cursor: pointer; }
.timeline__item--expandable:hover { border-color: var(--app-primary); }
.timeline__body-wrap {
margin-top: var(--space-2);
border-top: 1px solid var(--color-border-light);
padding-top: var(--space-2);
}
.timeline__body {
font-size: var(--text-sm); white-space: pre-wrap; margin: 0;
color: var(--color-text); max-height: 280px; overflow-y: auto;
font-family: var(--font-body);
}
.timeline__body-loading { font-size: var(--text-xs); color: var(--color-text-muted); }
.timeline__body-empty { font-size: var(--text-xs); color: var(--color-text-muted); margin: 0; }
.timeline__draft-body { .timeline__draft-body {
width: 100%; font-size: var(--text-sm); font-family: var(--font-body); width: 100%; font-size: var(--text-sm); font-family: var(--font-body);
padding: var(--space-2); border: 1px solid var(--color-border); padding: var(--space-2); border: 1px solid var(--color-border);
@ -522,20 +595,45 @@ onUnmounted(() => {
.timeline__empty { color: var(--color-text-muted); font-size: var(--text-sm); padding: var(--space-2); } .timeline__empty { color: var(--color-text-muted); font-size: var(--text-sm); padding: var(--space-2); }
/* Buttons */ /* Buttons */
.btn { padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); font-size: var(--text-sm); font-weight: 500; cursor: pointer; min-height: 36px; } .btn {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
min-height: 36px;
display: inline-flex;
align-items: center;
gap: 6px;
transition: background 120ms ease, border-color 120ms ease, opacity 120ms ease, transform 80ms ease;
}
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:focus-visible { outline: 2px solid var(--app-primary); outline-offset: 2px; }
.btn--sm { padding: var(--space-1) var(--space-3); min-height: 30px; font-size: var(--text-xs); } .btn--sm { padding: var(--space-1) var(--space-3); min-height: 30px; font-size: var(--text-xs); }
.btn--primary { background: var(--app-primary); color: var(--color-surface); border: none; } .btn--primary { background: var(--app-primary); color: var(--color-surface); border: none; }
.btn--primary:hover:not(:disabled) { opacity: 0.9; } .btn--primary:hover:not(:disabled) { opacity: 0.88; }
.btn--primary:disabled { opacity: 0.5; cursor: not-allowed; } .btn--primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn--ghost { background: none; border: 1px solid var(--color-border); color: var(--color-text); } .btn--ghost { background: none; border: 1px solid var(--color-border); color: var(--color-text); }
.btn--ghost:hover { background: var(--color-surface-alt); } .btn--ghost:hover:not(:disabled) { background: var(--color-surface-alt); border-color: var(--app-primary); color: var(--app-primary); }
.btn--danger { background: var(--app-accent); color: white; border: none; } .btn--danger { background: var(--app-accent); color: var(--app-accent-text); border: none; }
.btn--danger:hover { opacity: 0.9; } .btn--danger:hover:not(:disabled) { opacity: 0.88; }
/* Spinner inside buttons */
.btn__spinner {
width: 13px;
height: 13px;
border: 2px solid rgba(255,255,255,0.35);
border-top-color: white;
border-radius: 50%;
animation: btn-spin 0.65s linear infinite;
flex-shrink: 0;
}
@keyframes btn-spin { to { transform: rotate(360deg); } }
/* Modals (delete confirm) */ /* Modals (delete confirm) */
.modal-backdrop { .modal-backdrop {
position: fixed; inset: 0; position: fixed; inset: 0;
background: rgba(0,0,0,0.5); background: var(--color-overlay);
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
z-index: 200; z-index: 200;
} }

View file

@ -442,7 +442,7 @@ onMounted(fetchRefs)
.tag-chip--technical { background: var(--app-primary-light); color: var(--app-primary); } .tag-chip--technical { background: var(--app-primary-light); color: var(--app-primary); }
.tag-chip--managerial { background: rgba(39, 174, 96, 0.12); color: var(--color-success); } .tag-chip--managerial { background: rgba(39, 174, 96, 0.12); color: var(--color-success); }
.tag-chip--character { background: rgba(212, 137, 26, 0.12); color: var(--score-mid); } .tag-chip--character { background: rgba(212, 137, 26, 0.12); color: var(--score-mid); }
.tag-chip--academic { background: rgba(103, 58, 183, 0.12); color: #7c3aed; } .tag-chip--academic { background: color-mix(in srgb, var(--status-synced) 12%, var(--color-surface)); color: var(--status-synced); }
.ref-card__actions { .ref-card__actions {
display: flex; display: flex;

View file

@ -332,7 +332,7 @@ onBeforeRouteLeave(() => {
.rv__item-star { color: var(--color-warning, #f59e0b); font-size: 1rem; flex-shrink: 0; margin-top: 2px; } .rv__item-star { color: var(--color-warning, #f59e0b); font-size: 1rem; flex-shrink: 0; margin-top: 2px; }
.rv__item-info { display: flex; flex-direction: column; gap: 2px; } .rv__item-info { display: flex; flex-direction: column; gap: 2px; }
.rv__item-name { font-weight: 500; font-size: var(--font-sm, 0.875rem); } .rv__item-name { font-weight: 500; font-size: var(--text-sm); }
.rv__item-meta { font-size: var(--font-xs, 0.75rem); color: var(--color-text-muted, #64748b); } .rv__item-meta { font-size: var(--font-xs, 0.75rem); color: var(--color-text-muted, #64748b); }
.rv__item-source { font-size: var(--font-xs, 0.75rem); color: var(--color-accent, #6366f1); } .rv__item-source { font-size: var(--font-xs, 0.75rem); color: var(--color-accent, #6366f1); }
@ -340,7 +340,7 @@ onBeforeRouteLeave(() => {
.rv__preview-header { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3, 0.75rem); } .rv__preview-header { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3, 0.75rem); }
.rv__preview-meta { display: flex; align-items: center; gap: var(--space-2, 0.5rem); flex-wrap: wrap; } .rv__preview-meta { display: flex; align-items: center; gap: var(--space-2, 0.5rem); flex-wrap: wrap; }
.rv__preview-name { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; } .rv__preview-name { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; }
.rv__preview-words { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #64748b); } .rv__preview-words { font-size: var(--text-sm); color: var(--color-text-muted, #64748b); }
.rv__default-badge { .rv__default-badge {
font-size: var(--font-xs, 0.75rem); font-weight: 600; font-size: var(--font-xs, 0.75rem); font-weight: 600;
background: var(--color-success, #16a34a); color: #fff; background: var(--color-success, #16a34a); color: #fff;
@ -352,7 +352,7 @@ onBeforeRouteLeave(() => {
border: 1px solid var(--color-error, #dc2626); border: 1px solid var(--color-error, #dc2626);
border-radius: var(--radius-md, 0.5rem); border-radius: var(--radius-md, 0.5rem);
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
cursor: pointer; font-size: var(--font-sm, 0.875rem); cursor: pointer; font-size: var(--text-sm);
} }
.rv__delete-btn:disabled { opacity: 0.4; cursor: not-allowed; } .rv__delete-btn:disabled { opacity: 0.4; cursor: not-allowed; }
@ -364,13 +364,13 @@ onBeforeRouteLeave(() => {
.rv__textarea { .rv__textarea {
flex: 1; min-height: 400px; padding: var(--space-3, 0.75rem); flex: 1; min-height: 400px; padding: var(--space-3, 0.75rem);
border: 1px solid var(--color-border, #e2e8f0); border-radius: var(--radius-md, 0.5rem); border: 1px solid var(--color-border, #e2e8f0); border-radius: var(--radius-md, 0.5rem);
font-family: monospace; font-size: var(--font-sm, 0.875rem); resize: vertical; font-family: monospace; font-size: var(--text-sm); resize: vertical;
background: var(--color-surface-alt, #f8fafc); background: var(--color-surface-alt, #f8fafc);
color: var(--color-text); color: var(--color-text);
} }
.rv__textarea:not([readonly]) { background: var(--color-surface); } .rv__textarea:not([readonly]) { background: var(--color-surface); }
.rv__edit-actions { display: flex; gap: var(--space-2, 0.5rem); } .rv__edit-actions { display: flex; gap: var(--space-2, 0.5rem); }
.rv__error { color: var(--color-error, #dc2626); font-size: var(--font-sm, 0.875rem); } .rv__error { color: var(--color-error, #dc2626); font-size: var(--text-sm); }
.rv__download-menu { position: relative; } .rv__download-menu { position: relative; }
.rv__download-dropdown { .rv__download-dropdown {
@ -382,11 +382,11 @@ onBeforeRouteLeave(() => {
.rv__download-dropdown button { .rv__download-dropdown button {
width: 100%; text-align: left; background: none; border: none; width: 100%; text-align: left; background: none; border: none;
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
cursor: pointer; font-size: var(--font-sm, 0.875rem); border-radius: var(--radius-sm, 0.25rem); cursor: pointer; font-size: var(--text-sm); border-radius: var(--radius-sm, 0.25rem);
} }
.rv__download-dropdown button:hover { background: var(--color-surface-alt, #f8fafc); } .rv__download-dropdown button:hover { background: var(--color-surface-alt, #f8fafc); }
.rv__loading, .rv__empty { color: var(--color-text-muted, #64748b); font-size: var(--font-sm, 0.875rem); } .rv__loading, .rv__empty { color: var(--color-text-muted, #64748b); font-size: var(--text-sm); }
/* Button styles — defined locally since no global button sheet exists yet */ /* Button styles — defined locally since no global button sheet exists yet */
.btn-secondary { .btn-secondary {
@ -396,7 +396,7 @@ onBeforeRouteLeave(() => {
border-radius: var(--radius-md, 0.5rem); border-radius: var(--radius-md, 0.5rem);
color: var(--color-text-muted); color: var(--color-text-muted);
cursor: pointer; cursor: pointer;
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
white-space: nowrap; white-space: nowrap;
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
@ -412,7 +412,7 @@ onBeforeRouteLeave(() => {
border: none; border: none;
border-radius: var(--radius-md, 0.5rem); border-radius: var(--radius-md, 0.5rem);
cursor: pointer; cursor: pointer;
font-size: var(--font-sm, 0.875rem); font-size: var(--text-sm);
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
display: inline-flex; display: inline-flex;

View file

@ -3,16 +3,31 @@ import { ref, onMounted, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useFineTuneStore } from '../../stores/settings/fineTune' import { useFineTuneStore } from '../../stores/settings/fineTune'
import { useAppConfigStore } from '../../stores/appConfig' import { useAppConfigStore } from '../../stores/appConfig'
import { showToast } from '../../composables/useToast'
const store = useFineTuneStore() const store = useFineTuneStore()
const config = useAppConfigStore() const config = useAppConfigStore()
const { step, inFlightJob, jobStatus, pairsCount, quotaRemaining, pairs, pairsLoading } = storeToRefs(store) const { step, inFlightJob, jobStatus, pairsCount, quotaRemaining, pairs, pairsLoading,
optedIn, dbPairs, dbPairsLoading, dbExcludedCount } = storeToRefs(store)
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
const selectedFiles = ref<File[]>([]) const selectedFiles = ref<File[]>([])
const uploadResult = ref<{ file_count: number } | null>(null) const uploadResult = ref<{ file_count: number } | null>(null)
const extractError = ref<string | null>(null) const extractError = ref<string | null>(null)
const modelReady = ref<boolean | null>(null) const modelReady = ref<boolean | null>(null)
const toggling = ref(false)
const toggleSaved = ref(false)
async function handleOptInChange(e: Event) {
const enabled = (e.target as HTMLInputElement).checked
toggling.value = true
toggleSaved.value = false
await store.toggleOptIn(enabled)
await store.loadDbPairs()
toggling.value = false
toggleSaved.value = true
setTimeout(() => { toggleSaved.value = false }, 2000)
}
async function handleUpload() { async function handleUpload() {
if (!selectedFiles.value.length) return if (!selectedFiles.value.length) return
@ -45,7 +60,9 @@ async function checkLocalModel() {
onMounted(async () => { onMounted(async () => {
store.startPolling() store.startPolling()
await store.loadStatus()
await store.loadPairs() await store.loadPairs()
await store.loadDbPairs()
if (store.step === 3 && !config.isCloud) await checkLocalModel() if (store.step === 3 && !config.isCloud) await checkLocalModel()
}) })
onUnmounted(() => { store.stopPolling(); store.resetStep() }) onUnmounted(() => { store.stopPolling(); store.resetStep() })
@ -55,6 +72,115 @@ onUnmounted(() => { store.stopPolling(); store.resetStep() })
<div class="fine-tune-view"> <div class="fine-tune-view">
<h2>Fine-Tune Model</h2> <h2>Fine-Tune Model</h2>
<!-- Training Export: consent toggle (always visible) -->
<section class="form-section training-export-consent">
<h3>Training Export</h3>
<p class="section-note">
When enabled, your applied-job cover letters are available as a local dataset file
for fine-tuning a language model to your writing style.
</p>
<label class="toggle-label" :class="{ 'toggle-saving': toggling }">
<input
type="checkbox"
:checked="optedIn"
:disabled="toggling"
@change="handleOptInChange"
aria-describedby="opt-in-desc"
/>
Include cover letters in training export
<span v-if="toggling" class="toggle-status" aria-live="polite">Saving</span>
<span v-else-if="toggleSaved" class="toggle-status" aria-live="polite">Saved</span>
</label>
<p class="section-note" id="opt-in-desc">
<template v-if="!config.isCloud">
Your cover letters stay on your device unless you explicitly request cloud fine-tuning.
</template>
<template v-else>
Your cover letters are stored on your CircuitForge account and are not shared with any
third party unless you explicitly request cloud fine-tuning.
</template>
<span v-if="!optedIn" class="opt-out-receipt">
Training export is off cover letters remain local only.
You can change this in Settings at any time.
</span>
</p>
</section>
<!-- From Applied Jobs: curation list (only when opted in) -->
<section v-if="optedIn" class="form-section">
<h3>From Applied Jobs</h3>
<div class="db-pairs-header">
<span class="pairs-count">
{{
dbPairs.filter(p => !p.excluded).length === 1
? '1 pair available'
: `${dbPairs.filter(p => !p.excluded).length} pairs available`
}}
<span
v-if="dbExcludedCount > 0"
class="excluded-badge"
:title="`${dbExcludedCount} pair(s) excluded — use Restore to re-include`"
>{{ dbExcludedCount }} excluded</span>
</span>
<div class="db-pairs-actions">
<button
class="btn-secondary"
:disabled="dbPairs.filter(p => !p.excluded).length === 0"
@click="store.downloadExport()"
>
Download JSONL <span aria-hidden="true"></span>
</button>
<div class="cloud-finetune-wrap">
<button
class="btn-secondary"
:disabled="config.tier !== 'premium' || dbPairs.filter(p => !p.excluded).length === 0"
@click="config.tier === 'premium' && showToast('Cloud fine-tuning coming soon')"
>
Request Cloud Fine-Tune
</button>
<p v-if="config.tier !== 'premium'" class="tier-gate-note">
Available on Premium.
<a href="/settings?tab=license" class="upgrade-link">Upgrade your plan </a>
</p>
</div>
</div>
</div>
<p class="section-note download-advisory">
The downloaded file contains your cover letters in plain text (JSONL format).
Store it in a secure location.
</p>
<div aria-live="polite" aria-atomic="false" aria-label="Applied jobs training pairs">
<div v-if="dbPairsLoading" class="pairs-loading">Loading</div>
<ul v-else-if="dbPairs.length > 0" class="pairs-items db-pairs-items">
<li
v-for="pair in dbPairs"
:key="pair.job_id"
class="pair-item"
:class="{ 'pair-excluded': pair.excluded }"
>
<div class="pair-info">
<span class="pair-instruction">{{ pair.title }} · {{ pair.company }}</span>
<span class="pair-source">{{ pair.status }}</span>
</div>
<button
v-if="!pair.excluded"
class="pair-delete"
title="Exclude from training export"
@click="store.excludeDbPair(pair.job_id)"
>Exclude</button>
<button
v-else
class="pair-restore"
title="Restore to training export"
@click="store.includeDbPair(pair.job_id)"
>Restore</button>
</li>
</ul>
<p v-else class="section-note">No applied jobs with cover letters found.</p>
</div>
</section>
<!-- Wizard steps indicator --> <!-- Wizard steps indicator -->
<div class="wizard-steps"> <div class="wizard-steps">
<span :class="['step', step >= 1 ? 'active' : '']">1. Upload</span> <span :class="['step', step >= 1 ? 'active' : '']">1. Upload</span>
@ -189,4 +315,21 @@ onUnmounted(() => { store.stopPolling(); store.resetStep() })
.pair-source { font-size: 0.75rem; color: var(--color-text-muted); } .pair-source { font-size: 0.75rem; color: var(--color-text-muted); }
.pair-delete { flex-shrink: 0; background: none; border: none; color: var(--color-error); cursor: pointer; font-size: 0.9rem; padding: 2px 4px; border-radius: var(--radius-sm); transition: background 150ms; } .pair-delete { flex-shrink: 0; background: none; border: none; color: var(--color-error); cursor: pointer; font-size: 0.9rem; padding: 2px 4px; border-radius: var(--radius-sm); transition: background 150ms; }
.pair-delete:hover { background: var(--color-error); color: #fff; } .pair-delete:hover { background: var(--color-error); color: #fff; }
.training-export-consent { border: 1px solid var(--color-border-light); border-radius: var(--radius-md); padding: var(--space-4, 1rem); margin-bottom: var(--space-6, 1.5rem); }
.toggle-label { display: flex; align-items: center; gap: var(--space-2, 0.5rem); font-size: 0.9rem; font-weight: 500; cursor: pointer; flex-wrap: wrap; }
.toggle-label.toggle-saving { opacity: 0.7; }
.toggle-label input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--color-primary); cursor: pointer; flex-shrink: 0; }
.toggle-status { font-size: 0.8rem; color: var(--color-text-muted); margin-left: var(--space-1, 0.25rem); }
.opt-out-receipt { display: block; margin-top: var(--space-1, 0.25rem); color: var(--color-text-muted); font-size: 0.8rem; }
.db-pairs-header { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3, 0.75rem); margin-bottom: var(--space-4, 1rem); }
.db-pairs-actions { display: flex; align-items: flex-start; gap: var(--space-2, 0.5rem); flex-wrap: wrap; }
.cloud-finetune-wrap { display: flex; flex-direction: column; gap: var(--space-1, 0.25rem); }
.tier-gate-note { font-size: 0.8rem; color: var(--color-text-muted); margin: 0; }
.upgrade-link { color: var(--color-primary); text-decoration: underline; }
.excluded-badge { margin-left: var(--space-2, 0.5rem); background: var(--color-warning-bg, #fef3c7); color: var(--color-warning-fg, #92400e); font-size: 0.75rem; padding: 1px 6px; border-radius: var(--radius-full, 9999px); }
.db-pairs-items { max-height: 320px; }
.pair-excluded { opacity: 0.5; }
.pair-restore { flex-shrink: 0; background: none; border: 1px solid var(--color-border); color: var(--color-text-muted); cursor: pointer; font-size: 0.8rem; padding: 2px 8px; border-radius: var(--radius-sm); }
.pair-restore:hover { background: var(--color-surface-alt); }
.download-advisory { margin-top: var(--space-2, 0.5rem); font-style: italic; }
</style> </style>

View file

@ -74,7 +74,7 @@ const form = reactive({
careerSummary: wizard.identity.careerSummary, careerSummary: wizard.identity.careerSummary,
}) })
function back() { router.push('/setup/resume') } function back() { router.push('/setup/training') }
async function next() { async function next() {
validationError.value = '' validationError.value = ''
@ -92,7 +92,7 @@ async function next() {
} }
wizard.identity = { ...form } wizard.identity = { ...form }
const ok = await wizard.saveStep(4, { const ok = await wizard.saveStep(5, {
name: form.name, name: form.name,
email: form.email, email: form.email,
phone: form.phone, phone: form.phone,

View file

@ -127,7 +127,7 @@ async function next() {
}) })
wizard.inference.services = svcMap wizard.inference.services = svcMap
const ok = await wizard.saveStep(5, { const ok = await wizard.saveStep(6, {
anthropic_key: form.anthropicKey, anthropic_key: form.anthropicKey,
openai_url: form.openaiUrl, openai_url: form.openaiUrl,
openai_key: form.openaiKey, openai_key: form.openaiKey,

View file

@ -85,7 +85,7 @@ function back() { router.push('/setup/search') }
async function finish() { async function finish() {
// Save integration selections (step 7) then mark wizard complete // Save integration selections (step 7) then mark wizard complete
await wizard.saveStep(7, { integrations: [...checkedIds.value] }) await wizard.saveStep(8, { integrations: [...checkedIds.value] })
const ok = await wizard.complete() const ok = await wizard.complete()
if (ok) router.replace('/') if (ok) router.replace('/')
} }

View file

@ -216,7 +216,7 @@ async function next() {
experience: wizard.resume.experience, experience: wizard.resume.experience,
...(wizard.resume.parsedData ?? {}), ...(wizard.resume.parsedData ?? {}),
}}) }})
if (ok) router.push('/setup/identity') if (ok) router.push('/setup/training')
} }
</script> </script>

View file

@ -143,7 +143,7 @@ async function next() {
wizard.search.titles = [...form.titles] wizard.search.titles = [...form.titles]
wizard.search.locations = [...form.locations] wizard.search.locations = [...form.locations]
const ok = await wizard.saveStep(6, { const ok = await wizard.saveStep(7, {
search: { search: {
titles: form.titles, titles: form.titles,
locations: form.locations, locations: form.locations,

View file

@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAppConfigStore } from '../../stores/appConfig'
const router = useRouter()
const config = useAppConfigStore()
const optIn = ref(false)
const saving = ref(false)
async function next() {
saving.value = true
try {
if (optIn.value) {
await fetch('/api/settings/fine-tune/opt-in', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true }),
})
}
router.push('/setup/identity')
} finally {
saving.value = false
}
}
function back() { router.push('/setup/resume') }
</script>
<template>
<div class="wizard-step">
<h2 class="step-title">
Training Export
<span class="optional-badge">Optional</span>
</h2>
<p class="step-body">
Would you like to save your cover letters for training export?
This lets you build a personal dataset to fine-tune a language model to your writing style.
</p>
<p v-if="!config.isCloud" class="step-body-note">
Your data stays on your device unless you explicitly request cloud fine-tuning.
</p>
<p v-else class="step-body-note">
Your cover letters are stored on your CircuitForge account.
They are not shared with any third party unless you request cloud fine-tuning.
</p>
<label class="opt-in-label">
<input type="checkbox" v-model="optIn" />
Yes, include my cover letters in training export
</label>
<div class="step-actions">
<button class="btn-ghost" @click="back">
<span aria-hidden="true"></span> Back
</button>
<button class="btn-primary" :disabled="saving" :aria-busy="saving" @click="next">
{{ saving ? 'Saving…' : 'Continue' }}
<span v-if="!saving" aria-hidden="true"></span>
</button>
</div>
</div>
</template>
<style scoped>
.wizard-step { display: flex; flex-direction: column; gap: var(--space-5, 1.25rem); }
.step-title { font-family: var(--font-display); font-size: 1.25rem; font-weight: 700; display: flex; align-items: center; gap: var(--space-2, 0.5rem); }
.optional-badge { font-family: var(--font-body); font-size: 0.75rem; font-weight: 500; background: var(--color-surface-alt); color: var(--color-text-muted); padding: 2px 8px; border-radius: var(--radius-full, 9999px); }
.step-body { font-size: 0.9rem; color: var(--color-text); line-height: 1.6; }
.step-body-note { font-size: 0.85rem; color: var(--color-text-muted); line-height: 1.5; margin-top: calc(-1 * var(--space-3, 0.75rem)); }
.opt-in-label { display: flex; align-items: flex-start; gap: var(--space-2, 0.5rem); font-size: 0.9rem; cursor: pointer; }
.opt-in-label input[type="checkbox"] { margin-top: 2px; width: 16px; height: 16px; accent-color: var(--color-primary); flex-shrink: 0; cursor: pointer; }
.step-actions { display: flex; gap: var(--space-3, 0.75rem); justify-content: flex-end; padding-top: var(--space-4, 1rem); border-top: 1px solid var(--color-border-light); }
</style>