From 82c26074d8e682f03112660d7c2c59491eaf0d0a Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 15 Jun 2026 16:52:56 -0700 Subject: [PATCH] fix: search prefs wizard data loss, resume sync link, docs + GUI help links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 4 +- .gitignore | 1 + README.md | 9 +- config/resume_keywords.yaml | 46 ++++ dev-api.py | 198 +++++++++++++-- docs/getting-started/docker-profiles.md | 179 +++++++++++--- docs/getting-started/installation.md | 74 ++++-- docs/user-guide/daily-workflow.md | 142 +++++++++++ docs/user-guide/settings.md | 226 ++++++++++-------- mkdocs.yml | 1 + scripts/resume_parser.py | 149 +++++++++--- web/src/composables/useDocsUrl.ts | 5 + web/src/stores/wizard.ts | 10 +- web/src/stores/wizard/aiInterview.ts | 15 +- web/src/views/HomeView.vue | 20 +- web/src/views/JobReviewView.vue | 12 + web/src/views/ResumesView.vue | 6 +- web/src/views/settings/ResumeProfileView.vue | 9 +- web/src/views/settings/SearchPrefsView.vue | 11 +- web/src/views/settings/SystemSettingsView.vue | 51 ++++ web/src/views/wizard/WizardHardwareStep.vue | 76 +++++- web/src/views/wizard/WizardIdentityStep.vue | 2 +- web/src/views/wizard/WizardInferenceStep.vue | 98 +++++++- .../views/wizard/WizardIntegrationsStep.vue | 10 +- web/src/views/wizard/WizardResumeStep.vue | 194 ++++++++++++++- web/src/views/wizard/WizardSearchStep.vue | 2 +- 26 files changed, 1301 insertions(+), 249 deletions(-) create mode 100644 docs/user-guide/daily-workflow.md create mode 100644 web/src/composables/useDocsUrl.ts diff --git a/.env.example b/.env.example index 27a4861..bb52dbc 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,10 @@ # Auto-generated by the setup wizard, or fill in manually. # NEVER commit .env to git. -STREAMLIT_PORT=8502 +VUE_PORT=8506 OLLAMA_PORT=11434 VLLM_PORT=8000 -CF_TEXT_PORT=8006 +CF_TEXT_PORT=8008 SEARXNG_PORT=8888 VISION_PORT=8002 VISION_MODEL=vikhyatk/moondream2 diff --git a/.gitignore b/.gitignore index ca1d82a..b49125b 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,4 @@ demo/seed_demo.py tests/e2e/results/demo/ tests/e2e/results/cloud/ tests/e2e/results/local/ +config/wizard-test/ diff --git a/README.md b/README.md index 29d3a55..f676440 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ cd peregrine ./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. > **Windows:** use WSL2 with Ubuntu. @@ -78,10 +78,11 @@ Open **http://localhost:8502** — the setup wizard walks you through the rest. ### Inference profiles ```bash -./manage.sh start # remote — no GPU; LLM calls go to Anthropic / OpenAI -./manage.sh start --profile cpu # local Ollama on CPU (or Metal via native Ollama on macOS) +./manage.sh start # cpu — local Ollama on CPU (recommended default) ./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 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 ¹ | | Cover letter model fine-tuning — your writing, your model | Premium | | Multi-user support | Premium | -| Human-in-the-loop operator (CAPTCHAs, phone calls, wet signatures) | Ultra | +| 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. diff --git a/config/resume_keywords.yaml b/config/resume_keywords.yaml index 7cfdab3..1137dee 100644 --- a/config/resume_keywords.yaml +++ b/config/resume_keywords.yaml @@ -1,17 +1,47 @@ domains: - B2B SaaS - enterprise software +- cybersecurity - security - compliance - post-sale lifecycle - SaaS metrics - web security +- risk management +- Fortune 500 +- enterprise accounts +- consulting +- CS advisory +- startup keywords: - churn reduction - escalation management - cross-functional - product feedback loop - 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: - Customer Success - Technical Account Management @@ -21,3 +51,19 @@ skills: - project management - onboarding - 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 diff --git a/dev-api.py b/dev-api.py index 3b7908f..8f7f6aa 100644 --- a/dev-api.py +++ b/dev-api.py @@ -116,12 +116,35 @@ def _load_demo_seed(db_path: str, seed_file: str) -> None: con.close() +def _load_data_env() -> None: + """Load API keys written by the wizard into the running process. + + The wizard saves keys to /.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 async def lifespan(app: FastAPI): """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 # when dev_api is imported by tests (only when uvicorn actually starts). _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 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: 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) + # 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 { "ok": True, "backup_id": backup["id"], @@ -3250,7 +3281,23 @@ async def upload_resume(file: UploadFile): resume_path.parent.mkdir(parents=True, exist_ok=True) with open(resume_path, "w") as f: 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["library_id"] = library_entry["id"] return {"ok": True, "data": result} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -3313,6 +3360,10 @@ def get_search_prefs(): 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 except Exception as 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"} +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 ─────────────────────────────────────────────────────── _TRAINING_JSONL = Path("/Library/Documents/JobSearch/training_data/cover_letters.jsonl") @@ -4350,6 +4421,7 @@ def wizard_status(): "linkedin": cfg.get("linkedin", ""), "career_summary": cfg.get("career_summary", ""), "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 data = payload.data - if step < 1 or step > 7: - raise HTTPException(status_code=400, detail="step must be 1–7") + if step < 1 or step > 8: + raise HTTPException(status_code=400, detail="step must be 1–8") updates: dict = {"wizard_step": step} @@ -4398,13 +4470,16 @@ def wizard_save_step(payload: WizardStepPayload): with open(resume_path, "w") as f: 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"): if field in data: updates[field] = data[field] - elif step == 5: - # Write API keys to .env (never store in user.yaml) + elif step == 6: + # Step 6 — inference: API keys + optional Orchard coordinator URL. env_path = Path(_wizard_yaml_path()).parent.parent / ".env" 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"]) if data.get("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.write_text("\n".join(env_lines) + "\n") if "services" in data: updates["services"] = data["services"] - elif step == 6: - # Persist search preferences to search_profiles.yaml in canonical format: - # profiles: [{name, titles, locations, boards, ...}] - titles = data.get("titles", []) - locations = data.get("locations", []) + elif step == 7: + # Step 7 — search preferences. + # Wizard sends { search: { titles, locations, remote_only } }; fall back to + # top-level keys for direct API callers that omit the "search" wrapper. + 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() existing_search: dict = {} if search_path.exists(): @@ -4450,14 +4531,15 @@ def wizard_save_step(payload: WizardStepPayload): if default_profile is None: default_profile = {"name": "default"} profiles_list.append(default_profile) - default_profile["titles"] = titles + default_profile["job_titles"] = titles default_profile["locations"] = locations + default_profile["remote_only"] = remote_only existing_search["profiles"] = profiles_list search_path.parent.mkdir(parents=True, exist_ok=True) with open(search_path, "w") as f: 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. try: @@ -4484,6 +4566,39 @@ def _fetch_cforch_nodes() -> list[dict]: 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") def wizard_hardware(): """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"], }) + 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 { "gpus": gpus, "suggested_profile": suggested, "profiles": list(_WIZARD_PROFILES), "cf_orch_available": len(orch_nodes) > 0, "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): profile: str = "remote" anthropic_key: str = "" openai_url: str = "" openai_key: str = "" + orch_url: str = "" ollama_host: str = "localhost" ollama_port: int = 11434 @app.post("/api/wizard/inference/test") 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 - soft warning (message), not an HTTP error, so the wizard can let the - user continue past a temporarily-down Ollama instance. + Always returns {ok, message} — a connection failure is a soft warning so + the wizard lets the user continue past a temporarily-unreachable service. """ - 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: - # Temporarily inject key if provided (don't persist yet) env_override = {} if 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 except Exception as exc: return {"ok": False, "message": f"LLM test failed: {exc}"} + else: - # Local profile — ping Ollama - ollama_url = f"http://{payload.ollama_host}:{payload.ollama_port}" + # Local profiles (cpu, single-gpu, dual-gpu) — ping Ollama + host = payload.ollama_host or "localhost" + ollama_url = _container_safe_url(f"http://{host}:{payload.ollama_port}") try: resp = requests.get(f"{ollama_url}/api/tags", timeout=5) ok = resp.status_code == 200 message = "Ollama is running." if ok else f"Ollama returned HTTP {resp.status_code}." except Exception: - # Soft-fail: user can skip and configure later return { "ok": False, "message": ( diff --git a/docs/getting-started/docker-profiles.md b/docs/getting-started/docker-profiles.md index 347c9a6..7a6ed9c 100644 --- a/docs/getting-started/docker-profiles.md +++ b/docs/getting-started/docker-profiles.md @@ -1,69 +1,129 @@ # Docker Profiles -Peregrine uses Docker Compose profiles to start only the services your hardware can support. Choose a profile with `make start PROFILE=`. +Peregrine uses Docker Compose profiles to start only the services your hardware supports. Choose a profile with `./manage.sh start --profile `. + +`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 | Services started | Use case | -|---------|----------------|----------| -| `remote` | `app`, `searxng` | No GPU. LLM calls go to an external API (Anthropic, OpenAI-compatible). | -| `cpu` | `app`, `ollama`, `searxng` | No GPU. Runs local models on CPU — functional but slow. | -| `single-gpu` | `app`, `ollama`, `vision`, `searxng` | One NVIDIA GPU. Covers cover letters, research, and vision (survey screenshots). | -| `dual-gpu` | `app`, `ollama`, `vllm`, `vision`, `searxng` | Two NVIDIA GPUs. GPU 0 = Ollama (cover letters), GPU 1 = vLLM (research). | +|---------|-----------------|----------| +| `cpu` | `web`, `api`, `ollama`, `searxng` | No GPU. Local models on CPU. Recommended default for new installs. | +| `single-gpu` | `web`, `api`, `ollama`, `vision`, `searxng` | One NVIDIA GPU. Covers cover letters, research, and vision. | +| `dual-gpu` | `web`, `api`, `ollama`, `vllm`, `vision`, `searxng` | Two NVIDIA GPUs. GPU split controlled by `DUAL_GPU_MODE`. | +| `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 | Image / Source | Port | Purpose | -|---------|---------------|------|---------| -| `app` | `Dockerfile` (Streamlit) | 8501 | The main Peregrine UI | +| Service | Image / Source | Host Port | Purpose | +|---------|---------------|-----------|---------| +| `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 | -| `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 | -| `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 -### 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 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 -Pull a model after the container starts: +Pull a model after starting: ```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 Use `single-gpu` if: - You have one NVIDIA GPU with at least 8 GB VRAM - 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 Use `dual-gpu` if: - You have two or more NVIDIA GPUs -- GPU 0 handles Ollama (cover letters, quick tasks) -- GPU 1 handles vLLM (research, long-context tasks) -- The vision service shares GPU 0 with Ollama +- Default: GPU 0 handles Ollama (cover letters), GPU 1 handles vLLM (research) + +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: | 4–8 GB | `single-gpu` | Run smaller models (3B–8B parameters) | | 8–16 GB | `single-gpu` | Run 8B–13B models comfortably | | 16–24 GB | `single-gpu` | Run 13B–34B 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 -`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 ` uses the profile you specify. + +Run preflight independently at any time: ```bash -make preflight +./manage.sh preflight # 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 -Edit `.env` before running `make start`: +Edit `.env` before running `./manage.sh start`: ```bash -STREAMLIT_PORT=8501 +VUE_PORT=8506 # main UI (Vue SPA) OLLAMA_PORT=11434 VLLM_PORT=8000 SEARXNG_PORT=8888 @@ -116,3 +205,15 @@ VISION_PORT=8002 ``` 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. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 21bd639..5b5e298 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -7,7 +7,7 @@ This page walks through a full Peregrine installation from scratch. ## Prerequisites - **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) !!! warning "Windows" @@ -34,16 +34,28 @@ bash install.sh 1. **Detects your platform** (Ubuntu/Debian, Fedora/RHEL, Arch, macOS) 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) -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 !!! 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" - 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: ```bash -# Default ports -STREAMLIT_PORT=8501 -OLLAMA_PORT=11434 -VLLM_PORT=8000 -SEARXNG_PORT=8888 -VISION_PORT=8002 +# Main UI port +VUE_PORT=8506 + +# Model paths — use full absolute paths, not ~ (tilde does not expand inside containers) +DOCS_DIR=/home/yourname/Documents/JobSearch +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: ```bash -make start # remote — no GPU, use API-only LLMs -make start PROFILE=cpu # cpu — local models on CPU (slow) -make start PROFILE=single-gpu # single-gpu — one NVIDIA GPU -make start PROFILE=dual-gpu # dual-gpu — GPU 0 = Ollama, GPU 1 = vLLM +./manage.sh start # cpu — local Ollama on CPU (recommended default) +./manage.sh start --profile single-gpu # one NVIDIA GPU +./manage.sh start --profile dual-gpu # two NVIDIA GPUs +./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 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 -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 | | | RHEL / Rocky / AlmaLinux | 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 | | 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. Requirements: + - NVIDIA driver installed and `nvidia-smi` working before running `install.sh` - CUDA 12.x recommended (CUDA 11.x may work but is untested) - 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 ```bash -make stop # stop all containers -make restart # stop then start again (runs preflight first) +./manage.sh stop # stop all containers +./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 ```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. diff --git a/docs/user-guide/daily-workflow.md b/docs/user-guide/daily-workflow.md new file mode 100644 index 0000000..9dc7302 --- /dev/null +++ b/docs/user-guide/daily-workflow.md @@ -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. diff --git a/docs/user-guide/settings.md b/docs/user-guide/settings.md index 23ab8eb..1a33da6 100644 --- a/docs/user-guide/settings.md +++ b/docs/user-guide/settings.md @@ -1,6 +1,8 @@ # 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 | |-------|-------------| -| Name | Your full name | -| Email | Contact email address | +| Full name | Your name as it appears in generated documents | +| Email | Contact email | | Phone | Contact phone number | -| LinkedIn | LinkedIn profile URL | -| Career summary | 2–4 sentence professional summary | -| 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`) | +| LinkedIn URL | Used in cover letter headers | +| Career summary | 2–4 sentences that anchor all LLM-generated content | ### 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 | -|-------|-----|---------| -| Music industry note | `music` | "I've played in bands for 15 years and care deeply about how artists get paid" | -| Animal welfare note | `animal_welfare` | "I volunteer at my local shelter every weekend" | -| Education note | `education` | "I tutored underserved kids and care deeply about literacy" | +| Field | Tag | +|-------|-----| +| Music industry note | `music` | +| Animal welfare note | `animal_welfare` | +| Education note | `education` | Leave a field blank to use a generic default when alignment is detected. ### 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 | -|---------|--------------| -| Candidate accessibility focus | Disability inclusion and accessibility signals (ADA, ERGs, WCAG) | -| Candidate LGBTQIA+ focus | LGBTQIA+ inclusion signals (ERGs, non-discrimination policies, culture) | - ---- - -## 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. +| Setting | Section added to brief | +|---------|----------------------| +| Accessibility focus | Disability inclusion signals (ADA, ERGs, WCAG) | +| LGBTQIA+ focus | Inclusion signals (ERGs, non-discrimination policies) | --- ## 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 -**Tier: Premium** +*Tier: Premium only.* Tools for fine-tuning a cover letter model on your personal writing style. -- Export cover letter training data as JSONL -- Configure training parameters (rank, epochs, learning rate) -- Start a fine-tuning run (requires `ogma` conda environment with Unsloth) -- Register the output model with Ollama +1. **Export Training Data** — produces a JSONL file from your saved cover letters +2. **Configure training** — rank, epochs, learning rate +3. **Start fine-tune** — runs via the `ogma` conda environment with Unsloth +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 and debugging tools. +Developer and debugging tools. Only visible when dev mode is enabled or a `dev_tier_override` is set. | Option | Description | |--------|-------------| -| Reset wizard | Sets `wizard_complete: false` and `wizard_step: 0`; resumes at step 1 on next page load | -| Dev tier override | Set `dev_tier_override` 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) | -| View raw config | Shows the current `config/user.yaml` contents | +| Reset wizard | Sets `wizard_complete: false`; wizard restarts on next page load | +| Dev tier override | Set tier to `paid` or `premium` to test tier-gated features locally | +| Clear stuck tasks | Manually fails any `running` or `queued` background tasks | +| View raw config | Shows current `user.yaml` contents | diff --git a/mkdocs.yml b/mkdocs.yml index 9a545e7..5a88c35 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -52,6 +52,7 @@ nav: - First-Run Wizard: getting-started/first-run-wizard.md - Docker Profiles: getting-started/docker-profiles.md - User Guide: + - Daily Workflow: user-guide/daily-workflow.md - Job Discovery: user-guide/job-discovery.md - Job Review: user-guide/job-review.md - Apply Workspace: user-guide/apply-workspace.md diff --git a/scripts/resume_parser.py b/scripts/resume_parser.py index aa7e67e..45e362e 100644 --- a/scripts/resume_parser.py +++ b/scripts/resume_parser.py @@ -19,6 +19,14 @@ from docx import Document 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 " 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_NAMES = { @@ -27,6 +35,8 @@ _SECTION_NAMES = { "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), "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 @@ -163,6 +173,8 @@ def _split_sections(text: str) -> dict[str, list[str]]: stripped = line.strip() if not stripped: continue + if _BROWSER_ARTIFACT_RE.match(stripped): + continue matched = False for section, pattern in _SECTION_NAMES.items(): # 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 Dates • bullet • bullet + (C) Title\tDates (tab-separated, common in DOCX exports) + Company | Location + • bullet """ entries: list[dict] = [] current: dict | None = None prev_line = "" + seen_bullets = False # True once we've appended the first bullet to current for line in lines: date_match = _DATE_RANGE_RE.search(line) @@ -243,12 +259,13 @@ def _parse_experience(lines: list[str]) -> list[dict]: if current: entries.append(current) # 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) # (C) "Title | Company | Dates" all on one line same_line = _DATE_RANGE_RE.sub("", line) # Remove residual punctuation-only fragments like "()" left after date removal 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(): # Layout A: title = prev_line, company = first segment of same_line title = prev_line.strip() @@ -268,8 +285,19 @@ def _parse_experience(lines: list[str]) -> list[dict]: "bullets": [], } prev_line = "" + seen_bullets = False elif current is not None: 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 = ( not is_bullet and " | " in line @@ -282,7 +310,10 @@ def _parse_experience(lines: list[str]) -> list[dict]: clean = re.sub(r"^[•\-–—*◦▪▸►]\s*", "", line).strip() if 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: prev_line = line @@ -294,39 +325,77 @@ def _parse_experience(lines: list[str]) -> list[dict]: # ── Education ───────────────────────────────────────────────────────────────── +_INSTITUTION_RE = re.compile(r"\b(university|college|institute|school|academy)\b", re.I) + + 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] = [] current: dict | None = None prev_line = "" 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: entries.append(current) - current = { - "institution": "", - "degree": "", - "field": "", - "graduation_year": "", - } + current = {"institution": "", "degree": "", "field": "", "graduation_year": ""} + year_m = re.search(r"\b(19|20)\d{2}\b", line) if year_m: current["graduation_year"] = year_m.group(0) - degree_m = _DEGREE_RE.search(line) - if degree_m: - current["degree"] = degree_m.group(0).upper() - remainder = _DEGREE_RE.sub("", _DATE_RE.sub("", line)) - remainder = re.sub(r"\b(19|20)\d{2}\b", "", remainder) - 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): - current["institution"] = prev_line.strip(" ,–—|•") - elif current is not None and not current["institution"]: - # Layout B: institution follows the degree line - clean = line.strip(" ,–—|•") + + if has_degree: + degree_m = _DEGREE_RE.search(line) + if degree_m: + current["degree"] = degree_m.group(0).upper() + remainder = _DEGREE_RE.sub("", _DATE_RE.sub("", line)) + remainder = re.sub(r"\b(19|20)\d{2}\b", "", remainder) + current["field"] = remainder.strip(" ,–—|•.") + if prev_line and not _DEGREE_RE.search(prev_line) and not _DATE_RE.search(prev_line): + current["institution"] = prev_line.strip(" ,–—|•") + else: + # Fallback: year-range line without a degree keyword. + # Two layouts: + # (A) PDF: "Graphic Design, 2005–2006" 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: - current["institution"] = clean - prev_line = line.strip() + if not current["institution"]: + current["institution"] = clean + elif not current["field"]: + current["field"] = clean + prev_line = "" # field consumed — don't seed the next entry + continue + prev_line = line.strip() + + else: + prev_line = line.strip() if current: entries.append(current) @@ -336,13 +405,39 @@ def _parse_education(lines: list[str]) -> list[dict]: # ── 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]: skills: list[str] = [] for line in lines: - # Split on common delimiters - for item in re.split(r"[,|•·/]+", line): - clean = item.strip(" -–—*◦▪▸►()") - if 1 < len(clean) <= 50: + for item in _split_skill_tokens(line): + # Strip only bullet/dash markers and whitespace, NOT parentheses — + # many skills contain parens, e.g. "C++ (Arduino / Embedded)" + clean = item.strip(" -–—*◦▪▸►") + if 1 < len(clean) <= 60: skills.append(clean) return skills diff --git a/web/src/composables/useDocsUrl.ts b/web/src/composables/useDocsUrl.ts new file mode 100644 index 0000000..575e730 --- /dev/null +++ b/web/src/composables/useDocsUrl.ts @@ -0,0 +1,5 @@ +const DOCS_BASE = 'https://docs.circuitforge.tech/peregrine' + +export function useDocsUrl(path: string): string { + return `${DOCS_BASE}/${path}` +} diff --git a/web/src/stores/wizard.ts b/web/src/stores/wizard.ts index 86ec7f8..141484a 100644 --- a/web/src/stores/wizard.ts +++ b/web/src/stores/wizard.ts @@ -2,7 +2,7 @@ import { ref, computed } from 'vue' import { defineStore } from 'pinia' 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 interface WorkExperience { @@ -36,6 +36,7 @@ export interface WizardInferenceData { anthropicKey: string openaiUrl: string openaiKey: string + orchUrl: string ollamaHost: string ollamaPort: number services: Record<string, string | number> @@ -90,7 +91,8 @@ export const useWizardStore = defineStore('wizard', () => { anthropicKey: '', openaiUrl: '', openaiKey: '', - ollamaHost: 'localhost', + orchUrl: '', + ollamaHost: '', ollamaPort: 11434, services: {}, confirmed: false, @@ -127,6 +129,7 @@ export const useWizardStore = defineStore('wizard', () => { wizard_step: number saved_data: { inference_profile?: string + cf_orch_url?: string tier?: string name?: string email?: string @@ -143,6 +146,8 @@ export const useWizardStore = defineStore('wizard', () => { if (saved.inference_profile) 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) tier.value = saved.tier as WizardTier if (saved.name) identity.value.name = saved.name @@ -222,6 +227,7 @@ export const useWizardStore = defineStore('wizard', () => { anthropic_key: inference.value.anthropicKey, openai_url: inference.value.openaiUrl, openai_key: inference.value.openaiKey, + orch_url: inference.value.orchUrl, ollama_host: inference.value.ollamaHost, ollama_port: inference.value.ollamaPort, } diff --git a/web/src/stores/wizard/aiInterview.ts b/web/src/stores/wizard/aiInterview.ts index 220c226..322f513 100644 --- a/web/src/stores/wizard/aiInterview.ts +++ b/web/src/stores/wizard/aiInterview.ts @@ -55,7 +55,20 @@ export const useAiInterviewStore = defineStore('aiInterview', () => { }) loading.value = false if (err || !data) { - error.value = 'Could not reach the assistant. Please try again.' + 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.' + } + } else { + error.value = 'Could not reach the assistant. Please try again.' + } return } messages.value = [...messages.value, { role: 'assistant', content: data.reply }] diff --git a/web/src/views/HomeView.vue b/web/src/views/HomeView.vue index d04736e..4eaa775 100644 --- a/web/src/views/HomeView.vue +++ b/web/src/views/HomeView.vue @@ -12,7 +12,10 @@ {{ greeting }} <span v-if="isMidnight" aria-label="Late night session">🌙</span> </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> </header> @@ -600,7 +603,22 @@ onUnmounted(() => { font-size: var(--text-sm); text-transform: uppercase; 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 { display: grid; diff --git a/web/src/views/JobReviewView.vue b/web/src/views/JobReviewView.vue index cfff320..2820b4f 100644 --- a/web/src/views/JobReviewView.vue +++ b/web/src/views/JobReviewView.vue @@ -9,6 +9,7 @@ <header class="review__header"> <div class="review__title-row"> <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"> <span aria-hidden="true">?</span> <span class="sr-only">Keyboard shortcuts</span> @@ -429,6 +430,17 @@ onUnmounted(() => { 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 { width: 32px; height: 32px; diff --git a/web/src/views/ResumesView.vue b/web/src/views/ResumesView.vue index f17b6c4..dcff487 100644 --- a/web/src/views/ResumesView.vue +++ b/web/src/views/ResumesView.vue @@ -2,6 +2,7 @@ <div class="rv"> <div class="rv__header"> <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"> <span aria-hidden="true">📥</span> Import <input type="file" accept=".txt,.pdf,.docx,.odt,.yaml,.yml" @@ -314,7 +315,10 @@ onBeforeRouteLeave(() => { <style scoped> .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__file-input { display: none; } .rv__import-btn { cursor: pointer; } diff --git a/web/src/views/settings/ResumeProfileView.vue b/web/src/views/settings/ResumeProfileView.vue index 11b42aa..445254e 100644 --- a/web/src/views/settings/ResumeProfileView.vue +++ b/web/src/views/settings/ResumeProfileView.vue @@ -1,6 +1,9 @@ <template> <div class="resume-profile"> - <h2>Resume Profile</h2> + <div class="page-header"> + <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 --> <div v-if="loadError" class="error-banner"> @@ -401,6 +404,10 @@ async function handleUpload() { <style scoped> .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); } 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); } diff --git a/web/src/views/settings/SearchPrefsView.vue b/web/src/views/settings/SearchPrefsView.vue index eea5500..27d5a27 100644 --- a/web/src/views/settings/SearchPrefsView.vue +++ b/web/src/views/settings/SearchPrefsView.vue @@ -1,6 +1,9 @@ <template> <div class="search-prefs"> - <h2>Search Preferences</h2> + <div class="page-header"> + <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> <!-- Remote Preference --> @@ -154,8 +157,10 @@ <script setup lang="ts"> import { ref, onMounted } from 'vue' import { useSearchStore } from '../../stores/settings/search' +import { useDocsUrl } from '../../composables/useDocsUrl' const store = useSearchStore() +const docsUrl = useDocsUrl('user-guide/settings/#search-prefs') const remoteOptions = [ { value: 'remote' as const, label: 'Remote only' }, @@ -186,6 +191,10 @@ onMounted(() => store.load()) <style scoped> .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); } 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); } diff --git a/web/src/views/settings/SystemSettingsView.vue b/web/src/views/settings/SystemSettingsView.vue index 7b1bd92..2f78bd2 100644 --- a/web/src/views/settings/SystemSettingsView.vue +++ b/web/src/views/settings/SystemSettingsView.vue @@ -136,6 +136,29 @@ <p v-if="store.deployError" class="error-msg">{{ store.deployError }}</p> </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 --> <Teleport to="body"> <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) } +// ── 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 () => { await store.loadLlm() const tasks = [ store.loadServices(), store.loadFilePaths(), store.loadDeployConfig(), + loadOrchUrl(), ] if (config.isCloud && tierOrder.indexOf(tier.value) >= tierOrder.indexOf('paid')) { 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 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-input-wide { width: 100%; max-width: 400px; } .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-danger { diff --git a/web/src/views/wizard/WizardHardwareStep.vue b/web/src/views/wizard/WizardHardwareStep.vue index 84a8660..e0666b6 100644 --- a/web/src/views/wizard/WizardHardwareStep.vue +++ b/web/src/views/wizard/WizardHardwareStep.vue @@ -13,9 +13,28 @@ {{ wizard.hardware.gpus.join(', ') }} </div> <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> + <!-- 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"> <label class="step__label" for="hw-profile">Inference profile</label> <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="dual-gpu">Dual GPU — local Ollama + two GPUs</option> <option value="cf-orch"> - cf-orch — CircuitForge GPU cluster + Orchard — CircuitForge GPU cluster {{ orchAvailable ? `(${orchGpus.length} GPU(s) available)` : '(configure endpoint below)' }} </option> <option value="remote">Remote — use cloud API keys</option> @@ -49,7 +68,7 @@ </div> <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 id="orch-url" v-model="orchUrl" @@ -58,14 +77,14 @@ placeholder="http://10.1.10.71:7700" /> <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. </p> </div> <div class="step__tier-note"> <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. </div> </template> @@ -75,7 +94,7 @@ class="step__warning" > ⚠️ 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> </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 orchUrl = ref('') +// local service probe results +const ollamaRunning = ref(false) +const searxngRunning = ref(false) + onMounted(async () => { detecting.value = true const { data } = await useApiFetch<{ @@ -115,6 +138,8 @@ onMounted(async () => { profiles: string[] cf_orch_available: boolean 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') detecting.value = false if (!data) return @@ -128,6 +153,8 @@ onMounted(async () => { orchAvailable.value = data.cf_orch_available ?? false orchGpus.value = data.cf_orch_gpus ?? [] + ollamaRunning.value = data.ollama_running ?? false + searxngRunning.value = data.searxng_running ?? false }) async function next() { @@ -140,3 +167,40 @@ async function next() { if (ok) router.push('/setup/tier') } </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> diff --git a/web/src/views/wizard/WizardIdentityStep.vue b/web/src/views/wizard/WizardIdentityStep.vue index 46ee6bc..6928159 100644 --- a/web/src/views/wizard/WizardIdentityStep.vue +++ b/web/src/views/wizard/WizardIdentityStep.vue @@ -1,6 +1,6 @@ <template> <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"> Used in cover letters, research briefs, and interview prep. You can update this any time in Settings → My Profile. diff --git a/web/src/views/wizard/WizardInferenceStep.vue b/web/src/views/wizard/WizardInferenceStep.vue index a59a711..e96e02d 100644 --- a/web/src/views/wizard/WizardInferenceStep.vue +++ b/web/src/views/wizard/WizardInferenceStep.vue @@ -1,6 +1,6 @@ <template> <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"> Configure how Peregrine generates AI content. You can adjust this any time in Settings → System. @@ -36,7 +36,35 @@ </div> </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> <div class="step__info"> Local mode ({{ wizard.hardware.selectedProfile }}): Peregrine uses @@ -81,12 +109,19 @@ import { reactive, ref, computed } from 'vue' import { useRouter } from 'vue-router' import { useWizardStore } from '../../stores/wizard' +import { useAppConfigStore } from '../../stores/appConfig' import './wizard.css' const wizard = useWizardStore() +const config = useAppConfigStore() const router = useRouter() +const MANAGED_ORCH_URL = 'https://orch.circuitforge.tech' + 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 testing = ref(false) const testResult = ref<{ ok: boolean; message: string } | null>(null) @@ -95,19 +130,42 @@ const form = reactive({ anthropicKey: wizard.inference.anthropicKey, openaiUrl: wizard.inference.openaiUrl, openaiKey: wizard.inference.openaiKey, + orchUrl: wizard.inference.orchUrl, }) +const savedSvcs = wizard.inference.services as Record<string, string | number> 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() { testing.value = true testResult.value = null wizard.inference.anthropicKey = form.anthropicKey wizard.inference.openaiUrl = form.openaiUrl 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() testing.value = false } @@ -115,10 +173,10 @@ async function runTest() { function back() { router.push('/setup/identity') } async function next() { - // Sync form back to store wizard.inference.anthropicKey = form.anthropicKey wizard.inference.openaiUrl = form.openaiUrl wizard.inference.openaiKey = form.openaiKey + wizard.inference.orchUrl = form.orchUrl const svcMap: Record<string, string | number> = {} services.forEach(s => { @@ -131,6 +189,7 @@ async function next() { anthropic_key: form.anthropicKey, openai_url: form.openaiUrl, openai_key: form.openaiKey, + orch_url: form.orchUrl, services: svcMap, }) if (ok) router.push('/setup/search') @@ -166,4 +225,33 @@ async function next() { .svc-port { 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> diff --git a/web/src/views/wizard/WizardIntegrationsStep.vue b/web/src/views/wizard/WizardIntegrationsStep.vue index 7e032ff..5c6b92d 100644 --- a/web/src/views/wizard/WizardIntegrationsStep.vue +++ b/web/src/views/wizard/WizardIntegrationsStep.vue @@ -1,6 +1,6 @@ <template> <div class="step"> - <h2 class="step__heading">Step 7 — Integrations</h2> + <h2 class="step__heading">Step 8 — Integrations</h2> <p class="step__caption"> Optional. Connect external tools to supercharge your workflow. You can configure these any time in Settings → System. @@ -54,6 +54,7 @@ const wizard = useWizardStore() const config = useAppConfigStore() const router = useRouter() + const isPaid = computed(() => wizard.tier === 'paid' || wizard.tier === 'premium', ) @@ -87,7 +88,12 @@ async function finish() { // Save integration selections (step 7) then mark wizard complete await wizard.saveStep(8, { integrations: [...checkedIds.value] }) 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> diff --git a/web/src/views/wizard/WizardResumeStep.vue b/web/src/views/wizard/WizardResumeStep.vue index e86aa2a..550ecfa 100644 --- a/web/src/views/wizard/WizardResumeStep.vue +++ b/web/src/views/wizard/WizardResumeStep.vue @@ -2,7 +2,7 @@ <div class="step"> <h2 class="step__heading">Step 3 — Your Resume</h2> <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> <!-- Tabs --> @@ -13,14 +13,31 @@ class="resume-tab" :class="{ 'resume-tab--active': 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 role="tab" :aria-selected="tab === 'manual'" class="resume-tab" :class="{ 'resume-tab--active': 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> <!-- Upload tab --> @@ -106,6 +123,34 @@ </button> </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)"> {{ validationError }} </div> @@ -120,17 +165,21 @@ </template> <script setup lang="ts"> -import { ref } from 'vue' +import { ref, computed } from 'vue' import { useRouter } from 'vue-router' import { useWizardStore } from '../../stores/wizard' import type { WorkExperience } from '../../stores/wizard' import { useApiFetch } from '../../composables/useApi' +import { useAppConfigStore } from '../../stores/appConfig' import './wizard.css' const wizard = useWizardStore() 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', ) const dragging = ref(false) @@ -223,30 +272,69 @@ async function next() { <style scoped> .resume-tabs { display: flex; - gap: 0; - border-bottom: 2px solid var(--color-border-light); + gap: var(--space-2); + border-bottom: 2px solid var(--color-border); margin-bottom: var(--space-6); } .resume-tab { + display: flex; + align-items: center; + gap: var(--space-2); padding: var(--space-2) var(--space-5); - background: none; - border: none; + background: var(--color-surface-alt); + border: 1.5px solid var(--color-border); border-bottom: 2px solid transparent; + border-radius: var(--radius-md) var(--radius-md) 0 0; margin-bottom: -2px; cursor: pointer; font-family: var(--font-body); - font-size: 0.9rem; + font-size: 0.875rem; 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 { + background: var(--color-surface); color: var(--color-primary); - border-bottom-color: var(--color-primary); + border-color: var(--color-border); + border-bottom-color: var(--color-surface); 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 { display: flex; flex-direction: column; @@ -310,4 +398,86 @@ async function next() { grid-template-columns: 1fr 1fr; 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> diff --git a/web/src/views/wizard/WizardSearchStep.vue b/web/src/views/wizard/WizardSearchStep.vue index f3689da..432e937 100644 --- a/web/src/views/wizard/WizardSearchStep.vue +++ b/web/src/views/wizard/WizardSearchStep.vue @@ -1,6 +1,6 @@ <template> <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"> Tell Peregrine what roles and markets to watch. You can add more profiles in Settings → Search later.