840 lines
40 KiB
Python
840 lines
40 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
|
||
|
||
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 / "aihawk" / "data_folder" / "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": []}
|
||
|
||
tab_search, tab_llm, tab_notion, tab_services, tab_resume, tab_email, tab_skills = st.tabs(
|
||
["🔎 Search", "🤖 LLM Backends", "📚 Notion", "🔌 Services", "📝 Resume Profile", "📧 Email", "🏷️ Skills"]
|
||
)
|
||
|
||
# ── 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.")
|
||
|
||
# ── LLM Backends tab ─────────────────────────────────────────────────────────
|
||
with tab_llm:
|
||
import requests as _req
|
||
|
||
def _ollama_models(base_url: str) -> list[str]:
|
||
"""Fetch installed model names from the Ollama /api/tags endpoint."""
|
||
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 []
|
||
|
||
cfg = load_yaml(LLM_CFG)
|
||
backends = cfg.get("backends", {})
|
||
fallback_order = cfg.get("fallback_order", list(backends.keys()))
|
||
|
||
# Persist reordering across reruns triggered by ↑↓ buttons.
|
||
# Reset to config order whenever the config file is fresher than the session key.
|
||
_cfg_key = str(fallback_order)
|
||
if st.session_state.get("_llm_order_cfg_key") != _cfg_key:
|
||
st.session_state["_llm_order"] = list(fallback_order)
|
||
st.session_state["_llm_order_cfg_key"] = _cfg_key
|
||
new_order: list[str] = st.session_state["_llm_order"]
|
||
|
||
# All known backends (in current order first, then any extras)
|
||
all_names = list(new_order) + [n for n in backends if n not in new_order]
|
||
|
||
st.caption("Enable/disable backends and drag their priority with the ↑ ↓ buttons. "
|
||
"First enabled + reachable backend wins on each call.")
|
||
|
||
updated_backends = {}
|
||
|
||
for name in all_names:
|
||
b = backends.get(name, {})
|
||
enabled = b.get("enabled", True)
|
||
label = name.replace("_", " ").title()
|
||
pos = new_order.index(name) + 1 if name in new_order else "—"
|
||
header = f"{'🟢' if enabled else '⚫'} **{pos}. {label}**"
|
||
|
||
with st.expander(header, expanded=False):
|
||
col_tog, col_up, col_dn, col_spacer = st.columns([2, 1, 1, 4])
|
||
|
||
new_enabled = col_tog.checkbox("Enabled", value=enabled, key=f"{name}_enabled")
|
||
|
||
# Up / Down only apply to backends currently in the order
|
||
if name in new_order:
|
||
idx = new_order.index(name)
|
||
if col_up.button("↑", key=f"{name}_up", disabled=idx == 0):
|
||
new_order[idx], new_order[idx - 1] = new_order[idx - 1], new_order[idx]
|
||
st.session_state["_llm_order"] = new_order
|
||
st.rerun()
|
||
if col_dn.button("↓", key=f"{name}_dn", disabled=idx == len(new_order) - 1):
|
||
new_order[idx], new_order[idx + 1] = new_order[idx + 1], new_order[idx]
|
||
st.session_state["_llm_order"] = new_order
|
||
st.rerun()
|
||
|
||
if b.get("type") == "openai_compat":
|
||
url = st.text_input("URL", value=b.get("base_url", ""), key=f"{name}_url")
|
||
|
||
# Ollama gets a live model picker; other backends get a text input
|
||
if name == "ollama":
|
||
ollama_models = _ollama_models(b.get("base_url", "http://localhost:11434"))
|
||
current_model = b.get("model", "")
|
||
if ollama_models:
|
||
options = ollama_models
|
||
idx_default = options.index(current_model) if current_model in options else 0
|
||
model = st.selectbox(
|
||
"Model",
|
||
options,
|
||
index=idx_default,
|
||
key=f"{name}_model",
|
||
help="Lists models currently installed in Ollama. Pull new ones with `ollama pull <name>`.",
|
||
)
|
||
else:
|
||
st.caption("_Ollama not reachable — enter model name manually_")
|
||
model = st.text_input("Model", value=current_model, key=f"{name}_model")
|
||
else:
|
||
model = st.text_input("Model", value=b.get("model", ""), key=f"{name}_model")
|
||
|
||
updated_backends[name] = {**b, "base_url": url, "model": model, "enabled": new_enabled}
|
||
elif b.get("type") == "anthropic":
|
||
model = st.text_input("Model", value=b.get("model", ""), key=f"{name}_model")
|
||
updated_backends[name] = {**b, "model": model, "enabled": new_enabled}
|
||
else:
|
||
updated_backends[name] = {**b, "enabled": new_enabled}
|
||
|
||
if b.get("type") == "openai_compat":
|
||
if st.button(f"Test connection", key=f"test_{name}"):
|
||
with st.spinner("Testing…"):
|
||
try:
|
||
from scripts.llm_router import LLMRouter
|
||
r = LLMRouter()
|
||
reachable = r._is_reachable(b.get("base_url", ""))
|
||
if reachable:
|
||
st.success("Reachable ✓")
|
||
else:
|
||
st.warning("Not reachable ✗")
|
||
except Exception as e:
|
||
st.error(f"Error: {e}")
|
||
|
||
st.divider()
|
||
st.caption("Current priority: " + " → ".join(
|
||
f"{'✓' if backends.get(n, {}).get('enabled', True) else '✗'} {n}"
|
||
for n in new_order
|
||
))
|
||
|
||
col_save_llm, col_sync_llm = st.columns(2)
|
||
if col_save_llm.button("💾 Save LLM settings", type="primary"):
|
||
save_yaml(LLM_CFG, {**cfg, "backends": updated_backends, "fallback_order": new_order})
|
||
st.session_state.pop("_llm_order", None)
|
||
st.session_state.pop("_llm_order_cfg_key", None)
|
||
st.success("LLM settings saved!")
|
||
|
||
if col_sync_llm.button("🔄 Sync URLs from Profile", help="Regenerate backend base_url values from your service host/port settings in user.yaml"):
|
||
if _profile is not None:
|
||
from scripts.generate_llm_config import apply_service_urls as _apply_urls
|
||
_apply_urls(_profile, LLM_CFG)
|
||
st.success("Profile saved and service URLs updated.")
|
||
else:
|
||
st.warning("No user profile found — configure it in the My Profile tab first.")
|
||
|
||
# ── Notion tab ────────────────────────────────────────────────────────────────
|
||
with tab_notion:
|
||
cfg = load_yaml(NOTION_CFG) if NOTION_CFG.exists() else {}
|
||
|
||
st.subheader("Notion Connection")
|
||
token = st.text_input(
|
||
"Integration Token",
|
||
value=cfg.get("token", ""),
|
||
type="password",
|
||
help="Find this at notion.so/my-integrations → your integration → Internal Integration Token",
|
||
)
|
||
db_id = st.text_input(
|
||
"Database ID",
|
||
value=cfg.get("database_id", ""),
|
||
help="The 32-character ID from your Notion database URL",
|
||
)
|
||
|
||
col_save, col_test = st.columns(2)
|
||
if col_save.button("💾 Save Notion settings", type="primary"):
|
||
save_yaml(NOTION_CFG, {**cfg, "token": token, "database_id": db_id})
|
||
st.success("Notion settings saved!")
|
||
|
||
if col_test.button("🔌 Test connection"):
|
||
with st.spinner("Connecting…"):
|
||
try:
|
||
from notion_client import Client
|
||
n = Client(auth=token)
|
||
db = n.databases.retrieve(db_id)
|
||
st.success(f"Connected to: **{db['title'][0]['plain_text']}**")
|
||
except Exception as e:
|
||
st.error(f"Connection failed: {e}")
|
||
|
||
# ── Services tab ───────────────────────────────────────────────────────────────
|
||
with tab_services:
|
||
import socket
|
||
import subprocess as _sp
|
||
|
||
TOKENS_CFG = CONFIG_DIR / "tokens.yaml"
|
||
|
||
# Service definitions: (display_name, port, start_cmd, stop_cmd, notes)
|
||
SERVICES = [
|
||
{
|
||
"name": "Streamlit UI",
|
||
"port": 8501,
|
||
"start": ["bash", str(Path(__file__).parent.parent.parent / "scripts/manage-ui.sh"), "start"],
|
||
"stop": ["bash", str(Path(__file__).parent.parent.parent / "scripts/manage-ui.sh"), "stop"],
|
||
"cwd": str(Path(__file__).parent.parent.parent),
|
||
"note": "Job Seeker web interface",
|
||
},
|
||
{
|
||
"name": "Ollama (local LLM)",
|
||
"port": 11434,
|
||
"start": ["sudo", "systemctl", "start", "ollama"],
|
||
"stop": ["sudo", "systemctl", "stop", "ollama"],
|
||
"cwd": "/",
|
||
"note": "Local inference engine — systemd service",
|
||
},
|
||
{
|
||
"name": "vLLM Server",
|
||
"port": 8000,
|
||
"start": ["bash", str(Path(__file__).parent.parent.parent / "scripts/manage-vllm.sh"), "start"],
|
||
"stop": ["bash", str(Path(__file__).parent.parent.parent / "scripts/manage-vllm.sh"), "stop"],
|
||
"cwd": str(Path(__file__).parent.parent.parent),
|
||
"model_dir": str(_profile.vllm_models_dir) if _profile else str(Path.home() / "models" / "vllm"),
|
||
"note": "Local vLLM inference (port 8000, GPU 1)",
|
||
},
|
||
{
|
||
"name": "Vision Service (moondream2)",
|
||
"port": 8002,
|
||
"start": ["bash", str(Path(__file__).parent.parent.parent / "scripts/manage-vision.sh"), "start"],
|
||
"stop": ["bash", str(Path(__file__).parent.parent.parent / "scripts/manage-vision.sh"), "stop"],
|
||
"cwd": str(Path(__file__).parent.parent.parent),
|
||
"note": "Survey screenshot analysis — moondream2 (port 8002, optional)",
|
||
},
|
||
{
|
||
"name": "SearXNG (company scraper)",
|
||
"port": _profile._svc["searxng_port"] if _profile else 8888,
|
||
"start": ["docker", "compose", "--profile", "searxng", "up", "-d", "searxng"],
|
||
"stop": ["docker", "compose", "stop", "searxng"],
|
||
"cwd": str(Path(__file__).parent.parent.parent),
|
||
"note": "Privacy-respecting meta-search for company research",
|
||
},
|
||
]
|
||
|
||
def _port_open(port: int) -> bool:
|
||
try:
|
||
with socket.create_connection(("127.0.0.1", port), timeout=1):
|
||
return True
|
||
except OSError:
|
||
return False
|
||
|
||
st.caption("Monitor and control the LLM backend services. Status is checked live on each page load.")
|
||
|
||
for svc in SERVICES:
|
||
up = _port_open(svc["port"])
|
||
badge = "🟢 Running" if up else "🔴 Stopped"
|
||
header = f"**{svc['name']}** — {badge}"
|
||
|
||
with st.container(border=True):
|
||
left_col, right_col = st.columns([3, 1])
|
||
with left_col:
|
||
st.markdown(header)
|
||
st.caption(f"Port {svc['port']} · {svc['note']}")
|
||
|
||
# Model selector for services backed by a local model directory (e.g. vLLM)
|
||
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:
|
||
_default = _models.index(_loaded) if _loaded in _models else 0
|
||
st.selectbox(
|
||
"Model",
|
||
_models,
|
||
index=_default,
|
||
key=_mk,
|
||
disabled=up,
|
||
help="Model to load on start. Stop then Start to swap models.",
|
||
)
|
||
else:
|
||
st.caption(f"_No models found in {svc['model_dir']}_")
|
||
|
||
with right_col:
|
||
if svc["start"] is None:
|
||
st.caption("_Manual start only_")
|
||
elif up:
|
||
if st.button("⏹ Stop", key=f"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"])
|
||
if r.returncode == 0:
|
||
st.success("Stopped.")
|
||
else:
|
||
st.error(f"Error: {r.stderr or r.stdout}")
|
||
st.rerun()
|
||
else:
|
||
# Build start command, appending selected model for services with model_dir
|
||
_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"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"])
|
||
if r.returncode == 0:
|
||
st.success("Started!")
|
||
else:
|
||
st.error(f"Error: {r.stderr or r.stdout}")
|
||
st.rerun()
|
||
|
||
st.divider()
|
||
st.subheader("🤗 Hugging Face")
|
||
st.caption(
|
||
"Used for uploading training data and running fine-tune jobs on HF infrastructure. "
|
||
"Token is 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_…",
|
||
)
|
||
|
||
col_save_hf, col_test_hf = st.columns(2)
|
||
if col_save_hf.button("💾 Save HF token", type="primary"):
|
||
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"):
|
||
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"
|
||
auth = info.get("auth", {})
|
||
perm = 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}")
|
||
|
||
# ── Resume Profile tab ────────────────────────────────────────────────────────
|
||
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.error(f"Resume YAML not found at `{RESUME_PATH}`. Is AIHawk cloned?")
|
||
st.stop()
|
||
|
||
_data = yaml.safe_load(RESUME_PATH.read_text()) or {}
|
||
|
||
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")
|
||
|
||
# ── 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, "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()
|
||
|
||
# ── Email tab ─────────────────────────────────────────────────────────────────
|
||
with tab_email:
|
||
EMAIL_CFG = CONFIG_DIR / "email.yaml"
|
||
EMAIL_EXAMPLE = CONFIG_DIR / "email.yaml.example"
|
||
|
||
st.caption(
|
||
f"Connect {_name}'s email via IMAP to automatically associate recruitment "
|
||
"emails with job applications. Only emails that mention the company name "
|
||
"AND contain a recruitment keyword are ever imported — no personal emails "
|
||
"are touched."
|
||
)
|
||
|
||
if not EMAIL_CFG.exists():
|
||
st.info("No email config found — fill in your credentials below and click **Save** to create it.")
|
||
|
||
em_cfg = load_yaml(EMAIL_CFG) if EMAIL_CFG.exists() else {}
|
||
|
||
col_a, col_b = st.columns(2)
|
||
with col_a:
|
||
em_host = st.text_input("IMAP Host", em_cfg.get("host", "imap.gmail.com"), key="em_host")
|
||
em_port = st.number_input("Port", value=int(em_cfg.get("port", 993)),
|
||
min_value=1, max_value=65535, key="em_port")
|
||
em_ssl = st.checkbox("Use SSL", value=em_cfg.get("use_ssl", True), key="em_ssl")
|
||
with col_b:
|
||
em_user = st.text_input("Username (email address)", em_cfg.get("username", ""), key="em_user")
|
||
em_pass = st.text_input("Password / App Password", em_cfg.get("password", ""),
|
||
type="password", key="em_pass")
|
||
em_sent = st.text_input("Sent folder (blank = auto-detect)",
|
||
em_cfg.get("sent_folder", ""), key="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="em_days")
|
||
|
||
st.caption(
|
||
"**Gmail users:** create an App Password at "
|
||
"myaccount.google.com/apppasswords (requires 2-Step Verification). "
|
||
"Enable IMAP at Gmail Settings → Forwarding and POP/IMAP."
|
||
)
|
||
|
||
col_save, col_test = st.columns(2)
|
||
|
||
if col_save.button("💾 Save email settings", type="primary", key="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 col_test.button("🔌 Test connection", key="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)
|
||
_, _caps = _conn.capability()
|
||
_conn.logout()
|
||
st.success(f"Connected successfully to {em_host}")
|
||
except Exception as e:
|
||
st.error(f"Connection failed: {e}")
|
||
|
||
# ── Skills & Keywords tab ─────────────────────────────────────────────────────
|
||
with tab_skills:
|
||
st.subheader("🏷️ Skills & Keywords")
|
||
st.caption(
|
||
f"These are matched against job descriptions to select {_name}'s most relevant "
|
||
"experience and highlight keyword overlap in the research brief."
|
||
)
|
||
|
||
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)
|
||
|
||
changed = False
|
||
for category in ["skills", "domains", "keywords"]:
|
||
st.markdown(f"**{category.title()}**")
|
||
tags: list[str] = kw_data.get(category, [])
|
||
|
||
if not tags:
|
||
st.caption("No tags yet — add one below.")
|
||
|
||
# Render existing tags as removable chips (value-based keys for stability)
|
||
n_cols = min(max(len(tags), 1), 6)
|
||
cols = st.columns(n_cols)
|
||
to_remove = None
|
||
for i, tag in enumerate(tags):
|
||
with cols[i % n_cols]:
|
||
if st.button(f"× {tag}", key=f"rm_{category}_{tag}", use_container_width=True):
|
||
to_remove = tag
|
||
if to_remove:
|
||
tags.remove(to_remove)
|
||
kw_data[category] = tags
|
||
changed = True
|
||
|
||
# Add new tag
|
||
new_col, btn_col = st.columns([4, 1])
|
||
new_tag = new_col.text_input(
|
||
"Add",
|
||
key=f"new_{category}",
|
||
label_visibility="collapsed",
|
||
placeholder=f"Add {category[:-1] if category.endswith('s') else category}…",
|
||
)
|
||
if btn_col.button("+ Add", key=f"add_{category}"):
|
||
tag = new_tag.strip()
|
||
if tag and tag not in tags:
|
||
tags.append(tag)
|
||
kw_data[category] = tags
|
||
changed = True
|
||
|
||
st.markdown("---")
|
||
|
||
if changed:
|
||
save_yaml(KEYWORDS_CFG, kw_data)
|
||
st.success("Saved.")
|
||
st.rerun()
|