Skip to content

Commit 6002c1f

Browse files
authored
feat: master-resume workflows + global, persisted memory toggle (#6)
1 parent d400d7e commit 6002c1f

28 files changed

Lines changed: 605 additions & 44 deletions

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,13 +229,17 @@ Click **🎯 Tailor to JD**, paste any JD, and drag the intensity slider (0–10
229229

230230
### Persistent profile (My Profile)
231231

232-
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.
232+
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.
233233

234234
**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.
235235

236236
### Master resumes
237237

238-
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.
238+
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:
239+
240+
- **⭐ 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.
241+
- **⭐ 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.
242+
- **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.
239243

240244
### Manual editor + Re-score
241245

backend/app/api/_pipeline.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ async def _save_progress(msg_id: str, events: list) -> None:
7979
pass
8080

8181

82-
async def _run_pipeline_background(msg_id: str, session_id: str, initial_state: dict):
82+
async def _run_pipeline_background(msg_id: str, session_id: str, initial_state: dict,
83+
run_meta: dict | None = None):
8384
# Imported lazily so the test harness can stub the graph without forcing a
8485
# heavy import chain at app startup.
8586
from app.agents.graph import RESUME_GRAPH
@@ -159,7 +160,12 @@ def _sync_stream():
159160
)
160161
else:
161162
status_str = "complete"
162-
summary = _build_summary(resume, has_cover=bool(cover_letter), n_emails=len(outreach_emails or []))
163+
meta_info = run_meta or {}
164+
summary = _build_summary(
165+
resume, has_cover=bool(cover_letter), n_emails=len(outreach_emails or []),
166+
used_memory=bool(meta_info.get("used_memory")),
167+
from_master_name=meta_info.get("from_master_name"),
168+
)
163169

164170
async with AsyncSessionLocal() as db:
165171
result = await db.execute(select(Message).where(Message.id == msg_id))
@@ -301,7 +307,8 @@ def _safe_score(v) -> float:
301307
return 0.0
302308

303309

304-
def _build_summary(resume: dict | None, has_cover: bool = False, n_emails: int = 0) -> str:
310+
def _build_summary(resume: dict | None, has_cover: bool = False, n_emails: int = 0,
311+
used_memory: bool = False, from_master_name: str | None = None) -> str:
305312
if not resume:
306313
return "I encountered an issue generating your resume. Please try again."
307314
meta = resume.get("metadata", {})
@@ -337,6 +344,16 @@ def _build_summary(resume: dict | None, has_cover: bool = False, n_emails: int =
337344
for s in improvements[:3]:
338345
lines.append(f"• {s}")
339346

347+
# Provenance note — records what fed this generation so it's clear on a later
348+
# read whether the master resume and/or profile memory were applied.
349+
sources = []
350+
if from_master_name:
351+
sources.append(f'master resume **{from_master_name}**')
352+
if used_memory:
353+
sources.append("your **profile memory**")
354+
if sources:
355+
lines.append(f"\n📎 Generated using {' and '.join(sources)}.")
356+
340357
lines.append("")
341358
lines.append("Tip: ask me to refine sections, raise seniority, target a company, add certifications, or paste a JD to tailor.")
342359
return "\n".join(lines)

backend/app/api/messages_routes.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,15 @@ async def send_message(
150150
"section_preferences": section_prefs,
151151
}
152152

153+
# Provenance for the reply's "sources" note — what fed this generation.
154+
run_meta = {
155+
"used_memory": bool(memory_context),
156+
"from_master_name": (req.from_master_name or "").strip() or None,
157+
}
158+
153159
background_tasks.add_task(
154160
_run_pipeline_background,
155-
asst_msg.id, session_id, initial_state,
161+
asst_msg.id, session_id, initial_state, run_meta,
156162
)
157163

158164
return {"user_message_id": user_msg.id, "assistant_message_id": asst_msg.id, "status": "processing"}

backend/app/api/sessions_routes.py

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Session CRUD endpoints — list/create/read/update/delete one or all."""
2+
import copy
23
from datetime import datetime, timezone
34

45
from fastapi import APIRouter, Depends, HTTPException
@@ -7,14 +8,15 @@
78
from sqlalchemy.orm import selectinload
89

910
from app.database import get_db
10-
from app.models import Session, Message
11+
from app.models import Session, Message, MasterResume
1112
from app.schemas import (
12-
CreateSessionRequest, SessionOut, SessionSummary,
13-
UpdateSessionRequest, SessionsPage,
13+
CreateSessionRequest, CreateSessionFromMasterRequest, SessionOut,
14+
SessionSummary, UpdateSessionRequest, SessionsPage,
1415
)
1516
from app.config import settings
1617

1718
from ._pipeline import get_cancel_event
19+
from .memory_routes import _resolve_default
1820

1921

2022
router = APIRouter(prefix="/api", tags=["sessions"])
@@ -90,6 +92,81 @@ async def create_session(req: CreateSessionRequest, db: AsyncSession = Depends(g
9092
updated_at=s.updated_at, messages=[])
9193

9294

95+
@router.post("/sessions/from-master", response_model=SessionOut, status_code=201)
96+
async def create_session_from_master(
97+
req: CreateSessionFromMasterRequest, db: AsyncSession = Depends(get_db)
98+
):
99+
"""Create a new chat seeded with a master resume **verbatim** — no LLM /
100+
pipeline run, so the resume is guaranteed identical to the master. Lets the
101+
user start from their master (e.g. to track an application) and edit on top
102+
of it without re-tailoring. The seeded resume lands as a completed assistant
103+
message, exactly like a normal pipeline result, so editing/export/diff all
104+
work unchanged.
105+
"""
106+
# Resolve which master to load: explicit id, else the default (or earliest).
107+
# `_resolve_default` is the single source of truth for "which master is the
108+
# default", shared with the memory routes.
109+
if req.master_id:
110+
res = await db.execute(select(MasterResume).where(MasterResume.id == req.master_id))
111+
master = res.scalar_one_or_none()
112+
if not master:
113+
raise HTTPException(404, "Master resume not found")
114+
else:
115+
master = await _resolve_default(db)
116+
if not master:
117+
raise HTTPException(404, "No master resume saved yet")
118+
119+
# Deep-copy so later edits to this chat never mutate the stored master.
120+
resume = copy.deepcopy(master.resume) if isinstance(master.resume, dict) else (master.resume or {})
121+
pi = resume.get("personal_info") or {}
122+
meta = resume.get("metadata") or {}
123+
full_name = (pi.get("full_name") or "").strip()
124+
role = (pi.get("professional_title") or meta.get("jd_role") or "").strip()
125+
title = (f"{full_name}{role}" if full_name and role else full_name or master.name or "Master Resume")[:255]
126+
127+
try:
128+
score = float(meta.get("overall_score"))
129+
except (TypeError, ValueError):
130+
score = None
131+
132+
s = Session(
133+
title=title,
134+
llm_provider=req.llm_provider or settings.llm_provider,
135+
llm_model=req.llm_model or settings.llm_model,
136+
)
137+
db.add(s)
138+
await db.flush()
139+
140+
ts = int(datetime.now(timezone.utc).timestamp() * 1000)
141+
trace = [{
142+
"agent": "Master Resume", "status": "complete",
143+
"notes": f'Loaded "{master.name}" verbatim — no AI changes applied.',
144+
"timestamp": ts,
145+
}]
146+
db.add(Message(
147+
session_id=s.id, role="user", status="complete",
148+
content=f'⭐ Start from master resume "{master.name}" (verbatim — no changes).',
149+
))
150+
db.add(Message(
151+
session_id=s.id, role="assistant", status="complete",
152+
content=(
153+
f"⭐ Loaded master resume **{master.name}** as-is — no AI changes were applied.\n\n"
154+
"Edit any section directly, paste a job description to tailor it, or fill in "
155+
"the application tracker for this chat."
156+
),
157+
resume_json=resume,
158+
agent_trace=trace,
159+
progress_events=trace,
160+
final_score=score,
161+
))
162+
await db.commit()
163+
164+
result = await db.execute(
165+
select(Session).options(selectinload(Session.messages)).where(Session.id == s.id)
166+
)
167+
return result.scalar_one()
168+
169+
93170
@router.get("/sessions/{session_id}", response_model=SessionOut)
94171
async def get_session(session_id: str, db: AsyncSession = Depends(get_db)):
95172
result = await db.execute(

backend/app/api/settings_routes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ class AppSettingsUpsert(BaseModel):
3636
max_review_iterations: Optional[int] = None
3737
min_quality_score: Optional[float] = None
3838
default_jd_intensity: Optional[int] = None
39+
# Global "Use memory" preference — persisted across chats/sessions.
40+
memory_enabled: Optional[bool] = None
3941
# {"sections": {key: bool}, "fields": {"section.field": bool}} — missing = enabled
4042
section_preferences: Optional[dict] = None
4143

backend/app/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ class Settings(BaseSettings):
4444
# Remembered JD-tailoring slider value (0–100). DB overlay populates this
4545
# at runtime — the .env baseline is None so the UI defaults to 100%.
4646
default_jd_intensity: Optional[int] = None
47+
# Global "Use memory" default. Baseline ON; the DB overlay flips it when the
48+
# user toggles the pill, and the value persists across chats/sessions.
49+
memory_enabled: bool = True
4750

4851
# ── Optional Basic Auth (single admin user) ────────────────────────────
4952
# When AUTH_ENABLED=true, every /api/* request requires HTTP Basic auth

backend/app/database.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ async def get_db():
5656
("app_settings", "default_jd_intensity", "ALTER TABLE app_settings ADD COLUMN default_jd_intensity INTEGER"),
5757
("app_settings", "section_preferences", "ALTER TABLE app_settings ADD COLUMN section_preferences JSON"),
5858
("app_settings", "openai_base_url", "ALTER TABLE app_settings ADD COLUMN openai_base_url VARCHAR(500)"),
59+
("app_settings", "memory_enabled", "ALTER TABLE app_settings ADD COLUMN memory_enabled BOOLEAN"),
5960
]
6061

6162

backend/app/documents/docx_generator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,7 @@ def _set_doc_margins(doc: Document, *, side_cm: float, vert_cm: float):
434434
_FA_ICONS = {
435435
"phone": (_FA_SOLID, ""),
436436
"email": (_FA_SOLID, ""),
437-
"location": (_FA_SOLID, ""),
437+
"location": (_FA_SOLID, ""),
438438
"linkedin": (_FA_BRANDS, ""),
439439
"github": (_FA_BRANDS, ""),
440440
"web": (_FA_SOLID, ""),

backend/app/documents/odt_generator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,7 @@ def _make_doc(*, side_cm: float, vert_cm: float, bg_color: str | None = None) ->
404404
_FA_ICONS = {
405405
"phone": (_FA_SOLID, ""), # faPhone
406406
"email": (_FA_SOLID, ""), # faEnvelope
407-
"location": (_FA_SOLID, ""), # faMapMarker
407+
"location": (_FA_SOLID, ""), # faMapMarker
408408
"linkedin": (_FA_BRANDS, ""), # faLinkedin
409409
"github": (_FA_BRANDS, ""), # faGithub
410410
"web": (_FA_SOLID, ""), # faGlobe

backend/app/documents/pdf_generator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -817,7 +817,7 @@ def _ensure_fa_fonts() -> bool:
817817
_FA_ICONS = {
818818
"phone": ("FA-Solid", ""), # faPhone (handset)
819819
"email": ("FA-Solid", ""), # faEnvelope (solid)
820-
"location": ("FA-Solid", ""), # faMapMarker (solid teardrop)
820+
"location": ("FA-Solid", ""), # faMapMarkerAlt / location-dot (pin with cutout)
821821
"linkedin": ("FA-Brands", ""), # faLinkedin
822822
"github": ("FA-Brands", ""), # faGithub
823823
"web": ("FA-Solid", ""), # faGlobe

0 commit comments

Comments
 (0)