Compare commits

..

No commits in common. "main" and "feat/cover-letter-training-export" have entirely different histories.

107 changed files with 574 additions and 1349 deletions

View file

@ -45,8 +45,7 @@ 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=
GPU_SERVER_URL=https://orch.circuitforge.tech CF_ORCH_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

@ -7,7 +7,7 @@ name: CI
on: on:
push: push:
branches: [main, 'feature/**', 'fix/**', 'freeze/**'] branches: [main, 'feature/**', 'fix/**']
pull_request: pull_request:
branches: [main] branches: [main]
@ -23,15 +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
- name: Install lint tools
run: pip install ruff
- name: Lint - name: Lint
run: ruff check . run: ruff check .

View file

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

View file

@ -9,93 +9,6 @@ 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 ## [0.9.2] — 2026-05-02
### Added ### Added

261
README.md
View file

@ -1,143 +1,213 @@
<div align="center"> # Peregrine
<img src="web/public/peregrine.svg" alt="Peregrine" width="120" />
<h1>Peregrine</h1> > **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.
<p><strong>Job search pipeline — by <a href="https://circuitforge.tech">Circuit Forge LLC</a></strong></p> [![License: BSL 1.1](https://img.shields.io/badge/License-BSL_1.1-blue.svg)](./LICENSE-BSL)
[![CI](https://github.com/CircuitForge/peregrine/actions/workflows/ci.yml/badge.svg)](https://github.com/CircuitForge/peregrine/actions/workflows/ci.yml)
[![Docs](https://img.shields.io/badge/docs-docs.circuitforge.tech-orange)](https://docs.circuitforge.tech/peregrine/)
<p><em>AI for the tasks the system made hard on purpose.</em></p> **Job search pipeline — by [Circuit Forge LLC](https://circuitforge.tech)**
[![License: MIT / BSL 1.1](https://img.shields.io/badge/License-MIT%20%2F%20BSL%201.1-blue.svg)](#license) > *"Tools for the jobs that the system made hard on purpose."*
[![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/)
[![Version](https://img.shields.io/badge/version-0.9.0-green)](https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine/releases)
<p> **[Try the live demo](https://demo.circuitforge.tech/peregrine)** — no account required, nothing saved.
<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>
<blockquote>
<strong>Primary development</strong> happens at
<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 and discovery controls"/></td> <td><img src="docs/screenshots/01-dashboard.png" alt="Dashboard with pipeline stats"/></td>
<td><img src="docs/screenshots/02-review.png" alt="Job review — approve, skip, or reject with keyboard shortcuts"/></td> <td><img src="docs/screenshots/04-interviews.png" alt="Interview kanban with recruiter emails attached"/></td>
</tr> </tr>
<tr> <tr>
<td><img src="docs/screenshots/03-apply.png" alt="Apply workspace with LLM-drafted cover letter"/></td> <td><img src="docs/screenshots/03-apply.png" alt="Apply workspace with AI cover letter draft"/></td>
<td><img src="docs/screenshots/04-interviews.png" alt="Interview kanban with company research and recruiter emails"/></td> <td><img src="docs/screenshots/02-review.png" alt="Job review card with match score and ghost-post detection"/></td>
</tr> </tr>
</table> </table>
--- ---
## Why Peregrine? Job search is a second job nobody hired you for.
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. 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.
- **Handles the full pipeline.** Discover, filter, match, draft, track — one tool, one database, no duct tape. Peregrine handles the pipeline — discovery, matching, tracking, drafting, and prep — so you can spend your time doing the work you actually want to be doing.
- **LLM 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.
- **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. **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.
- **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.
- **Privacy · Safety · Accessibility** are architectural constraints, not aspirational copy. No PII (personally identifiable information) logging, no behavioral profiling, no dark patterns. ### What Peregrine does not do
Peregrine does **not** submit job applications for you. You still have to go to each employer's site and click apply yourself.
This is intentional. Automated mass-applying is a bad experience for everyone — it's also a trust violation with employers who took the time to post a real role. Peregrine is a preparation and organization tool, not a bot.
What it *does* cover is everything before and after that click: finding the jobs, matching them against your resume, generating cover letters and prep materials, and once you've applied — tracking where you stand, classifying the emails that come back, and surfacing company research when an interview lands on your calendar. The submit button is yours. The rest of the grind is ours.
> **Exception:** [AIHawk](https://github.com/nicolomantini/LinkedIn-Easy-Apply) is a separate, optional tool that handles LinkedIn Easy Apply automation. Peregrine integrates with it for AIHawk-compatible profiles, but it is not part of Peregrine's core pipeline.
--- ---
## Quick Start ## Quick Start
One-line install: **1. Clone and install dependencies** (Docker, NVIDIA toolkit if needed):
```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
``` ```
Open **http://localhost:8502** — the setup wizard walks you through the rest. **2. Start Peregrine:**
> **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 — no GPU; LLM calls go to Anthropic / OpenAI ./manage.sh start # remote profile (API-only, no GPU)
./manage.sh start --profile cpu # local Ollama on CPU (or Metal via native Ollama on macOS) ./manage.sh start --profile cpu # local Ollama (CPU, or Metal GPU on Apple Silicon — see below)
./manage.sh start --profile single-gpu # Ollama + vision on GPU 0 (NVIDIA only) ./manage.sh start --profile single-gpu # Ollama + Vision on GPU 0 (NVIDIA only)
./manage.sh start --profile dual-gpu # Ollama + vLLM on two NVIDIA GPUs ./manage.sh start --profile dual-gpu # Ollama + Vision + vLLM (GPU 0 + 1) (NVIDIA only)
``` ```
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 — LinkedIn, Indeed, Glassdoor, Adzuna, The Ladders | Free | | Job discovery (JobSpy + custom boards) | Free |
| Ghost-post detection | Free | | Resume keyword matching & gap analysis | Free |
| Resume keyword matching and gap analysis | Free | | Document storage sync (Google Drive, Dropbox, OneDrive, MEGA, Nextcloud) | 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 and auto-classification | Paid | | Email sync & auto-classification | Paid |
| LLM-powered keyword blocklist | Paid |
| Job tracking integrations (Notion, Airtable, Google Sheets) | Paid | | Job tracking integrations (Notion, Airtable, Google Sheets) | Paid |
| Calendar sync (Google, Apple) | Paid | | Calendar sync (Google, Apple) | Paid |
| Slack notifications | Paid | | Slack notifications | Paid |
| CircuitForge shared cover-letter model | Paid | | CircuitForge shared cover-letter model | Paid |
| **Voice guidelines** (custom writing style and tone) | Premium with LLM ¹ | | Vue 3 SPA — full UI with onboarding wizard, job board, apply workspace, sort/filter, research modal, draft cover letter | Free |
| Cover letter model fine-tuning — your writing, your model | Premium | | **Voice guidelines** (custom writing style & tone) | Premium with LLM¹ ² |
| 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) unlock:** configure any LLM backend — a local [Ollama](https://ollama.com) or vLLM instance, or your own API key (Anthropic, OpenAI-compatible) — and all "Free with LLM" and "Premium with LLM" features unlock at no charge. ¹ **BYOK (bring your own key/backend) unlock:** configure any LLM backend — a local [Ollama](https://ollama.com) or vLLM instance,
or your own API key (Anthropic, OpenAI-compatible) — and all features marked **Free with LLM** or **Premium with LLM**
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.
--- ---
## What Peregrine does not do ## Email Sync
Peregrine does **not** submit job applications for you. You still click apply on the employer's site. Monitors your inbox for job-related emails and automatically updates job stages (interview requests, rejections, survey links, offers).
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. Configure in **Settings → Email**. Requires IMAP access and, for Gmail, an App Password.
--- ---
## Stack ## Integrations
| Layer | Technology | Connect external services in **Settings → Integrations**:
|-------|-----------|
| Frontend | Vue 3 SPA (Vite) | - **Job tracking:** Notion, Airtable, Google Sheets
| Backend | FastAPI + Python | - **Document storage:** Google Drive, Dropbox, OneDrive, MEGA, Nextcloud
| Database | SQLite (local, per-user) | - **Calendar:** Google Calendar, Apple Calendar (CalDAV)
| Job scraping | [JobSpy](https://github.com/Bunsly/JobSpy) + custom board scrapers | - **Notifications:** Slack, Discord (webhook), Home Assistant
| LLM inference | Ollama, vLLM, Anthropic, OpenAI-compatible — configurable fallback chain |
| Vision | moondream2 (survey screenshot analysis) |
| Container | Docker / Podman |
--- ---
## manage.sh reference ## CLI Reference (`manage.sh`)
`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
@ -146,38 +216,31 @@ This is intentional. Automated mass-applying is a bad experience for everyone an
./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 and rebuild app container ./manage.sh update Pull latest images + 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 — outputs training JSONL ./manage.sh prepare-training Scan docs for cover letters training JSONL
./manage.sh finetune Run LoRA fine-tune (requires single-gpu profile or higher) ./manage.sh finetune Run LoRA fine-tune (needs --profile single-gpu+)
./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)
``` ```
--- ---
## Documentation ## Developer Docs
Full docs at **[docs.circuitforge.tech/peregrine](https://docs.circuitforge.tech/peregrine)** Full documentation at: https://docs.circuitforge.tech/peregrine
Bug reports and feature requests: [Forgejo issues](https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine/issues) - [Installation guide](https://docs.circuitforge.tech/peregrine/getting-started/installation/)
- [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
Peregrine uses a split license: Core discovery pipeline: [MIT](LICENSE-MIT)
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

@ -37,8 +37,7 @@ 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:-}
- GPU_SERVER_URL=${GPU_SERVER_URL:-http://host.docker.internal:7700} - 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
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"

View file

@ -29,8 +29,7 @@ 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=
- GPU_SERVER_URL=${GPU_SERVER_URL:-http://host.docker.internal:7700} - CF_ORCH_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,8 +20,7 @@ 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:-}
- GPU_SERVER_URL=${GPU_SERVER_URL:-${CF_ORCH_URL:-http://host.docker.internal:7700}} - CF_ORCH_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,61 +46,11 @@ backends:
type: vision_service type: vision_service
supports_images: true supports_images: true
# ── cf-orch task-routed backends (preferred for GPU inference) ──────────── # ── cf-orch trunk services ─────────────────────────────────────────────────
# Use these when GPU_SERVER_URL is configured. The coordinator resolves # These backends allocate via cf-orch rather than connecting to a static URL.
# product+task → model_id → node via assignments.yaml; no model IDs needed here. # cf-orch starts the service on-demand and returns its URL; the router then
# Set enabled: true once GPU_SERVER_URL is configured. # calls it directly using the openai_compat path.
cf_cover_letter: # Set CF_ORCH_URL (env) or url below; leave enabled: false if cf-orch is
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

@ -14,6 +14,7 @@ import sqlite3
import ssl as ssl_mod import ssl as ssl_mod
import subprocess import subprocess
import sys import sys
import threading
from contextvars import ContextVar from contextvars import ContextVar
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -38,7 +39,7 @@ if str(PEREGRINE_ROOT) not in sys.path:
from circuitforge_core.api import make_feedback_router as _make_feedback_router # noqa: E402 from circuitforge_core.api import make_feedback_router as _make_feedback_router # noqa: E402
from circuitforge_core.config.settings import load_env as _load_env # noqa: E402 from circuitforge_core.config.settings import load_env as _load_env # noqa: E402
from scripts.credential_store import get_credential, set_credential # noqa: E402 from scripts.credential_store import get_credential, set_credential, delete_credential # noqa: E402
DB_PATH = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db") DB_PATH = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db")
@ -47,21 +48,6 @@ _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)
@ -128,38 +114,6 @@ 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."""
@ -204,16 +158,6 @@ 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."""
@ -221,36 +165,16 @@ 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)
@ -650,51 +574,6 @@ 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)."""
@ -737,6 +616,7 @@ def preview_resume_review(job_id: int, body: ResumeReviewBody):
3. render_resume_text() renders to plain text for the preview panel 3. render_resume_text() renders to plain text for the preview panel
Returns: {preview_text, preview_struct} struct preserved for the approve step. Returns: {preview_text, preview_struct} struct preserved for the approve step.
""" """
import json as _json
from scripts.db import get_resume_draft as _get_draft from scripts.db import get_resume_draft as _get_draft
from scripts.resume_optimizer import ( from scripts.resume_optimizer import (
apply_review_decisions, frame_skill_gaps, render_resume_text, apply_review_decisions, frame_skill_gaps, render_resume_text,
@ -750,13 +630,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:
db_path_obj = Path(_request_db.get() or DB_PATH)
job_row = _get_db().execute( job_row = _get_db().execute(
"SELECT title, company FROM jobs WHERE id=?", (job_id,) "SELECT title, company FROM jobs WHERE id=?", (job_id,)
).fetchone() ).fetchone()
@ -774,19 +651,6 @@ 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.
@ -803,8 +667,7 @@ 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
override = (body.get("preview_text_override") or "").strip() final_text = render_resume_text(struct)
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)
@ -826,6 +689,7 @@ def approve_resume(job_id: int, body: dict):
saved_resume_id: int | None = None saved_resume_id: int | None = None
if body.get("save_to_library"): if body.get("save_to_library"):
from scripts.db import create_resume as _create_r from scripts.db import create_resume as _create_r
import json as _json2
resume_name = (body.get("resume_name") or "").strip() or f"Optimized for job {job_id}" resume_name = (body.get("resume_name") or "").strip() or f"Optimized for job {job_id}"
saved = _create_r( saved = _create_r(
db_path, db_path,
@ -922,7 +786,7 @@ def create_resume_endpoint(body: dict):
@app.post("/api/resumes/import") @app.post("/api/resumes/import")
async def import_resume_endpoint(file: UploadFile, name: str = ""): async def import_resume_endpoint(file: UploadFile, name: str = ""):
import json as _json import os, tempfile, json as _json
from scripts.db import create_resume as _create from scripts.db import create_resume as _create
db_path = Path(_request_db.get() or DB_PATH) db_path = Path(_request_db.get() or DB_PATH)
content = await file.read() content = await file.read()
@ -1458,8 +1322,14 @@ def calendar_push(job_id: int):
# ── Survey endpoints ───────────────────────────────────────────────────────── # ── Survey endpoints ─────────────────────────────────────────────────────────
# Module-level imports so tests can patch dev_api.LLMRouter etc. # Module-level imports so tests can patch dev_api.LLMRouter etc.
from scripts.llm_router import LLMRouter
from scripts.db import insert_survey_response, get_survey_responses from scripts.db import insert_survey_response, get_survey_responses
from scripts.survey_assistant import (
SURVEY_SYSTEM as _SURVEY_SYSTEM,
build_text_prompt as _build_text_prompt,
build_image_prompt as _build_image_prompt,
)
@app.get("/api/vision/health") @app.get("/api/vision/health")
@ -1806,16 +1676,6 @@ 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):
@ -2251,7 +2111,6 @@ 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)
@ -2263,7 +2122,6 @@ 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())
@ -2273,7 +2131,6 @@ 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)
@ -4342,8 +4199,7 @@ def _fetch_cforch_nodes() -> list[dict]:
if not url: if not url:
return [] return []
try: try:
import urllib.request import urllib.request, json as _json
import json as _json
req = urllib.request.Request(f"{url}/api/nodes", headers={"Accept": "application/json"}) req = urllib.request.Request(f"{url}/api/nodes", headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=3) as resp: with urllib.request.urlopen(req, timeout=3) as resp:
data = _json.loads(resp.read()) data = _json.loads(resp.read())

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.opensourcesolarpunk.com/Circuit-Forge/peregrine git clone https://git.circuitforge.io/circuitforge/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.opensourcesolarpunk.com/Circuit-Forge/peregrine git clone https://git.circuitforge.io/circuitforge/peregrine
cd peregrine cd peregrine
bash install.sh bash install.sh
@ -31,23 +31,20 @@ 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 | BYOK‡ | Yes | Yes | | Cover letter generation | - | Yes | Yes |
| Company research briefs | BYOK‡ | Yes | Yes | | Company research briefs | - | Yes | Yes |
| Interview prep & practice Q&A | BYOK‡ | Yes | Yes | | Interview prep & practice Q&A | - | Yes | Yes |
| Email sync & auto-classification | - | Yes | Yes | | Email sync & auto-classification | - | Yes | Yes |
| Survey assistant (culture-fit Q&A) | BYOK‡ | Yes | Yes | | Survey assistant (culture-fit Q&A) | - | 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.
--- ---
@ -63,8 +60,8 @@ See [Tier System](reference/tier-system.md) for the full feature gate table.
## License ## License
Core discovery pipeline: [MIT](https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine/src/branch/main/LICENSE-MIT) Core discovery pipeline: [MIT](https://git.circuitforge.io/circuitforge/peregrine/src/branch/main/LICENSE-MIT)
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) AI features (cover letter generation, company research, interview prep, UI): [BSL 1.1](https://git.circuitforge.io/circuitforge/peregrine/src/branch/main/LICENSE-BSL)
© 2026 Circuit Forge LLC © 2026 Circuit Forge LLC

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View file

@ -15,11 +15,6 @@ 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 ""
@ -33,10 +28,9 @@ 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: api)" echo -e " ${GREEN}logs [service]${NC} Tail logs (default: app)"
echo -e " ${GREEN}update${NC} Pull latest images + rebuild" echo -e " ${GREEN}update${NC} Pull latest images + rebuild app"
echo -e " ${GREEN}preflight${NC} Check ports + resources; write .env" echo -e " ${GREEN}preflight${NC} Check ports + resources; write .env"
echo -e " ${GREEN}models${NC} Check ollama models in config; pull any missing" echo -e " ${GREEN}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"
@ -47,12 +41,6 @@ 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"
@ -82,7 +70,7 @@ while [[ $# -gt 0 ]]; do
esac esac
done done
SERVICE="${1:-api}" # used by `logs` command SERVICE="${1:-app}" # 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.
@ -113,7 +101,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 '^VUE_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8506)" PORT="$(grep -m1 '^STREAMLIT_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8501)"
success "Peregrine is up → http://localhost:${PORT}" success "Peregrine is up → http://localhost:${PORT}"
;; ;;
@ -126,30 +114,33 @@ case "$CMD" in
restart) restart)
info "Restarting (PROFILE=${PROFILE})..." info "Restarting (PROFILE=${PROFILE})..."
make restart PROFILE="$PROFILE" make restart PROFILE="$PROFILE"
PORT="$(grep -m1 '^VUE_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8506)" PORT="$(grep -m1 '^STREAMLIT_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8501)"
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..." info "Pulling latest images and rebuilding app..."
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 api web $COMPOSE build app web
success "Update complete. Run './manage.sh restart' to apply." success "Update complete. Run './manage.sh restart' to apply."
;; ;;
@ -176,7 +167,7 @@ case "$CMD" in
;; ;;
open) open)
PORT="$(grep -m1 '^VUE_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8506)" PORT="$(grep -m1 '^STREAMLIT_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8501)"
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
@ -206,32 +197,6 @@ 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

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

View file

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

View file

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

View file

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

View file

@ -234,11 +234,10 @@ 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, status) (title, company, url, source, location, is_remote, salary, description, date_found, date_posted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
( (
job.get("title", ""), job.get("title", ""),
job.get("company", ""), job.get("company", ""),
@ -250,7 +249,6 @@ 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()

View file

@ -323,6 +323,6 @@ if gguf_path and gguf_path.exists():
else: else:
print(f"\n{'='*60}") print(f"\n{'='*60}")
print(" Adapter saved (no GGUF produced).") print(" Adapter saved (no GGUF produced).")
print(" Re-run without --no-gguf to generate a GGUF for Ollama registration.") print(f" Re-run without --no-gguf to generate a GGUF for Ollama registration.")
print(f" Adapter path: {adapter_path}") print(f" Adapter path: {adapter_path}")
print(f"{'='*60}\n") print(f"{'='*60}\n")

View file

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

View file

@ -392,7 +392,6 @@ 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 = {
@ -448,75 +447,6 @@ 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:
@ -628,29 +558,20 @@ def _scan_unmatched_leads(conn: imaplib.IMAP4, cfg: dict,
if mid in known_message_ids: if mid in known_message_ids:
continue continue
# ── Job alert digests — parse each card deterministically ─────── # ── LinkedIn Job Alert digest — parse each card individually ──────
from_lower = parsed["from_addr"].lower() if _LINKEDIN_ALERT_SENDER in parsed["from_addr"].lower():
alert_cards: list[dict] = [] cards = parse_linkedin_alert(parsed["body"])
alert_source = "" for card in cards:
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": alert_source, "source": "linkedin",
"location": card.get("location", ""), "location": card["location"],
"is_remote": 0, "is_remote": 0,
"salary": card.get("salary", ""), "salary": "",
"description": "", "description": "",
"date_found": datetime.now().isoformat()[:10], "date_found": datetime.now().isoformat()[:10],
}) })
@ -659,7 +580,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] {alert_source} alert → {card['company']}{card['title']}") print(f"[imap] LinkedIn 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

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

View file

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

View file

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

View file

@ -19,6 +19,7 @@ from __future__ import annotations
import json import json
import logging import logging
import re import re
from pathlib import Path
from typing import Any from typing import Any
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -277,8 +278,7 @@ 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. Do NOT use markdown formatting — no **, __, or * for bullets.\n" f"5. Return ONLY the rewritten section content, no labels or explanation."
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,8 +305,7 @@ 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", []):
clean_b = re.sub(r"^[•\-–—*◦▪▸►\s]+", "", b).strip() lines.append(f"{b}")
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)"
@ -315,7 +314,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"] = _clean_summary_markup(rewritten) updated["career_summary"] = 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()]
@ -327,19 +326,6 @@ 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,
@ -369,9 +355,9 @@ def _reparse_experience_bullets(
chunk = remaining chunk = remaining
bullets = [ bullets = [
re.sub(r"^([•\-–—*◦▪▸►]\s*)+", "", line.strip()).strip() re.sub(r"^[•\-–—*◦▪▸►]\s*", "", line).strip()
for line in chunk.splitlines() for line in chunk.splitlines()
if re.match(r"^\s*[•\-–—*◦▪▸►]", line) if re.match(r"^[•\-–—*◦▪▸►]\s*", line.strip())
] ]
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"]
@ -546,37 +532,27 @@ def apply_review_decisions(
struct["skills"] = sorted(original_kept | approved_additions) struct["skills"] = sorted(original_kept | approved_additions)
break break
# ── Summary: accept/reject + optional user-edited text ───────────────── # ── Summary: accept proposed or revert to original ──────────────────────
summary_dec = decisions.get("summary", {}) if not decisions.get("summary", {}).get("accepted", True):
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 + optional user-edited bullets ── # ── Experience: per-entry accept/reject ─────────────────────────────────
exp_entry_map: dict[str, dict] = { exp_decisions: dict[str, bool] = {
f"{ed.get('title', '')}|{ed.get('company', '')}": ed f"{ed.get('title', '')}|{ed.get('company', '')}": ed.get("accepted", True)
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']}"
entry_dec = exp_entry_map.get(key, {}) if not exp_decisions.get(key, True):
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

@ -9,9 +9,11 @@ Falls back to empty dict on unrecoverable errors — caller shows the form build
from __future__ import annotations from __future__ import annotations
import io import io
import json
import logging import logging
import re import re
import zipfile import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
import pdfplumber import pdfplumber

View file

@ -57,7 +57,7 @@ _TIMEOUT = 12
def _detect_board(url: str) -> str: def _detect_board(url: str) -> str:
"""Return 'linkedin', 'indeed', 'glassdoor', 'jobgether', 'oracle_hcm', or 'generic'.""" """Return 'linkedin', 'indeed', 'glassdoor', 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,8 +67,6 @@ 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"
@ -203,70 +201,6 @@ 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")
@ -344,8 +278,6 @@ 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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
"""Tests for interview prep endpoints: research GET/generate/task, contacts GET.""" """Tests for interview prep endpoints: research GET/generate/task, contacts GET."""
import json
import pytest import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@ -16,6 +17,7 @@ def client():
def test_get_research_found(client): def test_get_research_found(client):
"""Returns research row (minus raw_output) when present.""" """Returns research row (minus raw_output) when present."""
import sqlite3
mock_row = { mock_row = {
"job_id": 1, "job_id": 1,
"company_brief": "Acme Corp makes anvils.", "company_brief": "Acme Corp makes anvils.",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,7 @@ from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
from app.wizard.tiers import can_use, tier_label, TIERS, BYOK_UNLOCKABLE from app.wizard.tiers import can_use, tier_label, TIERS, FEATURES, BYOK_UNLOCKABLE
def test_tiers_list(): def test_tiers_list():

View file

@ -352,8 +352,8 @@ with tab_fetch:
if not accounts: if not accounts:
st.warning( st.warning(
"No accounts configured. Copy `config/label_tool.yaml.example` → " f"No accounts configured. Copy `config/label_tool.yaml.example` → "
"`config/label_tool.yaml` and add your IMAP accounts.", f"`config/label_tool.yaml` and add your IMAP accounts.",
icon="⚠️", icon="⚠️",
) )
else: else:

View file

@ -5,20 +5,11 @@
<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>
<!-- Apply stored theme before first paint — prevents FOUT flash on dark/hacker themes. <!-- Inline background prevents blank flash before CSS bundle loads -->
Mirrors the logic in useTheme.initTheme(). Must run before the <style> below. --> <!-- Matches --color-surface light / dark from theme.css -->
<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; min-height: 100vh; } html, body { margin: 0; background: #eaeff8; min-height: 100vh; }
html { background: #eaeff8; } @media (prefers-color-scheme: dark) { html, body { background: #16202e; } }
@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,10 +71,6 @@ 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;
} }
@ -139,8 +135,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, #f5f7fc); background: var(--color-surface-raised, #2a3650);
color: var(--color-text, #1a2338); color: var(--color-text, #eaeff8);
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,9 +58,6 @@ 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);
@ -82,7 +79,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]) { :root:not([data-theme="hacker"]) {
--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;
@ -94,8 +91,6 @@ 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;
@ -117,8 +112,6 @@ 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,9 +63,6 @@
--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,6 +59,9 @@
<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>
@ -131,6 +134,23 @@ 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' },
@ -301,6 +321,29 @@ 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, #eaeff8); background: var(--color-surface, #0d1829);
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, #1a2338); } .hint-chip__dismiss:hover { color: var(--color-text, #eaeff8); }
</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 const s = (props.job.match_score ?? 0) * 100
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)}%` ? `${Math.round(props.job.match_score * 100)}%`
: '—' : '—'
) )
@ -588,7 +588,7 @@ async function saveFeedback() {
background: var(--color-hover); background: var(--color-hover);
} }
.btn-chip-active { .btn-chip-active {
background: var(--app-primary-light); background: var(--color-primary-muted, #e8f0ff);
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(--text-sm); font-size: var(--font-sm, 0.875rem);
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(--text-sm); font-size: var(--font-sm, 0.875rem);
} }
.rlc__meta { .rlc__meta {
@ -143,7 +143,7 @@ onMounted(load)
} }
.rlc__empty { .rlc__empty {
font-size: var(--text-sm); font-size: var(--font-sm, 0.875rem);
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(--text-sm); font-size: var(--font-sm, 0.875rem);
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(--text-sm); font-size: var(--font-sm, 0.875rem);
} }
.rlc__picker-item:hover, .rlc__picker-item:hover,

View file

@ -112,15 +112,16 @@
<span class="rop__preview-badge">Preview not yet saved</span> <span class="rop__preview-badge">Preview not yet saved</span>
</div> </div>
<textarea <textarea
v-model="previewText" :value="previewText"
class="rop__textarea" class="rop__textarea rop__textarea--preview"
aria-label="Resume preview — editable before approving" aria-label="Resume preview text"
spellcheck="true" spellcheck="false"
readonly
/> />
<p class="rop__preview-hint"> <p class="rop__preview-hint">
Review and edit the assembled resume above. Click Review the assembled resume above. If it looks right, click
<strong>Approve &amp; Save</strong> to lock it in, or go back to adjust <strong>Approve &amp; Save</strong> to lock it in. You can also go back and adjust
your section-level decisions. your review 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">
@ -491,10 +492,7 @@ async function approveResume() {
if (!previewStruct.value) return if (!previewStruct.value) return
approvingResume.value = true approvingResume.value = true
const body: Record<string, unknown> = { const body: Record<string, unknown> = { preview_struct: previewStruct.value }
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}`
@ -578,7 +576,7 @@ onUnmounted(stopPolling)
} }
.rop__tier-note { .rop__tier-note {
font-size: var(--text-sm); font-size: var(--font-sm, 0.875rem);
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);
@ -603,13 +601,13 @@ onUnmounted(stopPolling)
.rop__hint, .rop__hint,
.rop__empty { .rop__empty {
font-size: var(--text-sm); font-size: var(--font-sm, 0.875rem);
color: var(--app-text-muted, #64748b); color: var(--app-text-muted, #64748b);
margin: 0; margin: 0;
} }
.rop__error { .rop__error {
font-size: var(--text-sm); font-size: var(--font-sm, 0.875rem);
color: var(--app-danger, #dc2626); color: var(--app-danger, #dc2626);
margin: 0; margin: 0;
} }
@ -618,7 +616,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(--text-sm); font-size: var(--font-sm, 0.875rem);
color: var(--app-text-muted, #64748b); color: var(--app-text-muted, #64748b);
} }
@ -643,7 +641,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(--text-sm); font-size: var(--font-sm, 0.875rem);
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
} }
@ -706,7 +704,7 @@ onUnmounted(stopPolling)
} }
.rop__wordcount { .rop__wordcount {
font-size: var(--text-sm); font-size: var(--font-sm, 0.875rem);
color: var(--app-text-muted, #64748b); color: var(--app-text-muted, #64748b);
} }
@ -727,7 +725,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(--text-sm); font-size: var(--font-sm, 0.875rem);
color: var(--app-danger, #dc2626); color: var(--app-danger, #dc2626);
} }
@ -736,7 +734,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(--text-sm); font-size: var(--font-sm, 0.875rem);
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);
@ -762,7 +760,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(--text-sm); font-size: var(--font-sm, 0.875rem);
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: background 0.15s; transition: background 0.15s;
@ -781,7 +779,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(--text-sm); font-size: var(--font-sm, 0.875rem);
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: background 0.15s; transition: background 0.15s;
@ -799,7 +797,7 @@ onUnmounted(stopPolling)
} }
.rop__review-intro { .rop__review-intro {
font-size: var(--text-sm); font-size: var(--font-sm, 0.875rem);
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);
@ -819,7 +817,7 @@ onUnmounted(stopPolling)
} }
.rop__review-section-title { .rop__review-section-title {
font-size: var(--text-sm); font-size: var(--font-sm, 0.875rem);
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
@ -852,7 +850,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(--text-sm); font-size: var(--font-sm, 0.875rem);
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;
@ -887,7 +885,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(--text-sm); font-size: var(--font-sm, 0.875rem);
} }
.rop__diff-col--original { .rop__diff-col--original {
@ -936,7 +934,7 @@ onUnmounted(stopPolling)
} }
.rop__exp-company { .rop__exp-company {
font-size: var(--text-sm); font-size: var(--font-sm, 0.875rem);
color: var(--app-text-muted, #64748b); color: var(--app-text-muted, #64748b);
} }
@ -945,7 +943,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(--text-sm); font-size: var(--font-sm, 0.875rem);
cursor: pointer; cursor: pointer;
color: var(--app-text, #1e293b); color: var(--app-text, #1e293b);
} }
@ -975,7 +973,7 @@ onUnmounted(stopPolling)
background: none; background: none;
border: none; border: none;
color: var(--app-accent, #6366f1); color: var(--app-accent, #6366f1);
font-size: var(--text-sm); font-size: var(--font-sm, 0.875rem);
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
} }
@ -994,7 +992,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(--text-sm); font-size: var(--font-sm, 0.875rem);
} }
.rop__history-date { .rop__history-date {
@ -1060,7 +1058,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(--text-sm); font-size: var(--font-sm, 0.875rem);
cursor: pointer; cursor: pointer;
color: var(--app-text, #1e293b); color: var(--app-text, #1e293b);
} }
@ -1068,7 +1066,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(--text-sm); font-size: var(--font-sm, 0.875rem);
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);
@ -1101,9 +1099,13 @@ 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(--text-sm); font-size: var(--font-sm, 0.875rem);
color: var(--app-text-muted, #64748b); color: var(--app-text-muted, #64748b);
margin: 0; margin: 0;
} }
@ -1132,7 +1134,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(--text-sm); font-size: var(--font-sm, 0.875rem);
cursor: pointer; cursor: pointer;
transition: background 0.15s; transition: background 0.15s;
} }
@ -1163,7 +1165,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(--text-sm); font-size: var(--font-sm, 0.875rem);
color: var(--app-text, #1e293b); color: var(--app-text, #1e293b);
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
@ -1171,7 +1173,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(--text-sm); font-size: var(--font-sm, 0.875rem);
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,9 +63,7 @@
<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>
@ -74,9 +72,7 @@
<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>
@ -259,17 +255,11 @@ 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'])
@ -332,22 +322,15 @@ function emitSubmit() {
decisions.skills = { approved_additions: [...approvedSkills.value] } decisions.skills = { approved_additions: [...approvedSkills.value] }
} }
if (summarySection.value) { if (summarySection.value) {
decisions.summary = { decisions.summary = { accepted: summaryAccepted.value }
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[key] ?? true, accepted: expAccepted.value[`${e.title}|${e.company}`] ?? 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(--text-sm); font-weight: 600; font-size: var(--font-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(--text-sm); font-size: var(--font-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(--text-sm); color: var(--color-text-muted, #4a5c7a); margin: 0; } .rp__hint { font-size: var(--font-sm, 0.875rem); 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(--text-sm); } .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__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(--text-sm); margin: 0; } .rp__error { color: var(--color-error, #c0392b); font-size: var(--font-sm, 0.875rem); 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(--text-sm); font-weight: 600; cursor: pointer; font-size: var(--font-sm, 0.875rem); 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(--text-sm); font-weight: 600; cursor: pointer; font-size: var(--font-sm, 0.875rem); font-weight: 600; cursor: pointer;
} }
</style> </style>

View file

@ -9,25 +9,11 @@
<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 rp__diff-col--editable"> <div class="rp__diff-col">
<span class="rp__diff-label">Proposed edit below</span> <span class="rp__diff-label">Proposed</span>
<div class="rp__bullet-edit-list" role="list" :aria-label="`Edit proposed bullets for ${entry.title}`"> <ul class="rp__bullet-list">
<div <li v-for="b in entry.proposed_bullets" :key="b">{{ b }}</li>
v-for="(bullet, idx) in editedBullets" </ul>
: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">
@ -42,7 +28,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ defineProps<{
entry: { entry: {
title: string title: string
company: string company: string
@ -50,46 +36,21 @@ const props = 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(--text-sm); color: var(--color-text-muted, #4a5c7a); margin: 0; } .rp__company { font-size: var(--font-sm, 0.875rem); 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(--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__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-edit-list { display: flex; flex-direction: column; gap: var(--space-2, 0.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-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(--text-sm); color: var(--color-text-muted, #4a5c7a); margin: 0; } .rp__hint { font-size: var(--font-sm, 0.875rem); 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(--text-sm); cursor: pointer; font-size: var(--font-sm, 0.875rem);
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(--text-sm); 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(--font-sm, 0.875rem); resize: vertical; }
.rp__removed { font-size: var(--text-sm); color: var(--color-text-muted, #4a5c7a); font-style: italic; } .rp__removed { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #4a5c7a); font-style: italic; }
</style> </style>

View file

@ -6,15 +6,9 @@
<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 rp__diff-col--editable"> <div class="rp__diff-col">
<span class="rp__diff-label" aria-label="Proposed editable">Proposed</span> <span class="rp__diff-label" aria-label="Proposed">Proposed</span>
<textarea <p class="rp__diff-text">{{ section.proposed }}</p>
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">
@ -34,12 +28,10 @@ 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>
@ -49,23 +41,7 @@ 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(--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__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__edit-textarea { .rp__accept-toggle { display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem); cursor: pointer; font-size: var(--font-sm, 0.875rem); }
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

@ -27,7 +27,6 @@ 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,
@ -51,7 +50,6 @@ 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 })
@ -64,7 +62,6 @@ 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 })
@ -105,7 +102,6 @@ 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 })
@ -116,12 +112,11 @@ 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, qa, task, fullJob // re-fetch on completed: research, contacts, 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 })
@ -139,7 +134,6 @@ 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 })
@ -168,7 +162,6 @@ 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

@ -54,20 +54,14 @@ 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: { status: 'completed', stage: null, message: null, data: { output: '1. B — reason', source: 'text_paste' },
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')
@ -75,7 +69,6 @@ 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

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, 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,8 +26,6 @@ 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() {
@ -78,45 +76,9 @@ 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',
} }
async function fetchSyncStatus() { onMounted(fetchContacts)
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>
@ -129,20 +91,6 @@ onMounted(async () => {
<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">
@ -167,16 +115,8 @@ onMounted(async () => {
<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 for that search. No contacts found{{ search ? ' for that search' : '' }}.
</div> </div>
<div v-else class="contacts-table-wrap"> <div v-else class="contacts-table-wrap">
@ -399,69 +339,4 @@ onMounted(async () => {
.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>

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