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
40 changes: 40 additions & 0 deletions changelog/1.3.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# 1.3.0 — 2026-07-02

This release closes the loop from **application to interview**: tailor your resume to a
job, download it, apply — then interview against *exactly the resume you submitted*.

## Added

- **Tailor Resume.** A new page that turns your resume + a job description into a
complete application:
- Pick a **profile or upload/paste a resume** (PDF / DOCX / TXT / MD) as the base.
- Add the **job description** — paste it, upload a file, or fetch it from the posting URL.
- Optionally paste the **application questions** (one per line).
- One click produces an **ATS-friendly tailored resume** — reworded, reordered, and
keyword-matched to the JD, but grounded strictly in your real resume (nothing is ever
invented) — plus **grounded answers** to every application question.

- **Download as PDF.** The tailored resume exports as a clean, single-column, standard-font
PDF built for ATS parsers — saved wherever you choose.

- **Applications.** Every tailoring is saved as “*name* - *job title* at *company*” (the
title and company are read from the JD) in a dense, searchable table, newest first.

- **Interview with your tailored resume.** **Start interview** on any application launches
a live session grounded in that application’s *tailored* resume + JD — the Cue Card
answers from what you actually submitted, not your generic base resume. Your notes and
STAR stories still apply.

- **Compare with base.** See exactly what changed: a side-by-side view highlights what was
removed from your base resume and what was added or rewritten for the job.

- **Smart profile reuse.** Uploading a resume creates a real, reusable profile — and
tailoring the same resume against more jobs reuses it (parsed once, no duplicates).

## Under the hood

