Merge branch 'ralph/llm-cv-knowledge'

Merge LLM context rewrite
This commit is contained in:
2026-02-16 01:38:05 +00:00
12 changed files with 14308 additions and 13687 deletions
+8 -8
View File
@@ -3,12 +3,12 @@ import { motion, AnimatePresence } from 'framer-motion'
import { MessageCircle, X, Send, Loader2 } from 'lucide-react'
import {
sendChatMessage,
isGeminiAvailable,
isLLMAvailable,
parseItemIds,
stripItemsSuffix,
GEMINI_DISPLAY_NAME,
LLM_DISPLAY_NAME,
type ChatMessage,
} from '@/lib/gemini'
} from '@/lib/llm'
import { buildPaletteData } from '@/lib/search'
import type { PaletteItem, PaletteAction } from '@/lib/search'
import { iconByType, iconColorStyles } from '@/lib/palette-icons'
@@ -64,7 +64,7 @@ export function ChatWidget({ onAction }: ChatWidgetProps) {
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const geminiAvailable = isGeminiAvailable()
const llmAvailable = isLLMAvailable()
// Build palette map for looking up items by ID
const paletteMap = useMemo(() => {
@@ -264,7 +264,7 @@ export function ChatWidget({ onAction }: ChatWidgetProps) {
color: 'var(--text-tertiary)',
}}
>
{GEMINI_DISPLAY_NAME}
{LLM_DISPLAY_NAME}
</span>
</div>
<button
@@ -306,7 +306,7 @@ export function ChatWidget({ onAction }: ChatWidgetProps) {
}}
className="pmr-scrollbar"
>
{!geminiAvailable && (
{!llmAvailable && (
<div
style={{
textAlign: 'center',
@@ -320,7 +320,7 @@ export function ChatWidget({ onAction }: ChatWidgetProps) {
</div>
)}
{geminiAvailable && messages.length === 0 && (
{llmAvailable && messages.length === 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{/* Welcome bubble — styled as assistant message */}
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
@@ -537,7 +537,7 @@ export function ChatWidget({ onAction }: ChatWidgetProps) {
</div>
{/* Input area */}
{geminiAvailable && (
{llmAvailable && (
<div
style={{
padding: '12px 16px',
+13440 -13440
View File
File diff suppressed because it is too large Load Diff
-158
View File
@@ -1,158 +0,0 @@
import { buildEmbeddingTexts } from '@/lib/search'
export interface ChatMessage {
role: 'user' | 'assistant'
content: string
}
export const GEMINI_MODEL = 'gemini-3-flash-preview'
export const GEMINI_DISPLAY_NAME = 'Gemini 3 Flash'
const GEMINI_API_BASE = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}`
function getApiKey(): string | undefined {
return import.meta.env.VITE_GEMINI_API_KEY as string | undefined
}
export function isGeminiAvailable(): boolean {
return !!getApiKey()
}
function buildSystemPrompt(): string {
const texts = buildEmbeddingTexts()
const cvContent = texts.map((t) => `[${t.id}] ${t.text}`).join('\n')
return `You are a helpful assistant on Andy Charlwood's portfolio website.
## Profile Data
Each entry is prefixed with its ID in square brackets.
${cvContent}
## Response Rules
- Answer ONLY from the profile data above. Never invent facts, roles, dates, or achievements.
- Be concise: 2-4 sentences. Professional and friendly tone.
- If the answer isn't in the profile, say so honestly.
- Do not fabricate URLs, email addresses, or contact details.
## Item References
End your response with a single line listing relevant item IDs:
[ITEMS: exp-nhs-nwicb, skill-python]
Only include IDs that directly support your answer. Omit the line if none are relevant.`
}
function buildRequestBody(
messages: ChatMessage[],
systemPrompt: string,
): object {
const contents = messages.map((msg) => ({
role: msg.role === 'assistant' ? 'model' : 'user',
parts: [{ text: msg.content }],
}))
return {
system_instruction: {
parts: [{ text: systemPrompt }],
},
contents,
generationConfig: {
temperature: 0.7,
maxOutputTokens: 512,
},
}
}
export async function* sendChatMessage(
messages: ChatMessage[],
): AsyncGenerator<string> {
const apiKey = getApiKey()
if (!apiKey) {
throw new Error('Gemini API key not configured')
}
const systemPrompt = buildSystemPrompt()
const body = buildRequestBody(messages, systemPrompt)
const response = await fetch(
`${GEMINI_API_BASE}:streamGenerateContent?alt=sse&key=${apiKey}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
)
if (!response.ok) {
throw new Error(`Gemini API error: ${response.status}`)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('No response body')
}
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
// Keep the last potentially incomplete line in the buffer
buffer = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed.startsWith('data:')) continue
const jsonStr = trimmed.slice(5).trim()
if (!jsonStr || jsonStr === '[DONE]') continue
try {
const parsed = JSON.parse(jsonStr)
const text = parsed?.candidates?.[0]?.content?.parts?.[0]?.text
if (text) {
yield text
}
} catch {
// Skip malformed JSON chunks
}
}
}
// Process any remaining buffer
if (buffer.trim().startsWith('data:')) {
const jsonStr = buffer.trim().slice(5).trim()
if (jsonStr && jsonStr !== '[DONE]') {
try {
const parsed = JSON.parse(jsonStr)
const text = parsed?.candidates?.[0]?.content?.parts?.[0]?.text
if (text) {
yield text
}
} catch {
// Skip malformed final chunk
}
}
}
} finally {
reader.releaseLock()
}
}
export function parseItemIds(text: string): string[] {
const match = text.match(/\[ITEMS:\s*([^\]]+)\]/)
if (!match) return []
return match[1]
.split(',')
.map((id) => id.trim())
.filter(Boolean)
}
export function stripItemsSuffix(text: string): string {
return text.replace(/\n?\[ITEMS:[^\]]*\]\s*$/, '').trim()
}
+228
View File
@@ -0,0 +1,228 @@
export interface ChatMessage {
role: 'user' | 'assistant'
content: string
}
export const LLM_MODEL = 'z-ai/glm-5'
export const LLM_DISPLAY_NAME = 'GLM-5'
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'
function getApiKey(): string | undefined {
return import.meta.env.VITE_OPEN_ROUTER_API_KEY as string | undefined
}
export function isLLMAvailable(): boolean {
return !!getApiKey()
}
export function buildSystemPrompt(): string {
return `You are a helpful assistant on Andy Charlwood's portfolio website. Answer questions about Andy's professional background using ONLY the information below.
## Profile
Andy Charlwood — MPharm, GPhC Registered Pharmacist. Norwich, UK.
Healthcare leader combining clinical pharmacy with Python, SQL, and data analytics (self-taught). Leading population health analytics for NHS Norfolk & Waveney ICB, serving 1.2M people. Specialises in prescribing data at scale — financial modelling, algorithm design, pathway development. Identified efficiency programmes worth £14.6M+ through automated analysis.
## Employment Timeline (IMPORTANT)
- **NHS employment**: May 2022present (all roles at NHS Norfolk & Waveney ICB). Total NHS service: ~4 years.
- **Private sector**: Nov 2017May 2022 at Tesco PLC (community pharmacy). This was NOT NHS employment.
- GPhC registration (Aug 2016) is a professional licence, NOT an employer or NHS role.
## Career History
### [exp-interim-head-2025] Interim Head, Population Health & Data Analysis
NHS Norfolk & Waveney ICB | MayNov 2025
Led population health initiatives and data-driven medicines optimisation, reporting to Associate Director of Pharmacy with accountability to CMO.
- Identified £14.6M efficiency programme; achieved over-target performance by October 2025
- Built Python switching algorithm: real-world GP prescribing data, 14,000 patients, £2.6M annual savings (£2M on target), compressed months into 3 days
- Novel GP payment system linking rewards to savings; 50% prescribing reduction within 2 months
- Presented to CMO bimonthly; led transformation to patient-level SQL analytics
### [exp-deputy-head-2024] Deputy Head, Population Health & Data Analysis
NHS Norfolk & Waveney ICB | Jul 2024Present (substantive role)
Data analytics strategy for medicines optimisation from real-world GP prescribing data.
- Managed £220M prescribing budget with forecasting models for proactive financial planning
- Created comprehensive dm+d medicines data table: standardised strengths, morphine equivalents, Anticholinergic Burden scoring — single source of truth for all medicines analytics
- Led DOAC switching financial modelling: interactive dashboard with rebate mechanics, patent expiry timelines
- Renegotiated pharmaceutical rebate terms ahead of patent expiry
- Tirzepatide commissioning (NICE TA1026): financial projections, cohort identification; authored executive paper advocating primary care model, driving system shift to GP-led delivery
- Built Python controlled drug monitoring: oral morphine equivalents across all opioid prescriptions, patient-level tracking, high-risk identification, diversion detection
- Improved team data fluency through training and self-serve tools
### [exp-high-cost-drugs-2022] High-Cost Drugs & Interface Pharmacist
NHS Norfolk & Waveney ICB | May 2022Jul 2024
Led NICE TA implementation and high-cost drug pathways across the ICS. Pathways spanning: rheumatology, ophthalmology (wet AMD, DMO, RVO), dermatology, gastroenterology, neurology, migraine.
- Blueteq automation: 70% form reduction, 200 hours immediate savings, 78 hours ongoing weekly gains
- Integrated Blueteq with secondary care databases for accurate high-cost drug spend tracking
- Python Sankey chart tool for patient pathway visualisation and trust compliance auditing
### [exp-pharmacy-manager-2017] Pharmacy Manager
Tesco PLC (private sector, NOT NHS) | Nov 2017May 2022
Community pharmacy with full operational autonomy (100-hour contract). LPC representative for Norfolk.
- Asthma screening process adopted nationally (~300 branches): reduced pharmacist time 60→6 hours/store/month, ~£1M revenue
- Leadership training: Created national induction training plan and eLearning modules for Tesco pharmacy staff
- Leadership development: Supervised two staff through NVQ3 to pharmacy technician registration; full HR responsibilities
## Projects
### [proj-inv-pharmetrics] PharMetrics Interactive Platform (2024, Live)
Real-time medicines expenditure dashboard for NHS decision-makers. Tech: Power BI, SQL, DAX. Tracks £220M prescribing budget.
### [proj-inv-switching-algorithm] Patient Switching Algorithm (2025, Complete)
Python algorithm using GP prescribing data to auto-identify patients for cost-effective alternatives. Tech: Python, Pandas, SQL. 14,000 patients, £2.6M annual savings, novel GP payment system.
### [proj-inv-blueteq-gen] Blueteq Generator (2023, Complete)
Automated Blueteq prior approval form creation. Tech: Python, SQL. 70% form reduction, 200 hours immediate savings, 78 hours ongoing weekly gains.
### [proj-inv-cd-monitoring] CD Monitoring System (2024, Complete)
Controlled drug monitoring calculating oral morphine equivalents (OME) across all opioid prescriptions. Tech: Python, SQL. Patient-level tracking, high-risk identification, diversion detection.
### [proj-inv-sankey-tool] Sankey Chart Analysis Tool (2023, Complete)
Patient journey visualisation through high-cost drug pathways. Tech: Python, Matplotlib, SQL. Trust compliance auditing.
## Education
### [edu-0] NHS Mary Seacole Programme (2018)
NHS Leadership Academy. Score: 78%. Covers change management, healthcare leadership, system-level thinking.
### [edu-1] MPharm (Hons) 2:1 — University of East Anglia (20112015)
4-year integrated Master's degree. Research project on drug delivery and cocrystals: 75.1% (Distinction).
### [edu-2] A-Levels — Highworth Grammar School (20092011)
Mathematics A*, Chemistry B, Politics C.
### [edu-3] GPhC Registration — General Pharmaceutical Council (August 2016Present)
Professional registration required to practise as a pharmacist in Great Britain.
## Skills
Technical: [skill-data-analysis] Data Analysis (9yr, 95%), [skill-python] Python (6yr, 90%), [skill-sql] SQL (7yr, 88%), [skill-power-bi] Power BI (5yr, 92%), [skill-javascript-typescript] JavaScript/TypeScript (3yr, 70%), [skill-excel] Excel (9yr, 85%), [skill-algorithm-design] Algorithm Design (3yr, 82%), [skill-data-pipelines] Data Pipelines (2yr, 75%)
Domain: [skill-medicines-optimisation] Medicines Optimisation (9yr, 95%), [skill-population-health] Population Health (3yr, 90%), [skill-nice-ta] NICE TA Implementation (3yr, 92%), [skill-health-economics] Health Economics (3yr, 80%), [skill-clinical-pathways] Clinical Pathways (3yr, 88%), [skill-controlled-drugs] Controlled Drugs (1yr, 85%)
Leadership: [skill-budget-management] Budget Management (1yr, 90%), [skill-stakeholder-engagement] Stakeholder Engagement (3yr, 88%), [skill-pharma-negotiation] Pharmaceutical Negotiation (1yr, 82%), [skill-team-development] Team Development (8yr, 85%), [skill-change-management] Change Management (7yr, 80%), [skill-financial-modelling] Financial Modelling (1yr, 78%), [skill-executive-comms] Executive Communication (1yr, 85%)
## Response Rules
1. Answer ONLY from the data above. If the answer is not in the data, say "I don't have that information" — never invent facts, roles, dates, achievements, URLs, or contact details.
2. Distinguish NHS employment (May 2022present, ~4 years, all at Norfolk & Waveney ICB) from private sector (Tesco PLC, Nov 2017May 2022, community pharmacy). Never conflate the two. GPhC registration is a professional licence, not NHS employment.
3. When asked broad questions about tools, skills, projects, or achievements across Andy's career, aggregate from ALL roles — do not limit your answer to one position.
4. Cite exact numbers, dates, percentages, and outcomes. Never say "approximately" or "around" when exact figures exist in the data.
5. For detailed or list-based questions, give a thorough answer covering all relevant items. For simple questions, be concise (2-4 sentences).
## Item References
End your response with a single line listing relevant item IDs from the square-bracketed IDs above:
[ITEMS: exp-deputy-head-2024, skill-python]
Only include IDs that directly support your answer. Omit the line if none are relevant.`
}
function buildRequestBody(
messages: ChatMessage[],
systemPrompt: string,
): object {
return {
model: LLM_MODEL,
stream: true,
temperature: 0.4,
max_tokens: 800,
messages: [
{ role: 'system', content: systemPrompt },
...messages.map((msg) => ({
role: msg.role,
content: msg.content,
})),
],
}
}
export async function* sendChatMessage(
messages: ChatMessage[],
): AsyncGenerator<string> {
const apiKey = getApiKey()
if (!apiKey) {
throw new Error('LLM API key not configured')
}
const systemPrompt = buildSystemPrompt()
const body = buildRequestBody(messages, systemPrompt)
const response = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'HTTP-Referer': window.location.origin,
'X-Title': 'Andy Charlwood Portfolio',
},
body: JSON.stringify(body),
})
if (!response.ok) {
throw new Error(`LLM API error: ${response.status}`)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('No response body')
}
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed.startsWith('data:')) continue
const jsonStr = trimmed.slice(5).trim()
if (!jsonStr || jsonStr === '[DONE]') continue
try {
const parsed = JSON.parse(jsonStr)
const text = parsed?.choices?.[0]?.delta?.content
if (text) {
yield text
}
} catch {
// Skip malformed JSON chunks
}
}
}
// Process any remaining buffer
if (buffer.trim().startsWith('data:')) {
const jsonStr = buffer.trim().slice(5).trim()
if (jsonStr && jsonStr !== '[DONE]') {
try {
const parsed = JSON.parse(jsonStr)
const text = parsed?.choices?.[0]?.delta?.content
if (text) {
yield text
}
} catch {
// Skip malformed final chunk
}
}
}
} finally {
reader.releaseLock()
}
}
export function parseItemIds(text: string): string[] {
const match = text.match(/\[ITEMS:\s*([^\]]+)\]/)
if (!match) return []
return match[1]
.split(',')
.map((id) => id.trim())
.filter(Boolean)
}
export function stripItemsSuffix(text: string): string {
return text.replace(/\n?\[ITEMS:[^\]]*\]\s*$/, '').trim()
}
+71 -13
View File
@@ -247,25 +247,68 @@ export function groupBySection(items: PaletteItem[]): Array<{ section: PaletteSe
export function buildEmbeddingTexts(): Array<{ id: string; text: string }> {
const texts: Array<{ id: string; text: string }> = []
// Consultations (Experience)
// Consultations (Experience) — enriched with plan outcomes, employer classification, clinical specialties
consultations.forEach((c) => {
const isNHS = c.organization.includes('NHS') || c.organization.includes('ICB')
const employer = isNHS
? `NHS employer: ${c.organization}`
: `Private sector employer: ${c.organization} (not NHS)`
const examBullets = c.examination.join('. ')
const planOutcomes = c.plan.join('. ')
const codedDescriptions = c.codedEntries.map(e => e.description).join('. ')
// Role-specific enrichment for clinical specialties and methodology
let roleContext = ''
if (c.id === 'high-cost-drugs-2022') {
roleContext = ' Clinical specialties covered: rheumatology, ophthalmology (wet AMD, DMO, RVO), dermatology, gastroenterology, neurology, and migraine. Wrote most of the system\'s high-cost drug pathways, implementing NICE technology appraisals while balancing legal requirements against financial costs and local clinical preferences.'
} else if (c.id === 'deputy-head-2024') {
roleContext = ' Created dm+d medicines data table integrating all dictionary of medicines and devices products with standardised strengths, morphine equivalents, and Anticholinergic Burden scoring — single source of truth for all medicines analytics. Supported tirzepatide commissioning (NICE TA1026) with financial projections and authored executive paper advocating primary care model, driving system shift to GP-led delivery.'
} else if (c.id === 'interim-head-2025') {
roleContext = ' Built Python switching algorithm using real-world GP prescribing data to identify patients eligible for cost-effective alternatives — compressed months of manual analysis into 3 days. Created novel GP payment system linking incentive rewards to prescribing savings.'
} else if (c.id === 'pharmacy-manager-2017') {
roleContext = ' Community pharmacy role at Tesco PLC, a private sector employer. Served as Local Pharmaceutical Committee (LPC) representative for Norfolk. Full HR responsibilities including recruitment, performance management, grievances.'
}
texts.push({
id: `exp-${c.id}`,
text: `${c.role} at ${c.organization}, ${c.duration}. ${c.history} Key achievements: ${examBullets}. ${codedDescriptions}.`,
text: `${c.role} at ${c.organization}, ${c.duration}. ${employer}. ${c.history} Key achievements: ${examBullets}. Outcomes: ${planOutcomes}. ${codedDescriptions}.${roleContext}`,
})
})
// Skills
// Skills — enriched with role context and practical application
const skillContextMap: Record<string, string> = {
'data-analysis': 'Applied across NHS medicines optimisation, identifying £14.6M efficiency programme. Used for prescribing pattern analysis, budget forecasting, and population health analytics serving 1.2M people.',
'python': 'Used to build switching algorithms (14,000 patients, £2.6M savings), controlled drug monitoring systems, Blueteq form automation, and Sankey chart visualisation tools. Self-taught.',
'sql': 'Core tool for patient-level analytics, dm+d data integration, and transforming practice-level data to patient-level SQL analysis. Used across all NHS data roles.',
'power-bi': 'Built PharMetrics interactive dashboard tracking £220M prescribing budget. Created dashboards used by 200+ clinicians and commissioners across Norfolk & Waveney ICB.',
'javascript-typescript': 'Used for web development including this portfolio website. Built with React, TypeScript, and Vite.',
'excel': 'Used for financial modelling, data validation, and ad-hoc analysis. Foundational tool across all roles from community pharmacy to NHS ICB.',
'algorithm-design': 'Designed patient switching algorithm and automated incentive scheme analysis. Applied to real-world GP prescribing data at population scale.',
'data-pipelines': 'Built automated data processing pipelines for medicines analytics, enabling self-serve models for team data fluency.',
'medicines-optimisation': 'Core domain expertise spanning community pharmacy through to NHS ICB-level population health. Led efficiency programmes worth £14.6M+.',
'population-health': 'Leading population health analytics for 1.2M people across Norfolk & Waveney ICS. Developing patient-level datasets from real-world GP prescribing data.',
'nice-ta': 'Led NICE technology appraisal implementation across high-cost drug pathways. Covered rheumatology, ophthalmology, dermatology, gastroenterology, neurology, and migraine.',
'health-economics': 'Financial modelling for DOAC switching programmes, tirzepatide commissioning, and pharmaceutical rebate negotiations.',
'clinical-pathways': 'Wrote most of the Norfolk & Waveney ICB high-cost drug pathways. Created Sankey chart tool for patient pathway visualisation and trust compliance auditing.',
'controlled-drugs': 'Built Python-based population-scale monitoring system calculating oral morphine equivalents (OME) across all opioid prescriptions. Enables high-risk patient identification and potential diversion detection.',
'budget-management': 'Managed £220M NHS prescribing budget with sophisticated forecasting models, variance analysis, and monthly financial reporting to the ICB executive team.',
'stakeholder-engagement': 'Presented to Chief Medical Officer bimonthly. Engaged with GP practices, trusts, commissioners, and pharmaceutical companies across the integrated care system.',
'pharma-negotiation': 'Renegotiated pharmaceutical rebate terms ahead of patent expiry, securing improved commercial position for the ICB.',
'team-development': 'Improved team data fluency through training and documentation. Supervised staff through NVQ3 to pharmacy technician registration. Created national induction training at Tesco.',
'change-management': 'Completed NHS Mary Seacole Programme (2018, 78%). Led transformation to patient-level SQL analytics and self-serve analytical models.',
'financial-modelling': 'Built interactive DOAC switching dashboard with rebate mechanics, workforce constraints, and patent expiry timelines. Financial projections for tirzepatide commissioning.',
'executive-comms': 'Authored executive papers for ICB board including tirzepatide commissioning advocacy. Presented evidence-based recommendations to CMO bimonthly.',
}
skills.forEach((skill) => {
const context = skillContextMap[skill.id] ?? ''
texts.push({
id: `skill-${skill.id}`,
text: `${skill.name} is a ${skill.category.toLowerCase()} skill used ${skill.frequency.toLowerCase()}, with ${skill.proficiency}% proficiency and ${skill.yearsOfExperience} years of experience since ${skill.startYear}.`,
text: `${skill.name} is a ${skill.category.toLowerCase()} skill used ${skill.frequency.toLowerCase()}, with ${skill.proficiency}% proficiency and ${skill.yearsOfExperience} years of experience since ${skill.startYear}. ${context}`,
})
})
// KPI-backed Achievements
// KPI-backed Achievements — enriched with full story context and outcomes
const achievementMap: Array<{ id: string; title: string; subtitle: string; kpiId: string }> = [
{ id: 'ach-0', title: '£14.6M Efficiency Savings Identified', subtitle: 'Data-driven prescribing interventions', kpiId: 'savings' },
{ id: 'ach-1', title: '£220M Budget Oversight', subtitle: 'Full analytical accountability to ICB board', kpiId: 'budget' },
@@ -275,26 +318,40 @@ export function buildEmbeddingTexts(): Array<{ id: string; text: string }> {
achievementMap.forEach((entry) => {
const kpi = kpis.find(k => k.id === entry.kpiId)
const storyContext = kpi?.story
? ` ${kpi.story.context} ${kpi.story.role} Outcomes: ${kpi.story.outcomes.join('. ')}.`
: ''
const explanation = kpi?.explanation ?? ''
const storyParts: string[] = []
if (kpi?.story) {
storyParts.push(kpi.story.context)
storyParts.push(kpi.story.role)
if (kpi.story.period) storyParts.push(`Period: ${kpi.story.period}.`)
storyParts.push(`Outcomes: ${kpi.story.outcomes.join('. ')}.`)
}
texts.push({
id: entry.id,
text: `Achievement: ${entry.title}. ${entry.subtitle}. ${kpi?.explanation ?? ''}${storyContext}`,
text: `Achievement: ${entry.title}. ${entry.subtitle}. ${explanation} ${storyParts.join(' ')}`,
})
})
// Investigations (Active Projects)
// Investigations (Active Projects) — enriched with role context and cross-references
const projectContextMap: Record<string, string> = {
'inv-pharmetrics': 'Built during Deputy Head role at NHS Norfolk & Waveney ICB. Provides self-serve analytics for budget holders across the integrated care system. Live at medicines.charlwood.xyz.',
'inv-switching-algorithm': 'Built during Interim Head role at NHS Norfolk & Waveney ICB. Uses real-world GP prescribing data to auto-identify patients on expensive drugs suitable for cost-effective alternatives. Compressed months of manual analysis into 3 days. Includes novel GP payment system linking incentive rewards to prescribing savings.',
'inv-blueteq-gen': 'Built during High-Cost Drugs & Interface Pharmacist role at NHS Norfolk & Waveney ICB. Automates prior approval form creation for high-cost drug pathways spanning rheumatology, ophthalmology, dermatology, gastroenterology, neurology, and migraine.',
'inv-cd-monitoring': 'Built during Deputy Head role at NHS Norfolk & Waveney ICB. Calculates oral morphine equivalents (OME) across all opioid prescriptions at population scale. Enables previously impossible population-level controlled drug analysis. Related to controlled drugs skill.',
'inv-sankey-tool': 'Built during High-Cost Drugs & Interface Pharmacist role at NHS Norfolk & Waveney ICB. Visualises patient journeys through high-cost drug pathways. Enables trust-level compliance auditing across multiple clinical specialties.',
}
investigations.forEach((inv) => {
const techList = inv.techStack.join(', ')
const resultList = inv.results.join('. ')
const context = projectContextMap[inv.id] ?? ''
texts.push({
id: `proj-${inv.id}`,
text: `Project: ${inv.name}. ${inv.methodology} Tech stack: ${techList}. Results: ${resultList}.`,
text: `Project: ${inv.name} (${inv.status}, ${inv.requestedYear}). ${inv.methodology} Tech stack: ${techList}. Results: ${resultList}. ${context}`,
})
})
// Education
// Education — enriched with research grades and specific subject details
const educationItems: Array<{ id: string; docId: string; fallbackTitle: string; fallbackSub: string }> = [
{ id: 'edu-0', docId: 'doc-mary-seacole', fallbackTitle: 'NHS Leadership Academy — Mary Seacole Programme', fallbackSub: 'NHS Leadership Academy · 2018' },
{ id: 'edu-1', docId: 'doc-mpharm', fallbackTitle: 'MPharm (Hons) — 2:1', fallbackSub: 'University of East Anglia · 20112015' },
@@ -306,10 +363,11 @@ export function buildEmbeddingTexts(): Array<{ id: string; text: string }> {
const doc = documents.find(d => d.id === entry.docId)
if (doc) {
const research = doc.researchDetail ? ` Research: ${doc.researchDetail}.` : ''
const researchGrade = doc.researchGrade ? ` Research grade: ${doc.researchGrade}.` : ''
const classification = doc.classification ? ` Classification: ${doc.classification}.` : ''
texts.push({
id: entry.id,
text: `Education: ${doc.title}. ${doc.type} from ${doc.institution ?? doc.source}, ${doc.duration ?? doc.date}.${classification}${research} ${doc.notes ?? ''}`,
text: `Education: ${doc.title}. ${doc.type} from ${doc.institution ?? doc.source}, ${doc.duration ?? doc.date}.${classification}${research}${researchGrade} ${doc.notes ?? ''}`,
})
} else {
texts.push({