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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified public/resume/Justin_Traille.pdf
Binary file not shown.
135 changes: 98 additions & 37 deletions src/components/ResumePdfViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,6 @@ function isHotspotBoundaryLine(text: string): boolean {
if (normalized.includes('internal innovation initiative')) {
return true;
}
if (normalized.startsWith('atos client:')) {
return true;
}
return isSectionHeadingLine(text) || isJobHeadingLine(text);
}

Expand Down Expand Up @@ -162,10 +159,25 @@ function getExpandedLineIndices(
return indices;
}

function getLineBounds(
items: PdfTextItem[],
viewport: { width: number; height: number; transform: number[]; scale: number }
) {
interface PdfViewport {
width: number;
height: number;
transform: number[];
scale: number;
}

interface PageRenderData {
textLayer: HTMLDivElement;
viewport: PdfViewport;
lines: LineGroup[];
}

interface GlobalLineRef {
pageIndex: number;
lineIndex: number;
}

function getLineBounds(items: PdfTextItem[], viewport: PdfViewport) {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
Expand Down Expand Up @@ -198,6 +210,78 @@ function getLineBounds(
};
}

function addHotspotHitArea(
textLayer: HTMLDivElement,
hotspot: ResumeHotspot,
items: PdfTextItem[],
viewport: PageRenderData['viewport'],
onHotspotClick?: (hotspot: ResumeHotspot) => void
) {
const bounds = getLineBounds(items, viewport);
const hitArea = document.createElement('button');
hitArea.type = 'button';
hitArea.className = 'resume-hotspot';
hitArea.setAttribute('aria-label', `More about ${hotspot.title}`);
hitArea.style.left = bounds.left;
hitArea.style.top = bounds.top;
hitArea.style.width = bounds.width;
hitArea.style.height = bounds.height;
hitArea.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
onHotspotClick?.(hotspot);
};
textLayer.appendChild(hitArea);
}

function placeHotspotsAcrossPages(
pages: PageRenderData[],
onHotspotClick?: (hotspot: ResumeHotspot) => void
) {
const globalLines: GlobalLineRef[] = [];
const lineGroups: LineGroup[] = [];

pages.forEach((page, pageIndex) => {
page.lines.forEach((line, lineIndex) => {
globalLines.push({ pageIndex, lineIndex });
lineGroups.push(line);
});
});

const usedHotspotIds = new Set<string>();
const usedGlobalIndices = new Set<number>();

globalLines.forEach((_, globalIndex) => {
if (usedGlobalIndices.has(globalIndex)) return;

const hotspot = findResumeHotspot(lineGroups[globalIndex].text);
if (!hotspot || usedHotspotIds.has(hotspot.id)) return;

usedHotspotIds.add(hotspot.id);
const expandedIndices = getExpandedLineIndices(lineGroups, globalIndex, hotspot);
expandedIndices.forEach((index) => usedGlobalIndices.add(index));

const itemsByPage = new Map<number, PdfTextItem[]>();
expandedIndices.forEach((index) => {
const { pageIndex, lineIndex } = globalLines[index];
const lineItems = pages[pageIndex].lines[lineIndex].items;
const existing = itemsByPage.get(pageIndex) ?? [];
existing.push(...lineItems);
itemsByPage.set(pageIndex, existing);
});

itemsByPage.forEach((items, pageIndex) => {
addHotspotHitArea(
pages[pageIndex].textLayer,
hotspot,
items,
pages[pageIndex].viewport,
onHotspotClick
);
});
});
}

