Compare commits
No commits in common. "a6d787fed29b33efe89ad0ea25f935380f8b9bbd" and "34cdbfda0aef607710dd7a12fa7b099bc766a084" have entirely different histories.
a6d787fed2
...
34cdbfda0a
26 changed files with 360 additions and 114 deletions
19
app/app.py
19
app/app.py
|
|
@ -163,6 +163,25 @@ with st.sidebar:
|
|||
icon="🔒",
|
||||
)
|
||||
_task_indicator()
|
||||
|
||||
# Cloud LLM indicator — shown whenever any cloud backend is active
|
||||
_llm_cfg_path = Path(__file__).parent.parent / "config" / "llm.yaml"
|
||||
try:
|
||||
import yaml as _yaml
|
||||
from scripts.byok_guard import cloud_backends as _cloud_backends
|
||||
_active_cloud = _cloud_backends(_yaml.safe_load(_llm_cfg_path.read_text(encoding="utf-8")) or {})
|
||||
except Exception:
|
||||
_active_cloud = []
|
||||
if _active_cloud:
|
||||
_provider_names = ", ".join(b.replace("_", " ").title() for b in _active_cloud)
|
||||
st.warning(
|
||||
f"**Cloud LLM active**\n\n"
|
||||
f"{_provider_names}\n\n"
|
||||
"AI features send content to this provider. "
|
||||
"[Change in Settings](2_Settings)",
|
||||
icon="🔓",
|
||||
)
|
||||
|
||||
st.divider()
|
||||
st.caption(f"Peregrine {_get_version()}")
|
||||
inject_feedback_button(page=pg.title)
|
||||
|
|
|
|||
|
|
@ -1048,12 +1048,80 @@ with tab_system:
|
|||
f"{'✓' if llm_backends.get(n, {}).get('enabled', True) else '✗'} {n}"
|
||||
for n in llm_new_order
|
||||
))
|
||||
if st.button("💾 Save LLM settings", type="primary", key="sys_save_llm"):
|
||||
save_yaml(LLM_CFG, {**llm_cfg, "backends": llm_updated_backends, "fallback_order": llm_new_order})
|
||||
# ── Cloud backend warning + acknowledgment ─────────────────────────────
|
||||
from scripts.byok_guard import cloud_backends as _cloud_backends
|
||||
|
||||
_pending_cfg = {**llm_cfg, "backends": llm_updated_backends, "fallback_order": llm_new_order}
|
||||
_pending_cloud = set(_cloud_backends(_pending_cfg))
|
||||
|
||||
_user_cfg_for_ack = yaml.safe_load(USER_CFG.read_text(encoding="utf-8")) or {} if USER_CFG.exists() else {}
|
||||
_already_acked = set(_user_cfg_for_ack.get("byok_acknowledged_backends", []))
|
||||
# Intentional: once a backend is acknowledged, it stays acknowledged even if
|
||||
# temporarily disabled and re-enabled. This avoids nagging returning users.
|
||||
_unacknowledged = _pending_cloud - _already_acked
|
||||
|
||||
def _do_save_llm(ack_backends: set) -> None:
|
||||
"""Write llm.yaml and update acknowledgment in user.yaml."""
|
||||
save_yaml(LLM_CFG, _pending_cfg)
|
||||
st.session_state.pop("_llm_order", None)
|
||||
st.session_state.pop("_llm_order_cfg_key", None)
|
||||
if ack_backends:
|
||||
# Re-read user.yaml at save time (not at render time) to avoid
|
||||
# overwriting changes made by other processes between render and save.
|
||||
_uy = yaml.safe_load(USER_CFG.read_text(encoding="utf-8")) or {} if USER_CFG.exists() else {}
|
||||
_uy["byok_acknowledged_backends"] = sorted(_already_acked | ack_backends)
|
||||
save_yaml(USER_CFG, _uy)
|
||||
st.success("LLM settings saved!")
|
||||
|
||||
if _unacknowledged:
|
||||
_provider_labels = ", ".join(b.replace("_", " ").title() for b in sorted(_unacknowledged))
|
||||
_policy_links = []
|
||||
for _b in sorted(_unacknowledged):
|
||||
if _b in ("anthropic", "claude_code"):
|
||||
_policy_links.append("[Anthropic privacy policy](https://www.anthropic.com/privacy)")
|
||||
elif _b == "openai":
|
||||
_policy_links.append("[OpenAI privacy policy](https://openai.com/policies/privacy-policy)")
|
||||
_policy_str = " · ".join(_policy_links) if _policy_links else "Review your provider's documentation."
|
||||
|
||||
st.warning(
|
||||
f"**Cloud LLM active — your data will leave this machine**\n\n"
|
||||
f"Enabling **{_provider_labels}** means AI features will send content "
|
||||
f"directly to that provider. CircuitForge does not receive or log it, "
|
||||
f"but their privacy policy governs it — not ours.\n\n"
|
||||
f"**What leaves your machine:**\n"
|
||||
f"- Cover letter generation: your resume, job description, and profile\n"
|
||||
f"- Keyword suggestions: your skills list and resume summary\n"
|
||||
f"- Survey assistant: survey question text\n"
|
||||
f"- Company research / Interview prep: company name and role only\n\n"
|
||||
f"**What stays local always:** your jobs database, email credentials, "
|
||||
f"license key, and Notion token.\n\n"
|
||||
f"For sensitive data (disability, immigration, medical), a local model is "
|
||||
f"strongly recommended. These tools assist with paperwork — they don't "
|
||||
f"replace professional advice.\n\n"
|
||||
f"{_policy_str} · "
|
||||
f"[CircuitForge privacy policy](https://circuitforge.tech/privacy)",
|
||||
icon="⚠️",
|
||||
)
|
||||
|
||||
_ack = st.checkbox(
|
||||
f"I understand — content will be sent to **{_provider_labels}** when I use AI features",
|
||||
key="byok_ack_checkbox",
|
||||
)
|
||||
_col_cancel, _col_save = st.columns(2)
|
||||
if _col_cancel.button("Cancel", key="byok_cancel"):
|
||||
st.session_state.pop("byok_ack_checkbox", None)
|
||||
st.rerun()
|
||||
if _col_save.button(
|
||||
"💾 Save with cloud LLM",
|
||||
type="primary",
|
||||
key="sys_save_llm_cloud",
|
||||
disabled=not _ack,
|
||||
):
|
||||
_do_save_llm(_unacknowledged)
|
||||
else:
|
||||
if st.button("💾 Save LLM settings", type="primary", key="sys_save_llm"):
|
||||
_do_save_llm(set())
|
||||
|
||||
# ── Services ──────────────────────────────────────────────────────────────
|
||||
with st.expander("🔌 Services", expanded=True):
|
||||
import subprocess as _sp
|
||||
|
|
|
|||
|
|
@ -61,6 +61,6 @@ vision_fallback_order:
|
|||
- vision_service
|
||||
- claude_code
|
||||
- anthropic
|
||||
# Note: 'ollama' (meghan-cover-writer) intentionally excluded — research
|
||||
# Note: 'ollama' (alex-cover-writer) intentionally excluded — research
|
||||
# must never use the fine-tuned writer model, and this also avoids evicting
|
||||
# the writer from GPU memory while a cover letter task is in flight.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{"subject": "Interview Invitation — Senior Engineer", "body": "Hi Meghan, we'd love to schedule a 30-min phone screen. Are you available Thursday at 2pm? Please reply to confirm.", "label": "interview_scheduled"}
|
||||
{"subject": "Interview Invitation — Senior Engineer", "body": "Hi Alex, we'd love to schedule a 30-min phone screen. Are you available Thursday at 2pm? Please reply to confirm.", "label": "interview_scheduled"}
|
||||
{"subject": "Your application to Acme Corp", "body": "Thank you for your interest in the Senior Engineer role. After careful consideration, we have decided to move forward with other candidates whose experience more closely matches our current needs.", "label": "rejected"}
|
||||
{"subject": "Offer Letter — Product Manager at Initech", "body": "Dear Meghan, we are thrilled to extend an offer of employment for the Product Manager position. Please find the attached offer letter outlining compensation and start date.", "label": "offer_received"}
|
||||
{"subject": "Quick question about your background", "body": "Hi Meghan, I came across your profile and would love to connect. We have a few roles that seem like a great match. Would you be open to a brief chat this week?", "label": "positive_response"}
|
||||
{"subject": "Company Culture Survey — Acme Corp", "body": "Meghan, as part of our evaluation process, we invite all candidates to complete our culture fit assessment. The survey takes approximately 15 minutes. Please click the link below.", "label": "survey_received"}
|
||||
{"subject": "Offer Letter — Product Manager at Initech", "body": "Dear Alex, we are thrilled to extend an offer of employment for the Product Manager position. Please find the attached offer letter outlining compensation and start date.", "label": "offer_received"}
|
||||
{"subject": "Quick question about your background", "body": "Hi Alex, I came across your profile and would love to connect. We have a few roles that seem like a great match. Would you be open to a brief chat this week?", "label": "positive_response"}
|
||||
{"subject": "Company Culture Survey — Acme Corp", "body": "Alex, as part of our evaluation process, we invite all candidates to complete our culture fit assessment. The survey takes approximately 15 minutes. Please click the link below.", "label": "survey_received"}
|
||||
{"subject": "Application Received — DataCo", "body": "Thank you for submitting your application for the Data Engineer role at DataCo. We have received your materials and will be in touch if your qualifications match our needs.", "label": "neutral"}
|
||||
{"subject": "Following up on your application", "body": "Hi Meghan, I wanted to follow up on your recent application. Your background looks interesting and we'd like to learn more. Can we set up a quick call?", "label": "positive_response"}
|
||||
{"subject": "We're moving forward with other candidates", "body": "Dear Meghan, thank you for taking the time to interview with us. After thoughtful consideration, we have decided not to move forward with your candidacy at this time.", "label": "rejected"}
|
||||
{"subject": "Following up on your application", "body": "Hi Alex, I wanted to follow up on your recent application. Your background looks interesting and we'd like to learn more. Can we set up a quick call?", "label": "positive_response"}
|
||||
{"subject": "We're moving forward with other candidates", "body": "Dear Alex, thank you for taking the time to interview with us. After thoughtful consideration, we have decided not to move forward with your candidacy at this time.", "label": "rejected"}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Job Seeker Platform — Design Document
|
||||
**Date:** 2026-02-20
|
||||
**Status:** Approved
|
||||
**Candidate:** Meghan McCann
|
||||
**Candidate:** Alex Rivera
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -52,7 +52,7 @@ JobSpy (LinkedIn / Indeed / Glassdoor / ZipRecruiter)
|
|||
Notion DB (daily review — decide what to pursue)
|
||||
└─▶ match.py <notion-page-url>
|
||||
├─ fetch job description from listing URL
|
||||
├─ run Resume Matcher vs. /Library/Documents/JobSearch/Meghan_McCann_Resume_02-19-2025.pdf
|
||||
├─ run Resume Matcher vs. /Library/Documents/JobSearch/Alex_Rivera_Resume_02-19-2025.pdf
|
||||
└─▶ write Match Score + Keyword Gaps back to Notion page
|
||||
|
||||
AIHawk (when ready to apply)
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Stand up a job discovery pipeline (JobSpy → Notion) with LLM routing, resume matching, and automated LinkedIn application support for Meghan McCann.
|
||||
**Goal:** Stand up a job discovery pipeline (JobSpy → Notion) with LLM routing, resume matching, and automated LinkedIn application support for Alex Rivera.
|
||||
|
||||
**Architecture:** JobSpy scrapes listings from multiple boards and pushes deduplicated results into a Notion database. A local LLM router with 5-backend fallback chain powers AIHawk's application answer generation. Resume Matcher scores each listing against Meghan's resume and writes keyword gaps back to Notion.
|
||||
**Architecture:** JobSpy scrapes listings from multiple boards and pushes deduplicated results into a Notion database. A local LLM router with 5-backend fallback chain powers AIHawk's application answer generation. Resume Matcher scores each listing against Alex's resume and writes keyword gaps back to Notion.
|
||||
|
||||
**Tech Stack:** Python 3.12, conda env `job-seeker`, `python-jobspy`, `notion-client`, `openai` SDK, `anthropic` SDK, `pyyaml`, `pandas`, Resume-Matcher (cloned), Auto_Jobs_Applier_AIHawk (cloned), pytest, pytest-mock
|
||||
|
||||
|
|
@ -194,7 +194,7 @@ This task creates the Notion DB that all scripts write to. Do it once manually.
|
|||
|
||||
**Step 1: Open Notion and create a new database**
|
||||
|
||||
Create a full-page database called **"Meghan's Job Search"** in whatever Notion workspace you use for tracking.
|
||||
Create a full-page database called **"Alex's Job Search"** in whatever Notion workspace you use for tracking.
|
||||
|
||||
**Step 2: Add the required properties**
|
||||
|
||||
|
|
@ -256,7 +256,7 @@ print('Connected to:', db['title'][0]['plain_text'])
|
|||
"
|
||||
```
|
||||
|
||||
Expected: `Connected to: Meghan's Job Search`
|
||||
Expected: `Connected to: Alex's Job Search`
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -752,7 +752,7 @@ Stop it with Ctrl+C — we'll run it on-demand.
|
|||
|
||||
The ATS-clean resume to use with Resume Matcher:
|
||||
```
|
||||
/Library/Documents/JobSearch/Meghan_McCann_Resume_02-19-2025.pdf
|
||||
/Library/Documents/JobSearch/Alex_Rivera_Resume_02-19-2025.pdf
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -824,7 +824,7 @@ Expected: `ImportError` — `scripts.match` doesn't exist.
|
|||
```python
|
||||
# scripts/match.py
|
||||
"""
|
||||
Resume Matcher integration: score a Notion job listing against Meghan's resume.
|
||||
Resume Matcher integration: score a Notion job listing against Alex's resume.
|
||||
Writes Match Score and Keyword Gaps back to the Notion page.
|
||||
|
||||
Usage:
|
||||
|
|
@ -840,7 +840,7 @@ from bs4 import BeautifulSoup
|
|||
from notion_client import Client
|
||||
|
||||
CONFIG_DIR = Path(__file__).parent.parent / "config"
|
||||
RESUME_PATH = Path("/Library/Documents/JobSearch/Meghan_McCann_Resume_02-19-2025.pdf")
|
||||
RESUME_PATH = Path("/Library/Documents/JobSearch/Alex_Rivera_Resume_02-19-2025.pdf")
|
||||
|
||||
|
||||
def load_notion() -> tuple[Client, str]:
|
||||
|
|
@ -999,7 +999,7 @@ cp /devl/job-seeker/aihawk/data_folder/plain_text_resume.yaml \
|
|||
/devl/job-seeker/aihawk/data_folder/plain_text_resume.yaml.bak
|
||||
```
|
||||
|
||||
Edit `/devl/job-seeker/aihawk/data_folder/plain_text_resume.yaml` with Meghan's info.
|
||||
Edit `/devl/job-seeker/aihawk/data_folder/plain_text_resume.yaml` with Alex's info.
|
||||
Key fields to fill:
|
||||
- `personal_information`: name, email, phone, linkedin, github (leave blank), location
|
||||
- `work_experience`: pull from the SVG content already extracted
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
## Overview
|
||||
|
||||
A Streamlit multi-page web UI that gives Meghan (and her partner) a friendly interface to review scraped job listings, curate them before they hit Notion, edit search/LLM/Notion settings, and fill out her AIHawk application profile. Designed to be usable by anyone — no technical knowledge required.
|
||||
A Streamlit multi-page web UI that gives Alex (and her partner) a friendly interface to review scraped job listings, curate them before they hit Notion, edit search/LLM/Notion settings, and fill out her AIHawk application profile. Designed to be usable by anyone — no technical knowledge required.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Build a Streamlit web UI with SQLite staging so Meghan can review scraped jobs, approve/batch-sync to Notion, edit settings, and complete her AIHawk profile.
|
||||
**Goal:** Build a Streamlit web UI with SQLite staging so Alex can review scraped jobs, approve/batch-sync to Notion, edit settings, and complete her AIHawk profile.
|
||||
|
||||
**Architecture:** `discover.py` writes to a local SQLite `staging.db` instead of Notion directly. Streamlit pages read/write SQLite for job review, YAML files for settings and resume. A new `sync.py` pushes approved jobs to Notion on demand.
|
||||
|
||||
|
|
@ -788,7 +788,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
|||
from scripts.db import DEFAULT_DB, init_db, get_job_counts
|
||||
|
||||
st.set_page_config(
|
||||
page_title="Meghan's Job Search",
|
||||
page_title="Alex's Job Search",
|
||||
page_icon="🔍",
|
||||
layout="wide",
|
||||
)
|
||||
|
|
@ -796,7 +796,7 @@ st.set_page_config(
|
|||
init_db(DEFAULT_DB)
|
||||
counts = get_job_counts(DEFAULT_DB)
|
||||
|
||||
st.title("🔍 Meghan's Job Search")
|
||||
st.title("🔍 Alex's Job Search")
|
||||
st.caption("Discover → Review → Sync to Notion")
|
||||
|
||||
st.divider()
|
||||
|
|
@ -877,7 +877,7 @@ st.info("Coming soon — Task 7")
|
|||
```bash
|
||||
conda run -n job-seeker streamlit run /devl/job-seeker/app/Home.py --server.headless true &
|
||||
sleep 4
|
||||
curl -s http://localhost:8501 | grep -q "Meghan" && echo "OK" || echo "FAIL"
|
||||
curl -s http://localhost:8501 | grep -q "Alex" && echo "OK" || echo "FAIL"
|
||||
kill %1
|
||||
```
|
||||
|
||||
|
|
@ -1215,7 +1215,7 @@ git commit -m "feat: add Settings page with search, LLM, and Notion tabs"
|
|||
```python
|
||||
# app/pages/3_Resume_Editor.py
|
||||
"""
|
||||
Resume Editor — form-based editor for Meghan's AIHawk profile YAML.
|
||||
Resume Editor — form-based editor for Alex's AIHawk profile YAML.
|
||||
FILL_IN fields highlighted in amber.
|
||||
"""
|
||||
import sys
|
||||
|
|
@ -1227,7 +1227,7 @@ import yaml
|
|||
|
||||
st.set_page_config(page_title="Resume Editor", page_icon="📝", layout="wide")
|
||||
st.title("📝 Resume Editor")
|
||||
st.caption("Edit Meghan's application profile used by AIHawk for LinkedIn Easy Apply.")
|
||||
st.caption("Edit Alex's application profile used by AIHawk for LinkedIn Easy Apply.")
|
||||
|
||||
RESUME_PATH = Path(__file__).parent.parent.parent / "aihawk" / "data_folder" / "plain_text_resume.yaml"
|
||||
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ def test_classify_stage_signal_interview(tmp_path):
|
|||
mock_router.complete.return_value = "interview_scheduled"
|
||||
result = classify_stage_signal(
|
||||
"Let's schedule a call",
|
||||
"Hi Meghan, we'd love to book a 30-min phone screen with you.",
|
||||
"Hi Alex, we'd love to book a 30-min phone screen with you.",
|
||||
)
|
||||
assert result == "interview_scheduled"
|
||||
|
||||
|
|
@ -362,11 +362,11 @@ def test_sync_job_emails_classifies_inbound(tmp_path):
|
|||
|
||||
fake_msg_bytes = (
|
||||
b"From: recruiter@acme.com\r\n"
|
||||
b"To: meghan@example.com\r\n"
|
||||
b"To: alex@example.com\r\n"
|
||||
b"Subject: Interview Invitation\r\n"
|
||||
b"Message-ID: <unique-001@acme.com>\r\n"
|
||||
b"\r\n"
|
||||
b"Hi Meghan, we'd like to schedule a phone screen."
|
||||
b"Hi Alex, we'd like to schedule a phone screen."
|
||||
)
|
||||
|
||||
conn_mock = MagicMock()
|
||||
|
|
@ -465,7 +465,7 @@ def test_extract_lead_info_returns_company_and_title():
|
|||
from scripts.imap_sync import extract_lead_info
|
||||
with patch("scripts.imap_sync._CLASSIFIER_ROUTER") as mock_router:
|
||||
mock_router.complete.return_value = '{"company": "Wiz", "title": "Senior TAM"}'
|
||||
result = extract_lead_info("Senior TAM at Wiz", "Hi Meghan, we have a role…", "recruiter@wiz.com")
|
||||
result = extract_lead_info("Senior TAM at Wiz", "Hi Alex, we have a role…", "recruiter@wiz.com")
|
||||
assert result == ("Wiz", "Senior TAM")
|
||||
|
||||
|
||||
|
|
@ -945,7 +945,7 @@ def test_get_email_leads(tmp_path):
|
|||
insert_job(db_path, {
|
||||
"title": "TAM", "company": "Wiz", "url": "email://wiz.com/abc123",
|
||||
"source": "email", "location": "", "is_remote": 0,
|
||||
"salary": "", "description": "Hi Meghan…", "date_found": "2026-02-21",
|
||||
"salary": "", "description": "Hi Alex…", "date_found": "2026-02-21",
|
||||
})
|
||||
leads = get_email_leads(db_path)
|
||||
assert len(leads) == 1
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
## Problem
|
||||
|
||||
The current `company_research.py` produces shallow output:
|
||||
- Resume context is a hardcoded 2-sentence blurb — talking points aren't grounded in Meghan's actual experience
|
||||
- Resume context is a hardcoded 2-sentence blurb — talking points aren't grounded in Alex's actual experience
|
||||
- Search coverage is limited: CEO, HQ, LinkedIn, one generic news query
|
||||
- Output has 4 sections; new data categories (tech stack, funding, culture, competitors) have nowhere to go
|
||||
- No skills/keyword config to drive experience matching against the JD
|
||||
|
|
@ -91,10 +91,10 @@ keywords:
|
|||
## Job Description
|
||||
{JD text, up to 2500 chars}
|
||||
|
||||
## Meghan's Matched Experience
|
||||
## Alex's Matched Experience
|
||||
[Top 2 scored experience entries — full detail]
|
||||
|
||||
Also in Meghan's background: [remaining entries as one-liners]
|
||||
Also in Alex's background: [remaining entries as one-liners]
|
||||
|
||||
## Matched Skills & Keywords
|
||||
Skills matching this JD: {matched_keywords joined}
|
||||
|
|
@ -132,7 +132,7 @@ Skills matching this JD: {matched_keywords joined}
|
|||
| `## Funding & Market Position` | Stage, investors, recent rounds, competitor landscape |
|
||||
| `## Recent Developments` | News, launches, pivots, exec moves |
|
||||
| `## Red Flags & Watch-outs` | Culture issues, layoffs, exec departures, financial stress |
|
||||
| `## Talking Points for Meghan` | 5 role-matched, resume-grounded, UpGuard-aware talking points ready to speak aloud |
|
||||
| `## Talking Points for Alex` | 5 role-matched, resume-grounded, UpGuard-aware talking points ready to speak aloud |
|
||||
|
||||
Talking points prompt instructs LLM to: cite the specific matched experience by name, reference matched skills, apply UpGuard NDA rule, frame each as a ready-to-speak sentence.
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Expand company research to gather richer web data (funding, tech stack, competitors, culture/Glassdoor, news), match Meghan's resume experience against the JD, and produce a 7-section brief with role-grounded talking points.
|
||||
**Goal:** Expand company research to gather richer web data (funding, tech stack, competitors, culture/Glassdoor, news), match Alex's resume experience against the JD, and produce a 7-section brief with role-grounded talking points.
|
||||
|
||||
**Architecture:** Parallel SearXNG JSON queries (6 types) feed a structured context block alongside tiered resume experience (top-2 scored full, rest condensed) from `config/resume_keywords.yaml`. Single LLM call produces 7 output sections stored in expanded DB columns.
|
||||
|
||||
|
|
@ -197,7 +197,7 @@ cp config/resume_keywords.yaml config/resume_keywords.yaml.example
|
|||
|
||||
**Step 3: Add to `.gitignore` if personal, or commit both**
|
||||
|
||||
`resume_keywords.yaml` contains Meghan's personal keywords — commit both (no secrets).
|
||||
`resume_keywords.yaml` contains Alex's personal keywords — commit both (no secrets).
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
|
|
@ -275,7 +275,7 @@ def test_build_resume_context_top2_full_rest_condensed():
|
|||
assert "Lead Technical Account Manager" in ctx
|
||||
assert "Managed enterprise security accounts" in ctx
|
||||
# Condensed for rest
|
||||
assert "Also in Meghan" in ctx
|
||||
assert "Also in Alex" in ctx
|
||||
assert "Generic Co" in ctx
|
||||
# UpGuard NDA note present
|
||||
assert "NDA" in ctx or "enterprise security vendor" in ctx
|
||||
|
|
@ -297,7 +297,7 @@ Add to `scripts/company_research.py`, after the `_parse_sections` function:
|
|||
_RESUME_YAML = Path(__file__).parent.parent / "aihawk" / "data_folder" / "plain_text_resume.yaml"
|
||||
_KEYWORDS_YAML = Path(__file__).parent.parent / "config" / "resume_keywords.yaml"
|
||||
|
||||
# Companies where Meghan has an NDA — reference engagement but not specifics
|
||||
# Companies where Alex has an NDA — reference engagement but not specifics
|
||||
# unless the role is a strong security/compliance match (score >= 3 on JD).
|
||||
_NDA_COMPANIES = {"upguard"}
|
||||
|
||||
|
|
@ -353,14 +353,14 @@ def _build_resume_context(resume: dict, keywords: list[str], jd: str) -> str:
|
|||
bullets.extend(resp.values())
|
||||
return "\n".join(f" - {b}" for b in bullets)
|
||||
|
||||
lines = ["## Meghan's Matched Experience"]
|
||||
lines = ["## Alex's Matched Experience"]
|
||||
for exp in top2:
|
||||
lines.append(f"\n**{_exp_label(exp)}** (match score: {exp['score']})")
|
||||
lines.append(_exp_bullets(exp))
|
||||
|
||||
if rest:
|
||||
condensed = ", ".join(_exp_label(e) for e in rest)
|
||||
lines.append(f"\nAlso in Meghan's background: {condensed}")
|
||||
lines.append(f"\nAlso in Alex's background: {condensed}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
|
@ -554,7 +554,7 @@ At the top of `research_company()`, after `jd_excerpt`, add:
|
|||
Replace the existing `prompt = f"""..."""` block with:
|
||||
|
||||
```python
|
||||
prompt = f"""You are preparing Meghan McCann for a job interview.
|
||||
prompt = f"""You are preparing Alex Rivera for a job interview.
|
||||
|
||||
Role: **{title}** at **{company}**
|
||||
|
||||
|
|
@ -591,9 +591,9 @@ Draw on the live snippets above; if none available, note what is publicly known.
|
|||
Culture issues, layoffs, exec departures, financial stress, or Glassdoor concerns worth knowing before the call.
|
||||
If nothing notable, write "No significant red flags identified."
|
||||
|
||||
## Talking Points for Meghan
|
||||
## Talking Points for Alex
|
||||
Five specific talking points for the phone screen. Each must:
|
||||
- Reference a concrete experience from Meghan's matched background by name
|
||||
- Reference a concrete experience from Alex's matched background by name
|
||||
(UpGuard NDA rule: say "enterprise security vendor" unless role has clear security focus)
|
||||
- Connect to a specific signal from the JD or company context above
|
||||
- Be 1–2 sentences, ready to speak aloud
|
||||
|
|
@ -615,7 +615,7 @@ Replace the existing return block:
|
|||
"ceo_brief": sections.get("Leadership & Culture", ""),
|
||||
"tech_brief": sections.get("Tech Stack & Product", ""),
|
||||
"funding_brief": sections.get("Funding & Market Position", ""),
|
||||
"talking_points": sections.get("Talking Points for Meghan", ""),
|
||||
"talking_points": sections.get("Talking Points for Alex", ""),
|
||||
# Recent Developments and Red Flags stored in raw_output; rendered from there
|
||||
# (avoids adding more columns right now — can migrate later if needed)
|
||||
}
|
||||
|
|
@ -632,7 +632,7 @@ Wait — `Recent Developments` and `Red Flags` aren't in the return dict above.
|
|||
"funding_brief": sections.get("Funding & Market Position", ""),
|
||||
"competitors_brief": sections.get("Funding & Market Position", ""), # same section
|
||||
"red_flags": sections.get("Red Flags & Watch-outs", ""),
|
||||
"talking_points": sections.get("Talking Points for Meghan", ""),
|
||||
"talking_points": sections.get("Talking Points for Alex", ""),
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -769,7 +769,7 @@ Append at the end of the file:
|
|||
with tab_skills:
|
||||
st.subheader("🏷️ Skills & Keywords")
|
||||
st.caption(
|
||||
"These are matched against job descriptions to select Meghan's most relevant "
|
||||
"These are matched against job descriptions to select Alex's most relevant "
|
||||
"experience and highlight keyword overlap in the research brief."
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -486,7 +486,7 @@ backends:
|
|||
api_key: ollama
|
||||
base_url: http://localhost:11434/v1
|
||||
enabled: true
|
||||
model: meghan-cover-writer:latest
|
||||
model: alex-cover-writer:latest
|
||||
type: openai_compat
|
||||
supports_images: false
|
||||
ollama_research:
|
||||
|
|
@ -524,7 +524,7 @@ vision_fallback_order:
|
|||
- vision_service
|
||||
- claude_code
|
||||
- anthropic
|
||||
# Note: 'ollama' (meghan-cover-writer) intentionally excluded — research
|
||||
# Note: 'ollama' (alex-cover-writer) intentionally excluded — research
|
||||
# must never use the fine-tuned writer model.
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ location_map:
|
|||
|
||||
**Step 2: Create `config/craigslist.yaml`** (personal config — gitignored)
|
||||
|
||||
Copy `.example` as-is (Meghan targets sfbay + remote, so this default is correct).
|
||||
Copy `.example` as-is (Alex targets sfbay + remote, so this default is correct).
|
||||
|
||||
**Step 3: Add to `.gitignore`**
|
||||
|
||||
|
|
|
|||
|
|
@ -43,20 +43,20 @@ Everything that must be extracted into `config/user.yaml` via a `UserProfile` cl
|
|||
|
||||
| File | Hardcoded value | Generalized as |
|
||||
|------|----------------|----------------|
|
||||
| `company_research.py` | `"Meghan McCann"` in prompts | `profile.name` |
|
||||
| `company_research.py` | `"Alex Rivera"` in prompts | `profile.name` |
|
||||
| `company_research.py` | `_NDA_COMPANIES = {"upguard"}` | `profile.nda_companies` |
|
||||
| `company_research.py` | `_SCRAPER_DIR = Path("/Library/...")` | bundled in Docker image |
|
||||
| `generate_cover_letter.py` | `SYSTEM_CONTEXT` with Meghan's bio | `profile.career_summary` |
|
||||
| `generate_cover_letter.py` | `SYSTEM_CONTEXT` with Alex's bio | `profile.career_summary` |
|
||||
| `generate_cover_letter.py` | `LETTERS_DIR = Path("/Library/...")` | `profile.docs_dir` |
|
||||
| `4_Apply.py` | contact block (name/email/phone) | `profile.*` |
|
||||
| `4_Apply.py` | `DOCS_DIR = Path("/Library/...")` | `profile.docs_dir` |
|
||||
| `5_Interviews.py` | email assistant persona "Meghan McCann is a Customer Success..." | `profile.name + profile.career_summary` |
|
||||
| `6_Interview_Prep.py` | `"Meghan"` in interviewer prompts | `profile.name` |
|
||||
| `5_Interviews.py` | email assistant persona "Alex Rivera is a Customer Success..." | `profile.name + profile.career_summary` |
|
||||
| `6_Interview_Prep.py` | `"Alex"` in interviewer prompts | `profile.name` |
|
||||
| `7_Survey.py` | `_SURVEY_SYSTEM` — "The candidate values collaborative teamwork..." | `profile.career_summary` or survey persona field |
|
||||
| `scripts/vision_service/main.py` | `model_id = "vikhyatk/moondream2"`, `revision = "2025-01-09"` | `config/llm.yaml` vision_service block |
|
||||
| `match.py` | `RESUME_PATH = Path("/Library/...Meghan_McCann_Resume...")` | configurable in Settings |
|
||||
| `Home.py` | `"Meghan's Job Search"` | `f"{profile.name}'s Job Search"` |
|
||||
| `finetune_local.py` | all `/Library/` paths + `"meghan-cover-writer"` | `profile.*` |
|
||||
| `match.py` | `RESUME_PATH = Path("/Library/...Alex_Rivera_Resume...")` | configurable in Settings |
|
||||
| `Home.py` | `"Alex's Job Search"` | `f"{profile.name}'s Job Search"` |
|
||||
| `finetune_local.py` | all `/Library/` paths + `"alex-cover-writer"` | `profile.*` |
|
||||
| `2_Settings.py` | `PFP_DIR`, host service paths (manage-services.sh etc.) | removed / compose-driven |
|
||||
| `config/llm.yaml` | hard-coded `base_url` values | auto-generated from `user.yaml` |
|
||||
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ auto-generates `config/llm.yaml` base URLs from service config, redirects to Hom
|
|||
|
||||
### New: My Profile tab
|
||||
Editable form for all `user.yaml` fields post-setup. Saving regenerates `config/llm.yaml`
|
||||
base URLs automatically. Replaces scattered "Meghan's" references in existing tab captions.
|
||||
base URLs automatically. Replaces scattered "Alex's" references in existing tab captions.
|
||||
|
||||
### Updated: Services tab
|
||||
- Reads port/host from `profile.services.*` instead of hard-coded values
|
||||
|
|
@ -173,21 +173,21 @@ personal data is currently hard-coded.
|
|||
|
||||
| Location | Current | Generalized |
|
||||
|---|---|---|
|
||||
| `company_research.py` prompts | `"Meghan McCann"` | `profile.name` |
|
||||
| `company_research.py` prompts | `"Alex Rivera"` | `profile.name` |
|
||||
| `company_research.py` | `_NDA_COMPANIES = {"upguard"}` | `profile.nda_companies` |
|
||||
| `company_research.py` | `_SCRAPER_DIR = Path("/Library/...")` | bundled in container |
|
||||
| `generate_cover_letter.py` | `SYSTEM_CONTEXT` with Meghan's bio | `profile.career_summary` |
|
||||
| `generate_cover_letter.py` | `SYSTEM_CONTEXT` with Alex's bio | `profile.career_summary` |
|
||||
| `generate_cover_letter.py` | `LETTERS_DIR = Path("/Library/...")` | `profile.docs_dir` |
|
||||
| `generate_cover_letter.py` | `_MISSION_SIGNALS` / `_MISSION_NOTES` (hardcoded) | `profile.mission_industries` list; First-Run Wizard step |
|
||||
| `4_Apply.py` | contact block with name/email/phone | `profile.*` |
|
||||
| `4_Apply.py` | `DOCS_DIR = Path("/Library/...")` | `profile.docs_dir` |
|
||||
| `5_Interviews.py` email assistant | `"Meghan McCann is a Customer Success..."` | `profile.name + profile.career_summary` |
|
||||
| `6_Interview_Prep.py` | `"Meghan"` in interviewer prompts | `profile.name` |
|
||||
| `5_Interviews.py` email assistant | `"Alex Rivera is a Customer Success..."` | `profile.name + profile.career_summary` |
|
||||
| `6_Interview_Prep.py` | `"Alex"` in interviewer prompts | `profile.name` |
|
||||
| `7_Survey.py` `_SURVEY_SYSTEM` | "The candidate values collaborative teamwork, clear communication, growth, and impact." | `profile.career_summary` or user-editable survey persona field |
|
||||
| `scripts/vision_service/main.py` | `model_id = "vikhyatk/moondream2"`, `revision = "2025-01-09"` | configurable in `config/llm.yaml` vision_service block |
|
||||
| `match.py` | `RESUME_PATH = Path("/Library/...Meghan_McCann_Resume...")` | configurable in Settings |
|
||||
| `Home.py` | `"Meghan's Job Search"` | `f"{profile.name}'s Job Search"` |
|
||||
| `finetune_local.py` | all `/Library/` paths + `"meghan-cover-writer"` | `profile.*` |
|
||||
| `match.py` | `RESUME_PATH = Path("/Library/...Alex_Rivera_Resume...")` | configurable in Settings |
|
||||
| `Home.py` | `"Alex's Job Search"` | `f"{profile.name}'s Job Search"` |
|
||||
| `finetune_local.py` | all `/Library/` paths + `"alex-cover-writer"` | `profile.*` |
|
||||
| `2_Settings.py` | `PFP_DIR`, hard-coded service paths | removed / compose-driven |
|
||||
| `config/llm.yaml` | hard-coded `base_url` values | auto-generated from `user.yaml` |
|
||||
| `config/search_profiles.yaml` | `mission_tags` on profiles (implicit) | `profile.mission_industries` drives profile generation in wizard |
|
||||
|
|
|
|||
|
|
@ -388,7 +388,7 @@ def _searxng_running(searxng_url: str = "http://localhost:8888") -> bool:
|
|||
return False
|
||||
```
|
||||
|
||||
Replace all `"Meghan McCann"` / `"Meghan's"` / `_NDA_COMPANIES` references:
|
||||
Replace all `"Alex Rivera"` / `"Alex's"` / `_NDA_COMPANIES` references:
|
||||
```python
|
||||
# At top of research_company():
|
||||
from scripts.user_profile import UserProfile
|
||||
|
|
@ -404,13 +404,13 @@ def _company_label(exp: dict) -> str:
|
|||
return _profile.nda_label(company, score)
|
||||
return company
|
||||
|
||||
# Replace "## Meghan's Matched Experience":
|
||||
# Replace "## Alex's Matched Experience":
|
||||
lines = [f"## {_profile.name if _profile else 'Candidate'}'s Matched Experience"]
|
||||
|
||||
# In research_company() prompt, replace "Meghan McCann":
|
||||
# In research_company() prompt, replace "Alex Rivera":
|
||||
name = _profile.name if _profile else "the candidate"
|
||||
summary = _profile.career_summary if _profile else ""
|
||||
# Replace "You are preparing Meghan McCann for a job interview." with:
|
||||
# Replace "You are preparing Alex Rivera for a job interview." with:
|
||||
prompt = f"""You are preparing {name} for a job interview.\n{summary}\n..."""
|
||||
```
|
||||
|
||||
|
|
@ -419,7 +419,7 @@ prompt = f"""You are preparing {name} for a job interview.\n{summary}\n..."""
|
|||
Replace:
|
||||
```python
|
||||
LETTERS_DIR = Path("/Library/Documents/JobSearch")
|
||||
SYSTEM_CONTEXT = """You are writing cover letters for Meghan McCann..."""
|
||||
SYSTEM_CONTEXT = """You are writing cover letters for Alex Rivera..."""
|
||||
```
|
||||
|
||||
With:
|
||||
|
|
@ -517,9 +517,9 @@ _name = _profile.name if _profile else "Job Seeker"
|
|||
|
||||
Replace:
|
||||
```python
|
||||
st.title("🔍 Meghan's Job Search")
|
||||
st.title("🔍 Alex's Job Search")
|
||||
# and:
|
||||
st.caption(f"Run TF-IDF match scoring against Meghan's resume...")
|
||||
st.caption(f"Run TF-IDF match scoring against Alex's resume...")
|
||||
```
|
||||
With:
|
||||
```python
|
||||
|
|
@ -534,9 +534,9 @@ Replace:
|
|||
```python
|
||||
DOCS_DIR = Path("/Library/Documents/JobSearch")
|
||||
# and the contact paragraph:
|
||||
Paragraph("MEGHAN McCANN", name_style)
|
||||
Paragraph("meghan.m.mccann@gmail.com · (510) 764-3155 · ...", contact_style)
|
||||
Paragraph("Warm regards,<br/><br/>Meghan McCann", body_style)
|
||||
Paragraph("ALEX RIVERA", name_style)
|
||||
Paragraph("alex@example.com · (555) 867-5309 · ...", contact_style)
|
||||
Paragraph("Warm regards,<br/><br/>Alex Rivera", body_style)
|
||||
```
|
||||
With:
|
||||
```python
|
||||
|
|
@ -560,12 +560,12 @@ Replace hard-coded persona strings with:
|
|||
_persona = (
|
||||
f"{_name} is a {_profile.career_summary[:120] if _profile and _profile.career_summary else 'professional'}"
|
||||
)
|
||||
# Replace all occurrences of "Meghan McCann is a Customer Success..." with _persona
|
||||
# Replace all occurrences of "Alex Rivera is a Customer Success..." with _persona
|
||||
```
|
||||
|
||||
**Step 5: 6_Interview_Prep.py — interviewer and Q&A prompts**
|
||||
|
||||
Replace all occurrences of `"Meghan"` in f-strings with `_name`.
|
||||
Replace all occurrences of `"Alex"` in f-strings with `_name`.
|
||||
|
||||
**Step 6: 2_Settings.py — Services tab**
|
||||
|
||||
|
|
@ -588,7 +588,7 @@ Replace the SearXNG entry to use Docker Compose instead of a host path:
|
|||
},
|
||||
```
|
||||
|
||||
Replace all caption strings containing "Meghan's" with `f"{_name}'s"`.
|
||||
Replace all caption strings containing "Alex's" with `f"{_name}'s"`.
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
|
|
|
|||
|
|
@ -95,14 +95,14 @@ data/email_compare_sample.jsonl
|
|||
Create `data/email_score.jsonl.example` with fake-but-realistic emails:
|
||||
|
||||
```jsonl
|
||||
{"subject": "Interview Invitation — Senior Engineer", "body": "Hi Meghan, we'd love to schedule a 30-min phone screen. Are you available Thursday at 2pm? Please reply to confirm.", "label": "interview_scheduled"}
|
||||
{"subject": "Interview Invitation — Senior Engineer", "body": "Hi Alex, we'd love to schedule a 30-min phone screen. Are you available Thursday at 2pm? Please reply to confirm.", "label": "interview_scheduled"}
|
||||
{"subject": "Your application to Acme Corp", "body": "Thank you for your interest in the Senior Engineer role. After careful consideration, we have decided to move forward with other candidates whose experience more closely matches our current needs.", "label": "rejected"}
|
||||
{"subject": "Offer Letter — Product Manager at Initech", "body": "Dear Meghan, we are thrilled to extend an offer of employment for the Product Manager position. Please find the attached offer letter outlining compensation and start date.", "label": "offer_received"}
|
||||
{"subject": "Quick question about your background", "body": "Hi Meghan, I came across your profile and would love to connect. We have a few roles that seem like a great match. Would you be open to a brief chat this week?", "label": "positive_response"}
|
||||
{"subject": "Company Culture Survey — Acme Corp", "body": "Meghan, as part of our evaluation process, we invite all candidates to complete our culture fit assessment. The survey takes approximately 15 minutes. Please click the link below.", "label": "survey_received"}
|
||||
{"subject": "Offer Letter — Product Manager at Initech", "body": "Dear Alex, we are thrilled to extend an offer of employment for the Product Manager position. Please find the attached offer letter outlining compensation and start date.", "label": "offer_received"}
|
||||
{"subject": "Quick question about your background", "body": "Hi Alex, I came across your profile and would love to connect. We have a few roles that seem like a great match. Would you be open to a brief chat this week?", "label": "positive_response"}
|
||||
{"subject": "Company Culture Survey — Acme Corp", "body": "Alex, as part of our evaluation process, we invite all candidates to complete our culture fit assessment. The survey takes approximately 15 minutes. Please click the link below.", "label": "survey_received"}
|
||||
{"subject": "Application Received — DataCo", "body": "Thank you for submitting your application for the Data Engineer role at DataCo. We have received your materials and will be in touch if your qualifications match our needs.", "label": "neutral"}
|
||||
{"subject": "Following up on your application", "body": "Hi Meghan, I wanted to follow up on your recent application. Your background looks interesting and we'd like to learn more. Can we set up a quick call?", "label": "positive_response"}
|
||||
{"subject": "We're moving forward with other candidates", "body": "Dear Meghan, thank you for taking the time to interview with us. After thoughtful consideration, we have decided not to move forward with your candidacy at this time.", "label": "rejected"}
|
||||
{"subject": "Following up on your application", "body": "Hi Alex, I wanted to follow up on your recent application. Your background looks interesting and we'd like to learn more. Can we set up a quick call?", "label": "positive_response"}
|
||||
{"subject": "We're moving forward with other candidates", "body": "Dear Alex, thank you for taking the time to interview with us. After thoughtful consideration, we have decided not to move forward with your candidacy at this time.", "label": "rejected"}
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
|
@ -493,7 +493,7 @@ def test_gliclass_adapter_returns_highest_score():
|
|||
return_value=mock_pipeline_instance):
|
||||
adapter = GLiClassAdapter("test-gli", "some/model")
|
||||
adapter.load()
|
||||
result = adapter.classify("Offer letter enclosed", "Dear Meghan, we are pleased to offer...")
|
||||
result = adapter.classify("Offer letter enclosed", "Dear Alex, we are pleased to offer...")
|
||||
|
||||
assert result == "offer_received"
|
||||
```
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ def test_mask_pii_phone_dashes():
|
|||
|
||||
def test_mask_pii_phone_parens():
|
||||
from scripts.feedback_api import mask_pii
|
||||
assert mask_pii("(510) 764-3155") == "[phone redacted]"
|
||||
assert mask_pii("(555) 867-5309") == "[phone redacted]"
|
||||
|
||||
|
||||
def test_mask_pii_clean_text():
|
||||
|
|
|
|||
58
scripts/byok_guard.py
Normal file
58
scripts/byok_guard.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""
|
||||
BYOK cloud backend detection.
|
||||
|
||||
Determines whether LLM backends in llm.yaml send data to third-party cloud
|
||||
providers. Used by Settings (activation warning) and app.py (sidebar indicator).
|
||||
|
||||
No Streamlit dependency — pure Python so it's unit-testable and reusable.
|
||||
"""
|
||||
|
||||
# 0.0.0.0 is a bind address (all interfaces), not a true loopback, but a backend
|
||||
# configured to call it is talking to the local machine — treat as local.
|
||||
LOCAL_URL_MARKERS = ("localhost", "127.0.0.1", "0.0.0.0")
|
||||
|
||||
|
||||
def is_cloud_backend(name: str, cfg: dict) -> bool:
|
||||
"""Return True if this backend sends prompts to a third-party cloud provider.
|
||||
|
||||
Classification rules (applied in order):
|
||||
1. local: true in cfg → always local (user override)
|
||||
2. vision_service type → always local
|
||||
3. anthropic or claude_code type → always cloud
|
||||
4. openai_compat with a localhost/loopback base_url → local
|
||||
5. openai_compat with any other base_url → cloud
|
||||
6. anything else → local (unknown types assumed safe)
|
||||
"""
|
||||
if cfg.get("local", False):
|
||||
return False
|
||||
|
||||
btype = cfg.get("type", "")
|
||||
|
||||
if btype == "vision_service":
|
||||
return False
|
||||
|
||||
if btype in ("anthropic", "claude_code"):
|
||||
return True
|
||||
|
||||
if btype == "openai_compat":
|
||||
url = cfg.get("base_url", "")
|
||||
return not any(marker in url for marker in LOCAL_URL_MARKERS)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def cloud_backends(llm_cfg: dict) -> list[str]:
|
||||
"""Return names of enabled cloud backends from a parsed llm.yaml dict.
|
||||
|
||||
Args:
|
||||
llm_cfg: parsed contents of config/llm.yaml
|
||||
|
||||
Returns:
|
||||
List of backend names that are enabled and classified as cloud.
|
||||
Empty list means fully local configuration.
|
||||
"""
|
||||
return [
|
||||
name
|
||||
for name, cfg in llm_cfg.get("backends", {}).items()
|
||||
if cfg.get("enabled", True) and is_cloud_backend(name, cfg)
|
||||
]
|
||||
101
tests/test_byok_guard.py
Normal file
101
tests/test_byok_guard.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""Tests for BYOK cloud backend detection."""
|
||||
import pytest
|
||||
from scripts.byok_guard import is_cloud_backend, cloud_backends
|
||||
|
||||
|
||||
class TestIsCloudBackend:
|
||||
def test_anthropic_type_is_always_cloud(self):
|
||||
assert is_cloud_backend("anthropic", {"type": "anthropic", "enabled": True}) is True
|
||||
|
||||
def test_claude_code_type_is_cloud(self):
|
||||
assert is_cloud_backend("claude_code", {"type": "claude_code", "enabled": True}) is True
|
||||
|
||||
def test_vision_service_is_always_local(self):
|
||||
assert is_cloud_backend("vision", {"type": "vision_service"}) is False
|
||||
|
||||
def test_openai_compat_localhost_is_local(self):
|
||||
cfg = {"type": "openai_compat", "base_url": "http://localhost:11434/v1"}
|
||||
assert is_cloud_backend("ollama", cfg) is False
|
||||
|
||||
def test_openai_compat_127_is_local(self):
|
||||
cfg = {"type": "openai_compat", "base_url": "http://127.0.0.1:8000/v1"}
|
||||
assert is_cloud_backend("vllm", cfg) is False
|
||||
|
||||
def test_openai_compat_0000_is_local(self):
|
||||
cfg = {"type": "openai_compat", "base_url": "http://0.0.0.0:8000/v1"}
|
||||
assert is_cloud_backend("vllm", cfg) is False
|
||||
|
||||
def test_openai_compat_remote_url_is_cloud(self):
|
||||
cfg = {"type": "openai_compat", "base_url": "https://api.openai.com/v1"}
|
||||
assert is_cloud_backend("openai", cfg) is True
|
||||
|
||||
def test_openai_compat_together_is_cloud(self):
|
||||
cfg = {"type": "openai_compat", "base_url": "https://api.together.xyz/v1"}
|
||||
assert is_cloud_backend("together", cfg) is True
|
||||
|
||||
def test_local_override_suppresses_cloud_detection(self):
|
||||
cfg = {"type": "openai_compat", "base_url": "http://192.168.1.100:11434/v1", "local": True}
|
||||
assert is_cloud_backend("nas_ollama", cfg) is False
|
||||
|
||||
def test_local_override_on_anthropic_suppresses_detection(self):
|
||||
cfg = {"type": "anthropic", "local": True}
|
||||
assert is_cloud_backend("anthropic", cfg) is False
|
||||
|
||||
def test_openai_compat_missing_base_url_treated_as_cloud(self):
|
||||
# No base_url → unknown destination → defensively treated as cloud
|
||||
cfg = {"type": "openai_compat"}
|
||||
assert is_cloud_backend("unknown", cfg) is True
|
||||
|
||||
def test_unknown_type_without_url_is_local(self):
|
||||
assert is_cloud_backend("mystery", {"type": "unknown_type"}) is False
|
||||
|
||||
|
||||
class TestCloudBackends:
|
||||
def test_empty_config_returns_empty(self):
|
||||
assert cloud_backends({}) == []
|
||||
|
||||
def test_fully_local_config_returns_empty(self):
|
||||
cfg = {
|
||||
"backends": {
|
||||
"ollama": {"type": "openai_compat", "base_url": "http://localhost:11434/v1", "enabled": True},
|
||||
"vision": {"type": "vision_service", "enabled": True},
|
||||
}
|
||||
}
|
||||
assert cloud_backends(cfg) == []
|
||||
|
||||
def test_cloud_backend_returned(self):
|
||||
cfg = {
|
||||
"backends": {
|
||||
"anthropic": {"type": "anthropic", "enabled": True},
|
||||
}
|
||||
}
|
||||
assert cloud_backends(cfg) == ["anthropic"]
|
||||
|
||||
def test_disabled_cloud_backend_excluded(self):
|
||||
cfg = {
|
||||
"backends": {
|
||||
"anthropic": {"type": "anthropic", "enabled": False},
|
||||
}
|
||||
}
|
||||
assert cloud_backends(cfg) == []
|
||||
|
||||
def test_mix_returns_only_enabled_cloud(self):
|
||||
cfg = {
|
||||
"backends": {
|
||||
"ollama": {"type": "openai_compat", "base_url": "http://localhost:11434/v1", "enabled": True},
|
||||
"anthropic": {"type": "anthropic", "enabled": True},
|
||||
"openai": {"type": "openai_compat", "base_url": "https://api.openai.com/v1", "enabled": False},
|
||||
}
|
||||
}
|
||||
result = cloud_backends(cfg)
|
||||
assert result == ["anthropic"]
|
||||
|
||||
def test_multiple_cloud_backends_all_returned(self):
|
||||
cfg = {
|
||||
"backends": {
|
||||
"anthropic": {"type": "anthropic", "enabled": True},
|
||||
"openai": {"type": "openai_compat", "base_url": "https://api.openai.com/v1", "enabled": True},
|
||||
}
|
||||
}
|
||||
result = cloud_backends(cfg)
|
||||
assert set(result) == {"anthropic", "openai"}
|
||||
|
|
@ -148,7 +148,7 @@ def test_gliclass_adapter_returns_highest_score():
|
|||
return_value=mock_pipeline_instance):
|
||||
adapter = GLiClassAdapter("test-gli", "some/model")
|
||||
adapter.load()
|
||||
result = adapter.classify("Offer letter enclosed", "Dear Meghan, we are pleased to offer...")
|
||||
result = adapter.classify("Offer letter enclosed", "Dear Alex, we are pleased to offer...")
|
||||
|
||||
assert result == "offer_received"
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ def test_strip_greeting():
|
|||
"""strip_greeting removes the 'Dear X,' line and returns the body."""
|
||||
from scripts.prepare_training_data import strip_greeting
|
||||
|
||||
text = "Dear Hiring Team,\n\nI'm delighted to apply for the CSM role.\n\nBest regards,\nMeghan"
|
||||
text = "Dear Hiring Team,\n\nI'm delighted to apply for the CSM role.\n\nBest regards,\nAlex"
|
||||
result = strip_greeting(text)
|
||||
assert result.startswith("I'm delighted")
|
||||
assert "Dear" not in result
|
||||
|
|
@ -48,7 +48,7 @@ def test_build_records_from_tmp_corpus(tmp_path):
|
|||
"Dear Acme Hiring Team,\n\n"
|
||||
"I'm delighted to apply for the Director of Customer Success position at Acme Corp. "
|
||||
"With six years of experience, I bring strong skills.\n\n"
|
||||
"Best regards,\nMeghan McCann"
|
||||
"Best regards,\nAlex Rivera"
|
||||
)
|
||||
|
||||
records = build_records(tmp_path)
|
||||
|
|
@ -107,11 +107,11 @@ def test_generate_calls_llm_router():
|
|||
{"company": "Acme", "text": "I'm delighted to apply for the CSM role at Acme."},
|
||||
]
|
||||
mock_router = MagicMock()
|
||||
mock_router.complete.return_value = "Dear Hiring Team,\n\nI'm delighted to apply.\n\nWarm regards,\nMeghan McCann"
|
||||
mock_router.complete.return_value = "Dear Hiring Team,\n\nI'm delighted to apply.\n\nWarm regards,\nAlex Rivera"
|
||||
|
||||
with patch("scripts.generate_cover_letter.load_corpus", return_value=fake_corpus):
|
||||
result = generate("Customer Success Manager", "TestCo", "Looking for a CSM",
|
||||
_router=mock_router)
|
||||
|
||||
mock_router.complete.assert_called_once()
|
||||
assert "Meghan McCann" in result
|
||||
assert "Alex Rivera" in result
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class TestGenerateRefinement:
|
|||
"""Call generate() with a mock router and return the captured prompt."""
|
||||
captured = {}
|
||||
mock_router = MagicMock()
|
||||
mock_router.complete.side_effect = lambda p: (captured.update({"prompt": p}), "result")[1]
|
||||
mock_router.complete.side_effect = lambda p, **kwargs: (captured.update({"prompt": p}), "result")[1]
|
||||
with patch("scripts.generate_cover_letter.load_corpus", return_value=[]), \
|
||||
patch("scripts.generate_cover_letter.find_similar_letters", return_value=[]):
|
||||
from scripts.generate_cover_letter import generate
|
||||
|
|
|
|||
|
|
@ -408,7 +408,7 @@ def test_get_email_leads(tmp_path):
|
|||
insert_job(db_path, {
|
||||
"title": "TAM", "company": "Wiz", "url": "email://wiz.com/abc123",
|
||||
"source": "email", "location": "", "is_remote": 0,
|
||||
"salary": "", "description": "Hi Meghan…", "date_found": "2026-02-21",
|
||||
"salary": "", "description": "Hi Alex…", "date_found": "2026-02-21",
|
||||
})
|
||||
leads = get_email_leads(db_path)
|
||||
assert len(leads) == 1
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ def test_mask_pii_phone_dashes():
|
|||
|
||||
def test_mask_pii_phone_parens():
|
||||
from scripts.feedback_api import mask_pii
|
||||
assert mask_pii("(510) 764-3155") == "[phone redacted]"
|
||||
assert mask_pii("(555) 867-5309") == "[phone redacted]"
|
||||
|
||||
|
||||
def test_mask_pii_clean_text():
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ def test_classify_stage_signal_interview():
|
|||
mock_router.complete.return_value = "interview_scheduled"
|
||||
result = classify_stage_signal(
|
||||
"Let's schedule a call",
|
||||
"Hi Meghan, we'd love to book a 30-min phone screen with you.",
|
||||
"Hi Alex, we'd love to book a 30-min phone screen with you.",
|
||||
)
|
||||
assert result == "interview_scheduled"
|
||||
|
||||
|
|
@ -72,7 +72,7 @@ def test_extract_lead_info_returns_company_and_title():
|
|||
from scripts.imap_sync import extract_lead_info
|
||||
with patch("scripts.imap_sync._CLASSIFIER_ROUTER") as mock_router:
|
||||
mock_router.complete.return_value = '{"company": "Wiz", "title": "Senior TAM"}'
|
||||
result = extract_lead_info("Senior TAM at Wiz", "Hi Meghan, we have a role…", "recruiter@wiz.com")
|
||||
result = extract_lead_info("Senior TAM at Wiz", "Hi Alex, we have a role…", "recruiter@wiz.com")
|
||||
assert result == ("Wiz", "Senior TAM")
|
||||
|
||||
|
||||
|
|
@ -120,11 +120,11 @@ def test_sync_job_emails_classifies_inbound(tmp_path):
|
|||
|
||||
fake_msg_bytes = (
|
||||
b"From: recruiter@acme.com\r\n"
|
||||
b"To: meghan@example.com\r\n"
|
||||
b"To: alex@example.com\r\n"
|
||||
b"Subject: Interview Invitation\r\n"
|
||||
b"Message-ID: <unique-001@acme.com>\r\n"
|
||||
b"\r\n"
|
||||
b"Hi Meghan, we'd like to schedule a phone screen."
|
||||
b"Hi Alex, we'd like to schedule a phone screen."
|
||||
)
|
||||
|
||||
conn_mock = MagicMock()
|
||||
|
|
@ -227,7 +227,7 @@ View job: https://www.linkedin.com/comm/jobs/view/9999002/?trackingId=def
|
|||
_ALERT_EMAIL = {
|
||||
"message_id": "<alert-001@linkedin.com>",
|
||||
"from_addr": "jobalerts-noreply@linkedin.com",
|
||||
"to_addr": "meghan@example.com",
|
||||
"to_addr": "alex@example.com",
|
||||
"subject": "2 new jobs for customer success manager",
|
||||
"body": _ALERT_BODY,
|
||||
"date": "2026-02-24 12:00:00",
|
||||
|
|
@ -366,7 +366,7 @@ def test_ats_subject_phrase_not_matched_in_body_only():
|
|||
"""ATS confirm phrase in body alone does NOT trigger — subject-only check."""
|
||||
from scripts.imap_sync import _has_rejection_or_ats_signal
|
||||
# "thank you for applying" is an ATS subject phrase; must NOT be caught in body only
|
||||
body = "Hi Meghan, thank you for applying to our Senior TAM role. We'd love to chat."
|
||||
body = "Hi Alex, thank you for applying to our Senior TAM role. We'd love to chat."
|
||||
assert _has_rejection_or_ats_signal("Interview Invitation", body) is False
|
||||
|
||||
|
||||
|
|
@ -391,7 +391,7 @@ def test_rejection_uppercase_lowercased():
|
|||
def test_rejection_phrase_in_quoted_thread_beyond_limit_not_blocked():
|
||||
"""Rejection phrase beyond 1500-char body window does not block the email."""
|
||||
from scripts.imap_sync import _has_rejection_or_ats_signal
|
||||
clean_intro = "Hi Meghan, we'd love to schedule a call with you. " * 30 # ~1500 chars
|
||||
clean_intro = "Hi Alex, we'd love to schedule a call with you. " * 32 # ~1500 chars
|
||||
quoted_footer = "\n\nOn Mon, Jan 1 wrote:\n> Unfortunately we went with another candidate."
|
||||
body = clean_intro + quoted_footer
|
||||
# The phrase lands after the 1500-char cutoff — should NOT be blocked
|
||||
|
|
@ -519,7 +519,7 @@ def test_parse_message_no_message_id_returns_none():
|
|||
b"From: recruiter@acme.com\r\n"
|
||||
b"Subject: Interview Invitation\r\n"
|
||||
b"\r\n"
|
||||
b"Hi Meghan!"
|
||||
b"Hi Alex!"
|
||||
)
|
||||
conn = MagicMock()
|
||||
conn.fetch.return_value = ("OK", [(b"1 (RFC822 {40})", raw)])
|
||||
|
|
@ -563,7 +563,7 @@ def test_extract_lead_info_returns_none_on_llm_error():
|
|||
from scripts.imap_sync import extract_lead_info
|
||||
with patch("scripts.imap_sync._CLASSIFIER_ROUTER") as mock_router:
|
||||
mock_router.complete.side_effect = RuntimeError("timeout")
|
||||
result = extract_lead_info("Senior TAM at Wiz", "Hi Meghan…", "r@wiz.com")
|
||||
result = extract_lead_info("Senior TAM at Wiz", "Hi Alex…", "r@wiz.com")
|
||||
assert result == (None, None)
|
||||
|
||||
|
||||
|
|
@ -572,9 +572,9 @@ def test_extract_lead_info_returns_none_on_llm_error():
|
|||
_PLAIN_RECRUIT_EMAIL = {
|
||||
"message_id": "<recruit-001@acme.com>",
|
||||
"from_addr": "recruiter@acme.com",
|
||||
"to_addr": "meghan@example.com",
|
||||
"to_addr": "alex@example.com",
|
||||
"subject": "Interview Opportunity at Acme",
|
||||
"body": "Hi Meghan, we have an exciting opportunity for you.",
|
||||
"body": "Hi Alex, we have an exciting opportunity for you.",
|
||||
"date": "2026-02-25 10:00:00",
|
||||
}
|
||||
|
||||
|
|
@ -776,9 +776,9 @@ def test_scan_todo_label_email_matches_company_and_keyword(tmp_path):
|
|||
todo_email = {
|
||||
"message_id": "<todo-001@acme.com>",
|
||||
"from_addr": "recruiter@acme.com",
|
||||
"to_addr": "meghan@example.com",
|
||||
"to_addr": "alex@example.com",
|
||||
"subject": "Interview scheduled with Acme",
|
||||
"body": "Hi Meghan, your interview is confirmed.",
|
||||
"body": "Hi Alex, your interview is confirmed.",
|
||||
"date": "2026-02-25 10:00:00",
|
||||
}
|
||||
|
||||
|
|
@ -807,7 +807,7 @@ def test_scan_todo_label_no_action_keyword_skipped(tmp_path):
|
|||
no_keyword_email = {
|
||||
"message_id": "<todo-002@acme.com>",
|
||||
"from_addr": "noreply@acme.com",
|
||||
"to_addr": "meghan@example.com",
|
||||
"to_addr": "alex@example.com",
|
||||
"subject": "Acme newsletter",
|
||||
"body": "Company updates this week.",
|
||||
"date": "2026-02-25 10:00:00",
|
||||
|
|
@ -834,9 +834,9 @@ def test_scan_todo_label_no_company_match_skipped(tmp_path):
|
|||
unrelated_email = {
|
||||
"message_id": "<todo-003@other.com>",
|
||||
"from_addr": "recruiter@other.com",
|
||||
"to_addr": "meghan@example.com",
|
||||
"to_addr": "alex@example.com",
|
||||
"subject": "Interview scheduled with OtherCo",
|
||||
"body": "Hi Meghan, interview with OtherCo confirmed.",
|
||||
"body": "Hi Alex, interview with OtherCo confirmed.",
|
||||
"date": "2026-02-25 10:00:00",
|
||||
}
|
||||
|
||||
|
|
@ -861,9 +861,9 @@ def test_scan_todo_label_duplicate_message_id_not_reinserted(tmp_path):
|
|||
todo_email = {
|
||||
"message_id": "<already-seen@acme.com>",
|
||||
"from_addr": "recruiter@acme.com",
|
||||
"to_addr": "meghan@example.com",
|
||||
"to_addr": "alex@example.com",
|
||||
"subject": "Interview scheduled with Acme",
|
||||
"body": "Hi Meghan.",
|
||||
"body": "Hi Alex.",
|
||||
"date": "2026-02-25 10:00:00",
|
||||
}
|
||||
|
||||
|
|
@ -891,7 +891,7 @@ def test_scan_todo_label_stage_signal_set_for_non_neutral(tmp_path):
|
|||
todo_email = {
|
||||
"message_id": "<signal-001@acme.com>",
|
||||
"from_addr": "recruiter@acme.com",
|
||||
"to_addr": "meghan@example.com",
|
||||
"to_addr": "alex@example.com",
|
||||
"subject": "Interview scheduled with Acme",
|
||||
"body": "Your phone screen is confirmed.",
|
||||
"date": "2026-02-25 10:00:00",
|
||||
|
|
@ -924,7 +924,7 @@ def test_scan_todo_label_body_fallback_matches(tmp_path):
|
|||
body_only_email = {
|
||||
"message_id": "<body-fallback@noreply.greenhouse.io>",
|
||||
"from_addr": "noreply@greenhouse.io",
|
||||
"to_addr": "meghan@example.com",
|
||||
"to_addr": "alex@example.com",
|
||||
"subject": "Interview scheduled",
|
||||
"body": "Your interview with Acme has been confirmed for tomorrow.",
|
||||
"date": "2026-02-25 10:00:00",
|
||||
|
|
|
|||
Loading…
Reference in a new issue