feat(vue): open Vue SPA to all tiers; fix cloud navigation and feedback button #64
14 changed files with 93 additions and 58 deletions
|
|
@ -12,8 +12,11 @@ VISION_REVISION=2025-01-09
|
|||
|
||||
DOCS_DIR=~/Documents/JobSearch
|
||||
OLLAMA_MODELS_DIR=~/models/ollama
|
||||
VLLM_MODELS_DIR=~/models/vllm
|
||||
VLLM_MODEL=Ouro-1.4B
|
||||
VLLM_MODELS_DIR=~/models/vllm # override with full path to your model dir
|
||||
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
|
||||
|
||||
# API keys (required for remote profile)
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ def render_banner(yaml_path: Path, tier: str) -> None:
|
|||
|
||||
col1, col2, col3 = st.columns([8, 1, 1])
|
||||
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:
|
||||
if st.button("Try it", key="_ui_banner_try"):
|
||||
switch_ui(yaml_path, to="vue", tier=tier)
|
||||
|
|
|
|||
|
|
@ -15,28 +15,28 @@ import streamlit.components.v1 as components
|
|||
import yaml
|
||||
|
||||
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 (
|
||||
DEFAULT_DB, init_db, get_jobs_by_status,
|
||||
update_cover_letter, mark_applied, update_job_status,
|
||||
get_task_for_job,
|
||||
)
|
||||
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
|
||||
|
||||
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")
|
||||
|
||||
resolve_session("peregrine")
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
def _make_cover_letter_pdf(job: dict, cover_letter: str, output_dir: Path) -> Path:
|
||||
from reportlab.lib.pagesizes import letter
|
||||
|
|
|
|||
|
|
@ -64,8 +64,8 @@ FEATURES: dict[str, str] = {
|
|||
"apple_calendar_sync": "paid",
|
||||
"slack_notifications": "paid",
|
||||
|
||||
# Beta UI access — stays gated (access management, not compute)
|
||||
"vue_ui_beta": "paid",
|
||||
# Beta UI access — open to all tiers (access management, not compute)
|
||||
"vue_ui_beta": "free",
|
||||
}
|
||||
|
||||
# Features that unlock when the user supplies any LLM backend (local or BYOK).
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ services:
|
|||
- PEREGRINE_CADDY_PROXY=1
|
||||
- CF_ORCH_URL=http://host.docker.internal:7700
|
||||
- DEMO_MODE=false
|
||||
- FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-}
|
||||
depends_on:
|
||||
searxng:
|
||||
condition: service_healthy
|
||||
|
|
@ -48,6 +49,8 @@ services:
|
|||
build:
|
||||
context: .
|
||||
dockerfile: docker/web/Dockerfile
|
||||
args:
|
||||
VITE_BASE_PATH: /peregrine/
|
||||
ports:
|
||||
- "8508:80"
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ services:
|
|||
build:
|
||||
context: .
|
||||
dockerfile: docker/web/Dockerfile
|
||||
args:
|
||||
VITE_BASE_PATH: /peregrine/
|
||||
ports:
|
||||
- "8507:80"
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
19
compose.yml
19
compose.yml
|
|
@ -1,5 +1,5 @@
|
|||
# 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:
|
||||
|
||||
app:
|
||||
|
|
@ -129,23 +129,6 @@ services:
|
|||
profiles: [single-gpu, dual-gpu-ollama, dual-gpu-vllm, dual-gpu-mixed]
|
||||
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:
|
||||
build:
|
||||
context: .
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ backends:
|
|||
type: vision_service
|
||||
vllm:
|
||||
api_key: ''
|
||||
base_url: http://vllm:8000/v1
|
||||
base_url: http://host.docker.internal:8000/v1
|
||||
enabled: true
|
||||
model: __auto__
|
||||
supports_images: false
|
||||
|
|
@ -49,11 +49,10 @@ backends:
|
|||
service: vllm
|
||||
model_candidates:
|
||||
- Qwen2.5-3B-Instruct
|
||||
- Phi-4-mini-instruct
|
||||
ttl_s: 300
|
||||
vllm_research:
|
||||
api_key: ''
|
||||
base_url: http://vllm:8000/v1
|
||||
base_url: http://host.docker.internal:8000/v1
|
||||
enabled: true
|
||||
model: __auto__
|
||||
supports_images: false
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ WORKDIR /app
|
|||
COPY web/package*.json ./
|
||||
RUN npm ci --prefer-offline
|
||||
COPY web/ ./
|
||||
ARG VITE_BASE_PATH=/
|
||||
ENV VITE_BASE_PATH=${VITE_BASE_PATH}
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: serve
|
||||
|
|
|
|||
|
|
@ -5,9 +5,13 @@ server {
|
|||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
# Proxy API calls to the FastAPI backend service
|
||||
location /api/ {
|
||||
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
|
||||
|
|
@ -15,4 +19,9 @@ server {
|
|||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA fallback — must come after API and assets
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,13 +26,14 @@ LETTERS_DIR = _profile.docs_dir if _profile else Path.home() / "Documents" / "Jo
|
|||
LETTER_GLOB = "*Cover Letter*.md"
|
||||
|
||||
# Background injected into every prompt so the model has the candidate's facts
|
||||
def _build_system_context() -> str:
|
||||
if not _profile:
|
||||
def _build_system_context(profile=None) -> str:
|
||||
p = profile or _profile
|
||||
if not p:
|
||||
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}"]
|
||||
if _profile.candidate_voice:
|
||||
parts = [f"You are writing cover letters for {p.name}. {p.career_summary}"]
|
||||
if p.candidate_voice:
|
||||
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, "
|
||||
"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."""
|
||||
prefs = _profile.mission_preferences if _profile else {}
|
||||
p = profile or _profile
|
||||
name = candidate_name or _candidate
|
||||
prefs = p.mission_preferences if p else {}
|
||||
notes = {}
|
||||
for industry, default_note in _MISSION_DEFAULTS.items():
|
||||
custom = (prefs.get(industry) or "").strip()
|
||||
if custom:
|
||||
notes[industry] = (
|
||||
f"Mission alignment — {_candidate} shared: \"{custom}\". "
|
||||
f"Mission alignment — {name} shared: \"{custom}\". "
|
||||
"Para 3 should warmly and specifically reflect this authentic connection."
|
||||
)
|
||||
else:
|
||||
|
|
@ -144,12 +147,15 @@ def _build_mission_notes() -> dict[str, str]:
|
|||
_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."""
|
||||
notes = mission_notes if mission_notes is not None else _MISSION_NOTES
|
||||
text = f"{company} {description}".lower()
|
||||
for industry, signals in _MISSION_SIGNALS.items():
|
||||
if any(sig in text for sig in signals):
|
||||
return _MISSION_NOTES[industry]
|
||||
return notes[industry]
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -190,10 +196,14 @@ def build_prompt(
|
|||
examples: list[dict],
|
||||
mission_hint: str | None = None,
|
||||
is_jobgether: bool = False,
|
||||
system_context: str | None = None,
|
||||
candidate_name: str | None = None,
|
||||
) -> 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:
|
||||
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):
|
||||
parts.append(f"--- Example {i} ({ex['company']}) ---")
|
||||
parts.append(ex["text"])
|
||||
|
|
@ -231,13 +241,14 @@ def build_prompt(
|
|||
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.
|
||||
|
||||
Fine-tuned models sometimes loop after completing the letter. This cuts at
|
||||
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 = (
|
||||
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+)?')
|
||||
|
|
@ -257,6 +268,8 @@ def generate(
|
|||
feedback: str = "",
|
||||
is_jobgether: bool = False,
|
||||
_router=None,
|
||||
config_path: "Path | None" = None,
|
||||
user_yaml_path: "Path | None" = None,
|
||||
) -> str:
|
||||
"""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
|
||||
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).
|
||||
"""
|
||||
# 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()
|
||||
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:
|
||||
print(f"[cover-letter] Mission alignment detected for {company}", file=sys.stderr)
|
||||
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:
|
||||
prompt += f"\n\n---\nPrevious draft:\n{previous_result}"
|
||||
|
|
@ -281,8 +308,9 @@ def generate(
|
|||
|
||||
if _router is None:
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from scripts.llm_router import LLMRouter
|
||||
_router = LLMRouter()
|
||||
from scripts.llm_router import LLMRouter, CONFIG_PATH
|
||||
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] 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
|
||||
# and prevents fine-tuned models from looping into repetitive garbage output.
|
||||
result = _router.complete(prompt, max_tokens=1200)
|
||||
return _trim_to_letter_end(result)
|
||||
return _trim_to_letter_end(result, _prof)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
|
|
|||
|
|
@ -179,6 +179,9 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int,
|
|||
import json as _json
|
||||
p = _json.loads(params or "{}")
|
||||
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(
|
||||
job.get("title", ""),
|
||||
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", ""),
|
||||
feedback=p.get("feedback", ""),
|
||||
is_jobgether=job.get("source") == "jobgether",
|
||||
config_path=_user_llm_cfg,
|
||||
user_yaml_path=_user_yaml,
|
||||
)
|
||||
update_cover_letter(db_path, job_id, result)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useAppConfigStore } from '../stores/appConfig'
|
|||
import { settingsGuard } from './settingsGuard'
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{ path: '/', component: () => import('../views/HomeView.vue') },
|
||||
{ path: '/review', component: () => import('../views/JobReviewView.vue') },
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue'
|
|||
import UnoCSS from 'unocss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
plugins: [vue(), UnoCSS()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
|
|
|
|||
Loading…
Reference in a new issue