export default function ResumePdfViewer({
url,
onError,
Expand Down Expand Up @@ -248,6 +332,8 @@ export default function ResumePdfViewer({
containerWidth = container.clientWidth || 860;
}

const pages: PageRenderData[] = [];

for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
if (cancelled) return;

Expand Down Expand Up @@ -284,36 +370,8 @@ export default function ResumePdfViewer({
);

const lines = groupTextIntoLines(textItems);
const usedHotspotIds = new Set<string>();
const usedLineIndices = new Set<number>();

lines.forEach((line, lineIndex) => {
if (usedLineIndices.has(lineIndex)) return;

const hotspot = findResumeHotspot(line.text);
if (!hotspot || usedHotspotIds.has(hotspot.id)) return;

usedHotspotIds.add(hotspot.id);
const expandedIndices = getExpandedLineIndices(lines, lineIndex, hotspot);
expandedIndices.forEach((index) => usedLineIndices.add(index));

const mergedItems = expandedIndices.flatMap((index) => lines[index].items);
const bounds = getLineBounds(mergedItems, viewport);
const hitArea = document.createElement('button');
hitArea.type = 'button';
hitArea.className = 'resume-hotspot';
hitArea.setAttribute('aria-label', `More about ${hotspot.title}`);
hitArea.style.left = bounds.left;
hitArea.style.top = bounds.top;
hitArea.style.width = bounds.width;
hitArea.style.height = bounds.height;
hitArea.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
onHotspotClickRef.current?.(hotspot);
};
textLayer.appendChild(hitArea);
});

pages.push({ textLayer, viewport, lines });

pageWrap.appendChild(canvas);
pageWrap.appendChild(textLayer);
Expand All @@ -324,6 +382,9 @@ export default function ResumePdfViewer({
}

if (!cancelled) {
placeHotspotsAcrossPages(pages, (hotspot) => {
onHotspotClickRef.current?.(hotspot);
});
setLoading(false);
}
} catch (err) {
Expand Down
50 changes: 32 additions & 18 deletions src/content/appShowcases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,36 +150,50 @@ export const appShowcases: Record<string, AppShowcaseContent> = {
'Retrieval-augmented generation for querying documents and knowledge bases with AI.',
githubUrl: 'https://github.com/TheTraille18/jet_rag_ai_project',
summary: [
'RAG System will let users upload documents, embed them into a vector store, and ask natural-language questions grounded in their own data. Answers are generated by an LLM using retrieved context rather than model memory alone.',
'The planned architecture separates ingestion (chunking, embedding, indexing) from query time (retrieval, reranking, and response generation) so the system scales as the knowledge base grows.',
'RAG System ingests PDF documents, embeds them with Amazon Titan on AWS Bedrock, and stores chunks in a Chroma vector database. Users ask natural-language questions and receive answers grounded in retrieved transcript context via Claude on Bedrock.',
'A LangGraph agent judges whether the RAG answer is sufficient. If the document cannot answer the question — for example, events after the report — the agent falls back to Tavily web search and synthesizes a final response from live results.',
],
architectureCaption:
'Documents are chunked and embedded at ingest time, then stored in a vector database. At query time, the user question is embedded, relevant chunks are retrieved, and an LLM generates a grounded answer from that context.',
'At ingest time, PDFs are chunked and embedded into Chroma. At query time, the question is embedded, relevant chunks are retrieved, and Claude generates a transcript-grounded RAG answer. A LangGraph agent then routes the flow: if the answer is sufficient, it is returned; otherwise Tavily searches the web and Claude produces a final answer from those results.',
diagram: {
viewBox: '0 0 760 400',
boxes: [
{ x: 40, y: 40, w: 160, h: 52, label: 'Documents', sublabel: 'PDF / text', fill: 'rgba(0,0,0,0.45)' },
{ x: 280, y: 40, w: 200, h: 52, label: 'Ingest pipeline', sublabel: 'Chunk + embed', fill: 'rgba(255,90,95,0.35)' },
{ x: 540, y: 40, w: 180, h: 52, label: 'Vector store', sublabel: 'Embeddings', fill: 'rgba(0,166,153,0.35)' },
{ x: 40, y: 200, w: 160, h: 52, label: 'User query', sublabel: 'Natural language', fill: 'rgba(0,0,0,0.45)' },
{ x: 280, y: 200, w: 200, h: 52, label: 'Retrieval', sublabel: 'Similarity search', fill: 'rgba(255,90,95,0.25)' },
{ x: 540, y: 200, w: 180, h: 52, label: 'LLM', sublabel: 'Grounded answer', fill: 'rgba(0,166,153,0.25)' },
{ x: 40, y: 30, w: 150, h: 52, label: 'PDF document', sublabel: 'Earnings transcript', fill: 'rgba(0,0,0,0.45)' },
{ x: 240, y: 30, w: 180, h: 52, label: 'Ingest pipeline', sublabel: 'Chunk + Titan embed', fill: 'rgba(255,90,95,0.35)' },
{ x: 470, y: 30, w: 160, h: 52, label: 'ChromaDB', sublabel: 'Vector store', fill: 'rgba(0,166,153,0.35)' },
{ x: 40, y: 130, w: 150, h: 52, label: 'User query', sublabel: 'Natural language', fill: 'rgba(0,0,0,0.45)' },
{ x: 240, y: 130, w: 180, h: 52, label: 'Retrieval', sublabel: 'Similarity search', fill: 'rgba(255,90,95,0.25)' },
{ x: 470, y: 130, w: 160, h: 52, label: 'Claude', sublabel: 'RAG answer', fill: 'rgba(0,166,153,0.25)' },
{ x: 280, y: 240, w: 200, h: 52, label: 'LangGraph agent', sublabel: 'Judge RAG answer', fill: 'rgba(255,90,95,0.35)' },
{ x: 40, y: 330, w: 180, h: 52, label: 'Transcript answer', sublabel: 'Return RAG result', fill: 'rgba(0,166,153,0.25)' },
{ x: 300, y: 330, w: 160, h: 52, label: 'Tavily', sublabel: 'Web search', fill: 'rgba(0,0,0,0.45)' },
{ x: 520, y: 330, w: 180, h: 52, label: 'Claude', sublabel: 'Final answer', fill: 'rgba(0,166,153,0.25)' },
],
arrows: [
{ x1: 200, y1: 66, x2: 278, y2: 66 },
{ x1: 480, y1: 66, x2: 538, y2: 66 },
{ x1: 200, y1: 226, x2: 278, y2: 226 },
{ x1: 480, y1: 226, x2: 538, y2: 226 },
{ x1: 630, y1: 92, x2: 630, y2: 198 },
{ x1: 190, y1: 56, x2: 238, y2: 56 },
{ x1: 420, y1: 56, x2: 468, y2: 56 },
{ x1: 190, y1: 156, x2: 238, y2: 156 },
{ x1: 420, y1: 156, x2: 468, y2: 156 },
{ x1: 550, y1: 82, x2: 550, y2: 128 },
{ x1: 380, y1: 182, x2: 380, y2: 238 },
{ x1: 320, y1: 292, x2: 130, y2: 328 },
{ x1: 440, y1: 292, x2: 378, y2: 328 },
{ x1: 460, y1: 356, x2: 518, y2: 356 },
],
footer: 'Planned RAG pipeline — ingest, retrieve, generate',
footer: 'Hybrid RAG + agent — transcript grounding with web search fallback',
},
toolsUsed: [
{ category: 'AI', tools: ['LLM', 'Embeddings', 'Vector search', 'RAG'] },
{ category: 'Backend', tools: ['Python', 'FastAPI', 'AWS Bedrock'] },
{ category: 'Data', tools: ['ChromaDB', 'S3', 'Chunking pipeline'] },
{ category: 'AI', tools: ['RAG', 'LangChain', 'LangGraph', 'Claude', 'Titan Embeddings'] },
{ category: 'Backend', tools: ['Python', 'AWS Bedrock', 'Tavily'] },
{ category: 'Data', tools: ['ChromaDB', 'PyPDF', 'Chunking pipeline'] },
],
progressUpdates: [
{
date: 'Jun 26, 2026',
title: 'LangGraph agent + web fallback',
detail:
'Added a LangGraph agent that judges RAG answers and falls back to Tavily web search when the transcript cannot answer. Updated README, architecture diagram, and tools to reflect the hybrid pipeline.',
},
{
date: 'Jun 25, 2026',
title: 'LangChain',
Expand Down
Loading
Loading