diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..23ae032 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +All notable changes to Peregrine are documented here. +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +--- + +## [Unreleased] + +### Added +- Expanded first-run wizard: 7-step onboarding with GPU detection, tier selection, + resume upload/parsing, LLM inference test, search profile builder, integration cards +- Tier system: free / paid / premium feature gates (`app/wizard/tiers.py`) +- 13 integration drivers: Notion, Google Sheets, Airtable, Google Drive, Dropbox, + OneDrive, MEGA, Nextcloud, Google Calendar, Apple Calendar, Slack, Discord, + Home Assistant — with auto-discovery registry +- Resume parser: PDF (pdfplumber) and DOCX (python-docx) + LLM structuring +- `wizard_generate` background task type with iterative refinement (feedback loop) +- Dismissible setup banners on Home page (13 contextual prompts) +- Developer tab in Settings: tier override selectbox and wizard reset button +- Integrations tab in Settings: connect / test / disconnect all 12 non-Notion drivers +- HuggingFace token moved to Developer tab +- `params` column in `background_tasks` for wizard task payloads +- `wizard_complete`, `wizard_step`, `tier`, `dev_tier_override`, `dismissed_banners`, + `effective_tier` added to UserProfile +- MkDocs documentation site (Material theme, 20 pages) +- `LICENSE-MIT` and `LICENSE-BSL`, `CONTRIBUTING.md`, `CHANGELOG.md` + +### Changed +- `app.py` wizard gate now checks `wizard_complete` flag in addition to file existence +- Settings tabs reorganised: Integrations tab added, Developer tab conditionally shown +- HF token removed from Services tab (now Developer-only) + +### Removed +- Dead `app/pages/3_Resume_Editor.py` (functionality lives in Settings → Resume Profile) + +--- + +## [0.1.0] — 2026-02-01 + +### Added +- Initial release: JobSpy discovery pipeline, SQLite staging, Streamlit UI +- Job Review, Apply Workspace, Interviews kanban, Interview Prep, Survey Assistant +- LLM router with fallback chain (Ollama, vLLM, Claude Code wrapper, Anthropic) +- Notion sync, email sync with IMAP classifier, company research with SearXNG +- Background task runner with daemon threads +- Vision service (moondream2) for survey screenshot analysis +- Adzuna, The Ladders, and Craigslist custom board scrapers +- Docker Compose profiles: remote, cpu, single-gpu, dual-gpu +- `setup.sh` cross-platform dependency installer +- `scripts/preflight.py` and `scripts/migrate.py` diff --git a/app/pages/3_Resume_Editor.py b/app/pages/3_Resume_Editor.py deleted file mode 100644 index bca0008..0000000 --- a/app/pages/3_Resume_Editor.py +++ /dev/null @@ -1,191 +0,0 @@ -# app/pages/3_Resume_Editor.py -""" -Resume Editor — form-based editor for the user's AIHawk profile YAML. -FILL_IN fields highlighted in amber. -""" -import sys -from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) - -import streamlit as st -import yaml - -st.set_page_config(page_title="Resume Editor", page_icon="📝", layout="wide") -st.title("📝 Resume Editor") -st.caption("Edit your application profile used by AIHawk for LinkedIn Easy Apply.") - -RESUME_PATH = Path(__file__).parent.parent.parent / "aihawk" / "data_folder" / "plain_text_resume.yaml" - -if not RESUME_PATH.exists(): - st.error(f"Resume file 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: - """Render a text input, highlighted amber if value is FILL_IN or empty.""" - needs_attention = str(value).startswith("FILL_IN") or value == "" - if needs_attention: - st.markdown( - '
⚠️ Needs your attention
', - unsafe_allow_html=True, - ) - return st.text_input(label, value=value or "", key=key, help=help, - type="password" if password else "default") - - -st.divider() - -# ── Personal Info ───────────────────────────────────────────────────────────── -with st.expander("👤 Personal Information", expanded=True): - info = data.get("personal_information", {}) - col1, col2 = st.columns(2) - with col1: - name = field("First Name", info.get("name", ""), "pi_name") - email = field("Email", info.get("email", ""), "pi_email") - phone = field("Phone", info.get("phone", ""), "pi_phone") - city = field("City", info.get("city", ""), "pi_city") - with col2: - surname = field("Last Name", info.get("surname", ""), "pi_surname") - linkedin = field("LinkedIn URL", info.get("linkedin", ""), "pi_linkedin") - zip_code = field("Zip Code", info.get("zip_code", ""), "pi_zip") - dob = field("Date of Birth", info.get("date_of_birth", ""), "pi_dob", - help="Format: MM/DD/YYYY") - -# ── Education ───────────────────────────────────────────────────────────────── -with st.expander("🎓 Education"): - edu_list = data.get("education_details", [{}]) - updated_edu = [] - degree_options = ["Bachelor's Degree", "Master's Degree", "Some College", - "Associate's Degree", "High School", "Other"] - for i, edu in enumerate(edu_list): - st.markdown(f"**Entry {i+1}**") - col1, col2 = st.columns(2) - with col1: - inst = field("Institution", edu.get("institution", ""), f"edu_inst_{i}") - field_study = st.text_input("Field of Study", edu.get("field_of_study", ""), key=f"edu_field_{i}") - start = st.text_input("Start Year", edu.get("start_date", ""), key=f"edu_start_{i}") - with col2: - current_level = edu.get("education_level", "Some College") - level_idx = degree_options.index(current_level) if current_level in degree_options else 2 - level = st.selectbox("Degree Level", degree_options, index=level_idx, key=f"edu_level_{i}") - end = st.text_input("Completion Year", edu.get("year_of_completion", ""), key=f"edu_end_{i}") - updated_edu.append({ - "education_level": level, "institution": inst, "field_of_study": field_study, - "start_date": start, "year_of_completion": end, "final_evaluation_grade": "", "exam": {}, - }) - st.divider() - -# ── Experience ──────────────────────────────────────────────────────────────── -with st.expander("💼 Work Experience"): - exp_list = data.get("experience_details", [{}]) - if "exp_count" not in st.session_state: - st.session_state.exp_count = len(exp_list) - if st.button("+ Add Experience Entry"): - st.session_state.exp_count += 1 - exp_list.append({}) - - updated_exp = [] - for i in range(st.session_state.exp_count): - exp = exp_list[i] if i < len(exp_list) else {} - st.markdown(f"**Position {i+1}**") - col1, col2 = st.columns(2) - with col1: - pos = field("Job Title", exp.get("position", ""), f"exp_pos_{i}") - company = field("Company", exp.get("company", ""), f"exp_co_{i}") - period = field("Employment Period", exp.get("employment_period", ""), f"exp_period_{i}", - help="e.g. 01/2022 - Present") - with col2: - location = st.text_input("Location", exp.get("location", ""), key=f"exp_loc_{i}") - industry = st.text_input("Industry", exp.get("industry", ""), key=f"exp_ind_{i}") - - responsibilities = 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"exp_resp_{i}", height=100, - ) - skills = st.text_input( - "Skills (comma-separated)", - value=", ".join(exp.get("skills_acquired", [])), - key=f"exp_skills_{i}", - ) - resp_list = [{"responsibility_1": r.strip()} for r in responsibilities.splitlines() if r.strip()] - skill_list = [s.strip() for s in skills.split(",") if s.strip()] - updated_exp.append({ - "position": pos, "company": company, "employment_period": period, - "location": location, "industry": industry, - "key_responsibilities": resp_list, "skills_acquired": skill_list, - }) - st.divider() - -# ── Preferences ─────────────────────────────────────────────────────────────── -with st.expander("⚙️ Preferences & Availability"): - wp = data.get("work_preferences", {}) - sal = data.get("salary_expectations", {}) - avail = data.get("availability", {}) - col1, col2 = st.columns(2) - with col1: - salary_range = st.text_input("Salary Range (USD)", sal.get("salary_range_usd", ""), - key="pref_salary", help="e.g. 120000 - 180000") - notice = st.text_input("Notice Period", avail.get("notice_period", "2 weeks"), key="pref_notice") - with col2: - remote_work = st.checkbox("Open to Remote", value=wp.get("remote_work", "Yes") == "Yes", key="pref_remote") - relocation = st.checkbox("Open to Relocation", value=wp.get("open_to_relocation", "No") == "Yes", key="pref_reloc") - assessments = st.checkbox("Willing to complete assessments", - value=wp.get("willing_to_complete_assessments", "Yes") == "Yes", key="pref_assess") - bg_checks = st.checkbox("Willing to undergo background checks", - value=wp.get("willing_to_undergo_background_checks", "Yes") == "Yes", key="pref_bg") - drug_tests = st.checkbox("Willing to undergo drug tests", - value=wp.get("willing_to_undergo_drug_tests", "No") == "Yes", key="pref_drug") - -# ── Self-ID ─────────────────────────────────────────────────────────────────── -with st.expander("🏳️🌈 Self-Identification (optional)"): - sid = data.get("self_identification", {}) - col1, col2 = st.columns(2) - with col1: - gender = st.text_input("Gender identity", sid.get("gender", "Non-binary"), key="sid_gender", - help="Select 'Non-binary' or 'Prefer not to say' when options allow") - pronouns = st.text_input("Pronouns", sid.get("pronouns", "Any"), key="sid_pronouns") - ethnicity = field("Ethnicity", sid.get("ethnicity", ""), "sid_ethnicity", - help="'Prefer not to say' is always an option") - with col2: - vet_options = ["No", "Yes", "Prefer not to say"] - veteran = st.selectbox("Veteran status", vet_options, - index=vet_options.index(sid.get("veteran", "No")), key="sid_vet") - dis_options = ["Prefer not to say", "No", "Yes"] - disability = st.selectbox("Disability disclosure", dis_options, - index=dis_options.index(sid.get("disability", "Prefer not to say")), - key="sid_dis") - -st.divider() - -# ── Save ────────────────────────────────────────────────────────────────────── -if st.button("💾 Save Resume Profile", type="primary", use_container_width=True): - 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["education_details"] = updated_edu - 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_work else "No", - "open_to_relocation": "Yes" if relocation else "No", - "willing_to_complete_assessments": "Yes" if assessments else "No", - "willing_to_undergo_background_checks": "Yes" if bg_checks else "No", - "willing_to_undergo_drug_tests": "Yes" if drug_tests 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("✅ Profile saved!") - st.balloons() diff --git a/docs/backlog.md b/docs/backlog.md index 04b57a5..e7f63c9 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -7,12 +7,20 @@ Unscheduled ideas and deferred features. Roughly grouped by area. ## Settings / Data Management - **Backup / Restore / Teleport** — Settings panel option to export a full config snapshot (user.yaml + all gitignored configs) as a zip, restore from a snapshot, and "teleport" (export + import to a new machine or Docker volume). Useful for migrations, multi-machine setups, and safe wizard testing. +- **Complete Google Drive integration test()** — `scripts/integrations/google_drive.py` `test()` currently only checks that the credentials file exists (TODO comment). Implement actual Google Drive API call using `google-api-python-client` to verify the token works. + +--- + +## First-Run Wizard + +- **Wire real LLM test in Step 5 (Inference)** — `app/wizard/step_inference.py` validates an `endpoint_confirmed` boolean flag only. Replace with an actual LLM call: submit a minimal prompt to the configured endpoint, show pass/fail, and only set `endpoint_confirmed: true` on success. Should test whichever backend the user selected (Ollama, vLLM, Anthropic, etc.). --- ## Cover Letter / Resume Generation - **Iterative refinement feedback loop** — Apply Workspace cover letter generator: show previous result + a "Feedback / changes requested" text area + "Regenerate" button. Pass `previous_result` and `feedback` through `generate()` in `scripts/generate_cover_letter.py` to the LLM prompt. Same pattern for resume bullet expansion in the wizard (`wizard_generate: expand_bullets`). Backend already supports `previous_result`/`feedback` in `wizard_generate` tasks (added to `_run_wizard_generate`). +- **Apply Workspace refinement UI ready to wire** — Remaining work: add a "Feedback / changes requested" text area and "Regenerate" button in `app/pages/4_Apply.py`, pass both fields through `submit_task` → `_run_wizard_generate`. Backend is complete. --- diff --git a/environment.yml b/environment.yml index d381d9d..8839279 100644 --- a/environment.yml +++ b/environment.yml @@ -66,3 +66,6 @@ dependencies: - pytest>=9.0 - pytest-cov - pytest-mock + # Documentation + - mkdocs>=1.5 + - mkdocs-material>=9.5 diff --git a/requirements.txt b/requirements.txt index 89158aa..30b7078 100644 --- a/requirements.txt +++ b/requirements.txt @@ -61,3 +61,7 @@ pytest>=9.0 pytest-cov pytest-mock lxml + +# ── Documentation ──────────────────────────────────────────────────────── +mkdocs>=1.5 +mkdocs-material>=9.5