Merge pull request 'feat(vue): open Vue SPA to all tiers; fix cloud navigation and feedback button' (#64) from feature/vue-streamlit-parity into main
Some checks failed
CI / test (push) Failing after 28s

This commit is contained in:
pyr0ball 2026-04-02 17:46:47 -07:00
commit 8b1d576e43
16 changed files with 101 additions and 60 deletions

View file

@ -12,8 +12,11 @@ VISION_REVISION=2025-01-09
DOCS_DIR=~/Documents/JobSearch DOCS_DIR=~/Documents/JobSearch
OLLAMA_MODELS_DIR=~/models/ollama OLLAMA_MODELS_DIR=~/models/ollama
VLLM_MODELS_DIR=~/models/vllm VLLM_MODELS_DIR=~/models/vllm # override with full path to your model dir
VLLM_MODEL=Ouro-1.4B VLLM_MODEL=Ouro-1.4B # cover letters — fast 1.4B model
VLLM_RESEARCH_MODEL=Ouro-2.6B-Thinking # research — reasoning 2.6B model; restart vllm to switch
VLLM_MAX_MODEL_LEN=4096 # increase to 8192 for Thinking models with long CoT
VLLM_GPU_MEM_UTIL=0.75 # lower to 0.6 if sharing GPU with other services
OLLAMA_DEFAULT_MODEL=llama3.2:3b OLLAMA_DEFAULT_MODEL=llama3.2:3b
# API keys (required for remote profile) # API keys (required for remote profile)

View file

@ -200,7 +200,7 @@ def render_banner(yaml_path: Path, tier: str) -> None:
col1, col2, col3 = st.columns([8, 1, 1]) col1, col2, col3 = st.columns([8, 1, 1])
with col1: with col1:
st.info("✨ **New Peregrine UI available** — try the modern Vue interface (Beta, Paid tier)") st.info("✨ **New Peregrine UI available** — try the modern Vue interface (Beta)")
with col2: with col2:
if st.button("Try it", key="_ui_banner_try"): if st.button("Try it", key="_ui_banner_try"):
switch_ui(yaml_path, to="vue", tier=tier) switch_ui(yaml_path, to="vue", tier=tier)

View file

@ -15,28 +15,28 @@ import streamlit.components.v1 as components
import yaml import yaml
from scripts.user_profile import UserProfile from scripts.user_profile import UserProfile
_USER_YAML = Path(__file__).parent.parent.parent / "config" / "user.yaml"
_profile = UserProfile(_USER_YAML) if UserProfile.exists(_USER_YAML) else None
_name = _profile.name if _profile else "Job Seeker"
from scripts.db import ( from scripts.db import (
DEFAULT_DB, init_db, get_jobs_by_status, DEFAULT_DB, init_db, get_jobs_by_status,
update_cover_letter, mark_applied, update_job_status, update_cover_letter, mark_applied, update_job_status,
get_task_for_job, get_task_for_job,
) )
from scripts.task_runner import submit_task from scripts.task_runner import submit_task
from app.cloud_session import resolve_session, get_db_path from app.cloud_session import resolve_session, get_db_path, get_config_dir
from app.telemetry import log_usage_event from app.telemetry import log_usage_event
DOCS_DIR = _profile.docs_dir if _profile else Path.home() / "Documents" / "JobSearch"
RESUME_YAML = Path(__file__).parent.parent.parent / "config" / "plain_text_resume.yaml"
st.title("🚀 Apply Workspace") st.title("🚀 Apply Workspace")
resolve_session("peregrine") resolve_session("peregrine")
init_db(get_db_path()) init_db(get_db_path())
_CONFIG_DIR = get_config_dir()
_USER_YAML = _CONFIG_DIR / "user.yaml"
_profile = UserProfile(_USER_YAML) if UserProfile.exists(_USER_YAML) else None
_name = _profile.name if _profile else "Job Seeker"
DOCS_DIR = _profile.docs_dir if _profile else Path.home() / "Documents" / "JobSearch"
RESUME_YAML = _CONFIG_DIR / "plain_text_resume.yaml"
# ── PDF generation ───────────────────────────────────────────────────────────── # ── PDF generation ─────────────────────────────────────────────────────────────
def _make_cover_letter_pdf(job: dict, cover_letter: str, output_dir: Path) -> Path: def _make_cover_letter_pdf(job: dict, cover_letter: str, output_dir: Path) -> Path:
from reportlab.lib.pagesizes import letter from reportlab.lib.pagesizes import letter

View file

@ -64,8 +64,8 @@ FEATURES: dict[str, str] = {
"apple_calendar_sync": "paid", "apple_calendar_sync": "paid",
"slack_notifications": "paid", "slack_notifications": "paid",
# Beta UI access — stays gated (access management, not compute) # Beta UI access — open to all tiers (access management, not compute)
"vue_ui_beta": "paid", "vue_ui_beta": "free",
} }
# Features that unlock when the user supplies any LLM backend (local or BYOK). # Features that unlock when the user supplies any LLM backend (local or BYOK).

