feat: US-001 - Reverse timeline direction to top = most recent
This commit is contained in:
+20
-459
@@ -1,464 +1,25 @@
|
||||
# Progress Log — Semantic Search & AI Chat
|
||||
# Branch: ralph/semantic-search
|
||||
# Started: 2026-02-15
|
||||
# Progress Log — Career Constellation Clinical Pathway Overhaul
|
||||
# Branch: ralph/constellation-overhaul
|
||||
# Started: 2026-02-16
|
||||
|
||||
## Codebase Patterns
|
||||
- `@xenova/transformers` pipeline with `pooling: 'mean'` and `normalize: true` returns a Tensor; use `Array.from(output.data as Float32Array)` to extract the 384-d vector
|
||||
- Scripts live in `scripts/` and run via `npx tsx` (tsx is not a project dep, npx fetches it)
|
||||
- tsconfig `include` only covers `src/` — scripts are type-checked by tsx at runtime, not by `tsc --noEmit`
|
||||
- Project uses `"type": "module"` in package.json
|
||||
- Palette item IDs: `exp-{consultation.id}`, `skill-{skill.id}`, `proj-{investigation.id}`, `ach-{0-3}`, `edu-{0-3}`, `action-{0-3}`
|
||||
- `buildEmbeddingTexts()` in `src/lib/search.ts` returns `Array<{ id: string, text: string }>` with IDs matching PaletteItem IDs — use this for both embedding generation and chat context
|
||||
- `src/data/embeddings.json` is an array of `{ id: string, embedding: number[] }` — 42 items, 384-d vectors, IDs match PaletteItem IDs. Vite imports JSON natively.
|
||||
- `src/lib/embedding-model.ts` exports `initModel()`, `embedQuery(text)`, `isModelReady()` — check `isModelReady()` before calling `embedQuery()`
|
||||
- `initModel()` is called fire-and-forget in `App.tsx` on mount — model loads during boot/ECG/login phases
|
||||
- ONNX model files self-hosted in `public/models/Xenova/all-MiniLM-L6-v2/` — `env.localModelPath = '/models/'`, `env.allowRemoteModels = false`, `env.useBrowserCache = false` eliminates HF CDN dependency
|
||||
- `src/lib/semantic-search.ts` exports `semanticSearch(queryEmbedding, embeddings, threshold?)` and `loadEmbeddings()` — embeddings are normalized so cosine similarity is dot(a,b)/(mag(a)*mag(b))
|
||||
- CommandPalette uses `semanticResults` state + debounced `useEffect` for async semantic search, falling back to Fuse.js when `isModelReady()` returns false or on any error
|
||||
- `loadEmbeddings()` and `paletteMap` (Map<id, PaletteItem>) are precomputed via `useMemo` — no re-computation on each search
|
||||
- ChatWidget is mounted in DashboardLayout alongside CommandPalette and DetailPanel — z-index 90 (below command palette z-1000)
|
||||
- `prefersReducedMotion` pattern: read `window.matchMedia` at module level, use in framer-motion variants to skip animation
|
||||
- ChatWidget stores messages as `Array<{ role: 'user' | 'assistant', content: string }>` — same shape as LLM message format
|
||||
- ChatWidget `isOpen` state controls both panel visibility and button icon (MessageCircle ↔ X) — panel rendering handled by AnimatePresence
|
||||
- `src/lib/llm.ts` exports `sendChatMessage(messages)` (async generator), `isLLMAvailable()`, `buildSystemPrompt()`, `parseItemIds(text)`, `stripItemsSuffix(text)`, `LLM_MODEL`, `LLM_DISPLAY_NAME` — ChatMessage type is `{ role: 'user' | 'assistant', content: string }`
|
||||
- LLM API uses OpenRouter (OpenAI-compatible): POST to `https://openrouter.ai/api/v1/chat/completions` with `stream: true`, auth via `Authorization: Bearer` header, parse SSE `data:` lines as JSON, extract `choices[0].delta.content`
|
||||
- System prompt sent as `role: 'system'` message (first in messages array), built from `buildEmbeddingTexts()` — instructs model to end responses with `[ITEMS: id1, id2, id3]` for portfolio item linking
|
||||
- `isLLMAvailable()` checks `import.meta.env.VITE_OPEN_ROUTER_API_KEY` — when missing, chat panel shows "unavailable" message but button remains visible
|
||||
- OpenRouter requires `HTTP-Referer` and `X-Title` headers — set to `window.location.origin` and `'Andy Charlwood Portfolio'` respectively
|
||||
- Model is `z-ai/glm-5` (set in `LLM_MODEL` constant in `llm.ts`)
|
||||
- Assistant messages store item IDs as `<!--ITEMS:id1,id2-->` HTML comment suffix for US-010 to parse — `getDisplayText()` strips this before rendering
|
||||
- Conversation history capped at 10 messages (`MAX_HISTORY`), metadata stripped before sending to API
|
||||
- Icon/color mappings (`iconByType`, `iconColorStyles`) live in `src/lib/palette-icons.ts` — shared between CommandPalette and ChatWidget
|
||||
- ChatWidget accepts optional `onAction?: (action: PaletteAction) => void` prop — same pattern as CommandPalette's `onAction`
|
||||
- `DashboardLayout` passes `handlePaletteAction` to both CommandPalette and ChatWidget for unified action routing
|
||||
- TopBar is `z-index: 100` (fixed), nav is `z-index: 99` (sticky) — mobile full-screen overlays need `z-index > 100` to appear above them
|
||||
- Inline `style={{ display: 'flex' }}` overrides Tailwind's `hidden` class — use `!important` modifier (`max-md:!hidden`) or move display to Tailwind classes to allow responsive hiding
|
||||
- ChatWidget mobile breakpoint is `md` (768px) — below this, panel is full-screen; above, it's 380px anchored bottom-right
|
||||
- `handleSubmit(overrideText?)` accepts optional text param — use this when programmatically sending messages (e.g., suggested question chips) to avoid stale `inputValue` state
|
||||
- `SUGGESTED_QUESTIONS` const array at top of ChatWidget — edit here to change welcome screen chip text
|
||||
- System prompt prefixes each CV entry with `[item-id]` so the model can directly reference IDs in its `[ITEMS: ...]` suffix — more reliable than expecting pattern inference
|
||||
- Benchmark script (`scripts/benchmark.ts`) uses OpenRouter non-streaming endpoint — response format: `choices[0].message.content` (not `.delta.content` like streaming). Auth via `Authorization: Bearer` header, API key from `process.env.VITE_OPEN_ROUTER_API_KEY`
|
||||
- Cannot import `buildSystemPrompt` from `src/lib/llm.ts` into Node scripts — `llm.ts` uses `import.meta.env` (Vite) and `window.location` (browser). Benchmark keeps its own copy of `buildSystemPrompt` that mirrors production
|
||||
- `buildEmbeddingTexts()` uses `skillContextMap` and `projectContextMap` Record objects to enrich each item with role context, cross-references, and practical application detail — edit these maps when adding new skills/projects
|
||||
- System prompt has an **Employment Timeline (IMPORTANT)** section that explicitly separates NHS from private sector — this is critical for preventing employer conflation. System prompt must stay under 8KB.
|
||||
- Benchmark config `scripts/benchmark-config.json` expected answers must accurately reflect the source CV data — ambiguous expected answers cause false negatives in scoring
|
||||
- CareerConstellation.tsx is a D3 force-directed graph rendered in an SVG with React overlay buttons for accessibility
|
||||
- D3 simulation uses forceSimulation with charge, link, x, y, and collide forces
|
||||
- Module-level window.matchMedia reads for prefersReducedMotion and supportsCoarsePointer
|
||||
- DashboardLayout manages constellation state: highlightedNodeId, pinnedNodeId via callbacks
|
||||
- Work experience data in src/data/consultations.ts, skills in src/data/skills.ts, constellation-specific data in src/data/constellation.ts
|
||||
- CSS layout: .pathway-columns is a grid that switches from 1fr (mobile) to minmax(0,1.15fr) minmax(0,1.5fr) at desktop breakpoint
|
||||
- .pathway-graph-sticky has position: sticky; top: 12px for the graph column
|
||||
- Design tokens in index.css :root — use var(--accent), var(--border-light), var(--text-tertiary), etc.
|
||||
- Use the d3-viz skill for all D3 rendering stories
|
||||
- yScale domain reversal automatically flows through all timeline elements (guides, dots, labels, role positions, simulation forces) — no per-element changes needed
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-15 - US-001
|
||||
- Installed `@xenova/transformers` (^2.17.2)
|
||||
- Created `scripts/generate-embeddings.ts` with main() that loads `Xenova/all-MiniLM-L6-v2` and embeds a test string
|
||||
- Added `"generate-embeddings"` npm script
|
||||
- Verified: outputs vector length 384 and exits cleanly
|
||||
- Typecheck passes
|
||||
- Files changed: `package.json`, `package-lock.json`, `scripts/generate-embeddings.ts`
|
||||
## 2026-02-16 - US-001
|
||||
- Reversed yScale domain from [minYear, maxYear] to [maxYear, minYear] so 2025 appears at top
|
||||
- Updated buildScreenReaderDescription() to mention reverse-chronological order
|
||||
- Files changed: src/components/CareerConstellation.tsx
|
||||
- **Learnings for future iterations:**
|
||||
- `pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2')` auto-downloads and caches the ONNX model (~23MB)
|
||||
- First run takes a few seconds for model download; subsequent runs are near-instant from cache
|
||||
- The pipeline's `pooling: 'mean'` and `normalize: true` options handle mean-pooling and L2 normalization in one step — no manual tensor manipulation needed
|
||||
- `output.data` is a `Float32Array`; wrap in `Array.from()` for a plain number array
|
||||
---
|
||||
|
||||
## 2026-02-15 - US-002
|
||||
- Added `buildEmbeddingTexts()` function to `src/lib/search.ts`
|
||||
- Imports all raw data files (consultations, skills, kpis, investigations, documents)
|
||||
- Generates natural-language paragraphs for each palette item type:
|
||||
- Consultations: role, org, duration, history narrative, examination bullets, coded entry descriptions
|
||||
- Skills: name, category, frequency, proficiency %, years of experience
|
||||
- Achievements: title, subtitle, full KPI explanation + story context + outcomes
|
||||
- Investigations: name, methodology, tech stack, results
|
||||
- Education: title, type, institution, duration, classification, research detail, notes (from documents.ts)
|
||||
- Quick Actions: title + subtitle
|
||||
- IDs match PaletteItem IDs (e.g. `exp-{id}`, `skill-{id}`, `ach-{i}`, `proj-{id}`, `edu-{i}`, `action-{i}`)
|
||||
- Typecheck and lint pass
|
||||
- Files changed: `src/lib/search.ts`
|
||||
- **Learnings for future iterations:**
|
||||
- Education items in `buildPaletteData()` are hardcoded arrays (not iterated from `documents`), with ids `edu-0` through `edu-3`. The mapping to `documents.ts` entries is: edu-0→doc-mary-seacole, edu-1→doc-mpharm, edu-2→doc-alevels, edu-3→doc-gphc
|
||||
- Achievement items are similarly hardcoded with ids `ach-0` through `ach-3`, each linked to a KPI id
|
||||
- Quick action items are `action-0` through `action-3`
|
||||
- `documents.ts` is imported but wasn't previously used in `search.ts` — now used for education embedding text
|
||||
---
|
||||
|
||||
## 2026-02-15 - US-003
|
||||
- Updated `scripts/generate-embeddings.ts` to import `buildEmbeddingTexts()` and generate full embeddings
|
||||
- Script embeds all 42 palette items sequentially using `Xenova/all-MiniLM-L6-v2`
|
||||
- Outputs `src/data/embeddings.json` as `Array<{ id: string, embedding: number[] }>`
|
||||
- Each embedding is a 384-dimensional float array
|
||||
- File is ~453KB (42 items × 384 floats with pretty-printed JSON)
|
||||
- `npm run generate-embeddings` regenerates the file successfully
|
||||
- Typecheck and lint pass
|
||||
- Files changed: `scripts/generate-embeddings.ts`, `src/data/embeddings.json`
|
||||
- **Learnings for future iterations:**
|
||||
- `import.meta.dirname` works in tsx/Node ESM scripts — use it instead of `__dirname` (which isn't available in ESM)
|
||||
- `@/` path alias works in `npx tsx` scripts because tsx resolves tsconfig paths automatically
|
||||
- The embeddings file is ~450KB with pretty-print; could be reduced with compact JSON but readability is preferred for now
|
||||
- Processing 42 items takes ~10-15 seconds on first run (model cached after first download)
|
||||
---
|
||||
|
||||
## 2026-02-15 - US-004
|
||||
- Created `src/lib/embedding-model.ts` with three exports: `initModel()`, `embedQuery()`, `isModelReady()`
|
||||
- Module-level `let extractor` pattern avoids React re-render issues
|
||||
- `initModel()` uses `loading` guard to prevent duplicate pipeline loads
|
||||
- `embedQuery()` uses same `pooling: 'mean'` and `normalize: true` as the build script
|
||||
- `initModel()` called fire-and-forget in `App.tsx` `useEffect([], [])` — runs during boot phase
|
||||
- Silent failure: try/catch swallows errors, `isModelReady()` stays false
|
||||
- Typecheck, lint, and build all pass
|
||||
- Files changed: `src/lib/embedding-model.ts` (new), `src/App.tsx`
|
||||
- **Learnings for future iterations:**
|
||||
- `FeatureExtractionPipeline` type is exported from `@xenova/transformers` and can be used for the module-level variable
|
||||
- The `loading` boolean guard prevents race conditions if `initModel()` is called multiple times (e.g., React strict mode double-mount)
|
||||
- `initModel()` is intentionally not awaited — it's fire-and-forget so it doesn't block the boot animation
|
||||
- Consumers should check `isModelReady()` before calling `embedQuery()` — it throws if model isn't loaded
|
||||
---
|
||||
|
||||
## 2026-02-15 - US-005
|
||||
- Created `src/lib/semantic-search.ts` with cosine similarity search and embeddings loader
|
||||
- `semanticSearch()` computes cosine similarity, filters by threshold (default 0.3), returns sorted by score descending
|
||||
- `loadEmbeddings()` imports `embeddings.json` via Vite's native JSON import and returns typed array
|
||||
- Typecheck and lint pass (0 new warnings)
|
||||
- Files changed: `src/lib/semantic-search.ts` (new)
|
||||
- **Learnings for future iterations:**
|
||||
- Vite handles JSON imports natively — `import data from '@/data/embeddings.json'` just works, no dynamic import needed
|
||||
- Since embeddings are already L2-normalized (from pipeline's `normalize: true`), cosine similarity simplifies to just the dot product. However, the full formula is kept for correctness in case non-normalized vectors are ever used
|
||||
- With only ~42 items and 384-d vectors, brute-force cosine similarity is fast enough — no need for approximate nearest neighbor libraries
|
||||
---
|
||||
|
||||
## 2026-02-15 - US-006
|
||||
- Integrated semantic search into CommandPalette with Fuse.js fallback
|
||||
- When `isModelReady()` is true: debounces query by 200ms, calls `embedQuery()`, runs `semanticSearch()` against preloaded embeddings, maps result IDs back to PaletteItems via O(1) Map lookup
|
||||
- When model is NOT ready: uses existing Fuse.js search (behavior preserved exactly)
|
||||
- Results maintain `groupBySection()` grouping and section ordering
|
||||
- Existing keyboard navigation, action routing, and UI unchanged
|
||||
- Semantic results state is cleared when palette opens/closes and when query is empty
|
||||
- Error handling: any failure in embedQuery/semanticSearch silently falls back to Fuse.js
|
||||
- Typecheck, lint, and build all pass
|
||||
- Browser verified: Fuse.js fallback works correctly; ONNX model loads asynchronously during boot and activates semantic search when ready
|
||||
- Files changed: `src/components/CommandPalette.tsx`
|
||||
- **Learnings for future iterations:**
|
||||
- Semantic search is async so it can't live in a `useMemo` — use `useState` + debounced `useEffect` pattern instead
|
||||
- The `useRef + setTimeout` debounce pattern works well here: set `debounceRef.current = setTimeout(...)`, clear it in the cleanup function, and in early-return paths
|
||||
- `isModelReady()` is a synchronous check — call it before setting up the debounce timeout to avoid unnecessary delays when model isn't loaded
|
||||
- The ONNX model takes several seconds to load in the browser (downloads ~23MB first time, then cached in IndexedDB), so initial searches will always use Fuse.js fallback
|
||||
- `loadEmbeddings()` is cheap (just returns the already-imported JSON) — safe to call in `useMemo` without performance concern
|
||||
---
|
||||
|
||||
## 2026-02-15 - US-007
|
||||
- Created `src/components/ChatWidget.tsx` — floating chat button with toggle state
|
||||
- 48px circular button (40px on mobile <640px), fixed bottom-right, teal accent background, white MessageCircle icon
|
||||
- Entrance animation: fade + translateY(8px→0), 1s delay after mount, via framer-motion variants
|
||||
- Respects `prefers-reduced-motion` — skips animation, shows immediately
|
||||
- Hover: shadow-md → shadow-lg + scale(1.05), 150ms transition
|
||||
- z-index 90 (below command palette z-1000)
|
||||
- onClick toggles `isOpen` state, swaps icon between MessageCircle and X
|
||||
- Mounted in `DashboardLayout.tsx` alongside CommandPalette and DetailPanel
|
||||
- Typecheck, lint (0 errors), and build all pass
|
||||
- Browser verified: button visible at bottom-right, toggle works (Open chat ↔ Close chat)
|
||||
- Files changed: `src/components/ChatWidget.tsx` (new), `src/components/DashboardLayout.tsx`
|
||||
- **Learnings for future iterations:**
|
||||
- Responsive sizing via Tailwind classes (`h-10 w-10 sm:h-12 sm:w-12`) works well with inline style for non-Tailwind properties (boxShadow, border-radius)
|
||||
- `AnimatePresence` is already imported and ready for the panel animation in US-008
|
||||
- The `isOpen` state lives in ChatWidget — US-008 will add the panel UI inside the same component
|
||||
- Hover effects use `onMouseEnter/Leave` with direct style mutation (same pattern as other dashboard components)
|
||||
---
|
||||
|
||||
## 2026-02-15 - US-008
|
||||
- Built chat panel UI inside `ChatWidget.tsx` with header, message area, and input
|
||||
- Panel opens above the floating button with scale+opacity entrance/exit animation via framer-motion `AnimatePresence`
|
||||
- Messages stored as `Array<{ role: 'user' | 'assistant', content: string }>` in component state
|
||||
- User messages right-aligned in teal-tinted bubbles (`var(--accent-light)` bg, `var(--accent-border)` border)
|
||||
- Assistant messages left-aligned in light gray bubbles (`var(--bg-dashboard)` bg, `var(--border-light)` border)
|
||||
- Message corner radii differ: user bubbles have small bottom-right radius, assistant bubbles small bottom-left (conversational feel)
|
||||
- Input area: textarea with Enter to submit, Shift+Enter for newline. Send button enabled/disabled based on input content
|
||||
- Empty state shows placeholder text when no messages yet
|
||||
- Auto-scrolls to latest message via `useRef` + `scrollIntoView`
|
||||
- Auto-focuses input when panel opens (200ms delay for animation)
|
||||
- Responsive: on mobile (<640px), panel is full-width bottom sheet with rounded top corners; on desktop, 380px wide positioned above the button
|
||||
- Panel entrance: scale(0.95)+opacity(0) → scale(1)+opacity(1), 200ms. Exit: reverse, 150ms
|
||||
- Respects `prefers-reduced-motion` — skips all animation
|
||||
- Close button in header triggers `setIsOpen(false)` (same as floating button toggle)
|
||||
- Submitting appends both user message and placeholder assistant response to state
|
||||
- Typecheck, lint (0 errors), and build all pass
|
||||
- Browser verified: panel opens/closes correctly, messages display, input works, Enter submits, close button works
|
||||
- Files changed: `src/components/ChatWidget.tsx`
|
||||
- **Learnings for future iterations:**
|
||||
- `AnimatePresence` with `key` prop on the panel div is needed for exit animations to work
|
||||
- Panel uses `transformOrigin: 'bottom right'` for natural scale animation from the button corner
|
||||
- CSS-in-JS `<style>` tag with `data-chat-panel` attribute handles responsive width/height (Tailwind can't express max-height conditionally based on viewport width easily)
|
||||
- `textarea` with `rows={1}` and `maxHeight: 80px` gives auto-growing feel; `resize: none` prevents manual resize
|
||||
- The `ChatMessage` interface (`{ role, content }`) is ready to be extended for US-009 Gemini integration — same shape as typical LLM message format
|
||||
- `onFocus/onBlur` border color transitions on the textarea give a polished input interaction
|
||||
---
|
||||
|
||||
## 2026-02-15 - US-009
|
||||
- Created `src/lib/gemini.ts` — Gemini Flash streaming integration module
|
||||
- `sendChatMessage(messages)` async generator that streams SSE tokens from Gemini 2.0 Flash
|
||||
- `isGeminiAvailable()` checks for `VITE_GEMINI_API_KEY` env var
|
||||
- `parseItemIds(text)` extracts `[ITEMS: id1, id2]` from response text
|
||||
- `stripItemsSuffix(text)` removes the `[ITEMS: ...]` line for clean display
|
||||
- System prompt built from `buildEmbeddingTexts()` output — full CV context (~42 items)
|
||||
- Model instructed to answer concisely and append relevant palette item IDs
|
||||
- Rewired `ChatWidget.tsx` to use real Gemini API instead of placeholder responses
|
||||
- Streaming: tokens progressively appear in assistant message bubble
|
||||
- Typing indicator (Loader2 spinner + "Thinking...") shown while waiting for first token
|
||||
- Input disabled during streaming, send button grayed out
|
||||
- Error handling: API failures show "Sorry, I couldn't process that. Please try again."
|
||||
- Missing API key: panel shows "Chat is currently unavailable", input area hidden
|
||||
- Conversation history capped at 10 messages before sending to API
|
||||
- Assistant messages store parsed item IDs as `<!--ITEMS:id1,id2-->` HTML comment (for US-010)
|
||||
- Messages sent to API have metadata stripped to keep context clean
|
||||
- Typecheck, lint (0 errors), and build all pass
|
||||
- Files changed: `src/lib/gemini.ts` (new), `src/components/ChatWidget.tsx`
|
||||
- **Learnings for future iterations:**
|
||||
- Gemini SSE format: `data:` prefix per line, JSON body with `candidates[0].content.parts[0].text`
|
||||
- `system_instruction` field in Gemini request body sets the system prompt (not a message in `contents`)
|
||||
- Gemini role mapping: `'assistant'` → `'model'` in the API's `contents` array
|
||||
- Buffer-based SSE parsing handles chunk boundaries: split on `\n`, keep last incomplete line in buffer
|
||||
- `buildEmbeddingTexts()` is a great source for structured CV context — natural language paragraphs per item
|
||||
- The `<!--ITEMS:-->` HTML comment pattern is invisible when rendered but parseable by US-010 for item card display
|
||||
- `useCallback` on `handleSubmit` with `[inputValue, isStreaming, messages]` deps is needed because it reads all three
|
||||
---
|
||||
|
||||
## 2026-02-15 - US-010
|
||||
- Extracted `iconByType` and `iconColorStyles` from `CommandPalette.tsx` into shared `src/lib/palette-icons.ts`
|
||||
- Updated `CommandPalette.tsx` to import from the shared module (no behavioral change)
|
||||
- Added `onAction?: (action: PaletteAction) => void` prop to `ChatWidget` — same pattern as `CommandPalette`
|
||||
- `DashboardLayout.tsx` passes `handlePaletteAction` to `ChatWidget` (same handler used by CommandPalette)
|
||||
- ChatWidget builds a `paletteMap` (Map<id, PaletteItem>) via `useMemo` for O(1) item lookups
|
||||
- Added `getMessageItemIds()` to parse `<!--ITEMS:id1,id2-->` HTML comments from message content
|
||||
- Added `getMessageItems()` to resolve parsed IDs to PaletteItem objects via the map
|
||||
- Assistant message bubbles now render compact clickable item cards below text when items are referenced:
|
||||
- Cards use same icon/color scheme from CommandPalette (22px icon + title + subtitle)
|
||||
- Cards have hover highlight (`var(--accent-light)`) and trigger `onAction(item.action)` on click
|
||||
- Cards only appear after streaming completes (when `<!--ITEMS:-->` metadata is in final content)
|
||||
- If no items referenced or IDs don't match, no cards shown — just text
|
||||
- Typecheck, lint (0 errors), and build all pass
|
||||
- Files changed: `src/lib/palette-icons.ts` (new), `src/components/ChatWidget.tsx`, `src/components/CommandPalette.tsx`, `src/components/DashboardLayout.tsx`
|
||||
- **Learnings for future iterations:**
|
||||
- Extracting shared constants to `src/lib/` is the right pattern — both `CommandPalette` and `ChatWidget` now use the same icon mappings without duplication
|
||||
- `buildPaletteData()` is pure (no side effects) and idempotent — safe to call in `useMemo` with empty deps
|
||||
- The `<!--ITEMS:-->` HTML comment regex `<!--ITEMS:([^>]*)-->` works reliably; `[^>]*` captures everything between the colons and closing
|
||||
- Item card buttons use `fontFamily: 'inherit'` to pick up the panel's `font-ui` — without this, browser defaults apply
|
||||
- The `overflow: 'hidden'` on the message bubble container is needed so the item cards section (with its own border-top) stays visually contained within the bubble's border-radius
|
||||
---
|
||||
|
||||
## 2026-02-15 - US-011
|
||||
- Updated ChatWidget mobile breakpoint from `sm` (640px) to `md` (768px)
|
||||
- Changed mobile panel from 85vh bottom-sheet to full-screen overlay using `position: fixed; inset: 0` with `100dvh` height
|
||||
- Panel z-index on mobile bumped to 101 (`max-md:z-[101]`) to render above TopBar (z-100) and nav (z-99)
|
||||
- Floating chat button hidden on mobile when panel is open via `max-md:!hidden` Tailwind class
|
||||
- Fixed specificity issue: inline `style={{ display: 'flex' }}` was overriding Tailwind's `hidden` — moved flex/centering to Tailwind classes (`flex items-center justify-center`)
|
||||
- Safe area insets applied via `env(safe-area-inset-*)` CSS on the `[data-chat-panel]` element for notched devices
|
||||
- Input area stays pinned to bottom via existing flex layout (flex-col container + flex-1 message area + flex-shrink-0 input)
|
||||
- Desktop behavior unchanged: 380px wide, anchored bottom-right, max-height 480px, floating button visible
|
||||
- Panel open/close animations still respect `prefers-reduced-motion`
|
||||
- Typecheck, lint (0 errors), and build all pass
|
||||
- Browser verified at 375×812 (mobile) and 1280×800 (desktop): full-screen overlay works, button hides/shows correctly, close button works
|
||||
- Files changed: `src/components/ChatWidget.tsx`
|
||||
- **Learnings for future iterations:**
|
||||
- Inline `style` properties always override CSS classes — to allow Tailwind responsive utilities (like `max-md:hidden`) to work, move conflicting properties (especially `display`) to Tailwind classes instead
|
||||
- Use `!important` modifier (`max-md:!hidden`) when competing with framer-motion's inline styles that can't be easily removed
|
||||
- TopBar (`z-100`) and nav (`z-99`) sit above the chat panel's default `z-90` — mobile full-screen panels need `z-101+` to overlay properly
|
||||
- `100dvh` (dynamic viewport height) is essential for mobile full-screen panels — it accounts for browser chrome (address bar, toolbar) unlike `100vh`
|
||||
- The `[data-chat-panel]` CSS selector in the `<style>` block is the right place for responsive size rules since Tailwind can't conditionally set max-height based on viewport width
|
||||
---
|
||||
|
||||
## 2026-02-15 - US-012
|
||||
- Replaced empty-state centered text with welcome bubble + suggested question chips
|
||||
- Welcome bubble styled as assistant message (left-aligned, `var(--bg-dashboard)` bg, `var(--border-light)` border)
|
||||
- Added `SUGGESTED_QUESTIONS` const array at module top for easy future editing
|
||||
- Three chips: "What's his NHS experience?", "Tell me about his data skills", "What projects has he built?"
|
||||
- Chips styled: rounded-full, teal accent border, teal hover tint, `font-ui` 12.5px
|
||||
- Clicking a chip calls `handleSubmit(questionText)` — same codepath as typing + Enter
|
||||
- Refactored `handleSubmit` to accept optional `overrideText` parameter (avoids stale state issue with `setInputValue` + immediate submit)
|
||||
- Wrapped send button `onClick` in arrow function to prevent passing MouseEvent as text argument
|
||||
- Welcome/chips visible when `messages.length === 0`, replaced by conversation once any message is sent
|
||||
- Typecheck passes (0 errors), lint passes (0 new errors/warnings)
|
||||
- Browser verified: welcome bubble displays correctly, chips render, clicking chip sends message and replaces welcome state
|
||||
- Files changed: `src/components/ChatWidget.tsx`
|
||||
- **Learnings for future iterations:**
|
||||
- When refactoring a callback to accept optional parameters, wrap `onClick={handler}` as `onClick={() => handler()}` to prevent React from passing the SyntheticEvent as the first argument
|
||||
- `SUGGESTED_QUESTIONS` as a module-level const is the simplest approach — easily editable, no data file needed for 3 items
|
||||
- The `handleSubmit(overrideText?)` pattern avoids the stale-state problem: `setInputValue(text)` followed by immediate `handleSubmit()` would read the old `inputValue` since React batches state updates
|
||||
---
|
||||
|
||||
## 2026-02-15 - US-013
|
||||
- Downloaded all-MiniLM-L6-v2 model files to `public/models/Xenova/all-MiniLM-L6-v2/`:
|
||||
- `config.json`, `tokenizer.json`, `tokenizer_config.json`, `onnx/model_quantized.onnx` (~22MB)
|
||||
- Updated `src/lib/embedding-model.ts`:
|
||||
- `env.localModelPath = '/models/'` — Vite serves `public/` at root
|
||||
- `env.allowRemoteModels = false` — prevents any HF CDN fallback
|
||||
- `env.useBrowserCache = false` — prevents stale Cache API entries from interfering
|
||||
- Updated `scripts/generate-embeddings.ts`:
|
||||
- `env.localModelPath = resolve(import.meta.dirname, '..', 'public', 'models')` — absolute path for Node.js
|
||||
- `env.allowRemoteModels = false`
|
||||
- Model files committed as static assets (not in .gitignore)
|
||||
- Browser verified: all 4 model files fetched from `localhost:5173/models/` with 200 OK, zero `huggingface.co` requests
|
||||
- Semantic search verified working: "data analysis" returns multi-category results (Core Skills, Active Projects, Achievements)
|
||||
- Build script (`npm run generate-embeddings`) still works with local model files
|
||||
- Typecheck passes (0 errors), lint passes (0 new errors/warnings)
|
||||
- Files changed: `src/lib/embedding-model.ts`, `scripts/generate-embeddings.ts`, `public/models/Xenova/all-MiniLM-L6-v2/` (new directory with 4 files)
|
||||
- **Learnings for future iterations:**
|
||||
- `@xenova/transformers` env configuration: `env.localModelPath` sets the base path, `env.allowRemoteModels = false` prevents CDN fallback, `env.useBrowserCache = false` bypasses Browser Cache API
|
||||
- The library constructs paths as `{localModelPath}/{modelId}/{filename}` — so `/models/` + `Xenova/all-MiniLM-L6-v2` + `/onnx/model_quantized.onnx`
|
||||
- Browser Cache API can retain stale entries from previous HF CDN loads — setting `useBrowserCache = false` forces fresh fetches from the configured local path
|
||||
- For Node.js scripts, use an absolute filesystem path for `localModelPath` (not a URL)
|
||||
- The quantized ONNX model (`model_quantized.onnx`) is ~22MB — acceptable for a static asset since it's cached after first load
|
||||
---
|
||||
|
||||
## 2026-02-15 - US-014
|
||||
- Reviewed and tightened system prompt in `src/lib/gemini.ts` for Gemini 3 Flash Preview
|
||||
- Prefixed each CV entry with its item ID (`[exp-nhs-nwicb] ...`) so the model can directly map entries to IDs for the ITEMS suffix
|
||||
- Replaced numbered rules with cleaner bullet-point format, added rule against fabricating URLs/contacts
|
||||
- Provided concrete example in ITEMS instruction (`[ITEMS: exp-nhs-nwicb, skill-python]`) instead of generic placeholders
|
||||
- Verified model constant (`GEMINI_MODEL = 'gemini-3-flash-preview'`), display name, API URL, and header indicator were already in place from previous iteration
|
||||
- Confirmed `gemini-3-flash-preview` is the correct model ID via Google AI docs
|
||||
- Typecheck (0 errors), lint (0 new warnings), and production build all pass
|
||||
- Files changed: `src/lib/gemini.ts`
|
||||
- **Learnings for future iterations:**
|
||||
- Prefixing CV data with `[item-id]` in the system prompt makes ID references more reliable — model can directly see and copy IDs rather than inferring from patterns
|
||||
- Concrete examples in format instructions (e.g., `[ITEMS: exp-nhs-nwicb, skill-python]`) are more reliable than generic placeholders (`[ITEMS: id1, id2]`)
|
||||
- The `GEMINI_MODEL` and `GEMINI_DISPLAY_NAME` constants in `gemini.ts` are already exported and used by `ChatWidget.tsx` — single source of truth for model identity
|
||||
---
|
||||
|
||||
## 2026-02-16 - US-014
|
||||
- Renamed `src/lib/gemini.ts` → `src/lib/llm.ts` via `git mv`
|
||||
- Rewrote `llm.ts` for OpenRouter API (OpenAI-compatible format):
|
||||
- API endpoint: `https://openrouter.ai/api/v1/chat/completions`
|
||||
- Model: `z-ai/glm-5` (exported as `LLM_MODEL`)
|
||||
- Display name: `GLM-5` (exported as `LLM_DISPLAY_NAME`)
|
||||
- Auth: `Authorization: Bearer` header using `VITE_OPEN_ROUTER_API_KEY` env var
|
||||
- Added `HTTP-Referer` and `X-Title` headers per OpenRouter docs
|
||||
- System prompt sent as `role: 'system'` message (first in messages array) instead of Gemini's `system_instruction` field
|
||||
- SSE streaming parses `choices[0].delta.content` instead of Gemini's `candidates[0].content.parts[0].text`
|
||||
- No `'model'` role mapping needed — OpenRouter uses `'assistant'` directly
|
||||
- Request body uses `max_tokens` (OpenAI format) instead of `maxOutputTokens` (Gemini format)
|
||||
- Renamed `isGeminiAvailable()` → `isLLMAvailable()`, updated all call sites in `ChatWidget.tsx`
|
||||
- Updated all imports: `ChatWidget.tsx` now imports from `@/lib/llm` instead of `@/lib/gemini`
|
||||
- Renamed `GEMINI_DISPLAY_NAME` → `LLM_DISPLAY_NAME` and updated ChatWidget header display
|
||||
- `buildSystemPrompt()` now exported (was private) for use by benchmark script in US-015
|
||||
- Fixed merge conflict in `Ralph/prd.json` (resolved to keep OpenRouter migration stories US-014–US-019)
|
||||
- `parseItemIds()` and `stripItemsSuffix()` unchanged — response format spec is the same
|
||||
- Typecheck (0 errors), lint (0 new errors), production build all pass
|
||||
- Files changed: `src/lib/gemini.ts` → `src/lib/llm.ts` (renamed + rewritten), `src/components/ChatWidget.tsx`, `Ralph/prd.json`
|
||||
- **Learnings for future iterations:**
|
||||
- OpenRouter uses OpenAI-compatible format: `messages` array with `role: 'system'|'user'|'assistant'`, `choices[0].delta.content` for streaming
|
||||
- Gemini's `system_instruction` field → OpenRouter's first message with `role: 'system'`
|
||||
- Gemini's `'model'` role → OpenRouter's `'assistant'` role (no mapping needed since ChatMessage already uses 'assistant')
|
||||
- OpenRouter requires `HTTP-Referer` and `X-Title` headers — use `window.location.origin` for referer
|
||||
- `VITE_OPEN_ROUTER_API_KEY` replaces `VITE_GEMINI_API_KEY` — update `.env` file accordingly
|
||||
- `buildSystemPrompt()` is now exported from `llm.ts` — benchmark script (US-015) can import it directly instead of duplicating the logic
|
||||
- The benchmark script (`scripts/benchmark.ts`) still uses the old Gemini API — needs separate migration in US-015
|
||||
---
|
||||
|
||||
## 2026-02-16 - US-015
|
||||
- Migrated `scripts/benchmark.ts` from Gemini API to OpenRouter API
|
||||
- Replaced `GEMINI_MODEL` / `GEMINI_API_BASE` with `LLM_MODEL = 'z-ai/glm-5'` and `OPENROUTER_API_URL`
|
||||
- Updated `getApiKey()` to read `VITE_OPEN_ROUTER_API_KEY` from `.env`
|
||||
- Renamed `callGemini()` → `callLLM()` with OpenRouter request format:
|
||||
- OpenAI-compatible messages array with `role: 'system'` for system prompt
|
||||
- Auth via `Authorization: Bearer` header (not URL param)
|
||||
- Added `HTTP-Referer` and `X-Title` headers per OpenRouter docs
|
||||
- Response parsing: `choices[0].message.content` (non-streaming format)
|
||||
- `max_tokens` (OpenAI format) instead of `maxOutputTokens` (Gemini format)
|
||||
- Updated `buildSystemPrompt()` to match production `llm.ts` format: item ID prefixes (`[item-id]`), same rules and instructions
|
||||
- Scoring calls also use OpenRouter via `callLLM()` (same model)
|
||||
- Rate limit retry logic kept same structure, updated error message text for OpenRouter
|
||||
- Model name in results output updated to `z-ai/glm-5`
|
||||
- Verified end-to-end: `npm run benchmark` runs all 10 questions, scores them, saves results to `scripts/benchmark-results/iteration-0.json`
|
||||
- Typecheck passes (0 errors), lint passes (0 new errors/warnings)
|
||||
- Files changed: `scripts/benchmark.ts`
|
||||
- **Learnings for future iterations:**
|
||||
- Cannot import `buildSystemPrompt` from `src/lib/llm.ts` into Node scripts — `llm.ts` uses `import.meta.env` (Vite-only) and `window.location` (browser-only). Keep a mirrored copy in the benchmark script
|
||||
- OpenRouter non-streaming response format: `{ choices: [{ message: { content: '...' } }] }` — different from streaming which uses `delta.content`
|
||||
- For Node.js scripts, use a static URL for `HTTP-Referer` header (e.g., `'https://andycharlwood.co.uk'`) since `window.location` isn't available
|
||||
- The benchmark script's `buildSystemPrompt()` should be kept in sync with `llm.ts` manually — if one changes, update the other (US-016/US-017 will modify the production prompt)
|
||||
---
|
||||
|
||||
## 2026-02-16 - US-016
|
||||
- Rewrote `buildSystemPrompt()` in `src/lib/llm.ts` with full CV context from `References/CV_v4.md`
|
||||
- Replaced `buildEmbeddingTexts()` approach (one-paragraph-per-item) with structured CV format:
|
||||
- Profile section with professional summary
|
||||
- Career History with full achievement bullets per role, clinical specialties, methodology details
|
||||
- Projects with tech stack and outcomes
|
||||
- Education with grades, subjects, research topics, classifications
|
||||
- Skills in compact format with years and proficiency
|
||||
- NHS employment (May 2022+, all at Norfolk & Waveney ICB) explicitly distinguished from private sector (Tesco PLC)
|
||||
- Clinical specialties listed under High-Cost Drugs role: rheumatology, ophthalmology (wet AMD, DMO, RVO), dermatology, gastroenterology, neurology, migraine
|
||||
- dm+d integration details, switching algorithm methodology, tirzepatide commissioning context all included
|
||||
- Mary Seacole Programme: 2018, 78%, NHS Leadership Academy
|
||||
- A-Levels: Mathematics A*, Chemistry B, Politics C — Highworth Grammar School 2009–2011
|
||||
- System prompt is 7,982 bytes (under 8KB limit)
|
||||
- Removed `buildEmbeddingTexts` import from llm.ts (no longer needed)
|
||||
- Mirrored identical prompt in `scripts/benchmark.ts` (with comment noting manual sync requirement)
|
||||
- Removed `buildEmbeddingTexts` import from benchmark.ts
|
||||
- Typecheck (0 errors), lint (0 errors), production build all pass
|
||||
- Files changed: `src/lib/llm.ts`, `scripts/benchmark.ts`
|
||||
- **Learnings for future iterations:**
|
||||
- The structured CV format (markdown headers + bullets per role) is more effective for LLM Q&A than one-paragraph-per-palette-item — LLMs parse structured markdown better
|
||||
- Item IDs are embedded in section headers (e.g., `### [exp-deputy-head-2024]`) rather than as line prefixes — cleaner format that still allows the model to reference IDs
|
||||
- System prompt no longer depends on `buildEmbeddingTexts()` — the CV context is hardcoded. This means prompt content and embedding texts can diverge (prompt is optimised for Q&A, embeddings for semantic search)
|
||||
- When the prompt is close to the 8KB limit, trim verbose connecting phrases and redundant qualifiers first — the specific facts and numbers are what matter for accuracy
|
||||
---
|
||||
|
||||
## 2026-02-16 - US-017
|
||||
- Improved Response Rules in system prompt (`src/lib/llm.ts`) with numbered, clearer behavioral instructions:
|
||||
1. Explicit "I don't have that information" phrasing for missing data
|
||||
2. Stronger employer distinction instruction with "Never conflate the two"
|
||||
3. Aggregation instruction broadened to include "projects" alongside tools/skills/achievements
|
||||
4. Explicit prohibition on "approximately" and "around" when exact figures exist
|
||||
5. Adaptive length instruction: thorough for list/detail questions, concise for simple ones
|
||||
- Lowered temperature from 0.7 to 0.4 for more consistent factual responses
|
||||
- Increased max_tokens from 512 to 800 to avoid truncating detailed answers
|
||||
- Preserved [ITEMS: ...] suffix instruction unchanged
|
||||
- Mirrored identical changes in `scripts/benchmark.ts` (prompt, temperature defaults, max_tokens defaults)
|
||||
- Typecheck (0 errors), lint (0 errors), production build passes
|
||||
- Files changed: `src/lib/llm.ts`, `scripts/benchmark.ts`
|
||||
- **Learnings for future iterations:**
|
||||
- Numbered rules in system prompts tend to be followed more reliably by LLMs than bullet points
|
||||
- Temperature 0.4 is a good balance for factual Q&A — low enough for consistency, high enough to avoid repetitive phrasing
|
||||
- The benchmark script's `callLLM()` uses default params `temperature = 0.4, maxTokens = 800` — these match production. The scoring call overrides temperature to 0 for deterministic scoring
|
||||
- The adaptive length rule ("thorough for detailed questions, concise for simple ones") replaces the fixed "2-4 sentences" rule — this should improve scores on questions requiring enumeration
|
||||
---
|
||||
|
||||
## 2026-02-16 - US-018
|
||||
- Enriched `buildEmbeddingTexts()` in `src/lib/search.ts` with significantly richer text per item:
|
||||
- **Consultations**: Added employer classification (NHS vs private sector), `plan` outcomes alongside `examination` bullets, and role-specific context (clinical specialties for high-cost drugs, dm+d/tirzepatide for deputy head, switching algorithm detail for interim head, LPC/community pharmacy for Tesco)
|
||||
- **Skills**: Added `skillContextMap` with per-skill practical application context — links each skill to specific roles, projects, and outcomes (e.g., Python → switching algorithm, CD monitoring; Power BI → PharMetrics dashboard; NICE TA → clinical specialties covered)
|
||||
- **Projects**: Added `projectContextMap` with role context and cross-references (e.g., CD monitoring links to controlled drugs skill, Blueteq links to clinical specialties)
|
||||
- **Achievements**: Added full KPI story period alongside existing context/role/outcomes
|
||||
- **Education**: Added `researchGrade` to embedding text (75.1% Distinction for MPharm research)
|
||||
- Regenerated `src/data/embeddings.json` — 42 items × 384-d vectors (file now ~453KB, 74% rewritten due to new vector values)
|
||||
- Typecheck (0 errors), lint (0 new warnings), production build all pass
|
||||
- Files changed: `src/lib/search.ts`, `src/data/embeddings.json`, `Ralph/prd.json`
|
||||
- **Learnings for future iterations:**
|
||||
- Enriching embedding texts with role context and cross-references dramatically improves semantic search quality — queries like "clinical specialties" now match the high-cost drugs role AND the NICE TA skill AND clinical pathways skill, not just items with "clinical" in the title
|
||||
- The `skillContextMap` and `projectContextMap` approach keeps enrichment data co-located with the embedding function rather than spreading it across data files — easier to maintain and update
|
||||
- Embedding text should include employer classification (NHS vs private sector) since benchmark questions specifically test this distinction
|
||||
- Cross-referencing between items (e.g., "Related to controlled drugs skill") helps semantic search surface related items even when the query doesn't exactly match an item's primary topic
|
||||
---
|
||||
|
||||
## 2026-02-16 - US-019
|
||||
- Ran benchmark iteration 1 after structural prompt improvements → 18/20 score but Q10 had a zero due to ambiguous expected answer
|
||||
- **Structural prompt improvements applied to both `src/lib/llm.ts` and `scripts/benchmark.ts`:**
|
||||
- Added **Employment Timeline (IMPORTANT)** section explicitly separating NHS (~4 years, May 2022+) from private sector (Tesco PLC)
|
||||
- Added GPhC registration clarification ("professional licence, NOT an employer or NHS role")
|
||||
- Labeled Tesco role bullets as "Leadership training:" and "Leadership development:" for discoverability
|
||||
- Strengthened Rule 2 to include GPhC distinction
|
||||
- Trimmed verbose text to keep prompt under 8KB (final: 8,007 bytes)
|
||||
- Fixed Q10 benchmark config: expected answer was ambiguous about whether Andy "completed" the Tesco induction (he created it) and "has" NVQ3 (he supervised others through it). Updated to accurately reflect CV data
|
||||
- **Iteration 2 results: 19/20 — PASSED** (threshold: 18/20, no zeros)
|
||||
- Q01: 2/2 (was 0 — NHS vs Tesco now correctly distinguished)
|
||||
- Q02: 2/2 (was 1 — tirzepatide details now fully covered)
|
||||
- Q08: 2/2 (was 1 — dm+d details now fully covered)
|
||||
- Q09: 1/2 (missing "variance analysis" — not a critical gap)
|
||||
- Q10: 2/2 (was 0/1 — leadership training now fully covered with corrected expected answer)
|
||||
- Tested 5 general questions: "Tell me about Andy", "What does Andy do?", "How can I contact Andy?", "What is this website?", "What are Andy's strongest skills?" — all produce sensible, accurate responses. Contact question correctly responds "I don't have that information"
|
||||
- Results saved to `scripts/benchmark-results/iteration-2.json`
|
||||
- Files changed: `src/lib/llm.ts`, `scripts/benchmark.ts`, `scripts/benchmark-config.json`, `Ralph/prd.json`, `Ralph/progress.txt`
|
||||
- **Learnings for future iterations:**
|
||||
- The Employment Timeline section at the top of the system prompt is critical for employer classification — without it, the model conflated GPhC registration with NHS employment
|
||||
- Labeling achievements with their category (e.g., "Leadership training:") helps the model surface them under relevant queries
|
||||
- When a benchmark question's expected answer is ambiguous, fix the expected answer to match the source CV data rather than tweaking the prompt to match a potentially incorrect expectation
|
||||
- System prompt size limit of 8KB requires careful compression — trim verbose connecting words and redundant qualifiers, not facts
|
||||
- The `z-ai/glm-5` model responds well to explicit structural cues like "(IMPORTANT)" headers and bold emphasis in the system prompt
|
||||
- The yScale is the single source of truth for vertical positioning — reversing its domain is a one-line change that cascades to all D3 elements using it
|
||||
- Year guide lines, year dots, year labels, role initial positions, and simulation forceY all reference yScale — no individual element updates needed
|
||||
- buildScreenReaderDescription() is defined at module level (line ~63), not inside the component
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user