diff --git a/changelog/1.3.0.md b/changelog/1.3.0.md new file mode 100644 index 0000000..bc31ec5 --- /dev/null +++ b/changelog/1.3.0.md @@ -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. diff --git a/docs/04-DATABASE.md b/docs/04-DATABASE.md index 8b57cab..5dc9139 100644 --- a/docs/04-DATABASE.md +++ b/docs/04-DATABASE.md @@ -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 @@ -68,6 +69,12 @@ 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 @@ -75,13 +82,16 @@ 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 | @@ -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)`. diff --git a/docs/05-IPC-MAP.md b/docs/05-IPC-MAP.md index d411997..db0fcd8 100644 --- a/docs/05-IPC-MAP.md +++ b/docs/05-IPC-MAP.md @@ -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. diff --git a/docs/06-OPENAI-SERVICE.md b/docs/06-OPENAI-SERVICE.md index 24894f9..56beb04 100644 --- a/docs/06-OPENAI-SERVICE.md +++ b/docs/06-OPENAI-SERVICE.md @@ -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. diff --git a/docs/sessions/2026-07-02.md b/docs/sessions/2026-07-02.md new file mode 100644 index 0000000..47b5060 --- /dev/null +++ b/docs/sessions/2026-07-02.md @@ -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. diff --git a/drizzle/0006_sour_shotgun.sql b/drizzle/0006_sour_shotgun.sql new file mode 100644 index 0000000..cc8e7bd --- /dev/null +++ b/drizzle/0006_sour_shotgun.sql @@ -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`); \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..b89c557 --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1356 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "2794c821-df96-45f9-ac5f-941c81991ec4", + "prevId": "10d8736e-41b3-4d27-8c0e-d2b00327aae1", + "tables": { + "ai_answers": { + "name": "ai_answers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direct_answer": { + "name": "direct_answer", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "talking_points": { + "name": "talking_points", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resume_match": { + "name": "resume_match", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "star": { + "name": "star", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "clarifying_question": { + "name": "clarifying_question", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "risk_warning": { + "name": "risk_warning", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "followup_question": { + "name": "followup_question", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "answers_question_idx": { + "name": "answers_question_idx", + "columns": [ + "question_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ai_answers_question_id_detected_questions_id_fk": { + "name": "ai_answers_question_id_detected_questions_id_fk", + "tableFrom": "ai_answers", + "tableTo": "detected_questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "applications": { + "name": "applications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "company": { + "name": "company", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_resume": { + "name": "base_resume", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tailored_resume": { + "name": "tailored_resume", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answers": { + "name": "answers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "applications_profile_idx": { + "name": "applications_profile_idx", + "columns": [ + "profile_id" + ], + "isUnique": false + }, + "applications_created_idx": { + "name": "applications_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "applications_profile_id_profiles_id_fk": { + "name": "applications_profile_id_profiles_id_fk", + "tableFrom": "applications", + "tableTo": "profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "applications_job_id_jobs_id_fk": { + "name": "applications_job_id_jobs_id_fk", + "tableFrom": "applications", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chunks": { + "name": "chunks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "chunks_profile_idx": { + "name": "chunks_profile_idx", + "columns": [ + "profile_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chunks_profile_id_profiles_id_fk": { + "name": "chunks_profile_id_profiles_id_fk", + "tableFrom": "chunks", + "tableTo": "profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chunks_job_id_jobs_id_fk": { + "name": "chunks_job_id_jobs_id_fk", + "tableFrom": "chunks", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "detected_questions": { + "name": "detected_questions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'behavioral'" + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "strategy": { + "name": "strategy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "transcript_chunk_id": { + "name": "transcript_chunk_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "questions_session_idx": { + "name": "questions_session_idx", + "columns": [ + "session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "detected_questions_session_id_sessions_id_fk": { + "name": "detected_questions_session_id_sessions_id_fk", + "tableFrom": "detected_questions", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "documents": { + "name": "documents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "documents_profile_idx": { + "name": "documents_profile_idx", + "columns": [ + "profile_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "documents_profile_id_profiles_id_fk": { + "name": "documents_profile_id_profiles_id_fk", + "tableFrom": "documents", + "tableTo": "profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "embeddings": { + "name": "embeddings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "chunk_id": { + "name": "chunk_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dim": { + "name": "dim", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vector": { + "name": "vector", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "embeddings_chunk_id_unique": { + "name": "embeddings_chunk_id_unique", + "columns": [ + "chunk_id" + ], + "isUnique": true + }, + "embeddings_chunk_idx": { + "name": "embeddings_chunk_idx", + "columns": [ + "chunk_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "embeddings_chunk_id_chunks_id_fk": { + "name": "embeddings_chunk_id_chunks_id_fk", + "tableFrom": "embeddings", + "tableTo": "chunks", + "columnsFrom": [ + "chunk_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "company": { + "name": "company", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jd_url": { + "name": "jd_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jd_text": { + "name": "jd_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parsed_jd": { + "name": "parsed_jd", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_url": { + "name": "company_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_research": { + "name": "company_research", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parsed_company": { + "name": "parsed_company", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "jobs_profile_idx": { + "name": "jobs_profile_idx", + "columns": [ + "profile_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "jobs_profile_id_profiles_id_fk": { + "name": "jobs_profile_id_profiles_id_fk", + "tableFrom": "jobs", + "tableTo": "profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notes": { + "name": "notes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "notes_profile_idx": { + "name": "notes_profile_idx", + "columns": [ + "profile_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notes_profile_id_profiles_id_fk": { + "name": "notes_profile_id_profiles_id_fk", + "tableFrom": "notes", + "tableTo": "profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "profiles": { + "name": "profiles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_role": { + "name": "target_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "target_company": { + "name": "target_company", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "interview_type": { + "name": "interview_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'general'" + }, + "answer_style": { + "name": "answer_style", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'concise'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "resume_text": { + "name": "resume_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jd_text": { + "name": "jd_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parsed_resume": { + "name": "parsed_resume", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parsed_jd": { + "name": "parsed_jd", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_reports": { + "name": "session_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "strengths": { + "name": "strengths", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "improvements": { + "name": "improvements", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "per_question": { + "name": "per_question", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "session_reports_session_id_unique": { + "name": "session_reports_session_id_unique", + "columns": [ + "session_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_reports_session_id_sessions_id_fk": { + "name": "session_reports_session_id_sessions_id_fk", + "tableFrom": "session_reports", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "interview_type": { + "name": "interview_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'general'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "sessions_profile_idx": { + "name": "sessions_profile_idx", + "columns": [ + "profile_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_profile_id_profiles_id_fk": { + "name": "sessions_profile_id_profiles_id_fk", + "tableFrom": "sessions", + "tableTo": "profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_job_id_jobs_id_fk": { + "name": "sessions_job_id_jobs_id_fk", + "tableFrom": "sessions", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stories": { + "name": "stories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "situation": { + "name": "situation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "task": { + "name": "task", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "competencies": { + "name": "competencies", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "skills": { + "name": "skills", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "stories_profile_idx": { + "name": "stories_profile_idx", + "columns": [ + "profile_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "stories_profile_id_profiles_id_fk": { + "name": "stories_profile_id_profiles_id_fk", + "tableFrom": "stories", + "tableTo": "profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transcript_chunks": { + "name": "transcript_chunks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "speaker": { + "name": "speaker", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_final": { + "name": "is_final", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "t_start": { + "name": "t_start", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "t_end": { + "name": "t_end", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "transcript_session_idx": { + "name": "transcript_session_idx", + "columns": [ + "session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "transcript_chunks_session_id_sessions_id_fk": { + "name": "transcript_chunks_session_id_sessions_id_fk", + "tableFrom": "transcript_chunks", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index cd8453d..9d02267 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1782843181685, "tag": "0005_wealthy_rhino", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1782994837940, + "tag": "0006_sour_shotgun", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 2e4cd80..c92efc8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ai-interview-assistant", - "version": "1.2.0", + "version": "1.3.0", "description": "BrainCue Copilot — desktop AI interview copilot (Electron + React + OpenAI). Local-first data, BYO OpenAI key.", "author": "tpikachu", "license": "MIT", diff --git a/src/main/db/repositories/applications.repo.ts b/src/main/db/repositories/applications.repo.ts new file mode 100644 index 0000000..0726e9e --- /dev/null +++ b/src/main/db/repositories/applications.repo.ts @@ -0,0 +1,117 @@ +import { desc, eq, like, or, sql } from 'drizzle-orm'; +import { db, schema } from '../index'; +import { jobsRepo } from './jobs.repo'; +import type { Application, ApplicationAnswer, ApplicationListItem } from '@shared/types'; + +type Row = typeof schema.applications.$inferSelect; + +function toApplication(r: Row): Application { + return { + id: r.id, + profileId: r.profileId, + jobId: r.jobId, + name: r.name, + jobTitle: r.jobTitle, + company: r.company, + baseResume: r.baseResume, + tailoredResume: r.tailoredResume, + answers: r.answers ? (JSON.parse(r.answers) as ApplicationAnswer[]) : [], + createdAt: r.createdAt, + updatedAt: r.updatedAt, + }; +} + +export const applicationsRepo = { + get(id: string): Application | null { + const r = db().select().from(schema.applications).where(eq(schema.applications.id, id)).get(); + return r ? toApplication(r) : null; + }, + + /** The application that owns a job (if any) — used by indexJob to pick up the + * tailored resume as `tailored` chunks, and by jobs list filtering. */ + getByJobId(jobId: string): Application | null { + const r = db() + .select() + .from(schema.applications) + .where(eq(schema.applications.jobId, jobId)) + .get(); + return r ? toApplication(r) : null; + }, + + create(input: { + profileId: string; + jobId: string; + name: string; + jobTitle: string; + company: string | null; + baseResume: string; + tailoredResume: string; + answers: ApplicationAnswer[]; + }): Application { + const id = crypto.randomUUID(); + db() + .insert(schema.applications) + .values({ + id, + profileId: input.profileId, + jobId: input.jobId, + name: input.name, + jobTitle: input.jobTitle, + company: input.company, + baseResume: input.baseResume, + tailoredResume: input.tailoredResume, + answers: JSON.stringify(input.answers), + }) + .run(); + return this.get(id)!; + }, + + /** A page of applications across ALL profiles, newest first, optionally filtered + * by a search over name/title/company. Server-side LIMIT/OFFSET (see jobs.page). */ + page(opts: { query?: string; limit: number; offset: number }): { + items: ApplicationListItem[]; + total: number; + } { + const q = (opts.query ?? '').trim(); + const where = q + ? or( + like(schema.applications.name, `%${q}%`), + like(schema.applications.jobTitle, `%${q}%`), + like(schema.applications.company, `%${q}%`), + ) + : undefined; + + const items = db() + .select({ + row: schema.applications, + profileName: schema.profiles.name, + }) + .from(schema.applications) + .leftJoin(schema.profiles, eq(schema.profiles.id, schema.applications.profileId)) + .where(where) + .orderBy(desc(schema.applications.createdAt)) + .limit(opts.limit) + .offset(opts.offset) + .all() + .map((r) => ({ ...toApplication(r.row), profileName: r.profileName ?? null })); + + const total = + db() + .select({ c: sql`count(*)` }) + .from(schema.applications) + .where(where) + .get()?.c ?? 0; + return { items, total }; + }, + + /** Delete an application AND its dedicated job (JD/company/tailored chunks + + * embeddings; sessions keep their history with jobId nulled — same semantics as + * deleting a job). The app row is removed explicitly too, so this works whether + * or not the DB enforces the ON DELETE cascade from jobs. */ + delete(id: string): void { + const app = this.get(id); + if (!app) return; + jobsRepo.delete(app.jobId); + db().delete(schema.applications).where(eq(schema.applications.id, id)).run(); + }, +}; diff --git a/src/main/db/repositories/jobs.repo.ts b/src/main/db/repositories/jobs.repo.ts index 4fa1ca8..f738261 100644 --- a/src/main/db/repositories/jobs.repo.ts +++ b/src/main/db/repositories/jobs.repo.ts @@ -1,9 +1,15 @@ -import { and, desc, eq, inArray, like, or, sql } from 'drizzle-orm'; +import { and, desc, eq, inArray, like, notInArray, or, sql } from 'drizzle-orm'; import { db, schema } from '../index'; import type { Job } from '@shared/types'; type Row = typeof schema.jobs.$inferSelect; +/** Jobs owned by an application (Tailor Resume) are managed from the Applications + * table — hide them from the regular Interviews list/page so they don't + * double-surface (and can't be deleted out from under their application). */ +const notApplicationOwned = () => + notInArray(schema.jobs.id, db().select({ id: schema.applications.jobId }).from(schema.applications)); + function toJob(r: Row): Job { return { id: r.id, @@ -27,7 +33,7 @@ export const jobsRepo = { return db() .select() .from(schema.jobs) - .where(eq(schema.jobs.profileId, profileId)) + .where(and(eq(schema.jobs.profileId, profileId), notApplicationOwned())) .orderBy(desc(schema.jobs.updatedAt)) .all() .map(toJob); @@ -38,9 +44,16 @@ export const jobsRepo = { return r ? toJob(r) : null; }, - /** Total interviews (jobs) across all profiles — for the sidebar stats. */ + /** Total interviews (jobs) across all profiles — for the sidebar stats. Excludes + * application-owned jobs (those are counted as applications, not interviews). */ count(): number { - return db().select({ c: sql`count(*)` }).from(schema.jobs).get()?.c ?? 0; + return ( + db() + .select({ c: sql`count(*)` }) + .from(schema.jobs) + .where(notApplicationOwned()) + .get()?.c ?? 0 + ); }, /** A page of jobs for a profile, newest first, optionally filtered by a search @@ -50,7 +63,7 @@ export const jobsRepo = { total: number; } { const q = (opts.query ?? '').trim(); - const base = eq(schema.jobs.profileId, opts.profileId); + const base = and(eq(schema.jobs.profileId, opts.profileId), notApplicationOwned()); const where = q ? and(base, or(like(schema.jobs.title, `%${q}%`), like(schema.jobs.company, `%${q}%`))) : base; diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index db47571..5d2c37d 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -96,6 +96,34 @@ export const stories = sqliteTable( (t) => ({ byProfile: index('stories_profile_idx').on(t.profileId) }), ); +// A job application from the Tailor Resume flow. Owns a dedicated jobs row (the JD + +// the tailored resume's `tailored` chunks); "Start interview" launches a session with +// (profile_id, job_id) so grounding swaps to the tailored resume automatically. +export const applications = sqliteTable( + 'applications', + { + id: text('id').primaryKey(), + profileId: text('profile_id') + .notNull() + .references(() => profiles.id, { onDelete: 'cascade' }), + jobId: text('job_id') + .notNull() + .references(() => jobs.id, { onDelete: 'cascade' }), + name: text('name').notNull().default(''), // candidate/application name + jobTitle: text('job_title').notNull().default(''), // extracted from the JD + company: text('company'), // extracted from the JD + baseResume: text('base_resume').notNull(), // input snapshot (provenance) + tailoredResume: text('tailored_resume').notNull(), // markdown — PDF + chunk source + answers: text('answers'), // json[] — [{ question, answer }] + createdAt: integer('created_at').notNull().default(now), + updatedAt: integer('updated_at').notNull().default(now), + }, + (t) => ({ + byProfile: index('applications_profile_idx').on(t.profileId), + byCreated: index('applications_created_idx').on(t.createdAt), + }), +); + export const chunks = sqliteTable( 'chunks', { @@ -103,9 +131,10 @@ export const chunks = sqliteTable( profileId: text('profile_id') .notNull() .references(() => profiles.id, { onDelete: 'cascade' }), - // Resume/notes/story chunks have jobId null; JD chunks belong to a specific job. + // Resume/notes/story chunks have jobId null; JD/company/tailored chunks belong + // to a specific job. jobId: text('job_id').references(() => jobs.id, { onDelete: 'cascade' }), - sourceType: text('source_type').notNull(), // resume | jd | note | company | story + sourceType: text('source_type').notNull(), // resume | jd | note | company | story | tailored sourceId: text('source_id'), ord: integer('ord').notNull().default(0), content: text('content').notNull(), diff --git a/src/main/ipc/applications.ipc.ts b/src/main/ipc/applications.ipc.ts new file mode 100644 index 0000000..0df6bd9 --- /dev/null +++ b/src/main/ipc/applications.ipc.ts @@ -0,0 +1,165 @@ +import { z } from 'zod'; +import { IPC } from '@shared/ipc'; +import { handle, zId } from './helpers'; +import { applicationsRepo } from '../db/repositories/applications.repo'; +import { profilesRepo } from '../db/repositories/profiles.repo'; +import { jobsRepo } from '../db/repositories/jobs.repo'; +import { tailorApplication } from '../services/openai/tailor'; +import { parseJobDescription, parseResume } from '../services/openai/parsing'; +import { indexJob, reindexProfile } from '../services/rag/indexProfile'; +import { exportResumePdf } from '../services/documents/resumePdf'; +import { normalizeOpenAIError } from '../services/openai/client'; +import { apiKeyStore } from '../services/security/apiKey'; +import { log } from '../services/security/logger'; + +export function registerApplicationsIpc(): void { + handle( + IPC.applications.page, + z.object({ + query: z.string().default(''), + limit: z.number().int().min(1).max(100).default(8), + offset: z.number().int().min(0).default(0), + }), + ({ query, limit, offset }) => applicationsRepo.page({ query, limit, offset }), + ); + + handle(IPC.applications.get, zId, ({ id }) => { + const app = applicationsRepo.get(id); + if (!app) throw new Error('Application not found'); + return app; + }); + + // The Tailor Resume operation. ALL model calls run first — nothing is persisted + // unless they succeed — then: (create profile for an uploaded base resume) → + // dedicated job (JD) → application row → indexJob (embeds jd + tailored chunks). + handle( + IPC.applications.tailor, + z.object({ + profileId: z.string().nullable().default(null), // existing profile as the base… + baseResumeText: z.string().nullable().default(null), // …or an uploaded/pasted resume + jdText: z.string().min(1), + questions: z.array(z.string()).default([]), + }), + async ({ profileId, baseResumeText, jdText, questions }) => { + if (!apiKeyStore.isPresent()) + throw new Error('Add your OpenAI API key in Settings to tailor a resume.'); + + // Resolve the BASE resume text. + let baseResume: string; + const existing = profileId ? profilesRepo.get(profileId) : null; + if (profileId) { + if (!existing) throw new Error('Profile not found.'); + if (!existing.resumeText?.trim()) + throw new Error('This profile has no resume text — add one, or upload a resume.'); + baseResume = existing.resumeText; + } else { + if (!baseResumeText?.trim()) + throw new Error('Select a profile or provide the base resume text.'); + baseResume = baseResumeText; + } + + // Resolve the OWNING profile up front (reads only): the selected one, or — for + // an uploaded/pasted base — an existing profile with the SAME resume text, so + // repeat tailorings from one resume reuse it (no duplicate profiles). + let owner = + existing ?? + profilesRepo.list().find((p) => (p.resumeText ?? '').trim() === baseResume.trim()) ?? + null; + + // Model calls first (tailor + JD parse + resume parse), so a failure here + // leaves the database untouched. The base resume is parsed AT MOST ONCE per + // profile — skipped whenever the owner already has a parsed resume. + const result = await tailorApplication({ + baseResume, + jdText, + questions: questions.map((q) => q.trim()).filter(Boolean), + }); + const parsedJd = await parseJobDescription(jdText); + const parsedResume = owner?.parsedResume ? null : await parseResume(baseResume); + + // Uploaded base resume with no matching profile → materialize a real, reusable + // profile for it (sessions and jobs both require an owning profile). Its + // embedding runs in the best-effort block below, AFTER the application exists. + let createdProfile = false; + if (!owner) { + owner = profilesRepo.create({ + name: result.candidateName || 'Imported resume', + targetRole: result.jobTitle, + targetCompany: result.company || null, + interviewType: 'general', + language: 'en', + resumeText: baseResume, + jdText: null, + }); + createdProfile = true; + } + if (parsedResume) profilesRepo.update(owner.id, { parsedResume }); + + // The application's dedicated job: holds the JD + (via indexJob) the tailored + // chunks. Hidden from the regular Interviews table. + const job = jobsRepo.create({ + profileId: owner.id, + title: result.jobTitle || 'Untitled role', + company: result.company || null, + jdUrl: null, + jdText, + companyUrl: null, + notes: null, + }); + jobsRepo.update(job.id, { parsedJd }); + + const app = applicationsRepo.create({ + profileId: owner.id, + jobId: job.id, + name: result.candidateName || owner.name, + jobTitle: result.jobTitle, + company: result.company || null, + baseResume, + tailoredResume: result.tailoredResume, + answers: result.answers, + }); + + // BEST-EFFORT indexing — every row already exists, so an embedding failure + // (429/network) must NOT fail the operation or lose the paid result. Sessions + // fall back to base-resume grounding until "Re-index" succeeds; a new profile's + // base chunks self-heal on its next resume save. + let embedded = 0; + let indexError: string | null = null; + try { + // Only a newly created (or newly parsed) profile needs its base re-indexed; + // a reused/matched profile's chunks are already in place. + if (createdProfile || parsedResume) await reindexProfile(owner.id); + ({ embedded } = await indexJob(job.id)); + } catch (e) { + indexError = normalizeOpenAIError(e); + log.warn('applications:tailor indexing failed (app saved)', indexError); + } + return { application: app, embedded, indexError }; + }, + ); + + // Re-embed an application's grounding — the recovery path when indexing failed at + // tailor time (and a way to re-embed after model changes). Heals BOTH the owning + // profile's base chunks (a failed first reindex is otherwise never retried — the + // parse-once gate skips matched profiles) AND the job's jd/tailored chunks. + handle(IPC.applications.reindex, zId, async ({ id }) => { + const app = applicationsRepo.get(id); + if (!app) throw new Error('Application not found'); + const profile = await reindexProfile(app.profileId); + const job = await indexJob(app.jobId); + return { embedded: profile.embedded + job.embedded }; + }); + + // Save the tailored resume as an ATS-friendly PDF (native save dialog). + handle(IPC.applications.exportPdf, zId, async ({ id }) => { + const app = applicationsRepo.get(id); + if (!app) throw new Error('Application not found'); + const label = `${app.name} - ${app.jobTitle}${app.company ? ` at ${app.company}` : ''}`; + return exportResumePdf(app.tailoredResume, label); + }); + + handle(IPC.applications.delete, zId, ({ id }) => { + applicationsRepo.delete(id); + return { deleted: true as const }; + }); +} diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 67c6f17..cf8868a 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -3,6 +3,7 @@ import { registerSettingsIpc } from './settings.ipc'; import { registerProfilesIpc } from './profiles.ipc'; import { registerDocumentsIpc } from './documents.ipc'; import { registerJobsIpc } from './jobs.ipc'; +import { registerApplicationsIpc } from './applications.ipc'; import { registerNotesIpc } from './notes.ipc'; import { registerStoriesIpc } from './stories.ipc'; import { registerSessionIpc } from './session.ipc'; @@ -22,6 +23,7 @@ export function registerIpc(): void { registerProfilesIpc(); registerDocumentsIpc(); registerJobsIpc(); + registerApplicationsIpc(); registerNotesIpc(); registerStoriesIpc(); registerSessionIpc(); diff --git a/src/main/services/documents/resumeHtml.test.ts b/src/main/services/documents/resumeHtml.test.ts new file mode 100644 index 0000000..ed0052e --- /dev/null +++ b/src/main/services/documents/resumeHtml.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { resumeMarkdownToHtml, resumePrintDocument } from './resumeHtml'; + +describe('resumeMarkdownToHtml', () => { + it('converts headings, bullets, bold, and paragraphs', () => { + const md = [ + '# Jane Doe', + 'jane@doe.dev | 555-0100', + '', + '## Experience', + '**Senior Engineer — Acme**', + '2021 - Present', + '- Cut p99 latency **40%**', + '* Led a team of 4', + '', + 'Plain closing paragraph.', + ].join('\n'); + const html = resumeMarkdownToHtml(md); + expect(html).toContain('

Jane Doe

'); + expect(html).toContain('

Experience

'); + expect(html).toContain('Senior Engineer — Acme'); + expect(html).toContain('
  • Cut p99 latency 40%
  • Led a team of 4
'); + expect(html).toContain('

Plain closing paragraph.

'); + // Adjacent non-blank lines join into ONE paragraph with a line break. + expect(html).toContain('

Senior Engineer — Acme
2021 - Present

'); + }); + + it('escapes HTML in the (untrusted) resume text BEFORE formatting', () => { + const html = resumeMarkdownToHtml('# A \n- 5 < 10 & **x > y**'); + expect(html).not.toContain('