Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 20 additions & 3 deletions backend/app/api/_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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", {})
Expand Down Expand Up @@ -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)
8 changes: 7 additions & 1 deletion backend/app/api/messages_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
83 changes: 80 additions & 3 deletions backend/app/api/sessions_routes.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"])
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions backend/app/api/settings_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions backend/app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]


Expand Down
2 changes: 1 addition & 1 deletion backend/app/documents/docx_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ""),
Expand Down
2 changes: 1 addition & 1 deletion backend/app/documents/odt_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/app/documents/pdf_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion backend/app/runtime_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)


Expand Down
9 changes: 9 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions backend/tests/api/test_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading
Loading