Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 19 additions & 10 deletions docs/04-DATABASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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)`.
22 changes: 22 additions & 0 deletions docs/05-IPC-MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -90,6 +91,16 @@ independently.
| `notes:create` | `{ profileId, content }` | `Note` |
| `notes:delete` | `{ id }` | `{ deleted: true }` |

### stories
The per-profile STAR story bank (`Story[]`). Stories are extracted from the parsed
résumé, persisted, and indexed as `story` chunks so they ground live answers.
| Channel | Request | Response |
|---|---|---|
| `stories:list` | `{ profileId }` | `Story[]` |
| `stories:generate` | `{ profileId }` | `Story[]` (extract grounded STAR stories from the résumé; **embeds first, then atomically replaces** rows + chunks + embeddings — a failed embedding or empty extraction leaves the prior bank intact. Throws without a key / parsed résumé) |
| `stories:update` | `{ id, patch: { title?, situation?, task?, action?, result? } }` | `Story` (edit one story's text; re-indexes) |
| `stories:delete` | `{ id }` | `{ deleted: true }` (re-indexes) |

### session
| Channel | Request | Response |
|---|---|---|
Expand Down Expand Up @@ -126,6 +137,17 @@ Runs as a non-persisted live session (`isMock`) that's deleted on end — never
| `mock:next` | `{ sessionId }` | `{ done, question?, audioBase64?, index, total }` (next question — spoken + answered in the Cue Card) |
| `mock:end` | `{ sessionId }` | `{ ended }` (stops + deletes the mock session) |

### sparring (two-way voice mock)
A back-and-forth voice drill: the AI asks aloud, the candidate answers by speaking
(push-to-talk), and each answer is coached. State is in-memory only (ephemeral — nothing
persisted; no DB session, no Cue Card).
| Channel | Request | Response |
|---|---|---|
| `sparring:start` | `{ profileId, voice, jobId, interviewType }` | `{ sessionId, question, audioBase64, index, total }` (asks Q1 aloud) |
| `sparring:answer` | `{ sessionId, audioBase64, mime }` | `{ transcript, feedback }` (transcribes the recorded clip + returns `SparringFeedback`) |
| `sparring:next` | `{ sessionId }` | `{ done, question?, audioBase64?, index, total }` (history-aware follow-up, spoken) |
| `sparring:end` | `{ sessionId }` | `{ ended }` (clears the in-memory session) |

### capture / coding
| Channel | Request | Response |
|---|---|---|
Expand Down
53 changes: 53 additions & 0 deletions docs/06-OPENAI-SERVICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,37 @@ Uses Responses API with a JSON instruction to return typed JSON (defensively def
from text scraped off the company website (see `services/documents/companyResearch.ts`),
used to tailor answers to the company.

### brief.ts — `generateBrief(input) => InterviewBrief`
Powers the **Pre-Interview Brief**. Input is the candidate's parsed résumé, the job's
parsed JD, and (optionally) parsed company research. One Responses call (`parsing` model,
`json_object`) returns a grounded study brief — `summary`, ranked `likelyQuestions`
(`{question, why}`), `gaps` (`{requirement, coverage: strong|partial|missing, howToAddress}`),
`strengths` (`{point, evidence}`), and `companyAngles`. Output is defensively defaulted and
coverage is normalized, so a malformed response can't crash callers. The system prompt
forbids inventing experience/employers/metrics/company facts — thin data yields fewer items,
not fabrication. The `jobs:brief` handler gathers résumé+JD+company from the repos and guards
on key/résumé/JD presence; the brief is returned (not persisted) and shown in the dashboard's
`BriefModal`.

### stories.ts — `generateStories(input) => StoryDraft[]`
Powers the **STAR Story Bank**. From the candidate's parsed résumé (+ raw text), one
Responses call (`parsing` model, `json_object`) extracts 4–8 reusable STAR stories, each
tagged with 1–3 competencies from a **closed set** (`COMPETENCIES`, kept in sync with the
`StoryCompetency` union) plus demonstrated skills. Output is defensively parsed: competencies
are clamped to the closed set, non-string skills dropped, and stories missing
title/situation/action/result are filtered out (so a degenerate response yields fewer/zero
stories, never a crash). The system prompt forbids inventing employers/projects/metrics.
The `stories:generate` handler bails if extraction is empty, then `replaceStories`
(`rag/indexProfile.ts`) **embeds first and commits rows + `story` chunks + embeddings in one
transaction** — a failed embedding leaves the prior bank intact. `indexStories` re-embeds on
edit/delete with the same embed-before-mutate guarantee. Stories surface live as `📖 story`
source chips via the normal retriever (they're just `story` chunks). **Story-to-tell cue:**
`retrieve` (rag/retriever.ts) embeds the question once and, alongside the top-k, force-includes
the single best-matching `story` chunk when its score ≥ `STORY_CUE_MIN_SCORE` (`@shared/types`) —
so it grounds the answer, stays citable, and the Cue Card surfaces it as a prominent
**"📖 Story to tell"** callout (`StoryCue` in Overlay.tsx, derived from the `contextSent` chunks —
no extra IPC event or embedding call).

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

Expand All @@ -68,6 +99,13 @@ Builds a **grounding** prompt:
- System: persona + rules ("ground answers in provided context; never invent
experience; if no relevant experience, give a transferable-skills answer and
set a risk warning"); LENGTH is a hard constraint.
- **Grounded / proof-linked answers:** `buildContext` numbers the chunks `[1] (resume) …`;
the prompt makes the model cite those numbers inline after each grounded claim
(e.g. `…cut p99 latency 40% [1]`). The Cue Card renders the cited `[i]` as source chips
(the `Citations` component, backed by the `contextSent` chunks). **Fabrication guard:**
for anything the context can't support the model must not invent it — it leads with
`⚠`, says it's not in the candidate's background, and pivots to a cited transferable
framing.
- User: question + retrieved context + profile summary + the chosen format/length,
plus optional pronunciation hints for rare/technical terms.
- `length` (`key_points` | `detailed`) also sets a hard `max_output_tokens` ceiling
Expand Down Expand Up @@ -99,6 +137,21 @@ them — the buffer + thumbnail strip live in `capture/codingMode.ts` (see `capt
Power the mock-interview mode: `generateQuestion` produces the next question and
per-answer feedback; `speak` renders the interviewer's voice (returns audio Buffer).

### feedback.ts — `evaluateAnswer(input) => SparringFeedback`
Powers **Sparring** (the two-way voice mock). Given the question, the candidate's
transcribed spoken answer, and their résumé/JD context, one Responses call
(`mock` model, `json_object`) returns coaching feedback — `verdict`, a 1–5 `rating`,
`strengths[]`, `improvements[]`, and one actionable `tip` (ideally naming a real résumé
item they could have used). Output is defensively parsed: the rating is rounded + clamped
to 1–5, arrays are string-filtered, and missing fields default — a malformed model reply
can't crash the turn loop. The prompt judges ONLY what the candidate actually said and
forbids inventing experience.

The `sparringManager` (in-memory, no DB) drives the turns:
`generateQuestion` + `speak` to ask, `transcribeChunk` on the recorded clip, then
`evaluateAnswer`; the question is committed to history only after TTS succeeds so a
transient failure can't skip a turn.

## Cross-cutting
- **Cost/usage**: each call returns token usage; persisted on `ai_answers.tokens`
and surfaced in the UI ("what was sent to OpenAI").
Expand Down
Loading
Loading