diff --git a/docs/04-DATABASE.md b/docs/04-DATABASE.md index 12707d9..8b57cab 100644 --- a/docs/04-DATABASE.md +++ b/docs/04-DATABASE.md @@ -11,7 +11,8 @@ them for cosine search. profiles 1───* documents 1───* chunks ──* embeddings │ (1:1 chunk:embedding) ├──* notes - ├──* jobs ────* chunks (JD + company-research chunks carry job_id; resume/note chunks have job_id null) + ├──* 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) │ └──── sessions (a session optionally references the job it's for) └──* sessions 1──* transcript_chunks 1──* detected_questions 1──* ai_answers @@ -67,12 +68,20 @@ Uploaded file metadata + parsed text. Freeform additional notes attached to a profile. | id | profile_id FK | content | created_at | +### `stories` +Reusable STAR stories extracted from the résumé, tagged by competency + skills. +Profile-level (reused across every interview); also indexed as `story` chunks so +they can ground live answers. +| id | profile_id FK | title | situation | task | action | result | competencies (json[]) | skills (json[]) | created_at | updated_at | + ### `chunks` -Chunked text from documents/notes/profile fields for RAG. -| id | profile_id FK | job_id FK (nullable) | source_type (resume/jd/note/company) | source_id | ord | content | token_count | created_at | +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 | `job_id` is set on JD **and** company-research chunks (both cascade on job -delete); resume/note chunks have `job_id` null. +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. ### `embeddings` | id | chunk_id FK (unique) | model | dim | vector BLOB | created_at | @@ -106,14 +115,14 @@ Known keys: - `tour_done` — `'1'` once the first-run guided tour is completed/skipped. ## Deletion semantics -Deleting a profile cascades to its documents, notes, jobs, chunks, embeddings, -sessions, and everything under sessions (FK `on delete cascade`). Deleting a job +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. ## Indexes -- `chunks(profile_id)`, `jobs(profile_id)`, `embeddings(chunk_id)`, - `transcript_chunks(session_id)`, `detected_questions(session_id)`, - `ai_answers(question_id)`, `sessions(profile_id)`, `documents(profile_id)`, - `notes(profile_id)`. +- `chunks(profile_id)`, `jobs(profile_id)`, `stories(profile_id)`, + `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 2472a43..b1cb937 100644 --- a/docs/05-IPC-MAP.md +++ b/docs/05-IPC-MAP.md @@ -81,6 +81,7 @@ independently. | `jobs:get` | `{ id }` | `Job` | | `jobs:save` | `{ id?, profileId, title, company, jdUrl, jdText, companyUrl, notes }` | `{ job, keyMissing, embedded, companyResearched, companyError }` (create or update; parses JD + indexes when a key exists. `jdUrl` is reference-only. If `companyUrl` is set, best-effort scrapes + parses the company site into `parsed_company` and indexes it as `company` chunks; failures surface in `companyError`, not as an error) | | `jobs:set-notes` | `{ id, notes }` | `{ job }` (updates the free-form client notes) | +| `jobs:brief` | `{ id }` | `InterviewBrief` (grounded pre-interview prep brief from the profile's parsed résumé × the job's parsed JD × parsed company research — likely questions, coverage gaps, strengths, company angles. Not persisted; regenerated on demand. Throws a guidance error if the key, parsed résumé, or parsed JD is missing) | | `jobs:delete` | `{ id }` | `{ deleted: true }` | ### notes @@ -90,6 +91,16 @@ independently. | `notes:create` | `{ profileId, content }` | `Note` | | `notes:delete` | `{ id }` | `{ deleted: true }` | +### 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. +| Channel | Request | Response | +|---|---|---| +| `stories:list` | `{ profileId }` | `Story[]` | +| `stories:generate` | `{ profileId }` | `Story[]` (extract grounded STAR stories from the résumé; **embeds first, then atomically replaces** rows + chunks + embeddings — a failed embedding or empty extraction leaves the prior bank intact. Throws without a key / parsed résumé) | +| `stories:update` | `{ id, patch: { title?, situation?, task?, action?, result? } }` | `Story` (edit one story's text; re-indexes) | +| `stories:delete` | `{ id }` | `{ deleted: true }` (re-indexes) | + ### session | Channel | Request | Response | |---|---|---| @@ -126,6 +137,17 @@ Runs as a non-persisted live session (`isMock`) that's deleted on end — never | `mock:next` | `{ sessionId }` | `{ done, question?, audioBase64?, index, total }` (next question — spoken + answered in the Cue Card) | | `mock:end` | `{ sessionId }` | `{ ended }` (stops + deletes the mock session) | +### sparring (two-way voice mock) +A back-and-forth voice drill: the AI asks aloud, the candidate answers by speaking +(push-to-talk), and each answer is coached. State is in-memory only (ephemeral — nothing +persisted; no DB session, no Cue Card). +| Channel | Request | Response | +|---|---|---| +| `sparring:start` | `{ profileId, voice, jobId, interviewType }` | `{ sessionId, question, audioBase64, index, total }` (asks Q1 aloud) | +| `sparring:answer` | `{ sessionId, audioBase64, mime }` | `{ transcript, feedback }` (transcribes the recorded clip + returns `SparringFeedback`) | +| `sparring:next` | `{ sessionId }` | `{ done, question?, audioBase64?, index, total }` (history-aware follow-up, spoken) | +| `sparring:end` | `{ sessionId }` | `{ ended }` (clears the in-memory session) | + ### capture / coding | Channel | Request | Response | |---|---|---| diff --git a/docs/06-OPENAI-SERVICE.md b/docs/06-OPENAI-SERVICE.md index c15c1df..20b39dd 100644 --- a/docs/06-OPENAI-SERVICE.md +++ b/docs/06-OPENAI-SERVICE.md @@ -55,6 +55,37 @@ Uses Responses API with a JSON instruction to return typed JSON (defensively def from text scraped off the company website (see `services/documents/companyResearch.ts`), used to tailor answers to the company. +### brief.ts — `generateBrief(input) => InterviewBrief` +Powers the **Pre-Interview Brief**. Input is the candidate's parsed résumé, the job's +parsed JD, and (optionally) parsed company research. One Responses call (`parsing` model, +`json_object`) returns a grounded study brief — `summary`, ranked `likelyQuestions` +(`{question, why}`), `gaps` (`{requirement, coverage: strong|partial|missing, howToAddress}`), +`strengths` (`{point, evidence}`), and `companyAngles`. Output is defensively defaulted and +coverage is normalized, so a malformed response can't crash callers. The system prompt +forbids inventing experience/employers/metrics/company facts — thin data yields fewer items, +not fabrication. The `jobs:brief` handler gathers résumé+JD+company from the repos and guards +on key/résumé/JD presence; the brief is returned (not persisted) and shown in the dashboard's +`BriefModal`. + +### stories.ts — `generateStories(input) => StoryDraft[]` +Powers the **STAR Story Bank**. From the candidate's parsed résumé (+ raw text), one +Responses call (`parsing` model, `json_object`) extracts 4–8 reusable STAR stories, each +tagged with 1–3 competencies from a **closed set** (`COMPETENCIES`, kept in sync with the +`StoryCompetency` union) plus demonstrated skills. Output is defensively parsed: competencies +are clamped to the closed set, non-string skills dropped, and stories missing +title/situation/action/result are filtered out (so a degenerate response yields fewer/zero +stories, never a crash). The system prompt forbids inventing employers/projects/metrics. +The `stories:generate` handler bails if extraction is empty, then `replaceStories` +(`rag/indexProfile.ts`) **embeds first and commits rows + `story` chunks + embeddings in one +transaction** — a failed embedding leaves the prior bank intact. `indexStories` re-embeds on +edit/delete with the same embed-before-mutate guarantee. Stories surface live as `📖 story` +source chips via the normal retriever (they're just `story` chunks). **Story-to-tell cue:** +`retrieve` (rag/retriever.ts) embeds the question once and, alongside the top-k, force-includes +the single best-matching `story` chunk when its score ≥ `STORY_CUE_MIN_SCORE` (`@shared/types`) — +so it grounds the answer, stays citable, and the Cue Card surfaces it as a prominent +**"📖 Story to tell"** callout (`StoryCue` in Overlay.tsx, derived from the `contextSent` chunks — +no extra IPC event or embedding call). + ### embeddings.ts — `embed(texts: string[]) => Float32Array[]` Batches inputs, returns vectors; caller stores BLOBs. Records model + dim. @@ -68,6 +99,13 @@ Builds a **grounding** prompt: - System: persona + rules ("ground answers in provided context; never invent experience; if no relevant experience, give a transferable-skills answer and set a risk warning"); LENGTH is a hard constraint. +- **Grounded / proof-linked answers:** `buildContext` numbers the chunks `[1] (resume) …`; + the prompt makes the model cite those numbers inline after each grounded claim + (e.g. `…cut p99 latency 40% [1]`). The Cue Card renders the cited `[i]` as source chips + (the `Citations` component, backed by the `contextSent` chunks). **Fabrication guard:** + for anything the context can't support the model must not invent it — it leads with + `⚠`, says it's not in the candidate's background, and pivots to a cited transferable + framing. - User: question + retrieved context + profile summary + the chosen format/length, plus optional pronunciation hints for rare/technical terms. - `length` (`key_points` | `detailed`) also sets a hard `max_output_tokens` ceiling @@ -99,6 +137,21 @@ them — the buffer + thumbnail strip live in `capture/codingMode.ts` (see `capt Power the mock-interview mode: `generateQuestion` produces the next question and per-answer feedback; `speak` renders the interviewer's voice (returns audio Buffer). +### feedback.ts — `evaluateAnswer(input) => SparringFeedback` +Powers **Sparring** (the two-way voice mock). Given the question, the candidate's +transcribed spoken answer, and their résumé/JD context, one Responses call +(`mock` model, `json_object`) returns coaching feedback — `verdict`, a 1–5 `rating`, +`strengths[]`, `improvements[]`, and one actionable `tip` (ideally naming a real résumé +item they could have used). Output is defensively parsed: the rating is rounded + clamped +to 1–5, arrays are string-filtered, and missing fields default — a malformed model reply +can't crash the turn loop. The prompt judges ONLY what the candidate actually said and +forbids inventing experience. + +The `sparringManager` (in-memory, no DB) drives the turns: +`generateQuestion` + `speak` to ask, `transcribeChunk` on the recorded clip, then +`evaluateAnswer`; the question is committed to history only after TTS succeeds so a +transient failure can't skip a turn. + ## Cross-cutting - **Cost/usage**: each call returns token usage; persisted on `ai_answers.tokens` and surfaced in the UI ("what was sent to OpenAI"). diff --git a/docs/sessions/2026-06-30.md b/docs/sessions/2026-06-30.md new file mode 100644 index 0000000..2e02499 --- /dev/null +++ b/docs/sessions/2026-06-30.md @@ -0,0 +1,150 @@ +# 2026-06-30 + +Continuing **Path A** (turn the read-along copilot into a prep + skill-building tool). +#1 Grounded Answers shipped earlier (`622394f`: inline `[i]` citations + fabrication +guard + Cue Card source chips). This entry covers **#2 Pre-Interview Brief**. + +## Pre-Interview Brief + +A grounded prep brief generated *before* the call — a résumé × JD × company gap analysis +the candidate can study, rather than relying on live cues alone. Reuses the existing +parsed structures (no re-parse, no new ingestion). + +**What it produces** (`InterviewBrief` in `@shared/types`): +- `summary` — 1–2 sentences framing what this interview will probe. +- `likelyQuestions[]` `{question, why}` — ranked, most-probable first, each tied to a + JD requirement or résumé item. +- `gaps[]` `{requirement, coverage: strong|partial|missing, howToAddress}` — JD asks with + thin résumé coverage + a concrete line on how to bridge each. +- `strengths[]` `{point, evidence}` — résumé highlights that map strongly to the JD. +- `companyAngles[]` — ways to tailor answers to this company (from company research). + +**Flow:** +- `services/openai/brief.ts` `generateBrief({targetRole, company, resume, jd, companyResearch})` + → one Responses call (`parsing` model, `json_object`) → defensively-defaulted brief. + Mirrors `parsing.ts`: coverage normalized, malformed items dropped, arrays default to + `[]`. System prompt forbids inventing experience/metrics/company facts. +- IPC `jobs:brief` `{ id }` (in `jobs.ipc.ts`) gathers the profile + job from the repos, + guards on key / parsed résumé / parsed JD (throws an instructive error otherwise), and + returns the brief. **Not persisted** — regenerated on demand (v1; persistence is a + possible fast-follow). +- Dashboard: a **Brief** button per interview row on the Interview page (disabled with a + tooltip when the job has no parsed JD) opens `BriefModal`, which generates on open and + renders the four sections with a Regenerate action. Added a `red` tone to the shared + `Badge` so "missing" gaps read stronger than "partial". + +**Tests:** `brief.test.ts` (+8) — model/JSON-format request, grounding-only system prompt, +résumé/JD/company fed into the prompt, defensive parsing (coverage normalization, empty +defaults, malformed-item filtering, null company research). Suite 79 → **87**. + +Verified: `typecheck` · 87 unit · `build` all green. Branch `feat/grounded-answers`. + +## STAR Story Bank (#3) + +A per-profile bank of reusable STAR stories, extracted from the résumé, tagged by +competency, persisted, and — the payoff — indexed as `story` chunks so they ground +LIVE answers through the existing retriever (rendered as `📖 story` source chips). + +Designed via a 5-agent survey workflow (parsing source, RAG/embeddings, schema/migration, +live-session hook, profile-UI patterns); the survey's "Option A — index stories as a new +chunk sourceType and reuse the retriever" was the clear winner (zero new retrieval code, no +hot-path latency, natural citations). + +**What shipped:** +- New `stories` table (migration `0005`, profile-cascade FK + index) + `storiesRepo`. +- `services/openai/stories.ts` `generateStories()` — grounded extraction, **closed + competency set** (no tag pollution), defensive parsing. Mirrors `parsing.ts`/`brief.ts`. +- `rag/indexProfile.ts`: `indexStories` (one `story` chunk per story) + `replaceStories` + (atomic regenerate). `reindexProfile` now **excludes** `sourceType='story'` so re-saving + a résumé doesn't wipe the curated bank. +- IPC `stories:{list,generate,update,delete}` + preload + `Story`/`StoryDraft`/ + `StoryCompetency` types; `ChunkSource += 'story'`; Cue Card `Citations` labels story chips. +- UI: a **Story Bank** card on the profile editor → `StoryBankModal` (generate-on-open, + browse/expand, edit STAR text, delete, regenerate-with-confirm). `red` `Badge` reused. +- Tests: `stories.test.ts` (+8) — request shape, grounding prompt, competency clamp, + incomplete-story filtering, empty/garbage handling. Suite 87 → **95**. + +**Adversarial review** (4-dimension find → refute-verify workflow) surfaced 2 confirmed +mediums, both in the destructive regenerate path — and correctly **refuted** the +cascade/migration worries (embeddings→chunks cascade has existed since `0000`): +1. *Empty extraction wiped the bank* — `generateStories` can return `[]` on a 200 (thin + résumé / `{stories:[]}`); the old `replaceAll([])` deleted everything silently. +2. *Embed failure after replace → persisted-but-ungrounded* — rows were replaced, then if + embedding threw the new chunks were committed without embeddings (excluded from search), + and the user saw a generic "failed" message while the old bank was already gone. + +**Fix (both):** `generate` now throws on empty drafts, and `replaceStories` **embeds first, +then commits rows + chunks + embeddings in a single transaction** — a failed embed mutates +nothing, so the prior bank stays fully intact. `indexStories` got the same embed-before-mutate +guarantee for the edit/delete paths. + +Verified: `typecheck` · 95 unit · `build` green. Branch `feat/grounded-answers`. + +## Sparring — two-way voice mock (#4) + +A back-and-forth voice drill: the AI interviewer asks aloud (TTS), the candidate answers +by **speaking** (push-to-talk → record clip → `transcribeChunk`), and each answer gets +coached. Distinct from Mock (which streams a model answer to the Cue Card to read along). + +Designed via a 4-agent survey of the existing mock mode + TTS + mic/STT + turn-state. Key +calls that kept it low-risk: +- **Discrete push-to-talk → one `transcribeChunk` per answer**, not the Realtime socket — + sidesteps the socket-lifecycle + VAD-boundary risks. Reuses the existing `useAnswerRecorder`. +- **Push-to-talk inherently solves half-duplex** — the mic only records after TTS playback + ends; they never overlap. +- **No Cue Card, no DB session** — Sparring's output is the *feedback*, so `sparringManager` + is pure in-memory orchestration (generateQuestion + speak + transcribeChunk + evaluateAnswer). + No migration, no persistence plumbing. + +**What shipped:** +- `services/openai/feedback.ts` `evaluateAnswer` → `SparringFeedback` {verdict, rating 1–5, + strengths[], improvements[], tip}. Grounded, defensive (rating clamp, array filter). +- `services/mock/sparringManager.ts` — in-memory start/answer/next/end (single active round). +- IPC `sparring.{start,answer,next,end}` + preload + `SparringFeedback` type. +- `SparringPage` + a `Sparring` nav item: setup → ask aloud → **hold-to-talk** button + (gated until TTS ends) → transcript + rated feedback → Next → recap. Reuses MockPage's + audio-playback + setup patterns. +- `feedback.test.ts` (+7). Suite 95 → **102**. + +**Adversarial review** (4-dimension find → refute-verify) surfaced **6 real findings**, all +in the renderer audio/turn plumbing (the service/IPC dimension was clean): +1. *(high)* Quick tap during `getUserMedia` leaked a live mic stream (orphaned MediaRecorder). +2. *(med)* `ask()` pushed the question to history **before** TTS → a transient `speak()` 429 + permanently consumed a turn slot (skipped questions, early "done", polluted context). +3. *(med)* Fast tap (release before `start()` resolves) stranded the UI in 'recording' with a hot mic. +4. *(low)* Space-key PTT released after focus left the button never fired keyup → stuck recording. +5/6. *(low)* A playback error mid-stream left `audioPlaying=true`, permanently disabling the answer button. + +**Fixes:** synchronous `recordingRef` gate (immune to stale-closure `phase`) + `useAnswerRecorder` +now reconciles a stop that races an in-flight `start()` (releases the just-granted stream) + +`endRecord` always calls `stop()`; a window-blur/visibility backstop releases a stranded +Space-hold; `finish()` stops the recorder; an `onerror` handler clears the audio gate; and +`ask()` commits to history only **after** TTS succeeds. + +Verified: `typecheck` · 102 unit · `build` green. Branch `feat/grounded-answers`. + +## Story-to-tell live cue (Story Bank polish) + +Made the Story Bank pay off in the live interview: the best-matching STAR story for the +current question now surfaces as a prominent **"📖 Story to tell"** callout in the Cue Card +(expandable to the full STAR), distinct from the inline `📖 story` citation chips. + +- `rag/retriever.ts` `retrieve` embeds the question ONCE and reuses the vector for a new + `vectorStore.topStory` lookup; a story scoring ≥ `STORY_CUE_MIN_SCORE` (0.3, `@shared/types`, + tunable) is force-included in the grounding chunks (even if outside the top-k) — so it's sent + to OpenAI, citable, and rides the existing `contextSent` payload. No extra embedding call, no + new IPC event. +- `Overlay.tsx` `StoryCue` derives the cue from `card.context.chunks` (highest-scoring `story` + chunk ≥ threshold). Zero backend↔overlay plumbing beyond the shared threshold const. +- No regression when there are no stories / all below threshold (retrieve is unchanged). + +Reviewed inline (the review-workflow subagents were rate-limited): checked topStory query + +empty-bank null + cosine epsilon; retrieve embed-once + dedup + length-agnostic downstream +(buildContext/contextSent); StoryCue null-safety + collapsed-card hiding + split() edge cases. +Verified: `typecheck` · 102 unit · `build` green. Branch `feat/grounded-answers`. + +## Next +- Path A #1–#4 done (Grounded Answers, Pre-Interview Brief, STAR Story Bank, Sparring) + + the live Story-to-tell cue. +- Optional: persist Sparring runs into Reports; competency-coverage view on the profile; + re-run the adversarial review on this cue once the agent rate-limit resets. diff --git a/drizzle/0005_wealthy_rhino.sql b/drizzle/0005_wealthy_rhino.sql new file mode 100644 index 0000000..a8b74fb --- /dev/null +++ b/drizzle/0005_wealthy_rhino.sql @@ -0,0 +1,16 @@ +CREATE TABLE `stories` ( + `id` text PRIMARY KEY NOT NULL, + `profile_id` text NOT NULL, + `title` text DEFAULT '' NOT NULL, + `situation` text DEFAULT '' NOT NULL, + `task` text DEFAULT '' NOT NULL, + `action` text DEFAULT '' NOT NULL, + `result` text DEFAULT '' NOT NULL, + `competencies` text, + `skills` 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 +); +--> statement-breakpoint +CREATE INDEX `stories_profile_idx` ON `stories` (`profile_id`); \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..3cb8d13 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1223 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "10d8736e-41b3-4d27-8c0e-d2b00327aae1", + "prevId": "9d756a48-b4c4-4cb8-a18f-68b8aa924501", + "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": {} + }, + "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 f91e616..cd8453d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1782219328941, "tag": "0004_fantastic_caretaker", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1782843181685, + "tag": "0005_wealthy_rhino", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/main/db/repositories/stories.repo.ts b/src/main/db/repositories/stories.repo.ts new file mode 100644 index 0000000..28de2d5 --- /dev/null +++ b/src/main/db/repositories/stories.repo.ts @@ -0,0 +1,80 @@ +import { desc, eq } from 'drizzle-orm'; +import { db, schema } from '../index'; +import type { Story, StoryCompetency, StoryInput } from '@shared/types'; + +type Row = typeof schema.stories.$inferSelect; + +function toStory(r: Row): Story { + return { + id: r.id, + profileId: r.profileId, + title: r.title, + situation: r.situation, + task: r.task, + action: r.action, + result: r.result, + competencies: r.competencies ? (JSON.parse(r.competencies) as StoryCompetency[]) : [], + skills: r.skills ? (JSON.parse(r.skills) as string[]) : [], + createdAt: r.createdAt, + updatedAt: r.updatedAt, + }; +} + +/** Column values for inserting a story row. Exported so an atomic regenerate + * (stories + chunks + embeddings in one transaction) can reuse the mapping. */ +export function storyInsertValues(id: string, input: StoryInput) { + return { + id, + profileId: input.profileId, + title: input.title, + situation: input.situation, + task: input.task, + action: input.action, + result: input.result, + competencies: JSON.stringify(input.competencies), + skills: JSON.stringify(input.skills), + }; +} + +export const storiesRepo = { + list(profileId: string): Story[] { + return db() + .select() + .from(schema.stories) + .where(eq(schema.stories.profileId, profileId)) + .orderBy(desc(schema.stories.updatedAt)) + .all() + .map(toStory); + }, + + get(id: string): Story | null { + const r = db().select().from(schema.stories).where(eq(schema.stories.id, id)).get(); + return r ? toStory(r) : null; + }, + + create(input: StoryInput): Story { + const id = crypto.randomUUID(); + db().insert(schema.stories).values(storyInsertValues(id, input)).run(); + return this.get(id)!; + }, + + update(id: string, patch: Partial): Story { + const set: Record = { updatedAt: Date.now() }; + const map: Record = { + title: patch.title, + situation: patch.situation, + task: patch.task, + action: patch.action, + result: patch.result, + }; + for (const [k, v] of Object.entries(map)) if (v !== undefined) set[k] = v; + if (patch.competencies !== undefined) set.competencies = JSON.stringify(patch.competencies); + if (patch.skills !== undefined) set.skills = JSON.stringify(patch.skills); + db().update(schema.stories).set(set).where(eq(schema.stories.id, id)).run(); + return this.get(id)!; + }, + + delete(id: string): void { + db().delete(schema.stories).where(eq(schema.stories.id, id)).run(); + }, +}; diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index ec53a45..db47571 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -73,6 +73,29 @@ export const jobs = sqliteTable( (t) => ({ byProfile: index('jobs_profile_idx').on(t.profileId) }), ); +// Reusable STAR stories extracted from the candidate's résumé, tagged by +// competency + demonstrated skills. Profile-level (reused across all interviews); +// also indexed as `story` chunks so they can ground live answers. +export const stories = sqliteTable( + 'stories', + { + id: text('id').primaryKey(), + profileId: text('profile_id') + .notNull() + .references(() => profiles.id, { onDelete: 'cascade' }), + title: text('title').notNull().default(''), + situation: text('situation').notNull().default(''), + task: text('task').notNull().default(''), + action: text('action').notNull().default(''), + result: text('result').notNull().default(''), + competencies: text('competencies'), // json[] — closed competency set + skills: text('skills'), // json[] + createdAt: integer('created_at').notNull().default(now), + updatedAt: integer('updated_at').notNull().default(now), + }, + (t) => ({ byProfile: index('stories_profile_idx').on(t.profileId) }), +); + export const chunks = sqliteTable( 'chunks', { @@ -80,9 +103,9 @@ export const chunks = sqliteTable( profileId: text('profile_id') .notNull() .references(() => profiles.id, { onDelete: 'cascade' }), - // Resume/notes chunks have jobId null; JD chunks belong to a specific job. + // Resume/notes/story chunks have jobId null; JD chunks belong to a specific job. jobId: text('job_id').references(() => jobs.id, { onDelete: 'cascade' }), - sourceType: text('source_type').notNull(), // resume | jd | note + sourceType: text('source_type').notNull(), // resume | jd | note | company | story sourceId: text('source_id'), ord: integer('ord').notNull().default(0), content: text('content').notNull(), diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index ec971da..67c6f17 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -4,8 +4,10 @@ import { registerProfilesIpc } from './profiles.ipc'; import { registerDocumentsIpc } from './documents.ipc'; import { registerJobsIpc } from './jobs.ipc'; import { registerNotesIpc } from './notes.ipc'; +import { registerStoriesIpc } from './stories.ipc'; import { registerSessionIpc } from './session.ipc'; import { registerMockIpc } from './mock.ipc'; +import { registerSparringIpc } from './sparring.ipc'; import { registerCaptureIpc } from './capture.ipc'; import { registerOverlayIpc } from './overlay.ipc'; import { registerWindowIpc } from './window.ipc'; @@ -21,8 +23,10 @@ export function registerIpc(): void { registerDocumentsIpc(); registerJobsIpc(); registerNotesIpc(); + registerStoriesIpc(); registerSessionIpc(); registerMockIpc(); + registerSparringIpc(); registerCaptureIpc(); registerOverlayIpc(); registerWindowIpc(); diff --git a/src/main/ipc/jobs.ipc.ts b/src/main/ipc/jobs.ipc.ts index ebe2c4c..79da07d 100644 --- a/src/main/ipc/jobs.ipc.ts +++ b/src/main/ipc/jobs.ipc.ts @@ -2,7 +2,9 @@ import { z } from 'zod'; import { IPC } from '@shared/ipc'; import { handle, zId } from './helpers'; import { jobsRepo } from '../db/repositories/jobs.repo'; +import { profilesRepo } from '../db/repositories/profiles.repo'; import { parseCompany, parseJobDescription } from '../services/openai/parsing'; +import { generateBrief } from '../services/openai/brief'; import { fetchCompanySite } from '../services/documents/companyResearch'; import { indexJob } from '../services/rag/indexProfile'; import { apiKeyStore } from '../services/security/apiKey'; @@ -99,6 +101,29 @@ export function registerJobsIpc(): void { ({ id, notes }) => jobsRepo.update(id, { notes }), ); + // Generate a grounded pre-interview brief from the profile's résumé × the job's + // JD × any company research. Reuses the parsed structures (no re-parse); returns + // the brief to the renderer (not persisted — it's regenerated on demand). + handle(IPC.jobs.brief, zId, async ({ id }) => { + const job = jobsRepo.get(id); + if (!job) throw new Error('Interview not found.'); + if (!apiKeyStore.isPresent()) + throw new Error('Add your OpenAI API key in Settings to generate a brief.'); + if (!job.parsedJd) + throw new Error('This interview needs a parsed job description first — add a JD in Detail.'); + const profile = profilesRepo.get(job.profileId); + if (!profile?.parsedResume) + throw new Error('This profile needs a parsed résumé first — add & parse one on the profile.'); + + return generateBrief({ + targetRole: profile.targetRole, + company: job.company, + resume: profile.parsedResume, + jd: job.parsedJd, + companyResearch: job.parsedCompany, + }); + }); + handle(IPC.jobs.delete, zId, ({ id }) => { jobsRepo.delete(id); return { deleted: true as const }; diff --git a/src/main/ipc/sparring.ipc.ts b/src/main/ipc/sparring.ipc.ts new file mode 100644 index 0000000..649132c --- /dev/null +++ b/src/main/ipc/sparring.ipc.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; +import { IPC } from '@shared/ipc'; +import { handle } from './helpers'; +import { sparringManager } from '../services/mock/sparringManager'; + +const voice = z.enum(['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']).default('alloy'); +const interviewType = z + .enum(['behavioral', 'technical', 'coding', 'system_design', 'product', 'sales', 'general']) + .default('general'); + +export function registerSparringIpc(): void { + handle( + IPC.sparring.start, + z.object({ + profileId: z.string().min(1), + voice, + jobId: z.string().nullable().default(null), + interviewType, + }), + ({ profileId, voice: v, jobId, interviewType: t }) => + sparringManager.start(profileId, v, jobId, t), + ); + + handle( + IPC.sparring.answer, + z.object({ + sessionId: z.string().min(1), + audioBase64: z.string().min(1), + mime: z.string().default('audio/webm'), + }), + ({ sessionId, audioBase64, mime }) => sparringManager.answer(sessionId, audioBase64, mime), + ); + + handle(IPC.sparring.next, z.object({ sessionId: z.string().min(1) }), ({ sessionId }) => + sparringManager.next(sessionId), + ); + + handle(IPC.sparring.end, z.object({ sessionId: z.string().min(1) }), ({ sessionId }) => { + sparringManager.end(sessionId); + return { ended: true as const }; + }); +} diff --git a/src/main/ipc/stories.ipc.ts b/src/main/ipc/stories.ipc.ts new file mode 100644 index 0000000..e822284 --- /dev/null +++ b/src/main/ipc/stories.ipc.ts @@ -0,0 +1,70 @@ +import { z } from 'zod'; +import { IPC } from '@shared/ipc'; +import { handle, zId } from './helpers'; +import { storiesRepo } from '../db/repositories/stories.repo'; +import { profilesRepo } from '../db/repositories/profiles.repo'; +import { generateStories } from '../services/openai/stories'; +import { indexStories, replaceStories } from '../services/rag/indexProfile'; +import { apiKeyStore } from '../services/security/apiKey'; + +const zProfileId = z.object({ profileId: z.string().min(1) }); + +// Only the STAR text is user-editable; competencies/skills are set by generation. +const storyPatch = z.object({ + title: z.string().optional(), + situation: z.string().optional(), + task: z.string().optional(), + action: z.string().optional(), + result: z.string().optional(), +}); + +export function registerStoriesIpc(): void { + handle(IPC.stories.list, zProfileId, ({ profileId }) => storiesRepo.list(profileId)); + + // Extract grounded STAR stories from the profile's parsed résumé, atomically + // replace the existing bank, and (re)index them as `story` chunks for live grounding. + handle(IPC.stories.generate, zProfileId, async ({ profileId }) => { + const profile = profilesRepo.get(profileId); + if (!profile) throw new Error('Profile not found.'); + if (!apiKeyStore.isPresent()) + throw new Error('Add your OpenAI API key in Settings to generate stories.'); + if (!profile.parsedResume) + throw new Error('This profile needs a parsed résumé first — add & parse one on the profile.'); + + const drafts = await generateStories({ + targetRole: profile.targetRole, + resume: profile.parsedResume, + resumeText: profile.resumeText, + }); + // Don't destroy the existing bank for an empty/degenerate extraction. + if (drafts.length === 0) + throw new Error( + 'No usable stories could be extracted from this résumé — your existing stories were kept.', + ); + // Embeds first, then replaces rows + chunks + embeddings atomically, so a + // failed embedding leaves the prior bank intact. + return replaceStories(profileId, drafts); + }); + + handle( + IPC.stories.update, + z.object({ id: z.string().min(1), patch: storyPatch }), + async ({ id, patch }) => { + const existing = storiesRepo.get(id); + if (!existing) throw new Error('Story not found.'); + const story = storiesRepo.update(id, patch); + // Re-embed so live retrieval reflects the edited text. + await indexStories(existing.profileId); + return story; + }, + ); + + handle(IPC.stories.delete, zId, async ({ id }) => { + const existing = storiesRepo.get(id); + if (existing) { + storiesRepo.delete(id); + await indexStories(existing.profileId); + } + return { deleted: true as const }; + }); +} diff --git a/src/main/services/mock/sparringManager.ts b/src/main/services/mock/sparringManager.ts new file mode 100644 index 0000000..7395e08 --- /dev/null +++ b/src/main/services/mock/sparringManager.ts @@ -0,0 +1,106 @@ +import { profilesRepo } from '../../db/repositories/profiles.repo'; +import { jobsRepo } from '../../db/repositories/jobs.repo'; +import { generateQuestion, type QaTurn } from '../openai/interviewer'; +import { speak, type TtsVoice } from '../openai/tts'; +import { transcribeChunk } from '../openai/transcription'; +import { evaluateAnswer } from '../openai/feedback'; +import { apiKeyStore } from '../security/apiKey'; +import type { InterviewType, SparringFeedback } from '@shared/types'; + +const MAX_QUESTIONS = 6; + +interface SparringState { + id: string; + profileId: string; + jobId: string | null; + interviewType: InterviewType; + voice: TtsVoice; + history: QaTurn[]; // each turn: { q, a } — a is filled in once the candidate answers +} + +// One active Sparring round at a time (mirrors mockManager's single `mock`). Held +// in memory only — Sparring is an ephemeral drill, nothing is persisted. +let spar: SparringState | null = null; + +/** Generate + speak the interviewer's next question, recording it in history. */ +async function ask(): Promise<{ question: string; audioBase64: string }> { + if (!spar) throw new Error('No active sparring session.'); + const profile = profilesRepo.get(spar.profileId); + if (!profile) throw new Error('Profile not found.'); + const job = spar.jobId ? jobsRepo.get(spar.jobId) : null; + + const question = await generateQuestion(profile, spar.history, job, spar.interviewType); + // Only commit the question to history AFTER TTS succeeds — a transient speak() + // failure must not permanently consume a turn slot (which would skip questions, + // end the round early, and pollute follow-up context with an unanswered turn). + const audio = await speak(question, spar.voice); + spar.history.push({ q: question, a: '' }); + return { question, audioBase64: audio.toString('base64') }; +} + +/** Decode base64 audio (from the renderer) into an ArrayBuffer for transcription. */ +function decodeAudio(base64: string): ArrayBuffer { + const buf = Buffer.from(base64, 'base64'); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); +} + +export const sparringManager = { + /** Begin a two-way voice mock: ask the first question aloud. */ + async start( + profileId: string, + voice: TtsVoice, + jobId: string | null = null, + interviewType: InterviewType = 'general', + ) { + const profile = profilesRepo.get(profileId); + if (!profile) throw new Error('Profile not found.'); + if (!apiKeyStore.isPresent()) + throw new Error('Add your OpenAI API key in Settings to start a sparring session.'); + + spar = { id: crypto.randomUUID(), profileId, jobId, interviewType, voice, history: [] }; + const q = await ask(); + return { sessionId: spar.id, ...q, index: 1, total: MAX_QUESTIONS }; + }, + + /** Transcribe the candidate's spoken answer to the current question and return + * coaching feedback. Records the answer in history so the next question can + * follow up on it. */ + async answer( + sessionId: string, + audioBase64: string, + mime: string, + ): Promise<{ transcript: string; feedback: SparringFeedback }> { + if (!spar || spar.id !== sessionId) throw new Error('No active sparring session.'); + const current = spar.history[spar.history.length - 1]; + if (!current) throw new Error('No question to answer yet.'); + + const profile = profilesRepo.get(spar.profileId); + if (!profile) throw new Error('Profile not found.'); + const job = spar.jobId ? jobsRepo.get(spar.jobId) : null; + + const transcript = (await transcribeChunk(decodeAudio(audioBase64), mime)).trim(); + current.a = transcript; + const feedback = await evaluateAnswer({ + question: current.q, + answer: transcript, + profile, + job, + interviewType: spar.interviewType, + }); + return { transcript, feedback }; + }, + + /** Ask the next question (history-aware, so it can follow up) or signal done. */ + async next(sessionId: string) { + if (!spar || spar.id !== sessionId) throw new Error('No active sparring session.'); + if (spar.history.length >= MAX_QUESTIONS) { + return { done: true as const, index: spar.history.length, total: MAX_QUESTIONS }; + } + const q = await ask(); + return { done: false as const, ...q, index: spar.history.length, total: MAX_QUESTIONS }; + }, + + end(sessionId: string) { + if (spar?.id === sessionId) spar = null; + }, +}; diff --git a/src/main/services/openai/answer.test.ts b/src/main/services/openai/answer.test.ts index 9804ed4..bebd5bf 100644 --- a/src/main/services/openai/answer.test.ts +++ b/src/main/services/openai/answer.test.ts @@ -85,6 +85,19 @@ describe('streamAnswer — request body', () => { expect(userPrompt()).toContain('(resume) Fixed a race condition'); }); + it('numbers the context so answers can cite [i]', async () => { + await collect(streamAnswer(baseInput())); + expect(userPrompt()).toContain('[1] (resume) Fixed a race condition'); + }); + + it('instructs inline [i] citations + a fabrication guard (system prompt)', async () => { + await collect(streamAnswer(baseInput())); + const system = String((h.lastBody!.input as { role: string; content: string }[])[0].content); + expect(system).toMatch(/cite/i); + expect(system).toContain('[1]'); + expect(system).toMatch(/FABRICATION GUARD|⚠/); + }); + it('notes when there is NO matching context', async () => { await collect(streamAnswer(baseInput({ contextChunks: [] }))); expect(userPrompt()).toContain('no relevant profile context found'); diff --git a/src/main/services/openai/answer.ts b/src/main/services/openai/answer.ts index e1714b3..73b12e5 100644 --- a/src/main/services/openai/answer.ts +++ b/src/main/services/openai/answer.ts @@ -66,13 +66,15 @@ first-person voice ("I led…", not "The candidate led…"). Rules: - LENGTH is a HARD constraint. Obey the requested length EXACTLY — even if you have more to say. When unsure, be shorter. Never pad. (KEY POINTS especially must stay tiny.) -- Ground every answer ONLY in the provided context (resume, job description, notes, - company research), tagged by source, e.g. (resume), (jd), (company). -- Use (company) context to tailor answers to the company — but NEVER invent the - candidate's own experience, employers, projects, or metrics that aren't in the - resume/notes. No fabricated numbers. -- If the context lacks relevant experience, say so briefly (riskWarning) and offer a - safe transferable-skills framing instead of fabricating. +- CITE YOUR SOURCES. The CONTEXT items are NUMBERED [1], [2], …. Immediately after each + claim drawn from the context, cite its number(s) inline, e.g. "cut p99 latency ~40% [1]" + or "[2][3]". Cite only real context numbers; never invent a citation. +- Ground every SPECIFIC claim (employers, projects, metrics, dates) ONLY in the context. + Use (company) context to tailor — but NEVER invent the candidate's own experience or + numbers that aren't there. Generic best-practice statements need no citation. +- FABRICATION GUARD: if the context can't support what's asked, do NOT make it up. Begin + the answer with "⚠", state in one short clause that it's not in their background, then + pivot to a grounded, cited, transferable-skills framing (this is the riskWarning case). - Then follow the requested FORMAT and the interview type. - Formatting: lead with the single most important line; **bold** only true key terms; prefer short bullets over dense paragraphs; no meta-commentary or headers.`; diff --git a/src/main/services/openai/brief.test.ts b/src/main/services/openai/brief.test.ts new file mode 100644 index 0000000..59b7f7a --- /dev/null +++ b/src/main/services/openai/brief.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { ParsedCompany, ParsedJd, ParsedResume } from '@shared/types'; + +// 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 is never +// loaded (it can't load under the vitest node env). +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}` })); + +import { generateBrief } from './brief'; + +const resume: ParsedResume = { + skills: ['TypeScript', 'distributed systems'], + projects: [{ name: 'Payments', description: 'Rebuilt the ledger', impact: 'cut errors 90%' }], + workHistory: [{ company: 'Acme', role: 'SWE', highlights: ['led migration'] }], + metrics: ['p99 latency −40%'], + education: [], + certifications: [], + techStack: ['Node', 'Postgres'], + leadership: [], +}; +const jd: ParsedJd = { + requirements: ['Kubernetes in production', '5y backend'], + responsibilities: ['own the payments platform'], + keywords: ['Go', 'k8s'], + focusAreas: ['reliability'], +}; +const company: ParsedCompany = { + overview: 'A fintech', + products: ['Wallet'], + techStack: ['Go'], + values: ['customer obsession'], + culture: [], + recentNews: ['Series C'], + interviewAngles: ['mention payments scale'], +}; + +function input(over: Partial[0]> = {}) { + return { targetRole: 'Staff SWE', company: 'Acme', resume, jd, companyResearch: company, ...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({ + summary: 'Reliability-focused backend round.', + likelyQuestions: [{ question: 'Walk me through the ledger rebuild.', why: 'résumé project' }], + gaps: [ + { requirement: 'Kubernetes in production', coverage: 'missing', howToAddress: 'study k8s' }, + { requirement: '5y backend', coverage: 'strong', howToAddress: '' }, + ], + strengths: [{ point: 'Payments depth', evidence: 'cut errors 90%' }], + companyAngles: ['tie answers to Wallet scale'], +}); + +beforeEach(() => { + h.lastBody = null; + h.reply = FULL; +}); + +describe('generateBrief — request', () => { + it('uses the parsing model and asks for JSON', async () => { + await generateBrief(input()); + expect(h.lastBody!.model).toBe('model:parsing'); + expect(h.lastBody!.text).toEqual({ format: { type: 'json_object' } }); + }); + + it('instructs grounding-only (no fabrication) in the system prompt', async () => { + await generateBrief(input()); + expect(systemPrompt()).toMatch(/never invent|ground/i); + }); + + it('feeds the parsed résumé, JD, and company research into the prompt', async () => { + await generateBrief(input()); + const u = userPrompt(); + expect(u).toContain('distributed systems'); // résumé skill + expect(u).toContain('Kubernetes in production'); // JD requirement + expect(u).toContain('mention payments scale'); // company angle + expect(u).toContain('Staff SWE'); // target role + }); +}); + +describe('generateBrief — defensive parsing', () => { + it('returns the full shape with parsed content', async () => { + const b = await generateBrief(input()); + expect(b.summary).toBe('Reliability-focused backend round.'); + expect(b.likelyQuestions).toHaveLength(1); + expect(b.gaps).toHaveLength(2); + expect(b.strengths[0]).toEqual({ point: 'Payments depth', evidence: 'cut errors 90%' }); + expect(b.companyAngles).toEqual(['tie answers to Wallet scale']); + }); + + it('normalizes coverage and preserves strong/missing', async () => { + h.reply = JSON.stringify({ + gaps: [ + { requirement: 'a', coverage: 'missing' }, + { requirement: 'b', coverage: 'strong' }, + { requirement: 'c', coverage: 'who-knows' }, + ], + }); + const b = await generateBrief(input()); + expect(b.gaps.map((g) => g.coverage)).toEqual(['missing', 'strong', 'partial']); + expect(b.gaps[0].howToAddress).toBe(''); // missing field defaulted + }); + + it('defaults to empty arrays when the model returns nothing usable', async () => { + h.reply = '{}'; + const b = await generateBrief(input()); + expect(b).toEqual({ + summary: '', + likelyQuestions: [], + gaps: [], + strengths: [], + companyAngles: [], + }); + }); + + it('drops malformed items (no question text, non-string angle)', async () => { + h.reply = JSON.stringify({ + likelyQuestions: [{ why: 'orphan' }, { question: 'Real?', why: '' }], + companyAngles: ['ok', 42, null], + }); + const b = await generateBrief(input()); + expect(b.likelyQuestions).toEqual([{ question: 'Real?', why: '' }]); + expect(b.companyAngles).toEqual(['ok']); + }); + + it('works with no company research (null)', async () => { + h.reply = FULL; + const b = await generateBrief(input({ companyResearch: null })); + expect(userPrompt()).toContain('"companyResearch":null'); + expect(b.summary).toBeTruthy(); + }); +}); diff --git a/src/main/services/openai/brief.ts b/src/main/services/openai/brief.ts new file mode 100644 index 0000000..922061e --- /dev/null +++ b/src/main/services/openai/brief.ts @@ -0,0 +1,79 @@ +import { openai } from './client'; +import { model } from './models'; +import type { InterviewBrief, ParsedCompany, ParsedJd, ParsedResume } from '@shared/types'; + +export interface BriefInput { + targetRole: string; + company: string | null; + resume: ParsedResume; + jd: ParsedJd; + /** Parsed company research, when the interview has a researched company site. */ + companyResearch: ParsedCompany | null; +} + +const SYSTEM = `You are an interview-prep coach. Given a candidate's parsed résumé, the +target job's parsed JD, and (optionally) parsed company research, produce a concise +PRE-INTERVIEW BRIEF the candidate can study before the call. Return JSON only, with keys: + +- summary: 1–2 sentences framing what THIS interview will most likely probe. +- likelyQuestions: 6–10 items [{question, why}] — the most probable questions given the + role + JD, MOST likely first. "why" is a one-line rationale tied to a specific JD + requirement/responsibility or a résumé item. +- gaps: JD requirements with weak résumé coverage as + [{requirement, coverage, howToAddress}] where coverage is exactly "strong", "partial", + or "missing". "howToAddress" is one concrete line on how to bridge it (a transferable + story, what to study, or how to be honest about it). Focus on partial/missing items. +- strengths: 3–6 résumé highlights that map STRONGLY to the JD as [{point, evidence}], + where "evidence" is the specific résumé item (project, metric, role) backing it. +- companyAngles: concrete ways to tailor answers to this company, drawn from the company + research (values, products, recent news). Empty if no company research is provided. + +Ground EVERYTHING only in the provided data — never invent the candidate's experience, +employers, metrics, or company facts. If the data is thin, return fewer items rather than +fabricating. Be specific and terse; no preamble.`; + +/** + * Generate a grounded pre-interview brief from the candidate's parsed résumé, + * the job's parsed JD, and any parsed company research. Output is defensively + * defaulted so a malformed model response can't crash callers. + */ +export async function generateBrief(input: BriefInput): Promise { + const payload = JSON.stringify({ + targetRole: input.targetRole, + company: input.company, + resume: input.resume, + jobDescription: input.jd, + companyResearch: input.companyResearch, + }); + + const res = await openai().responses.create({ + model: model('parsing'), + input: [ + { role: 'system', content: SYSTEM }, + { role: 'user', content: payload.slice(0, 24_000) }, + ], + text: { format: { type: 'json_object' } }, + }); + + const raw = JSON.parse(res.output_text) as Partial; + const coverage = (v: unknown): 'strong' | 'partial' | 'missing' => + v === 'strong' || v === 'missing' ? v : 'partial'; + + return { + summary: typeof raw.summary === 'string' ? raw.summary : '', + likelyQuestions: (raw.likelyQuestions ?? []) + .filter((q) => q && typeof q.question === 'string') + .map((q) => ({ question: q.question, why: typeof q.why === 'string' ? q.why : '' })), + gaps: (raw.gaps ?? []) + .filter((g) => g && typeof g.requirement === 'string') + .map((g) => ({ + requirement: g.requirement, + coverage: coverage(g.coverage), + howToAddress: typeof g.howToAddress === 'string' ? g.howToAddress : '', + })), + strengths: (raw.strengths ?? []) + .filter((s) => s && typeof s.point === 'string') + .map((s) => ({ point: s.point, evidence: typeof s.evidence === 'string' ? s.evidence : '' })), + companyAngles: (raw.companyAngles ?? []).filter((a): a is string => typeof a === 'string'), + }; +} diff --git a/src/main/services/openai/feedback.test.ts b/src/main/services/openai/feedback.test.ts new file mode 100644 index 0000000..8e43bbe --- /dev/null +++ b/src/main/services/openai/feedback.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { Job, Profile } from '@shared/types'; + +// Capture the request body 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}` })); + +import { evaluateAnswer } from './feedback'; + +const profile = { + targetRole: 'Staff SWE', + targetCompany: 'Acme', + parsedResume: { + skills: ['distributed systems'], + projects: [{ name: 'Payments', description: '', impact: '' }], + workHistory: [], + metrics: ['p99 latency −40%'], + education: [], + certifications: [], + techStack: [], + leadership: [], + }, + resumeText: null, +} as unknown as Profile; + +function input(over: Partial[0]> = {}) { + return { + question: 'Tell me about a hard scaling problem.', + answer: 'I rebuilt the ledger and cut latency.', + profile, + job: null as Job | null, + interviewType: 'behavioral' as const, + ...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({ + verdict: 'Solid, but light on metrics.', + rating: 4, + strengths: ['Clear structure'], + improvements: ['Quantify the impact'], + tip: 'Mention your p99 latency −40% result.', +}); + +beforeEach(() => { + h.lastBody = null; + h.reply = FULL; +}); + +describe('evaluateAnswer — request', () => { + it('uses the mock model and asks for JSON', async () => { + await evaluateAnswer(input()); + expect(h.lastBody!.model).toBe('model:mock'); + expect(h.lastBody!.text).toEqual({ format: { type: 'json_object' } }); + }); + + it('grounds the critique (no fabrication) and includes the question + answer', async () => { + await evaluateAnswer(input()); + expect(systemPrompt()).toMatch(/never invent|only what they actually said/i); + const u = userPrompt(); + expect(u).toContain('Tell me about a hard scaling problem.'); + expect(u).toContain('I rebuilt the ledger and cut latency.'); + expect(u).toContain('distributed systems'); // résumé context + }); + + it('passes a placeholder when the answer is empty', async () => { + await evaluateAnswer(input({ answer: ' ' })); + expect(userPrompt()).toContain('(no answer captured)'); + }); +}); + +describe('evaluateAnswer — defensive parsing', () => { + it('returns the parsed feedback', async () => { + const f = await evaluateAnswer(input()); + expect(f).toEqual({ + verdict: 'Solid, but light on metrics.', + rating: 4, + strengths: ['Clear structure'], + improvements: ['Quantify the impact'], + tip: 'Mention your p99 latency −40% result.', + }); + }); + + it('clamps rating into 1–5 and rounds', async () => { + h.reply = JSON.stringify({ rating: 9 }); + expect((await evaluateAnswer(input())).rating).toBe(5); + h.reply = JSON.stringify({ rating: 0 }); + expect((await evaluateAnswer(input())).rating).toBe(1); + h.reply = JSON.stringify({ rating: 3.6 }); + expect((await evaluateAnswer(input())).rating).toBe(4); + }); + + it('defaults a missing/non-numeric rating to 3 and arrays to empty', async () => { + h.reply = '{}'; + const f = await evaluateAnswer(input()); + expect(f).toEqual({ verdict: '', rating: 3, strengths: [], improvements: [], tip: '' }); + h.reply = JSON.stringify({ rating: 'great' }); + expect((await evaluateAnswer(input())).rating).toBe(3); + }); + + it('filters non-string strengths/improvements', async () => { + h.reply = JSON.stringify({ strengths: ['ok', 2, null], improvements: [true, 'fix this'] }); + const f = await evaluateAnswer(input()); + expect(f.strengths).toEqual(['ok']); + expect(f.improvements).toEqual(['fix this']); + }); +}); diff --git a/src/main/services/openai/feedback.ts b/src/main/services/openai/feedback.ts new file mode 100644 index 0000000..33be118 --- /dev/null +++ b/src/main/services/openai/feedback.ts @@ -0,0 +1,84 @@ +import { openai } from './client'; +import { model } from './models'; +import type { InterviewType, Job, Profile, SparringFeedback } from '@shared/types'; + +export interface FeedbackInput { + question: string; + /** The candidate's transcribed spoken answer. */ + answer: string; + profile: Profile; + job: Job | null; + interviewType: InterviewType; +} + +function context(p: Profile, job: Job | null): string { + const parts: string[] = [`Role: ${job?.title || p.targetRole || 'unspecified'}`]; + const company = job?.company || p.targetCompany; + if (company) parts.push(`Company: ${company}`); + if (p.parsedResume) { + parts.push(`Candidate skills: ${p.parsedResume.skills?.slice(0, 20).join(', ')}`); + parts.push(`Projects: ${p.parsedResume.projects?.slice(0, 6).map((x) => x.name).join(', ')}`); + if (p.parsedResume.metrics?.length) parts.push(`Metrics: ${p.parsedResume.metrics.slice(0, 8).join('; ')}`); + } else if (p.resumeText) { + parts.push(`Resume excerpt: ${p.resumeText.slice(0, 1500)}`); + } + if (job?.parsedJd) parts.push(`Job focus: ${job.parsedJd.focusAreas?.slice(0, 10).join(', ')}`); + return parts.join('\n'); +} + +const SYSTEM = `You are a sharp, supportive interview coach evaluating a candidate's SPOKEN answer +to an interview question (the answer is a speech-to-text transcript, so ignore minor +disfluencies/transcription noise — judge the substance). Return JSON only: + +- verdict: one honest sentence on how the answer landed. +- rating: integer 1–5 (1 = weak/off-topic, 3 = solid, 5 = excellent, specific, well-structured). +- strengths: 1–3 concrete things the answer did well (specific to what they actually said). +- improvements: 1–3 concrete, actionable fixes for next time (structure, specificity, a missing + metric/result, STAR framing, relevance to the role). +- tip: ONE actionable pointer — ideally name a real project/skill/metric FROM THEIR RÉSUMÉ they + could have used to strengthen the answer. + +Be specific and constructive. Judge ONLY what they actually said against the question. Never +invent experience or claim they said things they didn't. If the answer is empty or off-topic, +say so plainly and rate it low.`; + +/** + * Evaluate one spoken answer and return structured coaching feedback. Output is + * defensively defaulted and the rating is clamped to 1–5 so a malformed model + * response can't crash the Sparring turn loop. + */ +export async function evaluateAnswer(input: FeedbackInput): Promise { + const user = [ + context(input.profile, input.job), + `Interview type: ${input.interviewType}`, + '', + `QUESTION: ${input.question}`, + '', + `CANDIDATE'S SPOKEN ANSWER: ${input.answer.trim() || '(no answer captured)'}`, + '', + 'Evaluate the answer now.', + ].join('\n'); + + const res = await openai().responses.create({ + model: model('mock'), + input: [ + { role: 'system', content: SYSTEM }, + { role: 'user', content: user }, + ], + text: { format: { type: 'json_object' } }, + }); + + const raw = JSON.parse(res.output_text) as Partial; + const strs = (v: unknown): string[] => + Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; + const ratingNum = Math.round(Number(raw.rating)); + const rating = Number.isFinite(ratingNum) ? Math.min(5, Math.max(1, ratingNum)) : 3; + + return { + verdict: typeof raw.verdict === 'string' ? raw.verdict : '', + rating, + strengths: strs(raw.strengths), + improvements: strs(raw.improvements), + tip: typeof raw.tip === 'string' ? raw.tip : '', + }; +} diff --git a/src/main/services/openai/stories.test.ts b/src/main/services/openai/stories.test.ts new file mode 100644 index 0000000..c7eb3a4 --- /dev/null +++ b/src/main/services/openai/stories.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { ParsedResume } from '@shared/types'; + +// 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}` })); + +import { generateStories, COMPETENCIES } from './stories'; + +const resume: ParsedResume = { + skills: ['TypeScript', 'distributed systems'], + projects: [{ name: 'Payments', description: 'Rebuilt the ledger', impact: 'cut errors 90%' }], + workHistory: [{ company: 'Acme', role: 'SWE', highlights: ['led migration'] }], + metrics: ['p99 latency −40%'], + education: [], + certifications: [], + techStack: ['Node', 'Postgres'], + leadership: [], +}; + +function input(over: Partial[0]> = {}) { + return { targetRole: 'Staff SWE', resume, resumeText: 'Full résumé text 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 story = (over: Record = {}) => ({ + title: 'Rebuilt the ledger', + situation: 'Payments errors were high.', + task: 'I owned the ledger.', + action: 'I redesigned the reconciliation flow.', + result: 'Cut errors 90%.', + competencies: ['impact', 'ownership'], + skills: ['Node', 'Postgres'], + ...over, +}); + +beforeEach(() => { + h.lastBody = null; + h.reply = JSON.stringify({ stories: [story()] }); +}); + +describe('generateStories — request', () => { + it('uses the parsing model and asks for JSON', async () => { + await generateStories(input()); + expect(h.lastBody!.model).toBe('model:parsing'); + expect(h.lastBody!.text).toEqual({ format: { type: 'json_object' } }); + }); + + it('instructs grounding-only STAR extraction with the closed competency set', async () => { + await generateStories(input()); + const s = systemPrompt(); + expect(s).toMatch(/never invent|ground/i); + expect(s).toMatch(/STAR/); + // The exact competency vocabulary is injected so tags stay closed. + for (const c of COMPETENCIES) expect(s).toContain(c); + }); + + it('feeds the parsed résumé + raw text into the prompt', async () => { + await generateStories(input()); + const u = userPrompt(); + expect(u).toContain('distributed systems'); + expect(u).toContain('Full résumé text here'); + expect(u).toContain('Staff SWE'); + }); +}); + +describe('generateStories — defensive parsing', () => { + it('returns parsed story drafts', async () => { + const out = await generateStories(input()); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ + title: 'Rebuilt the ledger', + result: 'Cut errors 90%.', + competencies: ['impact', 'ownership'], + skills: ['Node', 'Postgres'], + }); + }); + + it('clamps competencies to the closed set and drops invalid tags', async () => { + h.reply = JSON.stringify({ stories: [story({ competencies: ['impact', 'made-up', 'synergy'] })] }); + const out = await generateStories(input()); + expect(out[0].competencies).toEqual(['impact']); + }); + + it('drops incomplete stories (missing title/situation/action/result)', async () => { + h.reply = JSON.stringify({ + stories: [ + story(), // complete + story({ title: '' }), // no title + story({ result: '' }), // no result + { situation: 'orphan' }, // missing most fields + ], + }); + const out = await generateStories(input()); + expect(out).toHaveLength(1); + expect(out[0].title).toBe('Rebuilt the ledger'); + }); + + it('defaults to empty when the model returns no stories array', async () => { + h.reply = '{}'; + expect(await generateStories(input())).toEqual([]); + }); + + it('filters non-string skills', async () => { + h.reply = JSON.stringify({ stories: [story({ skills: ['Node', 7, null, 'Go'] })] }); + const out = await generateStories(input()); + expect(out[0].skills).toEqual(['Node', 'Go']); + }); +}); diff --git a/src/main/services/openai/stories.ts b/src/main/services/openai/stories.ts new file mode 100644 index 0000000..b472331 --- /dev/null +++ b/src/main/services/openai/stories.ts @@ -0,0 +1,96 @@ +import { openai } from './client'; +import { model } from './models'; +import type { ParsedResume, StoryCompetency, StoryDraft } from '@shared/types'; + +/** Closed competency set the extractor may tag stories with. Keep in sync with + * the StoryCompetency union in @shared/types. A closed set keeps tags + * consistent + filterable (no free-form tag pollution). */ +export const COMPETENCIES: StoryCompetency[] = [ + 'leadership', + 'teamwork', + 'conflict', + 'failure', + 'ambiguity', + 'impact', + 'technical_depth', + 'communication', + 'ownership', + 'problem_solving', + 'growth', + 'customer_focus', +]; +const COMPETENCY_SET = new Set(COMPETENCIES); + +export interface StoriesInput { + targetRole: string; + resume: ParsedResume; + /** Raw résumé text (richer than the parsed structure for narrative detail). */ + resumeText: string | null; +} + +const SYSTEM = `You are an interview-prep coach building a candidate's reusable STAR story bank. +From their résumé ONLY, extract 4–8 distinct, reusable STAR stories — concrete accomplishments +they can retell to answer behavioral/experience questions. Return JSON only: { "stories": [ ... ] } +where each story is: + +- title: a short handle for the story (≤ 8 words), e.g. "Cut checkout latency 40%". +- situation: the context/problem (1–2 sentences, first person: "I…"). +- task: what they were responsible for (1 sentence). +- action: the specific actions THEY took (2–3 sentences, concrete). +- result: the outcome, with real numbers/metrics FROM THE RÉSUMÉ where present (1–2 sentences). +- competencies: 1–3 tags drawn ONLY from this exact set: + ${COMPETENCIES.join(', ')}. +- skills: specific technologies/skills the story demonstrates (drawn from the résumé). + +RULES: +- GROUND EVERYTHING in the résumé. Never invent employers, projects, metrics, or outcomes that + aren't supported by it. If a metric isn't in the résumé, describe the result qualitatively + rather than fabricating a number. +- Prefer DISTINCT stories that cover a RANGE of competencies (don't return five leadership + stories). Pick the strongest, most reusable accomplishments. +- Write in the candidate's first-person voice. Be specific and terse. +- If the résumé is thin, return fewer stories rather than padding with weak/invented ones.`; + +/** + * Extract grounded STAR stories from the candidate's parsed résumé (+ raw text). + * Output is defensively defaulted, competencies are clamped to the closed set, + * and malformed/empty stories are dropped — a bad model response can't crash callers. + */ +export async function generateStories(input: StoriesInput): Promise { + const payload = JSON.stringify({ + targetRole: input.targetRole, + resume: input.resume, + resumeText: input.resumeText ?? undefined, + }); + + const res = await openai().responses.create({ + model: model('parsing'), + input: [ + { role: 'system', content: SYSTEM }, + { role: 'user', content: payload.slice(0, 24_000) }, + ], + text: { format: { type: 'json_object' } }, + }); + + const raw = JSON.parse(res.output_text) as { stories?: unknown }; + const stories = Array.isArray(raw.stories) ? raw.stories : []; + const str = (v: unknown): string => (typeof v === 'string' ? v : ''); + + return stories + .map((s): StoryDraft => { + const o = (s ?? {}) as Record; + return { + title: str(o.title), + situation: str(o.situation), + task: str(o.task), + action: str(o.action), + result: str(o.result), + competencies: Array.isArray(o.competencies) + ? (o.competencies.filter((c): c is StoryCompetency => COMPETENCY_SET.has(c as string)) as StoryCompetency[]) + : [], + skills: Array.isArray(o.skills) ? o.skills.filter((k): k is string => typeof k === 'string') : [], + }; + }) + // Keep only stories with enough substance to be useful. + .filter((s) => s.title && s.situation && s.action && s.result); +} diff --git a/src/main/services/rag/indexProfile.ts b/src/main/services/rag/indexProfile.ts index facc892..1253e96 100644 --- a/src/main/services/rag/indexProfile.ts +++ b/src/main/services/rag/indexProfile.ts @@ -1,11 +1,14 @@ -import { and, eq, isNull } from 'drizzle-orm'; +import { and, eq, isNull, ne } from 'drizzle-orm'; import { db, schema } from '../../db'; import { chunkText } from './chunker'; import { embed } from '../openai/embeddings'; import { sqliteVectorStore } from './vectorStore'; +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 { apiKeyStore } from '../security/apiKey'; +import type { Story, StoryDraft, StoryInput } from '@shared/types'; async function embedChunks(rows: { id: string; content: string }[]): Promise { let embedded = 0; @@ -29,11 +32,19 @@ export async function reindexProfile( const profile = profilesRepo.get(profileId); if (!profile) throw new Error('Profile not found'); - // Clear only the base (non-job) chunks for this profile (embeddings cascade). - // This ALWAYS runs, so removing a resume cleans up its chunks even with no key. + // Clear only the base (non-job) resume/note chunks for this profile (embeddings + // cascade). Excludes `story` chunks — those are managed separately by indexStories, + // so re-saving a résumé doesn't wipe the curated story bank. ALWAYS runs, so + // removing a resume cleans up its chunks even with no key. db() .delete(schema.chunks) - .where(and(eq(schema.chunks.profileId, profileId), isNull(schema.chunks.jobId))) + .where( + and( + eq(schema.chunks.profileId, profileId), + isNull(schema.chunks.jobId), + ne(schema.chunks.sourceType, 'story'), + ), + ) .run(); // Without a key we can't embed, so there's nothing to (re)index — but the stale // chunks above are already gone. @@ -103,3 +114,97 @@ export async function indexJob(jobId: string): Promise<{ chunks: number; embedde } return { chunks: rows.length, embedded: await embedChunks(rows) }; } + +/** Flatten a STAR story into one embeddable/retrievable text blob (kept atomic — + * one chunk per story so a retrieved citation maps to a whole story). */ +function storyToText(s: Pick): string { + return [ + s.title, + `Situation: ${s.situation}`, + `Task: ${s.task}`, + `Action: ${s.action}`, + `Result: ${s.result}`, + ] + .filter((l) => l.trim()) + .join('\n'); +} + +/** WHERE matching a profile's `story` chunks (jobId is null for stories). */ +const storyChunksOf = (profileId: string) => + and(eq(schema.chunks.profileId, profileId), eq(schema.chunks.sourceType, 'story')); + +/** Pure insert-values builders (no transaction type needed), so the chunk + its + * embedding can be written inside a sync better-sqlite3 transaction. */ +function storyChunkValues(profileId: string, ord: number, content: string) { + return { + id: crypto.randomUUID(), + profileId, + jobId: null as string | null, + sourceType: 'story' as const, + ord, + content, + tokenCount: Math.ceil(content.length / 4), + }; +} +function embeddingValues(chunkId: string, vector: Float32Array) { + return { + id: crypto.randomUUID(), + chunkId, + model: model('embedding'), + dim: vector.length, + vector: vectorToBuffer(vector), + }; +} + +/** (Re)index a profile's STAR stories as `story` chunks (jobId = null) so they can + * ground live answers via the same retriever. Call after any story mutation. + * Embeds BEFORE mutating, so a failed embedding leaves the existing index intact + * (no half-written chunks-without-embeddings). */ +export async function indexStories( + profileId: string, +): Promise<{ chunks: number; embedded: number }> { + // No key → just clear story chunks (nothing to embed) so deleting stories still + // cleans up their chunks. ALWAYS runs. + if (!apiKeyStore.isPresent()) { + db().delete(schema.chunks).where(storyChunksOf(profileId)).run(); + return { chunks: 0, embedded: 0 }; + } + + const stories = storiesRepo.list(profileId); + const contents = stories.map((s) => storyToText(s)); + // Network FIRST: if this throws, no DB mutation happens below. + const vectors = contents.length ? await embed(contents) : []; + + db().transaction((tx) => { + tx.delete(schema.chunks).where(storyChunksOf(profileId)).run(); + contents.forEach((content, i) => { + const cv = storyChunkValues(profileId, i, content); + tx.insert(schema.chunks).values(cv).run(); + if (vectors[i]) tx.insert(schema.embeddings).values(embeddingValues(cv.id, vectors[i])).run(); + }); + }); + return { chunks: stories.length, embedded: vectors.filter(Boolean).length }; +} + +/** Atomically replace a profile's ENTIRE story bank (rows + `story` chunks + + * embeddings) from freshly-extracted drafts. Embeds BEFORE any destructive write, + * so a failed embedding (or a generation that yielded nothing) leaves the prior + * bank fully intact. Used by the regenerate path. */ +export async function replaceStories(profileId: string, drafts: StoryDraft[]): Promise { + const contents = drafts.map((d) => storyToText(d)); + // Network FIRST. A rejection here means nothing is deleted or inserted. + const vectors = apiKeyStore.isPresent() && contents.length ? await embed(contents) : []; + + db().transaction((tx) => { + tx.delete(schema.stories).where(eq(schema.stories.profileId, profileId)).run(); + tx.delete(schema.chunks).where(storyChunksOf(profileId)).run(); + drafts.forEach((d, i) => { + const input: StoryInput = { profileId, ...d }; + tx.insert(schema.stories).values(storyInsertValues(crypto.randomUUID(), input)).run(); + const cv = storyChunkValues(profileId, i, contents[i]); + tx.insert(schema.chunks).values(cv).run(); + if (vectors[i]) tx.insert(schema.embeddings).values(embeddingValues(cv.id, vectors[i])).run(); + }); + }); + return storiesRepo.list(profileId); +} diff --git a/src/main/services/rag/retriever.ts b/src/main/services/rag/retriever.ts index c11912a..ab09e4c 100644 --- a/src/main/services/rag/retriever.ts +++ b/src/main/services/rag/retriever.ts @@ -1,9 +1,13 @@ import { embedOne } from '../openai/embeddings'; import { sqliteVectorStore } from './vectorStore'; -import type { RetrievedChunk } from '@shared/types'; +import { STORY_CUE_MIN_SCORE, type RetrievedChunk } from '@shared/types'; /** Embed the query and return the top-k chunks for grounding: the profile's - * resume/notes plus, when given, the selected job's JD. */ + * resume/notes plus, when given, the selected job's JD. + * + * Additionally, a strongly-matching STAR `story` is force-included (even if it + * didn't make the top-k) so it grounds the answer AND surfaces as the Cue Card's + * "Story to tell" cue. The query is embedded ONCE and reused for the story lookup. */ export async function retrieve( profileId: string, query: string, @@ -11,5 +15,10 @@ export async function retrieve( jobId: string | null = null, ): Promise { const vector = await embedOne(query); - return sqliteVectorStore.search({ profileId, query: vector, k, jobId }); + const chunks = sqliteVectorStore.search({ profileId, query: vector, k, jobId }); + const story = sqliteVectorStore.topStory({ profileId, query: vector }); + if (story && story.score >= STORY_CUE_MIN_SCORE && !chunks.some((c) => c.id === story.id)) { + chunks.push(story); + } + return chunks; } diff --git a/src/main/services/rag/vectorStore.ts b/src/main/services/rag/vectorStore.ts index 312604b..97e8f80 100644 --- a/src/main/services/rag/vectorStore.ts +++ b/src/main/services/rag/vectorStore.ts @@ -1,4 +1,4 @@ -import { eq } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { db, schema } from '../../db'; import { bufferToVector, cosineSimilarity, vectorToBuffer } from './vectorMath'; import type { ChunkSource, RetrievedChunk } from '@shared/types'; @@ -20,6 +20,9 @@ export interface VectorStore { k: number; jobId?: string | null; }): RetrievedChunk[]; + /** The single best-matching `story` chunk for the query (or null), regardless of + * the top-k. Reuses the caller's query vector so it adds no extra embedding call. */ + topStory(args: { profileId: string; query: Float32Array }): RetrievedChunk | null; } export const sqliteVectorStore: VectorStore = { @@ -68,4 +71,26 @@ export const sqliteVectorStore: VectorStore = { .sort((a, b) => b.score - a.score) .slice(0, k); }, + + topStory({ profileId, query }) { + const rows = db() + .select({ + id: schema.chunks.id, + content: schema.chunks.content, + vector: schema.embeddings.vector, + }) + .from(schema.chunks) + .innerJoin(schema.embeddings, eq(schema.embeddings.chunkId, schema.chunks.id)) + .where(and(eq(schema.chunks.profileId, profileId), eq(schema.chunks.sourceType, 'story'))) + .all(); + + let best: RetrievedChunk | null = null; + for (const r of rows) { + const score = cosineSimilarity(query, bufferToVector(r.vector as Buffer)); + if (!best || score > best.score) { + best = { id: r.id, sourceType: 'story', content: r.content, score }; + } + } + return best; + }, }; diff --git a/src/preload/index.ts b/src/preload/index.ts index 74eb239..fdd6a65 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,7 @@ 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 { Result } from '@shared/result'; /** invoke + unwrap the Result envelope so renderer code uses normal try/catch. */ @@ -105,6 +106,7 @@ const api = { companyError: string | null; }>(IPC.jobs.save, input), setNotes: (id: string, notes: string | null) => invoke(IPC.jobs.setNotes, { id, notes }), + brief: (id: string) => invoke(IPC.jobs.brief, { id }), delete: (id: string) => invoke(IPC.jobs.delete, { id }), }, notes: { @@ -113,6 +115,21 @@ const api = { invoke(IPC.notes.create, { profileId, content }), delete: (id: string) => invoke(IPC.notes.delete, { id }), }, + stories: { + list: (profileId: string) => invoke(IPC.stories.list, { profileId }), + generate: (profileId: string) => invoke(IPC.stories.generate, { profileId }), + update: ( + id: string, + patch: { + title?: string; + situation?: string; + task?: string; + action?: string; + result?: string; + }, + ) => invoke(IPC.stories.update, { id, patch }), + delete: (id: string) => invoke<{ deleted: true }>(IPC.stories.delete, { id }), + }, session: { start: ( profileId: string, @@ -183,6 +200,25 @@ const api = { }>(IPC.mock.next, { sessionId }), end: (sessionId: string) => invoke<{ ended: true }>(IPC.mock.end, { sessionId }), }, + sparring: { + start: (profileId: string, voice: string, jobId: string | null, interviewType: string) => + invoke<{ sessionId: string; question: string; audioBase64: string; index: number; total: number }>( + IPC.sparring.start, + { profileId, voice, jobId, interviewType }, + ), + answer: (sessionId: string, audioBase64: string, mime: string) => + invoke<{ transcript: string; feedback: SparringFeedback }>(IPC.sparring.answer, { + sessionId, + audioBase64, + mime, + }), + next: (sessionId: string) => + invoke<{ done: boolean; question?: string; audioBase64?: string; index: number; total: number }>( + IPC.sparring.next, + { sessionId }, + ), + end: (sessionId: string) => invoke<{ ended: true }>(IPC.sparring.end, { sessionId }), + }, capture: { region: () => invoke<{ image: string }>(IPC.capture.region), openSelector: () => invoke<{ opened: true }>(IPC.capture.openSelector), diff --git a/src/renderer/components/ui.tsx b/src/renderer/components/ui.tsx index 208752c..c9e549c 100644 --- a/src/renderer/components/ui.tsx +++ b/src/renderer/components/ui.tsx @@ -129,7 +129,7 @@ export function Badge({ tone = 'neutral', children, }: { - tone?: 'neutral' | 'green' | 'amber' | 'blue'; + tone?: 'neutral' | 'green' | 'amber' | 'blue' | 'red'; children: React.ReactNode; }) { const tones: Record = { @@ -137,6 +137,7 @@ export function Badge({ green: 'bg-green-900/40 text-green-300', amber: 'bg-amber-900/40 text-amber-300', blue: 'bg-blue-900/40 text-blue-300', + red: 'bg-red-900/40 text-red-300', }; return ( diff --git a/src/renderer/dashboard/App.tsx b/src/renderer/dashboard/App.tsx index b0d8070..b8cac46 100644 --- a/src/renderer/dashboard/App.tsx +++ b/src/renderer/dashboard/App.tsx @@ -8,6 +8,7 @@ import ProfilesPage from './pages/ProfilesPage'; import ProfileEditorPage from './pages/ProfileEditorPage'; import InterviewPage from './pages/InterviewPage'; import MockPage from './pages/MockPage'; +import SparringPage from './pages/SparringPage'; import ReportsPage from './pages/ReportsPage'; import SettingsPage from './pages/SettingsPage'; import WhatsNewPage from './pages/WhatsNewPage'; @@ -16,6 +17,7 @@ import { Titlebar } from './Titlebar'; import { SidebarStatus } from './SidebarStatus'; import { UpdateBanner } from './UpdateBanner'; import { + BoltIcon, DatabaseIcon, MockIcon, ReportIcon, @@ -32,6 +34,7 @@ const navItems = [ { to: '/profiles', label: 'Profiles', Icon: UserIcon, tour: 'nav-profiles' }, { to: '/interview', label: 'Interview', Icon: MicIcon, tour: 'nav-session' }, { to: '/mock', label: 'Mock Interview', Icon: MockIcon, tour: 'nav-mock' }, + { to: '/sparring', label: 'Sparring', Icon: BoltIcon, tour: 'nav-sparring' }, { to: '/reports', label: 'Reports', Icon: ReportIcon, tour: 'nav-reports' }, { to: '/settings', label: 'Settings', Icon: SettingsIcon, tour: 'nav-settings' }, ...(DEV ? [{ to: '/dev', label: 'DB Explorer', Icon: DatabaseIcon, tour: 'nav-dev' }] : []), @@ -133,6 +136,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/renderer/dashboard/BriefModal.tsx b/src/renderer/dashboard/BriefModal.tsx new file mode 100644 index 0000000..7459d97 --- /dev/null +++ b/src/renderer/dashboard/BriefModal.tsx @@ -0,0 +1,171 @@ +import { useEffect, useState } from 'react'; +import { api } from '../lib/api'; +import type { InterviewBrief, Job } from '@shared/types'; +import { Badge, Button, Modal, Spinner } from '../components/ui'; + +const COVERAGE: Record = { + strong: { tone: 'green', label: 'strong' }, + partial: { tone: 'amber', label: 'partial' }, + missing: { tone: 'red', label: 'gap' }, +}; + +/** A grounded pre-interview prep brief for one interview (job): likely questions, + * coverage gaps, strengths to lead with, and company angles. Generated on open + * from the profile's résumé × the job's JD × company research (main process). */ +export function BriefModal({ + open, + job, + onClose, +}: { + open: boolean; + job: Job | null; + onClose: () => void; +}) { + const [brief, setBrief] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const generate = async (id: string) => { + setLoading(true); + setError(null); + setBrief(null); + try { + setBrief(await api.jobs.brief(id)); + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }; + + // Generate once when the modal opens for a job; reset when it closes. + useEffect(() => { + if (open && job) void generate(job.id); + if (!open) { + setBrief(null); + setError(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, job?.id]); + + return ( + + {loading && ( +
+ + Analysing your résumé against this role… +
+ )} + + {error && !loading && ( +
+

+ {error} +

+ {job && ( + + )} +
+ )} + + {brief && !loading && ( +
+ {brief.summary &&

{brief.summary}

} + + {brief.likelyQuestions.length > 0 && ( +
+
    + {brief.likelyQuestions.map((q, i) => ( +
  1. +
    + {i + 1}. + {q.question} +
    + {q.why &&
    {q.why}
    } +
  2. + ))} +
+
+ )} + + {brief.gaps.length > 0 && ( +
+
    + {brief.gaps.map((g, i) => { + const c = COVERAGE[g.coverage] ?? COVERAGE.partial; + return ( +
  • +
    + {c.label} + {g.requirement} +
    + {g.howToAddress && ( +
    → {g.howToAddress}
    + )} +
  • + ); + })} +
+
+ )} + + {brief.strengths.length > 0 && ( +
+
    + {brief.strengths.map((s, i) => ( +
  • +
    {s.point}
    + {s.evidence &&
    {s.evidence}
    } +
  • + ))} +
+
+ )} + + {brief.companyAngles.length > 0 && ( +
+
    + {brief.companyAngles.map((a, i) => ( +
  • {a}
  • + ))} +
+
+ )} + +
+

+ Grounded only in your résumé, this JD, and company research — nothing invented. +

+ {job && ( + + )} +
+
+ )} +
+ ); +} + +function Section({ + title, + hint, + children, +}: { + title: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {hint && · {hint}} +
+ {children} +
+ ); +} diff --git a/src/renderer/dashboard/StoryBankModal.tsx b/src/renderer/dashboard/StoryBankModal.tsx new file mode 100644 index 0000000..ea4d97e --- /dev/null +++ b/src/renderer/dashboard/StoryBankModal.tsx @@ -0,0 +1,285 @@ +import { useEffect, useState } from 'react'; +import { api } from '../lib/api'; +import type { Profile, Story, StoryCompetency } from '@shared/types'; +import { Badge, Button, Field, Modal, Spinner, TextArea, TextInput } from '../components/ui'; + +const COMPETENCY_LABEL: Record = { + leadership: 'Leadership', + teamwork: 'Teamwork', + conflict: 'Conflict', + failure: 'Failure', + ambiguity: 'Ambiguity', + impact: 'Impact', + technical_depth: 'Technical depth', + communication: 'Communication', + ownership: 'Ownership', + problem_solving: 'Problem-solving', + growth: 'Growth', + customer_focus: 'Customer focus', +}; + +type EditForm = Pick; + +/** Per-profile STAR story bank: extract grounded stories from the résumé, browse, + * edit, regenerate, delete. Stories also ground live answers (indexed as sources). */ +export function StoryBankModal({ + open, + profile, + keyPresent, + onClose, + onChanged, +}: { + open: boolean; + profile: Profile | null; + keyPresent: boolean; + onClose: () => void; + onChanged: () => void; +}) { + const [stories, setStories] = useState([]); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const [expandedId, setExpandedId] = useState(null); + const [editingId, setEditingId] = useState(null); + const [editForm, setEditForm] = useState({ title: '', situation: '', task: '', action: '', result: '' }); + const [confirmRegen, setConfirmRegen] = useState(false); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + + const canGenerate = keyPresent && !!profile?.parsedResume; + + useEffect(() => { + if (!open) { + // Fully reset so reopening is clean. + setStories([]); + setError(null); + setExpandedId(null); + setEditingId(null); + setConfirmRegen(false); + setConfirmDeleteId(null); + return; + } + if (!profile) return; + setLoading(true); + setError(null); + api.stories + .list(profile.id) + .then(setStories) + .catch((e) => setError((e as Error).message)) + .finally(() => setLoading(false)); + }, [open, profile?.id]); // eslint-disable-line react-hooks/exhaustive-deps + + const run = async (msg: string, fn: () => Promise) => { + setBusy(msg); + setError(null); + try { + await fn(); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(null); + } + }; + + const doGenerate = () => + run('Extracting STAR stories from your résumé…', async () => { + if (!profile) return; + setStories(await api.stories.generate(profile.id)); + setConfirmRegen(false); + setExpandedId(null); + setEditingId(null); + onChanged(); + }); + + const startEdit = (s: Story) => { + setEditingId(s.id); + setExpandedId(s.id); + setEditForm({ title: s.title, situation: s.situation, task: s.task, action: s.action, result: s.result }); + }; + + const saveEdit = () => + run('Saving…', async () => { + if (!editingId) return; + const updated = await api.stories.update(editingId, editForm); + setStories((ss) => ss.map((s) => (s.id === updated.id ? updated : s))); + setEditingId(null); + onChanged(); + }); + + const doDelete = (id: string) => + run('Deleting…', async () => { + await api.stories.delete(id); + setStories((ss) => ss.filter((s) => s.id !== id)); + setConfirmDeleteId(null); + onChanged(); + }); + + return ( + +
+
+

+ Reusable STAR stories pulled from your résumé — rehearse them, and they’ll also ground + your live answers (shown as 📖 sources in the Cue Card). +

+ {stories.length > 0 && !confirmRegen && ( + + )} +
+ + {!keyPresent && ( +

+ Add your OpenAI API key in Settings to generate stories. +

+ )} + {keyPresent && !profile?.parsedResume && ( +

+ Add & parse a résumé on this profile first — stories are extracted from it. +

+ )} + {error && ( +

+ {error} +

+ )} + + {confirmRegen && ( +
+ Replace all {stories.length} stories? Any edits you’ve made will be lost. +
+ + +
+
+ )} + + {busy && ( +
+ + {busy} +
+ )} + + {loading && !busy && ( +
+ + Loading stories… +
+ )} + + {/* Empty state */} + {!loading && !busy && stories.length === 0 && ( +
+

No stories yet.

+ +
+ )} + + {/* Story list */} + {!busy && stories.length > 0 && ( +
    + {stories.map((s) => { + const editing = editingId === s.id; + const expanded = expandedId === s.id || editing; + return ( +
  • + {editing ? ( +
    + + setEditForm((f) => ({ ...f, title: e.target.value }))} + /> + + {(['situation', 'task', 'action', 'result'] as const).map((k) => ( + +