From 0b24f467f1e9236a84427a2757816993fdfea523 Mon Sep 17 00:00:00 2001 From: tpikachu Date: Tue, 30 Jun 2026 10:49:41 -0500 Subject: [PATCH 1/5] feat: grounded, proof-linked answers + fabrication guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The next big feature (increment 1). Answers now trace to the candidate's own résumé/JD/company, and the copilot won't hand them a claim they can't defend. - answer.ts: the context is already numbered `[1] (resume) …`; the prompt now makes the model cite those numbers inline after each grounded claim, ground every specific claim (employer/metric/project) ONLY in context, and — the fabrication guard — when the context can't support the question, lead with "⚠", say it's not in their background, and pivot to a cited transferable framing instead of inventing. - Cue Card: a `Citations` component renders the cited `[i]` as glanceable source chips (sourceType + match %); clicking one expands the cited chunk — reusing the contextSent payload already shown in "Data sent to OpenAI". - Validated the claim→source attribution on the real model before building (the spike the review flagged): grounded answers cite accurately, and an unsupported question ("led a team of 50") correctly produced "⚠ I haven't led a team of 50 …". - Tests: answer.test.ts asserts the numbered context + the citation/fabrication-guard rules in the prompt. typecheck · 79 unit · build · 7 e2e green. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/06-OPENAI-SERVICE.md | 7 ++++ src/main/services/openai/answer.test.ts | 13 ++++++ src/main/services/openai/answer.ts | 16 ++++---- src/renderer/overlay/Overlay.tsx | 54 +++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 7 deletions(-) diff --git a/docs/06-OPENAI-SERVICE.md b/docs/06-OPENAI-SERVICE.md index c15c1df..0dadad1 100644 --- a/docs/06-OPENAI-SERVICE.md +++ b/docs/06-OPENAI-SERVICE.md @@ -68,6 +68,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 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/renderer/overlay/Overlay.tsx b/src/renderer/overlay/Overlay.tsx index e460fd2..af27559 100644 --- a/src/renderer/overlay/Overlay.tsx +++ b/src/renderer/overlay/Overlay.tsx @@ -77,6 +77,7 @@ export default function Overlay() { const [paused, setPaused] = useState(false); const [live, setLive] = useState(false); const [showData, setShowData] = useState(false); + const [openCite, setOpenCite] = useState(null); // expanded citation: `${cardId}:${n}` const [privacy, setPrivacy] = useState(true); const [clientInfo, setClientInfo] = useState(null); const [showClient, setShowClient] = useState(false); @@ -894,6 +895,7 @@ export default function Overlay() { Listening… ) : null} {c.streaming && } + )} @@ -1090,6 +1092,58 @@ export default function Overlay() { ); } +/** Proof-linked sources: the answer cites context chunks inline as [1], [2]…; this + * surfaces those as glanceable chips that expand to the cited chunk. The chunk list + * comes from the same contextSent payload shown in "Data sent to OpenAI". */ +function Citations({ + card, + openKey, + onToggle, +}: { + card: AnswerCard; + openKey: string | null; + onToggle: (k: string | null) => void; +}) { + const chunks = card.context?.chunks; + if (!chunks || !card.answer) return null; + const cited = [...new Set([...card.answer.matchAll(/\[(\d+)\]/g)].map((m) => Number(m[1])))] + .filter((n) => chunks[n - 1]) + .sort((a, b) => a - b); + if (cited.length === 0) return null; + const openN = openKey?.startsWith(`${card.id}:`) ? Number(openKey.split(':')[1]) : null; + const openChunk = openN ? chunks[openN - 1] : null; + return ( +
+
+ 📎 Sources: + {cited.map((n) => { + const key = `${card.id}:${n}`; + return ( + + ); + })} +
+ {openChunk && ( +

+ {openChunk.content.slice(0, 320)} + {openChunk.content.length > 320 ? '…' : ''} +

+ )} +
+ ); +} + function EqualizerBars() { return ( From 9e4bed051e8547b6316fdc79be6905b3fb2cd127 Mon Sep 17 00:00:00 2001 From: tpikachu Date: Tue, 30 Jun 2026 12:54:52 -0500 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20Pre-Interview=20Brief=20=E2=80=94?= =?UTF-8?q?=20grounded=20r=C3=A9sum=C3=A9=20=C3=97=20JD=20=C3=97=20company?= =?UTF-8?q?=20prep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generates a study brief before the call: ranked likely questions, coverage gaps (JD asks thin in the résumé) with how-to-bridge lines, strengths to lead with, and company angles. Reuses the existing parsed structures — no re-parse. - brief.ts generateBrief(): one Responses call (parsing model, json_object), defensively defaulted, coverage normalized, fabrication forbidden in-prompt. - jobs:brief IPC gathers profile+job, guards on key/résumé/JD, returns the brief (not persisted — regenerated on demand). Preload + types wired. - Interview page: per-row "Brief" button (disabled without a parsed JD) opens BriefModal, which generates on open. Added a red Badge tone for "missing" gaps. - brief.test.ts (+8): request shape, grounding-only prompt, defensive parsing. - Docs: IPC map, OpenAI service, session log. Verified: typecheck · 87 unit · build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/05-IPC-MAP.md | 1 + docs/06-OPENAI-SERVICE.md | 12 ++ docs/sessions/2026-06-30.md | 45 +++++ src/main/ipc/jobs.ipc.ts | 25 +++ src/main/services/openai/brief.test.ts | 149 +++++++++++++++ src/main/services/openai/brief.ts | 79 ++++++++ src/preload/index.ts | 2 + src/renderer/components/ui.tsx | 3 +- src/renderer/dashboard/BriefModal.tsx | 171 ++++++++++++++++++ .../dashboard/pages/InterviewPage.tsx | 15 +- src/shared/ipc.ts | 1 + src/shared/types.ts | 11 ++ 12 files changed, 512 insertions(+), 2 deletions(-) create mode 100644 docs/sessions/2026-06-30.md create mode 100644 src/main/services/openai/brief.test.ts create mode 100644 src/main/services/openai/brief.ts create mode 100644 src/renderer/dashboard/BriefModal.tsx diff --git a/docs/05-IPC-MAP.md b/docs/05-IPC-MAP.md index 2472a43..1cfb93c 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 diff --git a/docs/06-OPENAI-SERVICE.md b/docs/06-OPENAI-SERVICE.md index 0dadad1..54863c8 100644 --- a/docs/06-OPENAI-SERVICE.md +++ b/docs/06-OPENAI-SERVICE.md @@ -55,6 +55,18 @@ 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`. + ### embeddings.ts — `embed(texts: string[]) => Float32Array[]` Batches inputs, returns vectors; caller stores BLOBs. Records model + dim. diff --git a/docs/sessions/2026-06-30.md b/docs/sessions/2026-06-30.md new file mode 100644 index 0000000..efcf10b --- /dev/null +++ b/docs/sessions/2026-06-30.md @@ -0,0 +1,45 @@ +# 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`. + +## Next (Path A) +- #3 STAR Story Bank (L) — extract reusable STAR stories from the résumé, tag by + competency, surface the best-matching story per live question. +- #4 Sparring two-way voice mock (L) — needs a push-to-talk / half-duplex spike. 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/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/preload/index.ts b/src/preload/index.ts index 74eb239..537e97b 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 } 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: { 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/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/pages/InterviewPage.tsx b/src/renderer/dashboard/pages/InterviewPage.tsx index 22e4d42..69cb99e 100644 --- a/src/renderer/dashboard/pages/InterviewPage.tsx +++ b/src/renderer/dashboard/pages/InterviewPage.tsx @@ -8,6 +8,7 @@ import type { InterviewType, Job, SessionDetail, SessionListItem } from '@shared import { Badge, Button, Card, Field, Modal, Page, Select } from '../../components/ui'; import { DataTable, type Column } from '../../components/DataTable'; import { JobFormModal } from '../JobFormModal'; +import { BriefModal } from '../BriefModal'; import { SampleQuestions } from '../SampleQuestions'; import { PlayIcon, PlusIcon } from '../../components/icons'; @@ -44,6 +45,7 @@ export default function InterviewPage() { const [jobsLoading, setJobsLoading] = useState(false); const [formOpen, setFormOpen] = useState(false); const [editJob, setEditJob] = useState(null); // null => create + const [briefJob, setBriefJob] = useState(null); // open => prep-brief modal // Latest session per job, so each row offers Start (none yet) or Resume. const [sessionsByJob, setSessionsByJob] = useState>(new Map()); @@ -226,7 +228,7 @@ export default function InterviewPage() { { key: 'actions', header: '', - className: 'w-52 text-right', + className: 'w-72 text-right', render: (j) => { const isLive = !!session && session.jobId === j.id; const prior = sessionsByJob.get(j.id); @@ -261,6 +263,14 @@ export default function InterviewPage() { Start )} + @@ -401,6 +411,9 @@ export default function InterviewPage() { onDeleted={onJobDeleted} /> + {/* Pre-interview prep brief (résumé × JD × company), generated on open. */} + setBriefJob(null)} /> + {/* Save-or-discard the interview that just ended. */} Date: Tue, 30 Jun 2026 13:27:04 -0500 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20STAR=20Story=20Bank=20=E2=80=94=20g?= =?UTF-8?q?rounded,=20reusable,=20live-aware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract reusable STAR stories from the résumé, tag them by competency (closed set), persist them per profile, and index them as `story` chunks so they ground live answers through the existing retriever (rendered as 📖 source chips). - New stories table (migration 0005, profile-cascade) + storiesRepo. - services/openai/stories.ts generateStories(): grounded extraction, defensive parsing, closed competency vocabulary. Mirrors parsing.ts/brief.ts. - rag/indexProfile.ts: indexStories + replaceStories. reindexProfile now excludes sourceType=story so re-saving a résumé won't wipe the curated bank. - IPC stories.{list,generate,update,delete} + preload; ChunkSource += story; Cue Card Citations label story chips. - UI: Story Bank card on the profile editor → StoryBankModal (generate-on-open, browse/edit/regenerate/delete). - stories.test.ts (+8). Hardened per adversarial review (2 confirmed mediums in the regenerate path): generate() bails on empty extraction, and replaceStories embeds FIRST then commits rows + chunks + embeddings in one transaction — a failed embed or thin résumé can no longer wipe or de-ground the bank. indexStories got the same embed-before-mutate guarantee. Verified: typecheck · 95 unit · build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/04-DATABASE.md | 29 +- docs/05-IPC-MAP.md | 10 + docs/06-OPENAI-SERVICE.md | 14 + docs/sessions/2026-06-30.md | 45 +- drizzle/0005_wealthy_rhino.sql | 16 + drizzle/meta/0005_snapshot.json | 1223 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/main/db/repositories/stories.repo.ts | 80 ++ src/main/db/schema.ts | 27 +- src/main/ipc/index.ts | 2 + src/main/ipc/stories.ipc.ts | 70 + src/main/services/openai/stories.test.ts | 127 ++ src/main/services/openai/stories.ts | 96 ++ src/main/services/rag/indexProfile.ts | 113 +- src/preload/index.ts | 17 +- src/renderer/dashboard/StoryBankModal.tsx | 285 ++++ .../dashboard/pages/ProfileEditorPage.tsx | 47 +- src/renderer/overlay/Overlay.tsx | 2 +- src/shared/ipc.ts | 6 + src/shared/types.ts | 44 +- 20 files changed, 2238 insertions(+), 22 deletions(-) create mode 100644 drizzle/0005_wealthy_rhino.sql create mode 100644 drizzle/meta/0005_snapshot.json create mode 100644 src/main/db/repositories/stories.repo.ts create mode 100644 src/main/ipc/stories.ipc.ts create mode 100644 src/main/services/openai/stories.test.ts create mode 100644 src/main/services/openai/stories.ts create mode 100644 src/renderer/dashboard/StoryBankModal.tsx 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 1cfb93c..90e21ab 100644 --- a/docs/05-IPC-MAP.md +++ b/docs/05-IPC-MAP.md @@ -91,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 | |---|---|---| diff --git a/docs/06-OPENAI-SERVICE.md b/docs/06-OPENAI-SERVICE.md index 54863c8..e22c45d 100644 --- a/docs/06-OPENAI-SERVICE.md +++ b/docs/06-OPENAI-SERVICE.md @@ -67,6 +67,20 @@ not fabrication. The `jobs:brief` handler gathers résumé+JD+company from the r 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). + ### embeddings.ts — `embed(texts: string[]) => Float32Array[]` Batches inputs, returns vectors; caller stores BLOBs. Records model + dim. diff --git a/docs/sessions/2026-06-30.md b/docs/sessions/2026-06-30.md index efcf10b..cddbe10 100644 --- a/docs/sessions/2026-06-30.md +++ b/docs/sessions/2026-06-30.md @@ -39,7 +39,48 @@ 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`. + ## Next (Path A) -- #3 STAR Story Bank (L) — extract reusable STAR stories from the résumé, tag by - competency, surface the best-matching story per live question. - #4 Sparring two-way voice mock (L) — needs a push-to-talk / half-duplex spike. +- Optional: a dedicated "Tell this story" Cue Card cue (distinct from grounding) + + competency-coverage view on the profile. 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..df21e76 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -4,6 +4,7 @@ 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 { registerCaptureIpc } from './capture.ipc'; @@ -21,6 +22,7 @@ export function registerIpc(): void { registerDocumentsIpc(); registerJobsIpc(); registerNotesIpc(); + registerStoriesIpc(); registerSessionIpc(); registerMockIpc(); registerCaptureIpc(); 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/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/preload/index.ts b/src/preload/index.ts index 537e97b..a304351 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,7 +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 } from '@shared/types'; +import type { InterviewBrief, Story } from '@shared/types'; import type { Result } from '@shared/result'; /** invoke + unwrap the Result envelope so renderer code uses normal try/catch. */ @@ -115,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, 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) => ( + +