View file

@ -37,6 +37,7 @@ services:
- PEREGRINE_CADDY_PROXY=1 - PEREGRINE_CADDY_PROXY=1
- CF_ORCH_URL=http://host.docker.internal:7700 - CF_ORCH_URL=http://host.docker.internal:7700
- DEMO_MODE=false - DEMO_MODE=false
- FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-}
depends_on: depends_on:
searxng: searxng:
condition: service_healthy condition: service_healthy
@ -48,6 +49,8 @@ services:
build: build:
context: . context: .
dockerfile: docker/web/Dockerfile dockerfile: docker/web/Dockerfile
args:
VITE_BASE_PATH: /peregrine/
ports: ports:
- "8508:80" - "8508:80"
restart: unless-stopped restart: unless-stopped

View file

@ -42,6 +42,8 @@ services:
build: build:
context: . context: .
dockerfile: docker/web/Dockerfile dockerfile: docker/web/Dockerfile
args:
VITE_BASE_PATH: /peregrine/
ports: ports:
- "8507:80" - "8507:80"
restart: unless-stopped restart: unless-stopped

View file

@ -1,5 +1,5 @@
# compose.yml — Peregrine by Circuit Forge LLC # compose.yml — Peregrine by Circuit Forge LLC
# Profiles: remote | cpu | single-gpu | dual-gpu-ollama | dual-gpu-vllm | dual-gpu-mixed # Profiles: remote | cpu | single-gpu | dual-gpu-ollama
services: services:
app: app:
@ -129,23 +129,6 @@ services:
profiles: [single-gpu, dual-gpu-ollama, dual-gpu-vllm, dual-gpu-mixed] profiles: [single-gpu, dual-gpu-ollama, dual-gpu-vllm, dual-gpu-mixed]
restart: unless-stopped restart: unless-stopped
vllm:
image: vllm/vllm-openai:latest
ports:
- "${VLLM_PORT:-8000}:8000"
volumes:
- ${VLLM_MODELS_DIR:-~/models/vllm}:/models
command: >
--model /models/${VLLM_MODEL:-Ouro-1.4B}
--trust-remote-code
--max-model-len 4096
--gpu-memory-utilization 0.75
--enforce-eager
--max-num-seqs 8
--cpu-offload-gb ${CPU_OFFLOAD_GB:-0}
profiles: [dual-gpu-vllm, dual-gpu-mixed]
restart: unless-stopped
finetune: finetune:
build: build:
context: . context: .

View file

@ -28,9 +28,9 @@ backends:
type: openai_compat type: openai_compat
ollama_research: ollama_research:
api_key: ollama api_key: ollama
base_url: http://host.docker.internal:11434/v1 base_url: http://ollama_research:11434/v1
enabled: true enabled: true
model: llama3.2:3b model: llama3.1:8b
supports_images: false supports_images: false
type: openai_compat type: openai_compat
vision_service: vision_service:
@ -45,6 +45,11 @@ backends:
model: __auto__ model: __auto__
supports_images: false supports_images: false
type: openai_compat type: openai_compat
cf_orch:
service: vllm
model_candidates:
- Qwen2.5-3B-Instruct
ttl_s: 300
vllm_research: vllm_research:
api_key: '' api_key: ''
base_url: http://host.docker.internal:8000/v1 base_url: http://host.docker.internal:8000/v1

View file

@ -4,6 +4,8 @@ WORKDIR /app
COPY web/package*.json ./ COPY web/package*.json ./
RUN npm ci --prefer-offline RUN npm ci --prefer-offline
COPY web/ ./ COPY web/ ./
ARG VITE_BASE_PATH=/
ENV VITE_BASE_PATH=${VITE_BASE_PATH}
RUN npm run build RUN npm run build
# Stage 2: serve # Stage 2: serve

View file

@ -5,9 +5,13 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# SPA fallback # Proxy API calls to the FastAPI backend service
location / { location /api/ {
try_files $uri $uri/ /index.html; proxy_pass http://api:8601;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 120s;
} }
# Cache static assets # Cache static assets
@ -15,4 +19,9 @@ server {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
} }
# SPA fallback must come after API and assets
location / {
try_files $uri $uri/ /index.html;
}
} }

