|
1 | 1 | """Session CRUD endpoints — list/create/read/update/delete one or all.""" |
| 2 | +import copy |
2 | 3 | from datetime import datetime, timezone |
3 | 4 |
|
4 | 5 | from fastapi import APIRouter, Depends, HTTPException |
|
7 | 8 | from sqlalchemy.orm import selectinload |
8 | 9 |
|
9 | 10 | from app.database import get_db |
10 | | -from app.models import Session, Message |
| 11 | +from app.models import Session, Message, MasterResume |
11 | 12 | from app.schemas import ( |
12 | | - CreateSessionRequest, SessionOut, SessionSummary, |
13 | | - UpdateSessionRequest, SessionsPage, |
| 13 | + CreateSessionRequest, CreateSessionFromMasterRequest, SessionOut, |
| 14 | + SessionSummary, UpdateSessionRequest, SessionsPage, |
14 | 15 | ) |
15 | 16 | from app.config import settings |
16 | 17 |
|
17 | 18 | from ._pipeline import get_cancel_event |
| 19 | +from .memory_routes import _resolve_default |
18 | 20 |
|
19 | 21 |
|
20 | 22 | router = APIRouter(prefix="/api", tags=["sessions"]) |
@@ -90,6 +92,81 @@ async def create_session(req: CreateSessionRequest, db: AsyncSession = Depends(g |
90 | 92 | updated_at=s.updated_at, messages=[]) |
91 | 93 |
|
92 | 94 |
|
| 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 | + |
93 | 170 | @router.get("/sessions/{session_id}", response_model=SessionOut) |
94 | 171 | async def get_session(session_id: str, db: AsyncSession = Depends(get_db)): |
95 | 172 | result = await db.execute( |
|
0 commit comments