Compare commits
14 commits
feat/cover
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2051880d73 | |||
| 7d1b1319be | |||
| b44a7975bc | |||
| 0d6ddd35cf | |||
| 5c4992dbeb | |||
| fc3bd8859e | |||
| 73132222a2 | |||
| 293f0aba53 | |||
| 5d185650d9 | |||
| 6ae9fa62fe | |||
| 5d8018ef40 | |||
| 312631a5d9 | |||
| f4a524ba0b | |||
| 77e49db4e9 |
|
|
@ -45,7 +45,8 @@ FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
|
|||
# Set CF_LICENSE_KEY to authenticate with the hosted coordinator.
|
||||
# Leave both blank for local self-hosted cf-orch or bare-metal inference.
|
||||
CF_LICENSE_KEY=
|
||||
CF_ORCH_URL=https://orch.circuitforge.tech
|
||||
GPU_SERVER_URL=https://orch.circuitforge.tech
|
||||
# CF_ORCH_URL is also accepted as a backward-compat alias for GPU_SERVER_URL
|
||||
|
||||
# cf-orch agent — GPU profiles only (single-gpu, dual-gpu-*)
|
||||
# The agent registers this node with the cf-orch coordinator and reports VRAM stats.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ jobs:
|
|||
python-version: '3.12'
|
||||
cache: pip
|
||||
|
||||
- name: Install system dependencies
|
||||
run: sudo apt-get update -q && sudo apt-get install -y libsqlcipher-dev
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
|
|
|
|||
87
CHANGELOG.md
|
|
@ -9,6 +9,93 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||
|
||||
---
|
||||
|
||||
## [0.9.5] — 2026-05-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Theme: dark/explicit themes show correct page background** — `index.html` inline style
|
||||
set `html, body { background: #eaeff8 }` hardcoded. `body` paints on top of `html`, so
|
||||
even when `html { background: var(--color-surface) }` correctly resolved to `#16202e` in
|
||||
dark mode, the body's hardcoded light background covered it — producing dark cards on a
|
||||
light page. Fixed by: (1) removing body background from the inline style; (2) adding a
|
||||
tiny blocking `<script>` that reads `cf-theme` / `cf-hacker-mode` from localStorage and
|
||||
sets `data-theme` on `<html>` before first paint; (3) adding
|
||||
`html[data-theme="dark"|"solarized-dark"|"hacker"]` rules so FOUT prevention fires the
|
||||
right background immediately on load.
|
||||
|
||||
---
|
||||
|
||||
## [0.9.4] — 2026-05-08
|
||||
|
||||
### Added
|
||||
|
||||
- **Messages view — expandable email timeline** — click any email item to lazy-load
|
||||
and read the full body inline (HTML stripped to plain text via `DOMParser`).
|
||||
Bodies are fetched on-demand via the new `GET /api/contacts/{id}` endpoint to avoid
|
||||
loading 50KB+ email bodies on every page view.
|
||||
- **Messages view — compose bar** — action buttons (Log call, Log note, Use template,
|
||||
Draft reply with LLM, Call via Osprey) moved from the always-visible header into a
|
||||
sticky bottom compose bar triggered by a + New toggle. Reduces visual clutter when
|
||||
just reading the thread.
|
||||
- **Home view — "Skip review" checkbox** — when adding jobs by URL, a checkbox (default
|
||||
on) sends them directly to the Apply queue, bypassing Job Review.
|
||||
- **ContactsView — sync status** — shows last completed sync time and a spinner when
|
||||
an email sync is running.
|
||||
- **imap_sync: Indeed alert parser** — `parse_indeed_alert()` extracts job title,
|
||||
company, location, salary, and canonical URL from Indeed Job Alert digest emails.
|
||||
- **scrape_url: Oracle HCM support** — Playwright-based scraper for Oracle HCM
|
||||
CandidateExperience portals (React SPAs requiring JS execution).
|
||||
- **manage.sh** — compose engine auto-detection (docker compose / podman compose /
|
||||
podman-compose), `build` command, and cloud/demo stack shortcuts.
|
||||
- **theme.css** — `--color-overlay` token for modal/dialog backdrops.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Messages view layout** — changed `height: 100%` to `height: 100dvh` with a mobile
|
||||
override for the 56px tab bar. `height: 100%` was resolving to "shrink-wrap" because
|
||||
`.app-main` has no explicit height; compose bar is now correctly pinned to the bottom.
|
||||
- **Accessibility: danger button contrast** — `btn--danger` used `color: white` on
|
||||
`--app-accent` (Talon Orange), yielding 2.8:1 contrast (fails WCAG AA 4.5:1 for
|
||||
normal text). Fixed to `color: var(--app-accent-text)` (dark navy, 5.5:1).
|
||||
- **Accessibility: warning badge contrast** — `tab-badge` in Job Review used `color: white`
|
||||
on `--color-warning` (amber). Same fix applied.
|
||||
- **Theme: Interviews signal banners** — hardcoded `rgba(245,158,11,…)` / `rgba(39,174,…)`
|
||||
/ `rgba(192,57,…)` replaced with `color-mix()` against `--color-warning/success/error`.
|
||||
- **Theme: Interviews signal count** — `color: #e67e22` hardcode replaced with
|
||||
`var(--app-accent)`.
|
||||
- **Theme: References academic tag chip** — `color: #7c3aed` hardcode replaced with
|
||||
`var(--status-synced)`; background uses `color-mix()` with the same token.
|
||||
- **Theme: Interviews signal-move button** — `color: #fff` on `--color-primary` fails
|
||||
in dark mode (light green bg); fixed to `var(--color-text-inverse)`.
|
||||
- **Modal backdrops** — `rgba(0,0,0,0.5)` replaced with `var(--color-overlay)` for
|
||||
theme consistency.
|
||||
|
||||
---
|
||||
|
||||
## [0.9.3] — 2026-05-05
|
||||
|
||||
### Added
|
||||
|
||||
- **Editable resume review** — proposed summary and experience bullets in the review modal
|
||||
are now editable text areas. Edits flow through `apply_review_decisions()` and override
|
||||
the LLM output in the final resume struct. Preview textarea in Apply Workspace is also
|
||||
editable, with manual changes preserved through the approve step via `preview_text_override`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Double bullets in resume optimizer** — `_section_text_for_prompt` now strips existing
|
||||
bullet characters before prefixing with `•`, and `_reparse_experience_bullets` uses a
|
||||
greedy strip regex so `• •` patterns can no longer survive parsing.
|
||||
- **Asterisk markup in summary** — added `_clean_summary_markup()` to strip LLM-generated
|
||||
markdown bullet chars (`*`, `-`, etc.) from career summary output; injected no-markdown
|
||||
rule into the LLM prompt's CRITICAL RULES list.
|
||||
- **Light theme dark CSS bleed** — `peregrine.css` media dark override now scoped to
|
||||
`:root:not([data-theme])` (auto mode only) instead of `:root:not([data-theme="hacker"])`.
|
||||
Fixes dark navy `--app-primary-light`/`--app-accent-light` bleeding into light themes
|
||||
(light, solarized-light, colorblind) on dark-OS machines.
|
||||
|
||||
---
|
||||
|
||||
## [0.9.2] — 2026-05-02
|
||||
|
||||
### Added
|
||||
|
|
|
|||
261
README.md
|
|
@ -1,213 +1,143 @@
|
|||
# Peregrine
|
||||
<div align="center">
|
||||
<img src="web/public/peregrine.svg" alt="Peregrine" width="120" />
|
||||
|
||||
> **Primary development** happens at [git.opensourcesolarpunk.com](https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine) — GitHub and Codeberg are push mirrors. Issues and PRs are welcome on either platform.
|
||||
<h1>Peregrine</h1>
|
||||
|
||||
[](./LICENSE-BSL)
|
||||
[](https://github.com/CircuitForge/peregrine/actions/workflows/ci.yml)
|
||||
[](https://docs.circuitforge.tech/peregrine/)
|
||||
<p><strong>Job search pipeline — by <a href="https://circuitforge.tech">Circuit Forge LLC</a></strong></p>
|
||||
|
||||
**Job search pipeline — by [Circuit Forge LLC](https://circuitforge.tech)**
|
||||
<p><em>AI for the tasks the system made hard on purpose.</em></p>
|
||||
|
||||
> *"Tools for the jobs that the system made hard on purpose."*
|
||||
[](#license)
|
||||
[](https://github.com/CircuitForgeLLC/peregrine/actions/workflows/ci.yml)
|
||||
[](https://docs.circuitforge.tech/peregrine/)
|
||||
[](https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine/releases)
|
||||
|
||||
**[Try the live demo](https://demo.circuitforge.tech/peregrine)** — no account required, nothing saved.
|
||||
<p>
|
||||
<a href="https://demo.circuitforge.tech/peregrine"><strong>Live Demo</strong></a> —
|
||||
no account required, nothing saved |
|
||||
<a href="https://docs.circuitforge.tech/peregrine/">Docs</a> |
|
||||
<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>
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="docs/screenshots/01-dashboard.png" alt="Dashboard with pipeline stats"/></td>
|
||||
<td><img src="docs/screenshots/04-interviews.png" alt="Interview kanban with recruiter emails attached"/></td>
|
||||
<td><img src="docs/screenshots/01-dashboard.png" alt="Dashboard with pipeline stats and discovery controls"/></td>
|
||||
<td><img src="docs/screenshots/02-review.png" alt="Job review — approve, skip, or reject with keyboard shortcuts"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="docs/screenshots/03-apply.png" alt="Apply workspace with AI cover letter draft"/></td>
|
||||
<td><img src="docs/screenshots/02-review.png" alt="Job review card with match score and ghost-post detection"/></td>
|
||||
<td><img src="docs/screenshots/03-apply.png" alt="Apply workspace with LLM-drafted cover letter"/></td>
|
||||
<td><img src="docs/screenshots/04-interviews.png" alt="Interview kanban with company research and recruiter emails"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
Job search is a second job nobody hired you for.
|
||||
## Why Peregrine?
|
||||
|
||||
ATS filters designed to reject. Job boards that show the same listing eight times. Cover letter number forty-seven for a role that might already be filled. Hours of prep for a phone screen that lasts twelve minutes.
|
||||
Job search is a second job nobody hired you for. ATS (applicant tracking system) filters designed to reject. Boards that show the same listing eight times. Cover letter number forty-seven for a role that might already be filled. Hours of prep for a phone screen that lasts twelve minutes.
|
||||
|
||||
Peregrine handles the pipeline — discovery, matching, tracking, drafting, and prep — so you can spend your time doing the work you actually want to be doing.
|
||||
|
||||
**LLM support is optional.** The full discovery and tracking pipeline works without one. When you do configure a backend, the LLM drafts the parts that are genuinely miserable — cover letters, company research briefs, interview prep sheets — and waits for your approval before anything goes anywhere.
|
||||
|
||||
### What Peregrine does not do
|
||||
|
||||
Peregrine does **not** submit job applications for you. You still have to go to each employer's site and click apply yourself.
|
||||
|
||||
This is intentional. Automated mass-applying is a bad experience for everyone — it's also a trust violation with employers who took the time to post a real role. Peregrine is a preparation and organization tool, not a bot.
|
||||
|
||||
What it *does* cover is everything before and after that click: finding the jobs, matching them against your resume, generating cover letters and prep materials, and once you've applied — tracking where you stand, classifying the emails that come back, and surfacing company research when an interview lands on your calendar. The submit button is yours. The rest of the grind is ours.
|
||||
|
||||
> **Exception:** [AIHawk](https://github.com/nicolomantini/LinkedIn-Easy-Apply) is a separate, optional tool that handles LinkedIn Easy Apply automation. Peregrine integrates with it for AIHawk-compatible profiles, but it is not part of Peregrine's core pipeline.
|
||||
- **Handles the full pipeline.** Discover, filter, match, draft, track — one tool, one database, no duct tape.
|
||||
- **LLM is optional and local-first.** Discovery and tracking work with no LLM at all. When you do configure one, it runs on your hardware by default. Cloud inference is a fallback, not the default path.
|
||||
- **Ghost-post detection baked in.** Listings that have been open too long or look like sourcing traps get flagged before you spend time on them.
|
||||
- **Human approval at every step.** LLM drafts cover letters and research briefs; you approve before anything goes anywhere. Peregrine never submits an application on your behalf.
|
||||
- **Privacy · Safety · Accessibility** are architectural constraints, not aspirational copy. No PII (personally identifiable information) logging, no behavioral profiling, no dark patterns.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
**1. Clone and install dependencies** (Docker, NVIDIA toolkit if needed):
|
||||
One-line install:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine/raw/branch/main/install.sh)
|
||||
```
|
||||
|
||||
Or clone and run manually:
|
||||
|
||||
```bash
|
||||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine
|
||||
cd peregrine
|
||||
./manage.sh setup
|
||||
./manage.sh start
|
||||
```
|
||||
|
||||
**2. Start Peregrine:**
|
||||
Open **http://localhost:8502** — the setup wizard walks you through the rest.
|
||||
|
||||
> **macOS / Apple Silicon:** install Ollama natively via Homebrew before starting for Metal GPU-accelerated inference. `install.sh` handles this automatically.
|
||||
> **Windows:** use WSL2 with Ubuntu.
|
||||
|
||||
### Inference profiles
|
||||
|
||||
```bash
|
||||
./manage.sh start # remote profile (API-only, no GPU)
|
||||
./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 dual-gpu # Ollama + Vision + vLLM (GPU 0 + 1) (NVIDIA only)
|
||||
./manage.sh start # remote — no GPU; LLM calls go to Anthropic / OpenAI
|
||||
./manage.sh start --profile cpu # local Ollama on CPU (or Metal via native Ollama on macOS)
|
||||
./manage.sh start --profile single-gpu # Ollama + vision on GPU 0 (NVIDIA only)
|
||||
./manage.sh start --profile dual-gpu # Ollama + vLLM on two NVIDIA GPUs
|
||||
```
|
||||
|
||||
Or use `make` directly:
|
||||
|
||||
```bash
|
||||
make start # remote profile
|
||||
make start PROFILE=single-gpu
|
||||
```
|
||||
|
||||
**3.** Open http://localhost:8501 — the setup wizard guides you through the rest.
|
||||
|
||||
> **macOS / Apple Silicon:** Docker Desktop must be running. For Metal GPU-accelerated inference, install Ollama natively before starting — `install.sh` will prompt you to do this. See [Apple Silicon GPU](#apple-silicon-gpu) below.
|
||||
> **Windows:** Not supported — use WSL2 with Ubuntu.
|
||||
|
||||
### Installing to `/opt` or other system directories
|
||||
|
||||
If you clone into a root-owned directory (e.g. `sudo git clone ... /opt/peregrine`), two things need fixing:
|
||||
|
||||
**1. Git ownership warning** (`fatal: detected dubious ownership`) — `./manage.sh setup` fixes this automatically. If you need git to work *before* running setup:
|
||||
|
||||
```bash
|
||||
git config --global --add safe.directory /opt/peregrine
|
||||
```
|
||||
|
||||
**2. Preflight write access** — preflight writes `.env` and `compose.override.yml` into the repo directory. Fix ownership once:
|
||||
|
||||
```bash
|
||||
sudo chown -R $USER:$USER /opt/peregrine
|
||||
```
|
||||
|
||||
After that, run everything without `sudo`.
|
||||
|
||||
### Podman
|
||||
|
||||
Podman is rootless by default — **no `sudo` needed.** `./manage.sh setup` will configure `podman-compose` if it isn't already present.
|
||||
|
||||
### Docker
|
||||
|
||||
After `./manage.sh setup`, log out and back in for docker group membership to take effect. Until then, prefix commands with `sudo`. After re-login, `sudo` is no longer required.
|
||||
|
||||
---
|
||||
|
||||
## Inference Profiles
|
||||
|
||||
| Profile | Services started | Use case |
|
||||
|---------|-----------------|----------|
|
||||
| `remote` | app + searxng | No GPU; LLM calls go to Anthropic / OpenAI |
|
||||
| `cpu` | app + ollama + searxng | No GPU; local models on CPU. On Apple Silicon, use with native Ollama for Metal acceleration — see below. |
|
||||
| `single-gpu` | app + ollama + vision + searxng | One **NVIDIA** GPU: cover letters, research, vision |
|
||||
| `dual-gpu` | app + ollama + vllm + vision + searxng | Two **NVIDIA** GPUs: GPU 0 = Ollama, GPU 1 = vLLM |
|
||||
|
||||
### Apple Silicon GPU
|
||||
|
||||
Docker Desktop on macOS runs in a Linux VM — it cannot access the Apple GPU. Metal-accelerated inference requires Ollama to run **natively** on the host.
|
||||
|
||||
`install.sh` handles this automatically: it offers to install Ollama via Homebrew, starts it as a background service, and explains what happens next. If Ollama is running on port 11434 when you start Peregrine, preflight detects it, stubs out the Docker Ollama container, and routes inference through the native process — which uses Metal automatically.
|
||||
|
||||
To do it manually:
|
||||
|
||||
```bash
|
||||
brew install ollama
|
||||
brew services start ollama # starts at login, uses Metal GPU
|
||||
./manage.sh start --profile cpu # preflight adopts native Ollama; Docker container is skipped
|
||||
```
|
||||
|
||||
The `cpu` profile label is a slight misnomer in this context — Ollama will be running on the GPU. `single-gpu` and `dual-gpu` profiles are NVIDIA-specific and not applicable on Mac.
|
||||
|
||||
---
|
||||
|
||||
## First-Run Wizard
|
||||
|
||||
On first launch the setup wizard walks through seven steps:
|
||||
|
||||
1. **Hardware** — detects NVIDIA GPUs (Linux) or Apple Silicon GPU (macOS) and recommends a profile
|
||||
2. **Tier** — choose free, paid, or premium (or use `dev_tier_override` for local testing)
|
||||
3. **Identity** — name, email, phone, LinkedIn, career summary
|
||||
4. **Resume** — upload a PDF/DOCX for LLM parsing, or use the guided form builder
|
||||
5. **Inference** — configure LLM backends and API keys
|
||||
6. **Search** — job titles, locations, boards, keywords, blocklist
|
||||
7. **Integrations** — optional cloud storage, calendar, and notification services
|
||||
|
||||
Wizard state is saved after each step — a crash or browser close resumes where you left off.
|
||||
Re-enter the wizard any time via **Settings → Developer → Reset wizard**.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | Tier |
|
||||
|---------|------|
|
||||
| Job discovery (JobSpy + custom boards) | Free |
|
||||
| Resume keyword matching & gap analysis | Free |
|
||||
| Document storage sync (Google Drive, Dropbox, OneDrive, MEGA, Nextcloud) | Free |
|
||||
| Job discovery — LinkedIn, Indeed, Glassdoor, Adzuna, The Ladders | Free |
|
||||
| Ghost-post detection | Free |
|
||||
| Resume keyword matching and gap analysis | Free |
|
||||
| Document storage sync (Google Drive, Dropbox, OneDrive, Nextcloud) | Free |
|
||||
| Webhook notifications (Discord, Home Assistant) | Free |
|
||||
| **Cover letter generation** | Free with LLM¹ |
|
||||
| **Company research briefs** | Free with LLM¹ |
|
||||
| **Interview prep & practice Q&A** | 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¹ |
|
||||
| Vue 3 SPA — full UI with onboarding wizard, job board, apply workspace, interview kanban | Free |
|
||||
| **Cover letter generation** | Free with LLM ¹ |
|
||||
| **Company research briefs** | Free with LLM ¹ |
|
||||
| **Interview prep and practice Q&A** | Free with LLM ¹ |
|
||||
| **Survey assistant** (culture-fit Q&A, screenshot analysis) | Free with LLM ¹ |
|
||||
| Managed cloud LLM (no API key needed) | Paid |
|
||||
| Email sync & auto-classification | Paid |
|
||||
| LLM-powered keyword blocklist | Paid |
|
||||
| Email sync and auto-classification | Paid |
|
||||
| Job tracking integrations (Notion, Airtable, Google Sheets) | Paid |
|
||||
| Calendar sync (Google, Apple) | Paid |
|
||||
| Slack notifications | Paid |
|
||||
| CircuitForge shared cover-letter model | Paid |
|
||||
| Vue 3 SPA — full UI with onboarding wizard, job board, apply workspace, sort/filter, research modal, draft cover letter | Free |
|
||||
| **Voice guidelines** (custom writing style & tone) | Premium with LLM¹ ² |
|
||||
| Cover letter model fine-tuning (your writing, your model) | Premium |
|
||||
| **Voice guidelines** (custom writing style and tone) | Premium with LLM ¹ |
|
||||
| Cover letter model fine-tuning — your writing, your model | Premium |
|
||||
| Multi-user support | Premium |
|
||||
| Human-in-the-loop operator (CAPTCHAs, phone calls, wet signatures) | Ultra |
|
||||
|
||||
¹ **BYOK (bring your own key/backend) unlock:** configure any LLM backend — a local [Ollama](https://ollama.com) or vLLM instance,
|
||||
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.
|
||||
¹ **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.
|
||||
|
||||
---
|
||||
|
||||
## Email Sync
|
||||
## What Peregrine does not do
|
||||
|
||||
Monitors your inbox for job-related emails and automatically updates job stages (interview requests, rejections, survey links, offers).
|
||||
Peregrine does **not** submit job applications for you. You still click apply on the employer's site.
|
||||
|
||||
Configure in **Settings → Email**. Requires IMAP access and, for Gmail, an App Password.
|
||||
This is intentional. Automated mass-applying is a bad experience for everyone and a trust violation with employers who posted a real role. The submit button is yours. The rest of the grind is ours.
|
||||
|
||||
---
|
||||
|
||||
## Integrations
|
||||
## Stack
|
||||
|
||||
Connect external services in **Settings → Integrations**:
|
||||
|
||||
- **Job tracking:** Notion, Airtable, Google Sheets
|
||||
- **Document storage:** Google Drive, Dropbox, OneDrive, MEGA, Nextcloud
|
||||
- **Calendar:** Google Calendar, Apple Calendar (CalDAV)
|
||||
- **Notifications:** Slack, Discord (webhook), Home Assistant
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Frontend | Vue 3 SPA (Vite) |
|
||||
| Backend | FastAPI + Python |
|
||||
| Database | SQLite (local, per-user) |
|
||||
| Job scraping | [JobSpy](https://github.com/Bunsly/JobSpy) + custom board scrapers |
|
||||
| LLM inference | Ollama, vLLM, Anthropic, OpenAI-compatible — configurable fallback chain |
|
||||
| Vision | moondream2 (survey screenshot analysis) |
|
||||
| Container | Docker / Podman |
|
||||
|
||||
---
|
||||
|
||||
## CLI Reference (`manage.sh`)
|
||||
|
||||
`manage.sh` is the single entry point for all common operations — no need to remember Make targets or Docker commands.
|
||||
## manage.sh reference
|
||||
|
||||
```
|
||||
./manage.sh setup Install Docker/Podman + NVIDIA toolkit
|
||||
|
|
@ -216,31 +146,38 @@ Connect external services in **Settings → Integrations**:
|
|||
./manage.sh restart Restart all services
|
||||
./manage.sh status Show running containers
|
||||
./manage.sh logs [service] Tail logs (default: app)
|
||||
./manage.sh update Pull latest images + rebuild app container
|
||||
./manage.sh preflight Check ports + resources; write .env
|
||||
./manage.sh update Pull latest images and rebuild app container
|
||||
./manage.sh test Run test suite
|
||||
./manage.sh prepare-training Scan docs for cover letters → training JSONL
|
||||
./manage.sh finetune Run LoRA fine-tune (needs --profile single-gpu+)
|
||||
./manage.sh prepare-training Scan docs for cover letters — outputs training JSONL
|
||||
./manage.sh finetune Run LoRA fine-tune (requires single-gpu profile or higher)
|
||||
./manage.sh open Open the web UI in your browser
|
||||
./manage.sh clean Remove containers, images, volumes (asks to confirm)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Developer Docs
|
||||
## Documentation
|
||||
|
||||
Full documentation at: https://docs.circuitforge.tech/peregrine
|
||||
Full docs at **[docs.circuitforge.tech/peregrine](https://docs.circuitforge.tech/peregrine)**
|
||||
|
||||
- [Installation guide](https://docs.circuitforge.tech/peregrine/getting-started/installation/)
|
||||
- [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/)
|
||||
Bug reports and feature requests: [Forgejo issues](https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine/issues)
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
Core discovery pipeline: [MIT](LICENSE-MIT)
|
||||
LLM features (cover letter generation, company research, interview prep, UI): [BSL 1.1](LICENSE-BSL)
|
||||
Peregrine uses a split license:
|
||||
|
||||
| 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
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ services:
|
|||
- HEIMDALL_ADMIN_TOKEN=${HEIMDALL_ADMIN_TOKEN}
|
||||
- PYTHONUNBUFFERED=1
|
||||
- FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-}
|
||||
- CF_ORCH_URL=http://host.docker.internal:7700
|
||||
- GPU_SERVER_URL=${GPU_SERVER_URL:-http://host.docker.internal:7700}
|
||||
- CF_ORCH_URL=${CF_ORCH_URL:-${GPU_SERVER_URL:-http://host.docker.internal:7700}}
|
||||
- CF_APP_NAME=peregrine
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ services:
|
|||
- STAGING_DB=/devl/job-seeker/staging.db
|
||||
- PYTHONUNBUFFERED=1
|
||||
- STREAMLIT_SERVER_BASE_URL_PATH=
|
||||
- CF_ORCH_URL=http://host.docker.internal:7700
|
||||
- GPU_SERVER_URL=${GPU_SERVER_URL:-http://host.docker.internal:7700}
|
||||
- CF_ORCH_URL=${CF_ORCH_URL:-${GPU_SERVER_URL:-http://host.docker.internal:7700}}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: "no"
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ services:
|
|||
- OPENAI_COMPAT_KEY=${OPENAI_COMPAT_KEY:-}
|
||||
- PEREGRINE_GPU_COUNT=${PEREGRINE_GPU_COUNT:-0}
|
||||
- PEREGRINE_GPU_NAMES=${PEREGRINE_GPU_NAMES:-}
|
||||
- CF_ORCH_URL=${CF_ORCH_URL:-http://host.docker.internal:7700}
|
||||
- GPU_SERVER_URL=${GPU_SERVER_URL:-${CF_ORCH_URL:-http://host.docker.internal:7700}}
|
||||
- CF_ORCH_URL=${CF_ORCH_URL:-${GPU_SERVER_URL:-http://host.docker.internal:7700}}
|
||||
- CF_APP_NAME=peregrine
|
||||
- PYTHONUNBUFFERED=1
|
||||
extra_hosts:
|
||||
|
|
|
|||
|
|
@ -46,11 +46,61 @@ backends:
|
|||
type: vision_service
|
||||
supports_images: true
|
||||
|
||||
# ── cf-orch trunk services ─────────────────────────────────────────────────
|
||||
# These backends allocate via cf-orch rather than connecting to a static URL.
|
||||
# cf-orch starts the service on-demand and returns its URL; the router then
|
||||
# calls it directly using the openai_compat path.
|
||||
# Set CF_ORCH_URL (env) or url below; leave enabled: false if cf-orch is
|
||||
# ── cf-orch task-routed backends (preferred for GPU inference) ────────────
|
||||
# Use these when GPU_SERVER_URL is configured. The coordinator resolves
|
||||
# product+task → model_id → node via assignments.yaml; no model IDs needed here.
|
||||
# Set enabled: true once GPU_SERVER_URL is configured.
|
||||
cf_cover_letter:
|
||||
type: openai_compat
|
||||
enabled: false
|
||||
base_url: http://localhost:8008/v1 # fallback when cf-orch is unavailable
|
||||
model: __auto__
|
||||
api_key: any
|
||||
supports_images: false
|
||||
cf_orch:
|
||||
product: peregrine
|
||||
task: cover_letter
|
||||
ttl_s: 3600
|
||||
|
||||
cf_ats_rewrite:
|
||||
type: openai_compat
|
||||
enabled: false
|
||||
base_url: http://localhost:8008/v1
|
||||
model: __auto__
|
||||
api_key: any
|
||||
supports_images: false
|
||||
cf_orch:
|
||||
product: peregrine
|
||||
task: ats_rewrite
|
||||
ttl_s: 3600
|
||||
|
||||
cf_job_research:
|
||||
type: openai_compat
|
||||
enabled: false
|
||||
base_url: http://localhost:8008/v1
|
||||
model: __auto__
|
||||
api_key: any
|
||||
supports_images: false
|
||||
cf_orch:
|
||||
product: peregrine
|
||||
task: job_research
|
||||
ttl_s: 3600
|
||||
|
||||
cf_interview_prep:
|
||||
type: openai_compat
|
||||
enabled: false
|
||||
base_url: http://localhost:8008/v1
|
||||
model: __auto__
|
||||
api_key: any
|
||||
supports_images: false
|
||||
cf_orch:
|
||||
product: peregrine
|
||||
task: interview_prep
|
||||
ttl_s: 3600
|
||||
|
||||
# ── cf-orch trunk services (service-based, legacy) ─────────────────────────
|
||||
# Generic service allocation — use the task-routed backends above when possible.
|
||||
# Set GPU_SERVER_URL (env) or url below; leave enabled: false if cf-orch is
|
||||
# not deployed in your environment.
|
||||
cf_text:
|
||||
type: openai_compat
|
||||
|
|
|
|||
155
dev-api.py
|
|
@ -48,6 +48,21 @@ _CLOUD_DATA_ROOT = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/menagerie-data
|
|||
_DIRECTUS_SECRET = os.environ.get("DIRECTUS_JWT_SECRET", "")
|
||||
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
|
||||
_request_db: ContextVar[str | None] = ContextVar("_request_db", default=None)
|
||||
|
||||
|
|
@ -114,6 +129,38 @@ app.include_router(_feedback_router, prefix="/api/feedback")
|
|||
|
||||
_log = logging.getLogger("peregrine.session")
|
||||
|
||||
# ── 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:
|
||||
"""Raise 403 if running in demo mode. Call at the top of any write endpoint."""
|
||||
|
|
@ -158,6 +205,16 @@ def _resolve_cf_user_id(cookie_str: str) -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
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")
|
||||
async def cloud_session_middleware(request: Request, call_next):
|
||||
"""In cloud mode, resolve per-user staging.db from the X-CF-Session header."""
|
||||
|
|
@ -165,16 +222,36 @@ async def cloud_session_middleware(request: Request, call_next):
|
|||
cookie_header = request.headers.get("X-CF-Session", "")
|
||||
user_id = _resolve_cf_user_id(cookie_header)
|
||||
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")
|
||||
if user_db not in _migrated_db_paths:
|
||||
from scripts.db_migrate import migrate_db
|
||||
migrate_db(Path(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)
|
||||
try:
|
||||
return await call_next(request)
|
||||
finally:
|
||||
_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)
|
||||
|
||||
|
||||
|
|
@ -574,6 +651,51 @@ def resume_optimizer_task_status(job_id: int):
|
|||
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")
|
||||
def get_resume_review(job_id: int):
|
||||
"""Return the pending review draft for this job (populated when task is awaiting_review)."""
|
||||
|
|
@ -630,6 +752,10 @@ def preview_resume_review(job_id: int, body: ResumeReviewBody):
|
|||
# Step 1: apply section-level decisions
|
||||
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)
|
||||
framings = [f.model_dump() for f in body.gap_framings if f.mode in ("adjacent", "learning")]
|
||||
if framings:
|
||||
|
|
@ -651,6 +777,19 @@ def preview_resume_review(job_id: int, body: ResumeReviewBody):
|
|||
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")
|
||||
def approve_resume(job_id: int, body: dict):
|
||||
"""Save the user-approved assembled resume struct and mark the task complete.
|
||||
|
|
@ -667,7 +806,8 @@ def approve_resume(job_id: int, body: dict):
|
|||
raise HTTPException(400, "preview_struct is required")
|
||||
|
||||
from scripts.resume_optimizer import render_resume_text
|
||||
final_text = render_resume_text(struct)
|
||||
override = (body.get("preview_text_override") or "").strip()
|
||||
final_text = override if override else render_resume_text(struct)
|
||||
|
||||
# Persist plain text + struct (struct enables YAML export later)
|
||||
_finalize(db_path=db_path, job_id=job_id, final_text=final_text)
|
||||
|
|
@ -1676,6 +1816,16 @@ def list_contacts(job_id: Optional[int] = None, direction: Optional[str] = None,
|
|||
return {"total": total, "contacts": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class ReferencePayload(BaseModel):
|
||||
|
|
@ -2111,6 +2261,7 @@ def bulk_purge_jobs(body: BulkPurgeBody):
|
|||
|
||||
class AddJobsBody(BaseModel):
|
||||
urls: List[str]
|
||||
skip_review: bool = True
|
||||
|
||||
|
||||
@app.post("/api/jobs/add", status_code=202)
|
||||
|
|
@ -2122,6 +2273,7 @@ def add_jobs_by_url(body: AddJobsBody):
|
|||
from scripts.task_runner import submit_task
|
||||
db_path = _db_path()
|
||||
existing = get_existing_urls(db_path)
|
||||
status = "approved" if body.skip_review else "pending"
|
||||
queued = 0
|
||||
for raw_url in body.urls:
|
||||
url = canonicalize_url(raw_url.strip())
|
||||
|
|
@ -2131,6 +2283,7 @@ def add_jobs_by_url(body: AddJobsBody):
|
|||
"title": "Importing...", "company": "", "url": url,
|
||||
"source": "manual", "location": "", "description": "",
|
||||
"date_found": _dt.now().isoformat()[:10],
|
||||
"status": status,
|
||||
})
|
||||
if job_id:
|
||||
submit_task(db_path, "scrape_url", job_id)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ Thank you for your interest in contributing to Peregrine. This guide covers the
|
|||
## Fork and Clone
|
||||
|
||||
```bash
|
||||
git clone https://git.circuitforge.io/circuitforge/peregrine
|
||||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine
|
||||
cd peregrine
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ Peregrine automates the full job search lifecycle: discovery, matching, cover le
|
|||
|
||||
```bash
|
||||
# 1. Clone and install dependencies
|
||||
git clone https://git.circuitforge.io/circuitforge/peregrine
|
||||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine
|
||||
cd peregrine
|
||||
bash install.sh
|
||||
|
||||
|
|
@ -31,20 +31,23 @@ The first-run wizard guides you through hardware detection, tier selection, iden
|
|||
|
||||
## Feature Overview
|
||||
|
||||
| Feature | Free | Paid | Premium |
|
||||
|---------|------|------|---------|
|
||||
| Feature | Free | Paid† | Premium |
|
||||
|---------|------|-------|---------|
|
||||
| Job discovery (JobSpy + custom boards) | Yes | Yes | Yes |
|
||||
| Resume keyword matching | Yes | Yes | Yes |
|
||||
| Cover letter generation | - | Yes | Yes |
|
||||
| Company research briefs | - | Yes | Yes |
|
||||
| Interview prep & practice Q&A | - | Yes | Yes |
|
||||
| Cover letter generation | BYOK‡ | Yes | Yes |
|
||||
| Company research briefs | BYOK‡ | Yes | Yes |
|
||||
| Interview prep & practice Q&A | BYOK‡ | Yes | Yes |
|
||||
| Email sync & auto-classification | - | Yes | Yes |
|
||||
| Survey assistant (culture-fit Q&A) | - | Yes | Yes |
|
||||
| Survey assistant (culture-fit Q&A) | BYOK‡ | Yes | Yes |
|
||||
| Integration connectors (Notion, Airtable, etc.) | Partial | Yes | Yes |
|
||||
| Calendar sync (Google, Apple) | - | Yes | Yes |
|
||||
| Cover letter model fine-tuning | - | - | 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.
|
||||
|
||||
---
|
||||
|
|
@ -60,8 +63,8 @@ See [Tier System](reference/tier-system.md) for the full feature gate table.
|
|||
|
||||
## License
|
||||
|
||||
Core discovery pipeline: [MIT](https://git.circuitforge.io/circuitforge/peregrine/src/branch/main/LICENSE-MIT)
|
||||
Core discovery pipeline: [MIT](https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine/src/branch/main/LICENSE-MIT)
|
||||
|
||||
AI features (cover letter generation, company research, interview prep, UI): [BSL 1.1](https://git.circuitforge.io/circuitforge/peregrine/src/branch/main/LICENSE-BSL)
|
||||
AI features (cover letter generation, company research, interview prep, UI): [BSL 1.1](https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine/src/branch/main/LICENSE-BSL)
|
||||
|
||||
© 2026 Circuit Forge LLC
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 73 KiB |
71
manage.sh
|
|
@ -15,6 +15,11 @@ cd "$SCRIPT_DIR"
|
|||
|
||||
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() {
|
||||
echo ""
|
||||
|
|
@ -28,9 +33,10 @@ usage() {
|
|||
echo -e " ${GREEN}start${NC} Start Peregrine (preflight → up)"
|
||||
echo -e " ${GREEN}stop${NC} Stop 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}logs [service]${NC} Tail logs (default: app)"
|
||||
echo -e " ${GREEN}update${NC} Pull latest images + rebuild app"
|
||||
echo -e " ${GREEN}logs [service]${NC} Tail logs (default: api)"
|
||||
echo -e " ${GREEN}update${NC} Pull latest images + rebuild"
|
||||
echo -e " ${GREEN}preflight${NC} Check ports + resources; write .env"
|
||||
echo -e " ${GREEN}models${NC} Check ollama models in config; pull any missing"
|
||||
echo -e " ${GREEN}test${NC} Run test suite"
|
||||
|
|
@ -41,6 +47,12 @@ usage() {
|
|||
echo -e " ${GREEN}clean${NC} Remove containers, images, volumes (DESTRUCTIVE)"
|
||||
echo -e " ${GREEN}open${NC} Open the web UI in your browser"
|
||||
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 " remote API-only, no local inference (default)"
|
||||
echo " cpu Local Ollama inference on CPU"
|
||||
|
|
@ -70,7 +82,7 @@ while [[ $# -gt 0 ]]; do
|
|||
esac
|
||||
done
|
||||
|
||||
SERVICE="${1:-app}" # used by `logs` command
|
||||
SERVICE="${1:-api}" # used by `logs` command
|
||||
|
||||
# ── Dependency guard ──────────────────────────────────────────────────────────
|
||||
# Commands that delegate to make; others (status, logs, update, open, setup) run fine without it.
|
||||
|
|
@ -101,7 +113,7 @@ case "$CMD" in
|
|||
start)
|
||||
info "Starting Peregrine (PROFILE=${PROFILE})..."
|
||||
make start PROFILE="$PROFILE"
|
||||
PORT="$(grep -m1 '^STREAMLIT_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8501)"
|
||||
PORT="$(grep -m1 '^VUE_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8506)"
|
||||
success "Peregrine is up → http://localhost:${PORT}"
|
||||
;;
|
||||
|
||||
|
|
@ -114,33 +126,30 @@ case "$CMD" in
|
|||
restart)
|
||||
info "Restarting (PROFILE=${PROFILE})..."
|
||||
make restart PROFILE="$PROFILE"
|
||||
PORT="$(grep -m1 '^STREAMLIT_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8501)"
|
||||
PORT="$(grep -m1 '^VUE_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8506)"
|
||||
success "Peregrine restarted → http://localhost:${PORT}"
|
||||
;;
|
||||
|
||||
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
|
||||
;;
|
||||
|
||||
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}"
|
||||
$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)
|
||||
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"))"
|
||||
info "Pulling latest images and rebuilding..."
|
||||
$COMPOSE pull searxng ollama 2>/dev/null || true
|
||||
$COMPOSE build app web
|
||||
$COMPOSE build api web
|
||||
success "Update complete. Run './manage.sh restart' to apply."
|
||||
;;
|
||||
|
||||
|
|
@ -167,7 +176,7 @@ case "$CMD" in
|
|||
;;
|
||||
|
||||
open)
|
||||
PORT="$(grep -m1 '^STREAMLIT_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8501)"
|
||||
PORT="$(grep -m1 '^VUE_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8506)"
|
||||
URL="http://localhost:${PORT}"
|
||||
info "Opening ${URL}"
|
||||
if command -v xdg-open &>/dev/null; then
|
||||
|
|
@ -197,6 +206,32 @@ case "$CMD" in
|
|||
-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)
|
||||
usage
|
||||
;;
|
||||
|
|
|
|||
|
|
@ -234,10 +234,11 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]:
|
|||
return None
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
status = job.get("status", "pending")
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO jobs
|
||||
(title, company, url, source, location, is_remote, salary, description, date_found, date_posted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(title, company, url, source, location, is_remote, salary, description, date_found, date_posted, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
job.get("title", ""),
|
||||
job.get("company", ""),
|
||||
|
|
@ -249,6 +250,7 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]:
|
|||
job.get("description", ""),
|
||||
job.get("date_found", ""),
|
||||
job.get("date_posted", "") or "",
|
||||
status,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
|
|
|||
|
|
@ -392,6 +392,7 @@ def _has_todo_keyword(subject: str) -> bool:
|
|||
|
||||
|
||||
_LINKEDIN_ALERT_SENDER = "jobalerts-noreply@linkedin.com"
|
||||
_INDEED_ALERT_SENDER = "jobalerts@indeed.com"
|
||||
|
||||
# Social-proof / nav lines to skip when parsing alert blocks
|
||||
_ALERT_SKIP_PHRASES = {
|
||||
|
|
@ -447,6 +448,75 @@ def parse_linkedin_alert(body: str) -> list[dict]:
|
|||
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,
|
||||
active_jobs: list[dict],
|
||||
known_message_ids: set) -> int:
|
||||
|
|
@ -558,20 +628,29 @@ def _scan_unmatched_leads(conn: imaplib.IMAP4, cfg: dict,
|
|||
if mid in known_message_ids:
|
||||
continue
|
||||
|
||||
# ── LinkedIn Job Alert digest — parse each card individually ──────
|
||||
if _LINKEDIN_ALERT_SENDER in parsed["from_addr"].lower():
|
||||
cards = parse_linkedin_alert(parsed["body"])
|
||||
for card in cards:
|
||||
# ── Job alert digests — parse each card deterministically ───────
|
||||
from_lower = parsed["from_addr"].lower()
|
||||
alert_cards: list[dict] = []
|
||||
alert_source = ""
|
||||
if _LINKEDIN_ALERT_SENDER in from_lower:
|
||||
alert_cards = parse_linkedin_alert(parsed["body"])
|
||||
alert_source = "linkedin"
|
||||
elif _INDEED_ALERT_SENDER in from_lower:
|
||||
alert_cards = parse_indeed_alert(parsed["body"])
|
||||
alert_source = "indeed"
|
||||
|
||||
if alert_cards:
|
||||
for card in alert_cards:
|
||||
if card["url"] in existing_urls:
|
||||
continue
|
||||
job_id = insert_job(db_path, {
|
||||
"title": card["title"],
|
||||
"company": card["company"],
|
||||
"url": card["url"],
|
||||
"source": "linkedin",
|
||||
"location": card["location"],
|
||||
"source": alert_source,
|
||||
"location": card.get("location", ""),
|
||||
"is_remote": 0,
|
||||
"salary": "",
|
||||
"salary": card.get("salary", ""),
|
||||
"description": "",
|
||||
"date_found": datetime.now().isoformat()[:10],
|
||||
})
|
||||
|
|
@ -580,7 +659,7 @@ def _scan_unmatched_leads(conn: imaplib.IMAP4, cfg: dict,
|
|||
submit_task(db_path, "scrape_url", job_id)
|
||||
existing_urls.add(card["url"])
|
||||
new_leads += 1
|
||||
print(f"[imap] LinkedIn alert → {card['company']} — {card['title']}")
|
||||
print(f"[imap] {alert_source} alert → {card['company']} — {card['title']}")
|
||||
known_message_ids.add(mid)
|
||||
continue # skip normal LLM extraction path
|
||||
|
||||
|
|
|
|||
|
|
@ -278,7 +278,8 @@ def rewrite_for_ats(
|
|||
f"3. Only rephrase existing content — replace vague verbs/nouns with the "
|
||||
f" ATS-preferred equivalents listed above.\n"
|
||||
f"4. Keep the same number of bullet points in experience entries.\n"
|
||||
f"5. Return ONLY the rewritten section content, no labels or explanation."
|
||||
f"5. Do NOT use markdown formatting — no **, __, or * for bullets.\n"
|
||||
f"6. Return ONLY the rewritten section content, no labels or explanation."
|
||||
f"{voice_note}\n\n"
|
||||
f"Original {section} section:\n{original_content}"
|
||||
)
|
||||
|
|
@ -305,7 +306,8 @@ def _section_text_for_prompt(resume: dict[str, Any], section: str) -> str:
|
|||
for exp in resume.get("experience", []):
|
||||
lines.append(f"{exp['title']} at {exp['company']} ({exp['start_date']}–{exp['end_date']})")
|
||||
for b in exp.get("bullets", []):
|
||||
lines.append(f" • {b}")
|
||||
clean_b = re.sub(r"^[•\-–—*◦▪▸►\s]+", "", b).strip()
|
||||
lines.append(f" • {clean_b}")
|
||||
return "\n".join(lines) if lines else "(empty)"
|
||||
return "(unsupported section)"
|
||||
|
||||
|
|
@ -314,7 +316,7 @@ def _apply_section_rewrite(resume: dict[str, Any], section: str, rewritten: str)
|
|||
"""Return a new resume dict with the given section replaced by rewritten text."""
|
||||
updated = dict(resume)
|
||||
if section == "summary":
|
||||
updated["career_summary"] = rewritten
|
||||
updated["career_summary"] = _clean_summary_markup(rewritten)
|
||||
elif section == "skills":
|
||||
# LLM returns comma-separated or newline-separated skills
|
||||
skills = [s.strip() for s in re.split(r"[,\n•·]+", rewritten) if s.strip()]
|
||||
|
|
@ -326,6 +328,19 @@ def _apply_section_rewrite(resume: dict[str, Any], section: str, rewritten: str)
|
|||
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(
|
||||
original_entries: list[dict],
|
||||
rewritten_text: str,
|
||||
|
|
@ -355,9 +370,9 @@ def _reparse_experience_bullets(
|
|||
chunk = remaining
|
||||
|
||||
bullets = [
|
||||
re.sub(r"^[•\-–—*◦▪▸►]\s*", "", line).strip()
|
||||
re.sub(r"^([•\-–—*◦▪▸►]\s*)+", "", line.strip()).strip()
|
||||
for line in chunk.splitlines()
|
||||
if re.match(r"^[•\-–—*◦▪▸►]\s*", line.strip())
|
||||
if re.match(r"^\s*[•\-–—*◦▪▸►]", line)
|
||||
]
|
||||
new_entry = dict(entry)
|
||||
new_entry["bullets"] = bullets if bullets else entry["bullets"]
|
||||
|
|
@ -532,27 +547,37 @@ def apply_review_decisions(
|
|||
struct["skills"] = sorted(original_kept | approved_additions)
|
||||
break
|
||||
|
||||
# ── Summary: accept proposed or revert to original ──────────────────────
|
||||
if not decisions.get("summary", {}).get("accepted", True):
|
||||
# ── Summary: accept/reject + optional user-edited text ─────────────────
|
||||
summary_dec = decisions.get("summary", {})
|
||||
if not summary_dec.get("accepted", True):
|
||||
for sec in sections:
|
||||
if sec["section"] == "summary":
|
||||
struct["career_summary"] = sec.get("original", struct.get("career_summary", ""))
|
||||
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 ─────────────────────────────────
|
||||
exp_decisions: dict[str, bool] = {
|
||||
f"{ed.get('title', '')}|{ed.get('company', '')}": ed.get("accepted", True)
|
||||
# ── Experience: per-entry accept/reject + optional user-edited bullets ──
|
||||
exp_entry_map: dict[str, dict] = {
|
||||
f"{ed.get('title', '')}|{ed.get('company', '')}": ed
|
||||
for ed in (decisions.get("experience", {}).get("accepted_entries") or [])
|
||||
}
|
||||
for sec in sections:
|
||||
if sec["section"] == "experience":
|
||||
for entry_diff in (sec.get("entries") or []):
|
||||
key = f"{entry_diff['title']}|{entry_diff['company']}"
|
||||
if not exp_decisions.get(key, True):
|
||||
entry_dec = exp_entry_map.get(key, {})
|
||||
accepted = entry_dec.get("accepted", True)
|
||||
edited_bullets = entry_dec.get("edited_bullets")
|
||||
for exp_entry in (struct.get("experience") or []):
|
||||
if (exp_entry.get("title") == entry_diff["title"] and
|
||||
exp_entry.get("company") == entry_diff["company"]):
|
||||
if not accepted:
|
||||
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
|
||||
|
||||
return struct
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ _TIMEOUT = 12
|
|||
|
||||
|
||||
def _detect_board(url: str) -> str:
|
||||
"""Return 'linkedin', 'indeed', 'glassdoor', or 'generic'."""
|
||||
"""Return 'linkedin', 'indeed', 'glassdoor', 'jobgether', 'oracle_hcm', or 'generic'."""
|
||||
url_lower = url.lower()
|
||||
if "linkedin.com" in url_lower:
|
||||
return "linkedin"
|
||||
|
|
@ -67,6 +67,8 @@ def _detect_board(url: str) -> str:
|
|||
return "glassdoor"
|
||||
if "jobgether.com" in url_lower:
|
||||
return "jobgether"
|
||||
if "oraclecloud.com" in url_lower and "hcmui" in url_lower:
|
||||
return "oracle_hcm"
|
||||
return "generic"
|
||||
|
||||
|
||||
|
|
@ -201,6 +203,70 @@ def _scrape_jobgether(url: str) -> dict:
|
|||
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:
|
||||
"""Extract job fields from JSON-LD structured data, then og: meta tags."""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
|
@ -278,6 +344,8 @@ def scrape_job_url(db_path: Path = DEFAULT_DB, job_id: int = None) -> dict:
|
|||
fields = _scrape_glassdoor(url)
|
||||
elif board == "jobgether":
|
||||
fields = _scrape_jobgether(url)
|
||||
elif board == "oracle_hcm":
|
||||
fields = _scrape_oracle_hcm(url)
|
||||
else:
|
||||
fields = _scrape_generic(url)
|
||||
except requests.RequestException as exc:
|
||||
|
|
|
|||
|
|
@ -203,6 +203,73 @@ def test_parse_linkedin_alert_empty_body():
|
|||
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 ─────────────────────────────────────────
|
||||
|
||||
_ALERT_BODY = """\
|
||||
|
|
|
|||
|
|
@ -5,11 +5,20 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Peregrine — Job Search Assistant</title>
|
||||
<!-- Inline background prevents blank flash before CSS bundle loads -->
|
||||
<!-- Matches --color-surface light / dark from theme.css -->
|
||||
<!-- Apply stored theme before first paint — prevents FOUT flash on dark/hacker themes.
|
||||
Mirrors the logic in useTheme.initTheme(). Must run before the <style> below. -->
|
||||
<script>try{if(localStorage.getItem('cf-hacker-mode')==='true'){document.documentElement.dataset.theme='hacker';}else{var t=localStorage.getItem('cf-theme');if(t&&t!=='auto')document.documentElement.dataset.theme=t;}}catch(e){}</script>
|
||||
<!-- FOUT prevention: background only on html (body is transparent). Gotcha #14.
|
||||
body paints on top of html — a hardcoded body background covers html's CSS-
|
||||
variable-driven color even when it resolves correctly. Keep background off body.
|
||||
Covers auto mode (media query) and all explicit theme choices. -->
|
||||
<style>
|
||||
html, body { margin: 0; background: #eaeff8; min-height: 100vh; }
|
||||
@media (prefers-color-scheme: dark) { html, body { background: #16202e; } }
|
||||
html, body { margin: 0; min-height: 100vh; }
|
||||
html { background: #eaeff8; }
|
||||
@media (prefers-color-scheme: dark) { html:not([data-theme]) { background: #16202e; } }
|
||||
html[data-theme="dark"] { background: #16202e; }
|
||||
html[data-theme="solarized-dark"] { background: #002b36; }
|
||||
html[data-theme="hacker"] { background: #0a0c0a; }
|
||||
</style>
|
||||
<!-- Plausible analytics: cookie-free, GDPR-compliant, self-hosted.
|
||||
Skips localhost/127.0.0.1. Reports to hostname + circuitforge.tech rollup. -->
|
||||
|
|
|
|||
|
|
@ -71,6 +71,10 @@ html {
|
|||
}
|
||||
|
||||
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. */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
|
@ -135,8 +139,8 @@ body {
|
|||
bottom: calc(72px + env(safe-area-inset-bottom));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-surface-raised, #2a3650);
|
||||
color: var(--color-text, #eaeff8);
|
||||
background: var(--color-surface-raised, #f5f7fc);
|
||||
color: var(--color-text, #1a2338);
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
font-size: 0.9rem;
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ body {
|
|||
--score-low: var(--color-error); /* < 30% */
|
||||
--score-none: var(--color-text-muted);
|
||||
|
||||
/* ── Hover overlay ── */
|
||||
--color-hover: rgba(0, 0, 0, 0.06); /* subtle darkening on light surfaces */
|
||||
|
||||
/* ── Motion tokens ── */
|
||||
--swipe-exit: 300ms;
|
||||
--swipe-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
|
@ -79,7 +82,7 @@ body {
|
|||
/* ── Dark mode ─────────────────────────────────────── */
|
||||
/* Covers both: OS-level dark preference AND explicit dark theme selection in UI */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="hacker"]) {
|
||||
:root:not([data-theme]) {
|
||||
--app-primary: #68A8D8; /* Falcon Blue (dark) — 6.54:1 on #16202e ✅ AA */
|
||||
--app-primary-hover: #7BBDE6;
|
||||
--app-primary-light: #0D1F35;
|
||||
|
|
@ -91,6 +94,8 @@ body {
|
|||
|
||||
--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-survey: #b08fea;
|
||||
--status-phone: #4ec9be;
|
||||
|
|
@ -112,6 +117,8 @@ body {
|
|||
|
||||
--score-mid-high: #5ba3d9;
|
||||
|
||||
--color-hover: rgba(255, 255, 255, 0.07);
|
||||
|
||||
--status-synced: #9b8fea;
|
||||
--status-survey: #b08fea;
|
||||
--status-phone: #4ec9be;
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@
|
|||
--shadow-md: 0 4px 12px rgba(26, 35, 56, 0.1), 0 2px 4px rgba(26, 35, 56, 0.06);
|
||||
--shadow-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 */
|
||||
--transition: 200ms ease;
|
||||
--transition-slow: 400ms ease;
|
||||
|
|
|
|||
|
|
@ -59,9 +59,6 @@
|
|||
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
|
||||
<span class="sidebar__label">Settings</span>
|
||||
</RouterLink>
|
||||
<button class="sidebar__classic-btn" @click="switchToClassic" title="Switch to Classic (Streamlit) UI">
|
||||
⚡ Classic
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
|
@ -134,23 +131,6 @@ function exitHackerMode() {
|
|||
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(() => [
|
||||
{ to: '/', icon: HomeIcon, label: 'Home' },
|
||||
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
|
||||
|
|
@ -321,29 +301,6 @@ const mobileLinks = [
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar__classic-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
margin-top: var(--space-1);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 150ms, background 150ms;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar__classic-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--color-surface-alt);
|
||||
}
|
||||
|
||||
/* ── Theme picker ───────────────────────────────────── */
|
||||
.sidebar__theme {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ function dismiss(): void {
|
|||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2, 8px);
|
||||
background: var(--color-surface, #0d1829);
|
||||
background: var(--color-surface, #eaeff8);
|
||||
border: 1px solid var(--app-primary, #2B6CB0);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: var(--space-2, 8px) var(--space-3, 12px);
|
||||
|
|
@ -59,5 +59,5 @@ function dismiss(): void {
|
|||
line-height: 1;
|
||||
}
|
||||
|
||||
.hint-chip__dismiss:hover { color: var(--color-text, #eaeff8); }
|
||||
.hint-chip__dismiss:hover { color: var(--color-text, #1a2338); }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ async function reclassifySignal(sig: StageSignal, newLabel: StageSignal['stage_s
|
|||
}
|
||||
|
||||
const scoreClass = computed(() => {
|
||||
const s = (props.job.match_score ?? 0) * 100
|
||||
const s = props.job.match_score ?? 0
|
||||
if (s >= 85) return 'score--high'
|
||||
if (s >= 65) return 'score--mid'
|
||||
return 'score--low'
|
||||
|
|
@ -159,7 +159,7 @@ const scoreClass = computed(() => {
|
|||
|
||||
const scoreLabel = computed(() =>
|
||||
props.job.match_score != null
|
||||
? `${Math.round(props.job.match_score * 100)}%`
|
||||
? `${Math.round(props.job.match_score)}%`
|
||||
: '—'
|
||||
)
|
||||
|
||||
|
|
@ -588,7 +588,7 @@ async function saveFeedback() {
|
|||
background: var(--color-hover);
|
||||
}
|
||||
.btn-chip-active {
|
||||
background: var(--color-primary-muted, #e8f0ff);
|
||||
background: var(--app-primary-light);
|
||||
color: var(--color-primary); border-color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ onMounted(load)
|
|||
}
|
||||
|
||||
.rlc__title {
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
|
|
@ -128,7 +128,7 @@ onMounted(load)
|
|||
|
||||
.rlc__name {
|
||||
font-weight: 500;
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.rlc__meta {
|
||||
|
|
@ -143,7 +143,7 @@ onMounted(load)
|
|||
}
|
||||
|
||||
.rlc__empty {
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
|
||||
|
|
@ -153,7 +153,7 @@ onMounted(load)
|
|||
}
|
||||
|
||||
.rlc__loading {
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
|
||||
|
|
@ -183,7 +183,7 @@ onMounted(load)
|
|||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.rlc__picker-item:hover,
|
||||
|
|
|
|||
|
|
@ -112,16 +112,15 @@
|
|||
<span class="rop__preview-badge">Preview — not yet saved</span>
|
||||
</div>
|
||||
<textarea
|
||||
:value="previewText"
|
||||
class="rop__textarea rop__textarea--preview"
|
||||
aria-label="Resume preview text"
|
||||
spellcheck="false"
|
||||
readonly
|
||||
v-model="previewText"
|
||||
class="rop__textarea"
|
||||
aria-label="Resume preview — editable before approving"
|
||||
spellcheck="true"
|
||||
/>
|
||||
<p class="rop__preview-hint">
|
||||
Review the assembled resume above. If it looks right, click
|
||||
<strong>Approve & Save</strong> to lock it in. You can also go back and adjust
|
||||
your review decisions.
|
||||
Review and edit the assembled resume above. Click
|
||||
<strong>Approve & Save</strong> to lock it in, or go back to adjust
|
||||
your section-level decisions.
|
||||
</p>
|
||||
<div class="rop__save-to-library">
|
||||
<label class="rop__save-toggle">
|
||||
|
|
@ -492,7 +491,10 @@ async function approveResume() {
|
|||
if (!previewStruct.value) return
|
||||
approvingResume.value = true
|
||||
|
||||
const body: Record<string, unknown> = { preview_struct: previewStruct.value }
|
||||
const body: Record<string, unknown> = {
|
||||
preview_struct: previewStruct.value,
|
||||
preview_text_override: previewText.value,
|
||||
}
|
||||
if (saveToLibrary.value) {
|
||||
body.save_to_library = true
|
||||
body.resume_name = savedResumeName.value.trim() || `Optimized for job ${props.jobId}`
|
||||
|
|
@ -576,7 +578,7 @@ onUnmounted(stopPolling)
|
|||
}
|
||||
|
||||
.rop__tier-note {
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--app-text-muted, #64748b);
|
||||
background: var(--app-surface-alt, #f8fafc);
|
||||
border: 1px solid var(--app-border, #e2e8f0);
|
||||
|
|
@ -601,13 +603,13 @@ onUnmounted(stopPolling)
|
|||
|
||||
.rop__hint,
|
||||
.rop__empty {
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--app-text-muted, #64748b);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rop__error {
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--app-danger, #dc2626);
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -616,7 +618,7 @@ onUnmounted(stopPolling)
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--app-text-muted, #64748b);
|
||||
}
|
||||
|
||||
|
|
@ -641,7 +643,7 @@ onUnmounted(stopPolling)
|
|||
border-radius: var(--radius-sm, 0.25rem);
|
||||
border-left: 3px solid transparent;
|
||||
background: var(--app-surface-alt, #f8fafc);
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
|
@ -704,7 +706,7 @@ onUnmounted(stopPolling)
|
|||
}
|
||||
|
||||
.rop__wordcount {
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--app-text-muted, #64748b);
|
||||
}
|
||||
|
||||
|
|
@ -725,7 +727,7 @@ onUnmounted(stopPolling)
|
|||
background: color-mix(in srgb, var(--app-danger, #dc2626) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--app-danger, #dc2626) 30%, transparent);
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--app-danger, #dc2626);
|
||||
}
|
||||
|
||||
|
|
@ -734,7 +736,7 @@ onUnmounted(stopPolling)
|
|||
min-height: 20rem;
|
||||
padding: var(--space-3, 0.75rem);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
border: 1px solid var(--app-border, #e2e8f0);
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
|
|
@ -760,7 +762,7 @@ onUnmounted(stopPolling)
|
|||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
|
@ -779,7 +781,7 @@ onUnmounted(stopPolling)
|
|||
color: var(--app-text, #1e293b);
|
||||
border: 1px solid var(--app-border, #e2e8f0);
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
|
@ -797,7 +799,7 @@ onUnmounted(stopPolling)
|
|||
}
|
||||
|
||||
.rop__review-intro {
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--app-text-muted, #64748b);
|
||||
margin: 0;
|
||||
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
|
||||
|
|
@ -817,7 +819,7 @@ onUnmounted(stopPolling)
|
|||
}
|
||||
|
||||
.rop__review-section-title {
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
|
@ -850,7 +852,7 @@ onUnmounted(stopPolling)
|
|||
gap: var(--space-1, 0.25rem);
|
||||
padding: 0.3em 0.75em;
|
||||
border-radius: var(--radius-full, 9999px);
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
border: 1.5px solid var(--app-border, #e2e8f0);
|
||||
background: var(--app-surface, #fff);
|
||||
cursor: pointer;
|
||||
|
|
@ -885,7 +887,7 @@ onUnmounted(stopPolling)
|
|||
gap: var(--space-1, 0.25rem);
|
||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.rop__diff-col--original {
|
||||
|
|
@ -934,7 +936,7 @@ onUnmounted(stopPolling)
|
|||
}
|
||||
|
||||
.rop__exp-company {
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--app-text-muted, #64748b);
|
||||
}
|
||||
|
||||
|
|
@ -943,7 +945,7 @@ onUnmounted(stopPolling)
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1, 0.25rem);
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
color: var(--app-text, #1e293b);
|
||||
}
|
||||
|
|
@ -973,7 +975,7 @@ onUnmounted(stopPolling)
|
|||
background: none;
|
||||
border: none;
|
||||
color: var(--app-accent, #6366f1);
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
|
@ -992,7 +994,7 @@ onUnmounted(stopPolling)
|
|||
background: var(--app-surface-alt, #f8fafc);
|
||||
border: 1px solid var(--app-border, #e2e8f0);
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.rop__history-date {
|
||||
|
|
@ -1058,7 +1060,7 @@ onUnmounted(stopPolling)
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1, 0.25rem);
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
color: var(--app-text, #1e293b);
|
||||
}
|
||||
|
|
@ -1066,7 +1068,7 @@ onUnmounted(stopPolling)
|
|||
.rop__framing-context {
|
||||
width: 100%;
|
||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
border: 1px solid var(--app-border, #e2e8f0);
|
||||
|
|
@ -1099,13 +1101,9 @@ onUnmounted(stopPolling)
|
|||
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 {
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--app-text-muted, #64748b);
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -1134,7 +1132,7 @@ onUnmounted(stopPolling)
|
|||
color: var(--app-text-muted, #64748b);
|
||||
border: 1px solid var(--app-border, #e2e8f0);
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
|
@ -1165,7 +1163,7 @@ onUnmounted(stopPolling)
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--app-text, #1e293b);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
|
@ -1173,7 +1171,7 @@ onUnmounted(stopPolling)
|
|||
|
||||
.rop__resume-name-input {
|
||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
border: 1px solid var(--app-border, #e2e8f0);
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
|
|
|
|||
|
|
@ -63,7 +63,9 @@
|
|||
<SummaryPage
|
||||
:section="summarySection!"
|
||||
:accepted="summaryAccepted"
|
||||
:edited-proposed="summaryEdited"
|
||||
@update:accepted="summaryAccepted = $event"
|
||||
@update:editedProposed="summaryEdited = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
|
@ -72,7 +74,9 @@
|
|||
<ExperiencePage
|
||||
:entry="currentEntry!"
|
||||
:accepted="expAccepted[currentPage.entryKey!] ?? true"
|
||||
:edited-bullets="expEdited[currentPage.entryKey!] ?? currentEntry!.proposed_bullets"
|
||||
@update:accepted="expAccepted[currentPage.entryKey!] = $event"
|
||||
@update:editedBullets="expEdited[currentPage.entryKey!] = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
|
@ -255,11 +259,17 @@ function goTo(idx: number) {
|
|||
const approvedSkills = ref<Set<string>>(new Set(skillsSection.value?.added ?? []))
|
||||
const skillFramings = ref<Map<string, GapFraming>>(new Map())
|
||||
const summaryAccepted = ref(true)
|
||||
const summaryEdited = ref<string>(summarySection.value?.proposed ?? '')
|
||||
const expAccepted = ref<Record<string, boolean>>(
|
||||
Object.fromEntries(
|
||||
(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) {
|
||||
interactedPages.value = new Set([...interactedPages.value, 'skills'])
|
||||
|
|
@ -322,15 +332,22 @@ function emitSubmit() {
|
|||
decisions.skills = { approved_additions: [...approvedSkills.value] }
|
||||
}
|
||||
if (summarySection.value) {
|
||||
decisions.summary = { accepted: summaryAccepted.value }
|
||||
decisions.summary = {
|
||||
accepted: summaryAccepted.value,
|
||||
edited_text: summaryEdited.value,
|
||||
}
|
||||
}
|
||||
if (expSection.value) {
|
||||
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,
|
||||
company: e.company,
|
||||
accepted: expAccepted.value[`${e.title}|${e.company}`] ?? true,
|
||||
})),
|
||||
accepted: expAccepted.value[key] ?? true,
|
||||
edited_bullets: expEdited.value[key] ?? e.proposed_bullets,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ defineEmits<{
|
|||
background: var(--color-error, #dc2626);
|
||||
color: #fff; border: none;
|
||||
border-radius: var(--radius-md); cursor: pointer;
|
||||
font-size: var(--font-sm); font-weight: 600;
|
||||
font-size: var(--text-sm); font-weight: 600;
|
||||
}
|
||||
.btn-danger:hover { filter: brightness(1.1); }
|
||||
.btn-secondary {
|
||||
|
|
@ -140,7 +140,7 @@ defineEmits<{
|
|||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md); cursor: pointer;
|
||||
font-size: var(--font-sm);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.btn-secondary:hover { background: var(--color-surface-alt); }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -46,11 +46,11 @@ const emit = defineEmits<{
|
|||
<style scoped>
|
||||
.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__hint { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #4a5c7a); margin: 0; }
|
||||
.rp__hint { font-size: var(--text-sm); color: var(--color-text-muted, #4a5c7a); margin: 0; }
|
||||
.rp-confirm__list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
|
||||
.rp-confirm__item { display: flex; align-items: center; gap: var(--space-3, 0.75rem); padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); font-size: var(--font-sm, 0.875rem); }
|
||||
.rp-confirm__item { display: flex; align-items: center; gap: var(--space-3, 0.75rem); padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); font-size: var(--text-sm); }
|
||||
.rp-confirm__status { margin-left: auto; font-size: var(--font-xs, 0.75rem); color: var(--color-text-muted, #4a5c7a); text-transform: capitalize; }
|
||||
.rp__error { color: var(--color-error, #c0392b); font-size: var(--font-sm, 0.875rem); margin: 0; }
|
||||
.rp__error { color: var(--color-error, #c0392b); font-size: var(--text-sm); margin: 0; }
|
||||
.rp-confirm__actions { display: flex; gap: var(--space-3, 0.75rem); flex-wrap: wrap; }
|
||||
.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); }
|
||||
|
|
@ -64,7 +64,7 @@ const emit = defineEmits<{
|
|||
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
|
||||
background: var(--color-accent, #c4732a); color: #fff;
|
||||
border: none; border-radius: var(--radius-md, 0.5rem);
|
||||
font-size: var(--font-sm, 0.875rem); font-weight: 600; cursor: pointer;
|
||||
font-size: var(--text-sm); font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.btn-generate:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.btn-secondary {
|
||||
|
|
@ -72,6 +72,6 @@ const emit = defineEmits<{
|
|||
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
|
||||
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);
|
||||
font-size: var(--font-sm, 0.875rem); font-weight: 600; cursor: pointer;
|
||||
font-size: var(--text-sm); font-weight: 600; cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -9,11 +9,25 @@
|
|||
<li v-for="b in entry.original_bullets" :key="b">{{ b }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="rp__diff-col">
|
||||
<span class="rp__diff-label">Proposed</span>
|
||||
<ul class="rp__bullet-list">
|
||||
<li v-for="b in entry.proposed_bullets" :key="b">{{ b }}</li>
|
||||
</ul>
|
||||
<div class="rp__diff-col rp__diff-col--editable">
|
||||
<span class="rp__diff-label">Proposed — edit below</span>
|
||||
<div class="rp__bullet-edit-list" role="list" :aria-label="`Edit proposed bullets for ${entry.title}`">
|
||||
<div
|
||||
v-for="(bullet, idx) in editedBullets"
|
||||
:key="idx"
|
||||
class="rp__bullet-edit-row"
|
||||
role="listitem"
|
||||
>
|
||||
<textarea
|
||||
class="rp__bullet-textarea"
|
||||
:value="bullet"
|
||||
:aria-label="`Bullet ${idx + 1}`"
|
||||
rows="2"
|
||||
spellcheck="true"
|
||||
@input="updateBullet(idx, ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="rp__accept-toggle">
|
||||
|
|
@ -28,7 +42,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
entry: {
|
||||
title: string
|
||||
company: string
|
||||
|
|
@ -36,21 +50,46 @@ defineProps<{
|
|||
proposed_bullets: string[]
|
||||
}
|
||||
accepted: boolean
|
||||
editedBullets: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'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>
|
||||
|
||||
<style scoped>
|
||||
.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__company { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #4a5c7a); margin: 0; }
|
||||
.rp__company { font-size: var(--text-sm); color: var(--color-text-muted, #4a5c7a); margin: 0; }
|
||||
.rp__diff-pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4, 1rem); }
|
||||
@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--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__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__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-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-edit-list { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
|
||||
.rp__bullet-edit-row { display: flex; align-items: flex-start; gap: var(--space-1, 0.25rem); }
|
||||
.rp__bullet-textarea {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||
background: var(--color-surface, #eaeff8);
|
||||
border: 1.5px solid var(--color-accent, #c4732a);
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
color: var(--color-text, #1a2338);
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
}
|
||||
.rp__bullet-textarea:focus { outline: 2px solid var(--color-accent, #c4732a); outline-offset: 2px; }
|
||||
.rp__accept-toggle { display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem); cursor: pointer; font-size: var(--text-sm); }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ const emit = defineEmits<{
|
|||
<style scoped>
|
||||
.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__hint { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #4a5c7a); margin: 0; }
|
||||
.rp__hint { font-size: var(--text-sm); color: var(--color-text-muted, #4a5c7a); margin: 0; }
|
||||
.rp__skill-list { display: flex; flex-direction: column; gap: var(--space-3, 0.75rem); }
|
||||
.rp__skill-group { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
|
||||
.rp__skill-chip {
|
||||
|
|
@ -66,13 +66,13 @@ const emit = defineEmits<{
|
|||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||
border: 1px solid var(--color-border, #a8b8d0);
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
cursor: pointer; font-size: var(--font-sm, 0.875rem);
|
||||
cursor: pointer; font-size: var(--text-sm);
|
||||
background: var(--color-surface-raised, #f5f7fc);
|
||||
transition: background var(--transition, 200ms ease);
|
||||
}
|
||||
.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-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; color: var(--color-text-muted, #4a5c7a); }
|
||||
.rp__framing-context { border: 1px solid var(--color-border, #a8b8d0); border-radius: var(--radius-sm, 0.25rem); padding: var(--space-2, 0.5rem); font-size: var(--font-sm, 0.875rem); resize: vertical; }
|
||||
.rp__removed { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #4a5c7a); font-style: italic; }
|
||||
.rp__framing-context { border: 1px solid var(--color-border, #a8b8d0); border-radius: var(--radius-sm, 0.25rem); padding: var(--space-2, 0.5rem); font-size: var(--text-sm); resize: vertical; }
|
||||
.rp__removed { font-size: var(--text-sm); color: var(--color-text-muted, #4a5c7a); font-style: italic; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -6,9 +6,15 @@
|
|||
<span class="rp__diff-label" aria-label="Original">Original</span>
|
||||
<p class="rp__diff-text">{{ section.original || '(empty)' }}</p>
|
||||
</div>
|
||||
<div class="rp__diff-col">
|
||||
<span class="rp__diff-label" aria-label="Proposed">Proposed</span>
|
||||
<p class="rp__diff-text">{{ section.proposed }}</p>
|
||||
<div class="rp__diff-col rp__diff-col--editable">
|
||||
<span class="rp__diff-label" aria-label="Proposed — editable">Proposed</span>
|
||||
<textarea
|
||||
class="rp__edit-textarea"
|
||||
:value="editedProposed"
|
||||
:aria-label="`Edit proposed summary`"
|
||||
spellcheck="true"
|
||||
@input="emit('update:editedProposed', ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label class="rp__accept-toggle">
|
||||
|
|
@ -28,10 +34,12 @@ import type { TextDiff } from '../ResumeReviewModal.vue'
|
|||
defineProps<{
|
||||
section: TextDiff
|
||||
accepted: boolean
|
||||
editedProposed: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:accepted': [v: boolean]
|
||||
'update:editedProposed': [v: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
|
|
@ -41,7 +49,23 @@ const emit = defineEmits<{
|
|||
.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; } }
|
||||
.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-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__accept-toggle { display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem); cursor: pointer; font-size: var(--font-sm, 0.875rem); }
|
||||
.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__edit-textarea {
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
padding: var(--space-3, 0.75rem);
|
||||
background: var(--color-surface, #eaeff8);
|
||||
border: 1.5px solid var(--color-accent, #c4732a);
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
color: var(--color-text, #1a2338);
|
||||
resize: vertical;
|
||||
min-height: 7rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
}
|
||||
.rp__edit-textarea:focus { outline: 2px solid var(--color-accent, #c4732a); outline-offset: 2px; }
|
||||
.rp__accept-toggle { display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem); cursor: pointer; font-size: var(--text-sm); }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ describe('usePrepStore', () => {
|
|||
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||
generated_at: '2026-03-20T12:00:00' }, error: null }) // research
|
||||
.mockResolvedValueOnce({ data: [], error: null }) // contacts
|
||||
.mockResolvedValueOnce({ data: [], error: null }) // qa
|
||||
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task
|
||||
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
|
||||
description: 'Build things.', cover_letter: null, match_score: 80,
|
||||
|
|
@ -50,6 +51,7 @@ describe('usePrepStore', () => {
|
|||
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||
generated_at: null }, error: null })
|
||||
.mockResolvedValueOnce({ data: [], error: null })
|
||||
.mockResolvedValueOnce({ data: [], error: null }) // qa
|
||||
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: 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 })
|
||||
|
|
@ -62,6 +64,7 @@ describe('usePrepStore', () => {
|
|||
mockApiFetch
|
||||
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 404, detail: '' } }) // 404 → null
|
||||
.mockResolvedValueOnce({ data: [], error: null })
|
||||
.mockResolvedValueOnce({ data: [], error: null }) // qa
|
||||
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: 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 })
|
||||
|
|
@ -102,6 +105,7 @@ describe('usePrepStore', () => {
|
|||
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||
generated_at: null }, error: null })
|
||||
.mockResolvedValueOnce({ data: [], error: null })
|
||||
.mockResolvedValueOnce({ data: [], error: null }) // qa
|
||||
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
||||
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
|
||||
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
||||
|
|
@ -112,11 +116,12 @@ describe('usePrepStore', () => {
|
|||
// Mock first poll → completed
|
||||
mockApiFetch
|
||||
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null })
|
||||
// re-fetch on completed: research, contacts, task, fullJob
|
||||
// re-fetch on completed: research, contacts, qa, task, fullJob
|
||||
.mockResolvedValueOnce({ data: { company_brief: 'Updated!', ceo_brief: null, talking_points: null,
|
||||
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||
generated_at: '2026-03-20T13:00:00' }, error: null })
|
||||
.mockResolvedValueOnce({ data: [], error: null })
|
||||
.mockResolvedValueOnce({ data: [], error: null }) // qa
|
||||
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: 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 })
|
||||
|
|
@ -134,6 +139,7 @@ describe('usePrepStore', () => {
|
|||
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||
generated_at: null }, error: null })
|
||||
.mockResolvedValueOnce({ data: [], error: null })
|
||||
.mockResolvedValueOnce({ data: [], error: null }) // qa
|
||||
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
||||
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
|
||||
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
||||
|
|
@ -162,6 +168,7 @@ describe('usePrepStore', () => {
|
|||
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||
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: [], error: null }) // qa
|
||||
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task OK
|
||||
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
|
||||
description: 'Build things.', cover_letter: null, match_score: 80,
|
||||
|
|
|
|||
|
|
@ -54,14 +54,20 @@ describe('useSurveyStore', () => {
|
|||
})
|
||||
|
||||
it('analyze stores result including mode and rawInput', async () => {
|
||||
vi.useFakeTimers()
|
||||
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({
|
||||
data: { output: '1. B — reason', source: 'text_paste' },
|
||||
data: { status: 'completed', stage: null, message: null,
|
||||
result: { output: '1. B — reason', source: 'text_paste' } },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useSurveyStore()
|
||||
await store.analyze(1, { text: 'Q1: test', mode: 'quick' })
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
|
||||
expect(store.analysis).not.toBeNull()
|
||||
expect(store.analysis!.output).toBe('1. B — reason')
|
||||
|
|
@ -69,6 +75,7 @@ describe('useSurveyStore', () => {
|
|||
expect(store.analysis!.mode).toBe('quick')
|
||||
expect(store.analysis!.rawInput).toBe('Q1: test')
|
||||
expect(store.loading).toBe(false)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('analyze sets error on failure', async () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApiFetch } from '../composables/useApi'
|
||||
import HintChip from '../components/HintChip.vue'
|
||||
import { useAppConfigStore } from '../stores/appConfig'
|
||||
|
|
@ -26,6 +26,8 @@ const error = ref<string | null>(null)
|
|||
const search = ref('')
|
||||
const direction = ref<'all' | 'inbound' | 'outbound'>('all')
|
||||
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
|
||||
|
||||
async function fetchContacts() {
|
||||
|
|
@ -76,9 +78,45 @@ const signalLabel: Record<string, string> = {
|
|||
rejected: '✖ Rejected',
|
||||
positive_response: '✅ Positive',
|
||||
survey_received: '📋 Survey',
|
||||
event_rescheduled: '🔄 Rescheduled',
|
||||
neutral: '— Neutral',
|
||||
}
|
||||
|
||||
onMounted(fetchContacts)
|
||||
async function fetchSyncStatus() {
|
||||
const { data } = await useApiFetch<{ status: string; last_completed_at: string | null }>(
|
||||
'/api/email/sync/status'
|
||||
)
|
||||
if (data) syncStatus.value = data
|
||||
}
|
||||
|
||||
async function triggerSync() {
|
||||
syncing.value = true
|
||||
await useApiFetch('/api/tasks/email-sync', { method: 'POST' })
|
||||
// Poll until the task finishes or we give up after 60 s
|
||||
const deadline = Date.now() + 60_000
|
||||
const poll = setInterval(async () => {
|
||||
await fetchSyncStatus()
|
||||
if (syncStatus.value?.status === 'completed' || Date.now() > deadline) {
|
||||
clearInterval(poll)
|
||||
syncing.value = false
|
||||
fetchContacts()
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function formatSyncTime(iso: string | null): string {
|
||||
if (!iso) return 'never'
|
||||
const d = new Date(iso)
|
||||
const diff = Date.now() - d.getTime()
|
||||
if (diff < 60_000) return 'just now'
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchContacts(), fetchSyncStatus()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -91,6 +129,20 @@ onMounted(fetchContacts)
|
|||
<header class="contacts-header">
|
||||
<h1 class="contacts-title">Contacts</h1>
|
||||
<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>
|
||||
|
||||
<div class="contacts-toolbar">
|
||||
|
|
@ -115,8 +167,16 @@ onMounted(fetchContacts)
|
|||
|
||||
<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="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">
|
||||
No contacts found{{ search ? ' for that search' : '' }}.
|
||||
No contacts found for that search.
|
||||
</div>
|
||||
|
||||
<div v-else class="contacts-table-wrap">
|
||||
|
|
@ -339,4 +399,69 @@ onMounted(fetchContacts)
|
|||
.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>
|
||||
|
|
|
|||
|
|
@ -159,6 +159,10 @@
|
|||
rows="4"
|
||||
aria-label="Job URLs to add"
|
||||
/>
|
||||
<label class="add-jobs__skip-review">
|
||||
<input type="checkbox" v-model="skipReview" />
|
||||
Skip review — add directly to Apply queue
|
||||
</label>
|
||||
<button
|
||||
class="action-btn action-btn--primary"
|
||||
:disabled="!urlInput.trim()"
|
||||
|
|
@ -439,13 +443,14 @@ const runEnrich = () => runTask('enrich', '/api/tasks/enrich')
|
|||
|
||||
const addTab = ref<'url' | 'csv'>('url')
|
||||
const urlInput = ref('')
|
||||
const skipReview = ref(true)
|
||||
|
||||
async function addByUrl() {
|
||||
const urls = urlInput.value.split('\n').map(u => u.trim()).filter(Boolean)
|
||||
await useApiFetch('/api/jobs/add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ urls }),
|
||||
body: JSON.stringify({ urls, skip_review: skipReview.value }),
|
||||
})
|
||||
urlInput.value = ''
|
||||
store.refresh()
|
||||
|
|
@ -791,6 +796,16 @@ onUnmounted(() => {
|
|||
|
||||
.add-jobs__textarea:focus { outline: 2px solid var(--app-primary); outline-offset: 1px; }
|
||||
|
||||
.add-jobs__skip-review {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── Danger Zone ──────────────────────────────────────── */
|
||||
|
||||
.danger-zone {
|
||||
|
|
|
|||
|
|
@ -682,7 +682,7 @@ function formatRejectionDate(job: PipelineJob): string {
|
|||
padding: 1px 8px; font-size: 0.75em; font-weight: 700; margin-left: var(--space-1);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.pre-list-signal-count { margin-left: auto; font-size: 0.75em; font-weight: 700; color: #e67e22; }
|
||||
.pre-list-signal-count { margin-left: auto; font-size: 0.75em; font-weight: 700; color: var(--app-accent); }
|
||||
|
||||
/* Collapsible pre-list body */
|
||||
.pre-list-body {
|
||||
|
|
@ -713,15 +713,15 @@ function formatRejectionDate(job: PipelineJob): string {
|
|||
border-top: 1px solid transparent;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.pre-signal-banner[data-color="amber"] { background: rgba(245,158,11,0.08); border-top-color: rgba(245,158,11,0.4); }
|
||||
.pre-signal-banner[data-color="green"] { background: rgba(39,174,96,0.08); border-top-color: rgba(39,174,96,0.4); }
|
||||
.pre-signal-banner[data-color="red"] { background: rgba(192,57,43,0.08); border-top-color: rgba(192,57,43,0.4); }
|
||||
.pre-signal-banner[data-color="amber"] { background: color-mix(in srgb, var(--color-warning) 8%, var(--color-surface)); border-top-color: color-mix(in srgb, var(--color-warning) 40%, transparent); }
|
||||
.pre-signal-banner[data-color="green"] { background: color-mix(in srgb, var(--color-success) 8%, var(--color-surface)); border-top-color: color-mix(in srgb, var(--color-success) 40%, transparent); }
|
||||
.pre-signal-banner[data-color="red"] { background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface)); border-top-color: color-mix(in srgb, var(--color-error) 40%, transparent); }
|
||||
|
||||
.signal-label { font-size: 0.82em; }
|
||||
.signal-subject { font-size: 0.78em; color: var(--color-text-muted); }
|
||||
.signal-actions { display: flex; gap: 6px; align-items: center; }
|
||||
.btn-signal-move {
|
||||
background: var(--color-primary); color: #fff;
|
||||
background: var(--color-primary); color: var(--color-text-inverse);
|
||||
border: none; border-radius: 4px; padding: 2px 8px; font-size: 0.78em; cursor: pointer;
|
||||
}
|
||||
.btn-signal-dismiss {
|
||||
|
|
@ -767,7 +767,7 @@ function formatRejectionDate(job: PipelineJob): string {
|
|||
background: var(--color-hover);
|
||||
}
|
||||
.btn-chip-active {
|
||||
background: var(--color-primary-muted, #e8f0ff);
|
||||
background: var(--app-primary-light);
|
||||
color: var(--color-primary); border-color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -496,7 +496,7 @@ onUnmounted(() => {
|
|||
|
||||
.tab-badge {
|
||||
background: var(--color-warning);
|
||||
color: white;
|
||||
color: var(--app-accent-text);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
border-radius: 999px;
|
||||
|
|
|
|||
|
|
@ -34,35 +34,8 @@
|
|||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Action bar -->
|
||||
<div class="action-bar" role="toolbar" aria-label="Message actions">
|
||||
<button class="btn btn--ghost" @click="openLogModal('call_note')">Log call</button>
|
||||
<button class="btn btn--ghost" @click="openLogModal('in_person')">Log note</button>
|
||||
<button class="btn btn--ghost" @click="openTemplateModal('apply')">Use template</button>
|
||||
<button
|
||||
class="btn btn--primary"
|
||||
:disabled="store.loading"
|
||||
@click="requestDraft"
|
||||
>
|
||||
{{ store.loading ? 'Drafting…' : 'Draft reply with LLM' }}
|
||||
</button>
|
||||
|
||||
<!-- Osprey (Phase 2 stub) — aria-disabled, never hidden -->
|
||||
<button
|
||||
class="btn btn--osprey"
|
||||
aria-disabled="true"
|
||||
:title="ospreyTitle"
|
||||
@mouseenter="handleOspreyHover"
|
||||
@focus="handleOspreyHover"
|
||||
>
|
||||
📞 Call via Osprey
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Draft pending announcement (screen reader) -->
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
||||
{{ draftAnnouncement }}
|
||||
</div>
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">{{ draftAnnouncement }}</div>
|
||||
|
||||
<!-- Error banner -->
|
||||
<p v-if="store.error" class="thread-error" role="alert">{{ store.error }}</p>
|
||||
|
|
@ -76,9 +49,15 @@
|
|||
v-for="item in timeline"
|
||||
:key="item._key"
|
||||
class="timeline__item"
|
||||
:class="[`timeline__item--${item.type}`, item.approved_at === null && item.type === 'draft' ? 'timeline__item--draft-pending' : '']"
|
||||
:class="[
|
||||
`timeline__item--${item.type}`,
|
||||
item.approved_at === null && item.type === 'draft' ? 'timeline__item--draft-pending' : '',
|
||||
item.type !== 'draft' ? 'timeline__item--expandable' : '',
|
||||
expandedKeys.has(item._key) ? 'timeline__item--open' : '',
|
||||
]"
|
||||
role="listitem"
|
||||
:aria-label="`${typeLabel(item.type)}, ${item.direction || ''}, ${item.logged_at}`"
|
||||
@click="item.type !== 'draft' && toggleExpand(item)"
|
||||
>
|
||||
<span class="timeline__icon" aria-hidden="true">{{ typeIcon(item.type) }}</span>
|
||||
<div class="timeline__content">
|
||||
|
|
@ -89,19 +68,29 @@
|
|||
<span
|
||||
v-if="item.type === 'draft' && item.approved_at === null"
|
||||
class="timeline__badge timeline__badge--pending"
|
||||
>
|
||||
Pending approval
|
||||
</span>
|
||||
>Pending approval</span>
|
||||
<span
|
||||
v-if="item.type === 'draft' && item.approved_at !== null"
|
||||
class="timeline__badge timeline__badge--approved"
|
||||
>
|
||||
Approved
|
||||
>Approved</span>
|
||||
<span v-if="item.type !== 'draft'" class="timeline__expand-hint" aria-hidden="true">
|
||||
{{ expandedKeys.has(item._key) ? '▲' : '▼' }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="item.subject" class="timeline__subject">{{ item.subject }}</p>
|
||||
|
||||
<!-- Draft body is editable before approval -->
|
||||
<!-- Expandable body for non-draft items -->
|
||||
<template v-if="item.type !== 'draft' && expandedKeys.has(item._key)">
|
||||
<div class="timeline__body-wrap" @click.stop>
|
||||
<div v-if="bodyCache[item.id] === null" class="timeline__body-loading">
|
||||
Loading…
|
||||
</div>
|
||||
<pre v-else-if="bodyCache[item.id]" class="timeline__body">{{ bodyCache[item.id] }}</pre>
|
||||
<p v-else class="timeline__body-empty">No body content.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Draft: editable textarea + actions -->
|
||||
<template v-if="item.type === 'draft' && item.approved_at === null">
|
||||
<textarea
|
||||
:ref="el => setDraftRef(item.id, el)"
|
||||
|
|
@ -119,25 +108,50 @@
|
|||
v-if="item.to_addr"
|
||||
:href="`mailto:${item.to_addr}?subject=${encodeURIComponent(item.subject ?? '')}&body=${encodeURIComponent(item.body ?? '')}`"
|
||||
class="btn btn--ghost btn--sm"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Open in email client
|
||||
</a>
|
||||
target="_blank" rel="noopener"
|
||||
>Open in email client</a>
|
||||
<button class="btn btn--ghost btn--sm btn--danger" @click="confirmDelete(item.id)">
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="timeline__body">{{ item.body }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="timeline.length === 0" class="timeline__empty">
|
||||
No messages logged yet for this job.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Compose bar (sticky footer) -->
|
||||
<div class="compose-bar" role="toolbar" aria-label="Compose actions">
|
||||
<div v-if="composing" class="compose-bar__actions">
|
||||
<button class="btn btn--ghost btn--sm" @click="triggerAction(() => openLogModal('call_note'))">Log call</button>
|
||||
<button class="btn btn--ghost btn--sm" @click="triggerAction(() => openLogModal('in_person'))">Log note</button>
|
||||
<button class="btn btn--ghost btn--sm" @click="triggerAction(() => openTemplateModal('apply'))">Use template</button>
|
||||
<button
|
||||
class="btn btn--primary btn--sm"
|
||||
:disabled="store.loading"
|
||||
@click="triggerAction(requestDraft)"
|
||||
>
|
||||
<span v-if="store.loading" class="btn__spinner" aria-hidden="true"></span>
|
||||
{{ store.loading ? 'Drafting…' : 'Draft reply with LLM' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--osprey btn--sm"
|
||||
aria-disabled="true"
|
||||
:title="ospreyTitle"
|
||||
@mouseenter="handleOspreyHover"
|
||||
@focus="handleOspreyHover"
|
||||
>📞 Call via Osprey</button>
|
||||
</div>
|
||||
<button
|
||||
class="btn compose-bar__toggle"
|
||||
:class="composing ? 'btn--ghost' : 'btn--primary'"
|
||||
@click="composing = !composing"
|
||||
:aria-expanded="composing"
|
||||
aria-controls="compose-actions"
|
||||
>{{ composing ? '✕ Close' : '+ New' }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
|
|
@ -230,8 +244,8 @@ const jobContacts = ref<JobContact[]>([])
|
|||
|
||||
watch(selectedJobId, async (id) => {
|
||||
if (id === null) { jobContacts.value = []; return }
|
||||
const { data } = await useApiFetch<JobContact[]>(`/api/contacts?job_id=${id}`)
|
||||
jobContacts.value = data ?? []
|
||||
const { data } = await useApiFetch<{ total: number; contacts: JobContact[] }>(`/api/contacts?job_id=${id}`)
|
||||
jobContacts.value = data?.contacts ?? []
|
||||
})
|
||||
|
||||
const timeline = computed<TimelineItem[]>(() => {
|
||||
|
|
@ -262,6 +276,31 @@ const timeline = computed<TimelineItem[]>(() => {
|
|||
)
|
||||
})
|
||||
|
||||
// ── Body expansion ────────────────────────────────────────────────────────
|
||||
const expandedKeys = ref(new Set<string>())
|
||||
const bodyCache = ref<Record<number, string | null>>({}) // null = still loading
|
||||
|
||||
async function toggleExpand(item: TimelineItem) {
|
||||
const key = item._key
|
||||
const next = new Set(expandedKeys.value)
|
||||
if (next.has(key)) { next.delete(key); expandedKeys.value = next; return }
|
||||
next.add(key)
|
||||
expandedKeys.value = next
|
||||
if (key.startsWith('jc-') && !(item.id in bodyCache.value)) {
|
||||
bodyCache.value = { ...bodyCache.value, [item.id]: null }
|
||||
const { data } = await useApiFetch<{ body: string | null }>(`/api/contacts/${item.id}`)
|
||||
const raw = data?.body ?? ''
|
||||
const text = raw.trimStart().startsWith('<')
|
||||
? (new DOMParser().parseFromString(raw, 'text/html').body.textContent ?? '').trim()
|
||||
: raw.trim()
|
||||
bodyCache.value = { ...bodyCache.value, [item.id]: text }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Compose bar ────────────────────────────────────────────────────────────
|
||||
const composing = ref(false)
|
||||
function triggerAction(fn: () => void) { composing.value = false; fn() }
|
||||
|
||||
// ── Draft body edits (local, before approve) ──────────────────────────────
|
||||
|
||||
const draftBodyEdits = ref<Record<number, string>>({})
|
||||
|
|
@ -415,8 +454,15 @@ onUnmounted(() => {
|
|||
<style scoped>
|
||||
.messaging-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.messaging-layout {
|
||||
height: calc(100dvh - 56px - env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Left panel ─────────────────────── */
|
||||
|
|
@ -465,11 +511,6 @@ onUnmounted(() => {
|
|||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.action-bar {
|
||||
display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
.btn--osprey {
|
||||
opacity: 0.5; cursor: not-allowed;
|
||||
background: none; border: 1px dashed var(--color-border);
|
||||
|
|
@ -477,6 +518,21 @@ onUnmounted(() => {
|
|||
color: var(--color-text-muted); font-size: var(--text-sm);
|
||||
padding: var(--space-2) var(--space-3); min-height: 36px;
|
||||
}
|
||||
|
||||
/* Compose bar */
|
||||
.compose-bar {
|
||||
flex-shrink: 0;
|
||||
display: flex; flex-direction: column; align-items: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.compose-bar__actions {
|
||||
display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center;
|
||||
width: 100%; justify-content: flex-start;
|
||||
}
|
||||
.compose-bar__toggle { align-self: flex-end; min-width: 90px; justify-content: center; }
|
||||
.thread-error {
|
||||
margin: var(--space-2) var(--space-4);
|
||||
color: var(--app-accent); font-size: var(--text-sm);
|
||||
|
|
@ -507,10 +563,27 @@ onUnmounted(() => {
|
|||
font-size: var(--text-xs); font-weight: 700;
|
||||
padding: 1px 6px; border-radius: var(--radius-full);
|
||||
}
|
||||
.timeline__badge--pending { background: #fef3c7; color: #d97706; }
|
||||
.timeline__badge--approved { background: #d1fae5; color: #065f46; }
|
||||
.timeline__badge--pending { background: var(--color-accent-light); color: var(--color-accent); }
|
||||
.timeline__badge--approved { background: var(--color-primary-light); color: var(--color-primary); }
|
||||
.timeline__subject { font-size: var(--text-sm); font-weight: 500; margin: 0; }
|
||||
.timeline__body { font-size: var(--text-sm); white-space: pre-wrap; margin: 0; color: var(--color-text); }
|
||||
.timeline__expand-hint {
|
||||
font-size: var(--text-xs); color: var(--color-text-muted); margin-left: auto;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
.timeline__item--expandable { cursor: pointer; }
|
||||
.timeline__item--expandable:hover { border-color: var(--app-primary); }
|
||||
.timeline__body-wrap {
|
||||
margin-top: var(--space-2);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
.timeline__body {
|
||||
font-size: var(--text-sm); white-space: pre-wrap; margin: 0;
|
||||
color: var(--color-text); max-height: 280px; overflow-y: auto;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
.timeline__body-loading { font-size: var(--text-xs); color: var(--color-text-muted); }
|
||||
.timeline__body-empty { font-size: var(--text-xs); color: var(--color-text-muted); margin: 0; }
|
||||
.timeline__draft-body {
|
||||
width: 100%; font-size: var(--text-sm); font-family: var(--font-body);
|
||||
padding: var(--space-2); border: 1px solid var(--color-border);
|
||||
|
|
@ -522,20 +595,45 @@ onUnmounted(() => {
|
|||
.timeline__empty { color: var(--color-text-muted); font-size: var(--text-sm); padding: var(--space-2); }
|
||||
|
||||
/* Buttons */
|
||||
.btn { padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); font-size: var(--text-sm); font-weight: 500; cursor: pointer; min-height: 36px; }
|
||||
.btn {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
min-height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: background 120ms ease, border-color 120ms ease, opacity 120ms ease, transform 80ms ease;
|
||||
}
|
||||
.btn:active:not(:disabled) { transform: translateY(1px); }
|
||||
.btn:focus-visible { outline: 2px solid var(--app-primary); outline-offset: 2px; }
|
||||
.btn--sm { padding: var(--space-1) var(--space-3); min-height: 30px; font-size: var(--text-xs); }
|
||||
.btn--primary { background: var(--app-primary); color: var(--color-surface); border: none; }
|
||||
.btn--primary:hover:not(:disabled) { opacity: 0.9; }
|
||||
.btn--primary:hover:not(:disabled) { opacity: 0.88; }
|
||||
.btn--primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn--ghost { background: none; border: 1px solid var(--color-border); color: var(--color-text); }
|
||||
.btn--ghost:hover { background: var(--color-surface-alt); }
|
||||
.btn--danger { background: var(--app-accent); color: white; border: none; }
|
||||
.btn--danger:hover { opacity: 0.9; }
|
||||
.btn--ghost:hover:not(:disabled) { background: var(--color-surface-alt); border-color: var(--app-primary); color: var(--app-primary); }
|
||||
.btn--danger { background: var(--app-accent); color: var(--app-accent-text); border: none; }
|
||||
.btn--danger:hover:not(:disabled) { opacity: 0.88; }
|
||||
|
||||
/* Spinner inside buttons */
|
||||
.btn__spinner {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border: 2px solid rgba(255,255,255,0.35);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: btn-spin 0.65s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes btn-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Modals (delete confirm) */
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
background: var(--color-overlay);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -442,7 +442,7 @@ onMounted(fetchRefs)
|
|||
.tag-chip--technical { background: var(--app-primary-light); color: var(--app-primary); }
|
||||
.tag-chip--managerial { background: rgba(39, 174, 96, 0.12); color: var(--color-success); }
|
||||
.tag-chip--character { background: rgba(212, 137, 26, 0.12); color: var(--score-mid); }
|
||||
.tag-chip--academic { background: rgba(103, 58, 183, 0.12); color: #7c3aed; }
|
||||
.tag-chip--academic { background: color-mix(in srgb, var(--status-synced) 12%, var(--color-surface)); color: var(--status-synced); }
|
||||
|
||||
.ref-card__actions {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -332,7 +332,7 @@ onBeforeRouteLeave(() => {
|
|||
|
||||
.rv__item-star { color: var(--color-warning, #f59e0b); font-size: 1rem; flex-shrink: 0; margin-top: 2px; }
|
||||
.rv__item-info { display: flex; flex-direction: column; gap: 2px; }
|
||||
.rv__item-name { font-weight: 500; font-size: var(--font-sm, 0.875rem); }
|
||||
.rv__item-name { font-weight: 500; font-size: var(--text-sm); }
|
||||
.rv__item-meta { font-size: var(--font-xs, 0.75rem); color: var(--color-text-muted, #64748b); }
|
||||
.rv__item-source { font-size: var(--font-xs, 0.75rem); color: var(--color-accent, #6366f1); }
|
||||
|
||||
|
|
@ -340,7 +340,7 @@ onBeforeRouteLeave(() => {
|
|||
.rv__preview-header { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3, 0.75rem); }
|
||||
.rv__preview-meta { display: flex; align-items: center; gap: var(--space-2, 0.5rem); flex-wrap: wrap; }
|
||||
.rv__preview-name { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; }
|
||||
.rv__preview-words { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #64748b); }
|
||||
.rv__preview-words { font-size: var(--text-sm); color: var(--color-text-muted, #64748b); }
|
||||
.rv__default-badge {
|
||||
font-size: var(--font-xs, 0.75rem); font-weight: 600;
|
||||
background: var(--color-success, #16a34a); color: #fff;
|
||||
|
|
@ -352,7 +352,7 @@ onBeforeRouteLeave(() => {
|
|||
border: 1px solid var(--color-error, #dc2626);
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||
cursor: pointer; font-size: var(--font-sm, 0.875rem);
|
||||
cursor: pointer; font-size: var(--text-sm);
|
||||
}
|
||||
.rv__delete-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
|
|
@ -364,13 +364,13 @@ onBeforeRouteLeave(() => {
|
|||
.rv__textarea {
|
||||
flex: 1; min-height: 400px; padding: var(--space-3, 0.75rem);
|
||||
border: 1px solid var(--color-border, #e2e8f0); border-radius: var(--radius-md, 0.5rem);
|
||||
font-family: monospace; font-size: var(--font-sm, 0.875rem); resize: vertical;
|
||||
font-family: monospace; font-size: var(--text-sm); resize: vertical;
|
||||
background: var(--color-surface-alt, #f8fafc);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.rv__textarea:not([readonly]) { background: var(--color-surface); }
|
||||
.rv__edit-actions { display: flex; gap: var(--space-2, 0.5rem); }
|
||||
.rv__error { color: var(--color-error, #dc2626); font-size: var(--font-sm, 0.875rem); }
|
||||
.rv__error { color: var(--color-error, #dc2626); font-size: var(--text-sm); }
|
||||
|
||||
.rv__download-menu { position: relative; }
|
||||
.rv__download-dropdown {
|
||||
|
|
@ -382,11 +382,11 @@ onBeforeRouteLeave(() => {
|
|||
.rv__download-dropdown button {
|
||||
width: 100%; text-align: left; background: none; border: none;
|
||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||
cursor: pointer; font-size: var(--font-sm, 0.875rem); border-radius: var(--radius-sm, 0.25rem);
|
||||
cursor: pointer; font-size: var(--text-sm); border-radius: var(--radius-sm, 0.25rem);
|
||||
}
|
||||
.rv__download-dropdown button:hover { background: var(--color-surface-alt, #f8fafc); }
|
||||
|
||||
.rv__loading, .rv__empty { color: var(--color-text-muted, #64748b); font-size: var(--font-sm, 0.875rem); }
|
||||
.rv__loading, .rv__empty { color: var(--color-text-muted, #64748b); font-size: var(--text-sm); }
|
||||
|
||||
/* Button styles — defined locally since no global button sheet exists yet */
|
||||
.btn-secondary {
|
||||
|
|
@ -396,7 +396,7 @@ onBeforeRouteLeave(() => {
|
|||
border-radius: var(--radius-md, 0.5rem);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
|
|
@ -412,7 +412,7 @@ onBeforeRouteLeave(() => {
|
|||
border: none;
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ function back() { router.push('/setup/resume') }
|
|||
<style scoped>
|
||||
.wizard-step { display: flex; flex-direction: column; gap: var(--space-5, 1.25rem); }
|
||||
.step-title { font-family: var(--font-display); font-size: 1.25rem; font-weight: 700; display: flex; align-items: center; gap: var(--space-2, 0.5rem); }
|
||||
.optional-badge { font-family: var(--font-sans); font-size: 0.75rem; font-weight: 500; background: var(--color-surface-alt); color: var(--color-text-muted); padding: 2px 8px; border-radius: var(--radius-full, 9999px); }
|
||||
.optional-badge { font-family: var(--font-body); font-size: 0.75rem; font-weight: 500; background: var(--color-surface-alt); color: var(--color-text-muted); padding: 2px 8px; border-radius: var(--radius-full, 9999px); }
|
||||
.step-body { font-size: 0.9rem; color: var(--color-text); line-height: 1.6; }
|
||||
.step-body-note { font-size: 0.85rem; color: var(--color-text-muted); line-height: 1.5; margin-top: calc(-1 * var(--space-3, 0.75rem)); }
|
||||
.opt-in-label { display: flex; align-items: flex-start; gap: var(--space-2, 0.5rem); font-size: 0.9rem; cursor: pointer; }
|
||||
|
|
|
|||