From 03b9e5230110f582f1efe967d70ed7e83cf5a732 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Wed, 15 Apr 2026 08:42:06 -0700 Subject: [PATCH] feat: references tracker and recommendation letter system (#96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - references_ + job_references tables with CREATE + migration - Full CRUD: GET/POST /api/references, PATCH/DELETE /api/references/:id - Link/unlink to jobs: POST/DELETE /api/references/:id/link-job/:job_id - GET /api/references/for-job/:job_id — linked refs with prep/letter drafts - POST /api/references/:id/prep-email — LLM drafts heads-up email to send reference before interview; persisted to job_references.prep_email - POST /api/references/:id/rec-letter — LLM drafts recommendation letter reference can edit and send on their letterhead (Paid/BYOK tier) - ReferencesView.vue: add/edit/delete form, tag system (technical/managerial/ character/academic), inline confirm-before-delete - Route /references + IdentificationIcon nav link --- dev-api.py | 219 +++++++++++++++ scripts/db.py | 31 ++ web/src/components/AppNav.vue | 4 +- web/src/router/index.ts | 3 +- web/src/views/ReferencesView.vue | 469 +++++++++++++++++++++++++++++++ 5 files changed, 724 insertions(+), 2 deletions(-) create mode 100644 web/src/views/ReferencesView.vue diff --git a/dev-api.py b/dev-api.py index 7893a57..401b300 100644 --- a/dev-api.py +++ b/dev-api.py @@ -1571,6 +1571,225 @@ def list_contacts(job_id: Optional[int] = None, direction: Optional[str] = None, return {"total": total, "contacts": [dict(r) for r in rows]} +# ── References ───────────────────────────────────────────────────────────────── + +class ReferencePayload(BaseModel): + name: str + relationship: str = "" + company: str = "" + email: str = "" + phone: str = "" + notes: str = "" + tags: list[str] = [] + +class PrepEmailPayload(BaseModel): + job_id: int + +class RecLetterPayload(BaseModel): + job_id: int + talking_points: str = "" + + +@app.get("/api/references") +def list_references(): + db = _get_db() + rows = db.execute( + "SELECT * FROM references_ ORDER BY name ASC" + ).fetchall() + db.close() + return [dict(r) for r in rows] + + +@app.post("/api/references") +def create_reference(payload: ReferencePayload): + db = _get_db() + cur = db.execute( + """INSERT INTO references_ (name, relationship, company, email, phone, notes, tags) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (payload.name, payload.relationship, payload.company, + payload.email, payload.phone, payload.notes, + json.dumps(payload.tags)), + ) + db.commit() + row = db.execute("SELECT * FROM references_ WHERE id = ?", (cur.lastrowid,)).fetchone() + db.close() + return dict(row) + + +@app.patch("/api/references/{ref_id}") +def update_reference(ref_id: int, payload: ReferencePayload): + db = _get_db() + row = db.execute("SELECT id FROM references_ WHERE id = ?", (ref_id,)).fetchone() + if not row: + raise HTTPException(404, "Reference not found") + db.execute( + """UPDATE references_ SET name=?, relationship=?, company=?, email=?, phone=?, + notes=?, tags=?, updated_at=datetime('now') WHERE id=?""", + (payload.name, payload.relationship, payload.company, + payload.email, payload.phone, payload.notes, + json.dumps(payload.tags), ref_id), + ) + db.commit() + updated = db.execute("SELECT * FROM references_ WHERE id = ?", (ref_id,)).fetchone() + db.close() + return dict(updated) + + +@app.delete("/api/references/{ref_id}") +def delete_reference(ref_id: int): + db = _get_db() + db.execute("DELETE FROM references_ WHERE id = ?", (ref_id,)) + db.commit() + db.close() + return {"ok": True} + + +@app.get("/api/references/for-job/{job_id}") +def references_for_job(job_id: int): + db = _get_db() + rows = db.execute( + """SELECT r.*, jr.prep_email, jr.rec_letter, jr.id AS jr_id + FROM references_ r + JOIN job_references jr ON jr.reference_id = r.id + WHERE jr.job_id = ? + ORDER BY r.name ASC""", + (job_id,), + ).fetchall() + db.close() + return [dict(r) for r in rows] + + +@app.post("/api/references/{ref_id}/link-job") +def link_reference_to_job(ref_id: int, body: PrepEmailPayload): + db = _get_db() + try: + db.execute( + "INSERT INTO job_references (job_id, reference_id) VALUES (?, ?)", + (body.job_id, ref_id), + ) + db.commit() + except Exception: + pass # already linked + db.close() + return {"ok": True} + + +@app.delete("/api/references/{ref_id}/unlink-job/{job_id}") +def unlink_reference_from_job(ref_id: int, job_id: int): + db = _get_db() + db.execute( + "DELETE FROM job_references WHERE reference_id = ? AND job_id = ?", + (ref_id, job_id), + ) + db.commit() + db.close() + return {"ok": True} + + +@app.post("/api/references/{ref_id}/prep-email") +def generate_prep_email(ref_id: int, payload: PrepEmailPayload): + """Draft a short 'heads up' email to send a reference before they hear from the hiring team.""" + db = _get_db() + ref_row = db.execute("SELECT * FROM references_ WHERE id = ?", (ref_id,)).fetchone() + if not ref_row: + db.close() + raise HTTPException(404, "Reference not found") + job_row = db.execute( + "SELECT title, company, description FROM jobs WHERE id = ?", (payload.job_id,) + ).fetchone() + if not job_row: + db.close() + raise HTTPException(404, "Job not found") + ref = dict(ref_row) + job = dict(job_row) + db.close() + + prompt = f"""Draft a short, warm email to send to a professional reference before a job interview. + +Reference: {ref['name']} ({ref['relationship']} at {ref['company']}) +Role applying for: {job['title']} at {job['company']} +Job description excerpt: {(job['description'] or '')[:500]} + +The email should: +- Be 3-4 short paragraphs max +- Thank them for being a reference +- Briefly describe the role and why it's a good fit +- Mention 1-2 specific accomplishments they could speak to +- Give them a heads-up they may be contacted soon +- Be warm and professional, not overly formal + +Return only the email body (no subject line).""" + + try: + from scripts.llm_router import LLMRouter + router = LLMRouter() + email_text = router.complete(prompt) + except Exception as e: + raise HTTPException(500, f"LLM generation failed: {e}") + + # Persist to job_references + db = _get_db() + db.execute( + """INSERT INTO job_references (job_id, reference_id, prep_email) + VALUES (?, ?, ?) + ON CONFLICT(job_id, reference_id) DO UPDATE SET prep_email = excluded.prep_email""", + (payload.job_id, ref_id, email_text), + ) + db.commit() + db.close() + return {"prep_email": email_text} + + +@app.post("/api/references/{ref_id}/rec-letter") +def generate_rec_letter(ref_id: int, payload: RecLetterPayload): + """Draft a recommendation letter the reference can edit and send on their letterhead.""" + db = _get_db() + ref_row = db.execute("SELECT * FROM references_ WHERE id = ?", (ref_id,)).fetchone() + if not ref_row: + db.close() + raise HTTPException(404, "Reference not found") + job_row = db.execute( + "SELECT title, company, description FROM jobs WHERE id = ?", (payload.job_id,) + ).fetchone() + if not job_row: + db.close() + raise HTTPException(404, "Job not found") + ref = dict(ref_row) + job = dict(job_row) + db.close() + + prompt = f"""Draft a professional recommendation letter that {ref['name']} ({ref['relationship']}) could send on their letterhead for a candidate applying to {job['title']} at {job['company']}. + +Key talking points to highlight: {payload.talking_points or 'general professional excellence, collaboration, initiative'} + +The letter should: +- Be addressed generically (Dear Hiring Manager) +- Be 3-4 paragraphs +- Sound natural — written from the recommender's voice, not the candidate's +- Highlight specific, credible observations a {ref['relationship']} would have +- Close with strong endorsement and contact offer + +Return only the letter body.""" + + try: + from scripts.llm_router import LLMRouter + router = LLMRouter() + letter_text = router.complete(prompt) + except Exception as e: + raise HTTPException(500, f"LLM generation failed: {e}") + + db = _get_db() + db.execute( + """INSERT INTO job_references (job_id, reference_id, rec_letter) + VALUES (?, ?, ?) + ON CONFLICT(job_id, reference_id) DO UPDATE SET rec_letter = excluded.rec_letter""", + (payload.job_id, ref_id, letter_text), + ) + db.commit() + db.close() + return {"rec_letter": letter_text} + + # ── GET /api/interviews ──────────────────────────────────────────────────────── PIPELINE_STATUSES = { diff --git a/scripts/db.py b/scripts/db.py index a6c0a2a..67176fb 100644 --- a/scripts/db.py +++ b/scripts/db.py @@ -130,6 +130,32 @@ CREATE TABLE IF NOT EXISTS digest_queue ( ) """ +CREATE_REFERENCES = """ +CREATE TABLE IF NOT EXISTS references_ ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + relationship TEXT, + company TEXT, + email TEXT, + phone TEXT, + notes TEXT, + tags TEXT DEFAULT '[]', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) +); +""" + +CREATE_JOB_REFERENCES = """ +CREATE TABLE IF NOT EXISTS job_references ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + reference_id INTEGER NOT NULL REFERENCES references_(id) ON DELETE CASCADE, + prep_email TEXT, + rec_letter TEXT, + UNIQUE(job_id, reference_id) +); +""" + _MIGRATIONS = [ ("cover_letter", "TEXT"), ("applied_at", "TEXT"), @@ -178,6 +204,9 @@ def _migrate_db(db_path: Path) -> None: conn.execute("ALTER TABLE background_tasks ADD COLUMN params TEXT") except sqlite3.OperationalError: pass # column already exists + # Ensure references tables exist (CREATE IF NOT EXISTS is idempotent) + conn.execute(CREATE_REFERENCES) + conn.execute(CREATE_JOB_REFERENCES) conn.commit() conn.close() @@ -191,6 +220,8 @@ def init_db(db_path: Path = DEFAULT_DB) -> None: conn.execute(CREATE_BACKGROUND_TASKS) conn.execute(CREATE_SURVEY_RESPONSES) conn.execute(CREATE_DIGEST_QUEUE) + conn.execute(CREATE_REFERENCES) + conn.execute(CREATE_JOB_REFERENCES) conn.commit() conn.close() _migrate_db(db_path) diff --git a/web/src/components/AppNav.vue b/web/src/components/AppNav.vue index e43dc87..ae602f4 100644 --- a/web/src/components/AppNav.vue +++ b/web/src/components/AppNav.vue @@ -97,6 +97,7 @@ import { Cog6ToothIcon, DocumentTextIcon, UsersIcon, + IdentificationIcon, } from '@heroicons/vue/24/outline' import { useDigestStore } from '../stores/digest' @@ -156,7 +157,8 @@ const navLinks = computed(() => [ { to: '/apply', icon: PencilSquareIcon, label: 'Apply' }, { to: '/resumes', icon: DocumentTextIcon, label: 'Resumes' }, { to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' }, - { to: '/contacts', icon: UsersIcon, label: 'Contacts' }, + { to: '/contacts', icon: UsersIcon, label: 'Contacts' }, + { to: '/references', icon: IdentificationIcon, label: 'References' }, { to: '/digest', icon: NewspaperIcon, label: 'Digest', badge: digestStore.entries.length || undefined }, { to: '/prep', icon: LightBulbIcon, label: 'Interview Prep' }, diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 92c283c..bd24830 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -12,7 +12,8 @@ export const router = createRouter({ { path: '/apply/:id', component: () => import('../views/ApplyWorkspaceView.vue') }, { path: '/resumes', component: () => import('../views/ResumesView.vue') }, { path: '/interviews', component: () => import('../views/InterviewsView.vue') }, - { path: '/contacts', component: () => import('../views/ContactsView.vue') }, + { path: '/contacts', component: () => import('../views/ContactsView.vue') }, + { path: '/references', component: () => import('../views/ReferencesView.vue') }, { path: '/digest', component: () => import('../views/DigestView.vue') }, { path: '/prep', component: () => import('../views/InterviewPrepView.vue') }, { path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') }, diff --git a/web/src/views/ReferencesView.vue b/web/src/views/ReferencesView.vue new file mode 100644 index 0000000..ee87b86 --- /dev/null +++ b/web/src/views/ReferencesView.vue @@ -0,0 +1,469 @@ + + +