diff --git a/README.md b/README.md index b412342..10acd60 100644 --- a/README.md +++ b/README.md @@ -229,13 +229,17 @@ Click **🎯 Tailor to JD**, paste any JD, and drag the intensity slider (0–10 ### Persistent profile (My Profile) -Click **🧠 My Profile** to store your identity, work history, projects, education, and target roles across five tabs. Profile data is automatically injected into every pipeline run. +Click **🧠 My Profile** to store your identity, work history, projects, education, and target roles across five tabs. Profile data is injected into every pipeline run while the **🧠 Use memory** toggle is on β€” a global preference that persists across chats, sessions, and reloads (stored server-side). Each generated reply also notes whether your master resume and/or profile memory were used, so it's clear later what fed the result. **Import / Export** β€” download your profile as a portable JSON file and load it back on any install. Invalid files are rejected with a clear error. ### Master resumes -Save any generated resume as a named **master resume** and set one as the default. The sidebar attach button starts a new chat with a specific master pre-loaded β€” great for keeping a polished base and forking variants per role. +Save any generated resume as a named **master resume** and mark one as the default β€” great for keeping a polished base and forking variants per role. Three ways to put them to work: + +- **⭐ Use Master** (input bar) attaches a master as the starting point for your next message, so you can tailor it to a JD or refine it through the pipeline. +- **⭐ New from Master** (sidebar) opens a fresh chat with the master loaded **verbatim β€” no AI changes**. Click the main button for your default, or the caret to pick any saved master. Ideal for forking a polished base, or just attaching the application tracker to an untouched copy. +- **Compare to Master** on any resume version diffs it against a chosen master, so you can see exactly what tailoring changed relative to your base. ### Manual editor + Re-score diff --git a/backend/app/api/_pipeline.py b/backend/app/api/_pipeline.py index 7c15781..4da80d1 100644 --- a/backend/app/api/_pipeline.py +++ b/backend/app/api/_pipeline.py @@ -79,7 +79,8 @@ async def _save_progress(msg_id: str, events: list) -> None: pass -async def _run_pipeline_background(msg_id: str, session_id: str, initial_state: dict): +async def _run_pipeline_background(msg_id: str, session_id: str, initial_state: dict, + run_meta: dict | None = None): # Imported lazily so the test harness can stub the graph without forcing a # heavy import chain at app startup. from app.agents.graph import RESUME_GRAPH @@ -159,7 +160,12 @@ def _sync_stream(): ) else: status_str = "complete" - summary = _build_summary(resume, has_cover=bool(cover_letter), n_emails=len(outreach_emails or [])) + meta_info = run_meta or {} + summary = _build_summary( + resume, has_cover=bool(cover_letter), n_emails=len(outreach_emails or []), + used_memory=bool(meta_info.get("used_memory")), + from_master_name=meta_info.get("from_master_name"), + ) async with AsyncSessionLocal() as db: result = await db.execute(select(Message).where(Message.id == msg_id)) @@ -301,7 +307,8 @@ def _safe_score(v) -> float: return 0.0 -def _build_summary(resume: dict | None, has_cover: bool = False, n_emails: int = 0) -> str: +def _build_summary(resume: dict | None, has_cover: bool = False, n_emails: int = 0, + used_memory: bool = False, from_master_name: str | None = None) -> str: if not resume: return "I encountered an issue generating your resume. Please try again." meta = resume.get("metadata", {}) @@ -337,6 +344,16 @@ def _build_summary(resume: dict | None, has_cover: bool = False, n_emails: int = for s in improvements[:3]: lines.append(f"β€’ {s}") + # Provenance note β€” records what fed this generation so it's clear on a later + # read whether the master resume and/or profile memory were applied. + sources = [] + if from_master_name: + sources.append(f'master resume **{from_master_name}**') + if used_memory: + sources.append("your **profile memory**") + if sources: + lines.append(f"\nπŸ“Ž Generated using {' and '.join(sources)}.") + lines.append("") lines.append("Tip: ask me to refine sections, raise seniority, target a company, add certifications, or paste a JD to tailor.") return "\n".join(lines) diff --git a/backend/app/api/messages_routes.py b/backend/app/api/messages_routes.py index 119f8e4..3ed2041 100644 --- a/backend/app/api/messages_routes.py +++ b/backend/app/api/messages_routes.py @@ -150,9 +150,15 @@ async def send_message( "section_preferences": section_prefs, } + # Provenance for the reply's "sources" note β€” what fed this generation. + run_meta = { + "used_memory": bool(memory_context), + "from_master_name": (req.from_master_name or "").strip() or None, + } + background_tasks.add_task( _run_pipeline_background, - asst_msg.id, session_id, initial_state, + asst_msg.id, session_id, initial_state, run_meta, ) return {"user_message_id": user_msg.id, "assistant_message_id": asst_msg.id, "status": "processing"} diff --git a/backend/app/api/sessions_routes.py b/backend/app/api/sessions_routes.py index 7aef522..28350af 100644 --- a/backend/app/api/sessions_routes.py +++ b/backend/app/api/sessions_routes.py @@ -1,4 +1,5 @@ """Session CRUD endpoints β€” list/create/read/update/delete one or all.""" +import copy from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException @@ -7,14 +8,15 @@ from sqlalchemy.orm import selectinload from app.database import get_db -from app.models import Session, Message +from app.models import Session, Message, MasterResume from app.schemas import ( - CreateSessionRequest, SessionOut, SessionSummary, - UpdateSessionRequest, SessionsPage, + CreateSessionRequest, CreateSessionFromMasterRequest, SessionOut, + SessionSummary, UpdateSessionRequest, SessionsPage, ) from app.config import settings from ._pipeline import get_cancel_event +from .memory_routes import _resolve_default router = APIRouter(prefix="/api", tags=["sessions"]) @@ -90,6 +92,81 @@ async def create_session(req: CreateSessionRequest, db: AsyncSession = Depends(g updated_at=s.updated_at, messages=[]) +@router.post("/sessions/from-master", response_model=SessionOut, status_code=201) +async def create_session_from_master( + req: CreateSessionFromMasterRequest, db: AsyncSession = Depends(get_db) +): + """Create a new chat seeded with a master resume **verbatim** β€” no LLM / + pipeline run, so the resume is guaranteed identical to the master. Lets the + user start from their master (e.g. to track an application) and edit on top + of it without re-tailoring. The seeded resume lands as a completed assistant + message, exactly like a normal pipeline result, so editing/export/diff all + work unchanged. + """ + # Resolve which master to load: explicit id, else the default (or earliest). + # `_resolve_default` is the single source of truth for "which master is the + # default", shared with the memory routes. + if req.master_id: + res = await db.execute(select(MasterResume).where(MasterResume.id == req.master_id)) + master = res.scalar_one_or_none() + if not master: + raise HTTPException(404, "Master resume not found") + else: + master = await _resolve_default(db) + if not master: + raise HTTPException(404, "No master resume saved yet") + + # Deep-copy so later edits to this chat never mutate the stored master. + resume = copy.deepcopy(master.resume) if isinstance(master.resume, dict) else (master.resume or {}) + pi = resume.get("personal_info") or {} + meta = resume.get("metadata") or {} + full_name = (pi.get("full_name") or "").strip() + role = (pi.get("professional_title") or meta.get("jd_role") or "").strip() + title = (f"{full_name} β€” {role}" if full_name and role else full_name or master.name or "Master Resume")[:255] + + try: + score = float(meta.get("overall_score")) + except (TypeError, ValueError): + score = None + + s = Session( + title=title, + llm_provider=req.llm_provider or settings.llm_provider, + llm_model=req.llm_model or settings.llm_model, + ) + db.add(s) + await db.flush() + + ts = int(datetime.now(timezone.utc).timestamp() * 1000) + trace = [{ + "agent": "Master Resume", "status": "complete", + "notes": f'Loaded "{master.name}" verbatim β€” no AI changes applied.', + "timestamp": ts, + }] + db.add(Message( + session_id=s.id, role="user", status="complete", + content=f'⭐ Start from master resume "{master.name}" (verbatim β€” no changes).', + )) + db.add(Message( + session_id=s.id, role="assistant", status="complete", + content=( + f"⭐ Loaded master resume **{master.name}** as-is β€” no AI changes were applied.\n\n" + "Edit any section directly, paste a job description to tailor it, or fill in " + "the application tracker for this chat." + ), + resume_json=resume, + agent_trace=trace, + progress_events=trace, + final_score=score, + )) + await db.commit() + + result = await db.execute( + select(Session).options(selectinload(Session.messages)).where(Session.id == s.id) + ) + return result.scalar_one() + + @router.get("/sessions/{session_id}", response_model=SessionOut) async def get_session(session_id: str, db: AsyncSession = Depends(get_db)): result = await db.execute( diff --git a/backend/app/api/settings_routes.py b/backend/app/api/settings_routes.py index 80981b5..6c59f7a 100644 --- a/backend/app/api/settings_routes.py +++ b/backend/app/api/settings_routes.py @@ -36,6 +36,8 @@ class AppSettingsUpsert(BaseModel): max_review_iterations: Optional[int] = None min_quality_score: Optional[float] = None default_jd_intensity: Optional[int] = None + # Global "Use memory" preference β€” persisted across chats/sessions. + memory_enabled: Optional[bool] = None # {"sections": {key: bool}, "fields": {"section.field": bool}} β€” missing = enabled section_preferences: Optional[dict] = None diff --git a/backend/app/config.py b/backend/app/config.py index 3e1dd8b..04b0074 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -44,6 +44,9 @@ class Settings(BaseSettings): # Remembered JD-tailoring slider value (0–100). DB overlay populates this # at runtime β€” the .env baseline is None so the UI defaults to 100%. default_jd_intensity: Optional[int] = None + # Global "Use memory" default. Baseline ON; the DB overlay flips it when the + # user toggles the pill, and the value persists across chats/sessions. + memory_enabled: bool = True # ── Optional Basic Auth (single admin user) ──────────────────────────── # When AUTH_ENABLED=true, every /api/* request requires HTTP Basic auth diff --git a/backend/app/database.py b/backend/app/database.py index d453a98..9968425 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -56,6 +56,7 @@ async def get_db(): ("app_settings", "default_jd_intensity", "ALTER TABLE app_settings ADD COLUMN default_jd_intensity INTEGER"), ("app_settings", "section_preferences", "ALTER TABLE app_settings ADD COLUMN section_preferences JSON"), ("app_settings", "openai_base_url", "ALTER TABLE app_settings ADD COLUMN openai_base_url VARCHAR(500)"), + ("app_settings", "memory_enabled", "ALTER TABLE app_settings ADD COLUMN memory_enabled BOOLEAN"), ] diff --git a/backend/app/documents/docx_generator.py b/backend/app/documents/docx_generator.py index 2b16e9d..f0bf41f 100644 --- a/backend/app/documents/docx_generator.py +++ b/backend/app/documents/docx_generator.py @@ -434,7 +434,7 @@ def _set_doc_margins(doc: Document, *, side_cm: float, vert_cm: float): _FA_ICONS = { "phone": (_FA_SOLID, "ο‚•"), "email": (_FA_SOLID, "οƒ "), - "location": (_FA_SOLID, ""), + "location": (_FA_SOLID, ""), "linkedin": (_FA_BRANDS, "ο‚Œ"), "github": (_FA_BRANDS, "ο‚›"), "web": (_FA_SOLID, ""), diff --git a/backend/app/documents/odt_generator.py b/backend/app/documents/odt_generator.py index a397c76..d9d13e2 100644 --- a/backend/app/documents/odt_generator.py +++ b/backend/app/documents/odt_generator.py @@ -404,7 +404,7 @@ def _make_doc(*, side_cm: float, vert_cm: float, bg_color: str | None = None) -> _FA_ICONS = { "phone": (_FA_SOLID, "ο‚•"), # faPhone "email": (_FA_SOLID, "οƒ "), # faEnvelope - "location": (_FA_SOLID, ""), # faMapMarker + "location": (_FA_SOLID, ""), # faMapMarker "linkedin": (_FA_BRANDS, "ο‚Œ"), # faLinkedin "github": (_FA_BRANDS, "ο‚›"), # faGithub "web": (_FA_SOLID, ""), # faGlobe diff --git a/backend/app/documents/pdf_generator.py b/backend/app/documents/pdf_generator.py index 6ad1bb2..5af9490 100644 --- a/backend/app/documents/pdf_generator.py +++ b/backend/app/documents/pdf_generator.py @@ -817,7 +817,7 @@ def _ensure_fa_fonts() -> bool: _FA_ICONS = { "phone": ("FA-Solid", "ο‚•"), # faPhone (handset) "email": ("FA-Solid", "οƒ "), # faEnvelope (solid) - "location": ("FA-Solid", ""), # faMapMarker (solid teardrop) + "location": ("FA-Solid", ""), # faMapMarkerAlt / location-dot (pin with cutout) "linkedin": ("FA-Brands", "ο‚Œ"), # faLinkedin "github": ("FA-Brands", "ο‚›"), # faGithub "web": ("FA-Solid", ""), # faGlobe diff --git a/backend/app/models.py b/backend/app/models.py index 43ee85f..7f5d99f 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -106,6 +106,9 @@ class AppSettings(Base): # Last-used JD tailoring slider (0–100). Persisted so the UI starts on the # user's preferred intensity instead of resetting to 100 every reload. default_jd_intensity: Mapped[int | None] = mapped_column(Integer, nullable=True) + # Global "Use memory" preference. Persisted so the toggle is remembered + # across chats, sessions, and reloads. NULL β†’ fall back to baseline (on). + memory_enabled: Mapped[bool | None] = mapped_column(Boolean, default=None, nullable=True) # Per-user section/field enable map. Shape: # {"sections": {"languages": false, ...}, "fields": {"personal_info.website": false, ...}} # Missing keys default to enabled. diff --git a/backend/app/runtime_settings.py b/backend/app/runtime_settings.py index 85d1b8f..b53650d 100644 --- a/backend/app/runtime_settings.py +++ b/backend/app/runtime_settings.py @@ -27,7 +27,7 @@ "ollama_base_url", "openai_base_url", "langsmith_api_key", "langsmith_project", "langsmith_tracing", "max_review_iterations", "min_quality_score", - "default_jd_intensity", + "default_jd_intensity", "memory_enabled", ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 301bffd..17c3aef 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -22,6 +22,14 @@ class CreateSessionRequest(BaseModel): llm_model: Optional[str] = None +class CreateSessionFromMasterRequest(BaseModel): + # Which master resume to seed the new chat with. None β†’ the default master + # (or the only/earliest one if none is explicitly marked default). + master_id: Optional[str] = None + llm_provider: Optional[str] = None + llm_model: Optional[str] = None + + class UpdateSessionRequest(BaseModel): title: Optional[str] = None llm_provider: Optional[str] = None @@ -100,6 +108,7 @@ class SendMessageRequest(BaseModel): jd_text: Optional[str] = None # job description for tailoring jd_intensity: Optional[int] = None # 0–100; how aggressively to tailor to the JD (100 = full rewrite, 0 = barely touch the resume) attached_resume: Optional[dict] = None # uploaded JSON resume to start from / refine + from_master_name: Optional[str] = None # name of the master resume this turn was seeded from (for the "sources" note) use_memory: Optional[bool] = True # apply persistent profile memory to this turn llm_provider: Optional[str] = None llm_model: Optional[str] = None diff --git a/backend/tests/api/test_messages.py b/backend/tests/api/test_messages.py index 5e3c5ca..65826cb 100644 --- a/backend/tests/api/test_messages.py +++ b/backend/tests/api/test_messages.py @@ -191,3 +191,34 @@ async def test_session_title_auto_updates_from_resume(async_client, fake_llm): detail = (await async_client.get(f"/api/sessions/{sid}")).json() assert "Test Person" in detail["title"] + + +async def test_reply_notes_master_and_memory_sources(async_client, fake_llm): + """The generated reply records that the master resume + profile memory were + used, so it's clear on a later read what fed the generation.""" + # Seed a profile so "use memory" actually loads something (else it's a no-op). + await async_client.put("/api/memory", json={"full_name": "Ada Lovelace"}) + sid = await _create_session(async_client) + r = await async_client.post( + f"/api/sessions/{sid}/messages", + json={"content": "Senior Python dev", "from_master_name": "Backend Master", + "use_memory": True}, + ) + final = await _poll_until_done(async_client, r.json()["assistant_message_id"]) + assert final["status"] == "complete" + assert final["resume_json"] is not None + content = final["content"] + assert "Backend Master" in content + assert "profile memory" in content.lower() + + +async def test_reply_omits_sources_when_none_used(async_client, fake_llm): + """No master + memory off β†’ no sources note clutters the reply.""" + sid = await _create_session(async_client) + r = await async_client.post( + f"/api/sessions/{sid}/messages", + json={"content": "Senior Python dev", "use_memory": False}, + ) + final = await _poll_until_done(async_client, r.json()["assistant_message_id"]) + assert final["resume_json"] is not None + assert "Generated using" not in final["content"] diff --git a/backend/tests/api/test_sessions.py b/backend/tests/api/test_sessions.py index 1608678..ca1ff42 100644 --- a/backend/tests/api/test_sessions.py +++ b/backend/tests/api/test_sessions.py @@ -120,3 +120,78 @@ async def test_session_app_status_fields(async_client): assert detail["app_status"] == "applied" assert detail["notes"] == "Followed up via LinkedIn." assert detail["apply_url"] == "https://example.com/careers/123" + + +# ── New chat from master (verbatim seed, no pipeline) ───────────────────────── + +async def _create_master(async_client, resume, name=None, is_default=False) -> dict: + body = {"resume": resume, "is_default": is_default} + if name is not None: + body["name"] = name + r = await async_client.post("/api/memory/master-resumes", json=body) + assert r.status_code == 200, r.text + return r.json() + + +async def test_from_master_no_master_404(async_client): + r = await async_client.post("/api/sessions/from-master", json={}) + assert r.status_code == 404 + + +async def test_from_master_seeds_resume_verbatim(async_client, sample_resume): + await _create_master(async_client, sample_resume, name="Backend Master") + + r = await async_client.post("/api/sessions/from-master", json={}) + assert r.status_code == 201, r.text + sess = r.json() + + # Title derived from the resume identity, not "New Resume". + assert sess["title"] == "Ada Lovelace β€” Senior Software Engineer" + + # Seeded as a finished user + assistant pair, no processing. + roles = [m["role"] for m in sess["messages"]] + assert roles == ["user", "assistant"] + asst = sess["messages"][1] + assert asst["status"] == "complete" + # The resume is byte-for-byte the master β€” no AI edits. + assert asst["resume_json"] == sample_resume + assert asst["final_score"] == 88 + # A synthetic trace marks it as a verbatim load (no LLM agents ran). + agents = [e["agent"] for e in (asst["progress_events"] or [])] + assert agents == ["Master Resume"] + + +async def test_from_master_explicit_id_overrides_default(async_client, sample_resume, minimal_resume): + # First master becomes default automatically; second is explicit, non-default. + await _create_master(async_client, sample_resume, name="Default One") + second = await _create_master(async_client, minimal_resume, name="Picked One") + + r = await async_client.post( + "/api/sessions/from-master", json={"master_id": second["id"]} + ) + assert r.status_code == 201, r.text + asst = r.json()["messages"][1] + assert asst["resume_json"] == minimal_resume + + +async def test_from_master_unknown_id_404(async_client, sample_resume): + await _create_master(async_client, sample_resume) + r = await async_client.post( + "/api/sessions/from-master", json={"master_id": "nope"} + ) + assert r.status_code == 404 + + +async def test_from_master_deep_copies_resume(async_client, sample_resume): + """Editing the seeded chat's resume must not mutate the stored master.""" + master = await _create_master(async_client, sample_resume, name="Immutable") + sess = (await async_client.post("/api/sessions/from-master", json={})).json() + msg_id = sess["messages"][1]["id"] + + edited = dict(sample_resume) + edited["professional_summary"] = "MUTATED" + await async_client.patch(f"/api/messages/{msg_id}/resume", json={"resume_json": edited}) + + stored = (await async_client.get(f"/api/memory/master-resumes/{master['id']}")).json() + assert stored["resume"]["professional_summary"] == sample_resume["professional_summary"] + assert stored["resume"]["professional_summary"] != "MUTATED" diff --git a/backend/tests/api/test_settings_routes.py b/backend/tests/api/test_settings_routes.py index daa7d13..6430ec1 100644 --- a/backend/tests/api/test_settings_routes.py +++ b/backend/tests/api/test_settings_routes.py @@ -54,6 +54,26 @@ async def test_reset_settings_drops_overlay(async_client): assert body["max_review_iterations"] == 3 +async def test_memory_enabled_persists(async_client): + """The global 'Use memory' preference round-trips through GET/PUT so the + toggle is remembered across chats, sessions, and reloads.""" + # Turn it OFF β€” a bool False must persist (not be treated as "unset/clear"). + r = await async_client.put("/api/app-settings", json={"memory_enabled": False}) + assert r.json()["memory_enabled"] is False + assert (await async_client.get("/api/app-settings")).json()["memory_enabled"] is False + # And back ON. + r2 = await async_client.put("/api/app-settings", json={"memory_enabled": True}) + assert r2.json()["memory_enabled"] is True + assert (await async_client.get("/api/app-settings")).json()["memory_enabled"] is True + + +async def test_memory_enabled_defaults_on_after_reset(async_client): + """With no overlay, memory defaults ON (baseline).""" + await async_client.put("/api/app-settings", json={"memory_enabled": False}) + await async_client.delete("/api/app-settings") # drop overlay β†’ baseline + assert (await async_client.get("/api/app-settings")).json()["memory_enabled"] is True + + async def test_test_provider_uses_fake_llm(async_client, fake_llm): """/api/app-settings/test should hit the LLM and echo back a sample.""" # Make the (fake) generator respond to a custom prompt by overriding diff --git a/backend/tests/documents/test_contact_icons.py b/backend/tests/documents/test_contact_icons.py new file mode 100644 index 0000000..7ec3040 --- /dev/null +++ b/backend/tests/documents/test_contact_icons.py @@ -0,0 +1,51 @@ +"""Contact-icon glyph contract β€” shared across the PDF / DOCX / ODT exporters. + +Regression guard for the location pin: it must be Font Awesome ``location-dot`` +(U+F3C5 β€” a pin with a hollow centre), NOT ``location-pin`` (U+F041 β€” a solid +black teardrop that reads as an ink blob). All three exporters must agree so a +rΓ©sumΓ©'s contact line looks identical whichever format it's downloaded in. +""" +from __future__ import annotations + +import importlib + +import pytest + +pytestmark = pytest.mark.documents + +LOCATION_DOT = "" # what we want β€” pin with a hollow centre +LOCATION_PIN = "" # the old solid blob β€” must never come back + +_GENERATORS = [ + "app.documents.pdf_generator", + "app.documents.docx_generator", + "app.documents.odt_generator", +] + + +@pytest.mark.parametrize("module_path", _GENERATORS) +def test_location_icon_is_location_dot_not_blob(module_path): + mod = importlib.import_module(module_path) + _face, glyph = mod._FA_ICONS["location"] + assert glyph == LOCATION_DOT, ( + f"{module_path} location glyph is U+{ord(glyph):04X}, expected U+F3C5 (location-dot)" + ) + assert glyph != LOCATION_PIN, f"{module_path} regressed to the solid blob U+F041" + + +def test_all_exporters_agree_on_location_glyph(): + glyphs = {importlib.import_module(p)._FA_ICONS["location"][1] for p in _GENERATORS} + assert glyphs == {LOCATION_DOT}, f"exporters disagree on the location glyph: {glyphs}" + + +def test_location_dot_glyph_exists_in_bundled_font(): + """The chosen glyph must actually exist in fa-solid-900.ttf, otherwise the + pin silently renders as blank/tofu instead of an icon.""" + ttLib = pytest.importorskip("fontTools.ttLib") + import os + from app.documents.pdf_generator import _FONTS_DIR + + font = ttLib.TTFont(os.path.join(_FONTS_DIR, "fa-solid-900.ttf")) + cmap = font.getBestCmap() + assert ord(LOCATION_DOT) in cmap, "location-dot (U+F3C5) missing from fa-solid-900.ttf" + assert cmap[ord(LOCATION_DOT)] == "location-dot" diff --git a/backend/tests/unit/test_migrations.py b/backend/tests/unit/test_migrations.py new file mode 100644 index 0000000..a193018 --- /dev/null +++ b/backend/tests/unit/test_migrations.py @@ -0,0 +1,67 @@ +"""Lightweight SQLite migrations (app/database.py). + +Regression guard for the *"no such column: app_settings.memory_enabled"* startup +crash: when a column is added to a model, an existing DB created by an older +build must be upgraded in place by ``_apply_lightweight_migrations`` β€” +``create_all`` only creates missing *tables*, it never alters existing ones. +This path isn't exercised by the normal test suite (which recreates every table +fresh per test), so it gets its own test. +""" +from __future__ import annotations + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine + +pytestmark = pytest.mark.unit + + +async def _columns(conn, table: str) -> set[str]: + res = await conn.execute(text(f"PRAGMA table_info({table})")) + return {row[1] for row in res.fetchall()} + + +async def test_migration_adds_memory_enabled_to_existing_db(tmp_path): + from app.database import _apply_lightweight_migrations + + eng = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'old.db'}") + try: + # Simulate an app_settings table from an older build β€” no memory_enabled. + async with eng.begin() as conn: + await conn.execute(text( + "CREATE TABLE app_settings (" + "id VARCHAR(36) PRIMARY KEY, llm_provider VARCHAR(50), " + "llm_model VARCHAR(100), default_jd_intensity INTEGER)" + )) + await conn.execute(text( + "INSERT INTO app_settings (id, llm_provider) VALUES ('1', 'google')" + )) + assert "memory_enabled" not in await _columns(conn, "app_settings") + + # This is what init_db() runs right after create_all. + async with eng.begin() as conn: + await _apply_lightweight_migrations(conn) + cols = await _columns(conn, "app_settings") + + assert "memory_enabled" in cols # the column the crash was about + finally: + await eng.dispose() + + +async def test_migrations_are_idempotent(tmp_path): + """Re-running migrations must be a no-op, not a 'duplicate column' error β€” + every ALTER is guarded by a PRAGMA column check.""" + from app.database import _apply_lightweight_migrations + + eng = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'old.db'}") + try: + async with eng.begin() as conn: + await conn.execute(text("CREATE TABLE app_settings (id VARCHAR(36) PRIMARY KEY)")) + async with eng.begin() as conn: + await _apply_lightweight_migrations(conn) + async with eng.begin() as conn: + await _apply_lightweight_migrations(conn) # second pass β€” no error + cols = await _columns(conn, "app_settings") + assert "memory_enabled" in cols + finally: + await eng.dispose() diff --git a/backend/tests/unit/test_pipeline_helpers.py b/backend/tests/unit/test_pipeline_helpers.py index 2d43d03..e51b65f 100644 --- a/backend/tests/unit/test_pipeline_helpers.py +++ b/backend/tests/unit/test_pipeline_helpers.py @@ -66,6 +66,20 @@ def test_cover_letter_and_emails_called_out(self): assert "cover letter" in out assert "3 outreach email templates" in out + def test_notes_master_and_memory_sources(self): + """The reply records which inputs fed the generation, for later reference.""" + from app.api._pipeline import _build_summary + resume = {"metadata": {"overall_score": 90}, "personal_info": {"full_name": "Ada"}} + out = _build_summary(resume, used_memory=True, from_master_name="Backend Master") + assert "Backend Master" in out + assert "profile memory" in out.lower() + + def test_omits_sources_line_when_nothing_used(self): + from app.api._pipeline import _build_summary + resume = {"metadata": {"overall_score": 90}, "personal_info": {"full_name": "Ada"}} + out = _build_summary(resume) # no master, memory off + assert "Generated using" not in out + # ── _build_no_resume_error ──────────────────────────────────────────────────── diff --git a/frontend/css/sidebar.css b/frontend/css/sidebar.css index 1e8b0f9..320250b 100644 --- a/frontend/css/sidebar.css +++ b/frontend/css/sidebar.css @@ -40,6 +40,14 @@ body.sb-resizing{cursor:col-resize;user-select:none} .logo-sub{font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.6px;margin-top:-2px} #new-btn{width:100%;padding:9px 13px;background:var(--abg);border:1px solid var(--ab);border-radius:8px;color:var(--accent2);font-family:var(--font);font-size:13.5px;font-weight:500;cursor:pointer;display:flex;align-items:center;gap:8px;transition:all .15s} #new-btn:hover{background:rgba(124,131,255,.22)} +.nfm-split{display:flex;width:100%;margin-top:6px} +#new-from-master-btn{flex:1;min-width:0;padding:7px 13px;background:transparent;border:1px solid var(--bdr);border-radius:8px 0 0 8px;border-right:none;color:var(--text2);font-family:var(--font);font-size:12.5px;font-weight:500;cursor:pointer;display:flex;align-items:center;gap:8px;transition:all .15s} +#new-from-master-btn:hover{background:var(--bg3);border-color:var(--amber);color:var(--amber)} +#new-from-master-btn .ic{color:var(--amber)} +.nfm-caret{flex-shrink:0;width:34px;display:flex;align-items:center;justify-content:center;background:transparent;border:1px solid var(--bdr);border-radius:0 8px 8px 0;color:var(--text2);cursor:pointer;transition:all .15s} +.nfm-caret:hover,.nfm-caret.on{background:var(--bg3);border-color:var(--amber);color:var(--amber)} +.nfm-caret .ic{color:var(--amber);transition:transform .15s} +.nfm-caret.on .ic{transform:rotate(180deg)} .ss{flex:1;overflow-y:auto;padding:6px 8px} .ss::-webkit-scrollbar{width:3px}.ss::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px} diff --git a/frontend/index.html b/frontend/index.html index 7003223..b7ec74a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -71,6 +71,8 @@ + + @@ -102,6 +104,15 @@ New Resume +
+ @@ -802,6 +814,10 @@
removed  Β·  added
+
diff --git a/frontend/js/diff.js b/frontend/js/diff.js index 80c1e51..2eac5a1 100644 --- a/frontend/js/diff.js +++ b/frontend/js/diff.js @@ -1,14 +1,58 @@ // ── Resume Diff ─────────────────────────────────────────────────────────────── +// The resume currently being compared against a master (master-mode only), so +// the picker's onchange can re-render without re-resolving the message. +let _diffMasterTargetResume=null; + +function _hideDiffMasterRow(){ + const row=document.getElementById('diff-master-row'); + if(row)row.style.display='none'; +} + function openDiff(msgId,sessionId){ const versions=_resumeVersions[sessionId]||[]; const myIdx=versions.findIndex(v=>v.id===msgId); if(myIdx<1)return; const prev=versions[myIdx-1].resume; const curr=versions[myIdx].resume; + _diffMasterTargetResume=null; + _hideDiffMasterRow(); document.getElementById('diff-ver').textContent=`(v${myIdx} β†’ v${myIdx+1})`; document.getElementById('diff-body').innerHTML=_buildDiffHtml(prev,curr); document.getElementById('diff-modal').classList.add('open'); } + +// Compare any resume version against a saved master resume (the reference). +// Direction is master β†’ this version, so tailoring/edits read as additions. +function openDiffVsMaster(msgId){ + const curr=window['__r_'+msgId]; + if(!curr){showToast('Resume not loaded yet');return;} + if(typeof _masterResumes==='undefined'||!_masterResumes.length){ + showToast('No master resume saved to compare against');return; + } + _diffMasterTargetResume=curr; + const sel=document.getElementById('diff-master-pick'); + if(sel){ + sel.innerHTML=_masterResumes.map(it=> + `` + ).join(''); + } + const row=document.getElementById('diff-master-row'); + if(row)row.style.display='flex'; + _renderMasterDiff(); + document.getElementById('diff-modal').classList.add('open'); +} + +function _renderMasterDiff(){ + if(!_diffMasterTargetResume)return; + const sel=document.getElementById('diff-master-pick'); + const mid=sel?sel.value:null; + const m=(_masterResumes.find(x=>x.id===mid))||_defaultMaster(); + const body=document.getElementById('diff-body'); + if(!m){body.innerHTML='
No master resume to compare against.
';return;} + document.getElementById('diff-ver').textContent=`(Master β€œ${m.name}” β†’ this version)`; + body.innerHTML=_buildDiffHtml(m.resume,_diffMasterTargetResume); +} + function closeDiff(){document.getElementById('diff-modal').classList.remove('open');} document.getElementById('diff-modal').addEventListener('click',e=>{if(e.target===e.currentTarget)closeDiff();}); diff --git a/frontend/js/init.js b/frontend/js/init.js index c45eb61..43b03c0 100644 --- a/frontend/js/init.js +++ b/frontend/js/init.js @@ -39,9 +39,8 @@ async function init(){ document.getElementById('sb').classList.add('collapsed'); document.getElementById('sb-expand').style.display='flex'; } - // Pull live server defaults so the pill shows what'll actually run + // Pull live server defaults (provider/model, JD slider, memory pref) in one go await loadServerDefaults(); - loadJdIntensityDefault(); updProvPill(); await loadSessions(); await loadMemoryChip(); @@ -64,27 +63,24 @@ function _scheduleSessionRefreshIfNeeded(){ } async function loadServerDefaults(){ - // Only override localStorage if user hasn't picked anything yet - if(localStorage.getItem('sr_p')&&localStorage.getItem('sr_m'))return; + // One fetch for every server-side default applied on bootstrap. try{ const r=await fetch(`${API}/api/app-settings`); if(!r.ok)return; const s=await r.json(); - if(s.llm_provider)curProv=s.llm_provider; - if(s.llm_model)curModel=s.llm_model; - }catch(e){} -} - -async function loadJdIntensityDefault(){ - try{ - const r=await fetch(`${API}/api/app-settings`); - if(!r.ok)return; - const s=await r.json(); - if(s.default_jd_intensity==null)return; - const v=Math.max(0,Math.min(100,parseInt(s.default_jd_intensity,10))); - if(isNaN(v))return; - const sl=document.getElementById('jd-intensity'); - if(sl){sl.value=v;updateJdIntensityUI();} + // Adopt the server provider/model only if the user hasn't picked one yet. + if(!(localStorage.getItem('sr_p')&&localStorage.getItem('sr_m'))){ + if(s.llm_provider)curProv=s.llm_provider; + if(s.llm_model)curModel=s.llm_model; + } + // Remembered JD-tailoring slider + if(s.default_jd_intensity!=null){ + const v=Math.max(0,Math.min(100,parseInt(s.default_jd_intensity,10))); + const sl=document.getElementById('jd-intensity'); + if(sl&&!isNaN(v)){sl.value=v;updateJdIntensityUI();} + } + // Global "Use memory" preference (persisted across chats/sessions) + applyMemoryPref(s.memory_enabled); }catch(e){} } diff --git a/frontend/js/master-resumes.js b/frontend/js/master-resumes.js index b627352..0d67b5c 100644 --- a/frontend/js/master-resumes.js +++ b/frontend/js/master-resumes.js @@ -34,6 +34,10 @@ function _updateMasterUI(){ // Sidebar footer badge const badge=document.getElementById('master-sb-badge'); if(badge)badge.style.display=has?'':'none'; + // Sidebar "New from Master" split-button (only useful once a master exists) + const nfm=document.getElementById('nfm-wrap'); + if(nfm)nfm.style.display=has?'':'none'; + if(!has)_hideNfmMenu(); // Data Management section const dmTitle=document.getElementById('dm-master-title'); if(dmTitle){ @@ -367,7 +371,7 @@ function attachMasterById(id){ _hideMasterAttachMenu(); const it=_masterResumes.find(x=>x.id===id); if(!it){showToast('Master resume not found');return;} - attachedResume={kind:'json',resume:it.resume,filename:`master_${(it.name||'resume').replace(/\s+/g,'_')}.json`,fromMaster:true}; + attachedResume={kind:'json',resume:it.resume,filename:`master_${(it.name||'resume').replace(/\s+/g,'_')}.json`,fromMaster:true,masterName:it.name||'Master Resume'}; document.getElementById('att-ic').innerHTML=''; document.getElementById('att-label').textContent=it.name||'Master Resume'; document.getElementById('att-meta').textContent='JSON'; @@ -382,6 +386,87 @@ function closeMasterAttachAndOpenModal(){ openMasterModal(); } +// ── New chat seeded from a master (verbatim β€” no AI edits) ─────────────────── +// Creates a fresh chat whose first resume version IS the master, untouched, so +// you can edit on top of it (or just fill the application tracker) without +// re-running the pipeline. With no id: uses the only master, else the default. +async function newChatFromMaster(id){ + _hideMasterAttachMenu(); + _hideNfmMenu(); + let masterId=id||null; + if(!masterId){ + if(_masterResumes.length===0){showToast('No master resumes saved yet');return;} + if(_masterResumes.length===1)masterId=_masterResumes[0].id; + else masterId=(_defaultMaster()||{}).id||null; + } + try{ + const r=await fetch(`${API}/api/sessions/from-master`,{ + method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({master_id:masterId,llm_provider:curProv,llm_model:curModel}), + }); + if(!r.ok){const e=await r.json().catch(()=>({detail:'Failed to create chat'}));throw new Error(e.detail||'Failed to create chat');} + const sess=await r.json(); + closeViewMaster(); + await _refreshSessions(); // surface the new chat at the top of the sidebar + await loadSession(sess.id); // …and open it + showToast('⭐ New chat from master β€” edit away'); + }catch(e){showToast('⚠ '+e.message);} +} + +// ── "New from Master" caret dropdown β€” pick a specific master (β‰₯1 saved) ────── +// The split button's main half uses the default master; this menu lists them +// all (rendered into with position:fixed so the sidebar can't clip it). +function _ensureNfmMenu(){ + let menu=document.getElementById('nfm-menu'); + if(!menu){ + menu=document.createElement('div'); + menu.id='nfm-menu'; + menu.style.cssText='display:none;position:fixed;min-width:240px;max-width:340px;background:var(--bg2);border:1px solid var(--border2);border-radius:9px;box-shadow:var(--shadow-md);padding:4px;z-index:1500;max-height:320px;overflow-y:auto'; + document.body.appendChild(menu); + } + return menu; +} + +function toggleNewFromMasterMenu(ev){ + if(ev)ev.stopPropagation(); + if(_masterResumes.length===0){showToast('No master resumes saved yet');return;} + const caret=document.getElementById('nfm-caret'); + const menu=_ensureNfmMenu(); + if(menu.dataset.open==='1'){_hideNfmMenu();return;} + menu.innerHTML=`
New chat from…
`+ + _masterResumes.map(it=>``).join(''); + // Anchor to the caret; open downward if there's room, else upward. + const r=caret.getBoundingClientRect(); + menu.style.visibility='hidden';menu.style.display='block';menu.style.top='0px';menu.style.left='0px'; + const mh=menu.offsetHeight,mw=menu.offsetWidth; + const spaceBelow=window.innerHeight-r.bottom,spaceAbove=r.top; + const top=(spaceBelow>=mh+8||spaceBelow>spaceAbove)?Math.min(window.innerHeight-mh-8,r.bottom+6):Math.max(8,r.top-mh-6); + const left=Math.max(8,Math.min(window.innerWidth-mw-8,r.right-mw)); + menu.style.top=`${top}px`;menu.style.left=`${left}px`;menu.style.visibility=''; + menu.dataset.open='1'; + if(caret)caret.classList.add('on'); + document.addEventListener('click',_onNfmClickAway); +} + +function _onNfmClickAway(ev){ + const wrap=document.getElementById('nfm-wrap'); + const menu=document.getElementById('nfm-menu'); + if(wrap&&wrap.contains(ev.target))return; + if(menu&&menu.contains(ev.target))return; + _hideNfmMenu(); +} + +function _hideNfmMenu(){ + const menu=document.getElementById('nfm-menu'); + if(menu){menu.style.display='none';menu.dataset.open='0';} + const caret=document.getElementById('nfm-caret'); + if(caret)caret.classList.remove('on'); + document.removeEventListener('click',_onNfmClickAway); +} + // Stable entry points called from sidebar and data-mgmt. function attachMasterResume(){toggleMasterAttachMenu();} async function deleteMasterResume(){return deleteSelectedMaster();} diff --git a/frontend/js/render.js b/frontend/js/render.js index 932e6dd..68c472e 100644 --- a/frontend/js/render.js +++ b/frontend/js/render.js @@ -142,11 +142,13 @@ function renderDone(c,msg){ return; } const agMap={};trace.forEach(e=>{agMap[e.agent]=e;}); + let _nSteps=0; let stepsHtml=`
${ico('ic-check')} Pipeline complete
`; STEPS.forEach(step=>{ const ev=agMap[step.agent];if(!ev)return; + _nSteps++; const cls=ev.status==='complete'?'done':ev.status==='error'?'err':'pend'; const ic=cls==='done'?ico('ic-check'):ico('ic-x'); let llmHtml=''; @@ -163,6 +165,9 @@ function renderDone(c,msg){
${esc(ev.notes||step.desc)}
${llmHtml}
`; }); stepsHtml+=''; + // A verbatim "from master" seed has no pipeline agents to show β€” skip the + // (otherwise empty) "Pipeline complete" panel for it. + if(_nSteps===0)stepsHtml=''; let scHtml=''; if(meta.overall_score!==undefined){ @@ -186,9 +191,19 @@ function renderDone(c,msg){ _resumeVersions[sid].push({id:msg.id,resume}); const versions=_resumeVersions[sid]; const myIdx=versions.findIndex(v=>v.id===msg.id); - if(myIdx>0){ - const jvr=c.querySelector('.jv-r'); - if(jvr){ + const jvr=c.querySelector('.jv-r'); + if(jvr){ + // Compare this resume version against a saved master reference. + if(typeof _masterResumes!=='undefined' && _masterResumes.length){ + const mbtn=document.createElement('button'); + mbtn.className='ibtn cmp'; + mbtn.innerHTML=ico('ic-compare')+` Compare to Master`; + mbtn.title='Compare this resume against your master resume β€” see what changed'; + mbtn.onclick=()=>openDiffVsMaster(msg.id); + jvr.prepend(mbtn); + } + // Version-to-version diff (only once a previous version exists). + if(myIdx>0){ const btn=document.createElement('button'); btn.className='ibtn cmp'; btn.innerHTML=ico('ic-refresh')+` v${myIdx}β†’v${myIdx+1}`; diff --git a/frontend/js/send.js b/frontend/js/send.js index 6bd4229..ef6a572 100644 --- a/frontend/js/send.js +++ b/frontend/js/send.js @@ -38,10 +38,12 @@ async function sendMsg(){ let finalContent=content; let attachedJson=null; let attachLabel=null; + let fromMasterName=null; if(attachedResume){ if(attachedResume.kind==='json'&&attachedResume.resume){ attachedJson=attachedResume.resume; attachLabel=`JSON Β· ${attachedResume.filename||'resume.json'}`; + if(attachedResume.fromMaster)fromMasterName=attachedResume.masterName||'Master Resume'; }else if(attachedResume.kind==='text'&&attachedResume.text){ const trimmed=attachedResume.text.length>14000?attachedResume.text.slice(0,14000)+'\n…[truncated]':attachedResume.text; finalContent=`[ATTACHED RESUME β€” extracted from ${attachedResume.filename||'upload'}]\n${trimmed}\n[END ATTACHED RESUME]\n\n${content}`; @@ -73,7 +75,7 @@ async function sendMsg(){ try{ const r=await fetch(`${API}/api/sessions/${sessionId}/messages`,{ method:'POST',headers:{'Content-Type':'application/json'}, - body:JSON.stringify({content:finalContent,jd_text:jdText||null,jd_intensity:jdIntensity,attached_resume:submitAttached,use_memory:isMemoryOn(),llm_provider:curProv,llm_model:curModel})}); + body:JSON.stringify({content:finalContent,jd_text:jdText||null,jd_intensity:jdIntensity,attached_resume:submitAttached,from_master_name:fromMasterName,use_memory:isMemoryOn(),llm_provider:curProv,llm_model:curModel})}); if(!r.ok)throw new Error(`HTTP ${r.status}`); const d=await r.json(); diff --git a/frontend/js/sessions.js b/frontend/js/sessions.js index 1926740..78bd222 100644 --- a/frontend/js/sessions.js +++ b/frontend/js/sessions.js @@ -141,18 +141,16 @@ async function newChat(){ document.getElementById('tb-title').textContent='New Resume'; syncToolbar(); document.querySelectorAll('.si').forEach(e=>e.classList.remove('on')); - // Memory toggle is per-chat; new chats always start with it enabled. - const mt=document.getElementById('mem-toggle'); - mt.classList.add('on');mt.classList.remove('off'); + // "Use memory" is a global preference (loaded on bootstrap, persisted to the + // backend) β€” leave the toggle as the user set it, don't reset per chat. document.getElementById('mi').focus(); } async function loadSession(id){ closeMobileSidebar(); curSession=id; - // Memory toggle resets to on for each chat (per-chat, not global). - const mt=document.getElementById('mem-toggle'); - mt.classList.add('on');mt.classList.remove('off'); + // "Use memory" is global (not per-chat) β€” the toggle keeps the user's + // persisted preference across chats; nothing to reset here. let session; try{ const r=await fetch(`${API}/api/sessions/${id}`); diff --git a/frontend/js/toggles.js b/frontend/js/toggles.js index 62f2395..65f3d25 100644 --- a/frontend/js/toggles.js +++ b/frontend/js/toggles.js @@ -1,13 +1,30 @@ -// ── Memory toggle ─────────────────────────────────────────────────────────── +// ── Memory toggle (global, persisted across chats/sessions) ────────────────── function toggleMemPill(){ const el=document.getElementById('mem-toggle'); const willBeOn=el.classList.contains('off'); el.classList.toggle('on',willBeOn); el.classList.toggle('off',!willBeOn); + _saveMemoryPref(willBeOn); } function isMemoryOn(){ return document.getElementById('mem-toggle').classList.contains('on'); } +// Reflect the persisted global preference (default ON when unset/null). +function applyMemoryPref(enabled){ + const el=document.getElementById('mem-toggle'); + if(!el)return; + const on=enabled!==false; + el.classList.toggle('on',on); + el.classList.toggle('off',!on); +} +// Persist the preference to the backend so it survives reloads and applies to +// every chat. Fire-and-forget β€” the toggle is already updated in the DOM. +function _saveMemoryPref(on){ + fetch(`${API}/api/app-settings`,{ + method:'PUT',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({memory_enabled:!!on}), + }).catch(()=>{}); +} // ── JD toggle ─────────────────────────────────────────────────────────────── function toggleJD(){