Merge branch 'ralph/llm-cv-knowledge'
Merge LLM context rewrite
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 2022–present (all roles at NHS Norfolk & Waveney ICB). Total NHS service: ~4 years.
|
||||
- **Private sector**: Nov 2017–May 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 | May–Nov 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 2024–Present (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 2022–Jul 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, 7–8 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 2017–May 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, 7–8 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 (2011–2015)
|
||||
4-year integrated Master's degree. Research project on drug delivery and cocrystals: 75.1% (Distinction).
|
||||
|
||||
### [edu-2] A-Levels — Highworth Grammar School (2009–2011)
|
||||
Mathematics A*, Chemistry B, Politics C.
|
||||
|
||||
### [edu-3] GPhC Registration — General Pharmaceutical Council (August 2016–Present)
|
||||
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 2022–present, ~4 years, all at Norfolk & Waveney ICB) from private sector (Tesco PLC, Nov 2017–May 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
@@ -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 · 2011–2015' },
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user