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 @@