View file

@ -26,13 +26,14 @@ LETTERS_DIR = _profile.docs_dir if _profile else Path.home() / "Documents" / "Jo
LETTER_GLOB = "*Cover Letter*.md" LETTER_GLOB = "*Cover Letter*.md"
# Background injected into every prompt so the model has the candidate's facts # Background injected into every prompt so the model has the candidate's facts
def _build_system_context() -> str: def _build_system_context(profile=None) -> str:
if not _profile: p = profile or _profile
if not p:
return "You are a professional cover letter writer. Write in first person." return "You are a professional cover letter writer. Write in first person."
parts = [f"You are writing cover letters for {_profile.name}. {_profile.career_summary}"] parts = [f"You are writing cover letters for {p.name}. {p.career_summary}"]
if _profile.candidate_voice: if p.candidate_voice:
parts.append( parts.append(
f"Voice and personality: {_profile.candidate_voice} " f"Voice and personality: {p.candidate_voice} "
"Write in a way that reflects these authentic traits — not as a checklist, " "Write in a way that reflects these authentic traits — not as a checklist, "
"but as a natural expression of who this person is." "but as a natural expression of who this person is."
) )
@ -125,15 +126,17 @@ _MISSION_DEFAULTS: dict[str, str] = {
} }
def _build_mission_notes() -> dict[str, str]: def _build_mission_notes(profile=None, candidate_name: str | None = None) -> dict[str, str]:
"""Merge user's custom mission notes with generic defaults.""" """Merge user's custom mission notes with generic defaults."""
prefs = _profile.mission_preferences if _profile else {} p = profile or _profile
name = candidate_name or _candidate
prefs = p.mission_preferences if p else {}
notes = {} notes = {}
for industry, default_note in _MISSION_DEFAULTS.items(): for industry, default_note in _MISSION_DEFAULTS.items():
custom = (prefs.get(industry) or "").strip() custom = (prefs.get(industry) or "").strip()
if custom: if custom:
notes[industry] = ( notes[industry] = (
f"Mission alignment — {_candidate} shared: \"{custom}\". " f"Mission alignment — {name} shared: \"{custom}\". "
"Para 3 should warmly and specifically reflect this authentic connection." "Para 3 should warmly and specifically reflect this authentic connection."
) )
else: else:
@ -144,12 +147,15 @@ def _build_mission_notes() -> dict[str, str]:
_MISSION_NOTES = _build_mission_notes() _MISSION_NOTES = _build_mission_notes()
def detect_mission_alignment(company: str, description: str) -> str | None: def detect_mission_alignment(
company: str, description: str, mission_notes: dict | None = None
) -> str | None:
"""Return a mission hint string if company/JD matches a preferred industry, else None.""" """Return a mission hint string if company/JD matches a preferred industry, else None."""
notes = mission_notes if mission_notes is not None else _MISSION_NOTES
text = f"{company} {description}".lower() text = f"{company} {description}".lower()
for industry, signals in _MISSION_SIGNALS.items(): for industry, signals in _MISSION_SIGNALS.items():
if any(sig in text for sig in signals): if any(sig in text for sig in signals):
return _MISSION_NOTES[industry] return notes[industry]
return None return None
@ -190,10 +196,14 @@ def build_prompt(
examples: list[dict], examples: list[dict],
mission_hint: str | None = None, mission_hint: str | None = None,
is_jobgether: bool = False, is_jobgether: bool = False,
system_context: str | None = None,
candidate_name: str | None = None,
) -> str: ) -> str:
parts = [SYSTEM_CONTEXT.strip(), ""] ctx = system_context if system_context is not None else SYSTEM_CONTEXT
name = candidate_name or _candidate
parts = [ctx.strip(), ""]
if examples: if examples:
parts.append(f"=== STYLE EXAMPLES ({_candidate}'s past letters) ===\n") parts.append(f"=== STYLE EXAMPLES ({name}'s past letters) ===\n")
for i, ex in enumerate(examples, 1): for i, ex in enumerate(examples, 1):
parts.append(f"--- Example {i} ({ex['company']}) ---") parts.append(f"--- Example {i} ({ex['company']}) ---")
parts.append(ex["text"]) parts.append(ex["text"])
@ -231,13 +241,14 @@ def build_prompt(
return "\n".join(parts) return "\n".join(parts)
def _trim_to_letter_end(text: str) -> str: def _trim_to_letter_end(text: str, profile=None) -> str:
"""Remove repetitive hallucinated content after the first complete sign-off. """Remove repetitive hallucinated content after the first complete sign-off.
Fine-tuned models sometimes loop after completing the letter. This cuts at Fine-tuned models sometimes loop after completing the letter. This cuts at
the first closing + candidate name so only the intended letter is saved. the first closing + candidate name so only the intended letter is saved.
""" """
candidate_first = (_profile.name.split()[0] if _profile else "").strip() p = profile or _profile
candidate_first = (p.name.split()[0] if p else "").strip()
pattern = ( pattern = (
r'(?:Warm regards|Sincerely|Best regards|Kind regards|Thank you)[,.]?\s*\n+\s*' r'(?:Warm regards|Sincerely|Best regards|Kind regards|Thank you)[,.]?\s*\n+\s*'
+ (re.escape(candidate_first) if candidate_first else r'\w+(?:\s+\w+)?') + (re.escape(candidate_first) if candidate_first else r'\w+(?:\s+\w+)?')
@ -257,6 +268,8 @@ def generate(
feedback: str = "", feedback: str = "",
is_jobgether: bool = False, is_jobgether: bool = False,
_router=None, _router=None,
config_path: "Path | None" = None,
user_yaml_path: "Path | None" = None,
) -> str: ) -> str:
"""Generate a cover letter and return it as a string. """Generate a cover letter and return it as a string.
@ -264,15 +277,29 @@ def generate(
and requested changes are appended to the prompt so the LLM revises rather and requested changes are appended to the prompt so the LLM revises rather
than starting from scratch. than starting from scratch.
user_yaml_path overrides the module-level profile required in cloud mode
so each user's name/voice/mission prefs are used instead of the global default.
_router is an optional pre-built LLMRouter (used in tests to avoid real LLM calls). _router is an optional pre-built LLMRouter (used in tests to avoid real LLM calls).
""" """
# Per-call profile override (cloud mode: each user has their own user.yaml)
if user_yaml_path and Path(user_yaml_path).exists():
_prof = UserProfile(Path(user_yaml_path))
else:
_prof = _profile
sys_ctx = _build_system_context(_prof)
mission_notes = _build_mission_notes(_prof, candidate_name=(_prof.name if _prof else None))
candidate_name = _prof.name if _prof else _candidate
corpus = load_corpus() corpus = load_corpus()
examples = find_similar_letters(description or f"{title} {company}", corpus) examples = find_similar_letters(description or f"{title} {company}", corpus)
mission_hint = detect_mission_alignment(company, description) mission_hint = detect_mission_alignment(company, description, mission_notes=mission_notes)
if mission_hint: if mission_hint:
print(f"[cover-letter] Mission alignment detected for {company}", file=sys.stderr) print(f"[cover-letter] Mission alignment detected for {company}", file=sys.stderr)
prompt = build_prompt(title, company, description, examples, prompt = build_prompt(title, company, description, examples,
mission_hint=mission_hint, is_jobgether=is_jobgether) mission_hint=mission_hint, is_jobgether=is_jobgether,
system_context=sys_ctx, candidate_name=candidate_name)
if previous_result: if previous_result:
prompt += f"\n\n---\nPrevious draft:\n{previous_result}" prompt += f"\n\n---\nPrevious draft:\n{previous_result}"
@ -281,8 +308,9 @@ def generate(
if _router is None: if _router is None:
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
from scripts.llm_router import LLMRouter from scripts.llm_router import LLMRouter, CONFIG_PATH
_router = LLMRouter() resolved = config_path if (config_path and Path(config_path).exists()) else CONFIG_PATH
_router = LLMRouter(resolved)
print(f"[cover-letter] Generating for: {title} @ {company}", file=sys.stderr) print(f"[cover-letter] Generating for: {title} @ {company}", file=sys.stderr)
print(f"[cover-letter] Style examples: {[e['company'] for e in examples]}", file=sys.stderr) print(f"[cover-letter] Style examples: {[e['company'] for e in examples]}", file=sys.stderr)
@ -292,7 +320,7 @@ def generate(
# max_tokens=1200 caps generation at ~900 words — enough for any cover letter # max_tokens=1200 caps generation at ~900 words — enough for any cover letter
# and prevents fine-tuned models from looping into repetitive garbage output. # and prevents fine-tuned models from looping into repetitive garbage output.
result = _router.complete(prompt, max_tokens=1200) result = _router.complete(prompt, max_tokens=1200)
return _trim_to_letter_end(result) return _trim_to_letter_end(result, _prof)
def main() -> None: def main() -> None:

View file

@ -47,7 +47,7 @@ OVERRIDE_YML = ROOT / "compose.override.yml"
_SERVICES: dict[str, tuple[str, int, str, bool, bool]] = { _SERVICES: dict[str, tuple[str, int, str, bool, bool]] = {
"streamlit": ("streamlit_port", 8501, "STREAMLIT_PORT", True, False), "streamlit": ("streamlit_port", 8501, "STREAMLIT_PORT", True, False),
"searxng": ("searxng_port", 8888, "SEARXNG_PORT", True, True), "searxng": ("searxng_port", 8888, "SEARXNG_PORT", True, True),
"vllm": ("vllm_port", 8000, "VLLM_PORT", True, True), # vllm removed — now managed by cf-orch (host process), not a Docker service
"vision": ("vision_port", 8002, "VISION_PORT", True, True), "vision": ("vision_port", 8002, "VISION_PORT", True, True),
"ollama": ("ollama_port", 11434, "OLLAMA_PORT", True, True), "ollama": ("ollama_port", 11434, "OLLAMA_PORT", True, True),
"ollama_research": ("ollama_research_port", 11435, "OLLAMA_RESEARCH_PORT", True, True), "ollama_research": ("ollama_research_port", 11435, "OLLAMA_RESEARCH_PORT", True, True),
@ -65,7 +65,6 @@ _LLM_BACKENDS: dict[str, list[tuple[str, str]]] = {
_DOCKER_INTERNAL: dict[str, tuple[str, int]] = { _DOCKER_INTERNAL: dict[str, tuple[str, int]] = {
"ollama": ("ollama", 11434), "ollama": ("ollama", 11434),
"ollama_research": ("ollama_research", 11434), # container-internal port is always 11434 "ollama_research": ("ollama_research", 11434), # container-internal port is always 11434
"vllm": ("vllm", 8000),
"vision": ("vision", 8002), "vision": ("vision", 8002),
"searxng": ("searxng", 8080), # searxng internal port differs from host port "searxng": ("searxng", 8080), # searxng internal port differs from host port
} }

View file

@ -179,6 +179,9 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int,
import json as _json import json as _json
p = _json.loads(params or "{}") p = _json.loads(params or "{}")
from scripts.generate_cover_letter import generate from scripts.generate_cover_letter import generate
_cfg_dir = Path(db_path).parent / "config"
_user_llm_cfg = _cfg_dir / "llm.yaml"
_user_yaml = _cfg_dir / "user.yaml"
result = generate( result = generate(
job.get("title", ""), job.get("title", ""),
job.get("company", ""), job.get("company", ""),
@ -186,6 +189,8 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int,
previous_result=p.get("previous_result", ""), previous_result=p.get("previous_result", ""),
feedback=p.get("feedback", ""), feedback=p.get("feedback", ""),
is_jobgether=job.get("source") == "jobgether", is_jobgether=job.get("source") == "jobgether",
config_path=_user_llm_cfg,
user_yaml_path=_user_yaml,
) )
update_cover_letter(db_path, job_id, result) update_cover_letter(db_path, job_id, result)

View file

@ -80,7 +80,8 @@ class TestTaskRunnerCoverLetterParams:
captured = {} captured = {}
def mock_generate(title, company, description="", previous_result="", feedback="", def mock_generate(title, company, description="", previous_result="", feedback="",
is_jobgether=False, _router=None): is_jobgether=False, _router=None, config_path=None,
user_yaml_path=None):
captured.update({ captured.update({
"title": title, "company": company, "title": title, "company": company,
"previous_result": previous_result, "feedback": feedback, "previous_result": previous_result, "feedback": feedback,

View file

@ -3,7 +3,7 @@ import { useAppConfigStore } from '../stores/appConfig'
import { settingsGuard } from './settingsGuard' import { settingsGuard } from './settingsGuard'
export const router = createRouter({ export const router = createRouter({
history: createWebHistory(), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes: [
{ path: '/', component: () => import('../views/HomeView.vue') }, { path: '/', component: () => import('../views/HomeView.vue') },
{ path: '/review', component: () => import('../views/JobReviewView.vue') }, { path: '/review', component: () => import('../views/JobReviewView.vue') },

View file

@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue'
import UnoCSS from 'unocss/vite' import UnoCSS from 'unocss/vite'
export default defineConfig({ export default defineConfig({
base: process.env.VITE_BASE_PATH || '/',
plugins: [vue(), UnoCSS()], plugins: [vue(), UnoCSS()],
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',