From cc72fe49e56854dae2c8d8c47f2a7a4ff99e29ac Mon Sep 17 00:00:00 2001 From: tpikachu Date: Thu, 2 Jul 2026 07:28:15 -0500 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20Tailor=20Resume=20backend=20?= =?UTF-8?q?=E2=80=94=20applications,=20tailoring=20op,=20tailored=20ground?= =?UTF-8?q?ing=20(v1.3=20#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tailor Resume core: one LLM op (services/openai/tailor.ts, new 'tailor' model key — full gpt-4.1 on balanced) takes a base resume × JD × application questions and returns an ATS-friendly tailored resume (grounded ONLY in the base resume — never invented experience), grounded answers, and the extracted jobTitle/company. - New applications table (migration 0006, profile-cascade + created_at index) + applicationsRepo (global newest-first page() with LIKE search + profile join; delete() removes the dedicated job + its chunks). - Each application owns a dedicated Job row holding the JD; indexJob now also indexes the application's tailored resume as job-scoped `tailored` chunks, and vectorStore.search drops base `resume` chunks when the selected job has tailored ones — so "Start interview" grounds sessions in the TAILORED resume + JD with the session pipeline untouched. Application-owned jobs are hidden from the regular Interviews list/page/count. - applications:{page,get,tailor,delete} IPC + preload facade. The tailor handler runs ALL model calls before any DB write (an LLM failure persists nothing); an uploaded base resume materializes a real, reusable profile. - tailor.test.ts (+8): request shape, grounding/ATS prompt, defensive parsing. Verified: typecheck · 126 unit · build green. Co-Authored-By: Claude Fable 5 --- drizzle/0006_sour_shotgun.sql | 18 + drizzle/meta/0006_snapshot.json | 1356 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/main/db/repositories/applications.repo.ts | 117 ++ src/main/db/repositories/jobs.repo.ts | 23 +- src/main/db/schema.ts | 33 +- src/main/ipc/applications.ipc.ts | 120 ++ src/main/ipc/index.ts | 2 + src/main/services/openai/models.ts | 6 + src/main/services/openai/tailor.test.ts | 113 ++ src/main/services/openai/tailor.ts | 104 ++ src/main/services/rag/indexProfile.ts | 9 +- src/main/services/rag/vectorStore.ts | 19 +- src/preload/index.ts | 24 +- src/shared/ipc.ts | 6 + src/shared/types.ts | 33 +- 16 files changed, 1975 insertions(+), 15 deletions(-) create mode 100644 drizzle/0006_sour_shotgun.sql create mode 100644 drizzle/meta/0006_snapshot.json create mode 100644 src/main/db/repositories/applications.repo.ts create mode 100644 src/main/ipc/applications.ipc.ts create mode 100644 src/main/services/openai/tailor.test.ts create mode 100644 src/main/services/openai/tailor.ts 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/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..8f31bbd --- /dev/null +++ b/src/main/ipc/applications.ipc.ts @@ -0,0 +1,120 @@ +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 { apiKeyStore } from '../services/security/apiKey'; + +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; + } + + // Model calls first (tailor + JD parse + resume parse for a new profile), so a + // failure here leaves the database untouched. + const result = await tailorApplication({ + baseResume, + jdText, + questions: questions.map((q) => q.trim()).filter(Boolean), + }); + const parsedJd = await parseJobDescription(jdText); + const parsedResume = existing ? null : await parseResume(baseResume); + + // Uploaded base resume → materialize a real, reusable profile for it (sessions + // and jobs both require an owning profile). + let owner = existing; + 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, + }); + profilesRepo.update(owner.id, { parsedResume }); + await reindexProfile(owner.id); + } + + // 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, + }); + + // Embed JD + tailored chunks. If embedding fails the app still exists; its + // sessions fall back to base-resume grounding until a re-index succeeds. + const { embedded } = await indexJob(job.id); + return { application: app, embedded }; + }, + ); + + 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/openai/models.ts b/src/main/services/openai/models.ts index 8f78d5c..ba8b88b 100644 --- a/src/main/services/openai/models.ts +++ b/src/main/services/openai/models.ts @@ -16,6 +16,9 @@ const BALANCED = { // screenshot path (vision.ts). The one task where a reasoning model earns its cost: // latency-tolerant (the user waits a beat) and correctness is the whole point. coding: 'gpt-5-mini', + // Resume tailoring (Tailor Resume flow) — one-off, latency-tolerant, and the output + // IS the user's application document, so it gets the full non-reasoning model. + tailor: 'gpt-4.1', } as const; export type ModelKey = keyof typeof BALANCED; @@ -39,6 +42,7 @@ export const PRESETS: Record> = { tts: 'gpt-4o-mini-tts', mock: 'gpt-4.1-mini', coding: 'gpt-5-mini', + tailor: 'gpt-4.1-mini', }, best: { answer: 'gpt-4.1', // full model: higher quality, still non-reasoning = still snappy @@ -49,6 +53,7 @@ export const PRESETS: Record> = { tts: 'gpt-4o-mini-tts', mock: 'gpt-4.1', coding: 'gpt-5', // strongest reasoning solver for the hardest problems + tailor: 'gpt-5', // reasoning model may deliberate over wording; latency is fine here }, }; @@ -82,6 +87,7 @@ export type ReasoningEffort = 'low' | 'medium' | 'high'; * default; the live answer/classify paths stay on fast non-reasoning models. */ export const defaultEfforts: Partial> = { coding: 'low', + tailor: 'medium', // only sent when the resolved tailor model is a reasoning one ('best') }; /** Effective effort for a task (user override → built-in default → none). */ diff --git a/src/main/services/openai/tailor.test.ts b/src/main/services/openai/tailor.test.ts new file mode 100644 index 0000000..d2f3d14 --- /dev/null +++ b/src/main/services/openai/tailor.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Capture the request body passed to responses.create and feed back a fixed JSON +// reply. Mock the model resolver so models.ts → db → better-sqlite3 never loads. +const h = vi.hoisted(() => ({ + lastBody: null as Record | null, + reply: '{}', +})); +vi.mock('./client', () => ({ + openai: () => ({ + responses: { + create: (body: Record) => { + h.lastBody = body; + return { output_text: h.reply }; + }, + }, + }), +})); +vi.mock('./models', () => ({ + model: (k: string) => `model:${k}`, + reasoningParam: () => ({}), +})); + +import { tailorApplication } from './tailor'; + +function input(over: Partial[0]> = {}) { + return { + baseResume: 'Jane Doe\nSWE at Acme. Cut p99 latency 40% with a Node cache layer.', + jdText: 'Senior Backend Engineer at Globex. Requires Node.js and caching at scale.', + questions: ['Why do you want to work here?'], + ...over, + }; +} + +const systemPrompt = () => + String((h.lastBody!.input as { role: string; content: string }[])[0].content); +const userPrompt = () => + String((h.lastBody!.input as { role: string; content: string }[])[1].content); + +const FULL = JSON.stringify({ + candidateName: 'Jane Doe', + jobTitle: 'Senior Backend Engineer', + company: 'Globex', + tailoredResume: '# Jane Doe\n\n## Summary\nBackend engineer…', + answers: [{ question: 'Why do you want to work here?', answer: 'Because…' }], +}); + +beforeEach(() => { + h.lastBody = null; + h.reply = FULL; +}); + +describe('tailorApplication — request', () => { + it('uses the tailor model and asks for JSON', async () => { + await tailorApplication(input()); + expect(h.lastBody!.model).toBe('model:tailor'); + expect(h.lastBody!.text).toEqual({ format: { type: 'json_object' } }); + }); + + it('mandates grounding + ATS structure in the system prompt', async () => { + await tailorApplication(input()); + const s = systemPrompt(); + expect(s).toMatch(/NEVER invent/i); + expect(s).toMatch(/ATS/i); + expect(s).toMatch(/no tables/i); + }); + + it('feeds the base resume, JD, and numbered questions into the prompt', async () => { + await tailorApplication(input()); + const u = userPrompt(); + expect(u).toContain('Cut p99 latency 40%'); + expect(u).toContain('Senior Backend Engineer at Globex'); + expect(u).toContain('1. Why do you want to work here?'); + }); + + it('marks the questions section empty when none are given', async () => { + await tailorApplication(input({ questions: [] })); + expect(userPrompt()).toContain('APPLICATION QUESTIONS: (none)'); + }); +}); + +describe('tailorApplication — defensive parsing', () => { + it('returns the parsed result', async () => { + const r = await tailorApplication(input()); + expect(r).toEqual({ + candidateName: 'Jane Doe', + jobTitle: 'Senior Backend Engineer', + company: 'Globex', + tailoredResume: '# Jane Doe\n\n## Summary\nBackend engineer…', + answers: [{ question: 'Why do you want to work here?', answer: 'Because…' }], + }); + }); + + it('throws when the model returns no tailored resume (nothing gets persisted)', async () => { + h.reply = JSON.stringify({ candidateName: 'X', answers: [] }); + await expect(tailorApplication(input())).rejects.toThrow(/no resume/i); + }); + + it('defaults missing name/title/company to empty strings', async () => { + h.reply = JSON.stringify({ tailoredResume: '# R' }); + const r = await tailorApplication(input()); + expect(r).toMatchObject({ candidateName: '', jobTitle: '', company: '', answers: [] }); + }); + + it('drops malformed answer entries (missing question or answer)', async () => { + h.reply = JSON.stringify({ + tailoredResume: '# R', + answers: [{ question: 'Q1' }, { answer: 'orphan' }, { question: 'Q2', answer: 'A2' }, 7], + }); + const r = await tailorApplication(input()); + expect(r.answers).toEqual([{ question: 'Q2', answer: 'A2' }]); + }); +}); diff --git a/src/main/services/openai/tailor.ts b/src/main/services/openai/tailor.ts new file mode 100644 index 0000000..8772a6f --- /dev/null +++ b/src/main/services/openai/tailor.ts @@ -0,0 +1,104 @@ +import { openai } from './client'; +import { model, reasoningParam } from './models'; +import type { ApplicationAnswer } from '@shared/types'; + +export interface TailorInput { + /** The candidate's real resume text (profile resume or an uploaded file). */ + baseResume: string; + jdText: string; + /** Application questions to answer (may be empty). */ + questions: string[]; +} + +/** What the single tailoring call produces. */ +export interface TailorResult { + candidateName: string; // extracted from the base resume + jobTitle: string; // extracted from the JD + company: string; // extracted from the JD ('' when absent) + tailoredResume: string; // ATS-friendly markdown + answers: ApplicationAnswer[]; +} + +const SYSTEM = `You are an expert resume writer and career coach. You are given a candidate's +REAL resume (the only source of truth about them), a job description, and optional +application questions. Return JSON only, with keys: + +- candidateName: the candidate's name as written on the resume ("" if absent). +- jobTitle: the job title, extracted from the JD ("" if unclear). +- company: the hiring company name, extracted from the JD ("" if unclear). +- tailoredResume: the resume rewritten to TARGET THIS JOB, as clean markdown. +- answers: one { "question", "answer" } object per application question, in order. + +TAILORING RULES (the resume): +- GROUND EVERYTHING in the base resume. NEVER invent employers, titles, dates, degrees, + projects, metrics, or skills the candidate doesn't have. You may reword, reorder, + emphasize, quantify only with numbers already present, and trim what's irrelevant. +- Mirror the JD's language TRUTHFULLY: where the candidate genuinely has a skill the JD + asks for, use the JD's exact keywords for it (ATS keyword matching). Skills the + candidate lacks are simply omitted — never added. +- ATS-FRIENDLY structure, as markdown: name on the first line as an H1; one contact line + (only details present in the base resume); then standard sections in this order, each an + H2 — Summary, Skills, Experience, Education (plus Certifications/Projects only if the + base resume has them). Experience entries: a bold "Role — Company" line, a plain date + line if dates exist, then 3-6 achievement bullets (strongest, most JD-relevant first). +- Single column, plain text only: no tables, no images, no columns, no icons, no emoji. +- Concise: aim for the content of a 1-2 page resume. Cut filler, keep impact. + +ANSWER RULES (the application questions): +- Answer AS the candidate, first person, grounded ONLY in the base resume (plus honest + motivation/fit reasoning from the JD). Never invent experience. 60-150 words each, + natural and human — no corporate filler, no "As an AI". +- If a question can't be answered from their background, be honest and pivot to the + closest real, transferable experience.`; + +/** + * One call tailors the resume + answers the application questions + extracts the + * job title/company. Output is defensively defaulted so a malformed model response + * can't crash the handler — and nothing is persisted unless this call succeeds. + */ +export async function tailorApplication(input: TailorInput): Promise { + const user = [ + 'BASE RESUME (the only source of truth about the candidate):', + input.baseResume.slice(0, 24_000), + '', + 'JOB DESCRIPTION:', + input.jdText.slice(0, 24_000), + '', + input.questions.length + ? `APPLICATION QUESTIONS:\n${input.questions.map((q, i) => `${i + 1}. ${q}`).join('\n')}` + : 'APPLICATION QUESTIONS: (none)', + '', + 'Produce the JSON now.', + ].join('\n'); + + const res = await openai().responses.create({ + model: model('tailor'), + ...reasoningParam('tailor'), + input: [ + { role: 'system', content: SYSTEM }, + { role: 'user', content: user }, + ], + text: { format: { type: 'json_object' } }, + }); + + const raw = JSON.parse(res.output_text) as Partial & { answers?: unknown }; + const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : ''); + + const answers: ApplicationAnswer[] = (Array.isArray(raw.answers) ? (raw.answers as unknown[]) : []) + .map((a) => { + const o = (a ?? {}) as Record; + return { question: str(o.question), answer: str(o.answer) }; + }) + .filter((a) => a.question && a.answer); + + const tailoredResume = str(raw.tailoredResume); + if (!tailoredResume) throw new Error('Tailoring failed — the model returned no resume.'); + + return { + candidateName: str(raw.candidateName), + jobTitle: str(raw.jobTitle), + company: str(raw.company), + tailoredResume, + answers, + }; +} diff --git a/src/main/services/rag/indexProfile.ts b/src/main/services/rag/indexProfile.ts index 1253e96..4d376e4 100644 --- a/src/main/services/rag/indexProfile.ts +++ b/src/main/services/rag/indexProfile.ts @@ -7,6 +7,7 @@ import { vectorToBuffer } from './vectorMath'; import { model } from '../openai/models'; import { profilesRepo } from '../../db/repositories/profiles.repo'; import { storiesRepo, storyInsertValues } from '../../db/repositories/stories.repo'; +import { applicationsRepo } from '../../db/repositories/applications.repo'; import { apiKeyStore } from '../security/apiKey'; import type { Story, StoryDraft, StoryInput } from '@shared/types'; @@ -88,9 +89,15 @@ export async function indexJob(jobId: string): Promise<{ chunks: number; embedde db().delete(schema.chunks).where(eq(schema.chunks.jobId, jobId)).run(); if (!apiKeyStore.isPresent()) return { chunks: 0, embedded: 0 }; - const sources: { type: 'jd' | 'company'; text: string }[] = []; + const sources: { type: 'jd' | 'company' | 'tailored'; text: string }[] = []; if (job.jdText) sources.push({ type: 'jd', text: job.jdText }); if (job.companyResearch) sources.push({ type: 'company', text: job.companyResearch }); + // An application-owned job also indexes its TAILORED resume (job-scoped), which + // replaces the base resume in retrieval for this job's sessions (see vectorStore). + // Indexed here — inside indexJob's single clear-and-reinsert pass — so jd/company/ + // tailored chunks never wipe each other. + const app = applicationsRepo.getByJobId(jobId); + if (app?.tailoredResume) sources.push({ type: 'tailored', text: app.tailoredResume }); if (sources.length === 0) return { chunks: 0, embedded: 0 }; const rows: { id: string; content: string }[] = []; diff --git a/src/main/services/rag/vectorStore.ts b/src/main/services/rag/vectorStore.ts index 97e8f80..31af79e 100644 --- a/src/main/services/rag/vectorStore.ts +++ b/src/main/services/rag/vectorStore.ts @@ -56,12 +56,21 @@ export const sqliteVectorStore: VectorStore = { .from(schema.chunks) .innerJoin(schema.embeddings, eq(schema.embeddings.chunkId, schema.chunks.id)) .where(eq(schema.chunks.profileId, profileId)) - .all() - // Always include base chunks (resume/notes, jobId null); include JD chunks - // only for the selected job. - .filter((r) => r.jobId == null || r.jobId === jobId); + .all(); + + // An application job carries `tailored` chunks (the resume rewritten FOR that + // job) — when present, they REPLACE the base resume for this job's retrieval, + // so the session grounds in the tailored resume. Notes/stories still apply. + const hasTailored = rows.some((r) => r.jobId === jobId && r.sourceType === 'tailored'); + const filtered = rows.filter( + (r) => + // Base chunks (resume/notes/stories, jobId null) + the selected job's chunks… + (r.jobId == null || r.jobId === jobId) && + // …minus the base resume when this job has a tailored one. + !(hasTailored && r.jobId == null && r.sourceType === 'resume'), + ); - return rows + return filtered .map((r) => ({ id: r.id, sourceType: r.sourceType as ChunkSource, diff --git a/src/preload/index.ts b/src/preload/index.ts index 023da06..6f2ed4e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,7 +1,13 @@ import { contextBridge, ipcRenderer } from 'electron'; import { EVENTS, IPC } from '@shared/ipc'; import type { AnswerPrefs, ClientInfo, SavePrompt, UpdateStatus } from '@shared/ipc'; -import type { InterviewBrief, SparringFeedback, Story } from '@shared/types'; +import type { + Application, + ApplicationListItem, + InterviewBrief, + SparringFeedback, + Story, +} from '@shared/types'; import type { Result } from '@shared/result'; /** invoke + unwrap the Result envelope so renderer code uses normal try/catch. */ @@ -109,6 +115,22 @@ const api = { brief: (id: string) => invoke(IPC.jobs.brief, { id }), delete: (id: string) => invoke(IPC.jobs.delete, { id }), }, + applications: { + page: (query: string, limit: number, offset: number) => + invoke<{ items: ApplicationListItem[]; total: number }>(IPC.applications.page, { + query, + limit, + offset, + }), + get: (id: string) => invoke(IPC.applications.get, { id }), + tailor: (input: { + profileId: string | null; + baseResumeText: string | null; + jdText: string; + questions: string[]; + }) => invoke<{ application: Application; embedded: number }>(IPC.applications.tailor, input), + delete: (id: string) => invoke<{ deleted: true }>(IPC.applications.delete, { id }), + }, notes: { list: (profileId: string) => invoke(IPC.notes.list, { profileId }), create: (profileId: string, content: string) => diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 566003d..7644c84 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -62,6 +62,12 @@ export const IPC = { create: 'notes:create', delete: 'notes:delete', }, + applications: { + page: 'applications:page', + get: 'applications:get', + tailor: 'applications:tailor', // the Tailor Resume op: LLM → profile/job/app rows + index + delete: 'applications:delete', + }, stories: { list: 'stories:list', generate: 'stories:generate', // extract STAR stories from the résumé (replaces all) diff --git a/src/shared/types.ts b/src/shared/types.ts index ef694f8..3a5a3e6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -20,7 +20,9 @@ export type InterviewType = export type AnswerFormat = 'key_points' | 'explanation' | 'detailed' | 'story_teller'; export type DocumentKind = 'resume' | 'jd' | 'note' | 'other'; -export type ChunkSource = 'resume' | 'jd' | 'note' | 'company' | 'story'; +/** `tailored` = an application's tailored resume, indexed job-scoped; when a job has + * tailored chunks, retrieval drops the base `resume` chunks for that job's sessions. */ +export type ChunkSource = 'resume' | 'jd' | 'note' | 'company' | 'story' | 'tailored'; export type SessionStatus = 'idle' | 'live' | 'stopped'; export type Speaker = 'interviewer' | 'candidate' | 'unknown'; @@ -181,6 +183,35 @@ export interface Job { updatedAt: number; } +/** One application question + its grounded answer (Tailor Resume flow). */ +export interface ApplicationAnswer { + question: string; + answer: string; +} + +/** A job application produced by the Tailor Resume flow: an ATS-friendly resume + * tailored from a base resume × a JD (grounded — no invented experience), plus + * answers to the application's questions. Owns a dedicated Job row holding the JD; + * the tailored resume is indexed as that job's `tailored` chunks, so "Start + * interview" grounds the live session in the TAILORED resume + JD. */ +export interface Application { + id: string; + profileId: string; // owning (real) profile — sessions/stories still belong to it + jobId: string; // dedicated job row (JD + tailored chunks); hidden from the Interviews table + name: string; // candidate/application name — shown as "[name] - [jobTitle] at [company]" + jobTitle: string; + company: string | null; + baseResume: string; // input snapshot (provenance) + tailoredResume: string; // markdown — the PDF source + `tailored` chunk source + answers: ApplicationAnswer[]; + createdAt: number; + updatedAt: number; +} + +export interface ApplicationListItem extends Application { + profileName: string | null; +} + export interface RetrievedChunk { id: string; sourceType: ChunkSource; From fca76927663a569dc0ce1ec677c8834d0ed5c0d9 Mon Sep 17 00:00:00 2001 From: tpikachu Date: Thu, 2 Jul 2026 07:31:04 -0500 Subject: [PATCH 2/6] feat: tailored-resume PDF export (v1.3 #2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Save any application's tailored resume as an ATS-friendly PDF. A deliberately minimal, dependency-free markdown→HTML converter (resumeHtml.ts — headings, bullets, bold, paragraphs; HTML-escaped first; single column, standard fonts, one inline style, no scripts) renders in a hidden ephemeral BrowserWindow via a data: URL, then webContents.printToPDF (Letter, custom inch margins, tagged PDF) and a native save dialog (sanitized "[name] - [title] at [company].pdf" default). The window is destroyed in a finally so it can never leak. applications:export-pdf IPC + preload; resumeHtml.test.ts (+5, incl. escaping). Verified: typecheck · 131 unit · build green. Co-Authored-By: Claude Fable 5 --- src/main/ipc/applications.ipc.ts | 9 +++ .../services/documents/resumeHtml.test.ts | 55 +++++++++++++ src/main/services/documents/resumeHtml.ts | 80 +++++++++++++++++++ src/main/services/documents/resumePdf.ts | 46 +++++++++++ src/preload/index.ts | 2 + src/shared/ipc.ts | 1 + 6 files changed, 193 insertions(+) create mode 100644 src/main/services/documents/resumeHtml.test.ts create mode 100644 src/main/services/documents/resumeHtml.ts create mode 100644 src/main/services/documents/resumePdf.ts diff --git a/src/main/ipc/applications.ipc.ts b/src/main/ipc/applications.ipc.ts index 8f31bbd..d02a08b 100644 --- a/src/main/ipc/applications.ipc.ts +++ b/src/main/ipc/applications.ipc.ts @@ -7,6 +7,7 @@ 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 { apiKeyStore } from '../services/security/apiKey'; export function registerApplicationsIpc(): void { @@ -113,6 +114,14 @@ export function registerApplicationsIpc(): void { }, ); + // 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/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('