From 6eaad89b1a9c3d07731ae0f439290dbb6a6f0311 Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Fri, 18 Apr 2025 15:58:16 -0700 Subject: [PATCH] Refactored Resume Builder --- frontend/src/App.css | 2 + frontend/src/App.tsx | 4 +- frontend/src/ChatBubble.tsx | 2 +- frontend/src/Document.tsx | 45 +++ frontend/src/DocumentTypes.tsx | 27 ++ frontend/src/DocumentViewer.tsx | 581 +++++++++++++++++++----------- frontend/src/Message.tsx | 2 +- frontend/src/ResumeBuilder.tsx | 15 +- frontend/src/VectorVisualizer.tsx | 16 +- src/server.py | 244 ++++++------- 10 files changed, 582 insertions(+), 356 deletions(-) create mode 100644 frontend/src/Document.tsx create mode 100644 frontend/src/DocumentTypes.tsx diff --git a/frontend/src/App.css b/frontend/src/App.css index d612d92..49e0fe9 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,5 +1,7 @@ div { box-sizing: border-box; + overflow-wrap: break-word; + word-break: break-word; } .TabPanel { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 735d932..1bb2510 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -340,7 +340,7 @@ const App = () => { const [messageHistoryLength, setMessageHistoryLength] = useState(5); const [tab, setTab] = useState(0); const [about, setAbout] = useState(""); - const [jobDescription, setJobDescription] = useState(""); + const [jobDescription, setJobDescription] = useState(undefined); const [resume, setResume] = useState(undefined); const [facts, setFacts] = useState(undefined); const timerRef = useRef(null); @@ -1263,7 +1263,7 @@ const App = () => { - + diff --git a/frontend/src/ChatBubble.tsx b/frontend/src/ChatBubble.tsx index 72ca4b9..110bf24 100644 --- a/frontend/src/ChatBubble.tsx +++ b/frontend/src/ChatBubble.tsx @@ -62,7 +62,7 @@ function ChatBubble({ role, isFullWidth, children, sx }: ChatBubbleProps) { border: `1px solid ${theme.palette.secondary.main}`, // Dusty Teal borderRadius: '16px', padding: theme.spacing(1, 2), - maxWidth: isFullWidth ? '100%' : '95%', + maxWidth: isFullWidth ? '100%' : '100%', minWidth: '70%', alignSelf: 'flex-start', color: theme.palette.text.primary, // Charcoal Black (#2E2E2E) — much better contrast diff --git a/frontend/src/Document.tsx b/frontend/src/Document.tsx new file mode 100644 index 0000000..f960f0c --- /dev/null +++ b/frontend/src/Document.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Box, Typography } from '@mui/material'; +import { SxProps, Theme } from '@mui/material'; + +/** + * Props for the Document component + * @interface DocumentComponentProps + * @property {string} title - The title of the document + * @property {React.ReactNode} [children] - The content of the document + */ +interface DocumentComponentProps { + title: string; + children?: React.ReactNode; + sx?: SxProps; +} + +/** + * Document component renders a container with optional title and scrollable content + * + * This component provides a consistent document viewing experience across the application + * with a title header and scrollable content area + */ +const Document: React.FC = ({ title, children, sx }) => ( + + { + title !== "" && + {title} + } + + {children} + + +); + +export { + Document +}; \ No newline at end of file diff --git a/frontend/src/DocumentTypes.tsx b/frontend/src/DocumentTypes.tsx new file mode 100644 index 0000000..609691b --- /dev/null +++ b/frontend/src/DocumentTypes.tsx @@ -0,0 +1,27 @@ +import { SxProps, Theme } from '@mui/material'; +import { MessageData } from './MessageMeta'; + +/** + * Props for the DocumentViewer component + * @interface DocumentViewerProps + * @property {function} generateResume - Function to generate a resume based on job description + * @property {MessageData | undefined} resume - The generated resume data + * @property {function} setResume - Function to set the generated resume + * @property {function} factCheck - Function to fact check the generated resume + * @property {MessageData | undefined} facts - The fact check results + * @property {function} setFacts - Function to set the fact check results + * @property {string} jobDescription - The initial job description + * @property {function} setJobDescription - Function to set the job description + * @property {SxProps} [sx] - Optional styling properties + */ +export interface DocumentViewerProps { + generateResume: (jobDescription: string) => void; + resume: MessageData | undefined; + setResume: (resume: MessageData | undefined) => void; + factCheck: (resume: string) => void; + facts: MessageData | undefined; + setFacts: (facts: MessageData | undefined) => void; + jobDescription: string | undefined; + setJobDescription: (jobDescription: string | undefined) => void; + sx?: SxProps; +} \ No newline at end of file diff --git a/frontend/src/DocumentViewer.tsx b/frontend/src/DocumentViewer.tsx index 427cfc8..9f9a268 100644 --- a/frontend/src/DocumentViewer.tsx +++ b/frontend/src/DocumentViewer.tsx @@ -12,281 +12,446 @@ import { Divider, Slider, Stack, - TextField + TextField, + Tooltip } from '@mui/material'; -import Tooltip from '@mui/material/Tooltip'; import { useTheme } from '@mui/material/styles'; import SendIcon from '@mui/icons-material/Send'; import { ChevronLeft, ChevronRight, SwapHoriz, + RestartAlt as ResetIcon, } from '@mui/icons-material'; -import { SxProps, Theme } from '@mui/material'; import PropagateLoader from "react-spinners/PropagateLoader"; -import { MessageData } from './MessageMeta'; import { Message } from './Message'; +import { Document } from './Document'; +import { DocumentViewerProps } from './DocumentTypes'; +import MuiMarkdown from 'mui-markdown'; -interface DocumentComponentProps { - title: string; - children?: React.ReactNode; -} - -interface DocumentViewerProps { - generateResume: (jobDescription: string) => void, - factCheck: (resume: string) => void, - resume: MessageData | undefined, - facts: MessageData | undefined, - jobDescription: string, - sx?: SxProps, -}; - -// Document component -const Document: React.FC = ({ title, children }) => ( - - { - title !== "" && - {title} - } - - {children} - - -); - -const DocumentViewer: React.FC = ({ generateResume, jobDescription, factCheck, resume, facts, sx }: DocumentViewerProps) => { - const [editJobDescription, setEditJobDescription] = useState(jobDescription); - const [processing, setProcessing] = useState(false); +/** + * DocumentViewer component + * + * A responsive component that displays job descriptions, generated resumes and fact checks + * with different layouts for mobile and desktop views. + */ +const DocumentViewer: React.FC = ({ + generateResume, + jobDescription, + factCheck, + resume, + setResume, + facts, + setFacts, + sx +}) => { + // State for editing job description + const [editJobDescription, setEditJobDescription] = useState(jobDescription); + // Processing state to show loading indicators + const [processing, setProcessing] = useState(undefined); +// Theme and responsive design setup const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); // State for controlling which document is active on mobile - const [activeDocMobile, setActiveDocMobile] = useState(0); + const [activeTab, setActiveTab] = useState(0); // State for controlling split ratio on desktop - const [splitRatio, setSplitRatio] = useState(50); + const [splitRatio, setSplitRatio] = useState(100); + /** + * Reset processing state when resume is generated + */ useEffect(() => { - if (processing && resume !== undefined) { - setProcessing(false); + if (resume !== undefined && processing === "resume") { + setProcessing(undefined); } - }, [processing, resume, setProcessing]); + }, [processing, resume]); - const triggerGeneration = useCallback((jobDescription: string) => { - setProcessing(true); - setActiveDocMobile(1); + /** + * Reset processing state when facts is generated + */ + useEffect(() => { + if (facts !== undefined && processing === "facts") { + setProcessing(undefined); + } + }, [processing, facts]); + + /** + * Trigger resume generation and update UI state + */ + const triggerGeneration = useCallback((jobDescription: string | undefined) => { + if (jobDescription === undefined) { + setProcessing(undefined); + setResume(undefined); + setActiveTab(0); + return; + } + setProcessing("resume"); + setTimeout(() => { setActiveTab(1); }, 250); // Switch to resume view on mobile generateResume(jobDescription); - }, [setProcessing, generateResume]); + }, [generateResume, setProcessing, setActiveTab, setResume]); + /** + * Trigger fact check and update UI state + */ + const triggerFactCheck = useCallback((resume: string | undefined) => { + if (resume === undefined) { + setProcessing(undefined); + setResume(undefined); + setFacts(undefined); + setActiveTab(1); + return; + } + setProcessing("facts"); + factCheck(resume); + setTimeout(() => { setActiveTab(2); }, 250); // Switch to resume view on mobile + }, [factCheck, setResume, setProcessing, setActiveTab, setFacts]); + + /** + * Switch to resume tab when resume become available + */ + useEffect(() => { + if (resume !== undefined) { + setTimeout(() => { setActiveTab(1); }, 250); // Switch to resume view on mobile + } + }, [resume]); + + /** + * Switch to fact check tab when facts become available + */ useEffect(() => { if (facts !== undefined) { - setActiveDocMobile(2); + setTimeout(() => { setActiveTab(2); }, 250); // Switch to resume view on mobile } - }, [facts, setActiveDocMobile]); + }, [facts]); - // Handle tab change for mobile + /** + * Handle tab change for mobile view + */ const handleTabChange = (_event: React.SyntheticEvent, newValue: number): void => { - setActiveDocMobile(newValue); + setActiveTab(newValue); }; - // Adjust split ratio + /** + * Adjust split ratio for desktop view + */ const handleSliderChange = (_event: Event, newValue: number | number[]): void => { setSplitRatio(newValue as number); }; - // Reset split ratio + /** + * Reset split ratio to default + */ const resetSplit = (): void => { setSplitRatio(50); }; - const handleKeyPress = (event: any) => { + /** + * Handle keyboard shortcuts + */ + const handleKeyPress = (event: React.KeyboardEvent): void => { if (event.key === 'Enter' && event.ctrlKey) { - triggerGeneration(editJobDescription); + triggerGeneration(editJobDescription || ""); } }; - // Mobile view - if (isMobile) { - return ( - - {/* Tabs */} - { + const jobDescription = []; + + if (resume === undefined && processing === undefined) { + jobDescription.push( + + setEditJobDescription(e.target.value)} + onKeyDown={handleKeyPress} + placeholder="Paste a job description, then click Generate..." + /> + + ); + } else { + jobDescription.push({editJobDescription}) + } + + jobDescription.push( + + { setEditJobDescription(""); triggerGeneration(undefined); }} + > + + + + + + + + + ); + + return jobDescription; + } + + /** + * Renders the resume view with loading indicator + */ + const renderResumeView = () => ( + + + {resume !== undefined && } + + {processing === "resume" && ( + + + Generating resume... + + )} + + + ); + + /** + * Renders the fact check view + */ + const renderFactCheckView = () => ( + + + {facts !== undefined && } + + {processing === "facts" && ( + + + Fact Checking resume... + + )} + + ); + + // Render mobile view + if (isMobile) { + /** + * Gets the appropriate content based on active tab + */ + const getActiveMobileContent = () => { + switch (activeTab) { + case 0: + return renderJobDescriptionView(); + case 1: + return renderResumeView(); + case 2: + return renderFactCheckView(); + default: + return renderJobDescriptionView(); + } + }; + + return ( + + {/* Tabs */} + + + {(resume !== undefined || processing === "resume") && } + {(facts !== undefined || processing === "facts") && } + {/* Document display area */} - - {activeDocMobile === 0 ? (<> - - setEditJobDescription(e.target.value)} - onKeyDown={handleKeyPress} - // placeholder="Paste a job description (or URL that resolves to one), then click Generate..." - placeholder="Paste a job description, then click Generate..." - /> - - - - - ) : (activeDocMobile === 1 ? ( - {resume !== undefined && } - {processing === true && <> - - - Generating resume... - - } - - {resume !== undefined || processing === true - ? <> - NOTE: As with all LLMs, hallucination is always a possibility. If the generated resume seems too good to be true, Fact Check or, expand the LLM information for this query section (at the end of the resume) and click the links in the Top RAG matches to view the relavent RAG source document to read the details. Or go back to 'Backstory' and ask a question. {processing === false && - - } - : Once you click Generate under the Job Description, a resume will be generated based on the user's RAG content and the job description. - } - - ) : - ( - {facts !== undefined && } - ))} + + {getActiveMobileContent()} ); } - - // Desktop view - return ( - - {/* Split document view */} - - - - setEditJobDescription(e.target.value)} - onKeyDown={handleKeyPress} - // placeholder="Paste a job description (or URL that resolves to one), then click Generate..." - placeholder="Paste a job description, then click Generate..." - /> - - - - - - - - {resume !== undefined && } - - - - - {resume !== undefined || processing === true - ? <> - NOTE: As with all LLMs, hallucination is always a possibility. If the generated resume seems too good to be true, Fact Check or, expand the LLM information for this query section (at the end of the resume) and click the links in the Top RAG matches to view the relavent RAG source document to read the details. Or go back to 'Backstory' and ask a question. { processing === false && - - } - : Once you click Generate under the Job Description, a resume will be generated based on the user's RAG content and the job description. - } - - - { - facts !== undefined && <> - - - - - } - - {/* Split control panel */} + /** + * Gets the appropriate content based on active state for Desktop + */ + const getActiveDesktopContent = () => { + /* Left panel - Job Description */ + const showResume = resume !== undefined || processing === "resume" + const showFactCheck = facts !== undefined || processing === "facts" + const otherRatio = showResume ? (100 - splitRatio / 2) : 100; + const children = []; + children.push( + + {renderJobDescriptionView()} + ); - + /* Resume panel - conditionally rendered if resume defined, or processing is in progress */ + if (showResume) { + children.push( + + + {renderResumeView()} + + ); + } + + /* Fact Check panel - conditionally rendered if facts defined, or processing is in progress */ + if (showFactCheck) { + children.push( + + + {renderFactCheckView()} + + ); + } + + /* Split control panel - conditionally rendered if either facts or resume is set */ + let slider = ; + if (showResume || showFactCheck) { + slider = ( + - setSplitRatio(Math.max(20, splitRatio - 10))}> + setSplitRatio(Math.max(0, splitRatio - 10))}> - + - - setSplitRatio(Math.min(80, splitRatio + 10))}> + + setSplitRatio(Math.min(100, splitRatio + 10))}> - + - + + ); + } + + return ( + + + {children} + + {slider} + + ) + } + + return ( + + {getActiveDesktopContent()} ); }; -export type { - DocumentViewerProps -}; -export { DocumentViewer }; \ No newline at end of file +/** + * Props for the ResumeActionCard component + */ +interface ResumeActionCardProps { + resume: any; + processing: string | undefined; + triggerFactCheck: (resume: string | undefined) => void; +} + +/** + * Action card displayed underneath the resume with notes and fact check button + */ +const ResumeActionCard: React.FC = ({ resume, processing, triggerFactCheck }) => ( + + + {resume !== undefined || processing === "resume" ? ( + + NOTE: As with all LLMs, hallucination is always a possibility. If the generated resume seems too good to be true, Fact Check or, expand the LLM information for this query section (at the end of the resume) and click the links in the Top RAG matches to view the relavent RAG source document to read the details. Or go back to 'Backstory' and ask a question. + + ) : ( + + Once you click Generate under the Job Description, a resume will be generated based on the user's RAG content and the job description. + + )} + + + { triggerFactCheck(undefined); }} + > + + + + + + + + + + + +); + +export { + DocumentViewer +}; \ No newline at end of file diff --git a/frontend/src/Message.tsx b/frontend/src/Message.tsx index 1a435b6..4ca62d1 100644 --- a/frontend/src/Message.tsx +++ b/frontend/src/Message.tsx @@ -50,7 +50,7 @@ const Message = ({ message, submitQuery, isFullWidth }: MessageInterface) => { const formattedContent = message.content.trim(); return ( - + {message.role !== 'user' ? diff --git a/frontend/src/ResumeBuilder.tsx b/frontend/src/ResumeBuilder.tsx index 4ebaf21..93064e9 100644 --- a/frontend/src/ResumeBuilder.tsx +++ b/frontend/src/ResumeBuilder.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, } from 'react'; +import { useState, useCallback, } from 'react'; import Box from '@mui/material/Box'; import { SeverityType } from './Snack'; import { ContextStatus } from './ContextStatus'; @@ -17,10 +17,11 @@ interface ResumeBuilderProps { setResume: (resume: MessageData | undefined) => void, facts: MessageData | undefined, setFacts: (facts: MessageData | undefined) => void, - jobDescription: string, + jobDescription: string | undefined, + setJobDescription: (jobDescription: string | undefined) => void }; -const ResumeBuilder = ({ jobDescription, facts, setFacts, resume, setResume, setProcessing, processing, connectionBase, sessionId, setSnack }: ResumeBuilderProps) => { +const ResumeBuilder = ({ jobDescription, setJobDescription, facts, setFacts, resume, setResume, setProcessing, processing, connectionBase, sessionId, setSnack }: ResumeBuilderProps) => { const [lastEvalTPS, setLastEvalTPS] = useState(35); const [lastPromptTPS, setLastPromptTPS] = useState(430); const [contextStatus, setContextStatus] = useState({ context_used: 0, max_context: 0 }); @@ -191,7 +192,6 @@ const ResumeBuilder = ({ jobDescription, facts, setFacts, resume, setResume, set const factCheck = async (resume: string) => { if (!resume.trim()) return; setFacts(undefined); - setSnack('Fact Check is still under development', 'warning'); try { setProcessing(true); @@ -294,20 +294,21 @@ const ResumeBuilder = ({ jobDescription, facts, setFacts, resume, setResume, set return ( - + + }} {...{ factCheck, facts, jobDescription, generateResume, resume, setFacts, setResume, setJobDescription }} /> ); } - export type { ResumeBuilderProps }; diff --git a/frontend/src/VectorVisualizer.tsx b/frontend/src/VectorVisualizer.tsx index 3d7c605..b913a99 100644 --- a/frontend/src/VectorVisualizer.tsx +++ b/frontend/src/VectorVisualizer.tsx @@ -256,21 +256,7 @@ const VectorVisualizer: React.FC = ({ setSnack, connectio - { - const point = event.points[0]; - console.log('Point:', point); - const type = point.customdata.type; - const text = point.customdata.doc; - const emoji = emojiMap[type] || '❓'; - setTooltip({ - visible: true, - background: point['marker.color'], - color: getTextColorForBackground(point['marker.color']), - content: `${emoji} ${type.toUpperCase()}\n${text}`, - }); - }} - + { const point = event.points[0]; console.log('Point:', point); diff --git a/src/server.py b/src/server.py index 4911fc3..1890b21 100644 --- a/src/server.py +++ b/src/server.py @@ -20,14 +20,14 @@ def try_import(module_name, pip_name=None): print(f" pip install {pip_name or module_name}") # Third-party modules with import checks -try_import('ollama') -try_import('requests') -try_import('bs4', 'beautifulsoup4') -try_import('fastapi') -try_import('uvicorn') -try_import('numpy') -try_import('umap') -try_import('sklearn') +try_import("ollama") +try_import("requests") +try_import("bs4", "beautifulsoup4") +try_import("fastapi") +try_import("uvicorn") +try_import("numpy") +try_import("umap") +try_import("sklearn") import ollama import requests @@ -59,9 +59,9 @@ rags = [ def get_installed_ram(): try: - with open('/proc/meminfo', 'r') as f: + with open("/proc/meminfo", "r") as f: meminfo = f.read() - match = re.search(r'MemTotal:\s+(\d+)', meminfo) + match = re.search(r"MemTotal:\s+(\d+)", meminfo) if match: return f"{math.floor(int(match.group(1)) / 1000**2)}GB" # Convert KB to GB except Exception as e: @@ -71,12 +71,12 @@ def get_graphics_cards(): gpus = [] try: # Run the ze-monitor utility - result = subprocess.run(['ze-monitor'], capture_output=True, text=True, check=True) + result = subprocess.run(["ze-monitor"], capture_output=True, text=True, check=True) # Clean up the output (remove leading/trailing whitespace and newlines) output = result.stdout.strip() for index in range(len(output.splitlines())): - result = subprocess.run(['ze-monitor', '--device', f'{index+1}', '--info'], capture_output=True, text=True, check=True) + result = subprocess.run(["ze-monitor", "--device", f"{index+1}", "--info"], capture_output=True, text=True, check=True) gpu_info = result.stdout.strip().splitlines() gpu = { "discrete": True, # Assume it's discrete initially @@ -85,17 +85,17 @@ def get_graphics_cards(): } gpus.append(gpu) for line in gpu_info: - match = re.match(r'^Device: [^(]*\((.*)\)', line) + match = re.match(r"^Device: [^(]*\((.*)\)", line) if match: gpu["name"] = match.group(1) continue - match = re.match(r'^\s*Memory: (.*)', line) + match = re.match(r"^\s*Memory: (.*)", line) if match: gpu["memory"] = match.group(1) continue - match = re.match(r'^.*Is integrated with host: Yes.*', line) + match = re.match(r"^.*Is integrated with host: Yes.*", line) if match: gpu["discrete"] = False continue @@ -106,10 +106,10 @@ def get_graphics_cards(): def get_cpu_info(): try: - with open('/proc/cpuinfo', 'r') as f: + with open("/proc/cpuinfo", "r") as f: cpuinfo = f.read() - model_match = re.search(r'model name\s+:\s+(.+)', cpuinfo) - cores_match = re.findall(r'processor\s+:\s+\d+', cpuinfo) + model_match = re.search(r"model name\s+:\s+(.+)", cpuinfo) + cores_match = re.findall(r"processor\s+:\s+\d+", cpuinfo) if model_match and cores_match: return f"{model_match.group(1)} with {len(cores_match)} cores" except Exception as e: @@ -200,8 +200,8 @@ def parse_args(): parser.add_argument("--ollama-model", type=str, default=MODEL_NAME, help=f"LLM model to use. default={MODEL_NAME}") parser.add_argument("--web-host", type=str, default=WEB_HOST, help=f"Host to launch Flask web server. default={WEB_HOST} only if --web-disable not specified.") parser.add_argument("--web-port", type=str, default=WEB_PORT, help=f"Port to launch Flask web server. default={WEB_PORT} only if --web-disable not specified.") - parser.add_argument('--level', type=str, choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], - default=LOG_LEVEL, help=f'Set the logging level. default={LOG_LEVEL}') + parser.add_argument("--level", type=str, choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + default=LOG_LEVEL, help=f"Set the logging level. default={LOG_LEVEL}") return parser.parse_args() def setup_logging(level): @@ -209,7 +209,7 @@ def setup_logging(level): if not isinstance(numeric_level, int): raise ValueError(f"Invalid log level: {level}") - logging.basicConfig(level=numeric_level, format='%(asctime)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s') + logging.basicConfig(level=numeric_level, format="%(asctime)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s") logging.info(f"Logging is set to {level} level.") @@ -230,26 +230,26 @@ async def AnalyzeSite(url, question): try: # Fetch the webpage headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" } logging.info(f"Fetching {url}") response = requests.get(url, headers=headers, timeout=10) response.raise_for_status() logging.info(f"{url} returned. Processing...") # Parse the HTML - soup = BeautifulSoup(response.text, 'html.parser') + soup = BeautifulSoup(response.text, "html.parser") # Remove script and style elements for script in soup(["script", "style"]): script.extract() # Get text content - text = soup.get_text(separator=' ', strip=True) + text = soup.get_text(separator=" ", strip=True) # Clean up text (remove extra whitespace) lines = (line.strip() for line in text.splitlines()) chunks = (phrase.strip() for line in lines for phrase in line.split(" ")) - text = ' '.join(chunk for chunk in chunks if chunk) + text = " ".join(chunk for chunk in chunks if chunk) # Limit text length if needed (Ollama may have token limits) max_chars = 100000 @@ -265,12 +265,12 @@ async def AnalyzeSite(url, question): system="You are given the contents of {url}. Answer the question about the contents", prompt=prompt) - #logging.info(response['response']) + #logging.info(response["response"]) return { - 'source': 'summarizer-llm', - 'content': response['response'], - 'metadata': DateTime() + "source": "summarizer-llm", + "content": response["response"], + "metadata": DateTime() } except requests.exceptions.RequestException as e: @@ -306,40 +306,40 @@ async def handle_tool_calls(message): tools_used = [] all_responses = [] - for i, tool_call in enumerate(message['tool_calls']): - arguments = tool_call['function']['arguments'] - tool = tool_call['function']['name'] + for i, tool_call in enumerate(message["tool_calls"]): + arguments = tool_call["function"]["arguments"] + tool = tool_call["function"]["name"] # Yield status update before processing each tool - yield {"status": "processing", "message": f"Processing tool {i+1}/{len(message['tool_calls'])}: {tool}..."} + yield {"status": "processing", "message": f"Processing tool {i+1}/{len(message['tool_call'])}: {tool}..."} # Process the tool based on its type match tool: - case 'TickerValue': - ticker = arguments.get('ticker') + case "TickerValue": + ticker = arguments.get("ticker") if not ticker: ret = None else: ret = TickerValue(ticker) tools_used.append({ "tool": f"{tool}({ticker})", "result": ret}) - case 'AnalyzeSite': - url = arguments.get('url') - question = arguments.get('question', 'what is the summary of this content?') + case "AnalyzeSite": + url = arguments.get("url") + question = arguments.get("question", "what is the summary of this content?") # Additional status update for long-running operations yield {"status": "processing", "message": f"Retrieving and summarizing content from {url}..."} ret = await AnalyzeSite(url, question) tools_used.append({ "tool": f"{tool}('{url}', '{question}')", "result": ret }) - case 'DateTime': - tz = arguments.get('timezone') + case "DateTime": + tz = arguments.get("timezone") ret = DateTime(tz) tools_used.append({ "tool": f"{tool}('{tz}')", "result": ret }) - case 'WeatherForecast': - city = arguments.get('city') - state = arguments.get('state') + case "WeatherForecast": + city = arguments.get("city") + state = arguments.get("state") yield {"status": "processing", "message": f"Fetching weather data for {city}, {state}..."} ret = WeatherForecast(city, state) @@ -352,7 +352,7 @@ async def handle_tool_calls(message): tool_response = { "role": "tool", "content": str(ret), - "name": tool_call['function']['name'] + "name": tool_call["function"]["name"] } all_responses.append(tool_response) @@ -401,7 +401,7 @@ class WebServer: self.setup_routes() def setup_routes(self): - @self.app.get('/') + @self.app.get("/") async def root(): context = self.create_context() self.logging.info(f"Redirecting non-session to {context['id']}") @@ -474,7 +474,7 @@ class WebServer: # "document_count": file_watcher.collection.count() # } - @self.app.put('/api/umap/{context_id}') + @self.app.put("/api/umap/{context_id}") async def put_umap(context_id: str, request: Request): if not self.file_watcher: return @@ -487,24 +487,24 @@ class WebServer: try: data = await request.json() - dimensions = data.get('dimensions', 2) + dimensions = data.get("dimensions", 2) except: dimensions = 2 try: - result = self.file_watcher.collection.get(include=['embeddings', 'documents', 'metadatas']) - vectors = np.array(result['embeddings']) + result = self.file_watcher.collection.get(include=["embeddings", "documents", "metadatas"]) + vectors = np.array(result["embeddings"]) umap_model = umap.UMAP(n_components=dimensions, random_state=42) #, n_neighbors=15, min_dist=0.1) embedding = umap_model.fit_transform(vectors) - context['umap_model'] = umap_model - result['embeddings'] = embedding.tolist() + context["umap_model"] = umap_model + result["embeddings"] = embedding.tolist() return JSONResponse(result) except Exception as e: logging.error(e) return JSONResponse({"error": str(e)}, 500) - @self.app.put('/api/similarity/{context_id}') + @self.app.put("/api/similarity/{context_id}") async def put_similarity(context_id: str, request: Request): if not self.file_watcher: return @@ -519,9 +519,9 @@ class WebServer: try: data = await request.json() - query = data.get('query', '') + query = data.get("query", "") except: - query = '' + query = "" if not query: return JSONResponse({"error": "No query provided"}, status_code=400) @@ -537,7 +537,7 @@ class WebServer: logging.error(e) #return JSONResponse({"error": str(e)}, 500) - @self.app.put('/api/reset/{context_id}') + @self.app.put("/api/reset/{context_id}") async def put_reset(context_id: str, request: Request): if not is_valid_uuid(context_id): logging.warning(f"Invalid context_id: {context_id}") @@ -576,7 +576,7 @@ class WebServer: except: return JSONResponse({ "error": "Usage: { reset: rags|tools|history|system-prompt}"}) - @self.app.put('/api/tunables/{context_id}') + @self.app.put("/api/tunables/{context_id}") async def put_tunables(context_id: str, request: Request): if not is_valid_uuid(context_id): logging.warning(f"Invalid context_id: {context_id}") @@ -600,7 +600,7 @@ class WebServer: case _: return JSONResponse({ "error": f"Unrecognized tunable {k}"}, 404) - @self.app.get('/api/tunables/{context_id}') + @self.app.get("/api/tunables/{context_id}") async def get_tunables(context_id: str): if not is_valid_uuid(context_id): logging.warning(f"Invalid context_id: {context_id}") @@ -611,7 +611,7 @@ class WebServer: "message-history-length": context["message_history_length"] }) - @self.app.get('/api/resume/{context_id}') + @self.app.get("/api/resume/{context_id}") async def get_resume(context_id: str): if not is_valid_uuid(context_id): logging.warning(f"Invalid context_id: {context_id}") @@ -619,11 +619,11 @@ class WebServer: context = self.upsert_context(context_id) return JSONResponse(context["resume_history"]) - @self.app.get('/api/system-info/{context_id}') + @self.app.get("/api/system-info/{context_id}") async def get_system_info(context_id: str): return JSONResponse(system_info(self.model)) - @self.app.post('/api/chat/{context_id}') + @self.app.post("/api/chat/{context_id}") async def chat_endpoint(context_id: str, request: Request): if not is_valid_uuid(context_id): logging.warning(f"Invalid context_id: {context_id}") @@ -633,7 +633,7 @@ class WebServer: # Create a custom generator that ensures flushing async def flush_generator(): - async for message in self.chat(context=context, content=data['content']): + async for message in self.chat(context=context, content=data["content"]): # Convert to JSON and add newline yield json.dumps(message) + "\n" # Save the history as its generated @@ -652,7 +652,7 @@ class WebServer: } ) - @self.app.post('/api/generate-resume/{context_id}') + @self.app.post("/api/generate-resume/{context_id}") async def post_generate_resume(context_id: str, request: Request): if not is_valid_uuid(context_id): logging.warning(f"Invalid context_id: {context_id}") @@ -662,7 +662,7 @@ class WebServer: # Create a custom generator that ensures flushing async def flush_generator(): - async for message in self.generate_resume(context=context, content=data['content']): + async for message in self.generate_resume(context=context, content=data["content"]): # Convert to JSON and add newline yield json.dumps(message) + "\n" # Save the history as its generated @@ -681,7 +681,7 @@ class WebServer: } ) - @self.app.post('/api/fact-check/{context_id}') + @self.app.post("/api/fact-check/{context_id}") async def post_fact_check(context_id: str, request: Request): if not is_valid_uuid(context_id): logging.warning(f"Invalid context_id: {context_id}") @@ -691,7 +691,7 @@ class WebServer: # Create a custom generator that ensures flushing async def flush_generator(): - async for message in self.fact_check(context=context, content=data['content']): + async for message in self.fact_check(context=context, content=data["content"]): # Convert to JSON and add newline yield json.dumps(message) + "\n" # Save the history as its generated @@ -706,27 +706,27 @@ class WebServer: headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", - "X-Accel-Buffering": "no" # Prevents Nginx buffering if you're using it + "X-Accel-Buffering": "no" # Prevents Nginx buffering if you"re using it } ) - @self.app.post('/api/context') + @self.app.post("/api/context") async def create_context(): context = self.create_context() self.logging.info(f"Generated new session as {context['id']}") return JSONResponse(context) - @self.app.get('/api/history/{context_id}') + @self.app.get("/api/history/{context_id}") async def get_history(context_id: str): context = self.upsert_context(context_id) return JSONResponse(context["user_history"]) - @self.app.get('/api/tools/{context_id}') + @self.app.get("/api/tools/{context_id}") async def get_tools(context_id: str): context = self.upsert_context(context_id) return JSONResponse(context["tools"]) - @self.app.put('/api/tools/{context_id}') + @self.app.put("/api/tools/{context_id}") async def put_tools(context_id: str, request: Request): if not is_valid_uuid(context_id): logging.warning(f"Invalid context_id: {context_id}") @@ -745,12 +745,12 @@ class WebServer: except: return JSONResponse({ "status": "error" }), 405 - @self.app.get('/api/rags/{context_id}') + @self.app.get("/api/rags/{context_id}") async def get_rags(context_id: str): context = self.upsert_context(context_id) return JSONResponse(context["rags"]) - @self.app.put('/api/rags/{context_id}') + @self.app.put("/api/rags/{context_id}") async def put_rags(context_id: str, request: Request): if not is_valid_uuid(context_id): logging.warning(f"Invalid context_id: {context_id}") @@ -769,7 +769,7 @@ class WebServer: except: return JSONResponse({ "status": "error" }), 405 - @self.app.get('/api/context-status/{context_id}') + @self.app.get("/api/context-status/{context_id}") async def get_context_status(context_id): if not is_valid_uuid(context_id): logging.warning(f"Invalid context_id: {context_id}") @@ -777,18 +777,18 @@ class WebServer: context = self.upsert_context(context_id) return JSONResponse({"context_used": context["context_tokens"], "max_context": defines.max_context}) - @self.app.get('/api/health') + @self.app.get("/api/health") async def health_check(): return JSONResponse({"status": "healthy"}) - @self.app.get('/{path:path}') + @self.app.get("/{path:path}") async def serve_static(path: str): full_path = os.path.join(defines.static_content, path) if os.path.exists(full_path) and os.path.isfile(full_path): self.logging.info(f"Serve static request for {full_path}") return FileResponse(full_path) self.logging.info(f"Serve index.html for {path}") - return FileResponse(os.path.join(defines.static_content, 'index.html')) + return FileResponse(os.path.join(defines.static_content, "index.html")) def save_context(self, session_id): """ @@ -814,7 +814,7 @@ class WebServer: if umap_model: del context["umap_model"] # Serialize the data to JSON and write to file - with open(file_path, 'w') as f: + with open(file_path, "w") as f: json.dump(context, f) if umap_model: context["umap_model"] = umap_model @@ -837,7 +837,7 @@ class WebServer: return self.create_context(session_id) # Read and deserialize the data - with open(file_path, 'r') as f: + with open(file_path, "r") as f: self.contexts[session_id] = json.load(f) return self.contexts[session_id] @@ -934,21 +934,21 @@ class WebServer: yield {"status": "processing", "message": "Processing request...", "num_ctx": ctx_size} # Use the async generator in an async for loop - response = self.client.chat(model=self.model, messages=messages, tools=llm_tools(context["tools"]), options={ 'num_ctx': ctx_size }) - metadata["eval_count"] += response['eval_count'] - metadata["eval_duration"] += response['eval_duration'] - metadata["prompt_eval_count"] += response['prompt_eval_count'] - metadata["prompt_eval_duration"] += response['prompt_eval_duration'] - context["context_tokens"] = response['prompt_eval_count'] + response['eval_count'] + response = self.client.chat(model=self.model, messages=messages, tools=llm_tools(context["tools"]), options={ "num_ctx": ctx_size }) + metadata["eval_count"] += response["eval_count"] + metadata["eval_duration"] += response["eval_duration"] + metadata["prompt_eval_count"] += response["prompt_eval_count"] + metadata["prompt_eval_duration"] += response["prompt_eval_duration"] + context["context_tokens"] = response["prompt_eval_count"] + response["eval_count"] tools_used = [] yield {"status": "processing", "message": "Initial response received..."} - if 'tool_calls' in response.get('message', {}): + if "tool_calls" in response.get("message", {}): yield {"status": "processing", "message": "Processing tool calls..."} - message = response['message'] + message = response["message"] tool_result = None # Process all yielded items from the handler @@ -961,14 +961,14 @@ class WebServer: yield item message_dict = { - 'role': message.get('role', 'assistant'), - 'content': message.get('content', '') + "role": message.get("role", "assistant"), + "content": message.get("content", "") } - if 'tool_calls' in message: - message_dict['tool_calls'] = [ - {'function': {'name': tc['function']['name'], 'arguments': tc['function']['arguments']}} - for tc in message['tool_calls'] + if "tool_calls" in message: + message_dict["tool_calls"] = [ + {"function": {"name": tc["function"]["name"], "arguments": tc["function"]["arguments"]}} + for tc in message["tool_calls"] ] pre_add_index = len(messages) @@ -985,14 +985,14 @@ class WebServer: ctx_size = self.get_optimal_ctx_size(context["context_tokens"], messages=messages[pre_add_index:]) yield {"status": "processing", "message": "Generating final response...", "num_ctx": ctx_size } # Decrease creativity when processing tool call requests - response = self.client.chat(model=self.model, messages=messages, stream=False, options={ 'num_ctx': ctx_size }) #, "temperature": 0.5 }) - metadata["eval_count"] += response['eval_count'] - metadata["eval_duration"] += response['eval_duration'] - metadata["prompt_eval_count"] += response['prompt_eval_count'] - metadata["prompt_eval_duration"] += response['prompt_eval_duration'] - context["context_tokens"] = response['prompt_eval_count'] + response['eval_count'] + response = self.client.chat(model=self.model, messages=messages, stream=False, options={ "num_ctx": ctx_size }) #, "temperature": 0.5 }) + metadata["eval_count"] += response["eval_count"] + metadata["eval_duration"] += response["eval_duration"] + metadata["prompt_eval_count"] += response["prompt_eval_count"] + metadata["prompt_eval_duration"] += response["prompt_eval_duration"] + context["context_tokens"] = response["prompt_eval_count"] + response["eval_count"] - reply = response['message']['content'] + reply = response["message"]["content"] final_message = {"role": "assistant", "content": reply } # history is provided to the LLM and should not have additional metadata @@ -1006,7 +1006,7 @@ class WebServer: yield {"status": "done", "message": final_message } except Exception as e: - logging.exception({ 'model': self.model, 'messages': messages, 'error': str(e) }) + logging.exception({ "model": self.model, "messages": messages, "error": str(e) }) yield {"status": "error", "message": f"An error occurred: {str(e)}"} finally: @@ -1032,7 +1032,7 @@ class WebServer: "resume": "", "metadata": {}, "rag": "", - "fact_check": "" + "fact_check": {} } metadata = { @@ -1044,7 +1044,7 @@ class WebServer: "prompt_eval_duration": 0, } rag_docs = [] - resume_doc = open(defines.resume_doc, 'r').read() + resume_doc = open(defines.resume_doc, "r").read() rag_docs.append(resume_doc) for rag in context["rags"]: if rag["enabled"] and rag["name"] == "JPK": # Only support JPK rag right now... @@ -1076,24 +1076,24 @@ class WebServer: # 2. If not requested (no tool call,) abort the path # 3. Otherwise, we know the URL was good and can use that URLs fetched content as context. # - response = self.client.generate(model=self.model, system=system_generate_resume, prompt=content, options={ 'num_ctx': ctx_size }) - metadata["eval_count"] += response['eval_count'] - metadata["eval_duration"] += response['eval_duration'] - metadata["prompt_eval_count"] += response['prompt_eval_count'] - metadata["prompt_eval_duration"] += response['prompt_eval_duration'] - context["context_tokens"] = response['prompt_eval_count'] + response['eval_count'] + response = self.client.generate(model=self.model, system=system_generate_resume, prompt=content, options={ "num_ctx": ctx_size }) + metadata["eval_count"] += response["eval_count"] + metadata["eval_duration"] += response["eval_duration"] + metadata["prompt_eval_count"] += response["prompt_eval_count"] + metadata["prompt_eval_duration"] += response["prompt_eval_duration"] + context["context_tokens"] = response["prompt_eval_count"] + response["eval_count"] - reply = response['response'] + reply = response["response"] final_message = {"role": "assistant", "content": reply, "metadata": metadata } - resume['resume'] = final_message + resume["resume"] = final_message resume_history.append(resume) # Return the REST API with metadata yield {"status": "done", "message": final_message } except Exception as e: - logging.exception({ 'model': self.model, 'content': content, 'error': str(e) }) + logging.exception({ "model": self.model, "content": content, "error": str(e) }) yield {"status": "error", "message": f"An error occurred: {str(e)}"} finally: @@ -1128,29 +1128,29 @@ class WebServer: # Estimate token length of new messages ctx_size = self.get_optimal_ctx_size(context["context_tokens"], messages=[system_fact_check, content]) yield {"status": "processing", "message": "Processing request...", "num_ctx": ctx_size} - response = self.client.generate(model=self.model, system=system_fact_check, prompt=content, options={ 'num_ctx': ctx_size }) + response = self.client.generate(model=self.model, system=system_fact_check, prompt=content, options={ "num_ctx": ctx_size }) logging.info(f"Fact checking {ctx_size} tokens.") - metadata["eval_count"] += response['eval_count'] - metadata["eval_duration"] += response['eval_duration'] - metadata["prompt_eval_count"] += response['prompt_eval_count'] - metadata["prompt_eval_duration"] += response['prompt_eval_duration'] - context["context_tokens"] = response['prompt_eval_count'] + response['eval_count'] - reply = response['response'] + metadata["eval_count"] += response["eval_count"] + metadata["eval_duration"] += response["eval_duration"] + metadata["prompt_eval_count"] += response["prompt_eval_count"] + metadata["prompt_eval_duration"] += response["prompt_eval_duration"] + context["context_tokens"] = response["prompt_eval_count"] + response["eval_count"] + reply = response["response"] final_message = {"role": "assistant", "content": reply, "metadata": metadata } - resume['fact_check'] = final_message + resume["fact_check"] = final_message # Return the REST API with metadata yield {"status": "done", "message": final_message } except Exception as e: - logging.exception({ 'model': self.model, 'content': content, 'error': str(e) }) + logging.exception({ "model": self.model, "content": content, "error": str(e) }) yield {"status": "error", "message": f"An error occurred: {str(e)}"} finally: self.processing = False - def run(self, host='0.0.0.0', port=WEB_PORT, **kwargs): + def run(self, host="0.0.0.0", port=WEB_PORT, **kwargs): try: uvicorn.run(self.app, host=host, port=port) except KeyboardInterrupt: @@ -1176,7 +1176,7 @@ def main(): # documents = Rag.load_text_files(defines.doc_dir) # print(f"Documents loaded {len(documents)}") # chunks = Rag.create_chunks_from_documents(documents) -# doc_types = set(chunk.metadata['doc_type'] for chunk in chunks) +# doc_types = set(chunk.metadata["doc_type"] for chunk in chunks) # print(f"Document types: {doc_types}") # print(f"Vectorstore created with {collection.count()} documents")