fix: search prefs wizard data loss, resume sync link, docs + GUI help links

Bug fixes (filed as #125–#128):
- Wizard step 7 read data.titles instead of data.search.titles — user-entered
  job titles and locations were silently dropped on every wizard run (#125)
- GET /api/settings/search returned "titles" key but store expected "job_titles" —
  Settings → Search Prefs always showed empty even when data existed (#126)
- remote_only preference not persisted during wizard setup (#127)
- apply-to-profile didn't set default_resume_id in user.yaml, so future
  Resume Profile saves never synced back to the library entry (#128)

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

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

GUI help links:
- web/src/composables/useDocsUrl.ts — shared docs base URL composable
- Home: "Daily Workflow guide ↗" link in subtitle
- Job Review: "? Docs" link in title row
- Resume Library: "? Help" link in header
- Settings → Resume Profile: "? Help" link in page header
- Settings → Search Prefs: "? Help" link in page header
This commit is contained in:
pyr0ball 2026-06-15 16:52:56 -07:00
parent f799aff4e0
commit 82c26074d8
26 changed files with 1301 additions and 249 deletions

View file

@ -2,10 +2,10 @@
# Auto-generated by the setup wizard, or fill in manually. # Auto-generated by the setup wizard, or fill in manually.
# NEVER commit .env to git. # NEVER commit .env to git.
STREAMLIT_PORT=8502 VUE_PORT=8506
OLLAMA_PORT=11434 OLLAMA_PORT=11434
VLLM_PORT=8000 VLLM_PORT=8000
CF_TEXT_PORT=8006 CF_TEXT_PORT=8008
SEARXNG_PORT=8888 SEARXNG_PORT=8888
VISION_PORT=8002 VISION_PORT=8002
VISION_MODEL=vikhyatk/moondream2 VISION_MODEL=vikhyatk/moondream2

1
.gitignore vendored
View file

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

View file

@ -70,7 +70,7 @@ cd peregrine
./manage.sh start ./manage.sh start
``` ```
Open **http://localhost:8502** — the setup wizard walks you through the rest. Open **http://localhost:8506** — the setup wizard walks you through the rest.
> **macOS / Apple Silicon:** install Ollama natively via Homebrew before starting for Metal GPU-accelerated inference. `install.sh` handles this automatically. > **macOS / Apple Silicon:** install Ollama natively via Homebrew before starting for Metal GPU-accelerated inference. `install.sh` handles this automatically.
> **Windows:** use WSL2 with Ubuntu. > **Windows:** use WSL2 with Ubuntu.
@ -78,10 +78,11 @@ Open **http://localhost:8502** — the setup wizard walks you through the rest.
### Inference profiles ### Inference profiles
```bash ```bash
./manage.sh start # remote — no GPU; LLM calls go to Anthropic / OpenAI ./manage.sh start # cpu — local Ollama on CPU (recommended default)
./manage.sh start --profile cpu # local Ollama on CPU (or Metal via native Ollama on macOS)
./manage.sh start --profile single-gpu # Ollama + vision on GPU 0 (NVIDIA only) ./manage.sh start --profile single-gpu # Ollama + vision on GPU 0 (NVIDIA only)
./manage.sh start --profile dual-gpu # Ollama + vLLM on two NVIDIA GPUs ./manage.sh start --profile dual-gpu # Ollama + vLLM on two NVIDIA GPUs
./manage.sh start --profile cf-orch # no local LLM — route to CircuitForge GPU cluster
./manage.sh start --profile remote # no local LLM — use cloud API keys
``` ```
--- ---
@ -109,7 +110,7 @@ Open **http://localhost:8502** — the setup wizard walks you through the rest.
| **Voice guidelines** (custom writing style and tone) | Premium with LLM ¹ | | **Voice guidelines** (custom writing style and tone) | Premium with LLM ¹ |
| Cover letter model fine-tuning — your writing, your model | Premium | | Cover letter model fine-tuning — your writing, your model | Premium |
| Multi-user support | Premium | | Multi-user support | Premium |
| Human-in-the-loop operator (CAPTCHAs, phone calls, wet signatures) | Ultra | | Human-in-the-loop operator (CAPTCHAs, phone calls, wet signatures) | Premium |
¹ **BYOK (bring your own key) unlock:** configure any LLM backend — a local [Ollama](https://ollama.com) or vLLM instance, or your own API key (Anthropic, OpenAI-compatible) — and all "Free with LLM" and "Premium with LLM" features unlock at no charge. ¹ **BYOK (bring your own key) unlock:** configure any LLM backend — a local [Ollama](https://ollama.com) or vLLM instance, or your own API key (Anthropic, OpenAI-compatible) — and all "Free with LLM" and "Premium with LLM" features unlock at no charge.

View file

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

View file

@ -116,12 +116,35 @@ def _load_demo_seed(db_path: str, seed_file: str) -> None:
con.close() con.close()
def _load_data_env() -> None:
"""Load API keys written by the wizard into the running process.
The wizard saves keys to <data_dir>/.env (next to staging.db). The main
_load_env() call targets the image-baked /app/.env, which is a different
path. This helper bridges the gap by force-overriding env vars that are
unset or empty (compose injects empty strings for optional vars).
"""
data_env = Path(DB_PATH).parent / ".env"
if not data_env.exists():
return
for line in data_env.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key, value = key.strip(), value.strip()
if value and not os.environ.get(key):
os.environ[key] = value
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Load .env, run migrations, and (in demo mode) seed the demo DB.""" """Load .env, run migrations, and (in demo mode) seed the demo DB."""
# Load .env before any runtime env reads — safe because lifespan doesn't run # Load .env before any runtime env reads — safe because lifespan doesn't run
# when dev_api is imported by tests (only when uvicorn actually starts). # when dev_api is imported by tests (only when uvicorn actually starts).
_load_env(PEREGRINE_ROOT / ".env") _load_env(PEREGRINE_ROOT / ".env")
# Also load wizard-saved keys from the data directory (overrides empty compose vars).
_load_data_env()
from scripts.db_migrate import migrate_db from scripts.db_migrate import migrate_db
migrate_db(Path(DB_PATH)) migrate_db(Path(DB_PATH))
@ -1166,9 +1189,17 @@ def apply_resume_to_profile(resume_id: int):
with open(resume_path, "w", encoding="utf-8") as f: with open(resume_path, "w", encoding="utf-8") as f:
yaml.dump(current_profile, f, allow_unicode=True, default_flow_style=False) yaml.dump(current_profile, f, allow_unicode=True, default_flow_style=False)
from scripts.db import update_resume_synced_at as _mark_synced from scripts.db import update_resume_synced_at as _mark_synced, set_default_resume as _set_default
_mark_synced(db_path, resume_id) _mark_synced(db_path, resume_id)
# Establish this entry as the default so future Profile saves sync back to it
_set_default(db_path, resume_id)
_user_yaml = db_path.parent / "config" / "user.yaml"
if _user_yaml.exists():
_prof = yaml.safe_load(_user_yaml.read_text(encoding="utf-8")) or {}
_prof["default_resume_id"] = resume_id
_user_yaml.write_text(yaml.dump(_prof, default_flow_style=False, allow_unicode=True))
return { return {
"ok": True, "ok": True,
"backup_id": backup["id"], "backup_id": backup["id"],
@ -3250,7 +3281,23 @@ async def upload_resume(file: UploadFile):
resume_path.parent.mkdir(parents=True, exist_ok=True) resume_path.parent.mkdir(parents=True, exist_ok=True)
with open(resume_path, "w") as f: with open(resume_path, "w") as f:
yaml.dump(result, f, allow_unicode=True, default_flow_style=False) yaml.dump(result, f, allow_unicode=True, default_flow_style=False)
# Also add to resume library and mark as default
import json as _json
from scripts.db import create_resume as _create_r, set_default_resume as _set_default
db_path = Path(_request_db.get() or DB_PATH)
resume_name = Path(file.filename).stem or "Uploaded Resume"
library_entry = _create_r(
db_path,
name=resume_name,
text=raw_text,
source="upload",
struct_json=_json.dumps(result),
)
_set_default(db_path, library_entry["id"])
result["exists"] = True result["exists"] = True
result["library_id"] = library_entry["id"]
return {"ok": True, "data": result} return {"ok": True, "data": result}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@ -3313,6 +3360,10 @@ def get_search_prefs():
for b in boards for b in boards
] ]
# Normalize title key — wizard saved "titles", settings canonical is "job_titles"
if "titles" in profile and "job_titles" not in profile:
profile["job_titles"] = profile.pop("titles")
return profile return profile
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@ -3817,6 +3868,26 @@ def save_deploy_config(payload: dict):
return {"ok": True, "note": "Restart required to apply changes"} return {"ok": True, "note": "Restart required to apply changes"}
class OrchUrlPayload(BaseModel):
orch_url: str = ""
@app.get("/api/settings/system/orch-url")
def get_orch_url():
"""Return the saved Orchard coordinator URL."""
cfg = _load_wizard_yaml()
return {"orch_url": cfg.get("cf_orch_url", "")}
@app.post("/api/settings/system/orch-url")
def save_orch_url(payload: OrchUrlPayload):
"""Persist the Orchard coordinator URL to user.yaml."""
cfg = _load_wizard_yaml()
cfg["cf_orch_url"] = payload.orch_url.strip()
_save_wizard_yaml(cfg)
return {"ok": True}
# ── Settings: Fine-Tune ─────────────────────────────────────────────────────── # ── Settings: Fine-Tune ───────────────────────────────────────────────────────
_TRAINING_JSONL = Path("/Library/Documents/JobSearch/training_data/cover_letters.jsonl") _TRAINING_JSONL = Path("/Library/Documents/JobSearch/training_data/cover_letters.jsonl")
@ -4350,6 +4421,7 @@ def wizard_status():
"linkedin": cfg.get("linkedin", ""), "linkedin": cfg.get("linkedin", ""),
"career_summary": cfg.get("career_summary", ""), "career_summary": cfg.get("career_summary", ""),
"services": cfg.get("services", {}), "services": cfg.get("services", {}),
"cf_orch_url": cfg.get("cf_orch_url", ""),
}, },
} }
@ -4371,8 +4443,8 @@ def wizard_save_step(payload: WizardStepPayload):
step = payload.step step = payload.step
data = payload.data data = payload.data
if step < 1 or step > 7: if step < 1 or step > 8:
raise HTTPException(status_code=400, detail="step must be 17") raise HTTPException(status_code=400, detail="step must be 18")
updates: dict = {"wizard_step": step} updates: dict = {"wizard_step": step}
@ -4398,13 +4470,16 @@ def wizard_save_step(payload: WizardStepPayload):
with open(resume_path, "w") as f: with open(resume_path, "w") as f:
yaml.dump(resume, f, allow_unicode=True, default_flow_style=False) yaml.dump(resume, f, allow_unicode=True, default_flow_style=False)
elif step == 4: elif step in (4, 5):
# Step 4 (legacy) or step 5 (current) — identity fields.
# Step 4 was the original numbering before the training step was inserted
# between resume and identity; both are accepted for backward compat.
for field in ("name", "email", "phone", "linkedin", "career_summary"): for field in ("name", "email", "phone", "linkedin", "career_summary"):
if field in data: if field in data:
updates[field] = data[field] updates[field] = data[field]
elif step == 5: elif step == 6:
# Write API keys to .env (never store in user.yaml) # Step 6 — inference: API keys + optional Orchard coordinator URL.
env_path = Path(_wizard_yaml_path()).parent.parent / ".env" env_path = Path(_wizard_yaml_path()).parent.parent / ".env"
env_lines = env_path.read_text().splitlines() if env_path.exists() else [] env_lines = env_path.read_text().splitlines() if env_path.exists() else []
@ -4422,18 +4497,24 @@ def wizard_save_step(payload: WizardStepPayload):
env_lines = _set_env_key(env_lines, "OPENAI_COMPAT_URL", data["openai_url"]) env_lines = _set_env_key(env_lines, "OPENAI_COMPAT_URL", data["openai_url"])
if data.get("openai_key"): if data.get("openai_key"):
env_lines = _set_env_key(env_lines, "OPENAI_COMPAT_KEY", data["openai_key"]) env_lines = _set_env_key(env_lines, "OPENAI_COMPAT_KEY", data["openai_key"])
if any(data.get(k) for k in ("anthropic_key", "openai_url", "openai_key")): if data.get("orch_url"):
env_lines = _set_env_key(env_lines, "GPU_SERVER_URL", data["orch_url"])
updates["cf_orch_url"] = data["orch_url"]
if any(data.get(k) for k in ("anthropic_key", "openai_url", "openai_key", "orch_url")):
env_path.parent.mkdir(parents=True, exist_ok=True) env_path.parent.mkdir(parents=True, exist_ok=True)
env_path.write_text("\n".join(env_lines) + "\n") env_path.write_text("\n".join(env_lines) + "\n")
if "services" in data: if "services" in data:
updates["services"] = data["services"] updates["services"] = data["services"]
elif step == 6: elif step == 7:
# Persist search preferences to search_profiles.yaml in canonical format: # Step 7 — search preferences.
# profiles: [{name, titles, locations, boards, ...}] # Wizard sends { search: { titles, locations, remote_only } }; fall back to
titles = data.get("titles", []) # top-level keys for direct API callers that omit the "search" wrapper.
locations = data.get("locations", []) search = data.get("search", {})
titles = search.get("titles", data.get("titles", data.get("job_titles", [])))
locations = search.get("locations", data.get("locations", []))
remote_only = search.get("remote_only", data.get("remote_only", False))
search_path = _search_prefs_path() search_path = _search_prefs_path()
existing_search: dict = {} existing_search: dict = {}
if search_path.exists(): if search_path.exists():
@ -4450,14 +4531,15 @@ def wizard_save_step(payload: WizardStepPayload):
if default_profile is None: if default_profile is None:
default_profile = {"name": "default"} default_profile = {"name": "default"}
profiles_list.append(default_profile) profiles_list.append(default_profile)
default_profile["titles"] = titles default_profile["job_titles"] = titles
default_profile["locations"] = locations default_profile["locations"] = locations
default_profile["remote_only"] = remote_only
existing_search["profiles"] = profiles_list existing_search["profiles"] = profiles_list
search_path.parent.mkdir(parents=True, exist_ok=True) search_path.parent.mkdir(parents=True, exist_ok=True)
with open(search_path, "w") as f: with open(search_path, "w") as f:
yaml.dump(existing_search, f, allow_unicode=True, default_flow_style=False) yaml.dump(existing_search, f, allow_unicode=True, default_flow_style=False)
# Step 7 (integrations) has no extra side effects here — connections are # Step 8 (integrations) has no extra side effects here — connections are
# handled by the existing /api/settings/system/integrations/{id}/connect. # handled by the existing /api/settings/system/integrations/{id}/connect.
try: try:
@ -4484,6 +4566,39 @@ def _fetch_cforch_nodes() -> list[dict]:
return [] return []
def _probe_ollama() -> bool:
"""Return True if Ollama is reachable from inside the container."""
candidates = [
"http://host.docker.internal:11434/api/tags",
"http://ollama:11434/api/tags",
]
for url in candidates:
try:
r = requests.get(url, timeout=2)
if r.status_code == 200:
return True
except Exception:
pass
return False
def _probe_searxng() -> bool:
"""Return True if SearXNG is reachable from inside the container."""
candidates = [
"http://searxng:8080/",
"http://host.docker.internal:8888/",
"http://host.docker.internal:8080/",
]
for url in candidates:
try:
r = requests.get(url, timeout=2)
if r.status_code < 500:
return True
except Exception:
pass
return False
@app.get("/api/wizard/hardware") @app.get("/api/wizard/hardware")
def wizard_hardware(): def wizard_hardware():
"""Detect local GPUs, suggest an inference profile, and report cf-orch nodes.""" """Detect local GPUs, suggest an inference profile, and report cf-orch nodes."""
@ -4502,35 +4617,71 @@ def wizard_hardware():
"vram_free_mb": gpu["vram_free_mb"], "vram_free_mb": gpu["vram_free_mb"],
}) })
ollama_running = _probe_ollama()
searxng_running = _probe_searxng()
# If no GPU but Ollama is already running, default to cpu rather than remote
if suggested == "cpu" and not gpus and not ollama_running:
suggested = "remote"
return { return {
"gpus": gpus, "gpus": gpus,
"suggested_profile": suggested, "suggested_profile": suggested,
"profiles": list(_WIZARD_PROFILES), "profiles": list(_WIZARD_PROFILES),
"cf_orch_available": len(orch_nodes) > 0, "cf_orch_available": len(orch_nodes) > 0,
"cf_orch_gpus": orch_summary, "cf_orch_gpus": orch_summary,
"ollama_running": ollama_running,
"searxng_running": searxng_running,
} }
def _container_safe_url(url: str) -> str:
"""Replace localhost/127.0.0.1 with host.docker.internal so tests reach the host."""
import re as _re
return _re.sub(r"(https?://)(?:localhost|127\.0\.0\.1)\b", r"\1host.docker.internal", url)
class WizardInferenceTestPayload(BaseModel): class WizardInferenceTestPayload(BaseModel):
profile: str = "remote" profile: str = "remote"
anthropic_key: str = "" anthropic_key: str = ""
openai_url: str = "" openai_url: str = ""
openai_key: str = "" openai_key: str = ""
orch_url: str = ""
ollama_host: str = "localhost" ollama_host: str = "localhost"
ollama_port: int = 11434 ollama_port: int = 11434
@app.post("/api/wizard/inference/test") @app.post("/api/wizard/inference/test")
def wizard_test_inference(payload: WizardInferenceTestPayload): def wizard_test_inference(payload: WizardInferenceTestPayload):
"""Test LLM or Ollama connectivity. """Test LLM, Ollama, or Orchard coordinator connectivity.
Always returns {ok, message} a connection failure is reported as a Always returns {ok, message} a connection failure is a soft warning so
soft warning (message), not an HTTP error, so the wizard can let the the wizard lets the user continue past a temporarily-unreachable service.
user continue past a temporarily-down Ollama instance.
""" """
if payload.profile == "remote": if payload.profile == "cf-orch":
orch_url = _container_safe_url(payload.orch_url.rstrip("/")) if payload.orch_url else ""
if not orch_url:
return {"ok": False, "message": "Enter the Orchard coordinator URL first."}
try:
resp = requests.get(f"{orch_url}/api/nodes", timeout=5,
headers={"Accept": "application/json"})
if resp.status_code == 200:
nodes = resp.json().get("nodes", [])
n = len(nodes)
return {"ok": True, "message": f"Orchard reachable — {n} node(s) online."}
return {"ok": False, "message": f"Orchard returned HTTP {resp.status_code}."}
except Exception as exc:
return {
"ok": False,
"message": (
f"Cannot reach Orchard at {payload.orch_url}"
"check the URL and that the coordinator is running. "
f"({exc})"
),
}
elif payload.profile == "remote":
try: try:
# Temporarily inject key if provided (don't persist yet)
env_override = {} env_override = {}
if payload.anthropic_key: if payload.anthropic_key:
env_override["ANTHROPIC_API_KEY"] = payload.anthropic_key env_override["ANTHROPIC_API_KEY"] = payload.anthropic_key
@ -4554,15 +4705,16 @@ def wizard_test_inference(payload: WizardInferenceTestPayload):
os.environ[k] = v os.environ[k] = v
except Exception as exc: except Exception as exc:
return {"ok": False, "message": f"LLM test failed: {exc}"} return {"ok": False, "message": f"LLM test failed: {exc}"}
else: else:
# Local profile — ping Ollama # Local profiles (cpu, single-gpu, dual-gpu) — ping Ollama
ollama_url = f"http://{payload.ollama_host}:{payload.ollama_port}" host = payload.ollama_host or "localhost"
ollama_url = _container_safe_url(f"http://{host}:{payload.ollama_port}")
try: try:
resp = requests.get(f"{ollama_url}/api/tags", timeout=5) resp = requests.get(f"{ollama_url}/api/tags", timeout=5)
ok = resp.status_code == 200 ok = resp.status_code == 200
message = "Ollama is running." if ok else f"Ollama returned HTTP {resp.status_code}." message = "Ollama is running." if ok else f"Ollama returned HTTP {resp.status_code}."
except Exception: except Exception:
# Soft-fail: user can skip and configure later
return { return {
"ok": False, "ok": False,
"message": ( "message": (

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -55,7 +55,20 @@ export const useAiInterviewStore = defineStore('aiInterview', () => {
}) })
loading.value = false loading.value = false
if (err || !data) { if (err || !data) {
if (err?.kind === 'http' && err.status === 402) {
error.value = 'AI profile assistant requires a Paid plan or a BYOK API key.'
} else if (err?.kind === 'http' && err.status === 503) {
try {
const body = JSON.parse(err.detail) as { detail?: { error?: string } }
error.value = body.detail?.error === 'llm_error'
? 'No LLM backend configured — add an API key in Settings → System first.'
: 'Could not reach the assistant. Please try again.'
} catch {
error.value = 'Could not reach the assistant. Please try again.' error.value = 'Could not reach the assistant. Please try again.'
}
} else {
error.value = 'Could not reach the assistant. Please try again.'
}
return return
} }
messages.value = [...messages.value, { role: 'assistant', content: data.reply }] messages.value = [...messages.value, { role: 'assistant', content: data.reply }]

View file

@ -12,7 +12,10 @@
{{ greeting }} {{ greeting }}
<span v-if="isMidnight" aria-label="Late night session">🌙</span> <span v-if="isMidnight" aria-label="Late night session">🌙</span>
</h1> </h1>
<p class="home__subtitle">Discover Review Apply</p> <p class="home__subtitle">
Discover Review Apply
<a href="https://docs.circuitforge.tech/peregrine/user-guide/daily-workflow/" target="_blank" rel="noopener" class="home__docs-link" aria-label="Daily Workflow documentation">Daily Workflow guide </a>
</p>
</div> </div>
</header> </header>
@ -600,7 +603,22 @@ onUnmounted(() => {
font-size: var(--text-sm); font-size: var(--text-sm);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
} }
.home__docs-link {
font-size: 0.7rem;
text-transform: none;
letter-spacing: 0;
color: var(--color-text-muted);
text-decoration: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
padding: 1px 7px;
}
.home__docs-link:hover { color: var(--color-primary); border-color: var(--color-primary); }
.home__metrics { .home__metrics {
display: grid; display: grid;

View file

@ -9,6 +9,7 @@
<header class="review__header"> <header class="review__header">
<div class="review__title-row"> <div class="review__title-row">
<h1 class="review__title">Review Jobs</h1> <h1 class="review__title">Review Jobs</h1>
<a href="https://docs.circuitforge.tech/peregrine/user-guide/job-review/" target="_blank" rel="noopener" class="review__docs-link" aria-label="Job Review documentation">? Docs</a>
<button class="help-btn" :aria-expanded="showHelp" @click="showHelp = !showHelp"> <button class="help-btn" :aria-expanded="showHelp" @click="showHelp = !showHelp">
<span aria-hidden="true">?</span> <span aria-hidden="true">?</span>
<span class="sr-only">Keyboard shortcuts</span> <span class="sr-only">Keyboard shortcuts</span>
@ -429,6 +430,17 @@ onUnmounted(() => {
flex: 1; flex: 1;
} }
.review__docs-link {
font-size: 0.75rem;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
padding: 2px 8px;
text-decoration: none;
white-space: nowrap;
flex-shrink: 0;
}
.review__docs-link:hover { color: var(--color-primary); border-color: var(--color-primary); }
.help-btn { .help-btn {
width: 32px; width: 32px;
height: 32px; height: 32px;

View file

@ -2,6 +2,7 @@
<div class="rv"> <div class="rv">
<div class="rv__header"> <div class="rv__header">
<h1 class="rv__title">Resume Library</h1> <h1 class="rv__title">Resume Library</h1>
<a href="https://docs.circuitforge.tech/peregrine/user-guide/daily-workflow/#managing-your-resume" target="_blank" rel="noopener" class="rv__help-link" aria-label="Resume Library documentation">? Help</a>
<label class="btn-generate rv__import-btn"> <label class="btn-generate rv__import-btn">
<span aria-hidden="true">📥</span> Import <span aria-hidden="true">📥</span> Import
<input type="file" accept=".txt,.pdf,.docx,.odt,.yaml,.yml" <input type="file" accept=".txt,.pdf,.docx,.odt,.yaml,.yml"
@ -314,7 +315,10 @@ onBeforeRouteLeave(() => {
<style scoped> <style scoped>
.rv { display: flex; flex-direction: column; gap: var(--space-4, 1rem); padding: var(--space-5, 1.25rem); height: 100%; } .rv { display: flex; flex-direction: column; gap: var(--space-4, 1rem); padding: var(--space-5, 1.25rem); height: 100%; }
.rv__header { display: flex; align-items: center; justify-content: space-between; } .rv__header { display: flex; align-items: center; gap: var(--space-3); }
.rv__header .btn-generate { margin-left: auto; }
.rv__help-link { font-size: 0.75rem; color: var(--color-text-muted); border: 1px solid var(--color-border); border-radius: var(--radius-full); padding: 2px 8px; text-decoration: none; white-space: nowrap; flex-shrink: 0; }
.rv__help-link:hover { color: var(--color-primary); border-color: var(--color-primary); }
.rv__title { font-size: var(--font-xl, 1.25rem); font-weight: 700; margin: 0; } .rv__title { font-size: var(--font-xl, 1.25rem); font-weight: 700; margin: 0; }
.rv__file-input { display: none; } .rv__file-input { display: none; }
.rv__import-btn { cursor: pointer; } .rv__import-btn { cursor: pointer; }

View file

@ -1,6 +1,9 @@
<template> <template>
<div class="resume-profile"> <div class="resume-profile">
<div class="page-header">
<h2>Resume Profile</h2> <h2>Resume Profile</h2>
<a href="https://docs.circuitforge.tech/peregrine/user-guide/settings/#resume-profile" target="_blank" rel="noopener" class="help-link" aria-label="Resume Profile documentation">? Help</a>
</div>
<!-- Load error banner --> <!-- Load error banner -->
<div v-if="loadError" class="error-banner"> <div v-if="loadError" class="error-banner">
@ -401,6 +404,10 @@ async function handleUpload() {
<style scoped> <style scoped>
.resume-profile { max-width: 720px; margin: 0 auto; padding: var(--space-4); } .resume-profile { max-width: 720px; margin: 0 auto; padding: var(--space-4); }
.page-header { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-6); }
.page-header h2 { margin-bottom: 0; }
.help-link { font-size: 0.75rem; color: var(--color-text-muted); border: 1px solid var(--color-border); border-radius: var(--radius-full); padding: 2px 8px; text-decoration: none; white-space: nowrap; flex-shrink: 0; }
.help-link:hover { color: var(--color-primary); border-color: var(--color-primary); }
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: var(--space-6); } h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: var(--space-6); }
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3); } h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3); }
.form-section { margin-bottom: var(--space-8); padding-bottom: var(--space-6); border-bottom: 1px solid var(--color-border); } .form-section { margin-bottom: var(--space-8); padding-bottom: var(--space-6); border-bottom: 1px solid var(--color-border); }

View file

@ -1,6 +1,9 @@
<template> <template>
<div class="search-prefs"> <div class="search-prefs">
<div class="page-header">
<h2>Search Preferences</h2> <h2>Search Preferences</h2>
<a :href="docsUrl" target="_blank" rel="noopener" class="help-link" aria-label="Search Preferences documentation">? Help</a>
</div>
<p v-if="store.loadError" class="error-banner">{{ store.loadError }}</p> <p v-if="store.loadError" class="error-banner">{{ store.loadError }}</p>
<!-- Remote Preference --> <!-- Remote Preference -->
@ -154,8 +157,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useSearchStore } from '../../stores/settings/search' import { useSearchStore } from '../../stores/settings/search'
import { useDocsUrl } from '../../composables/useDocsUrl'
const store = useSearchStore() const store = useSearchStore()
const docsUrl = useDocsUrl('user-guide/settings/#search-prefs')
const remoteOptions = [ const remoteOptions = [
{ value: 'remote' as const, label: 'Remote only' }, { value: 'remote' as const, label: 'Remote only' },
@ -186,6 +191,10 @@ onMounted(() => store.load())
<style scoped> <style scoped>
.search-prefs { max-width: 720px; margin: 0 auto; padding: var(--space-4); } .search-prefs { max-width: 720px; margin: 0 auto; padding: var(--space-4); }
.page-header { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-6); }
.page-header h2 { margin-bottom: 0; }
.help-link { font-size: 0.75rem; color: var(--color-text-muted); border: 1px solid var(--color-border); border-radius: var(--radius-full); padding: 2px 8px; text-decoration: none; white-space: nowrap; flex-shrink: 0; }
.help-link:hover { color: var(--color-primary); border-color: var(--color-primary); }
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: var(--space-6); } h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: var(--space-6); }
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3); } h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3); }
.form-section { margin-bottom: var(--space-8); padding-bottom: var(--space-6); border-bottom: 1px solid var(--color-border); } .form-section { margin-bottom: var(--space-8); padding-bottom: var(--space-6); border-bottom: 1px solid var(--color-border); }

View file

@ -136,6 +136,29 @@
<p v-if="store.deployError" class="error-msg">{{ store.deployError }}</p> <p v-if="store.deployError" class="error-msg">{{ store.deployError }}</p>
</section> </section>
<!-- Orchard coordinator -->
<section class="form-section">
<h3>Orchard Coordinator</h3>
<p class="section-note">
The Orchard is CircuitForge's distributed GPU cluster. Requires a Paid license or higher.
Leave blank to disable Orchard routing.
</p>
<div class="field-row">
<label>Coordinator URL</label>
<input
v-model="orchUrl"
type="url"
placeholder="https://orch.circuitforge.tech"
class="field-input-wide"
/>
<button @click="saveOrchUrl" :disabled="orchSaving" class="btn-save-inline">
{{ orchSaving ? 'Saving…' : 'Save' }}
</button>
</div>
<p v-if="orchError" class="error">{{ orchError }}</p>
<p v-if="orchSaved" class="success">Saved.</p>
</section>
<!-- BYOK Modal --> <!-- BYOK Modal -->
<Teleport to="body"> <Teleport to="body">
<div v-if="store.byokPending.length > 0" class="modal-overlay" @click.self="store.cancelByok()"> <div v-if="store.byokPending.length > 0" class="modal-overlay" @click.self="store.cancelByok()">
@ -250,12 +273,39 @@ async function saveCoverLetterModel() {
setTimeout(() => { clmSaved.value = false }, 3000) setTimeout(() => { clmSaved.value = false }, 3000)
} }
// Orchard coordinator URL
const orchUrl = ref('')
const orchSaving = ref(false)
const orchError = ref<string | null>(null)
const orchSaved = ref(false)
async function loadOrchUrl() {
const { data } = await useApiFetch<{ orch_url: string }>('/api/settings/system/orch-url')
if (data) orchUrl.value = data.orch_url ?? ''
}
async function saveOrchUrl() {
orchSaving.value = true
orchError.value = null
orchSaved.value = false
const { error } = await useApiFetch('/api/settings/system/orch-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orch_url: orchUrl.value }),
})
orchSaving.value = false
if (error) { orchError.value = 'Failed to save.'; return }
orchSaved.value = true
setTimeout(() => { orchSaved.value = false }, 3000)
}
onMounted(async () => { onMounted(async () => {
await store.loadLlm() await store.loadLlm()
const tasks = [ const tasks = [
store.loadServices(), store.loadServices(),
store.loadFilePaths(), store.loadFilePaths(),
store.loadDeployConfig(), store.loadDeployConfig(),
loadOrchUrl(),
] ]
if (config.isCloud && tierOrder.indexOf(tier.value) >= tierOrder.indexOf('paid')) { if (config.isCloud && tierOrder.indexOf(tier.value) >= tierOrder.indexOf('paid')) {
tasks.push(loadCoverLetterModel()) tasks.push(loadCoverLetterModel())
@ -328,6 +378,7 @@ h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3); }
.field-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; } .field-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; }
.field-row label { font-size: 0.82rem; color: var(--color-text-muted); } .field-row label { font-size: 0.82rem; color: var(--color-text-muted); }
.field-row input { background: var(--color-surface-alt); border: 1px solid var(--color-border); border-radius: 6px; color: var(--color-text); padding: 7px 10px; font-size: 0.88rem; } .field-row input { background: var(--color-surface-alt); border: 1px solid var(--color-border); border-radius: 6px; color: var(--color-text); padding: 7px 10px; font-size: 0.88rem; }
.field-input-wide { width: 100%; max-width: 400px; }
.field-hint { font-size: 0.72rem; color: var(--color-text-muted); margin-top: 3px; } .field-hint { font-size: 0.72rem; color: var(--color-text-muted); margin-top: 3px; }
.btn-secondary { padding: 9px 18px; background: transparent; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-text-muted); cursor: pointer; font-size: 0.88rem; } .btn-secondary { padding: 9px 18px; background: transparent; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-text-muted); cursor: pointer; font-size: 0.88rem; }
.btn-danger { .btn-danger {

View file

@ -13,9 +13,28 @@
{{ wizard.hardware.gpus.join(', ') }} {{ wizard.hardware.gpus.join(', ') }}
</div> </div>
<div v-else class="step__info"> <div v-else class="step__info">
No local NVIDIA GPUs detected. CPU or cf-orch mode recommended. No local NVIDIA GPUs detected. CPU or Orchard mode recommended.
</div> </div>
<!-- Service status -->
<div class="hw-services">
<div class="hw-svc" :class="ollamaRunning ? 'hw-svc--up' : 'hw-svc--down'">
<span class="hw-svc__dot" aria-hidden="true" />
<span class="hw-svc__name">Ollama</span>
<span class="hw-svc__status">{{ ollamaRunning ? 'running' : 'not detected' }}</span>
</div>
<div class="hw-svc" :class="searxngRunning ? 'hw-svc--up' : 'hw-svc--down'">
<span class="hw-svc__dot" aria-hidden="true" />
<span class="hw-svc__name">SearXNG</span>
<span class="hw-svc__status">{{ searxngRunning ? 'running' : 'not detected' }}</span>
</div>
</div>
<p v-if="!ollamaRunning" class="step__field-hint">
Ollama not running start it on the host before continuing, or choose Remote or Orchard mode.
See <strong>Settings Services</strong> after setup to manage services.
</p>
<div class="step__field"> <div class="step__field">
<label class="step__label" for="hw-profile">Inference profile</label> <label class="step__label" for="hw-profile">Inference profile</label>
<select id="hw-profile" v-model="selectedProfile" class="step__select"> <select id="hw-profile" v-model="selectedProfile" class="step__select">
@ -23,7 +42,7 @@
<option value="single-gpu">Single GPU local Ollama + one GPU</option> <option value="single-gpu">Single GPU local Ollama + one GPU</option>
<option value="dual-gpu">Dual GPU local Ollama + two GPUs</option> <option value="dual-gpu">Dual GPU local Ollama + two GPUs</option>
<option value="cf-orch"> <option value="cf-orch">
cf-orch CircuitForge GPU cluster Orchard CircuitForge GPU cluster
{{ orchAvailable ? `(${orchGpus.length} GPU(s) available)` : '(configure endpoint below)' }} {{ orchAvailable ? `(${orchGpus.length} GPU(s) available)` : '(configure endpoint below)' }}
</option> </option>
<option value="remote">Remote use cloud API keys</option> <option value="remote">Remote use cloud API keys</option>
@ -49,7 +68,7 @@
</div> </div>
<div class="step__field"> <div class="step__field">
<label class="step__label" for="orch-url">cf-orch coordinator URL</label> <label class="step__label" for="orch-url">Orchard coordinator URL</label>
<input <input
id="orch-url" id="orch-url"
v-model="orchUrl" v-model="orchUrl"
@ -58,14 +77,14 @@
placeholder="http://10.1.10.71:7700" placeholder="http://10.1.10.71:7700"
/> />
<p class="step__field-hint"> <p class="step__field-hint">
The coordinator serves public inference endpoints for paid+ users. The Orchard coordinator serves public inference endpoints for Paid+ users.
Leave blank to use the default cluster URL from Settings. Leave blank to use the default cluster URL from Settings.
</p> </p>
</div> </div>
<div class="step__tier-note"> <div class="step__tier-note">
<span aria-hidden="true">🔒</span> <span aria-hidden="true">🔒</span>
cf-orch inference requires a <strong>Paid</strong> license or higher. Orchard inference requires a <strong>Paid</strong> license or higher.
You can select this profile now; it will activate once your license is verified. You can select this profile now; it will activate once your license is verified.
</div> </div>
</template> </template>
@ -75,7 +94,7 @@
class="step__warning" class="step__warning"
> >
No local GPUs detected a GPU profile may not work. Choose CPU No local GPUs detected a GPU profile may not work. Choose CPU
or cf-orch if you have access to the cluster. or Orchard if you have access to the cluster.
</div> </div>
</template> </template>
@ -107,6 +126,10 @@ const orchAvailable = ref(false)
const orchGpus = ref<Array<{ node: string; name: string; vram_total_mb: number; vram_free_mb: number }>>([]) const orchGpus = ref<Array<{ node: string; name: string; vram_total_mb: number; vram_free_mb: number }>>([])
const orchUrl = ref('') const orchUrl = ref('')
// local service probe results
const ollamaRunning = ref(false)
const searxngRunning = ref(false)
onMounted(async () => { onMounted(async () => {
detecting.value = true detecting.value = true
const { data } = await useApiFetch<{ const { data } = await useApiFetch<{
@ -115,6 +138,8 @@ onMounted(async () => {
profiles: string[] profiles: string[]
cf_orch_available: boolean cf_orch_available: boolean
cf_orch_gpus: Array<{ node: string; name: string; vram_total_mb: number; vram_free_mb: number }> cf_orch_gpus: Array<{ node: string; name: string; vram_total_mb: number; vram_free_mb: number }>
ollama_running: boolean
searxng_running: boolean
}>('/api/wizard/hardware') }>('/api/wizard/hardware')
detecting.value = false detecting.value = false
if (!data) return if (!data) return
@ -128,6 +153,8 @@ onMounted(async () => {
orchAvailable.value = data.cf_orch_available ?? false orchAvailable.value = data.cf_orch_available ?? false
orchGpus.value = data.cf_orch_gpus ?? [] orchGpus.value = data.cf_orch_gpus ?? []
ollamaRunning.value = data.ollama_running ?? false
searxngRunning.value = data.searxng_running ?? false
}) })
async function next() { async function next() {
@ -140,3 +167,40 @@ async function next() {
if (ok) router.push('/setup/tier') if (ok) router.push('/setup/tier')
} }
</script> </script>
<style scoped>
.hw-services {
display: flex;
gap: var(--space-4);
margin: var(--space-3) 0 var(--space-2);
flex-wrap: wrap;
}
.hw-svc {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-full);
font-size: 0.8rem;
font-weight: 500;
border: 1px solid var(--color-border-light);
background: var(--color-surface-alt);
}
.hw-svc--up { border-color: color-mix(in srgb, var(--color-success) 40%, transparent); }
.hw-svc--down { opacity: 0.65; }
.hw-svc__dot {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
flex-shrink: 0;
}
.hw-svc--up .hw-svc__dot { background: var(--color-success); }
.hw-svc--down .hw-svc__dot { background: var(--color-text-muted); }
.hw-svc__name { color: var(--color-text); }
.hw-svc__status { color: var(--color-text-muted); }
</style>

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="step"> <div class="step">
<h2 class="step__heading">Step 4 Your Identity</h2> <h2 class="step__heading">Step 5 Your Identity</h2>
<p class="step__caption"> <p class="step__caption">
Used in cover letters, research briefs, and interview prep. You can update Used in cover letters, research briefs, and interview prep. You can update
this any time in Settings My Profile. this any time in Settings My Profile.

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="step"> <div class="step">
<h2 class="step__heading">Step 5 Inference & API Keys</h2> <h2 class="step__heading">Step 6 Inference & API Keys</h2>
<p class="step__caption"> <p class="step__caption">
Configure how Peregrine generates AI content. You can adjust this any time Configure how Peregrine generates AI content. You can adjust this any time
in Settings System. in Settings System.
@ -36,7 +36,35 @@
</div> </div>
</template> </template>
<!-- Local mode --> <!-- Orchard mode -->
<template v-else-if="isCfOrch">
<div class="step__info">
Orchard mode: Peregrine routes AI generation through the CircuitForge GPU cluster.
</div>
<div class="step__field">
<label class="step__label" for="inf-orch-url">Orchard coordinator URL</label>
<input id="inf-orch-url" v-model="form.orchUrl" type="url"
class="step__input" placeholder="https://orch.circuitforge.tech" />
</div>
<div v-if="isPaid" class="step__check-row">
<label class="step__checkbox-label">
<input
type="checkbox"
class="step__checkbox"
:checked="form.orchUrl === MANAGED_ORCH_URL"
@change="onUseManagedOrchard"
/>
<span>Use CircuitForge managed Orchard</span>
</label>
<span class="step__check-hint">
Auto-fills your Paid+ cluster endpoint ({{ MANAGED_ORCH_URL }})
</span>
</div>
</template>
<!-- Local mode (CPU / single-gpu / dual-gpu) -->
<template v-else> <template v-else>
<div class="step__info"> <div class="step__info">
Local mode ({{ wizard.hardware.selectedProfile }}): Peregrine uses Local mode ({{ wizard.hardware.selectedProfile }}): Peregrine uses
@ -81,12 +109,19 @@
import { reactive, ref, computed } from 'vue' import { reactive, ref, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard' import { useWizardStore } from '../../stores/wizard'
import { useAppConfigStore } from '../../stores/appConfig'
import './wizard.css' import './wizard.css'
const wizard = useWizardStore() const wizard = useWizardStore()
const config = useAppConfigStore()
const router = useRouter() const router = useRouter()
const MANAGED_ORCH_URL = 'https://orch.circuitforge.tech'
const isRemote = computed(() => wizard.hardware.selectedProfile === 'remote') const isRemote = computed(() => wizard.hardware.selectedProfile === 'remote')
const isCfOrch = computed(() => wizard.hardware.selectedProfile === 'cf-orch')
const isPaid = computed(() => config.tier !== 'free')
const showAdvanced = ref(false) const showAdvanced = ref(false)
const testing = ref(false) const testing = ref(false)
const testResult = ref<{ ok: boolean; message: string } | null>(null) const testResult = ref<{ ok: boolean; message: string } | null>(null)
@ -95,19 +130,42 @@ const form = reactive({
anthropicKey: wizard.inference.anthropicKey, anthropicKey: wizard.inference.anthropicKey,
openaiUrl: wizard.inference.openaiUrl, openaiUrl: wizard.inference.openaiUrl,
openaiKey: wizard.inference.openaiKey, openaiKey: wizard.inference.openaiKey,
orchUrl: wizard.inference.orchUrl,
}) })
const savedSvcs = wizard.inference.services as Record<string, string | number>
const services = reactive([ const services = reactive([
{ key: 'ollama', label: 'Ollama', host: 'ollama', port: 11434 }, {
{ key: 'searxng', label: 'SearXNG', host: 'searxng', port: 8080 }, key: 'ollama',
label: 'Ollama',
host: (savedSvcs['ollama_host'] as string) || wizard.inference.ollamaHost || 'localhost',
port: (savedSvcs['ollama_port'] as number) || wizard.inference.ollamaPort || 11434,
},
{
key: 'searxng',
label: 'SearXNG',
host: (savedSvcs['searxng_host'] as string) || 'searxng',
port: (savedSvcs['searxng_port'] as number) || 8080,
},
]) ])
function onUseManagedOrchard(e: Event) {
const checked = (e.target as HTMLInputElement).checked
form.orchUrl = checked ? MANAGED_ORCH_URL : ''
}
async function runTest() { async function runTest() {
testing.value = true testing.value = true
testResult.value = null testResult.value = null
wizard.inference.anthropicKey = form.anthropicKey wizard.inference.anthropicKey = form.anthropicKey
wizard.inference.openaiUrl = form.openaiUrl wizard.inference.openaiUrl = form.openaiUrl
wizard.inference.openaiKey = form.openaiKey wizard.inference.openaiKey = form.openaiKey
wizard.inference.orchUrl = form.orchUrl
const ollamaSvc = services.find(s => s.key === 'ollama')
if (ollamaSvc) {
wizard.inference.ollamaHost = ollamaSvc.host
wizard.inference.ollamaPort = ollamaSvc.port
}
testResult.value = await wizard.testInference() testResult.value = await wizard.testInference()
testing.value = false testing.value = false
} }
@ -115,10 +173,10 @@ async function runTest() {
function back() { router.push('/setup/identity') } function back() { router.push('/setup/identity') }
async function next() { async function next() {
// Sync form back to store
wizard.inference.anthropicKey = form.anthropicKey wizard.inference.anthropicKey = form.anthropicKey
wizard.inference.openaiUrl = form.openaiUrl wizard.inference.openaiUrl = form.openaiUrl
wizard.inference.openaiKey = form.openaiKey wizard.inference.openaiKey = form.openaiKey
wizard.inference.orchUrl = form.orchUrl
const svcMap: Record<string, string | number> = {} const svcMap: Record<string, string | number> = {}
services.forEach(s => { services.forEach(s => {
@ -131,6 +189,7 @@ async function next() {
anthropic_key: form.anthropicKey, anthropic_key: form.anthropicKey,
openai_url: form.openaiUrl, openai_url: form.openaiUrl,
openai_key: form.openaiKey, openai_key: form.openaiKey,
orch_url: form.orchUrl,
services: svcMap, services: svcMap,
}) })
if (ok) router.push('/setup/search') if (ok) router.push('/setup/search')
@ -166,4 +225,33 @@ async function next() {
.svc-port { .svc-port {
text-align: right; text-align: right;
} }
.step__check-row {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-bottom: var(--space-4);
}
.step__checkbox-label {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
font-size: 0.9rem;
color: var(--color-text);
}
.step__checkbox {
width: 1rem;
height: 1rem;
accent-color: var(--color-primary);
flex-shrink: 0;
}
.step__check-hint {
font-size: 0.8rem;
color: var(--color-text-muted);
padding-left: calc(1rem + var(--space-2));
}
</style> </style>

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="step"> <div class="step">
<h2 class="step__heading">Step 7 Integrations</h2> <h2 class="step__heading">Step 8 Integrations</h2>
<p class="step__caption"> <p class="step__caption">
Optional. Connect external tools to supercharge your workflow. Optional. Connect external tools to supercharge your workflow.
You can configure these any time in Settings System. You can configure these any time in Settings System.
@ -54,6 +54,7 @@ const wizard = useWizardStore()
const config = useAppConfigStore() const config = useAppConfigStore()
const router = useRouter() const router = useRouter()
const isPaid = computed(() => const isPaid = computed(() =>
wizard.tier === 'paid' || wizard.tier === 'premium', wizard.tier === 'paid' || wizard.tier === 'premium',
) )
@ -87,7 +88,12 @@ async function finish() {
// Save integration selections (step 7) then mark wizard complete // Save integration selections (step 7) then mark wizard complete
await wizard.saveStep(8, { integrations: [...checkedIds.value] }) await wizard.saveStep(8, { integrations: [...checkedIds.value] })
const ok = await wizard.complete() const ok = await wizard.complete()
if (ok) router.replace('/') if (ok) {
// Update store before navigating so the router guard sees wizard as complete
// without waiting for a full config.load() round-trip.
config.wizardComplete = true
router.replace('/')
}
} }
</script> </script>

View file

@ -2,7 +2,7 @@
<div class="step"> <div class="step">
<h2 class="step__heading">Step 3 Your Resume</h2> <h2 class="step__heading">Step 3 Your Resume</h2>
<p class="step__caption"> <p class="step__caption">
Upload a resume to auto-populate your profile, or build it manually. Upload a resume to auto-populate your profile, build it manually, or let an AI guide you.
</p> </p>
<!-- Tabs --> <!-- Tabs -->
@ -13,14 +13,31 @@
class="resume-tab" class="resume-tab"
:class="{ 'resume-tab--active': tab === 'upload' }" :class="{ 'resume-tab--active': tab === 'upload' }"
@click="tab = 'upload'" @click="tab = 'upload'"
>Upload File</button> >
<span class="resume-tab__icon" aria-hidden="true">📄</span>
<span class="resume-tab__label">Upload File</span>
</button>
<button <button
role="tab" role="tab"
:aria-selected="tab === 'manual'" :aria-selected="tab === 'manual'"
class="resume-tab" class="resume-tab"
:class="{ 'resume-tab--active': tab === 'manual' }" :class="{ 'resume-tab--active': tab === 'manual' }"
@click="tab = 'manual'" @click="tab = 'manual'"
>Build Manually</button> >
<span class="resume-tab__icon" aria-hidden="true"></span>
<span class="resume-tab__label">Build Manually</span>
</button>
<button
role="tab"
:aria-selected="tab === 'ai'"
class="resume-tab resume-tab--ai"
:class="{ 'resume-tab--active': tab === 'ai' }"
@click="tab = 'ai'"
>
<span class="resume-tab__icon" aria-hidden="true">{{ hasAiAccess ? '✨' : '🔒' }}</span>
<span class="resume-tab__label">AI Assistant</span>
<span v-if="!hasAiAccess" class="resume-tab__badge">Paid</span>
</button>
</div> </div>
<!-- Upload tab --> <!-- Upload tab -->
@ -106,6 +123,34 @@
</button> </button>
</div> </div>
<!-- AI assistant tab -->
<div v-if="tab === 'ai'" class="resume-ai">
<div v-if="!hasAiAccess" class="ai-gate">
<p class="ai-gate__icon" aria-hidden="true">🔒</p>
<p class="ai-gate__heading">AI Assistant requires a Paid plan</p>
<p class="ai-gate__body">
Upgrade to Paid, or bring your own LLM key in
<strong>Settings LLM Backends</strong> to unlock the AI profile assistant for free.
</p>
<p class="ai-gate__body">
In the meantime, use <button class="ai-gate__link" @click="tab = 'upload'">Upload File</button>
or <button class="ai-gate__link" @click="tab = 'manual'">Build Manually</button>.
</p>
</div>
<div v-else class="ai-embed">
<p class="ai-embed__intro">
The AI assistant will ask you a few questions to build your profile.
Your answers are saved locally nothing is sent anywhere without your approval.
</p>
<a href="/wizard/ai-profile" class="btn-primary ai-embed__cta">
Open AI Assistant
</a>
<p class="ai-embed__note">
Opens in a focused view. Come back here to continue the wizard once you're done.
</p>
</div>
</div>
<div v-if="validationError" class="step__warning" style="margin-top: var(--space-4)"> <div v-if="validationError" class="step__warning" style="margin-top: var(--space-4)">
{{ validationError }} {{ validationError }}
</div> </div>
@ -120,17 +165,21 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard' import { useWizardStore } from '../../stores/wizard'
import type { WorkExperience } from '../../stores/wizard' import type { WorkExperience } from '../../stores/wizard'
import { useApiFetch } from '../../composables/useApi' import { useApiFetch } from '../../composables/useApi'
import { useAppConfigStore } from '../../stores/appConfig'
import './wizard.css' import './wizard.css'
const wizard = useWizardStore() const wizard = useWizardStore()
const router = useRouter() const router = useRouter()
const config = useAppConfigStore()
const tab = ref<'upload' | 'manual'>( const hasAiAccess = computed(() => config.tier !== 'free' || config.byokUnlocked)
const tab = ref<'upload' | 'manual' | 'ai'>(
wizard.resume.experience.length > 0 ? 'manual' : 'upload', wizard.resume.experience.length > 0 ? 'manual' : 'upload',
) )
const dragging = ref(false) const dragging = ref(false)
@ -223,30 +272,69 @@ async function next() {
<style scoped> <style scoped>
.resume-tabs { .resume-tabs {
display: flex; display: flex;
gap: 0; gap: var(--space-2);
border-bottom: 2px solid var(--color-border-light); border-bottom: 2px solid var(--color-border);
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
} }
.resume-tab { .resume-tab {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-5); padding: var(--space-2) var(--space-5);
background: none; background: var(--color-surface-alt);
border: none; border: 1.5px solid var(--color-border);
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
border-radius: var(--radius-md) var(--radius-md) 0 0;
margin-bottom: -2px; margin-bottom: -2px;
cursor: pointer; cursor: pointer;
font-family: var(--font-body); font-family: var(--font-body);
font-size: 0.9rem; font-size: 0.875rem;
color: var(--color-text-muted); color: var(--color-text-muted);
transition: color var(--transition), border-color var(--transition); transition: color var(--transition), background var(--transition), border-color var(--transition);
}
.resume-tab:hover:not(.resume-tab--active) {
background: var(--color-surface-raised);
color: var(--color-text);
border-color: var(--color-border);
} }
.resume-tab--active { .resume-tab--active {
background: var(--color-surface);
color: var(--color-primary); color: var(--color-primary);
border-bottom-color: var(--color-primary); border-color: var(--color-border);
border-bottom-color: var(--color-surface);
font-weight: 600; font-weight: 600;
} }
.resume-tab--ai.resume-tab--active {
color: var(--color-accent);
border-bottom-color: var(--color-surface);
}
.resume-tab__icon {
font-size: 1rem;
line-height: 1;
}
.resume-tab__label {
/* explicit — keeps tab text from being an accessibility mystery */
}
.resume-tab__badge {
font-size: 0.7rem;
font-weight: 700;
padding: 1px var(--space-2);
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
color: var(--color-accent);
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
border-radius: var(--radius-full);
margin-left: var(--space-1);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.upload-zone { .upload-zone {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -310,4 +398,86 @@ async function next() {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: var(--space-4); gap: var(--space-4);
} }
/* ── AI tab panels ──────────────────────────────────── */
.resume-ai {
min-height: 200px;
display: flex;
flex-direction: column;
justify-content: center;
}
.ai-gate {
text-align: center;
padding: var(--space-8) var(--space-4);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
background: var(--color-surface-alt);
border: 1px dashed var(--color-border);
border-radius: var(--radius-lg);
}
.ai-gate__icon {
font-size: 2rem;
margin: 0;
}
.ai-gate__heading {
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
margin: 0;
}
.ai-gate__body {
font-size: 0.875rem;
color: var(--color-text-muted);
line-height: 1.55;
margin: 0;
max-width: 380px;
}
.ai-gate__link {
background: none;
border: none;
padding: 0;
font-family: var(--font-body);
font-size: inherit;
color: var(--color-primary);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.ai-embed {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
padding: var(--space-6);
background: color-mix(in srgb, var(--color-accent) 6%, var(--color-surface));
border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
border-radius: var(--radius-lg);
}
.ai-embed__intro {
font-size: 0.9rem;
color: var(--color-text);
line-height: 1.6;
margin: 0;
}
.ai-embed__cta {
text-decoration: none;
display: inline-flex;
align-items: center;
}
.ai-embed__note {
font-size: 0.8rem;
color: var(--color-text-muted);
margin: 0;
}
</style> </style>

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="step"> <div class="step">
<h2 class="step__heading">Step 6 Search Preferences</h2> <h2 class="step__heading">Step 7 Search Preferences</h2>
<p class="step__caption"> <p class="step__caption">
Tell Peregrine what roles and markets to watch. You can add more profiles Tell Peregrine what roles and markets to watch. You can add more profiles
in Settings Search later. in Settings Search later.