Compare commits

...

2 Commits

13 changed files with 1060 additions and 1186 deletions

View File

@ -4,6 +4,11 @@ div {
word-break: break-word;
}
.gl-container #scene {
top: 0px !important;
left: 0px !important;
}
pre {
max-width: 100%;
max-height: 100%;

View File

@ -19,8 +19,7 @@ import MenuIcon from '@mui/icons-material/Menu';
import { ResumeBuilder } from './ResumeBuilder';
import { Message } from './Message';
import { MessageData } from './MessageMeta';
import { Message, ChatQuery, MessageList, MessageData } from './Message';
import { SeverityType } from './Snack';
import { VectorVisualizer } from './VectorVisualizer';
import { Controls } from './Controls';
@ -33,6 +32,8 @@ import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import MuiMarkdown from 'mui-markdown';
const getConnectionBase = (loc: any): string => {
if (!loc.host.match(/.*battle-linux.*/)) {
@ -130,10 +131,41 @@ const App = () => {
}, [about, setAbout])
const handleSubmitChatQuery = () => {
chatRef.current?.submitQuery();
const handleSubmitChatQuery = (query: string) => {
console.log(`handleSubmitChatQuery: ${query} -- `, chatRef.current ? ' sending' : 'no handler');
chatRef.current?.submitQuery(query);
};
const chatPreamble: MessageList = [
{
role: 'info',
content: `
# Welcome to Backstory
Backstory is a RAG enabled expert system with access to real-time data running self-hosted
(no cloud) versions of industry leading Large and Small Language Models (LLM/SLMs).
It was written by James Ketrenos in order to provide answers to
questions potential employers may have about his work history.
What would you like to know about James?
`
}
];
const chatQuestions = [
<Box sx={{ display: "flex", flexDirection: "row" }}>
<ChatQuery text="What is James Ketrenos' work history?" submitQuery={handleSubmitChatQuery} />
<ChatQuery text="What programming languages has James used?" submitQuery={handleSubmitChatQuery} />
<ChatQuery text="What are James' professional strengths?" submitQuery={handleSubmitChatQuery} />
<ChatQuery text="What are today's headlines on CNBC.com?" submitQuery={handleSubmitChatQuery} />
</Box>,
<MuiMarkdown>
As with all LLM interactions, the results may not be 100% accurate. If you have questions about my career,
I'd love to hear from you. You can send me an email at **james_backstory@ketrenos.com**.
</MuiMarkdown>
];
// Extract the sessionId from the URL if present, otherwise
// request a sessionId from the server.
useEffect(() => {
@ -368,10 +400,12 @@ const App = () => {
ref={chatRef}
{...{
type: "chat",
prompt: "Enter your question...",
prompt: "What would you like to know about James?",
sessionId,
connectionBase,
setSnack
setSnack,
preamble: chatPreamble,
defaultPrompts: chatQuestions
}}
/>
</Box>
@ -392,7 +426,7 @@ const App = () => {
<CustomTabPanel tab={tab} index={3}>
<Box className="ChatBox">
<Box className="Conversation">
<Message {...{ message: { role: 'assistant', content: about }, submitQuery: handleSubmitChatQuery }} />
<Message {...{ message: { role: 'assistant', content: about }, submitQuery: handleSubmitChatQuery, connectionBase, sessionId, setSnack }} />
</Box>
</Box>
</CustomTabPanel>

View File

@ -2,7 +2,7 @@ import { Box } from '@mui/material';
import { useTheme } from '@mui/material/styles';
import { SxProps, Theme } from '@mui/material';
import React from 'react';
import { MessageRoles } from './MessageMeta';
import { MessageRoles } from './Message';
interface ChatBubbleProps {
role: MessageRoles,
@ -16,64 +16,52 @@ interface ChatBubbleProps {
function ChatBubble({ role, isFullWidth, children, sx, className }: ChatBubbleProps) {
const theme = useTheme();
const defaultRadius = '16px';
const defaultStyle = {
padding: theme.spacing(1, 1),
fontSize: '0.875rem',
alignSelf: 'flex-start', // Left-aligned is used by default
maxWidth: '100%',
minWidth: '80%',
'& > *': {
color: 'inherit', // Children inherit 'color' from parent
},
}
const styles = {
'user': {
...defaultStyle,
backgroundColor: theme.palette.background.default, // Warm Gray (#D3CDBF)
border: `1px solid ${theme.palette.custom.highlight}`, // Golden Ochre (#D4A017)
borderRadius: '16px 16px 0 16px', // Rounded, flat bottom-right for user
padding: theme.spacing(1, 2),
maxWidth: isFullWidth ? '100%' : '100%',
minWidth: '80%',
borderRadius: `${defaultRadius} ${defaultRadius} 0 ${defaultRadius}`, // Rounded, flat bottom-right for user
alignSelf: 'flex-end', // Right-aligned for user
color: theme.palette.primary.main, // Midnight Blue (#1A2536) for text
'& > *': {
color: 'inherit', // Children inherit Midnight Blue unless overridden
},
},
'assistant': {
...defaultStyle,
backgroundColor: theme.palette.primary.main, // Midnight Blue (#1A2536)
border: `1px solid ${theme.palette.secondary.main}`, // Dusty Teal (#4A7A7D)
borderRadius: '16px 16px 16px 0', // Rounded, flat bottom-left for assistant
padding: theme.spacing(1, 2),
maxWidth: isFullWidth ? '100%' : '100%',
minWidth: '80%',
alignSelf: 'flex-start', // Left-aligned for assistant
borderRadius: `${defaultRadius} ${defaultRadius} ${defaultRadius} 0`, // Rounded, flat bottom-left for assistant
color: theme.palette.primary.contrastText, // Warm Gray (#D3CDBF) for text
'& > *': {
color: 'inherit', // Children inherit Warm Gray unless overridden
},
},
'system': {
...defaultStyle,
backgroundColor: '#EDEAE0', // Soft warm gray that plays nice with #D3CDBF
border: `1px dashed ${theme.palette.custom.highlight}`, // Golden Ochre
borderRadius: '12px',
padding: theme.spacing(1, 2),
borderRadius: defaultRadius,
maxWidth: isFullWidth ? '100%' : '90%',
minWidth: '60%',
alignSelf: 'center',
color: theme.palette.text.primary, // Charcoal Black
fontStyle: 'italic',
fontSize: '0.95rem',
'& > *': {
color: 'inherit',
},
},
'info': {
...defaultStyle,
backgroundColor: '#BFD8D8', // Softened Dusty Teal
border: `1px solid ${theme.palette.secondary.main}`, // Dusty Teal
borderRadius: '16px',
padding: theme.spacing(1, 2),
maxWidth: isFullWidth ? '100%' : '100%',
minWidth: '70%',
alignSelf: 'flex-start',
borderRadius: defaultRadius,
color: theme.palette.text.primary, // Charcoal Black (#2E2E2E) — much better contrast
opacity: 0.95,
fontSize: '0.875rem',
'& > *': {
color: 'inherit',
},
}
};
return (

View File

@ -109,11 +109,11 @@ const Controls = ({ sessionId, setSnack, connectionBase }: ControlsParams) => {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ "system-prompt": prompt }),
body: JSON.stringify({ "system_prompt": prompt }),
});
const data = await response.json();
const newPrompt = data["system-prompt"];
const newPrompt = data["system_prompt"];
if (newPrompt !== serverSystemPrompt) {
setServerSystemPrompt(newPrompt);
setSystemPrompt(newPrompt)
@ -141,11 +141,11 @@ const Controls = ({ sessionId, setSnack, connectionBase }: ControlsParams) => {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ "message-history-length": length }),
body: JSON.stringify({ "message_history_length": length }),
});
const data = await response.json();
const newLength = data["message-history-length"];
const newLength = data["message_history_length"];
if (newLength !== messageHistoryLength) {
setMessageHistoryLength(newLength);
setSnack("Message history length updated", "success");
@ -159,7 +159,7 @@ const Controls = ({ sessionId, setSnack, connectionBase }: ControlsParams) => {
sendMessageHistoryLength(messageHistoryLength);
}, [messageHistoryLength, setMessageHistoryLength, connectionBase, sessionId, setSnack]);
const reset = async (types: ("rags" | "tools" | "history" | "system-prompt" | "message-history-length")[], message: string = "Update successful.") => {
const reset = async (types: ("rags" | "tools" | "history" | "system_prompt" | "message_history_length")[], message: string = "Update successful.") => {
try {
const response = await fetch(connectionBase + `/api/reset/${sessionId}`, {
method: 'PUT',
@ -183,9 +183,9 @@ const Controls = ({ sessionId, setSnack, connectionBase }: ControlsParams) => {
case "tools":
setTools(value as Tool[]);
break;
case "system-prompt":
setServerSystemPrompt((value as any)["system-prompt"].trim());
setSystemPrompt((value as any)["system-prompt"].trim());
case "system_prompt":
setServerSystemPrompt((value as any)["system_prompt"].trim());
setSystemPrompt((value as any)["system_prompt"].trim());
break;
case "history":
console.log('TODO: handle history reset');
@ -346,10 +346,10 @@ const Controls = ({ sessionId, setSnack, connectionBase }: ControlsParams) => {
},
});
const data = await response.json();
const serverSystemPrompt = data["system-prompt"].trim();
const serverSystemPrompt = data["system_prompt"].trim();
setServerSystemPrompt(serverSystemPrompt);
setSystemPrompt(serverSystemPrompt);
setMessageHistoryLength(data["message-history-length"]);
setMessageHistoryLength(data["message_history_length"]);
}
fetchTunables();
@ -402,7 +402,7 @@ const Controls = ({ sessionId, setSnack, connectionBase }: ControlsParams) => {
/>
<div style={{ display: "flex", flexDirection: "row", gap: "8px", paddingTop: "8px" }}>
<Button variant="contained" disabled={editSystemPrompt === systemPrompt} onClick={() => { setSystemPrompt(editSystemPrompt); }}>Set</Button>
<Button variant="outlined" onClick={() => { reset(["system-prompt"], "System prompt reset."); }} color="error">Reset</Button>
<Button variant="outlined" onClick={() => { reset(["system_prompt"], "System prompt reset."); }} color="error">Reset</Button>
</div>
</AccordionActions>
</Accordion>
@ -481,7 +481,7 @@ const Controls = ({ sessionId, setSnack, connectionBase }: ControlsParams) => {
</AccordionActions>
</Accordion>
<Button startIcon={<ResetIcon />} onClick={() => { reset(["history"], "History cleared."); }}>Clear Backstory History</Button>
<Button onClick={() => { reset(["rags", "tools", "system-prompt", "message-history-length"], "Default settings restored.") }}>Reset system prompt, tunables, and RAG to defaults</Button>
<Button onClick={() => { reset(["rags", "tools", "system_prompt", "message_history_length"], "Default settings restored.") }}>Reset system prompt, tunables, and RAG to defaults</Button>
</div>);
}

View File

@ -3,55 +3,44 @@ import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import Box from '@mui/material/Box';
import SendIcon from '@mui/icons-material/Send';
import ResetIcon from '@mui/icons-material/RestartAlt';
import { SxProps, Theme } from '@mui/material';
import PropagateLoader from "react-spinners/PropagateLoader";
import { Message, MessageList } from './Message';
import { Message, MessageList, MessageData } from './Message';
import { SeverityType } from './Snack';
import { ContextStatus } from './ContextStatus';
import { MessageData } from './MessageMeta';
const welcomeMarkdown = `
# Welcome to Backstory
const loadingMessage: MessageData = { "role": "assistant", "content": "Establishing connection with server..." };
Backstory was written by James Ketrenos in order to provide answers to
questions potential employers may have about his work history.
You can ask things like:
<ChatQuery text="What is James Ketrenos' work history?"/>
<ChatQuery text="What programming languages has James used?"/>
<ChatQuery text="What are James' professional strengths?"/>
<ChatQuery text="What are today's headlines on CNBC.com?"/>
You can click the text above to submit that query, or type it in yourself (or whatever questions you may have.)
Backstory is a RAG enabled expert system with access to real-time data running self-hosted
(no cloud) versions of industry leading Large and Small Language Models (LLM/SLMs).
As with all LLM interactions, the results may not be 100% accurate. If you have questions about my career, I'd love to hear from you. You can send me an email at **james_backstory@ketrenos.com**.`;
const welcomeMessage: MessageData = {
"role": "assistant", "content": welcomeMarkdown
};
const loadingMessage: MessageData = { "role": "assistant", "content": "Instancing chat session..." };
type ConversationMode = 'chat' | 'fact-check' | 'system';
type ConversationMode = 'chat' | 'job_description' | 'resume' | 'fact_check';
interface ConversationHandle {
submitQuery: () => void;
submitQuery: (query: string) => void;
}
interface ConversationProps {
className?: string,
type: ConversationMode
prompt: string,
actionLabel?: string,
resetAction?: () => void,
resetLabel?: string,
connectionBase: string,
sessionId: string | undefined,
sessionId?: string,
setSnack: (message: string, severity: SeverityType) => void,
defaultPrompts?: React.ReactElement[],
preamble?: MessageList,
hideDefaultPrompts?: boolean,
messageFilter?: (messages: MessageList) => MessageList,
messages?: MessageList,
sx?: SxProps<Theme>,
};
const Conversation = forwardRef<ConversationHandle, ConversationProps>(({prompt, type, sessionId, setSnack, connectionBase} : ConversationProps, ref) => {
const Conversation = forwardRef<ConversationHandle, ConversationProps>(({ ...props }: ConversationProps, ref) => {
const [query, setQuery] = useState<string>("");
const [contextUsedPercentage, setContextUsedPercentage] = useState<number>(0);
const [processing, setProcessing] = useState<boolean>(false);
@ -62,12 +51,14 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({prompt,
const [lastPromptTPS, setLastPromptTPS] = useState<number>(430);
const [contextStatus, setContextStatus] = useState<ContextStatus>({ context_used: 0, max_context: 0 });
const [contextWarningShown, setContextWarningShown] = useState<boolean>(false);
const [noInteractions, setNoInteractions] = useState<boolean>(true);
const setSnack = props.setSnack;
// Update the context status
const updateContextStatus = useCallback(() => {
const fetchContextStatus = async () => {
try {
const response = await fetch(connectionBase + `/api/context-status/${sessionId}`, {
const response = await fetch(props.connectionBase + `/api/context-status/${props.sessionId}/${props.type}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
@ -87,35 +78,51 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({prompt,
}
};
fetchContextStatus();
}, [setContextStatus, connectionBase, setSnack, sessionId]);
}, [setContextStatus, props.connectionBase, setSnack, props.sessionId, props.type]);
// Set the initial chat history to "loading" or the welcome message if loaded.
useEffect(() => {
if (sessionId === undefined) {
setConversation([loadingMessage]);
} else {
fetch(connectionBase + `/api/history/${sessionId}`, {
// Set the initial chat history to "loading" or the welcome message if loaded.
useEffect(() => {
if (props.sessionId === undefined) {
setConversation([loadingMessage]);
return;
}
const fetchHistory = async () => {
try {
const response = await fetch(props.connectionBase + `/api/history/${props.sessionId}/${props.type}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
.then(response => response.json())
.then(data => {
console.log(`Session id: ${sessionId} -- history returned from server with ${data.length} entries`)
setConversation([
welcomeMessage,
...data
]);
})
.catch(error => {
console.error('Error generating session ID:', error);
setSnack("Unable to obtain chat history.", "error");
});
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log(`History returned from server with ${data.length} entries`)
if (data.length === 0) {
setConversation([
...(props.preamble || []),
...(props.messages || []),
]);
setNoInteractions(true);
} else {
setConversation([
...(props.messages || []),
...(props.messageFilter ? props.messageFilter(data) : data)
]);
setNoInteractions(false);
}
updateContextStatus();
} catch (error) {
console.error('Error generating session ID:', error);
setSnack("Unable to obtain chat history.", "error");
}
}, [sessionId, setConversation, updateContextStatus, connectionBase, setSnack]);
};
if (props.sessionId !== undefined) {
fetchHistory();
}
}, [props.sessionId, setConversation, updateContextStatus, props.connectionBase, setSnack, props.preamble, props.type]);
const isScrolledToBottom = useCallback(()=> {
// Current vertical scroll position
@ -138,7 +145,6 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({prompt,
});
}, []);
const startCountdown = (seconds: number) => {
if (timerRef.current) clearInterval(timerRef.current);
setCountdown(seconds);
@ -159,10 +165,6 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({prompt,
}, 1000);
};
const submitQuery = (text: string) => {
sendQuery(text);
}
const stopCountdown = () => {
if (timerRef.current) {
clearInterval(timerRef.current);
@ -182,12 +184,16 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({prompt,
};
useImperativeHandle(ref, () => ({
submitQuery: () => {
submitQuery: (query: string) => {
sendQuery(query);
}
}));
// If context status changes, show a warning if necessary. If it drops
const submitQuery = (query: string) => {
sendQuery(query);
}
// If context status changes, show a warning if necessary. If it drops
// back below the threshold, clear the warning trigger
useEffect(() => {
const context_used_percentage = Math.round(100 * contextStatus.context_used / contextStatus.max_context);
@ -201,7 +207,43 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({prompt,
setContextUsedPercentage(context_used_percentage)
}, [contextStatus, setContextWarningShown, contextWarningShown, setContextUsedPercentage, setSnack]);
const reset = async () => {
try {
const response = await fetch(props.connectionBase + `/api/reset/${props.sessionId}/${props.type}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ reset: 'history' })
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
props.messageFilter && props.messageFilter([]);
setConversation([
...(props.preamble || []),
...(props.messages || []),
]);
setNoInteractions(true);
} catch (e) {
setSnack("Error resetting history", "error")
console.error('Error resetting history:', e);
}
};
const sendQuery = async (query: string) => {
setNoInteractions(false);
if (!query.trim()) return;
//setTab(0);
@ -237,7 +279,7 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({prompt,
}
// Make the fetch request with proper headers
const response = await fetch(connectionBase + `/api/chat/${sessionId}`, {
const response = await fetch(props.connectionBase + `/api/chat/${props.sessionId}/${props.type}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -381,9 +423,12 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({prompt,
};
return (
<Box className="ConversationContainer" sx={{ display: "flex", flexDirection: "column", height: "100%", overflowY: "auto" }}>
<Box className="Conversation" sx={{ flexGrow: 2, p: 1 }}>
{conversation.map((message, index) => <Message key={index} submitQuery={submitQuery} message={message} />)}
<Box className={props.className || "Conversation"} sx={{ ...props.sx, display: "flex", flexDirection: "column" }}>
{
conversation.map((message, index) =>
<Message key={index} {...{ submitQuery, message, connectionBase: props.connectionBase, sessionId: props.sessionId, setSnack }} />
)
}
<Box sx={{
display: "flex",
flexDirection: "column",
@ -407,7 +452,51 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({prompt,
>Estimated response time: {countdown}s</Box>
)}
</Box>
<Box sx={{ ml: "0.25rem", fontSize: "0.6rem", color: "darkgrey", display: "flex", flexDirection: "row", gap: 1, mt: "auto" }}>
<Box className="Query" sx={{ display: "flex", flexDirection: props.type === "job_description" ? "column" : "row", p: 1 }}>
<TextField
variant="outlined"
disabled={processing}
fullWidth
multiline={props.type === "job_description"}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyPress}
placeholder={props.prompt}
id="QueryInput"
/>
<Box key="jobActions" sx={{ display: "flex", justifyContent: "center", flexDirection: "row" }}>
<IconButton
sx={{ display: "flex", margin: 'auto 0px' }}
size="large"
edge="start"
color="inherit"
onClick={() => { reset(); }}
>
<Tooltip title={props.resetLabel || "Reset"} >
<ResetIcon />
</Tooltip>
</IconButton>
<Tooltip title={props.actionLabel || "Send"}>
<Button
sx={{ m: 1, gap: 1, flexGrow: 1 }}
variant="contained"
onClick={() => { sendQuery(query); }}>
{props.actionLabel}<SendIcon />
</Button>
</Tooltip>
</Box>
</Box>
{(noInteractions || !props.hideDefaultPrompts) && props.defaultPrompts !== undefined && props.defaultPrompts.length &&
<Box sx={{ display: "flex", flexDirection: "column" }}>
{
props.defaultPrompts.map((element, index) => {
return (<Box key={index}>{element}</Box>);
})
}
</Box>
}
<Box sx={{ ml: "0.25rem", fontSize: "0.6rem", color: "darkgrey", display: "flex", flexShrink: 1, flexDirection: "row", gap: 1, mb: "auto", mt: 1 }}>
Context used: {contextUsedPercentage}% {contextStatus.context_used}/{contextStatus.max_context}
{
contextUsedPercentage >= 90 ? <Typography sx={{ fontSize: "0.6rem", color: "red" }}>WARNING: Context almost exhausted. You should start a new chat.</Typography>
@ -415,23 +504,7 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({prompt,
: <></>)
}
</Box>
</Box>
<Box className="Query" sx={{ display: "flex", flexDirection: "row", p: 1 }}>
<TextField
variant="outlined"
disabled={processing}
fullWidth
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="Enter your question..."
id="QueryInput"
/>
<Tooltip title="Send">
<Button sx={{ m: 1 }} variant="contained" onClick={() => { sendQuery(query); }}><SendIcon /></Button>
</Tooltip>
</Box>
<Box sx={{ display: "flex", flexGrow: 1 }}></Box>
</Box>
);
});

View File

@ -1,27 +0,0 @@
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<Theme>} [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<Theme>;
}

View File

@ -24,12 +24,30 @@ import {
RestartAlt as ResetIcon,
} from '@mui/icons-material';
import PropagateLoader from "react-spinners/PropagateLoader";
import { SxProps, Theme } from '@mui/material';
import { Message } from './Message';
import { Document } from './Document';
import { DocumentViewerProps } from './DocumentTypes';
import MuiMarkdown from 'mui-markdown';
import { Message, ChatQuery } from './Message';
import { Document } from './Document';
import { MessageData, MessageList } from './Message';
import { SeverityType } from './Snack';
import { Conversation } from './Conversation';
/**
* Props for the DocumentViewer component
* @interface DocumentViewerProps
* @property {SxProps<Theme>} [sx] - Optional styling properties
* @property {string} [connectionBase] - Base URL for fetch calls
* @property {string} [sessionId] - Session ID
* @property {(message: string, severity: SeverityType) => void} - setSnack UI callback
*/
export interface DocumentViewerProps {
sx?: SxProps<Theme>;
connectionBase: string;
sessionId: string;
setSnack: (message: string, severity: SeverityType) => void,
}
/**
* DocumentViewer component
*
@ -37,16 +55,16 @@ import MuiMarkdown from 'mui-markdown';
* with different layouts for mobile and desktop views.
*/
const DocumentViewer: React.FC<DocumentViewerProps> = ({
generateResume,
jobDescription,
factCheck,
resume,
setResume,
facts,
setFacts,
sx
sx,
connectionBase,
sessionId,
setSnack
}) => {
// State for editing job description
const [jobDescription, setJobDescription] = useState<string | undefined>(undefined);
const [facts, setFacts] = useState<MessageData | undefined>(undefined);
const [resume, setResume] = useState<MessageData | undefined>(undefined);
const [editJobDescription, setEditJobDescription] = useState<string | undefined>(jobDescription);
// Processing state to show loading indicators
const [processing, setProcessing] = useState<string | undefined>(undefined);
@ -89,8 +107,8 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
}
setProcessing("resume");
setTimeout(() => { setActiveTab(1); }, 250); // Switch to resume view on mobile
generateResume(description);
}, [generateResume, setProcessing, setActiveTab, setResume]);
console.log('generateResume(description);');
}, [/*generateResume*/, setProcessing, setActiveTab, setResume]);
/**
* Trigger fact check and update UI state
@ -104,9 +122,9 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
return;
}
setProcessing("facts");
factCheck(resume);
console.log('factCheck(resume)');
setTimeout(() => { setActiveTab(2); }, 250); // Switch to resume view on mobile
}, [factCheck, setResume, setProcessing, setActiveTab, setFacts]);
}, [/*factCheck,*/ setResume, setProcessing, setActiveTab, setFacts]);
useEffect(() => {
setEditJobDescription(jobDescription);
@ -159,62 +177,73 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
triggerGeneration(editJobDescription || "");
}
};
const handleJobQuery = (query: string) => {
triggerGeneration(query);
};
const renderJobDescriptionView = () => {
const children = [];
const jobDescriptionQuestions = [
<Box sx={{ display: "flex", flexDirection: "row" }}>
<ChatQuery text="What are the key skills necessary for this position?" submitQuery={handleJobQuery} />
<ChatQuery text="How much should this position pay (accounting for inflation)?" submitQuery={handleJobQuery} />
</Box>,
];
if (resume === undefined && processing === undefined) {
children.push(
<Document key="jobDescription" sx={{ display: "flex", flexGrow: 1 }} title="">
<TextField
variant="outlined"
fullWidth
multiline
type="text"
sx={{
flex: 1,
flexGrow: 1,
maxHeight: '100%',
overflow: 'auto',
}}
value={editJobDescription}
onChange={(e) => setEditJobDescription(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="Paste a job description, then click Generate..."
/>
</Document>
);
} else {
children.push(<MuiMarkdown key="jobDescription" >{editJobDescription}</MuiMarkdown>)
const filterJobDescriptionMessages = (messages: MessageList): MessageList => {
/* The second messages is the RESUME (the LLM response to the JOB-DESCRIPTION) */
if (messages.length > 1) {
setResume(messages[1]);
} else if (resume !== undefined) {
setResume(undefined);
}
children.push(
<Box key="jobActions" sx={{ display: "flex", justifyContent: "center", flexDirection: "row" }}>
<IconButton
sx={{ display: "flex", margin: 'auto 0px' }}
size="large"
edge="start"
color="inherit"
disabled={processing !== undefined}
onClick={() => { setEditJobDescription(""); triggerGeneration(undefined); }}
>
<Tooltip title="Reset Job Description">
<ResetIcon />
</Tooltip>
</IconButton>
<Tooltip title="Generate">
<Button
sx={{ m: 1, gap: 1, flexGrow: 1 }}
variant="contained"
onClick={() => { triggerGeneration(editJobDescription); }}
>
Generate<SendIcon />
</Button>
</Tooltip>
</Box>
);
/* Filter out the RESUME */
const reduced = messages.filter((message, index) => index != 1);
return children;
/* Set the first message as coming from the assistant (rendered as markdown) */
if (reduced.length > 0) {
reduced[0].role = 'assistant';
}
return reduced;
};
const jobDescriptionMessages: MessageList = [];
const renderJobDescriptionView = () => {
if (resume === undefined) {
return <Conversation
{...{
sx: { display: "flex", flexGrow: 1 },
actionLabel: "Generate Resume",
multiline: true,
type: "job_description",
prompt: "Paste a job description, then click Generate...",
messageFilter: filterJobDescriptionMessages,
messages: jobDescriptionMessages,
sessionId,
connectionBase,
setSnack,
defaultPrompts: jobDescriptionQuestions
}}
/>
} else {
return <Conversation
{...{
className: "ChatBox",
sx: { display: "flex", flexGrow: 1 },
type: "job_description",
actionLabel: "Send",
prompt: "Ask a question about this job description...",
messageFilter: filterJobDescriptionMessages,
messages: jobDescriptionMessages,
sessionId,
connectionBase,
setSnack,
defaultPrompts: jobDescriptionQuestions
}}
/>
}
}
/**
@ -223,7 +252,7 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
const renderResumeView = () => (
<Box key="ResumeView" sx={{ display: "flex", flexDirection: "column", overflow: "auto", flexGrow: 1, flexBasis: 0 }}>
<Document sx={{ display: "flex", flexGrow: 1 }} title="">
{resume !== undefined && <Message message={resume} />}
{resume !== undefined && <Message {...{ message: resume, connectionBase, sessionId, setSnack }} />}
</Document>
{processing === "resume" && (
<Box sx={{
@ -257,13 +286,7 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
const renderFactCheckView = () => (
<Box key="FactView" sx={{ display: "flex", flexDirection: "column", overflow: "auto", flexGrow: 1, flexBasis: 0, p: 0 }}>
<Document sx={{ display: "flex", flexGrow: 1 }} title="">
{facts !== undefined && <Message message={facts} />}
{/* <pre>
With over 20 years of experience as a software architect, team lead, and developer, James Ketrenos brings a unique blend of technical expertise and leadership to the table. Focused on advancing energy-efficient AI solutions, he excels in designing, building, and deploying scalable systems that enable rapid product development. His extensive background in Linux software architecture, DevOps, and open-source technologies makes him an ideal candidate for leading roles at technology-driven companies.
---
</pre> */}
{facts !== undefined && <Message {...{ message: facts, connectionBase, sessionId, setSnack }} />}
</Document>
{processing === "facts" && (
<Box sx={{
@ -336,7 +359,7 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
const otherRatio = showResume ? (100 - splitRatio / 2) : 100;
const children = [];
children.push(
<Box key="JobDescription" sx={{ display: 'flex', flexDirection: 'column', width: `${otherRatio}%`, p: 0, flexGrow: 1, overflow: 'hidden' }}>
<Box key="JobDescription" className="ChatBox" sx={{ display: 'flex', flexDirection: 'column', width: `${otherRatio}%`, p: 0, flexGrow: 1, overflowY: 'auto' }}>
{renderJobDescriptionView()}
</Box>);
@ -391,7 +414,7 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
}
return (
<Box sx={{ display: 'flex', flexGrow: 1, flexDirection: 'column', overflow: 'hidden', p: 0 }}>
<Box sx={{ ...sx, display: 'flex', flexGrow: 1, flexDirection: 'column', p: 0 }}>
<Box sx={{ display: 'flex', flexGrow: 1, flexDirection: 'row', overflow: 'hidden', p: 0 }}>
{children}
</Box>
@ -401,7 +424,7 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, ...sx }}>
<Box sx={{ ...sx, display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
{getActiveDesktopContent()}
</Box>
);

View File

@ -1,4 +1,15 @@
import { useState, useRef } from 'react';
import Divider from '@mui/material/Divider';
import Accordion from '@mui/material/Accordion';
import AccordionSummary from '@mui/material/AccordionSummary';
import AccordionDetails from '@mui/material/AccordionDetails';
import Card from '@mui/material/Card';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
@ -11,17 +22,50 @@ import { ExpandMore } from './ExpandMore';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import CheckIcon from '@mui/icons-material/Check';
import { MessageData, MessageMeta } from './MessageMeta';
import { ChatBubble } from './ChatBubble';
import { StyledMarkdown } from './StyledMarkdown';
import { Tooltip } from '@mui/material';
import { VectorVisualizer } from './VectorVisualizer';
import { SeverityType } from './Snack';
type MessageRoles = 'info' | 'user' | 'assistant' | 'system';
type MessageData = {
role: MessageRoles,
content: string,
user?: string,
type?: string,
id?: string,
isProcessing?: boolean,
metadata?: MessageMetaProps
};
interface MessageMetaProps {
query?: {
query_embedding: number[];
vector_embedding: number[];
},
rag: any,
tools: any[],
eval_count: number,
eval_duration: number,
prompt_eval_count: number,
prompt_eval_duration: number,
sessionId?: string,
connectionBase: string,
setSnack: (message: string, severity: SeverityType) => void,
}
type MessageList = MessageData[];
interface MessageInterface {
interface MessageProps {
message?: MessageData,
isFullWidth?: boolean,
submitQuery?: (text: string) => void
submitQuery?: (text: string) => void,
sessionId?: string,
connectionBase: string,
setSnack: (message: string, severity: SeverityType) => void,
};
interface ChatQueryInterface {
@ -29,18 +73,135 @@ interface ChatQueryInterface {
submitQuery?: (text: string) => void
}
const MessageMeta = ({ ...props }: MessageMetaProps) => {
return (<>
<Box sx={{ fontSize: "0.8rem", mb: 1 }}>
Below is the LLM performance of this query. Note that if tools are called, the
entire context is processed for each separate tool request by the LLM. This
can dramatically increase the total time for a response.
</Box>
<TableContainer component={Card} className="PromptStats" sx={{ mb: 1 }}>
<Table aria-label="prompt stats" size="small">
<TableHead>
<TableRow>
<TableCell></TableCell>
<TableCell align="right" >Tokens</TableCell>
<TableCell align="right">Time (s)</TableCell>
<TableCell align="right">TPS</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow key="prompt" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row">Prompt</TableCell>
<TableCell align="right">{props.prompt_eval_count}</TableCell>
<TableCell align="right">{Math.round(props.prompt_eval_duration / 10 ** 7) / 100}</TableCell>
<TableCell align="right">{Math.round(props.prompt_eval_count * 10 ** 9 / props.prompt_eval_duration)}</TableCell>
</TableRow>
<TableRow key="response" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row">Response</TableCell>
<TableCell align="right">{props.eval_count}</TableCell>
<TableCell align="right">{Math.round(props.eval_duration / 10 ** 7) / 100}</TableCell>
<TableCell align="right">{Math.round(props.eval_count * 10 ** 9 / props.eval_duration)}</TableCell>
</TableRow>
<TableRow key="total" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row">Total</TableCell>
<TableCell align="right">{props.prompt_eval_count + props.eval_count}</TableCell>
<TableCell align="right">{Math.round((props.prompt_eval_duration + props.eval_duration) / 10 ** 7) / 100}</TableCell>
<TableCell align="right">{Math.round((props.prompt_eval_count + props.eval_count) * 10 ** 9 / (props.prompt_eval_duration + props.eval_duration))}</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
{
props.tools !== undefined && props.tools.length !== 0 &&
<Accordion sx={{ boxSizing: "border-box" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: "0.8rem" }}>
Tools queried
</Box>
</AccordionSummary>
<AccordionDetails>
{props.tools.map((tool: any, index: number) => <Box key={index}>
{index !== 0 && <Divider />}
<Box sx={{ fontSize: "0.75rem", display: "flex", flexDirection: "column", mt: 0.5 }}>
<div style={{ display: "flex", paddingRight: "1rem", whiteSpace: "nowrap" }}>
{tool.tool}
</div>
<div style={{
display: "flex",
padding: "3px",
whiteSpace: "pre-wrap",
flexGrow: 1,
border: "1px solid #E0E0E0",
wordBreak: "break-all",
maxHeight: "5rem",
overflow: "auto"
}}>
{JSON.stringify(tool.result, null, 2)}
</div>
</Box>
</Box>)}
</AccordionDetails>
</Accordion>
}
{
props?.rag?.name !== undefined && <>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: "0.8rem" }}>
Top RAG {props.rag.ids.length} matches from '{props.rag.name}' collection against embedding vector of {props.rag.query_embedding.length} dimensions
</Box>
</AccordionSummary>
<AccordionDetails>
{props.rag.ids.map((id: number, index: number) => <Box key={index}>
{index !== 0 && <Divider />}
<Box sx={{ fontSize: "0.75rem", display: "flex", flexDirection: "row", mb: 0.5, mt: 0.5 }}>
<div style={{ display: "flex", flexDirection: "column", paddingRight: "1rem", minWidth: "10rem" }}>
<div style={{ whiteSpace: "nowrap" }}>Doc ID: {props.rag.ids[index].slice(-10)}</div>
<div style={{ whiteSpace: "nowrap" }}>Similarity: {Math.round(props.rag.distances[index] * 100) / 100}</div>
<div style={{ whiteSpace: "nowrap" }}>Type: {props.rag.metadatas[index].doc_type}</div>
<div style={{ whiteSpace: "nowrap" }}>Chunk Len: {props.rag.documents[index].length}</div>
</div>
<div style={{ display: "flex", padding: "3px", flexGrow: 1, border: "1px solid #E0E0E0", maxHeight: "5rem", overflow: "auto" }}>{props.rag.documents[index]}</div>
</Box>
</Box>
)}
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: "0.8rem" }}>
UMAP Vector Visualization of RAG
</Box>
</AccordionSummary>
<AccordionDetails>
<VectorVisualizer inline {...props} rag={props?.rag} />
</AccordionDetails>
</Accordion>
</>
}
</>
);
};
const ChatQuery = ({ text, submitQuery }: ChatQueryInterface) => {
return (submitQuery
? <Button variant="outlined" sx={{
if (submitQuery === undefined) {
return (<Box>{text}</Box>);
}
return (
<Button variant="outlined" sx={{
color: theme => theme.palette.custom.highlight, // Golden Ochre (#D4A017)
borderColor: theme => theme.palette.custom.highlight,
m: 1
}}
size="small" onClick={(e: any) => { console.log(text); submitQuery(text); }}>{text}</Button>
: <Box>{text}</Box>);
size="small" onClick={(e: any) => { console.log(text); submitQuery(text); }}>
{text}
</Button>
);
}
const Message = ({ message, submitQuery, isFullWidth }: MessageInterface) => {
const Message = ({ message, submitQuery, isFullWidth, sessionId, setSnack, connectionBase }: MessageProps) => {
const [expanded, setExpanded] = useState<boolean>(false);
const [copied, setCopied] = useState(false);
const textFieldRef = useRef(null);
@ -72,32 +233,31 @@ const Message = ({ message, submitQuery, isFullWidth }: MessageInterface) => {
const formattedContent = message.content.trim();
return (
<ChatBubble className="Message" isFullWidth={isFullWidth} role={message.role} sx={{ flexGrow: 1, pb: message.metadata ? 0 : "8px", m: 0, mb: 1, mt: 1, overflowX: "auto" }}>
<ChatBubble className="Message" isFullWidth={isFullWidth} role={message.role} sx={{ display: "flex", flexDirection: "column", pb: message.metadata ? 0 : "8px", m: 0, mb: 1, mt: 1, overflowX: "auto" }}>
<CardContent ref={textFieldRef} sx={{ position: "relative", display: "flex", flexDirection: "column", overflowX: "auto" }}>
<Tooltip title="Copy to clipboard" placement="top" arrow>
<IconButton
onClick={handleCopy}
sx={{
position: 'absolute',
top: 8,
right: 8,
width: 24,
height: 24,
bgcolor: 'background.paper',
'&:hover': { bgcolor: 'action.hover' },
}}
size="small"
color={copied ? "success" : "default"}
>
<IconButton
onClick={handleCopy}
sx={{
position: 'absolute',
top: 0,
right: 0,
width: 24,
height: 24,
bgcolor: 'background.paper',
'&:hover': { bgcolor: 'action.hover' },
}}
size="small"
color={copied ? "success" : "default"}
>
{copied ? <CheckIcon sx={{ width: 16, height: 16 }} /> : <ContentCopyIcon sx={{ width: 16, height: 16 }} />}
</IconButton>
</IconButton>
</Tooltip>
{message.role !== 'user' ?
<StyledMarkdown
className="MessageContent"
sx={{ display: "flex", color: 'text.secondary' }}
{...{ content: formattedContent, submitQuery }} />
:
<Typography
@ -110,8 +270,8 @@ const Message = ({ message, submitQuery, isFullWidth }: MessageInterface) => {
}
</CardContent>
{message.metadata && <>
<CardActions disableSpacing>
<Typography sx={{ color: "darkgrey", p: 1, textAlign: "end", flexGrow: 1 }}>LLM information for this query</Typography>
<CardActions disableSpacing sx={{ justifySelf: "flex-end" }}>
<Button variant="text" onClick={handleExpandClick} sx={{ color: "darkgrey", p: 1, flexGrow: 0 }}>LLM information for this query</Button>
<ExpandMore
expand={expanded}
onClick={handleExpandClick}
@ -123,7 +283,7 @@ const Message = ({ message, submitQuery, isFullWidth }: MessageInterface) => {
</CardActions>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<CardContent>
<MessageMeta metadata={message.metadata} />
<MessageMeta {...{ ...message.metadata, sessionId, connectionBase, setSnack }} />
</CardContent>
</Collapse>
</>}
@ -132,12 +292,17 @@ const Message = ({ message, submitQuery, isFullWidth }: MessageInterface) => {
};
export type {
MessageInterface,
MessageProps,
MessageList,
ChatQueryInterface,
MessageMetaProps,
MessageData,
MessageRoles
};
export {
Message,
ChatQuery,
MessageMeta
};

View File

@ -1,138 +0,0 @@
//import React, { useState, useEffect, useRef, useCallback, ReactElement } from 'react';
import Divider from '@mui/material/Divider';
import Accordion from '@mui/material/Accordion';
import AccordionSummary from '@mui/material/AccordionSummary';
import AccordionDetails from '@mui/material/AccordionDetails';
import Box from '@mui/material/Box';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import Card from '@mui/material/Card';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
type MessageMetadata = {
rag: any,
tools: any[],
eval_count: number,
eval_duration: number,
prompt_eval_count: number,
prompt_eval_duration: number
};
type MessageRoles = 'info' | 'user' | 'assistant' | 'system';
type MessageData = {
role: MessageRoles,
content: string,
user?: string,
type?: string,
id?: string,
isProcessing?: boolean,
metadata?: MessageMetadata
};
interface MessageMetaInterface {
metadata: MessageMetadata
}
const MessageMeta = ({ metadata }: MessageMetaInterface) => {
if (metadata === undefined) {
return <></>
}
return (<>
<Box sx={{ fontSize: "0.8rem", mb: 1 }}>
Below is the LLM performance of this query. Note that if tools are called, the entire context is processed for each separate tool request by the LLM. This can dramatically increase the total time for a response.
</Box>
<TableContainer component={Card} className="PromptStats" sx={{ mb: 1 }}>
<Table aria-label="prompt stats" size="small">
<TableHead>
<TableRow>
<TableCell></TableCell>
<TableCell align="right" >Tokens</TableCell>
<TableCell align="right">Time (s)</TableCell>
<TableCell align="right">TPS</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow key="prompt" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row">Prompt</TableCell>
<TableCell align="right">{metadata.prompt_eval_count}</TableCell>
<TableCell align="right">{Math.round(metadata.prompt_eval_duration / 10 ** 7) / 100}</TableCell>
<TableCell align="right">{Math.round(metadata.prompt_eval_count * 10 ** 9 / metadata.prompt_eval_duration)}</TableCell>
</TableRow>
<TableRow key="response" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row">Response</TableCell>
<TableCell align="right">{metadata.eval_count}</TableCell>
<TableCell align="right">{Math.round(metadata.eval_duration / 10 ** 7) / 100}</TableCell>
<TableCell align="right">{Math.round(metadata.eval_count * 10 ** 9 / metadata.eval_duration)}</TableCell>
</TableRow>
<TableRow key="total" sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row">Total</TableCell>
<TableCell align="right">{metadata.prompt_eval_count + metadata.eval_count}</TableCell>
<TableCell align="right">{Math.round((metadata.prompt_eval_duration + metadata.eval_duration) / 10 ** 7) / 100}</TableCell>
<TableCell align="right">{Math.round((metadata.prompt_eval_count + metadata.eval_count) * 10 ** 9 / (metadata.prompt_eval_duration + metadata.eval_duration))}</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
{
metadata.tools !== undefined && metadata.tools.length !== 0 &&
<Accordion sx={{ boxSizing: "border-box" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: "0.8rem" }}>
Tools queried
</Box>
</AccordionSummary>
<AccordionDetails>
{metadata.tools.map((tool: any, index: number) => <Box key={index}>
{index !== 0 && <Divider />}
<Box sx={{ fontSize: "0.75rem", display: "flex", flexDirection: "column", mt: 0.5 }}>
<div style={{ display: "flex", paddingRight: "1rem", whiteSpace: "nowrap" }}>
{tool.tool}
</div>
<div style={{ display: "flex", padding: "3px", whiteSpace: "pre-wrap", flexGrow: 1, border: "1px solid #E0E0E0", wordBreak: "break-all", maxHeight: "5rem", overflow: "auto" }}>{JSON.stringify(tool.result, null, 2)}</div>
</Box>
</Box>)}
</AccordionDetails>
</Accordion>
}
{
metadata?.rag?.name !== undefined &&
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ fontSize: "0.8rem" }}>
Top RAG {metadata.rag.ids.length} matches from '{metadata.rag.name}' collection against embedding vector of {metadata.rag.query_embedding.length} dimensions
</Box>
</AccordionSummary>
<AccordionDetails>
{metadata.rag.ids.map((id: number, index: number) => <Box key={index}>
{index !== 0 && <Divider />}
<Box sx={{ fontSize: "0.75rem", display: "flex", flexDirection: "row", mb: 0.5, mt: 0.5 }}>
<div style={{ display: "flex", flexDirection: "column", paddingRight: "1rem", minWidth: "10rem" }}>
<div style={{ whiteSpace: "nowrap" }}>Doc ID: {metadata.rag.ids[index].slice(-10)}</div>
<div style={{ whiteSpace: "nowrap" }}>Similarity: {Math.round(metadata.rag.distances[index] * 100) / 100}</div>
<div style={{ whiteSpace: "nowrap" }}>Type: {metadata.rag.metadatas[index].doc_type}</div>
<div style={{ whiteSpace: "nowrap" }}>Chunk Len: {metadata.rag.documents[index].length}</div>
</div>
<div style={{ display: "flex", padding: "3px", flexGrow: 1, border: "1px solid #E0E0E0", maxHeight: "5rem", overflow: "auto" }}>{metadata.rag.documents[index]}</div>
</Box>
</Box>
)}
</AccordionDetails>
</Accordion>
}
</>
);
};
export type {
MessageMetadata,
MessageMetaInterface,
MessageData,
MessageRoles,
};
export { MessageMeta };

View File

@ -1,8 +1,7 @@
import { useState, useCallback, useEffect } from 'react';
import Box from '@mui/material/Box';
import { SeverityType } from './Snack';
import { ContextStatus } from './ContextStatus';
import { MessageData, MessageMetadata } from './MessageMeta';
import { MessageData, MessageMetaProps } from './Message';
import { DocumentViewer } from './DocumentViewer';
interface ResumeBuilderProps {
@ -21,324 +20,15 @@ type Resume = {
resume: MessageData | undefined,
fact_check: MessageData | undefined,
job_description: string,
metadata: MessageMetadata
metadata: MessageMetaProps
};
const ResumeBuilder = ({ facts, setFacts, resume, setResume, setProcessing, processing, connectionBase, sessionId, setSnack }: ResumeBuilderProps) => {
const [lastEvalTPS, setLastEvalTPS] = useState<number>(35);
const [lastPromptTPS, setLastPromptTPS] = useState<number>(430);
const [contextStatus, setContextStatus] = useState<ContextStatus>({ context_used: 0, max_context: 0 });
const [jobDescription, setJobDescription] = useState<string | undefined>(undefined);
const updateContextStatus = useCallback(() => {
fetch(connectionBase + `/api/context-status/${sessionId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
.then(response => response.json())
.then(data => {
setContextStatus(data);
})
.catch(error => {
console.error('Error getting context status:', error);
setSnack("Unable to obtain context status.", "error");
});
}, [setContextStatus, connectionBase, setSnack, sessionId]);
// If the jobDescription and resume have not been set, fetch them from the server
useEffect(() => {
if (sessionId === undefined) {
return;
}
if (jobDescription !== undefined) {
return;
}
const fetchResume = async () => {
try {
// Make the fetch request with proper headers
const response = await fetch(connectionBase + `/api/resume/${sessionId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
if (!response.ok) {
throw Error();
}
const data: Resume[] = await response.json();
if (data.length) {
const lastResume = data[data.length - 1];
console.log(lastResume);
setJobDescription(lastResume['job_description']);
setResume(lastResume.resume);
if (lastResume['fact_check'] !== undefined && lastResume['fact_check'] !== null) {
lastResume['fact_check'].role = 'info';
setFacts(lastResume['fact_check'])
} else {
setFacts(undefined)
}
}
} catch (error: any) {
setSnack("Unable to fetch resume", "error");
console.error(error);
}
}
fetchResume();
}, [sessionId, resume, jobDescription, setResume, setJobDescription, setSnack, setFacts, connectionBase]);
// const startCountdown = (seconds: number) => {
// if (timerRef.current) clearInterval(timerRef.current);
// setCountdown(seconds);
// timerRef.current = setInterval(() => {
// setCountdown((prev) => {
// if (prev <= 1) {
// clearInterval(timerRef.current);
// timerRef.current = null;
// if (isScrolledToBottom()) {
// setTimeout(() => {
// scrollToBottom();
// }, 50)
// }
// return 0;
// }
// return prev - 1;
// });
// }, 1000);
// };
// const stopCountdown = () => {
// if (timerRef.current) {
// clearInterval(timerRef.current);
// timerRef.current = null;
// setCountdown(0);
// }
// };
if (sessionId === undefined) {
return (<></>);
}
const generateResume = async (description: string) => {
if (!description.trim()) return;
setResume(undefined);
setFacts(undefined);
try {
setProcessing(true);
// Add initial processing message
//setGenerateStatus({ role: 'assistant', content: 'Processing request...' });
// Make the fetch request with proper headers
const response = await fetch(connectionBase + `/api/generate-resume/${sessionId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ content: description.trim() }),
});
// We'll guess that the response will be around 500 tokens...
const token_guess = 500;
const estimate = Math.round(token_guess / lastEvalTPS + contextStatus.context_used / lastPromptTPS);
setSnack(`Job description sent. Response estimated in ${estimate}s.`, "info");
//startCountdown(Math.round(estimate));
if (!response.ok) {
throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
// Set up stream processing with explicit chunking
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const chunk = decoder.decode(value, { stream: true });
// Process each complete line immediately
buffer += chunk;
let lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
if (!line.trim()) continue;
try {
const update = JSON.parse(line);
// Force an immediate state update based on the message type
if (update.status === 'processing') {
// Update processing message with immediate re-render
//setGenerateStatus({ role: 'info', content: update.message });
console.log(update.num_ctx);
// Add a small delay to ensure React has time to update the UI
await new Promise(resolve => setTimeout(resolve, 0));
} else if (update.status === 'done') {
// Replace processing message with final result
//setGenerateStatus(undefined);
setResume(update.message);
const metadata = update.message.metadata;
const evalTPS = metadata.eval_count * 10 ** 9 / metadata.eval_duration;
const promptTPS = metadata.prompt_eval_count * 10 ** 9 / metadata.prompt_eval_duration;
setLastEvalTPS(evalTPS ? evalTPS : 35);
setLastPromptTPS(promptTPS ? promptTPS : 35);
updateContextStatus();
} else if (update.status === 'error') {
// Show error
//setGenerateStatus({ role: 'error', content: update.message });
}
} catch (e) {
setSnack("Error generating resume", "error")
console.error('Error parsing JSON:', e, line);
}
}
}
// Process any remaining buffer content
if (buffer.trim()) {
try {
const update = JSON.parse(buffer);
if (update.status === 'done') {
//setGenerateStatus(undefined);
setResume(update.message);
}
} catch (e) {
setSnack("Error processing job description", "error")
}
}
//stopCountdown();
setProcessing(false);
} catch (error) {
console.error('Fetch error:', error);
setSnack("Unable to process job description", "error");
//setGenerateStatus({ role: 'error', content: `Error: ${error}` });
setProcessing(false);
//stopCountdown();
}
};
const factCheck = async (resume: string) => {
if (!resume.trim()) return;
setFacts(undefined);
try {
setProcessing(true);
const response = await fetch(connectionBase + `/api/fact-check/${sessionId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ content: resume.trim() }),
});
// We'll guess that the response will be around 500 tokens...
const token_guess = 500;
const estimate = Math.round(token_guess / lastEvalTPS + contextStatus.context_used / lastPromptTPS);
setSnack(`Resume sent for Fact Check. Response estimated in ${estimate}s.`, "info");
//startCountdown(Math.round(estimate));
if (!response.ok) {
throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
// Set up stream processing with explicit chunking
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const chunk = decoder.decode(value, { stream: true });
// Process each complete line immediately
buffer += chunk;
let lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
if (!line.trim()) continue;
try {
const update = JSON.parse(line);
// Force an immediate state update based on the message type
if (update.status === 'processing') {
// Add a small delay to ensure React has time to update the UI
await new Promise(resolve => setTimeout(resolve, 0));
} else if (update.status === 'done') {
// Replace processing message with final result
update.message.role = 'info';
setFacts(update.message);
const metadata = update.message.metadata;
const evalTPS = metadata.eval_count * 10 ** 9 / metadata.eval_duration;
const promptTPS = metadata.prompt_eval_count * 10 ** 9 / metadata.prompt_eval_duration;
setLastEvalTPS(evalTPS ? evalTPS : 35);
setLastPromptTPS(promptTPS ? promptTPS : 35);
updateContextStatus();
} else if (update.status === 'error') {
}
} catch (e) {
setSnack("Error generating resume", "error")
console.error('Error parsing JSON:', e, line);
}
}
}
// Process any remaining buffer content
if (buffer.trim()) {
try {
const update = JSON.parse(buffer);
if (update.status === 'done') {
update.message.role = 'info';
setFacts(update.message);
}
} catch (e) {
setSnack("Error processing resume", "error")
}
}
//stopCountdown();
setProcessing(false);
} catch (error) {
console.error('Fetch error:', error);
setSnack("Unable to process resume", "error");
//setGenerateStatus({ role: 'error', content: `Error: ${error}` });
setProcessing(false);
//stopCountdown();
}
};
return (
<Box className="DocBox">
<Box className="Conversation" sx={{ p: 0, pt: 1 }}>
@ -350,7 +40,7 @@ const ResumeBuilder = ({ facts, setFacts, resume, setResume, setProcessing, proc
overflowY: "auto",
flexDirection: "column",
height: "calc(0vh - 0px)", // Hack to make the height work
}} {...{ factCheck, facts, jobDescription, generateResume, resume, setFacts, setResume, setJobDescription }} />
}} {...{ setSnack, connectionBase, sessionId }} />
</Box>
</Box>
);

View File

@ -7,6 +7,8 @@ import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip';
import Button from '@mui/material/Button';
import SendIcon from '@mui/icons-material/Send';
import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch';
import { SeverityType } from './Snack';
@ -19,6 +21,8 @@ interface ResultData {
embeddings: number[][] | number[][][];
documents: string[];
metadatas: Metadata[];
ids: string[];
dimensions: number;
}
interface PlotData {
@ -39,6 +43,8 @@ interface VectorVisualizerProps {
connectionBase: string;
sessionId?: string;
setSnack: (message: string, severity: SeverityType) => void;
inline?: boolean;
rag?: any;
}
interface ChromaResult {
@ -48,7 +54,8 @@ interface ChromaResult {
metadatas: Metadata[];
query_embedding: number[];
query?: string;
vector_embedding?: number[];
umap_embedding_2d?: number[];
umap_embedding_3d?: number[];
}
const normalizeDimension = (arr: number[]): number[] => {
@ -89,11 +96,12 @@ const symbolMap: Record<string, string> = {
'query': 'circle',
};
const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectionBase, sessionId }) => {
const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, rag, inline, connectionBase, sessionId }) => {
const [plotData, setPlotData] = useState<PlotData | null>(null);
const [query, setQuery] = useState<string>('');
const [queryEmbedding, setQueryEmbedding] = useState<ChromaResult | undefined>(undefined);
const [newQuery, setNewQuery] = useState<string>('');
const [newQueryEmbedding, setNewQueryEmbedding] = useState<ChromaResult | undefined>(undefined);
const [result, setResult] = useState<ResultData | undefined>(undefined);
const [view2D, setView2D] = useState<boolean>(true);
const [tooltip, setTooltip] = useState<{
visible: boolean,
// x: number,
@ -105,7 +113,7 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectio
// Get the collection to visualize
useEffect(() => {
if (result !== undefined || sessionId === undefined) {
if ((result !== undefined && result.dimensions !== (view2D ? 3 : 2)) || sessionId === undefined) {
return;
}
const fetchCollection = async () => {
@ -115,9 +123,10 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectio
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ dimensions: 3 }),
body: JSON.stringify({ dimensions: view2D ? 2 : 3 }),
});
const data = await response.json();
const data: ResultData = await response.json();
data.dimensions = view2D ? 2 : 3;
setResult(data);
} catch (error) {
console.error('Error obtaining collection information:', error);
@ -126,7 +135,7 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectio
};
fetchCollection();
}, [result, setResult, connectionBase, setSnack, sessionId])
}, [result, setResult, connectionBase, setSnack, sessionId, view2D])
useEffect(() => {
if (!result || !result.embeddings) return;
@ -135,12 +144,31 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectio
const vectors: number[][] = [...result.embeddings as number[][]];
const documents = [...result.documents || []];
const metadatas = [...result.metadatas || []];
const ids = [...result.ids || []];
if (queryEmbedding !== undefined && queryEmbedding.vector_embedding !== undefined) {
if (view2D && rag && rag.umap_embedding_2d) {
metadatas.unshift({ doc_type: 'query' });
documents.unshift(queryEmbedding.query || '');
vectors.unshift(queryEmbedding.vector_embedding);
documents.unshift('Query');
vectors.unshift(rag.umap_embedding_2d);
}
if (!view2D && rag && rag.umap_embedding_3d) {
metadatas.unshift({ doc_type: 'query' });
documents.unshift('Query');
vectors.unshift(rag.umap_embedding_3d);
}
if (newQueryEmbedding !== undefined) {
metadatas.unshift({ doc_type: 'query' });
documents.unshift(newQueryEmbedding.query || '');
if (view2D && newQueryEmbedding.umap_embedding_2d) {
vectors.unshift(newQueryEmbedding.umap_embedding_2d);
}
if (!view2D && newQueryEmbedding.umap_embedding_3d) {
vectors.unshift(newQueryEmbedding.umap_embedding_3d);
}
}
const is2D = vectors.every((v: number[]) => v.length === 2);
const is3D = vectors.every((v: number[]) => v.length === 3);
if (!is2D && !is3D) {
@ -148,11 +176,19 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectio
return;
}
const doc_types = metadatas.map(m => m.doc_type || 'unknown');
const sizes = doc_types.map(type => {
const doc_types = metadatas.map(m => m.doc_type || 'unknown')
const sizes = doc_types.map((type, index) => {
if (!sizeMap[type]) {
sizeMap[type] = 5;
}
/* If this is a match, increase the size */
if (rag && rag.ids.includes(ids[index])) {
return sizeMap[type] + 5;
}
if (newQueryEmbedding && newQueryEmbedding.ids && newQueryEmbedding.ids.includes(ids[index])) {
return sizeMap[type] + 5;
}
return sizeMap[type];
});
const symbols = doc_types.map(type => {
@ -189,7 +225,7 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectio
},
xaxis: { title: 'X', gridcolor: '#cccccc', zerolinecolor: '#aaaaaa' },
yaxis: { title: 'Y', gridcolor: '#cccccc', zerolinecolor: '#aaaaaa' },
margin: { r: 20, b: 10, l: 10, t: 40 },
margin: { r: 0, b: 0, l: 0, t: 0 },
};
const data: any = {
@ -212,17 +248,23 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectio
setPlotData({ data, layout });
}, [result, queryEmbedding]);
}, [result, newQueryEmbedding, rag, view2D, setPlotData, setSnack]);
if (setSnack === undefined) {
console.error('setSnack function is undefined');
return null;
}
const handleKeyPress = (event: any) => {
if (event.key === 'Enter') {
sendQuery(query);
sendQuery(newQuery);
}
};
const sendQuery = async (query: string) => {
if (!query.trim()) return;
setQuery('');
setNewQuery('');
try {
const response = await fetch(`${connectionBase}/api/similarity/${sessionId}`, {
method: 'PUT',
@ -231,11 +273,11 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectio
},
body: JSON.stringify({
query: query,
dimensions: view2D ? 2 : 3,
})
});
const chroma: ChromaResult = await response.json();
console.log('Chroma:', chroma);
setQueryEmbedding(chroma);
setNewQueryEmbedding(chroma);
} catch (error) {
console.error('Error obtaining query similarity information:', error);
setSnack("Unable to obtain query similarity information.", "error");
@ -249,86 +291,93 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = ({ setSnack, connectio
);
return (
<>
<Card sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', mb: 1, pt: 0 }}>
<Typography variant="h6" sx={{ p: 1, pt: 0 }}>
Similarity Visualization via Uniform Manifold Approximation and Projection (UMAP)
</Typography>
</Card>
<Box sx={{ display: 'flex', flexGrow: 1, justifyContent: 'center', alignItems: 'center' }}>
<Plot
onClick={(event: any) => {
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}`,
});
}}
<Box className="VectorVisualizer" sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, overflow: 'hidden' }}>
{
!inline &&
<Card sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', mb: 1, pt: 0 }}>
<Typography variant="h6" sx={{ p: 1, pt: 0 }}>
Similarity Visualization via Uniform Manifold Approximation and Projection (UMAP)
</Typography>
</Card>
}
<FormControlLabel sx={{ display: "inline-flex", width: "fit-content", mb: '-2.5rem', zIndex: 100, ml: 1, flexBasis: 0, flexGrow: 0 }} control={<Switch checked={!view2D} />} onChange={() => setView2D(!view2D)} label="3D" />
<Plot
onClick={(event: any) => {
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}`,
});
}}
data={[plotData.data]}
useResizeHandler={true}
config={{
responsive: true,
displayModeBar: false,
displaylogo: false,
showSendToCloud: false,
staticPlot: false,
}}
style={{ width: '100%', height: '100%' }}
layout={plotData.layout}
data={[plotData.data]}
useResizeHandler={true}
config={{
responsive: true,
// displayModeBar: false,
displaylogo: false,
showSendToCloud: false,
staticPlot: false,
}}
style={{ display: "flex", flexGrow: 1, justifyContent: 'center', alignItems: 'center', minHeight: '30vh', height: '30vh', padding: 0, margin: 0 }}
layout={plotData.layout}
/>
</Box>
<Card sx={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
mt: 1,
p: 0.5,
color: tooltip?.color || '#2E2E2E',
background: tooltip?.background || '#FFFFFF',
whiteSpace: 'pre-line',
zIndex: 1000,
overflow: 'auto',
maxHeight: '20vh',
minHeight: '20vh',
overflowWrap: 'break-all',
wordBreak: 'break-all',
}}
>
<Typography variant="body2" sx={{ p: 1, pt: 0 }}>
{tooltip?.content}
</Typography>
</Card>
{ queryEmbedding !== undefined &&
{!inline &&
<Card sx={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
mt: 1,
p: 0.5,
color: tooltip?.color || '#2E2E2E',
background: tooltip?.background || '#FFFFFF',
whiteSpace: 'pre-line',
zIndex: 1000,
overflow: 'auto',
maxHeight: '20vh',
minHeight: '20vh',
overflowWrap: 'break-all',
wordBreak: 'break-all',
}}
>
<Typography variant="body2" sx={{ p: 1, pt: 0 }}>
{tooltip?.content}
</Typography>
</Card>
}
{!inline && newQueryEmbedding !== undefined &&
<Card sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', mt: 1, pb: 0 }}>
<Typography variant="h6" sx={{ p: 1, pt: 0, maxHeight: '5rem', overflow: 'auto' }}>
Query: {queryEmbedding.query}
Query: {newQueryEmbedding.query}
</Typography>
</Card>
}
<Box className="Query" sx={{ display: "flex", flexDirection: "row", p: 1 }}>
<TextField
variant="outlined"
fullWidth
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="Enter query to find related documents..."
id="QueryInput"
/>
<Tooltip title="Send">
<Button sx={{ m: 1 }} variant="contained" onClick={() => { sendQuery(query); }}><SendIcon /></Button>
</Tooltip>
</Box>
</>
{
!inline &&
<Box className="Query" sx={{ display: "flex", flexDirection: "row", p: 1 }}>
<TextField
variant="outlined"
fullWidth
type="text"
value={newQuery}
onChange={(e) => setNewQuery(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="Enter query to find related documents..."
id="QueryInput"
/>
<Tooltip title="Send">
<Button sx={{ m: 1 }} variant="contained" onClick={() => { sendQuery(newQuery); }}><SendIcon /></Button>
</Tooltip>
</Box>
}
</Box>
);
};

View File

@ -11,6 +11,7 @@ import uuid
import subprocess
import re
import math
import copy
def try_import(module_name, pip_name=None):
try:
@ -52,6 +53,8 @@ from tools import (
tools
)
CONTEXT_VERSION=2
rags = [
{ "name": "JPK", "enabled": True, "description": "Expert data about James Ketrenos, including work history, personal hobbies, and projects." },
# { "name": "LKML", "enabled": False, "description": "Full associative data for entire LKML mailing list archive." },
@ -164,6 +167,8 @@ Always use tools and [{context_tag}] when possible. Be concise, and never make u
""".strip()
system_generate_resume = f"""
Launched on {DateTime()}.
You are a professional resume writer. Your task is to write a polished, tailored resume for a specific job based only on the individual's [WORK HISTORY].
When answering queries, follow these steps:
@ -188,9 +193,11 @@ Structure the resume professionally with the following sections where applicable
Do not include any information unless it is provided in [WORK HISTORY] or [INTRO].
Ensure the langauge is clear, concise, and aligned with industry standards for professional resumes.
"""
""".strip()
system_fact_check = f"""
Launched on {DateTime()}.
You are a professional resume fact checker. Your task is to identify any inaccuracies in the [RESUME] based on the individual's [WORK HISTORY].
If there are inaccuracies, list them in a bullet point format.
@ -198,7 +205,20 @@ If there are inaccuracies, list them in a bullet point format.
When answering queries, follow these steps:
1. You must not invent or assume any information not explicitly present in the [WORK HISTORY].
2. Analyze the [RESUME] to identify any discrepancies or inaccuracies based on the [WORK HISTORY].
"""
""".strip()
system_job_description = f"""
Launched on {DateTime()}.
You are a hiring and job placing specialist. Your task is to answers about a job description.
When answering queries, follow these steps:
1. Analyze the [JOB DESCRIPTION] to provide insights for the asked question.
2. If any financial information is requested, be sure to account for inflation.
""".strip()
def create_system_message(prompt):
return [{"role": "system", "content": prompt}]
tool_log = []
command_log = []
@ -374,6 +394,9 @@ async def handle_tool_calls(message):
final_result = all_responses[0] if len(all_responses) == 1 else all_responses
yield (final_result, tools_used)
# %%
class WebServer:
def __init__(self, logging, client, model=MODEL_NAME):
@ -431,71 +454,6 @@ class WebServer:
return RedirectResponse(url=f"/{context['id']}", status_code=307)
#return JSONResponse({"redirect": f"/{context['id']}"})
@self.app.get("/api/query")
async def query_documents(query: str, top_k: int = 3):
if not self.file_watcher:
return
"""Query the RAG system with the given prompt."""
results = self.file_watcher.find_similar(query, top_k=top_k)
return {
"query": query,
"results": [
{
"content": doc,
"metadata": meta,
"distance": dist
}
for doc, meta, dist in zip(
results["documents"],
results["metadatas"],
results["distances"]
)
]
}
@self.app.post("/api/refresh/{file_path:path}")
async def refresh_document(file_path: str, background_tasks: BackgroundTasks):
if not self.file_watcher:
return
"""Manually refresh a specific document in the collection."""
full_path = os.path.join(defines.doc_dir, file_path)
if not os.path.exists(full_path):
return {"status": "error", "message": "File not found"}
# Schedule the update in the background
background_tasks.add_task(
self.file_watcher.process_file_update, full_path
)
return {
"status": "success",
"message": f"Document refresh scheduled for {file_path}"
}
# @self.app.post("/api/refresh-all")
# async def refresh_all_documents():
# if not self.file_watcher:
# return
# """Refresh all documents in the collection."""
# # Re-initialize file hashes and process all files
# self.file_watcher._initialize_file_hashes()
# # Schedule updates for all files
# file_paths = self.file_watcher.file_hashes.keys()
# tasks = [self.file_watcher.process_file_update(path) for path in file_paths]
# # Wait for all updates to complete
# await asyncio.gather(*tasks)
# return {
# "status": "success",
# "message": f"Refreshed {len(file_paths)} documents",
# "document_count": file_watcher.collection.count()
# }
@self.app.put("/api/umap/{context_id}")
async def put_umap(context_id: str, request: Request):
@ -515,12 +473,16 @@ class WebServer:
dimensions = 2
try:
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()
result = self.file_watcher.umap_collection
if dimensions == 2:
logging.info("Returning 2D UMAP")
umap_embedding = self.file_watcher.umap_embedding_2d
else:
logging.info("Returning 3D UMAP")
umap_embedding = self.file_watcher.umap_embedding_3d
result["embeddings"] = umap_embedding.tolist()
return JSONResponse(result)
except Exception as e:
@ -536,10 +498,6 @@ class WebServer:
logging.warning(f"Invalid context_id: {context_id}")
return JSONResponse({"error": "Invalid context_id"}, status_code=400)
context = self.upsert_context(context_id)
if not context.get("umap_model"):
return JSONResponse({"error": "No umap_model found in context"}, status_code=404)
try:
data = await request.json()
query = data.get("query", "")
@ -552,28 +510,37 @@ class WebServer:
chroma_results = self.file_watcher.find_similar(query=query, top_k=10)
if not chroma_results:
return JSONResponse({"error": "No results found"}, status_code=404)
chroma_embedding = chroma_results["query_embedding"]
umap_embedding = context["umap_model"].transform([chroma_embedding])[0].tolist()
return JSONResponse({ **chroma_results, "query": query, "vector_embedding": umap_embedding })
return JSONResponse({
**chroma_results,
"query": query,
"umap_embedding_2d": self.file_watcher.umap_model_2d.transform([chroma_embedding])[0].tolist(),
"umap_embedding_3d": self.file_watcher.umap_model_3d.transform([chroma_embedding])[0].tolist()
})
except Exception as e:
logging.error(e)
#return JSONResponse({"error": str(e)}, 500)
@self.app.put("/api/reset/{context_id}")
async def put_reset(context_id: str, request: Request):
@self.app.put("/api/reset/{context_id}/{type}")
async def put_reset(context_id: str, type: str, request: Request):
if not is_valid_uuid(context_id):
logging.warning(f"Invalid context_id: {context_id}")
return JSONResponse({"error": "Invalid context_id"}, status_code=400)
context = self.upsert_context(context_id)
if type not in context["sessions"]:
return JSONResponse({ "error": f"{type} is not recognized", "context": context }, status_code=404)
data = await request.json()
try:
response = {}
for reset in data["reset"]:
match reset:
case "system-prompt":
context["system"] = [{"role": "system", "content": system_message}]
response["system-prompt"] = { "system-prompt": system_message }
case "system_prompt":
context["sessions"][type]["system_prompt"] = system_message
response["system_prompt"] = { "system_prompt": system_message }
case "rags":
context["rags"] = rags.copy()
response["rags"] = context["rags"]
@ -581,23 +548,23 @@ class WebServer:
context["tools"] = default_tools(tools)
response["tools"] = context["tools"]
case "history":
context["llm_history"] = []
context["user_history"] = []
context["sessions"][type]["llm_history"] = []
context["sessions"][type]["user_history"] = []
context["sessions"][type]["context_tokens"] = round(len(str(context["system"])) * 3 / 4) # Estimate context usage
response["history"] = []
context["context_tokens"] = round(len(str(context["system"])) * 3 / 4) # Estimate context usage
response["context_used"] = context["context_tokens"]
case "message-history-length":
response["context_used"] = context["sessions"][type]["context_tokens"]
case "message_history_length":
context["message_history_length"] = DEFAULT_HISTORY_LENGTH
response["message-history-length"] = DEFAULT_HISTORY_LENGTH
response["message_history_length"] = DEFAULT_HISTORY_LENGTH
if not response:
return JSONResponse({ "error": "Usage: { reset: rags|tools|history|system-prompt}"})
return JSONResponse({ "error": "Usage: { reset: rags|tools|history|system_prompt}"})
else:
self.save_context(context_id)
return JSONResponse(response)
except:
return JSONResponse({ "error": "Usage: { reset: rags|tools|history|system-prompt}"})
return JSONResponse({ "error": "Usage: { reset: rags|tools|history|system_prompt}"})
@self.app.put("/api/tunables/{context_id}")
async def put_tunables(context_id: str, request: Request):
@ -608,20 +575,20 @@ class WebServer:
data = await request.json()
for k in data.keys():
match k:
case "system-prompt":
case "system_prompt":
system_prompt = data[k].strip()
if not system_prompt:
return JSONResponse({ "status": "error", "message": "System prompt can not be empty." })
context["system"] = [{"role": "system", "content": system_prompt}]
self.save_context(context_id)
return JSONResponse({ "system-prompt": system_prompt })
case "message-history-length":
return JSONResponse({ "system_prompt": system_prompt })
case "message_history_length":
value = max(0, int(data[k]))
context["message_history_length"] = value
self.save_context(context_id)
return JSONResponse({ "message-history-length": value })
return JSONResponse({ "message_history_length": value })
case _:
return JSONResponse({ "error": f"Unrecognized tunable {k}"}, 404)
return JSONResponse({ "error": f"Unrecognized tunable {k}"}, status_code=404)
@self.app.get("/api/tunables/{context_id}")
async def get_tunables(context_id: str):
@ -630,33 +597,29 @@ class WebServer:
return JSONResponse({"error": "Invalid context_id"}, status_code=400)
context = self.upsert_context(context_id)
return JSONResponse({
"system-prompt": context["system"][0]["content"],
"message-history-length": context["message_history_length"]
"system_prompt": context["system"][0]["content"],
"message_history_length": context["message_history_length"]
})
@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}")
return JSONResponse({"error": "Invalid context_id"}, status_code=400)
context = self.upsert_context(context_id)
return JSONResponse(context["resume_history"])
@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}")
async def chat_endpoint(context_id: str, request: Request):
@self.app.post("/api/chat/{context_id}/{type}")
async def chat_endpoint(context_id: str, type: str, request: Request):
if not is_valid_uuid(context_id):
logging.warning(f"Invalid context_id: {context_id}")
return JSONResponse({"error": "Invalid context_id"}, status_code=400)
context = self.upsert_context(context_id)
if type not in context["sessions"]:
return JSONResponse({ "error": f"{type} is not recognized", "context": context }, status_code=404)
data = await request.json()
# 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, type=type, content=data["content"]):
# Convert to JSON and add newline
yield json.dumps(message) + "\n"
# Save the history as its generated
@ -675,74 +638,18 @@ class WebServer:
}
)
@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}")
return JSONResponse({"error": "Invalid context_id"}, status_code=400)
context = self.upsert_context(context_id)
data = await request.json()
# Create a custom generator that ensures flushing
async def flush_generator():
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
self.save_context(context_id)
# Explicitly flush after each yield
await asyncio.sleep(0) # Allow the event loop to process the write
# Return StreamingResponse with appropriate headers
return StreamingResponse(
flush_generator(),
media_type="application/json",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no" # Prevents Nginx buffering if you're using it
}
)
@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}")
return JSONResponse({"error": "Invalid context_id"}, status_code=400)
context = self.upsert_context(context_id)
data = await request.json()
# Create a custom generator that ensures flushing
async def flush_generator():
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
self.save_context(context_id)
# Explicitly flush after each yield
await asyncio.sleep(0) # Allow the event loop to process the write
# Return StreamingResponse with appropriate headers
return StreamingResponse(
flush_generator(),
media_type="application/json",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no" # Prevents Nginx buffering if you"re using it
}
)
@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}")
async def get_history(context_id: str):
@self.app.get("/api/history/{context_id}/{type}")
async def get_history(context_id: str, type: str):
context = self.upsert_context(context_id)
return JSONResponse(context["user_history"])
if type not in context["sessions"]:
return JSONResponse({ "error": f"{type} is not recognized", "context": context }, status_code=404)
return JSONResponse(context["sessions"][type]["user_history"])
@self.app.get("/api/tools/{context_id}")
async def get_tools(context_id: str):
@ -764,7 +671,7 @@ class WebServer:
tool["enabled"] = enabled
self.save_context(context_id)
return JSONResponse(context["tools"])
return JSONResponse({ "status": f"{modify} not found in tools." }), 404
return JSONResponse({ "status": f"{modify} not found in tools." }, status_code=404)
except:
return JSONResponse({ "status": "error" }), 405
@ -788,17 +695,19 @@ class WebServer:
tool["enabled"] = enabled
self.save_context(context_id)
return JSONResponse(context["rags"])
return JSONResponse({ "status": f"{modify} not found in tools." }), 404
return JSONResponse({ "status": f"{modify} not found in tools." }, status_code=404)
except:
return JSONResponse({ "status": "error" }), 405
@self.app.get("/api/context-status/{context_id}")
async def get_context_status(context_id):
@self.app.get("/api/context-status/{context_id}/{type}")
async def get_context_status(context_id, type: str):
if not is_valid_uuid(context_id):
logging.warning(f"Invalid context_id: {context_id}")
return JSONResponse({"error": "Invalid context_id"}, status_code=400)
context = self.upsert_context(context_id)
return JSONResponse({"context_used": context["context_tokens"], "max_context": defines.max_context})
if type not in context["sessions"]:
return JSONResponse({ "error": f"{type} is not recognized", "context": context }, status_code=404)
return JSONResponse({"context_used": context["sessions"][type]["context_tokens"], "max_context": defines.max_context})
@self.app.get("/api/health")
async def health_check():
@ -833,16 +742,80 @@ class WebServer:
# Create the full file path
file_path = os.path.join(defines.session_dir, session_id)
umap_model = context.get("umap_model")
if umap_model:
del context["umap_model"]
# Serialize the data to JSON and write to file
with open(file_path, "w") as f:
json.dump(context, f)
if umap_model:
context["umap_model"] = umap_model
return session_id
def migrate_context(self, context):
# No version
# context = {
# "id": context_id,
# "tools": default_tools(tools),
# "rags": rags.copy(),
# "context_tokens": round(len(str(system_context)) * 3 / 4), # Estimate context usage
# "message_history_length": 5, # Number of messages to supply in context
# "system": system_context,
# "system_generate_resume": system_generate_resume,
# "llm_history": [],
# "user_history": [],
# "resume_history": [],
# }
# Version 2:
# context = {
# "version": 2,
# "id": context_id,
# "sessions": {
# **TYPE**: { # chat, job-description, resume, fact-check
# "system_prompt": **SYSTEM_MESSAGE**,
# "llm_history": [],
# "user_history": [],
# "context_tokens": round(len(str(**SYSTEM_MESSAGE**)) * 3 / 4),
# }
# },
# "tools": default_tools(tools),
# "rags": rags.copy(),
# "message_history_length": 5 # Number of messages to supply in context
# }
if "version" not in context:
logging.info(f"Migrating {context['id']}")
context["version"] = CONTEXT_VERSION
context["sessions"] = {
"chat": {
"system_prompt": system_message,
"llm_history": context["llm_history"],
"user_history": context["user_history"],
"context_tokens": round(len(str(create_system_message(system_message))))
},
"job_description": {
"system_prompt": system_job_description,
"llm_history": [],
"user_history": [],
"context_tokens": round(len(str(create_system_message(system_job_description))))
},
"resume": {
"system_prompt": system_generate_resume,
"llm_history": [],
"user_history": [],
"context_tokens": round(len(str(create_system_message(system_generate_resume))))
},
"fact_check": {
"system_prompt": system_fact_check,
"llm_history": [],
"user_history": [],
"context_tokens": round(len(str(create_system_message(system_fact_check))))
},
}
del context["system"]
del context["system_generate_resume"]
del context["llm_history"]
del context["user_history"]
del context["resume_history"]
return context
def load_context(self, session_id):
"""
Load a serialized Python dictionary from a file in the sessions directory.
@ -863,22 +836,42 @@ class WebServer:
with open(file_path, "r") as f:
self.contexts[session_id] = json.load(f)
return self.contexts[session_id]
return self.migrate_context(self.contexts[session_id])
def create_context(self, context_id = None):
if not context_id:
context_id = str(uuid.uuid4())
system_context = [{"role": "system", "content": system_message}];
context = {
"id": context_id,
"system": system_context,
"system_generate_resume": system_generate_resume,
"llm_history": [],
"user_history": [],
"version": CONTEXT_VERSION,
"sessions": {
"chat": {
"system_prompt": system_message,
"llm_history": [],
"user_history": [],
"context_tokens": round(len(str(system_message)) * 3 / 4), # Estimate context usage
},
"job_description": {
"system_prompt": system_job_description,
"llm_history": [],
"user_history": [],
"context_tokens": round(len(str(system_job_description)) * 3 / 4), # Estimate context usage
},
"resume": {
"system_prompt": system_generate_resume,
"llm_history": [],
"user_history": [],
"context_tokens": round(len(str(system_generate_resume)) * 3 / 4), # Estimate context usage
},
"fact_check": {
"system_prompt": system_fact_check,
"llm_history": [],
"user_history": [],
"context_tokens": round(len(str(system_fact_check)) * 3 / 4), # Estimate context usage
},
},
"tools": default_tools(tools),
"resume_history": [],
"rags": rags.copy(),
"context_tokens": round(len(str(system_context)) * 3 / 4), # Estimate context usage
"message_history_length": 5 # Number of messages to supply in context
}
logging.info(f"{context_id} created and added to sessions.")
@ -894,12 +887,11 @@ class WebServer:
logging.warning("No context ID provided. Creating a new context.")
return self.create_context()
if context_id in self.contexts:
logging.info(f"Context {context_id} found.")
return self.contexts[context_id]
logging.info(f"Context {context_id} not found. Creating new context.")
return self.load_context(context_id)
async def chat(self, context, content):
async def chat(self, context, type, content):
if not self.file_watcher:
return
@ -914,55 +906,172 @@ class WebServer:
self.processing = True
llm_history = context["llm_history"]
user_history = context["user_history"]
metadata = {
"rag": {},
"tools": [],
"eval_count": 0,
"eval_duration": 0,
"prompt_eval_count": 0,
"prompt_eval_duration": 0,
}
rag_docs = []
for rag in context["rags"]:
if rag["enabled"] and rag["name"] == "JPK": # Only support JPK rag right now...
yield {"status": "processing", "message": f"Checking RAG context {rag['name']}..."}
chroma_results = self.file_watcher.find_similar(query=content, top_k=10)
if chroma_results:
rag_docs.extend(chroma_results["documents"])
metadata["rag"] = { "name": rag["name"], **chroma_results }
preamble = ""
if len(rag_docs):
preamble = f"""
1. Respond to this query: {content}
2. If there is information in this context to enhance the answer, do so:
[{context_tag}]:\n"""
for doc in rag_docs:
preamble += doc
preamble += f"\n[/{context_tag}]\nUse all of that information to respond to: "
# Figure
llm_history.append({"role": "user", "content": preamble + content})
user_history.append({"role": "user", "content": content})
if context["message_history_length"]:
messages = context["system"] + llm_history[-context["message_history_length"]:]
else:
messages = context["system"] + llm_history
try:
llm_history = context["sessions"][type]["llm_history"]
user_history = context["sessions"][type]["user_history"]
metadata = {
"type": type,
"rag": { "documents": [] },
"tools": [],
"eval_count": 0,
"eval_duration": 0,
"prompt_eval_count": 0,
"prompt_eval_duration": 0,
}
# Default to not using tools
enable_tools = False
# Default eo using RAG
enable_rag = True
# The first time a particular session type is used, it is handled differently. After the initial pass (once the
# llm_history has more than one entry), the standard 'chat' is used.
if len(user_history) >= 1:
process_type = "chat"
# Do not enable RAG when limiting context to the job description chat
if type == "job_description":
enable_rag = False
else:
process_type = type
if enable_rag:
for rag in context["rags"]:
if rag["enabled"] and rag["name"] == "JPK": # Only support JPK rag right now...
yield {"status": "processing", "message": f"Checking RAG context {rag['name']}..."}
chroma_results = self.file_watcher.find_similar(query=content, top_k=10)
if chroma_results:
chroma_embedding = chroma_results["query_embedding"]
metadata["rag"] = {
**chroma_results,
"name": rag["name"],
"umap_embedding_2d": self.file_watcher.umap_model_2d.transform([chroma_embedding])[0].tolist(),
"umap_embedding_3d": self.file_watcher.umap_model_3d.transform([chroma_embedding])[0].tolist()
}
match process_type:
# Normal chat interactions with context history
case "chat":
enable_tools = True
preamble = ""
rag_context = ""
for doc in metadata["rag"]["documents"]:
rag_context += doc
if rag_context:
preamble = f"""
1. Respond to this query: {content}
2. If there is information in this context to enhance the answer, do so:
[{context_tag}]
{rag_context}
[/{context_tag}]
Use that information to respond to: """
# Single job_description is provided; generate a resume
case "job_description":
# Always force the full resume to be in context
resume_doc = open(defines.resume_doc, "r").read()
work_history = f"{resume_doc}\n"
for doc in metadata["rag"]["documents"]:
work_history += f"{doc}\n"
preamble = f"""
[INTRO]
{resume_intro}
[/INTRO]
[WORK HISTORY]
{work_history}
[/WORK HISTORY]
[JOB DESCRIPTION]
{content}
[/JOB DESCRIPTION]
1. Use the above [INTRO] and [WORK HISTORY] to create the resume for the [JOB DESCRIPTION].
2. Do not use content from the [JOB DESCRIPTION] in the response unless the [WORK HISTORY] mentions them.
"""
# Seed the first context messages with the resume from the 'job_description' session
case "resume":
raise Exception(f"Invalid chat type: {type}")
# Fact check the resume created by the 'job_description' using only the RAG and resume
case "fact_check":
if len(context["sessions"]["resume"]["llm_history"]) < 3: # SYSTEM, USER, **ASSISTANT**
yield {"status": "done", "message": "No resume history found." }
return
resume = context["sessions"]["resume"]["llm_history"][2]
metadata = copy.deepcopy(resume["metadata"])
metadata["eval_count"] = 0
metadata["eval_duration"] = 0
metadata["prompt_eval_count"] = 0
metadata["prompt_eval_duration"] = 0
resume_doc = open(defines.resume_doc, "r").read()
work_history = f"{resume_doc}\n"
for doc in metadata["rag"]["documents"]:
work_history += f"{doc}\n"
preamble = f"""
[WORK HISTORY]
{work_history}
[/WORK HISTORY]
[RESUME]
{resume['content']}
[/RESUME]
"""
content = resume['content']
raise Exception(f"Invalid chat type: {type}")
case _:
raise Exception(f"Invalid chat type: {type}")
llm_history.append({"role": "user", "content": preamble + content})
user_history.append({"role": "user", "content": content})
if context["message_history_length"]:
messages = create_system_message(context["sessions"][type]["system_prompt"]) + llm_history[-context["message_history_length"]:]
else:
messages = create_system_message(context["sessions"][type]["system_prompt"]) + llm_history
# Estimate token length of new messages
ctx_size = self.get_optimal_ctx_size(context["context_tokens"], messages=llm_history[-1]["content"])
yield {"status": "processing", "message": "Processing request...", "num_ctx": ctx_size}
ctx_size = self.get_optimal_ctx_size(context["sessions"][type]["context_tokens"], messages=llm_history[-1]["content"])
processing_type = "Processing query..."
match type:
case "job_description":
processing_type = "Generating resume..."
case "fact_check":
processing_type = "Fact Checking resume..."
if len(llm_history) > 1:
processing_type = "Processing query..."
yield {"status": "processing", "message": processing_type, "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 })
try:
if enable_tools:
response = self.client.chat(model=self.model, messages=messages, tools=llm_tools(context["tools"]), options={ "num_ctx": ctx_size })
else:
response = self.client.chat(model=self.model, messages=messages, options={ "num_ctx": ctx_size })
except Exception as e:
logging.info(f"1. {messages[0]}")
logging.info(f"[LAST]. {messages[-1]}")
logging.exception({ "model": self.model, "error": str(e) })
yield {"status": "error", "message": f"An error occurred communicating with LLM"}
return
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"]
context["sessions"][type]["context_tokens"] = response["prompt_eval_count"] + response["eval_count"]
tools_used = []
@ -1005,7 +1114,7 @@ class WebServer:
metadata["tools"] = tools_used
# Estimate token length of new messages
ctx_size = self.get_optimal_ctx_size(context["context_tokens"], messages=messages[pre_add_index:])
ctx_size = self.get_optimal_ctx_size(context["sessions"][type]["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 })
@ -1013,7 +1122,7 @@ class WebServer:
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"]
context["sessions"][type]["context_tokens"] = response["prompt_eval_count"] + response["eval_count"]
reply = response["message"]["content"]
final_message = {"role": "assistant", "content": reply }
@ -1035,145 +1144,6 @@ class WebServer:
finally:
self.processing = False
async def generate_resume(self, context, content):
if not self.file_watcher:
return
content = content.strip()
if not content:
yield {"status": "error", "message": "Invalid request"}
return
if self.processing:
yield {"status": "error", "message": "Busy"}
return
self.processing = True
resume_history = context["resume_history"]
resume = {
"job_description": content,
"resume": "",
"metadata": {},
"rag": "",
"fact_check": {}
}
metadata = {
"rag": {},
"tools": [],
"eval_count": 0,
"eval_duration": 0,
"prompt_eval_count": 0,
"prompt_eval_duration": 0,
}
rag_docs = []
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...
yield {"status": "processing", "message": f"Checking RAG context {rag['name']}..."}
chroma_results = self.file_watcher.find_similar(query=content, top_k=10)
if chroma_results:
rag_docs.extend(chroma_results["documents"])
metadata["rag"] = { "name": rag["name"], **chroma_results }
preamble = f"[INTRO]\n{resume_intro}\n[/INTRO]\n"
preamble += f"""[WORK HISTORY]:\n"""
for doc in rag_docs:
preamble += f"{doc}\n"
resume["rag"] += f"{doc}\n"
preamble += f"\n[/WORK HISTORY]\n"
content = f"""{preamble}\n
Use the above [WORK HISTORY] and [INTRO] to create the resume for this [JOB DESCRIPTION]. Do not use the [JOB DESCRIPTION] in the generated resume unless the [WORK HISTORY] mentions them:\n[JOB DESCRIPTION]\n{content}\n[/JOB DESCRIPTION]\n"""
try:
# Estimate token length of new messages
ctx_size = self.get_optimal_ctx_size(context["context_tokens"], messages=[system_generate_resume, content])
yield {"status": "processing", "message": "Processing request...", "num_ctx": ctx_size}
# Use the async generator in an async for loop
#
# To support URL lookup:
#
# 1. Enable tools in a call to chat() with a simple prompt to invoke the tool to generate the summary if requested.
# 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"]
reply = response["response"]
final_message = {"role": "assistant", "content": reply, "metadata": metadata }
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) })
yield {"status": "error", "message": f"An error occurred: {str(e)}"}
finally:
self.processing = False
async def fact_check(self, context, content):
content = content.strip()
if not content:
yield {"status": "error", "message": "Invalid request"}
return
if self.processing:
yield {"status": "error", "message": "Busy"}
return
self.processing = True
resume_history = context["resume_history"]
if len(resume_history) == 0:
yield {"status": "done", "message": "No resume history found." }
return
resume = resume_history[-1]
metadata = resume["metadata"]
metadata["eval_count"] = 0
metadata["eval_duration"] = 0
metadata["prompt_eval_count"] = 0
metadata["prompt_eval_duration"] = 0
content = f"[WORK HISTORY]:{resume['rag']}[/WORK HISTORY]\n\n[RESUME]\n{resume['resume']['content']}\n[/RESUME]\n\n"
try:
# 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 })
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"]
final_message = {"role": "assistant", "content": reply, "metadata": metadata }
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) })
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):
try:
if self.ssl_enabled:

View File

@ -19,6 +19,7 @@ from langchain.text_splitter import CharacterTextSplitter
from langchain.schema import Document
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
import umap
# Import your existing modules
if __name__ == "__main__":
@ -52,6 +53,7 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
# Initialize ChromaDB collection
self._collection = self._get_vector_collection(recreate=recreate)
self._update_umaps()
# Setup text splitter
self.text_splitter = CharacterTextSplitter(
@ -68,6 +70,26 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
def collection(self):
return self._collection
@property
def umap_collection(self):
return self._umap_collection
@property
def umap_embedding_2d(self):
return self._umap_embedding_2d
@property
def umap_embedding_3d(self):
return self._umap_embedding_3d
@property
def umap_model_2d(self):
return self._umap_model_2d
@property
def umap_model_3d(self):
return self._umap_model_3d
def _save_hash_state(self):
"""Save the current file hash state to disk."""
try:
@ -185,6 +207,9 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
# Save the hash state after successful update
self._save_hash_state()
# Re-fit the UMAP for the new content
self._update_umaps()
except Exception as e:
logging.error(f"Error processing update for {file_path}: {e}")
finally:
@ -212,6 +237,23 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
except Exception as e:
logging.error(f"Error removing file from collection: {e}")
def _update_umaps(self):
# Update the UMAP embeddings
self._umap_collection = self._collection.get(include=["embeddings", "documents", "metadatas"])
if not self._umap_collection or not len(self._umap_collection["embeddings"]):
logging.warning("No embeddings found in the collection.")
return
logging.info(f"Updating 2D UMAP for {len(self._umap_collection['embeddings'])} vectors")
vectors = np.array(self._umap_collection["embeddings"])
self._umap_model_2d = umap.UMAP(n_components=2, random_state=8911, metric="cosine") #, n_neighbors=15, min_dist=0.1)
self._umap_embedding_2d = self._umap_model_2d.fit_transform(vectors)
logging.info(f"Updating 3D UMAP for {len(self._umap_collection['embeddings'])} vectors")
vectors = np.array(self._umap_collection["embeddings"])
self._umap_model_3d = umap.UMAP(n_components=3, random_state=8911, metric="cosine") #, n_neighbors=15, min_dist=0.1)
self._umap_embedding_3d = self._umap_model_3d.fit_transform(vectors)
def _get_vector_collection(self, recreate=False):
"""Get or create a ChromaDB collection."""
# Initialize ChromaDB client