- The tailoring call runs on the full-strength model (it writes your actual application
document), and nothing is persisted unless it succeeds. Indexing failures are recoverable
in-app via **Re-index**. Applications can be deleted from the row or the detail view —
guarded so you can’t delete the one a live interview is using. Every increment was
adversarially reviewed before merge; the unit-test suite grows to 137.
30 changes: 21 additions & 9 deletions docs/04-DATABASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ profiles 1───* documents 1───* chunks ──* embeddings
│ (1:1 chunk:embedding)
├──* notes
├──* stories (STAR stories; also indexed as `story` chunks, job_id null)
├──* jobs ────* chunks (JD + company-research chunks carry job_id; resume/note/story chunks have job_id null)
├──* applications ──1 jobs (Tailor Resume: each application owns a dedicated, hidden job; its tailored resume = that job's `tailored` chunks)
├──* jobs ────* chunks (JD + company-research + tailored chunks carry job_id; resume/note/story chunks have job_id null)
│ └──── sessions (a session optionally references the job it's for)
└──* sessions 1──* transcript_chunks
1──* detected_questions 1──* ai_answers
Expand Down Expand Up @@ -68,20 +69,29 @@ Uploaded file metadata + parsed text.
Freeform additional notes attached to a profile.
| id | profile_id FK | content | created_at |

### `applications`
A Tailor Resume application: the ATS-friendly resume tailored from a base resume × JD,
plus grounded answers to the application questions. Owns a dedicated jobs row (hidden
from the Interviews UI) whose `tailored` chunks ground that application's interviews.
| id | profile_id FK (cascade) | job_id FK (cascade) | name | job_title | company | base_resume | tailored_resume | answers (json[]) | created_at | updated_at |

### `stories`
Reusable STAR stories extracted from the résumé, tagged by competency + skills.
Profile-level (reused across every interview); also indexed as `story` chunks so
they can ground live answers.
| id | profile_id FK | title | situation | task | action | result | competencies (json[]) | skills (json[]) | created_at | updated_at |

### `chunks`
Chunked text from documents/notes/profile fields/stories for RAG.
| id | profile_id FK | job_id FK (nullable) | source_type (resume/jd/note/company/story) | source_id | ord | content | token_count | created_at |
Chunked text from documents/notes/profile fields/stories/tailored resumes for RAG.
| id | profile_id FK | job_id FK (nullable) | source_type (resume/jd/note/company/story/tailored) | source_id | ord | content | token_count | created_at |

`job_id` is set on JD **and** company-research chunks (both cascade on job
`job_id` is set on JD, company-research, **and** `tailored` chunks (all cascade on job
delete); resume/note/story chunks have `job_id` null. `story` chunks are managed
by `indexStories` (one chunk per story) and are deliberately **excluded** from the
résumé/notes re-index, so re-saving a résumé doesn't wipe the curated story bank.
`tailored` chunks (an application's tailored resume, indexed by `indexJob`) REPLACE the
base `resume` chunks in retrieval whenever the selected job has them — that's how
"Start interview" on an application grounds in the tailored resume instead of the base.

### `embeddings`
| id | chunk_id FK (unique) | model | dim | vector BLOB | created_at |
Expand Down Expand Up @@ -115,14 +125,16 @@ Known keys:
- `tour_done` — `'1'` once the first-run guided tour is completed/skipped.

## Deletion semantics
Deleting a profile cascades to its documents, notes, stories, jobs, chunks,
embeddings, sessions, and everything under sessions (FK `on delete cascade`). Deleting a job
cascades to its JD chunks and nulls `sessions.job_id` (the session history is
kept). Original uploaded files in `userData/documents/` are removed by the
documents service.
Deleting a profile cascades to its documents, notes, stories, applications, jobs,
chunks, embeddings, sessions, and everything under sessions (FK `on delete cascade`).
Deleting a job cascades to its JD/company/tailored chunks and nulls `sessions.job_id`
(the session history is kept). Deleting an application removes its dedicated job the
same way, then the application row. Original uploaded files in `userData/documents/`
are removed by the documents service.

## Indexes
- `chunks(profile_id)`, `jobs(profile_id)`, `stories(profile_id)`,
`applications(profile_id)`, `applications(created_at)`,
`embeddings(chunk_id)`, `transcript_chunks(session_id)`,
`detected_questions(session_id)`, `ai_answers(question_id)`,
`sessions(profile_id)`, `documents(profile_id)`, `notes(profile_id)`.
15 changes: 15 additions & 0 deletions docs/05-IPC-MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,21 @@ independently.
| `notes:create` | `{ profileId, content }` | `Note` |
| `notes:delete` | `{ id }` | `{ deleted: true }` |

### applications (Tailor Resume)
A job application produced by the Tailor Resume flow: an ATS-friendly resume tailored
from a base resume × JD (grounded — never invented), plus answers to the application
questions. Each application owns a dedicated (hidden) job row; its tailored resume is
indexed as that job's `tailored` chunks, so "Start interview" (session.start with the
app's profile+job) grounds the live session in the TAILORED resume + JD.
| Channel | Request | Response |
|---|---|---|
| `applications:page` | `{ query?, limit=8, offset=0 }` | `{ items: ApplicationListItem[], total }` (global, newest first; `LIKE` over name/title/company) |
| `applications:get` | `{ id }` | `Application` |
| `applications:tailor` | `{ profileId\|null, baseResumeText\|null, jdText, questions[] }` | `{ application, embedded, indexError }` (ALL model calls run before any write — an LLM failure persists nothing. An uploaded base resume materializes a real profile. Indexing is best-effort AFTER the rows exist: on failure the app is still saved and `indexError` is set — recover via `applications:reindex`) |
| `applications:reindex` | `{ id }` | `{ embedded }` (re-embed the owning profile's base chunks AND the job's jd/tailored chunks — full recovery/refresh) |
| `applications:export-pdf` | `{ id }` | `{ saved, filePath? }` (tailored resume → ATS-friendly PDF via a hidden window + printToPDF; native save dialog; `saved:false` on cancel) |
| `applications:delete` | `{ id }` | `{ deleted: true }` (also removes the dedicated job + its chunks; sessions keep history with jobId nulled) |

### stories
The per-profile STAR story bank (`Story[]`). Stories are extracted from the parsed
résumé, persisted, and indexed as `story` chunks so they ground live answers.
Expand Down
14 changes: 14 additions & 0 deletions docs/06-OPENAI-SERVICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,20 @@ so it grounds the answer, stays citable, and the Cue Card surfaces it as a promi
**"📖 Story to tell"** callout (`StoryCue` in Overlay.tsx, derived from the `contextSent` chunks —
no extra IPC event or embedding call).

### tailor.ts — `tailorApplication(input) => TailorResult`
Powers **Tailor Resume** (v1.3). One call (new `tailor` model key — full `gpt-4.1` on
balanced, `gpt-5` on best; latency-tolerant, quality-critical) takes the BASE resume ×
JD × application questions and returns `{ candidateName, jobTitle, company,
tailoredResume, answers[] }`. The prompt grounds EVERYTHING in the base resume (reword/
reorder/emphasize — never invent employers, dates, metrics, or skills), mirrors the JD's
keywords only where truthful (ATS matching), and mandates ATS structure: single column,
standard H2 sections, plain markdown, no tables/images. Answers are first-person and
grounded. Defensively parsed; throws if no resume comes back (so nothing persists).
The `applications:tailor` handler then materializes a profile (uploaded-base path),
a dedicated job (JD), and the application row; `indexJob` embeds the tailored resume as
job-scoped `tailored` chunks, and `vectorStore.search` drops base `resume` chunks for
jobs that have them — live sessions ground in the TAILORED resume + JD.

### embeddings.ts — `embed(texts: string[]) => Float32Array[]`
Batches inputs, returns vectors; caller stores BLOBs. Records model + dim.

Expand Down
89 changes: 89 additions & 0 deletions docs/sessions/2026-07-02.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# 2026-07-02

v1.2.0 released (tag → GitHub Release). Started **v1.3's flagship: Tailor Resume**
(branch `feat/tailor-resume`), designed via a 4-agent survey (upload/extract reuse,
RAG grounding design, dashboard UI patterns, PDF export options).

## Tailor Resume (v1.3)

**The flow:** pick a profile OR upload/paste a resume (base) → paste/upload/URL-fetch a
JD → optional application questions → ONE LLM op returns an **ATS-friendly tailored
resume** (grounded ONLY in the base resume — reword/reorder/emphasize, never invent) +
grounded answers + extracted jobTitle/company → **PDF download** → auto-saved as an
**Application** (`[name] - [jobTitle] at [company]`) in a dense, paginated, searchable,
newest-first table → per-row **Start interview** grounds a live session in the
TAILORED resume + JD.

**Key design decisions (survey-driven):**
- **Grounding swap via `tailored` chunks, not derived profiles.** Each application owns
a dedicated Job row (hidden from the Interviews UI via a `notInArray` subquery in
`jobsRepo.list/page/count`). `indexJob` also indexes the application's tailored resume
as job-scoped `tailored` chunks (inside its single clear-and-reinsert pass), and
`vectorStore.search` drops base `resume` chunks whenever the selected job has tailored
ones. Session pipeline untouched; notes/stories still ground; non-application jobs
are unaffected (the filter is a no-op without tailored chunks).
- **Uploaded base resume materializes a real profile** (named from the resume), keeping
the NOT NULL FKs and giving the user a reusable profile — no hidden-profile leakage.
- **Model-calls-first ordering:** tailor + JD-parse + resume-parse all run before any DB
write, so an LLM failure persists nothing.
- **PDF via hidden window + `printToPDF`** (zero deps): a deliberately minimal md→html
converter (`documents/resumeHtml.ts`, escape-first, single column, standard fonts, no
scripts — the simplicity IS the ATS feature) → data: URL → tagged Letter PDF → native
save dialog. Window destroyed in `finally`.
- New `tailor` model key (balanced `gpt-4.1` / low_cost mini / best `gpt-5` + medium
effort) — one-off, latency-tolerant, quality-critical.

**Shipped in 3 commits** (`cc72fe4` backend + migration 0006 + tailor.test (+8);
`fca7692` PDF export + resumeHtml.test (+5); `7876267` the /tailor page + nav).

**Adversarial review** (4 dimensions → refute-verify; 5 confirmed, 1 refuted):
1-2. *(med)* Embedding calls (`reindexProfile`, `indexJob`) violated the handler's own
"nothing persisted unless model calls succeed" invariant: a 429 mid-sequence could
orphan a profile, discard the paid tailor result, duplicate profiles/applications on
retry, and surface a saved application as a total failure with a stale table.
3. *(med)* A failed index had **no recovery path ever** (application jobs are hidden from
the only re-index trigger), silently grounding interviews in the base resume while
the Cue Card claimed JD context.
4. *(med)* The "Interview ended" save/discard prompt lived only on InterviewPage — a
session started from TailorPage and stopped via the Cue Card never showed it (and a
stale prompt would pop on the next /interview visit).
5. *(low)* Duplicate-application accumulation on retry (same root as 1-2).

**Fixes:** indexing is now BEST-EFFORT after all rows exist (`try/catch` → returns
`indexError`; TailorPage surfaces it); new `applications:reindex` IPC + a **Re-index**
button in ApplicationModal (recovery + re-embed after model changes); the save/discard
prompt extracted to a global `SavePromptModal` rendered in App (appears on any page;
InterviewPage refreshes its table off `pendingSave` clearing).

Verified: `typecheck` · 131 unit (+13) · `build` green throughout.

## Refinements (user feedback after testing)

1. **Row delete** — a ✕ with a two-step inline confirm on each applications-table row
(modal delete kept).
2. **Profile dedupe + parse-at-most-once** — the tailor handler resolves the owning
profile up front (selected, else an existing profile with the SAME resume text), so
repeat tailorings from one upload reuse one profile; `parseResume` is skipped whenever
the owner is already parsed; `reindexProfile` runs only for created/newly-parsed
profiles.
3. **Base vs tailored comparison** — a "Compare with base" toggle in ApplicationModal:
side-by-side panes with word-level highlights (red = removed, green = added/rewritten)
via a new dependency-free LCS util (`renderer/lib/wordDiff.ts`, +6 tests; plain
side-by-side fallback past the 4M-cell DP guard).

**Adversarial review of the refinements** (2 dimensions → refute-verify; 3 confirmed):
- *(med)* deleting the last row of the last page stranded an empty out-of-range page →
the fetch effect now self-heals to the new last page (covers all callers).
- *(low)* the parse-once gate made a failed first base-index unrecoverable (Re-index only
healed the job) → `applications:reindex` now re-embeds the profile's base chunks AND
the job's jd/tailored chunks.
- *(low)* the row ✕ could delete an application whose interview was LIVE, silently
downgrading its grounding mid-session → the ✕ is hidden for the live row, `deleteApp`
guards, and the modal's Delete is disabled with a hint.

Verified: `typecheck` · 137 unit (+6) · `build` green.

## Next
- v1.3.0 release cut when the user asks (changelog + version bump).
- Possible fast-follows: re-tailor an existing application; mock/sparring against an
application's JD; live-verify the tailor prompt quality on real JDs.
18 changes: 18 additions & 0 deletions drizzle/0006_sour_shotgun.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
CREATE TABLE `applications` (
`id` text PRIMARY KEY NOT NULL,
`profile_id` text NOT NULL,
`job_id` text NOT NULL,
`name` text DEFAULT '' NOT NULL,
`job_title` text DEFAULT '' NOT NULL,
`company` text,
`base_resume` text NOT NULL,
`tailored_resume` text NOT NULL,
`answers` text,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
FOREIGN KEY (`profile_id`) REFERENCES `profiles`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`job_id`) REFERENCES `jobs`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `applications_profile_idx` ON `applications` (`profile_id`);--> statement-breakpoint
CREATE INDEX `applications_created_idx` ON `applications` (`created_at`);
Loading
Loading