fix: search prefs wizard data loss, resume sync link, docs + GUI help links
Bug fixes (filed as #125–#128): - Wizard step 7 read data.titles instead of data.search.titles — user-entered job titles and locations were silently dropped on every wizard run (#125) - GET /api/settings/search returned "titles" key but store expected "job_titles" — Settings → Search Prefs always showed empty even when data existed (#126) - remote_only preference not persisted during wizard setup (#127) - apply-to-profile didn't set default_resume_id in user.yaml, so future Resume Profile saves never synced back to the library entry (#128) Also: - Wizard step headings corrected (off-by-one after Training step was inserted) - Ollama host in wizard inference step now reads from saved wizard state - Resume upload during wizard now creates a library entry and sets it as default Docs: - New: docs/user-guide/daily-workflow.md — end-to-end daily usage guide - Updated: docs/user-guide/settings.md — rewritten for Vue SPA (was Streamlit) - mkdocs.yml nav: Daily Workflow added as first User Guide entry GUI help links: - web/src/composables/useDocsUrl.ts — shared docs base URL composable - Home: "Daily Workflow guide ↗" link in subtitle - Job Review: "? Docs" link in title row - Resume Library: "? Help" link in header - Settings → Resume Profile: "? Help" link in page header - Settings → Search Prefs: "? Help" link in page header
This commit is contained in:
parent
f799aff4e0
commit
82c26074d8
26 changed files with 1301 additions and 249 deletions
|
|
@ -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
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -60,3 +60,4 @@ demo/seed_demo.py
|
|||
tests/e2e/results/demo/
|
||||
tests/e2e/results/cloud/
|
||||
tests/e2e/results/local/
|
||||
config/wizard-test/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
198
dev-api.py
198
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 <data_dir>/.env (next to staging.db). The main
|
||||
_load_env() call targets the image-baked /app/.env, which is a different
|
||||
path. This helper bridges the gap by force-overriding env vars that are
|
||||
unset or empty (compose injects empty strings for optional vars).
|
||||
"""
|
||||
data_env = Path(DB_PATH).parent / ".env"
|
||||
if not data_env.exists():
|
||||
return
|
||||
for line in data_env.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key, value = key.strip(), value.strip()
|
||||
if value and not os.environ.get(key):
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
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": (
|
||||
|
|
|
|||
|
|
@ -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=<name>`.
|
||||
Peregrine uses Docker Compose profiles to start only the services your hardware supports. Choose a profile with `./manage.sh start --profile <name>`.
|
||||
|
||||
`manage.sh` delegates to `make`, which auto-detects Docker vs Podman and applies the correct GPU overlay — `compose.gpu.yml` for Docker, `compose.podman-gpu.yml` for Podman (CDI-based). You do not need to specify the overlay manually.
|
||||
|
||||
---
|
||||
|
||||
## Profile Reference
|
||||
|
||||
| Profile | 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 <name>` 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.
|
||||
|
|
|
|||
|
|
@ -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 <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.
|
||||
|
|
|
|||
142
docs/user-guide/daily-workflow.md
Normal file
142
docs/user-guide/daily-workflow.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# Daily Workflow
|
||||
|
||||
This page describes how Peregrine fits into a typical active job search. The core loop is short: find jobs, triage them, generate and send applications, track what happens next.
|
||||
|
||||
---
|
||||
|
||||
## The Core Loop
|
||||
|
||||
```
|
||||
Run Discovery → Review Jobs → Apply Workspace → Track in Interviews
|
||||
```
|
||||
|
||||
Each stage feeds the next. You can run the full loop in under ten minutes on a good day, or spend longer editing cover letters and doing interview prep when you need to.
|
||||
|
||||
---
|
||||
|
||||
## Starting Your Day
|
||||
|
||||
### 1. Run Discovery
|
||||
|
||||
Open the **Home** page and click **Run Discovery**. Peregrine queries all your configured job boards simultaneously and stores results in the local database.
|
||||
|
||||
- Discovery runs one search profile at a time. Each profile produces results per board, then moves to the next.
|
||||
- A summary at the end shows how many new jobs were found vs. already known.
|
||||
- Jobs you have already seen (by URL) are skipped automatically.
|
||||
|
||||
If some jobs came back with short descriptions, click **Fill Missing Descriptions** to enrich them in the background while you work.
|
||||
|
||||
See [Job Discovery](job-discovery.md) for search profile configuration and board details.
|
||||
|
||||
---
|
||||
|
||||
### 2. Review the Queue
|
||||
|
||||
Navigate to **Job Review**. New jobs arrive with status `pending` and appear in the review queue.
|
||||
|
||||
For each job you can:
|
||||
- **Approve** — sends it into the application pipeline
|
||||
- **Reject** — archives it out of the queue
|
||||
|
||||
Sort by **Match Score** (high to low) to see the best keyword matches first. The match score compares the job description against your resume keywords — a rough signal, not a hard filter.
|
||||
|
||||
Jobs with incoming email leads (a recruiter contacted you about this role) sort to the top automatically.
|
||||
|
||||
See [Job Review](job-review.md) for sorting, keyword gaps, and bulk actions.
|
||||
|
||||
---
|
||||
|
||||
### 3. Write and Send Applications
|
||||
|
||||
Navigate to **Apply Workspace**. All approved jobs appear here.
|
||||
|
||||
For each job:
|
||||
1. Click **Generate Cover Letter** — runs as a background task using your resume and career summary.
|
||||
2. Read and edit the result. The generator uses your mission alignment notes when it detects company fit.
|
||||
3. Click **Export PDF** to save a formatted PDF to your documents directory.
|
||||
4. Apply externally (via the company site or board).
|
||||
5. Click **Mark Applied** to move the job into the Interviews kanban.
|
||||
|
||||
See [Apply Workspace](apply-workspace.md) for cover letter configuration, PDF formatting, and ATS optimization.
|
||||
|
||||
---
|
||||
|
||||
### 4. Track Interviews
|
||||
|
||||
The **Interviews** page is a kanban board. Jobs move through stages as your search progresses:
|
||||
|
||||
```
|
||||
applied → phone_screen → interviewing → offer → hired
|
||||
```
|
||||
|
||||
When a job moves to **phone_screen**, Peregrine automatically kicks off a company research brief in the background — a one-page summary of the company, recent news, leadership, and accessibility signals.
|
||||
|
||||
Use **Interview Prep** to review talking points, practice Q&A, and get live reference cards during calls.
|
||||
|
||||
See [Interviews](interviews.md) for stage transitions, research briefs, and prep tools.
|
||||
|
||||
---
|
||||
|
||||
## Managing Your Resume
|
||||
|
||||
Peregrine has two resume views that work together:
|
||||
|
||||
### Resume Library (`/resumes`)
|
||||
|
||||
An archive of every resume version — uploaded originals, AI-optimised variants, and auto-backups. The starred entry is your **active default**.
|
||||
|
||||
- **Import** a PDF, DOCX, ODT, or plain text file to add a version to the library.
|
||||
- **★ Set as Default** marks the entry as the active resume used for cover letter generation and keyword matching.
|
||||
- **⇩ Apply to profile** pushes a library entry into the structured Resume Profile (see below), and links it so future profile edits sync back automatically.
|
||||
|
||||
### Resume Profile (`Settings → Resume Profile`)
|
||||
|
||||
A structured editor for personal details, work experience, education, and skills. This is the data the cover letter generator reads directly.
|
||||
|
||||
- When content was applied from the library, the view shows a sync status and date.
|
||||
- Saving the Resume Profile automatically updates the linked library entry — keeping them in sync without manual effort.
|
||||
- You can replace the current profile by uploading a new file directly from this view.
|
||||
|
||||
**Recommended flow:** upload to the library → set as default → "Apply to profile" → edit in Resume Profile as needed. Your library stays current automatically.
|
||||
|
||||
---
|
||||
|
||||
## Keeping Search Preferences Fresh
|
||||
|
||||
Go to **Settings → Search Prefs** to update what Peregrine searches for.
|
||||
|
||||
Key fields:
|
||||
|
||||
| Field | What it does |
|
||||
|-------|-------------|
|
||||
| Job Titles | The roles searched across all boards |
|
||||
| Locations | Geographic scope (leave blank for unrestricted) |
|
||||
| Remote only | Filter to remote positions only |
|
||||
| Exclude Keywords | Drop any job title containing these words before it enters the database |
|
||||
| Job Boards | Enable or disable specific sources |
|
||||
| Blocklists | Companies, industries, or locations to always skip |
|
||||
|
||||
Click **Suggest** next to any field to get AI-generated suggestions based on your resume profile.
|
||||
|
||||
Changes take effect on the next discovery run — no restart needed.
|
||||
|
||||
---
|
||||
|
||||
## Weekly Habits
|
||||
|
||||
**Clean up the queue** — reject stale pending jobs at least once a week so the queue stays scannable.
|
||||
|
||||
**Update your search prefs** — if you are getting too many mismatches, add more terms to Exclude Keywords. If the queue is thin, broaden Locations or add boards.
|
||||
|
||||
**Check Interviews** — move any stalled jobs to the right stage so the kanban reflects reality. The research brief appears in Interview Prep once a job reaches `phone_screen`.
|
||||
|
||||
**Tune your resume keywords** — go to **Settings → Skills** if you want to add or reweight keywords used for match scoring.
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- **Match score is a triage signal, not a gate.** A score of 40 might be a perfect cultural fit that uses different terminology. Read the description.
|
||||
- **Cover letters improve with context.** The richer your career summary and mission alignment notes (Settings → My Profile), the more specific and accurate the generated letters.
|
||||
- **Company research auto-runs.** You do not need to request it manually — it starts the moment a job hits `phone_screen`.
|
||||
- **Everything is local.** Your database, resume, and application history live in `data/staging.db` and `data/config/`. Back them up like any other important file.
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <title>" and print footer "file:///... N/N")
|
||||
_BROWSER_ARTIFACT_RE = re.compile(
|
||||
r"^file:///" # file:// URL footer
|
||||
r"|^\d{1,2}/\d{1,2}/\d{2,4},\s+\d{1,2}:\d{2}\s+[AP]M\b", # MM/DD/YY, H:MM AM/PM header
|
||||
re.I,
|
||||
)
|
||||
|
||||
# ── Section header detection ──────────────────────────────────────────────────
|
||||
|
||||
_SECTION_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,38 +325,76 @@ 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)
|
||||
|
||||
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(" ,–—|•.")
|
||||
# Layout A: institution was on the line before the degree line
|
||||
if prev_line and not _DEGREE_RE.search(prev_line):
|
||||
if prev_line and not _DEGREE_RE.search(prev_line) and not _DATE_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(" ,–—|•")
|
||||
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:
|
||||
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:
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
5
web/src/composables/useDocsUrl.ts
Normal file
5
web/src/composables/useDocsUrl.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const DOCS_BASE = 'https://docs.circuitforge.tech/peregrine'
|
||||
|
||||
export function useDocsUrl(path: string): string {
|
||||
return `${DOCS_BASE}/${path}`
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,20 @@ export const useAiInterviewStore = defineStore('aiInterview', () => {
|
|||
})
|
||||
loading.value = false
|
||||
if (err || !data) {
|
||||
if (err?.kind === 'http' && err.status === 402) {
|
||||
error.value = 'AI profile assistant requires a Paid plan or a BYOK API key.'
|
||||
} else if (err?.kind === 'http' && err.status === 503) {
|
||||
try {
|
||||
const body = JSON.parse(err.detail) as { detail?: { error?: string } }
|
||||
error.value = body.detail?.error === 'llm_error'
|
||||
? 'No LLM backend configured — add an API key in Settings → System first.'
|
||||
: 'Could not reach the assistant. Please try again.'
|
||||
} catch {
|
||||
error.value = 'Could not reach the assistant. Please try again.'
|
||||
}
|
||||
} else {
|
||||
error.value = 'Could not reach the assistant. Please try again.'
|
||||
}
|
||||
return
|
||||
}
|
||||
messages.value = [...messages.value, { role: 'assistant', content: data.reply }]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
<template>
|
||||
<div class="resume-profile">
|
||||
<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); }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
<template>
|
||||
<div class="search-prefs">
|
||||
<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); }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue