1363 lines
72 KiB
Python
1363 lines
72 KiB
Python
# app/pages/2_Settings.py
|
||
"""
|
||
Settings — edit search profiles, LLM backends, Notion connection, services,
|
||
and resume profile (paste-able bullets used in Apply Workspace).
|
||
"""
|
||
import sys
|
||
from pathlib import Path
|
||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||
|
||
import streamlit as st
|
||
import yaml
|
||
import os as _os
|
||
|
||
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"
|
||
|
||
st.title("⚙️ Settings")
|
||
|
||
CONFIG_DIR = Path(__file__).parent.parent.parent / "config"
|
||
SEARCH_CFG = CONFIG_DIR / "search_profiles.yaml"
|
||
BLOCKLIST_CFG = CONFIG_DIR / "blocklist.yaml"
|
||
LLM_CFG = CONFIG_DIR / "llm.yaml"
|
||
NOTION_CFG = CONFIG_DIR / "notion.yaml"
|
||
RESUME_PATH = Path(__file__).parent.parent.parent / "config" / "plain_text_resume.yaml"
|
||
KEYWORDS_CFG = CONFIG_DIR / "resume_keywords.yaml"
|
||
|
||
def load_yaml(path: Path) -> dict:
|
||
if path.exists():
|
||
return yaml.safe_load(path.read_text()) or {}
|
||
return {}
|
||
|
||
def save_yaml(path: Path, data: dict) -> None:
|
||
path.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True))
|
||
|
||
|
||
def _suggest_search_terms(current_titles: list[str], resume_path: Path) -> dict:
|
||
"""Call LLM to suggest additional job titles and exclude keywords."""
|
||
import json
|
||
import re
|
||
from scripts.llm_router import LLMRouter
|
||
|
||
resume_context = ""
|
||
if resume_path.exists():
|
||
resume = load_yaml(resume_path)
|
||
lines = []
|
||
for exp in (resume.get("experience_details") or [])[:3]:
|
||
pos = exp.get("position", "")
|
||
co = exp.get("company", "")
|
||
skills = ", ".join((exp.get("skills_acquired") or [])[:5])
|
||
lines.append(f"- {pos} at {co}: {skills}")
|
||
resume_context = "\n".join(lines)
|
||
|
||
titles_str = "\n".join(f"- {t}" for t in current_titles)
|
||
prompt = f"""You are helping a job seeker optimize their search criteria.
|
||
|
||
Their background (from resume):
|
||
{resume_context or "Customer success and technical account management leader"}
|
||
|
||
Current job titles being searched:
|
||
{titles_str}
|
||
|
||
Suggest:
|
||
1. 5-8 additional job titles they might be missing (alternative names, adjacent roles, senior variants)
|
||
2. 3-5 keywords to add to the exclusion filter (to screen out irrelevant postings)
|
||
|
||
Return ONLY valid JSON in this exact format:
|
||
{{"suggested_titles": ["Title 1", "Title 2"], "suggested_excludes": ["keyword 1", "keyword 2"]}}"""
|
||
|
||
result = LLMRouter().complete(prompt).strip()
|
||
m = re.search(r"\{.*\}", result, re.DOTALL)
|
||
if m:
|
||
try:
|
||
return json.loads(m.group())
|
||
except Exception:
|
||
pass
|
||
return {"suggested_titles": [], "suggested_excludes": []}
|
||
|
||
_show_finetune = bool(_profile and _profile.inference_profile in ("single-gpu", "dual-gpu"))
|
||
|
||
USER_CFG = CONFIG_DIR / "user.yaml"
|
||
|
||
_dev_mode = _os.getenv("DEV_MODE", "").lower() in ("true", "1", "yes")
|
||
_u_for_dev = yaml.safe_load(USER_CFG.read_text()) or {} if USER_CFG.exists() else {}
|
||
_show_dev_tab = _dev_mode or bool(_u_for_dev.get("dev_tier_override"))
|
||
|
||
_tab_names = [
|
||
"👤 My Profile", "📝 Resume Profile", "🔎 Search",
|
||
"⚙️ System", "🎯 Fine-Tune", "🔑 License"
|
||
]
|
||
if _show_dev_tab:
|
||
_tab_names.append("🛠️ Developer")
|
||
_all_tabs = st.tabs(_tab_names)
|
||
tab_profile, tab_resume, tab_search, tab_system, tab_finetune, tab_license = _all_tabs[:6]
|
||
|
||
# ── Inline LLM generate buttons ───────────────────────────────────────────────
|
||
# Paid-tier feature: ✨ Generate buttons sit directly below each injectable field.
|
||
# Writes into session state keyed to the widget's `key=` param, then reruns.
|
||
from app.wizard.tiers import can_use as _cu
|
||
_gen_panel_active = bool(_profile) and _cu(
|
||
_profile.effective_tier if _profile else "free", "llm_career_summary"
|
||
)
|
||
|
||
# Seed session state for LLM-injectable text fields on first load
|
||
_u_init = yaml.safe_load(USER_CFG.read_text()) or {} if USER_CFG.exists() else {}
|
||
for _fk, _fv in [
|
||
("profile_career_summary", _u_init.get("career_summary", "")),
|
||
("profile_candidate_voice", _u_init.get("candidate_voice", "")),
|
||
]:
|
||
if _fk not in st.session_state:
|
||
st.session_state[_fk] = _fv
|
||
|
||
with tab_profile:
|
||
from scripts.user_profile import UserProfile as _UP, _DEFAULTS as _UP_DEFAULTS
|
||
import yaml as _yaml_up
|
||
|
||
st.caption("Your identity and service configuration. Saved values drive all LLM prompts, PDF headers, and service connections.")
|
||
|
||
_u = _yaml_up.safe_load(USER_CFG.read_text()) or {} if USER_CFG.exists() else {}
|
||
_svc = {**_UP_DEFAULTS["services"], **_u.get("services", {})}
|
||
|
||
with st.expander("👤 Identity", expanded=True):
|
||
c1, c2 = st.columns(2)
|
||
u_name = c1.text_input("Full Name", _u.get("name", ""))
|
||
u_email = c1.text_input("Email", _u.get("email", ""))
|
||
u_phone = c2.text_input("Phone", _u.get("phone", ""))
|
||
u_linkedin = c2.text_input("LinkedIn URL", _u.get("linkedin", ""))
|
||
u_summary = st.text_area("Career Summary (used in LLM prompts)",
|
||
key="profile_career_summary", height=100)
|
||
if _gen_panel_active:
|
||
if st.button("✨ Generate", key="gen_career_summary", help="Generate career summary with AI"):
|
||
_cs_draft = st.session_state.get("profile_career_summary", "").strip()
|
||
_cs_resume_ctx = ""
|
||
if RESUME_PATH.exists():
|
||
_rdata = load_yaml(RESUME_PATH)
|
||
_exps = (_rdata.get("experience_details") or [])[:3]
|
||
_exp_lines = []
|
||
for _e in _exps:
|
||
_t = _e.get("position", "")
|
||
_c = _e.get("company", "")
|
||
_b = "; ".join((_e.get("key_responsibilities") or [])[:2])
|
||
_exp_lines.append(f"- {_t} at {_c}: {_b}")
|
||
_cs_resume_ctx = "\n".join(_exp_lines)
|
||
_cs_prompt = (
|
||
f"Write a 3-4 sentence professional career summary for {_profile.name} in first person, "
|
||
f"suitable for use in cover letters and LLM prompts. "
|
||
f"Return only the summary, no preamble.\n"
|
||
)
|
||
if _cs_draft:
|
||
_cs_prompt += f"\nExisting draft to improve or replace:\n{_cs_draft}\n"
|
||
if _cs_resume_ctx:
|
||
_cs_prompt += f"\nRecent experience for context:\n{_cs_resume_ctx}\n"
|
||
with st.spinner("Generating…"):
|
||
from scripts.llm_router import LLMRouter as _LLMRouter
|
||
st.session_state["profile_career_summary"] = _LLMRouter().complete(_cs_prompt).strip()
|
||
st.rerun()
|
||
u_voice = st.text_area(
|
||
"Voice & Personality (shapes cover letter tone)",
|
||
key="profile_candidate_voice",
|
||
height=80,
|
||
help="Personality traits and writing voice that the LLM uses to write authentically in your style. Never disclosed in applications.",
|
||
)
|
||
if _gen_panel_active:
|
||
if st.button("✨ Generate", key="gen_candidate_voice", help="Generate voice descriptor with AI"):
|
||
_vc_draft = st.session_state.get("profile_candidate_voice", "").strip()
|
||
_vc_prompt = (
|
||
f"Write a 2-4 sentence voice and personality descriptor for {_profile.name} "
|
||
f"to guide an LLM writing cover letters in their authentic style. "
|
||
f"Describe personality traits, tone, and writing voice — not a bio. "
|
||
f"Career context: {_profile.career_summary}. "
|
||
f"Return only the descriptor, no preamble.\n"
|
||
)
|
||
if _vc_draft:
|
||
_vc_prompt += f"\nExisting descriptor to improve:\n{_vc_draft}\n"
|
||
with st.spinner("Generating…"):
|
||
from scripts.llm_router import LLMRouter as _LLMRouter
|
||
st.session_state["profile_candidate_voice"] = _LLMRouter().complete(_vc_prompt).strip()
|
||
st.rerun()
|
||
|
||
with st.expander("🎯 Mission & Values"):
|
||
st.caption("Industry passions and causes you care about. Used to inject authentic Para 3 alignment when a company matches. Never disclosed in applications.")
|
||
|
||
# Initialise session state from saved YAML; re-sync after a save (version bump)
|
||
_mission_ver = str(_u.get("mission_preferences", {}))
|
||
if "mission_rows" not in st.session_state or st.session_state.get("mission_ver") != _mission_ver:
|
||
st.session_state.mission_rows = [
|
||
{"key": k, "value": v}
|
||
for k, v in _u.get("mission_preferences", {}).items()
|
||
]
|
||
st.session_state.mission_ver = _mission_ver
|
||
|
||
_can_generate = _gen_panel_active
|
||
|
||
_to_delete = None
|
||
for _idx, _row in enumerate(st.session_state.mission_rows):
|
||
_rc1, _rc2 = st.columns([1, 3])
|
||
with _rc1:
|
||
_row["key"] = st.text_input(
|
||
"Domain", _row["key"],
|
||
key=f"mkey_{_idx}",
|
||
label_visibility="collapsed",
|
||
placeholder="e.g. animal_welfare",
|
||
)
|
||
with _rc2:
|
||
_btn_col, _area_col = st.columns([1, 5])
|
||
with _area_col:
|
||
_row["value"] = st.text_area(
|
||
"Alignment note", _row["value"],
|
||
key=f"mval_{_idx}",
|
||
label_visibility="collapsed",
|
||
placeholder="Your personal connection to this domain…",
|
||
height=68,
|
||
)
|
||
with _btn_col:
|
||
if _can_generate:
|
||
if st.button("✨", key=f"mgen_{_idx}", help="Generate alignment note with AI"):
|
||
_domain = _row["key"].replace("_", " ")
|
||
_m_draft = st.session_state.get(f"mval_{_idx}", _row["value"]).strip()
|
||
_gen_prompt = (
|
||
f"Write a 2–3 sentence personal mission alignment note "
|
||
f"(first person, warm, authentic) for {_profile.name if _profile else 'the candidate'} "
|
||
f"in the '{_domain}' domain for use in cover letters. "
|
||
f"Background: {_profile.career_summary if _profile else ''}. "
|
||
f"Voice: {_profile.candidate_voice if _profile else ''}. "
|
||
f"The note should explain their genuine personal connection and why they'd "
|
||
f"be motivated working in this space. Do not start with 'I'. "
|
||
f"Return only the note, no preamble.\n"
|
||
)
|
||
if _m_draft:
|
||
_gen_prompt += f"\nExisting note to improve:\n{_m_draft}\n"
|
||
with st.spinner(f"Generating note for {_domain}…"):
|
||
from scripts.llm_router import LLMRouter as _LLMRouter
|
||
_row["value"] = _LLMRouter().complete(_gen_prompt).strip()
|
||
st.rerun()
|
||
if st.button("🗑", key=f"mdel_{_idx}", help="Remove this domain"):
|
||
_to_delete = _idx
|
||
|
||
if _to_delete is not None:
|
||
st.session_state.mission_rows.pop(_to_delete)
|
||
st.rerun()
|
||
|
||
_ac1, _ac2 = st.columns([3, 1])
|
||
_new_domain = _ac1.text_input("New domain", key="mission_new_key",
|
||
label_visibility="collapsed", placeholder="Add a domain…")
|
||
if _ac2.button("+ Add", key="mission_add") and _new_domain.strip():
|
||
st.session_state.mission_rows.append({"key": _new_domain.strip(), "value": ""})
|
||
st.rerun()
|
||
|
||
if not _can_generate:
|
||
st.caption("✨ AI generation requires a paid tier.")
|
||
|
||
_mission_updated = {
|
||
r["key"]: r["value"]
|
||
for r in st.session_state.mission_rows
|
||
if r["key"].strip()
|
||
}
|
||
|
||
with st.expander("🔒 Sensitive Employers (NDA)"):
|
||
st.caption("Companies listed here appear as 'previous employer (NDA)' in research briefs.")
|
||
nda_list = list(_u.get("nda_companies", []))
|
||
if nda_list:
|
||
nda_cols = st.columns(len(nda_list))
|
||
_to_remove = None
|
||
for i, company in enumerate(nda_list):
|
||
if nda_cols[i].button(f"× {company}", key=f"rm_nda_{company}"):
|
||
_to_remove = company
|
||
if _to_remove:
|
||
nda_list.remove(_to_remove)
|
||
nc, nb = st.columns([4, 1])
|
||
new_nda = nc.text_input("Add employer", key="new_nda",
|
||
label_visibility="collapsed", placeholder="Employer name…")
|
||
if nb.button("+ Add", key="add_nda") and new_nda.strip():
|
||
nda_list.append(new_nda.strip())
|
||
|
||
with st.expander("🔍 Research Brief Preferences"):
|
||
st.caption("Optional identity-related sections added to pre-interview research briefs. For your personal decision-making only — never included in applications.")
|
||
u_access_focus = st.checkbox(
|
||
"Include disability & accessibility section",
|
||
value=_u.get("candidate_accessibility_focus", False),
|
||
help="Adds an ADA accommodation, ERG, and WCAG assessment to each company brief.",
|
||
)
|
||
u_lgbtq_focus = st.checkbox(
|
||
"Include LGBTQIA+ inclusion section",
|
||
value=_u.get("candidate_lgbtq_focus", False),
|
||
help="Adds an assessment of the company's LGBTQIA+ ERGs, policies, and culture signals.",
|
||
)
|
||
|
||
if st.button("💾 Save Profile", type="primary", key="save_user_profile"):
|
||
# Merge: read existing YAML and update only profile fields, preserving system fields
|
||
_existing = _yaml_up.safe_load(USER_CFG.read_text()) or {} if USER_CFG.exists() else {}
|
||
_existing.update({
|
||
"name": u_name, "email": u_email, "phone": u_phone,
|
||
"linkedin": u_linkedin, "career_summary": u_summary,
|
||
"candidate_voice": u_voice,
|
||
"nda_companies": nda_list,
|
||
"mission_preferences": {k: v for k, v in _mission_updated.items() if v.strip()},
|
||
"candidate_accessibility_focus": u_access_focus,
|
||
"candidate_lgbtq_focus": u_lgbtq_focus,
|
||
})
|
||
save_yaml(USER_CFG, _existing)
|
||
st.success("Profile saved.")
|
||
st.rerun()
|
||
|
||
# ── Search tab ───────────────────────────────────────────────────────────────
|
||
with tab_search:
|
||
cfg = load_yaml(SEARCH_CFG)
|
||
profiles = cfg.get("profiles", [{}])
|
||
p = profiles[0] if profiles else {}
|
||
|
||
# Seed session state from config on first load (or when config changes after save)
|
||
_sp_hash = str(p.get("titles", [])) + str(p.get("exclude_keywords", []))
|
||
if st.session_state.get("_sp_hash") != _sp_hash:
|
||
st.session_state["_sp_titles"] = "\n".join(p.get("titles", []))
|
||
st.session_state["_sp_excludes"] = "\n".join(p.get("exclude_keywords", []))
|
||
st.session_state["_sp_hash"] = _sp_hash
|
||
|
||
# ── Titles ────────────────────────────────────────────────────────────────
|
||
title_row, suggest_btn_col = st.columns([4, 1])
|
||
with title_row:
|
||
st.subheader("Job Titles to Search")
|
||
with suggest_btn_col:
|
||
st.write("") # vertical align
|
||
_run_suggest = st.button("✨ Suggest", key="sp_suggest_btn",
|
||
help="Ask the LLM to suggest additional titles and exclude keywords based on your resume")
|
||
|
||
titles_text = st.text_area(
|
||
"One title per line",
|
||
key="_sp_titles",
|
||
height=150,
|
||
help="JobSpy will search for any of these titles across all configured boards.",
|
||
label_visibility="visible",
|
||
)
|
||
|
||
# ── LLM suggestions panel ────────────────────────────────────────────────
|
||
if _run_suggest:
|
||
current = [t.strip() for t in titles_text.splitlines() if t.strip()]
|
||
with st.spinner("Asking LLM for suggestions…"):
|
||
suggestions = _suggest_search_terms(current, RESUME_PATH)
|
||
st.session_state["_sp_suggestions"] = suggestions
|
||
|
||
if st.session_state.get("_sp_suggestions"):
|
||
sugg = st.session_state["_sp_suggestions"]
|
||
s_titles = sugg.get("suggested_titles", [])
|
||
s_excl = sugg.get("suggested_excludes", [])
|
||
|
||
existing_titles = {t.lower() for t in titles_text.splitlines() if t.strip()}
|
||
existing_excl = {e.lower() for e in st.session_state.get("_sp_excludes", "").splitlines() if e.strip()}
|
||
|
||
if s_titles:
|
||
st.caption("**Suggested titles** — click to add:")
|
||
cols = st.columns(min(len(s_titles), 4))
|
||
for i, title in enumerate(s_titles):
|
||
with cols[i % 4]:
|
||
if title.lower() not in existing_titles:
|
||
if st.button(f"+ {title}", key=f"sp_add_title_{i}"):
|
||
st.session_state["_sp_titles"] = (
|
||
st.session_state.get("_sp_titles", "").rstrip("\n") + f"\n{title}"
|
||
)
|
||
st.rerun()
|
||
else:
|
||
st.caption(f"✓ {title}")
|
||
|
||
if s_excl:
|
||
st.caption("**Suggested exclusions** — click to add:")
|
||
cols2 = st.columns(min(len(s_excl), 4))
|
||
for i, kw in enumerate(s_excl):
|
||
with cols2[i % 4]:
|
||
if kw.lower() not in existing_excl:
|
||
if st.button(f"+ {kw}", key=f"sp_add_excl_{i}"):
|
||
st.session_state["_sp_excludes"] = (
|
||
st.session_state.get("_sp_excludes", "").rstrip("\n") + f"\n{kw}"
|
||
)
|
||
st.rerun()
|
||
else:
|
||
st.caption(f"✓ {kw}")
|
||
|
||
if st.button("✕ Clear suggestions", key="sp_clear_sugg"):
|
||
st.session_state.pop("_sp_suggestions", None)
|
||
st.rerun()
|
||
|
||
st.subheader("Locations")
|
||
locations_text = st.text_area(
|
||
"One location per line",
|
||
value="\n".join(p.get("locations", [])),
|
||
height=100,
|
||
)
|
||
|
||
st.subheader("Exclude Keywords")
|
||
st.caption("Jobs whose **title or description** contain any of these words are silently dropped before entering the queue. Case-insensitive.")
|
||
exclude_text = st.text_area(
|
||
"One keyword or phrase per line",
|
||
key="_sp_excludes",
|
||
height=150,
|
||
help="e.g. 'sales', 'account executive', 'SDR'",
|
||
)
|
||
|
||
st.subheader("Job Boards")
|
||
board_options = ["linkedin", "indeed", "glassdoor", "zip_recruiter", "google"]
|
||
selected_boards = st.multiselect(
|
||
"Standard boards (via JobSpy)", board_options,
|
||
default=[b for b in p.get("boards", board_options) if b in board_options],
|
||
help="Google Jobs aggregates listings from many sources and often finds roles the other boards miss.",
|
||
)
|
||
|
||
_custom_board_options = ["adzuna", "theladders"]
|
||
_custom_board_labels = {
|
||
"adzuna": "Adzuna (free API — requires app_id + app_key in config/adzuna.yaml)",
|
||
"theladders": "The Ladders (curl_cffi scraper — $100K+ roles, requires curl_cffi)",
|
||
}
|
||
st.caption("**Custom boards** — scrapers built into this app, not part of JobSpy.")
|
||
selected_custom = st.multiselect(
|
||
"Custom boards",
|
||
options=_custom_board_options,
|
||
default=[b for b in p.get("custom_boards", []) if b in _custom_board_options],
|
||
format_func=lambda b: _custom_board_labels.get(b, b),
|
||
)
|
||
|
||
col1, col2 = st.columns(2)
|
||
results_per = col1.slider("Results per board", 5, 100, p.get("results_per_board", 25))
|
||
hours_old = col2.slider("How far back to look (hours)", 24, 720, p.get("hours_old", 72))
|
||
|
||
if st.button("💾 Save search settings", type="primary"):
|
||
profiles[0] = {
|
||
**p,
|
||
"titles": [t.strip() for t in titles_text.splitlines() if t.strip()],
|
||
"locations": [loc.strip() for loc in locations_text.splitlines() if loc.strip()],
|
||
"boards": selected_boards,
|
||
"custom_boards": selected_custom,
|
||
"results_per_board": results_per,
|
||
"hours_old": hours_old,
|
||
"exclude_keywords": [k.strip() for k in exclude_text.splitlines() if k.strip()],
|
||
}
|
||
save_yaml(SEARCH_CFG, {"profiles": profiles})
|
||
st.session_state["_sp_hash"] = "" # force re-seed on next load
|
||
st.session_state.pop("_sp_suggestions", None)
|
||
st.success("Search settings saved!")
|
||
|
||
st.divider()
|
||
|
||
# ── Blocklist ──────────────────────────────────────────────────────────────
|
||
with st.expander("🚫 Blocklist — companies, industries, and locations I will never work at", expanded=False):
|
||
st.caption(
|
||
"Listings matching any rule below are **silently dropped before entering the review queue**, "
|
||
"across all search profiles and custom boards. Changes take effect on the next discovery run."
|
||
)
|
||
bl = load_yaml(BLOCKLIST_CFG)
|
||
|
||
bl_companies = st.text_area(
|
||
"Company names (partial match, one per line)",
|
||
value="\n".join(bl.get("companies", [])),
|
||
height=120,
|
||
help="e.g. 'Amazon' blocks any listing where the company name contains 'amazon' (case-insensitive).",
|
||
key="bl_companies",
|
||
)
|
||
bl_industries = st.text_area(
|
||
"Industry / content keywords (one per line)",
|
||
value="\n".join(bl.get("industries", [])),
|
||
height=100,
|
||
help="Blocked if the keyword appears in the company name OR job description. "
|
||
"e.g. 'gambling', 'crypto', 'tobacco', 'defense contractor'.",
|
||
key="bl_industries",
|
||
)
|
||
bl_locations = st.text_area(
|
||
"Location strings to exclude (one per line)",
|
||
value="\n".join(bl.get("locations", [])),
|
||
height=80,
|
||
help="e.g. 'Dallas' blocks any listing whose location contains 'dallas'.",
|
||
key="bl_locations",
|
||
)
|
||
|
||
if st.button("💾 Save blocklist", type="primary", key="save_blocklist"):
|
||
save_yaml(BLOCKLIST_CFG, {
|
||
"companies": [c.strip() for c in bl_companies.splitlines() if c.strip()],
|
||
"industries": [i.strip() for i in bl_industries.splitlines() if i.strip()],
|
||
"locations": [loc.strip() for loc in bl_locations.splitlines() if loc.strip()],
|
||
})
|
||
st.success("Blocklist saved — takes effect on next discovery run.")
|
||
|
||
# ── Resume Profile tab ────────────────────────────────────────────────────────
|
||
|
||
def _upload_resume_widget(key_prefix: str) -> None:
|
||
"""Upload + parse + save a resume file. Overwrites config/plain_text_resume.yaml on success."""
|
||
_uf = st.file_uploader(
|
||
"Upload resume (PDF, DOCX, or ODT)",
|
||
type=["pdf", "docx", "odt"],
|
||
key=f"{key_prefix}_file",
|
||
)
|
||
if _uf and st.button("Parse & Save", type="primary", key=f"{key_prefix}_parse"):
|
||
from scripts.resume_parser import (
|
||
extract_text_from_pdf, extract_text_from_docx,
|
||
extract_text_from_odt, structure_resume,
|
||
)
|
||
_fb = _uf.read()
|
||
_ext = _uf.name.rsplit(".", 1)[-1].lower()
|
||
if _ext == "pdf":
|
||
_raw = extract_text_from_pdf(_fb)
|
||
elif _ext == "odt":
|
||
_raw = extract_text_from_odt(_fb)
|
||
else:
|
||
_raw = extract_text_from_docx(_fb)
|
||
with st.spinner("Parsing resume…"):
|
||
_parsed, _perr = structure_resume(_raw)
|
||
if _parsed and any(_parsed.get(k) for k in ("name", "experience", "skills")):
|
||
RESUME_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
RESUME_PATH.write_text(yaml.dump(_parsed, default_flow_style=False, allow_unicode=True))
|
||
# Persist raw text to user.yaml for LLM context
|
||
if USER_CFG.exists():
|
||
_uy = yaml.safe_load(USER_CFG.read_text()) or {}
|
||
_uy["resume_raw_text"] = _raw[:8000]
|
||
save_yaml(USER_CFG, _uy)
|
||
st.success("Resume parsed and saved!")
|
||
st.rerun()
|
||
else:
|
||
st.warning(
|
||
f"Parsing found limited data — try a different file format. "
|
||
f"{('Error: ' + _perr) if _perr else ''}"
|
||
)
|
||
|
||
with tab_resume:
|
||
st.caption(
|
||
f"Edit {_name}'s application profile. "
|
||
"Bullets are used as paste-able shortcuts in the Apply Workspace."
|
||
)
|
||
|
||
if not RESUME_PATH.exists():
|
||
st.info(
|
||
"No resume profile found yet. Upload your resume below to get started, "
|
||
"or re-run the [Setup wizard](/0_Setup) to build one step-by-step."
|
||
)
|
||
_upload_resume_widget("rp_new")
|
||
st.stop()
|
||
|
||
with st.expander("🔄 Replace Resume"):
|
||
st.caption("Re-upload to overwrite your saved profile. Parsed fields will replace the current data.")
|
||
_upload_resume_widget("rp_replace")
|
||
|
||
_data = yaml.safe_load(RESUME_PATH.read_text()) or {}
|
||
|
||
if "FILL_IN" in RESUME_PATH.read_text():
|
||
st.info(
|
||
"Some fields still need attention (marked ⚠️ below). "
|
||
"Re-upload your resume above to auto-fill them, or "
|
||
"re-run the [Setup wizard](/0_Setup) to fill them step-by-step."
|
||
)
|
||
|
||
def _field(label: str, value: str, key: str, help: str = "", password: bool = False) -> str:
|
||
needs_attention = str(value).startswith("FILL_IN") or value == ""
|
||
if needs_attention:
|
||
st.markdown(
|
||
'<p style="color:#F59E0B;font-size:0.8em;margin-bottom:2px">⚠️ Needs attention</p>',
|
||
unsafe_allow_html=True,
|
||
)
|
||
return st.text_input(label, value=value or "", key=key, help=help,
|
||
type="password" if password else "default")
|
||
|
||
# ── Personal Info ─────────────────────────────────────────────────────────
|
||
with st.expander("👤 Personal Information", expanded=True):
|
||
_info = _data.get("personal_information", {})
|
||
_c1, _c2 = st.columns(2)
|
||
with _c1:
|
||
_name = _field("First Name", _info.get("name", ""), "rp_name")
|
||
_email = _field("Email", _info.get("email", ""), "rp_email")
|
||
_phone = _field("Phone", _info.get("phone", ""), "rp_phone")
|
||
_city = _field("City", _info.get("city", ""), "rp_city")
|
||
with _c2:
|
||
_surname = _field("Last Name", _info.get("surname", ""), "rp_surname")
|
||
_linkedin = _field("LinkedIn URL", _info.get("linkedin", ""), "rp_linkedin")
|
||
_zip_code = _field("Zip Code", _info.get("zip_code", ""), "rp_zip")
|
||
_dob = _field("Date of Birth", _info.get("date_of_birth", ""), "rp_dob",
|
||
help="MM/DD/YYYY")
|
||
_address = _field("Street Address", _info.get("address", ""), "rp_address",
|
||
help="Used in job applications. Not shown on your resume.")
|
||
|
||
# ── Experience ────────────────────────────────────────────────────────────
|
||
with st.expander("💼 Work Experience"):
|
||
_exp_list = _data.get("experience_details", [{}])
|
||
if "rp_exp_count" not in st.session_state:
|
||
st.session_state.rp_exp_count = len(_exp_list)
|
||
if st.button("+ Add Experience Entry", key="rp_add_exp"):
|
||
st.session_state.rp_exp_count += 1
|
||
_exp_list.append({})
|
||
|
||
_updated_exp = []
|
||
for _i in range(st.session_state.rp_exp_count):
|
||
_exp = _exp_list[_i] if _i < len(_exp_list) else {}
|
||
st.markdown(f"**Position {_i + 1}**")
|
||
_ec1, _ec2 = st.columns(2)
|
||
with _ec1:
|
||
_pos = _field("Job Title", _exp.get("position", ""), f"rp_pos_{_i}")
|
||
_co = _field("Company", _exp.get("company", ""), f"rp_co_{_i}")
|
||
_period = _field("Period", _exp.get("employment_period", ""), f"rp_period_{_i}",
|
||
help="e.g. 01/2022 - Present")
|
||
with _ec2:
|
||
_loc = st.text_input("Location", _exp.get("location", ""), key=f"rp_loc_{_i}")
|
||
_ind = st.text_input("Industry", _exp.get("industry", ""), key=f"rp_ind_{_i}")
|
||
_resp_raw = st.text_area(
|
||
"Key Responsibilities (one per line)",
|
||
value="\n".join(
|
||
r.get(f"responsibility_{j+1}", "") if isinstance(r, dict) else str(r)
|
||
for j, r in enumerate(_exp.get("key_responsibilities", []))
|
||
),
|
||
key=f"rp_resp_{_i}", height=100,
|
||
)
|
||
_skills_raw = st.text_input(
|
||
"Skills (comma-separated)",
|
||
value=", ".join(_exp.get("skills_acquired", [])),
|
||
key=f"rp_skills_{_i}",
|
||
)
|
||
_updated_exp.append({
|
||
"position": _pos, "company": _co, "employment_period": _period,
|
||
"location": _loc, "industry": _ind,
|
||
"key_responsibilities": [{"responsibility_1": r.strip()} for r in _resp_raw.splitlines() if r.strip()],
|
||
"skills_acquired": [s.strip() for s in _skills_raw.split(",") if s.strip()],
|
||
})
|
||
st.divider()
|
||
|
||
# ── Preferences ───────────────────────────────────────────────────────────
|
||
with st.expander("⚙️ Preferences & Availability"):
|
||
_wp = _data.get("work_preferences", {})
|
||
_sal = _data.get("salary_expectations", {})
|
||
_avail = _data.get("availability", {})
|
||
_pc1, _pc2 = st.columns(2)
|
||
with _pc1:
|
||
_salary_range = st.text_input("Salary Range (USD)", _sal.get("salary_range_usd", ""),
|
||
key="rp_salary", help="e.g. 120000 - 180000")
|
||
_notice = st.text_input("Notice Period", _avail.get("notice_period", "2 weeks"), key="rp_notice")
|
||
with _pc2:
|
||
_remote = st.checkbox("Open to Remote", value=_wp.get("remote_work", "Yes") == "Yes", key="rp_remote")
|
||
_reloc = st.checkbox("Open to Relocation", value=_wp.get("open_to_relocation", "No") == "Yes", key="rp_reloc")
|
||
_assessments = st.checkbox("Willing to complete assessments",
|
||
value=_wp.get("willing_to_complete_assessments", "Yes") == "Yes", key="rp_assess")
|
||
_bg = st.checkbox("Willing to undergo background checks",
|
||
value=_wp.get("willing_to_undergo_background_checks", "Yes") == "Yes", key="rp_bg")
|
||
|
||
# ── Self-ID ───────────────────────────────────────────────────────────────
|
||
with st.expander("🏳️🌈 Self-Identification (optional)"):
|
||
_sid = _data.get("self_identification", {})
|
||
_sc1, _sc2 = st.columns(2)
|
||
with _sc1:
|
||
_gender = st.text_input("Gender identity", _sid.get("gender", "Non-binary"), key="rp_gender")
|
||
_pronouns = st.text_input("Pronouns", _sid.get("pronouns", "Any"), key="rp_pronouns")
|
||
_ethnicity = _field("Ethnicity", _sid.get("ethnicity", ""), "rp_ethnicity")
|
||
with _sc2:
|
||
_vet_opts = ["No", "Yes", "Prefer not to say"]
|
||
_veteran = st.selectbox("Veteran status", _vet_opts,
|
||
index=_vet_opts.index(_sid.get("veteran", "No")), key="rp_vet")
|
||
_dis_opts = ["Prefer not to say", "No", "Yes"]
|
||
_disability = st.selectbox("Disability disclosure", _dis_opts,
|
||
index=_dis_opts.index(_sid.get("disability", "Prefer not to say")),
|
||
key="rp_dis")
|
||
|
||
st.divider()
|
||
if st.button("💾 Save Resume Profile", type="primary", use_container_width=True, key="rp_save"):
|
||
_data["personal_information"] = {
|
||
**_data.get("personal_information", {}),
|
||
"name": _name, "surname": _surname, "email": _email, "phone": _phone,
|
||
"city": _city, "zip_code": _zip_code, "address": _address,
|
||
"linkedin": _linkedin, "date_of_birth": _dob,
|
||
}
|
||
_data["experience_details"] = _updated_exp
|
||
_data["salary_expectations"] = {"salary_range_usd": _salary_range}
|
||
_data["availability"] = {"notice_period": _notice}
|
||
_data["work_preferences"] = {
|
||
**_data.get("work_preferences", {}),
|
||
"remote_work": "Yes" if _remote else "No",
|
||
"open_to_relocation": "Yes" if _reloc else "No",
|
||
"willing_to_complete_assessments": "Yes" if _assessments else "No",
|
||
"willing_to_undergo_background_checks": "Yes" if _bg else "No",
|
||
}
|
||
_data["self_identification"] = {
|
||
"gender": _gender, "pronouns": _pronouns, "veteran": _veteran,
|
||
"disability": _disability, "ethnicity": _ethnicity,
|
||
}
|
||
RESUME_PATH.write_text(yaml.dump(_data, default_flow_style=False, allow_unicode=True))
|
||
st.success("✅ Resume profile saved!")
|
||
st.balloons()
|
||
|
||
st.divider()
|
||
st.subheader("🏷️ Skills & Keywords")
|
||
st.caption(
|
||
f"Matched against job descriptions to surface {_name}'s most relevant experience "
|
||
"and highlight keyword overlap in research briefs. Search the bundled list or add your own."
|
||
)
|
||
|
||
from scripts.skills_utils import load_suggestions as _load_sugg, filter_tag as _filter_tag
|
||
|
||
if not KEYWORDS_CFG.exists():
|
||
st.warning("resume_keywords.yaml not found — create it at config/resume_keywords.yaml")
|
||
else:
|
||
kw_data = load_yaml(KEYWORDS_CFG)
|
||
kw_changed = False
|
||
|
||
_KW_META = {
|
||
"skills": ("🛠️ Skills", "e.g. Customer Success, SQL, Project Management"),
|
||
"domains": ("🏢 Domains", "e.g. B2B SaaS, EdTech, Non-profit"),
|
||
"keywords": ("🔑 Keywords", "e.g. NPS, churn prevention, cross-functional"),
|
||
}
|
||
|
||
for kw_category, (kw_label, kw_placeholder) in _KW_META.items():
|
||
st.markdown(f"**{kw_label}**")
|
||
kw_current: list[str] = kw_data.get(kw_category, [])
|
||
kw_suggestions = _load_sugg(kw_category)
|
||
|
||
# Merge: suggestions first, then any custom tags not in suggestions
|
||
kw_custom = [t for t in kw_current if t not in kw_suggestions]
|
||
kw_options = kw_suggestions + kw_custom
|
||
|
||
kw_selected = st.multiselect(
|
||
kw_label,
|
||
options=kw_options,
|
||
default=[t for t in kw_current if t in kw_options],
|
||
key=f"kw_ms_{kw_category}",
|
||
label_visibility="collapsed",
|
||
help=f"Search and select from the bundled list, or add custom tags below.",
|
||
)
|
||
|
||
# Custom tag input — for entries not in the suggestions list
|
||
kw_add_col, kw_btn_col = st.columns([5, 1])
|
||
kw_raw = kw_add_col.text_input(
|
||
"Custom tag", key=f"kw_custom_{kw_category}",
|
||
label_visibility="collapsed",
|
||
placeholder=f"Custom: {kw_placeholder}",
|
||
)
|
||
if kw_btn_col.button("+", key=f"kw_add_{kw_category}", help="Add custom tag"):
|
||
cleaned = _filter_tag(kw_raw)
|
||
if cleaned is None:
|
||
st.warning(f"'{kw_raw}' was rejected — check length, characters, or content.")
|
||
elif cleaned in kw_options:
|
||
st.info(f"'{cleaned}' is already in the list — select it above.")
|
||
else:
|
||
# Persist custom tag: add to YAML and session state so it appears in options
|
||
kw_new_list = kw_selected + [cleaned]
|
||
kw_data[kw_category] = kw_new_list
|
||
kw_changed = True
|
||
|
||
# Detect multiselect changes
|
||
if sorted(kw_selected) != sorted(kw_current):
|
||
kw_data[kw_category] = kw_selected
|
||
kw_changed = True
|
||
|
||
st.markdown("---")
|
||
|
||
if kw_changed:
|
||
save_yaml(KEYWORDS_CFG, kw_data)
|
||
st.rerun()
|
||
|
||
# ── System tab ────────────────────────────────────────────────────────────────
|
||
with tab_system:
|
||
st.caption("Infrastructure, LLM backends, integrations, and service connections.")
|
||
|
||
# ── File Paths & Inference ────────────────────────────────────────────────
|
||
with st.expander("📁 File Paths & Inference Profile"):
|
||
_su = _yaml_up.safe_load(USER_CFG.read_text()) or {} if USER_CFG.exists() else {}
|
||
_ssvc = {**_UP_DEFAULTS["services"], **_su.get("services", {})}
|
||
s_docs = st.text_input("Documents directory", _su.get("docs_dir", "~/Documents/JobSearch"))
|
||
s_ollama = st.text_input("Ollama models directory", _su.get("ollama_models_dir", "~/models/ollama"))
|
||
s_vllm = st.text_input("vLLM models directory", _su.get("vllm_models_dir", "~/models/vllm"))
|
||
_inf_profiles = ["remote", "cpu", "single-gpu", "dual-gpu"]
|
||
s_inf_profile = st.selectbox("Inference profile", _inf_profiles,
|
||
index=_inf_profiles.index(_su.get("inference_profile", "remote")))
|
||
|
||
# ── Service Hosts & Ports ─────────────────────────────────────────────────
|
||
with st.expander("🔌 Service Hosts & Ports"):
|
||
st.caption("Advanced — change only if services run on non-default ports or remote hosts.")
|
||
ssc1, ssc2, ssc3 = st.columns(3)
|
||
with ssc1:
|
||
st.markdown("**Ollama**")
|
||
s_ollama_host = st.text_input("Host", _ssvc["ollama_host"], key="sys_ollama_host")
|
||
s_ollama_port = st.number_input("Port", value=_ssvc["ollama_port"], step=1, key="sys_ollama_port")
|
||
s_ollama_ssl = st.checkbox("SSL", _ssvc["ollama_ssl"], key="sys_ollama_ssl")
|
||
s_ollama_verify = st.checkbox("Verify cert", _ssvc["ollama_ssl_verify"], key="sys_ollama_verify")
|
||
with ssc2:
|
||
st.markdown("**vLLM**")
|
||
s_vllm_host = st.text_input("Host", _ssvc["vllm_host"], key="sys_vllm_host")
|
||
s_vllm_port = st.number_input("Port", value=_ssvc["vllm_port"], step=1, key="sys_vllm_port")
|
||
s_vllm_ssl = st.checkbox("SSL", _ssvc["vllm_ssl"], key="sys_vllm_ssl")
|
||
s_vllm_verify = st.checkbox("Verify cert", _ssvc["vllm_ssl_verify"], key="sys_vllm_verify")
|
||
with ssc3:
|
||
st.markdown("**SearXNG**")
|
||
s_sxng_host = st.text_input("Host", _ssvc["searxng_host"], key="sys_sxng_host")
|
||
s_sxng_port = st.number_input("Port", value=_ssvc["searxng_port"], step=1, key="sys_sxng_port")
|
||
s_sxng_ssl = st.checkbox("SSL", _ssvc["searxng_ssl"], key="sys_sxng_ssl")
|
||
s_sxng_verify = st.checkbox("Verify cert", _ssvc["searxng_ssl_verify"], key="sys_sxng_verify")
|
||
|
||
if st.button("💾 Save System Settings", type="primary", key="save_system"):
|
||
_sys_existing = _yaml_up.safe_load(USER_CFG.read_text()) or {} if USER_CFG.exists() else {}
|
||
_sys_existing.update({
|
||
"docs_dir": s_docs, "ollama_models_dir": s_ollama, "vllm_models_dir": s_vllm,
|
||
"inference_profile": s_inf_profile,
|
||
"services": {
|
||
"streamlit_port": _ssvc["streamlit_port"],
|
||
"ollama_host": s_ollama_host, "ollama_port": int(s_ollama_port),
|
||
"ollama_ssl": s_ollama_ssl, "ollama_ssl_verify": s_ollama_verify,
|
||
"vllm_host": s_vllm_host, "vllm_port": int(s_vllm_port),
|
||
"vllm_ssl": s_vllm_ssl, "vllm_ssl_verify": s_vllm_verify,
|
||
"searxng_host": s_sxng_host, "searxng_port": int(s_sxng_port),
|
||
"searxng_ssl": s_sxng_ssl, "searxng_ssl_verify": s_sxng_verify,
|
||
},
|
||
})
|
||
save_yaml(USER_CFG, _sys_existing)
|
||
from scripts.generate_llm_config import apply_service_urls as _apply_urls
|
||
_apply_urls(_UP(USER_CFG), LLM_CFG)
|
||
st.success("System settings saved and service URLs updated.")
|
||
st.rerun()
|
||
|
||
st.divider()
|
||
|
||
# ── LLM Backends ─────────────────────────────────────────────────────────
|
||
with st.expander("🤖 LLM Backends", expanded=False):
|
||
import requests as _req
|
||
|
||
def _ollama_models(base_url: str) -> list[str]:
|
||
try:
|
||
r = _req.get(base_url.rstrip("/v1").rstrip("/") + "/api/tags", timeout=2)
|
||
if r.ok:
|
||
return [m["name"] for m in r.json().get("models", [])]
|
||
except Exception:
|
||
pass
|
||
return []
|
||
|
||
llm_cfg = load_yaml(LLM_CFG)
|
||
llm_backends = llm_cfg.get("backends", {})
|
||
llm_fallback_order = llm_cfg.get("fallback_order", list(llm_backends.keys()))
|
||
|
||
_llm_cfg_key = str(llm_fallback_order)
|
||
if st.session_state.get("_llm_order_cfg_key") != _llm_cfg_key:
|
||
st.session_state["_llm_order"] = list(llm_fallback_order)
|
||
st.session_state["_llm_order_cfg_key"] = _llm_cfg_key
|
||
llm_new_order: list[str] = st.session_state["_llm_order"]
|
||
llm_all_names = list(llm_new_order) + [n for n in llm_backends if n not in llm_new_order]
|
||
|
||
st.caption("Enable/disable backends and set priority with ↑ ↓. First enabled + reachable backend wins.")
|
||
llm_updated_backends = {}
|
||
for llm_name in llm_all_names:
|
||
b = llm_backends.get(llm_name, {})
|
||
llm_enabled = b.get("enabled", True)
|
||
llm_label = llm_name.replace("_", " ").title()
|
||
llm_pos = llm_new_order.index(llm_name) + 1 if llm_name in llm_new_order else "—"
|
||
llm_header = f"{'🟢' if llm_enabled else '⚫'} **{llm_pos}. {llm_label}**"
|
||
with st.expander(llm_header, expanded=False):
|
||
llm_c1, llm_c2, llm_c3, llm_c4 = st.columns([2, 1, 1, 4])
|
||
llm_new_enabled = llm_c1.checkbox("Enabled", value=llm_enabled, key=f"{llm_name}_enabled")
|
||
if llm_name in llm_new_order:
|
||
llm_idx = llm_new_order.index(llm_name)
|
||
if llm_c2.button("↑", key=f"{llm_name}_up", disabled=llm_idx == 0):
|
||
llm_new_order[llm_idx], llm_new_order[llm_idx-1] = llm_new_order[llm_idx-1], llm_new_order[llm_idx]
|
||
st.session_state["_llm_order"] = llm_new_order
|
||
st.rerun()
|
||
if llm_c3.button("↓", key=f"{llm_name}_dn", disabled=llm_idx == len(llm_new_order)-1):
|
||
llm_new_order[llm_idx], llm_new_order[llm_idx+1] = llm_new_order[llm_idx+1], llm_new_order[llm_idx]
|
||
st.session_state["_llm_order"] = llm_new_order
|
||
st.rerun()
|
||
if b.get("type") == "openai_compat":
|
||
llm_url = st.text_input("URL", value=b.get("base_url", ""), key=f"{llm_name}_url")
|
||
if llm_name == "ollama":
|
||
llm_om = _ollama_models(b.get("base_url", "http://localhost:11434"))
|
||
llm_cur = b.get("model", "")
|
||
if llm_om:
|
||
llm_model = st.selectbox("Model", llm_om,
|
||
index=llm_om.index(llm_cur) if llm_cur in llm_om else 0,
|
||
key=f"{llm_name}_model",
|
||
help="Lists models currently installed in Ollama.")
|
||
else:
|
||
st.caption("_Ollama not reachable — enter model name manually. Start it in the **Services** section below._")
|
||
llm_model = st.text_input("Model", value=llm_cur, key=f"{llm_name}_model")
|
||
else:
|
||
llm_model = st.text_input("Model", value=b.get("model", ""), key=f"{llm_name}_model")
|
||
llm_updated_backends[llm_name] = {**b, "base_url": llm_url, "model": llm_model, "enabled": llm_new_enabled}
|
||
elif b.get("type") == "anthropic":
|
||
llm_model = st.text_input("Model", value=b.get("model", ""), key=f"{llm_name}_model")
|
||
llm_updated_backends[llm_name] = {**b, "model": llm_model, "enabled": llm_new_enabled}
|
||
else:
|
||
llm_updated_backends[llm_name] = {**b, "enabled": llm_new_enabled}
|
||
if b.get("type") == "openai_compat":
|
||
if st.button("Test connection", key=f"test_{llm_name}"):
|
||
with st.spinner("Testing…"):
|
||
try:
|
||
from scripts.llm_router import LLMRouter as _LR
|
||
reachable = _LR()._is_reachable(b.get("base_url", ""))
|
||
st.success("Reachable ✓") if reachable else st.warning("Not reachable ✗")
|
||
except Exception as e:
|
||
st.error(f"Error: {e}")
|
||
|
||
st.caption("Priority: " + " → ".join(
|
||
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})
|
||
st.session_state.pop("_llm_order", None)
|
||
st.session_state.pop("_llm_order_cfg_key", None)
|
||
st.success("LLM settings saved!")
|
||
|
||
# ── Notion ────────────────────────────────────────────────────────────────
|
||
with st.expander("📚 Notion"):
|
||
notion_cfg = load_yaml(NOTION_CFG) if NOTION_CFG.exists() else {}
|
||
n_token = st.text_input("Integration Token", value=notion_cfg.get("token", ""),
|
||
type="password", key="sys_notion_token",
|
||
help="notion.so/my-integrations → your integration → Internal Integration Token")
|
||
n_db_id = st.text_input("Database ID", value=notion_cfg.get("database_id", ""),
|
||
key="sys_notion_db",
|
||
help="The 32-character ID from your Notion database URL")
|
||
n_c1, n_c2 = st.columns(2)
|
||
if n_c1.button("💾 Save Notion", type="primary", key="sys_save_notion"):
|
||
save_yaml(NOTION_CFG, {**notion_cfg, "token": n_token, "database_id": n_db_id})
|
||
st.success("Notion settings saved!")
|
||
if n_c2.button("🔌 Test Notion", key="sys_test_notion"):
|
||
with st.spinner("Connecting…"):
|
||
try:
|
||
from notion_client import Client as _NC
|
||
_ndb = _NC(auth=n_token).databases.retrieve(n_db_id)
|
||
st.success(f"Connected to: **{_ndb['title'][0]['plain_text']}**")
|
||
except Exception as e:
|
||
st.error(f"Connection failed: {e}")
|
||
|
||
# ── Services ──────────────────────────────────────────────────────────────
|
||
with st.expander("🔌 Services", expanded=True):
|
||
import subprocess as _sp
|
||
TOKENS_CFG = CONFIG_DIR / "tokens.yaml"
|
||
COMPOSE_DIR = str(Path(__file__).parent.parent.parent)
|
||
_sys_profile_name = _profile.inference_profile if _profile else "remote"
|
||
SYS_SERVICES = [
|
||
{
|
||
"name": "Streamlit UI",
|
||
"port": _profile._svc["streamlit_port"] if _profile else 8501,
|
||
"start": ["docker", "compose", "--profile", _sys_profile_name, "up", "-d", "app"],
|
||
"stop": ["docker", "compose", "stop", "app"],
|
||
"cwd": COMPOSE_DIR, "note": "Peregrine web interface",
|
||
},
|
||
{
|
||
"name": "Ollama (local LLM)",
|
||
"port": _profile._svc["ollama_port"] if _profile else 11434,
|
||
"start": ["docker", "compose", "--profile", _sys_profile_name, "up", "-d", "ollama"],
|
||
"stop": ["docker", "compose", "stop", "ollama"],
|
||
"cwd": COMPOSE_DIR,
|
||
"note": f"Local inference — profile: {_sys_profile_name}",
|
||
"hidden": _sys_profile_name == "remote",
|
||
},
|
||
{
|
||
"name": "vLLM Server",
|
||
"port": _profile._svc["vllm_port"] if _profile else 8000,
|
||
"start": ["docker", "compose", "--profile", _sys_profile_name, "up", "-d", "vllm"],
|
||
"stop": ["docker", "compose", "stop", "vllm"],
|
||
"cwd": COMPOSE_DIR,
|
||
"model_dir": str(_profile.vllm_models_dir) if _profile else str(Path.home() / "models" / "vllm"),
|
||
"note": "vLLM inference — dual-gpu profile only",
|
||
"hidden": _sys_profile_name != "dual-gpu",
|
||
},
|
||
{
|
||
"name": "Vision Service (moondream2)",
|
||
"port": 8002,
|
||
"start": ["docker", "compose", "--profile", _sys_profile_name, "up", "-d", "vision"],
|
||
"stop": ["docker", "compose", "stop", "vision"],
|
||
"cwd": COMPOSE_DIR, "note": "Screenshot analysis for survey assistant",
|
||
"hidden": _sys_profile_name not in ("single-gpu", "dual-gpu"),
|
||
},
|
||
{
|
||
"name": "SearXNG (company scraper)",
|
||
"port": _profile._svc["searxng_port"] if _profile else 8888,
|
||
"start": ["docker", "compose", "up", "-d", "searxng"],
|
||
"stop": ["docker", "compose", "stop", "searxng"],
|
||
"cwd": COMPOSE_DIR, "note": "Privacy-respecting meta-search for company research",
|
||
},
|
||
]
|
||
SYS_SERVICES = [s for s in SYS_SERVICES if not s.get("hidden")]
|
||
|
||
def _port_open(port: int, host: str = "127.0.0.1", ssl: bool = False, verify: bool = True) -> bool:
|
||
try:
|
||
import requests as _r
|
||
scheme = "https" if ssl else "http"
|
||
_r.get(f"{scheme}://{host}:{port}/", timeout=1, verify=verify)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
st.caption("Monitor and control backend services. Status checked live on each page load.")
|
||
for svc in SYS_SERVICES:
|
||
_sh = "127.0.0.1"
|
||
_ss = False
|
||
_sv = True
|
||
if _profile:
|
||
_sh = _profile._svc.get(f"{svc['name'].split()[0].lower()}_host", "127.0.0.1")
|
||
_ss = _profile._svc.get(f"{svc['name'].split()[0].lower()}_ssl", False)
|
||
_sv = _profile._svc.get(f"{svc['name'].split()[0].lower()}_ssl_verify", True)
|
||
up = _port_open(svc["port"], host=_sh, ssl=_ss, verify=_sv)
|
||
with st.container(border=True):
|
||
lc, rc = st.columns([3, 1])
|
||
with lc:
|
||
st.markdown(f"**{svc['name']}** — {'🟢 Running' if up else '🔴 Stopped'}")
|
||
st.caption(f"Port {svc['port']} · {svc['note']}")
|
||
if "model_dir" in svc:
|
||
_mdir = Path(svc["model_dir"])
|
||
_models = sorted(d.name for d in _mdir.iterdir() if d.is_dir()) if _mdir.exists() else []
|
||
_mk = f"svc_model_{svc['port']}"
|
||
_loaded_file = Path("/tmp/vllm-server.model")
|
||
_loaded = _loaded_file.read_text().strip() if _loaded_file.exists() else ""
|
||
if _models:
|
||
st.selectbox("Model", _models,
|
||
index=_models.index(_loaded) if _loaded in _models else 0,
|
||
key=_mk)
|
||
else:
|
||
st.caption(f"_No models found in `{svc['model_dir']}` — train one in the **🎯 Fine-Tune** tab above_")
|
||
with rc:
|
||
if svc.get("start") is None:
|
||
st.caption("_Manual start only_")
|
||
elif up:
|
||
if st.button("⏹ Stop", key=f"sys_svc_stop_{svc['port']}", use_container_width=True):
|
||
with st.spinner(f"Stopping {svc['name']}…"):
|
||
r = _sp.run(svc["stop"], capture_output=True, text=True, cwd=svc["cwd"])
|
||
st.success("Stopped.") if r.returncode == 0 else st.error(r.stderr or r.stdout)
|
||
st.rerun()
|
||
else:
|
||
_start_cmd = list(svc["start"])
|
||
if "model_dir" in svc:
|
||
_sel = st.session_state.get(f"svc_model_{svc['port']}")
|
||
if _sel:
|
||
_start_cmd.append(_sel)
|
||
if st.button("▶ Start", key=f"sys_svc_start_{svc['port']}", use_container_width=True, type="primary"):
|
||
with st.spinner(f"Starting {svc['name']}…"):
|
||
r = _sp.run(_start_cmd, capture_output=True, text=True, cwd=svc["cwd"])
|
||
st.success("Started!") if r.returncode == 0 else st.error(r.stderr or r.stdout)
|
||
st.rerun()
|
||
|
||
# ── Email ─────────────────────────────────────────────────────────────────
|
||
with st.expander("📧 Email"):
|
||
EMAIL_CFG = CONFIG_DIR / "email.yaml"
|
||
if not EMAIL_CFG.exists():
|
||
st.info("No email config found — fill in credentials below and click Save to create it.")
|
||
em_cfg = load_yaml(EMAIL_CFG) if EMAIL_CFG.exists() else {}
|
||
em_c1, em_c2 = st.columns(2)
|
||
with em_c1:
|
||
em_host = st.text_input("IMAP Host", em_cfg.get("host", "imap.gmail.com"), key="sys_em_host")
|
||
em_port = st.number_input("Port", value=int(em_cfg.get("port", 993)), min_value=1, max_value=65535, key="sys_em_port")
|
||
em_ssl = st.checkbox("Use SSL", value=em_cfg.get("use_ssl", True), key="sys_em_ssl")
|
||
with em_c2:
|
||
em_user = st.text_input("Username (email)", em_cfg.get("username", ""), key="sys_em_user")
|
||
em_pass = st.text_input("Password / App Password", em_cfg.get("password", ""), type="password", key="sys_em_pass")
|
||
em_sent = st.text_input("Sent folder (blank = auto-detect)", em_cfg.get("sent_folder", ""),
|
||
key="sys_em_sent", placeholder='e.g. "[Gmail]/Sent Mail"')
|
||
em_days = st.slider("Look-back window (days)", 14, 365, int(em_cfg.get("lookback_days", 90)), key="sys_em_days")
|
||
st.caption("**Gmail users:** create an App Password at myaccount.google.com/apppasswords. Enable IMAP at Gmail Settings → Forwarding and POP/IMAP.")
|
||
em_s1, em_s2 = st.columns(2)
|
||
if em_s1.button("💾 Save Email", type="primary", key="sys_em_save"):
|
||
save_yaml(EMAIL_CFG, {
|
||
"host": em_host, "port": int(em_port), "use_ssl": em_ssl,
|
||
"username": em_user, "password": em_pass,
|
||
"sent_folder": em_sent, "lookback_days": int(em_days),
|
||
})
|
||
EMAIL_CFG.chmod(0o600)
|
||
st.success("Saved!")
|
||
if em_s2.button("🔌 Test Email", key="sys_em_test"):
|
||
with st.spinner("Connecting…"):
|
||
try:
|
||
import imaplib as _imap
|
||
_conn = (_imap.IMAP4_SSL if em_ssl else _imap.IMAP4)(em_host, int(em_port))
|
||
_conn.login(em_user, em_pass)
|
||
_conn.logout()
|
||
st.success(f"Connected to {em_host}")
|
||
except Exception as e:
|
||
st.error(f"Connection failed: {e}")
|
||
|
||
# ── Integrations ──────────────────────────────────────────────────────────
|
||
with st.expander("🔗 Integrations"):
|
||
from scripts.integrations import REGISTRY as _IREGISTRY
|
||
from app.wizard.tiers import can_use as _ican_use, tier_label as _itier_label, TIERS as _ITIERS
|
||
_INTEG_CONFIG_DIR = CONFIG_DIR
|
||
_effective_tier = _profile.effective_tier if _profile else "free"
|
||
st.caption("Connect external services for job tracking, document storage, notifications, and calendar sync.")
|
||
for _iname, _icls in _IREGISTRY.items():
|
||
_iaccess = (
|
||
_ITIERS.index(_icls.tier) <= _ITIERS.index(_effective_tier)
|
||
if _icls.tier in _ITIERS and _effective_tier in _ITIERS
|
||
else _icls.tier == "free"
|
||
)
|
||
_iconfig_exists = _icls.is_configured(_INTEG_CONFIG_DIR)
|
||
_ilabel = _itier_label(_iname + "_sync") or ""
|
||
with st.container(border=True):
|
||
_ih1, _ih2 = st.columns([8, 2])
|
||
with _ih1:
|
||
st.markdown(f"**{_icls.label}** {'🟢 Connected' if _iconfig_exists else '⚪ Not connected'}")
|
||
with _ih2:
|
||
if _ilabel:
|
||
st.caption(_ilabel)
|
||
if not _iaccess:
|
||
st.caption(f"Upgrade to {_icls.tier} to enable {_icls.label}.")
|
||
elif _iconfig_exists:
|
||
_ic1, _ic2 = st.columns(2)
|
||
if _ic1.button("🔌 Test", key=f"itest_{_iname}", use_container_width=True):
|
||
_iinst = _icls()
|
||
_iinst.connect(_iinst.load_config(_INTEG_CONFIG_DIR))
|
||
with st.spinner("Testing…"):
|
||
st.success("Connection verified.") if _iinst.test() else st.error("Test failed — check credentials.")
|
||
if _ic2.button("🗑 Disconnect", key=f"idisconnect_{_iname}", use_container_width=True):
|
||
_icls.config_path(_INTEG_CONFIG_DIR).unlink(missing_ok=True)
|
||
st.rerun()
|
||
else:
|
||
_iinst = _icls()
|
||
_ifields = _iinst.fields()
|
||
_iform_vals: dict = {}
|
||
for _ifield in _ifields:
|
||
_iform_vals[_ifield["key"]] = st.text_input(
|
||
_ifield["label"],
|
||
placeholder=_ifield.get("placeholder", ""),
|
||
type="password" if _ifield["type"] == "password" else "default",
|
||
help=_ifield.get("help", ""),
|
||
key=f"ifield_{_iname}_{_ifield['key']}",
|
||
)
|
||
if st.button("🔗 Connect & Test", key=f"iconnect_{_iname}", type="primary"):
|
||
_imissing = [f["label"] for f in _ifields if f.get("required") and not _iform_vals.get(f["key"], "").strip()]
|
||
if _imissing:
|
||
st.warning(f"Required: {', '.join(_imissing)}")
|
||
else:
|
||
_iinst.connect(_iform_vals)
|
||
with st.spinner("Testing connection…"):
|
||
if _iinst.test():
|
||
_iinst.save_config(_iform_vals, _INTEG_CONFIG_DIR)
|
||
st.success(f"{_icls.label} connected!")
|
||
st.rerun()
|
||
else:
|
||
st.error("Connection test failed — check your credentials.")
|
||
|
||
# ── Fine-Tune Wizard tab ───────────────────────────────────────────────────────
|
||
with tab_finetune:
|
||
if not _show_finetune:
|
||
st.info(
|
||
f"Fine-tuning requires a GPU profile. "
|
||
f"Current profile: `{_profile.inference_profile if _profile else 'not configured'}`. "
|
||
"Switch to the **👤 My Profile** tab above and change your inference profile to `single-gpu` or `dual-gpu`."
|
||
)
|
||
else:
|
||
st.subheader("Fine-Tune Your Cover Letter Model")
|
||
st.caption(
|
||
"Upload your existing cover letters to train a personalised writing model. "
|
||
"Requires a GPU. The base model is used until fine-tuning completes."
|
||
)
|
||
|
||
ft_step = st.session_state.get("ft_step", 1)
|
||
|
||
if ft_step == 1:
|
||
st.markdown("**Step 1: Upload Cover Letters**")
|
||
st.caption("Accepted formats: `.md` or `.txt`. Convert PDFs to text before uploading.")
|
||
uploaded = st.file_uploader(
|
||
"Upload cover letters (.md or .txt)",
|
||
type=["md", "txt"],
|
||
accept_multiple_files=True,
|
||
)
|
||
if uploaded and st.button("Extract Training Pairs →", type="primary", key="ft_extract"):
|
||
upload_dir = _profile.docs_dir / "training_data" / "uploads"
|
||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||
for f in uploaded:
|
||
(upload_dir / f.name).write_bytes(f.read())
|
||
st.session_state.ft_step = 2
|
||
st.rerun()
|
||
|
||
elif ft_step == 2:
|
||
st.markdown("**Step 2: Extract Training Pairs**")
|
||
import json as _json
|
||
import sqlite3 as _sqlite3
|
||
from scripts.db import DEFAULT_DB as _FT_DB
|
||
|
||
jsonl_path = _profile.docs_dir / "training_data" / "cover_letters.jsonl"
|
||
|
||
# Show task status
|
||
_ft_conn = _sqlite3.connect(_FT_DB)
|
||
_ft_conn.row_factory = _sqlite3.Row
|
||
_ft_task = _ft_conn.execute(
|
||
"SELECT * FROM background_tasks WHERE task_type='prepare_training' ORDER BY id DESC LIMIT 1"
|
||
).fetchone()
|
||
_ft_conn.close()
|
||
|
||
if _ft_task:
|
||
_ft_status = _ft_task["status"]
|
||
if _ft_status == "completed":
|
||
st.success(f"✅ {_ft_task['error'] or 'Extraction complete'}")
|
||
elif _ft_status in ("running", "queued"):
|
||
st.info(f"⏳ {_ft_status.capitalize()}… refresh to check progress.")
|
||
elif _ft_status == "failed":
|
||
st.error(f"Extraction failed: {_ft_task['error']}")
|
||
|
||
if st.button("⚙️ Extract Training Pairs", type="primary", key="ft_extract2"):
|
||
from scripts.task_runner import submit_task as _ft_submit
|
||
_ft_submit(_FT_DB, "prepare_training", 0)
|
||
st.info("Extracting in the background — refresh in a moment.")
|
||
st.rerun()
|
||
|
||
if jsonl_path.exists():
|
||
pairs = [_json.loads(l) for l in jsonl_path.read_text().splitlines() if l.strip()]
|
||
st.caption(f"{len(pairs)} training pairs ready.")
|
||
for i, p in enumerate(pairs[:3]):
|
||
with st.expander(f"Pair {i+1}"):
|
||
st.text(p.get("output", p.get("input", ""))[:300])
|
||
else:
|
||
st.caption("No training pairs yet — click Extract above.")
|
||
|
||
col_back, col_next = st.columns([1, 4])
|
||
if col_back.button("← Back", key="ft_back2"):
|
||
st.session_state.ft_step = 1
|
||
st.rerun()
|
||
if col_next.button("Start Training →", type="primary", key="ft_next2"):
|
||
st.session_state.ft_step = 3
|
||
st.rerun()
|
||
|
||
elif ft_step == 3:
|
||
st.markdown("**Step 3: Fine-Tune**")
|
||
|
||
_ft_profile_name = ((_profile.name.split() or ["cover"])[0].lower()
|
||
if _profile else "cover")
|
||
_ft_model_name = f"{_ft_profile_name}-cover-writer"
|
||
|
||
st.info(
|
||
"Run the command below from your terminal. Training takes 30–90 min on GPU "
|
||
"and registers the model automatically when complete."
|
||
)
|
||
st.code("make finetune PROFILE=single-gpu", language="bash")
|
||
st.caption(
|
||
f"Your model will appear as **{_ft_model_name}:latest** in Ollama. "
|
||
"Cover letter generation will use it automatically."
|
||
)
|
||
|
||
st.markdown("**Model status:**")
|
||
try:
|
||
import os as _os
|
||
import requests as _ft_req
|
||
_ollama_url = _os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||
_tags = _ft_req.get(f"{_ollama_url}/api/tags", timeout=3)
|
||
if _tags.status_code == 200:
|
||
_model_names = [m["name"] for m in _tags.json().get("models", [])]
|
||
if any(_ft_model_name in m for m in _model_names):
|
||
st.success(f"✅ `{_ft_model_name}:latest` is ready in Ollama!")
|
||
else:
|
||
st.warning(f"⏳ `{_ft_model_name}:latest` not registered yet.")
|
||
else:
|
||
st.caption("Ollama returned an unexpected response.")
|
||
except Exception:
|
||
st.caption("Could not reach Ollama — ensure services are running with `make start`.")
|
||
|
||
col_back, col_refresh = st.columns([1, 3])
|
||
if col_back.button("← Back", key="ft_back3"):
|
||
st.session_state.ft_step = 2
|
||
st.rerun()
|
||
if col_refresh.button("🔄 Check model status", key="ft_refresh3"):
|
||
st.rerun()
|
||
|
||
# ── License tab ───────────────────────────────────────────────────────────────
|
||
with tab_license:
|
||
st.subheader("🔑 License")
|
||
|
||
from scripts.license import (
|
||
verify_local as _verify_local,
|
||
activate as _activate,
|
||
deactivate as _deactivate,
|
||
_DEFAULT_LICENSE_PATH,
|
||
_DEFAULT_PUBLIC_KEY_PATH,
|
||
)
|
||
|
||
_lic = _verify_local()
|
||
|
||
if _lic:
|
||
_grace_note = " _(grace period active)_" if _lic.get("in_grace") else ""
|
||
st.success(f"**{_lic['tier'].title()} tier** active{_grace_note}")
|
||
try:
|
||
import json as _json
|
||
_key_display = _json.loads(_DEFAULT_LICENSE_PATH.read_text()).get("key_display", "—")
|
||
except Exception:
|
||
_key_display = "—"
|
||
st.caption(f"Key: `{_key_display}`")
|
||
if _lic.get("notice"):
|
||
st.info(_lic["notice"])
|
||
if st.button("Deactivate this machine", type="secondary", key="lic_deactivate"):
|
||
_deactivate()
|
||
st.success("Deactivated. Restart the app to apply.")
|
||
st.rerun()
|
||
else:
|
||
st.info("No active license — running on **free tier**.")
|
||
st.caption("Enter a license key to unlock paid features.")
|
||
_key_input = st.text_input(
|
||
"License key",
|
||
placeholder="CFG-PRNG-XXXX-XXXX-XXXX",
|
||
label_visibility="collapsed",
|
||
key="lic_key_input",
|
||
)
|
||
if st.button("Activate", disabled=not (_key_input or "").strip(), key="lic_activate"):
|
||
with st.spinner("Activating…"):
|
||
try:
|
||
result = _activate(_key_input.strip())
|
||
st.success(f"Activated! Tier: **{result['tier']}**")
|
||
st.rerun()
|
||
except Exception as _e:
|
||
st.error(f"Activation failed: {_e}")
|
||
|
||
# ── Developer tab ─────────────────────────────────────────────────────────────
|
||
if _show_dev_tab:
|
||
with _all_tabs[-1]:
|
||
st.subheader("Developer Settings")
|
||
st.caption("These settings are for local testing only and are never used in production.")
|
||
|
||
st.markdown("**Tier Override**")
|
||
st.caption("Instantly switches effective tier without changing your billing tier.")
|
||
from app.wizard.tiers import TIERS as _TIERS
|
||
_current_override = _u_for_dev.get("dev_tier_override") or ""
|
||
_override_opts = ["(none — use real tier)"] + _TIERS
|
||
_override_idx = (_TIERS.index(_current_override) + 1) if _current_override in _TIERS else 0
|
||
_new_override = st.selectbox("dev_tier_override", _override_opts, index=_override_idx)
|
||
_new_override_val = None if _new_override.startswith("(none") else _new_override
|
||
|
||
if st.button("Apply tier override", key="apply_tier_override"):
|
||
_u_for_dev["dev_tier_override"] = _new_override_val
|
||
save_yaml(USER_CFG, _u_for_dev)
|
||
st.success(f"Tier override set to: {_new_override_val or 'none'}. Page will reload.")
|
||
st.rerun()
|
||
|
||
st.divider()
|
||
st.markdown("**Wizard Reset**")
|
||
st.caption("Sets `wizard_complete: false` to re-enter the wizard without deleting your config.")
|
||
|
||
if st.button("↩ Reset wizard", key="reset_wizard"):
|
||
_u_for_dev["wizard_complete"] = False
|
||
_u_for_dev["wizard_step"] = 0
|
||
save_yaml(USER_CFG, _u_for_dev)
|
||
st.success("Wizard reset. Reload the app to re-run setup.")
|
||
|
||
st.divider()
|
||
st.markdown("**🤗 Hugging Face Token**")
|
||
st.caption(
|
||
"Used for uploading training data and running fine-tune jobs on HF infrastructure. "
|
||
"Stored in `config/tokens.yaml` (git-ignored). "
|
||
"Create a **write-permission** token at huggingface.co/settings/tokens."
|
||
)
|
||
_tok_cfg = load_yaml(TOKENS_CFG) if TOKENS_CFG.exists() else {}
|
||
_hf_token = st.text_input(
|
||
"HF Token",
|
||
value=_tok_cfg.get("hf_token", ""),
|
||
type="password",
|
||
placeholder="hf_…",
|
||
key="dev_hf_token",
|
||
)
|
||
_col_save_hf, _col_test_hf = st.columns(2)
|
||
if _col_save_hf.button("💾 Save HF token", type="primary", key="dev_save_hf"):
|
||
save_yaml(TOKENS_CFG, {**_tok_cfg, "hf_token": _hf_token})
|
||
TOKENS_CFG.chmod(0o600)
|
||
st.success("Saved!")
|
||
if _col_test_hf.button("🔌 Test HF token", key="dev_test_hf"):
|
||
with st.spinner("Checking…"):
|
||
try:
|
||
import requests as _r
|
||
resp = _r.get(
|
||
"https://huggingface.co/api/whoami",
|
||
headers={"Authorization": f"Bearer {_hf_token}"},
|
||
timeout=5,
|
||
)
|
||
if resp.ok:
|
||
info = resp.json()
|
||
name = info.get("name") or info.get("fullname") or "unknown"
|
||
perm = info.get("auth", {}).get("accessToken", {}).get("role", "read")
|
||
st.success(f"Logged in as **{name}** · permission: `{perm}`")
|
||
if perm == "read":
|
||
st.warning("Token is read-only — create a **write** token to upload datasets and run Jobs.")
|
||
else:
|
||
st.error(f"Invalid token ({resp.status_code})")
|
||
except Exception as e:
|
||
st.error(f"Error: {e}")
|