Refactoring continues; working on styles

This commit is contained in:
James Ketr 2025-04-22 17:19:41 -07:00
parent 4ce616b64b
commit ca9dd950b3
6 changed files with 511 additions and 792 deletions

View File

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

View File

@ -3,9 +3,11 @@ import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import SendIcon from '@mui/icons-material/Send'; 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 PropagateLoader from "react-spinners/PropagateLoader";
import { Message, MessageList, MessageData } from './Message'; import { Message, MessageList, MessageData } from './Message';
@ -14,24 +16,31 @@ import { ContextStatus } from './ContextStatus';
const loadingMessage: MessageData = { "role": "assistant", "content": "Establishing connection with server..." }; const loadingMessage: MessageData = { "role": "assistant", "content": "Establishing connection with server..." };
type ConversationMode = 'chat' | 'fact-check' | 'system'; type ConversationMode = 'chat' | 'job_description' | 'resume' | 'fact_check';
interface ConversationHandle { interface ConversationHandle {
submitQuery: (query: string) => void; submitQuery: (query: string) => void;
} }
interface ConversationProps { interface ConversationProps {
className?: string,
type: ConversationMode type: ConversationMode
prompt: string, prompt: string,
actionLabel?: string,
resetAction?: () => void,
resetLabel?: string,
connectionBase: string, connectionBase: string,
sessionId?: string, sessionId?: string,
setSnack: (message: string, severity: SeverityType) => void, setSnack: (message: string, severity: SeverityType) => void,
defaultPrompts?: React.ReactElement[], defaultPrompts?: React.ReactElement[],
preamble?: MessageList, preamble?: MessageList,
hideDefaultPrompts?: boolean, hideDefaultPrompts?: boolean,
messageFilter?: (messages: MessageList) => MessageList,
messages?: MessageList,
sx?: SxProps<Theme>,
}; };
const Conversation = forwardRef<ConversationHandle, ConversationProps>(({ prompt, type, preamble, hideDefaultPrompts, defaultPrompts, sessionId, setSnack, connectionBase }: ConversationProps, ref) => { const Conversation = forwardRef<ConversationHandle, ConversationProps>(({ ...props }: ConversationProps, ref) => {
const [query, setQuery] = useState<string>(""); const [query, setQuery] = useState<string>("");
const [contextUsedPercentage, setContextUsedPercentage] = useState<number>(0); const [contextUsedPercentage, setContextUsedPercentage] = useState<number>(0);
const [processing, setProcessing] = useState<boolean>(false); const [processing, setProcessing] = useState<boolean>(false);
@ -43,12 +52,13 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({ prompt
const [contextStatus, setContextStatus] = useState<ContextStatus>({ context_used: 0, max_context: 0 }); const [contextStatus, setContextStatus] = useState<ContextStatus>({ context_used: 0, max_context: 0 });
const [contextWarningShown, setContextWarningShown] = useState<boolean>(false); const [contextWarningShown, setContextWarningShown] = useState<boolean>(false);
const [noInteractions, setNoInteractions] = useState<boolean>(true); const [noInteractions, setNoInteractions] = useState<boolean>(true);
const setSnack = props.setSnack;
// Update the context status // Update the context status
const updateContextStatus = useCallback(() => { const updateContextStatus = useCallback(() => {
const fetchContextStatus = async () => { const fetchContextStatus = async () => {
try { 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', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -68,18 +78,18 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({ prompt
} }
}; };
fetchContextStatus(); 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. // Set the initial chat history to "loading" or the welcome message if loaded.
useEffect(() => { useEffect(() => {
if (sessionId === undefined) { if (props.sessionId === undefined) {
setConversation([loadingMessage]); setConversation([loadingMessage]);
return; return;
} }
const fetchHistory = async () => { const fetchHistory = async () => {
try { try {
const response = await fetch(connectionBase + `/api/history/${sessionId}`, { const response = await fetch(props.connectionBase + `/api/history/${props.sessionId}/${props.type}`, {
method: 'GET', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -89,12 +99,18 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({ prompt
throw new Error(`Server responded with ${response.status}: ${response.statusText}`); throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json();
console.log(`Session id: ${sessionId} -- history returned from server with ${data.length} entries`) console.log(`History returned from server with ${data.length} entries`)
if (data.length === 0) { if (data.length === 0) {
setConversation(preamble || []); setConversation([
...(props.preamble || []),
...(props.messages || []),
]);
setNoInteractions(true); setNoInteractions(true);
} else { } else {
setConversation(data); setConversation([
...(props.messages || []),
...(props.messageFilter ? props.messageFilter(data) : data)
]);
setNoInteractions(false); setNoInteractions(false);
} }
updateContextStatus(); updateContextStatus();
@ -103,10 +119,10 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({ prompt
setSnack("Unable to obtain chat history.", "error"); setSnack("Unable to obtain chat history.", "error");
} }
}; };
if (sessionId !== undefined) { if (props.sessionId !== undefined) {
fetchHistory(); fetchHistory();
} }
}, [sessionId, setConversation, updateContextStatus, connectionBase, setSnack, preamble]); }, [props.sessionId, setConversation, updateContextStatus, props.connectionBase, setSnack, props.preamble, props.type]);
const isScrolledToBottom = useCallback(()=> { const isScrolledToBottom = useCallback(()=> {
// Current vertical scroll position // Current vertical scroll position
@ -191,6 +207,40 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({ prompt
setContextUsedPercentage(context_used_percentage) setContextUsedPercentage(context_used_percentage)
}, [contextStatus, setContextWarningShown, contextWarningShown, setContextUsedPercentage, setSnack]); }, [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) => { const sendQuery = async (query: string) => {
setNoInteractions(false); setNoInteractions(false);
@ -229,7 +279,7 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({ prompt
} }
// Make the fetch request with proper headers // 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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -373,8 +423,12 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({ prompt
}; };
return ( return (
<Box className="Conversation" sx={{ display: "flex", flexDirection: "column", overflowY: "auto" }}> <Box className={props.className || "Conversation"} sx={{ ...props.sx, display: "flex", flexDirection: "column" }}>
{conversation.map((message, index) => <Message key={index} {...{ submitQuery, message, connectionBase, sessionId, setSnack }} />)} {
conversation.map((message, index) =>
<Message key={index} {...{ submitQuery, message, connectionBase: props.connectionBase, sessionId: props.sessionId, setSnack }} />
)
}
<Box sx={{ <Box sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@ -398,26 +452,45 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({ prompt
>Estimated response time: {countdown}s</Box> >Estimated response time: {countdown}s</Box>
)} )}
</Box> </Box>
<Box className="Query" sx={{ display: "flex", flexDirection: "row", p: 1 }}> <Box className="Query" sx={{ display: "flex", flexDirection: props.type === "job_description" ? "column" : "row", p: 1 }}>
<TextField <TextField
variant="outlined" variant="outlined"
disabled={processing} disabled={processing}
fullWidth fullWidth
multiline={props.type === "job_description"}
type="text" type="text"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyPress} onKeyDown={handleKeyPress}
placeholder={prompt} placeholder={props.prompt}
id="QueryInput" id="QueryInput"
/> />
<Tooltip title="Send"> <Box key="jobActions" sx={{ display: "flex", justifyContent: "center", flexDirection: "row" }}>
<Button sx={{ m: 1 }} variant="contained" onClick={() => { sendQuery(query); }}><SendIcon /></Button> <IconButton
</Tooltip> 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> </Box>
{(noInteractions || !hideDefaultPrompts) && defaultPrompts !== undefined && defaultPrompts.length && {(noInteractions || !props.hideDefaultPrompts) && props.defaultPrompts !== undefined && props.defaultPrompts.length &&
<Box sx={{ display: "flex", flexDirection: "column" }}> <Box sx={{ display: "flex", flexDirection: "column" }}>
{ {
defaultPrompts.map((element, index) => { props.defaultPrompts.map((element, index) => {
return (<Box key={index}>{element}</Box>); return (<Box key={index}>{element}</Box>);
}) })
} }

View File

@ -28,33 +28,21 @@ import { SxProps, Theme } from '@mui/material';
import MuiMarkdown from 'mui-markdown'; import MuiMarkdown from 'mui-markdown';
import { Message } from './Message'; import { Message, ChatQuery } from './Message';
import { Document } from './Document'; import { Document } from './Document';
import { MessageData } from './Message'; import { MessageData, MessageList } from './Message';
import { SeverityType } from './Snack'; import { SeverityType } from './Snack';
import { Conversation } from './Conversation';
/** /**
* Props for the DocumentViewer component * Props for the DocumentViewer component
* @interface DocumentViewerProps * @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 * @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 { 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>; sx?: SxProps<Theme>;
connectionBase: string; connectionBase: string;
sessionId: string; sessionId: string;
@ -67,19 +55,16 @@ export interface DocumentViewerProps {
* with different layouts for mobile and desktop views. * with different layouts for mobile and desktop views.
*/ */
const DocumentViewer: React.FC<DocumentViewerProps> = ({ const DocumentViewer: React.FC<DocumentViewerProps> = ({
generateResume,
jobDescription,
factCheck,
resume,
setResume,
facts,
setFacts,
sx, sx,
connectionBase, connectionBase,
sessionId, sessionId,
setSnack setSnack
}) => { }) => {
// State for editing job description // 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); const [editJobDescription, setEditJobDescription] = useState<string | undefined>(jobDescription);
// Processing state to show loading indicators // Processing state to show loading indicators
const [processing, setProcessing] = useState<string | undefined>(undefined); const [processing, setProcessing] = useState<string | undefined>(undefined);
@ -122,8 +107,8 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
} }
setProcessing("resume"); setProcessing("resume");
setTimeout(() => { setActiveTab(1); }, 250); // Switch to resume view on mobile setTimeout(() => { setActiveTab(1); }, 250); // Switch to resume view on mobile
generateResume(description); console.log('generateResume(description);');
}, [generateResume, setProcessing, setActiveTab, setResume]); }, [/*generateResume*/, setProcessing, setActiveTab, setResume]);
/** /**
* Trigger fact check and update UI state * Trigger fact check and update UI state
@ -137,9 +122,9 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
return; return;
} }
setProcessing("facts"); setProcessing("facts");
factCheck(resume); console.log('factCheck(resume)');
setTimeout(() => { setActiveTab(2); }, 250); // Switch to resume view on mobile setTimeout(() => { setActiveTab(2); }, 250); // Switch to resume view on mobile
}, [factCheck, setResume, setProcessing, setActiveTab, setFacts]); }, [/*factCheck,*/ setResume, setProcessing, setActiveTab, setFacts]);
useEffect(() => { useEffect(() => {
setEditJobDescription(jobDescription); setEditJobDescription(jobDescription);
@ -192,62 +177,73 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
triggerGeneration(editJobDescription || ""); triggerGeneration(editJobDescription || "");
} }
}; };
const handleJobQuery = (query: string) => {
triggerGeneration(query);
};
const renderJobDescriptionView = () => { const jobDescriptionQuestions = [
const children = []; <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) { const filterJobDescriptionMessages = (messages: MessageList): MessageList => {
children.push( /* The second messages is the RESUME (the LLM response to the JOB-DESCRIPTION) */
<Document key="jobDescription" sx={{ display: "flex", flexGrow: 1 }} title=""> if (messages.length > 1) {
<TextField setResume(messages[1]);
variant="outlined" } else if (resume !== undefined) {
fullWidth setResume(undefined);
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>)
} }
children.push( /* Filter out the RESUME */
<Box key="jobActions" sx={{ display: "flex", justifyContent: "center", flexDirection: "row" }}> const reduced = messages.filter((message, index) => index != 1);
<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>
);
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
}}
/>
}
} }
/** /**
@ -363,7 +359,7 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
const otherRatio = showResume ? (100 - splitRatio / 2) : 100; const otherRatio = showResume ? (100 - splitRatio / 2) : 100;
const children = []; const children = [];
children.push( 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()} {renderJobDescriptionView()}
</Box>); </Box>);
@ -418,7 +414,7 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
} }
return ( 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 }}> <Box sx={{ display: 'flex', flexGrow: 1, flexDirection: 'row', overflow: 'hidden', p: 0 }}>
{children} {children}
</Box> </Box>
@ -428,7 +424,7 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
} }
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, ...sx }}> <Box sx={{ ...sx, display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
{getActiveDesktopContent()} {getActiveDesktopContent()}
</Box> </Box>
); );

View File

@ -233,7 +233,7 @@ const Message = ({ message, submitQuery, isFullWidth, sessionId, setSnack, conne
const formattedContent = message.content.trim(); const formattedContent = message.content.trim();
return ( return (
<ChatBubble className="Message" isFullWidth={isFullWidth} role={message.role} sx={{ 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" }}> <CardContent ref={textFieldRef} sx={{ position: "relative", display: "flex", flexDirection: "column", overflowX: "auto" }}>
<Tooltip title="Copy to clipboard" placement="top" arrow> <Tooltip title="Copy to clipboard" placement="top" arrow>
<IconButton <IconButton

View File

@ -1,7 +1,6 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { SeverityType } from './Snack'; import { SeverityType } from './Snack';
import { ContextStatus } from './ContextStatus';
import { MessageData, MessageMetaProps } from './Message'; import { MessageData, MessageMetaProps } from './Message';
import { DocumentViewer } from './DocumentViewer'; import { DocumentViewer } from './DocumentViewer';
@ -25,320 +24,11 @@ type Resume = {
}; };
const ResumeBuilder = ({ facts, setFacts, resume, setResume, setProcessing, processing, connectionBase, sessionId, setSnack }: ResumeBuilderProps) => { 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 [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) { if (sessionId === undefined) {
return (<></>); 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 ( return (
<Box className="DocBox"> <Box className="DocBox">
<Box className="Conversation" sx={{ p: 0, pt: 1 }}> <Box className="Conversation" sx={{ p: 0, pt: 1 }}>
@ -350,7 +40,7 @@ const ResumeBuilder = ({ facts, setFacts, resume, setResume, setProcessing, proc
overflowY: "auto", overflowY: "auto",
flexDirection: "column", flexDirection: "column",
height: "calc(0vh - 0px)", // Hack to make the height work height: "calc(0vh - 0px)", // Hack to make the height work
}} {...{ factCheck, facts, jobDescription, generateResume, resume, setFacts, setResume, setSnack, setJobDescription, connectionBase, sessionId }} /> }} {...{ setSnack, connectionBase, sessionId }} />
</Box> </Box>
</Box> </Box>
); );

View File

@ -11,6 +11,7 @@ import uuid
import subprocess import subprocess
import re import re
import math import math
import copy
def try_import(module_name, pip_name=None): def try_import(module_name, pip_name=None):
try: try:
@ -52,6 +53,8 @@ from tools import (
tools tools
) )
CONTEXT_VERSION=2
rags = [ rags = [
{ "name": "JPK", "enabled": True, "description": "Expert data about James Ketrenos, including work history, personal hobbies, and projects." }, { "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." }, # { "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() """.strip()
system_generate_resume = f""" 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]. 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: 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]. 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. Ensure the langauge is clear, concise, and aligned with industry standards for professional resumes.
""" """.strip()
system_fact_check = f""" 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]. 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. 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: When answering queries, follow these steps:
1. You must not invent or assume any information not explicitly present in the [WORK HISTORY]. 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]. 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 = [] tool_log = []
command_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 final_result = all_responses[0] if len(all_responses) == 1 else all_responses
yield (final_result, tools_used) yield (final_result, tools_used)
# %% # %%
class WebServer: class WebServer:
def __init__(self, logging, client, model=MODEL_NAME): def __init__(self, logging, client, model=MODEL_NAME):
@ -431,71 +454,6 @@ class WebServer:
return RedirectResponse(url=f"/{context['id']}", status_code=307) return RedirectResponse(url=f"/{context['id']}", status_code=307)
#return JSONResponse({"redirect": f"/{context['id']}"}) #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}") @self.app.put("/api/umap/{context_id}")
async def put_umap(context_id: str, request: Request): async def put_umap(context_id: str, request: Request):
@ -566,20 +524,23 @@ class WebServer:
logging.error(e) logging.error(e)
#return JSONResponse({"error": str(e)}, 500) #return JSONResponse({"error": str(e)}, 500)
@self.app.put("/api/reset/{context_id}") @self.app.put("/api/reset/{context_id}/{type}")
async def put_reset(context_id: str, request: Request): async def put_reset(context_id: str, type: str, request: Request):
if not is_valid_uuid(context_id): if not is_valid_uuid(context_id):
logging.warning(f"Invalid context_id: {context_id}") logging.warning(f"Invalid context_id: {context_id}")
return JSONResponse({"error": "Invalid context_id"}, status_code=400) return JSONResponse({"error": "Invalid context_id"}, status_code=400)
context = self.upsert_context(context_id) 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() data = await request.json()
try: try:
response = {} response = {}
for reset in data["reset"]: for reset in data["reset"]:
match reset: match reset:
case "system-prompt": case "system_prompt":
context["system"] = [{"role": "system", "content": system_message}] context["sessions"][type]["system_prompt"] = system_message
response["system-prompt"] = { "system-prompt": system_message } response["system_prompt"] = { "system_prompt": system_message }
case "rags": case "rags":
context["rags"] = rags.copy() context["rags"] = rags.copy()
response["rags"] = context["rags"] response["rags"] = context["rags"]
@ -587,23 +548,23 @@ class WebServer:
context["tools"] = default_tools(tools) context["tools"] = default_tools(tools)
response["tools"] = context["tools"] response["tools"] = context["tools"]
case "history": case "history":
context["llm_history"] = [] context["sessions"][type]["llm_history"] = []
context["user_history"] = [] context["sessions"][type]["user_history"] = []
context["sessions"][type]["context_tokens"] = round(len(str(context["system"])) * 3 / 4) # Estimate context usage
response["history"] = [] response["history"] = []
context["context_tokens"] = round(len(str(context["system"])) * 3 / 4) # Estimate context usage response["context_used"] = context["sessions"][type]["context_tokens"]
response["context_used"] = context["context_tokens"] case "message_history_length":
case "message-history-length":
context["message_history_length"] = DEFAULT_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: if not response:
return JSONResponse({ "error": "Usage: { reset: rags|tools|history|system-prompt}"}) return JSONResponse({ "error": "Usage: { reset: rags|tools|history|system_prompt}"})
else: else:
self.save_context(context_id) self.save_context(context_id)
return JSONResponse(response) return JSONResponse(response)
except: 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}") @self.app.put("/api/tunables/{context_id}")
async def put_tunables(context_id: str, request: Request): async def put_tunables(context_id: str, request: Request):
@ -614,20 +575,20 @@ class WebServer:
data = await request.json() data = await request.json()
for k in data.keys(): for k in data.keys():
match k: match k:
case "system-prompt": case "system_prompt":
system_prompt = data[k].strip() system_prompt = data[k].strip()
if not system_prompt: if not system_prompt:
return JSONResponse({ "status": "error", "message": "System prompt can not be empty." }) return JSONResponse({ "status": "error", "message": "System prompt can not be empty." })
context["system"] = [{"role": "system", "content": system_prompt}] context["system"] = [{"role": "system", "content": system_prompt}]
self.save_context(context_id) self.save_context(context_id)
return JSONResponse({ "system-prompt": system_prompt }) return JSONResponse({ "system_prompt": system_prompt })
case "message-history-length": case "message_history_length":
value = max(0, int(data[k])) value = max(0, int(data[k]))
context["message_history_length"] = value context["message_history_length"] = value
self.save_context(context_id) self.save_context(context_id)
return JSONResponse({ "message-history-length": value }) return JSONResponse({ "message_history_length": value })
case _: 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}") @self.app.get("/api/tunables/{context_id}")
async def get_tunables(context_id: str): async def get_tunables(context_id: str):
@ -636,33 +597,29 @@ class WebServer:
return JSONResponse({"error": "Invalid context_id"}, status_code=400) return JSONResponse({"error": "Invalid context_id"}, status_code=400)
context = self.upsert_context(context_id) context = self.upsert_context(context_id)
return JSONResponse({ return JSONResponse({
"system-prompt": context["system"][0]["content"], "system_prompt": context["system"][0]["content"],
"message-history-length": context["message_history_length"] "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}") @self.app.get("/api/system-info/{context_id}")
async def get_system_info(context_id: str): async def get_system_info(context_id: str):
return JSONResponse(system_info(self.model)) return JSONResponse(system_info(self.model))
@self.app.post("/api/chat/{context_id}") @self.app.post("/api/chat/{context_id}/{type}")
async def chat_endpoint(context_id: str, request: Request): async def chat_endpoint(context_id: str, type: str, request: Request):
if not is_valid_uuid(context_id): if not is_valid_uuid(context_id):
logging.warning(f"Invalid context_id: {context_id}") logging.warning(f"Invalid context_id: {context_id}")
return JSONResponse({"error": "Invalid context_id"}, status_code=400) return JSONResponse({"error": "Invalid context_id"}, status_code=400)
context = self.upsert_context(context_id) 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() data = await request.json()
# Create a custom generator that ensures flushing # Create a custom generator that ensures flushing
async def flush_generator(): 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 # Convert to JSON and add newline
yield json.dumps(message) + "\n" yield json.dumps(message) + "\n"
# Save the history as its generated # Save the history as its generated
@ -681,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") @self.app.post("/api/context")
async def create_context(): async def create_context():
context = self.create_context() context = self.create_context()
self.logging.info(f"Generated new session as {context['id']}") self.logging.info(f"Generated new session as {context['id']}")
return JSONResponse(context) return JSONResponse(context)
@self.app.get("/api/history/{context_id}") @self.app.get("/api/history/{context_id}/{type}")
async def get_history(context_id: str): async def get_history(context_id: str, type: str):
context = self.upsert_context(context_id) 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}") @self.app.get("/api/tools/{context_id}")
async def get_tools(context_id: str): async def get_tools(context_id: str):
@ -770,7 +671,7 @@ class WebServer:
tool["enabled"] = enabled tool["enabled"] = enabled
self.save_context(context_id) self.save_context(context_id)
return JSONResponse(context["tools"]) 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: except:
return JSONResponse({ "status": "error" }), 405 return JSONResponse({ "status": "error" }), 405
@ -794,17 +695,19 @@ class WebServer:
tool["enabled"] = enabled tool["enabled"] = enabled
self.save_context(context_id) self.save_context(context_id)
return JSONResponse(context["rags"]) 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: except:
return JSONResponse({ "status": "error" }), 405 return JSONResponse({ "status": "error" }), 405
@self.app.get("/api/context-status/{context_id}") @self.app.get("/api/context-status/{context_id}/{type}")
async def get_context_status(context_id): async def get_context_status(context_id, type: str):
if not is_valid_uuid(context_id): if not is_valid_uuid(context_id):
logging.warning(f"Invalid context_id: {context_id}") logging.warning(f"Invalid context_id: {context_id}")
return JSONResponse({"error": "Invalid context_id"}, status_code=400) return JSONResponse({"error": "Invalid context_id"}, status_code=400)
context = self.upsert_context(context_id) 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") @self.app.get("/api/health")
async def health_check(): async def health_check():
@ -839,15 +742,80 @@ class WebServer:
# Create the full file path # Create the full file path
file_path = os.path.join(defines.session_dir, session_id) 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 # Serialize the data to JSON and write to file
with open(file_path, "w") as f: with open(file_path, "w") as f:
json.dump(context, f) json.dump(context, f)
return session_id 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): def load_context(self, session_id):
""" """
Load a serialized Python dictionary from a file in the sessions directory. Load a serialized Python dictionary from a file in the sessions directory.
@ -868,22 +836,42 @@ class WebServer:
with open(file_path, "r") as f: with open(file_path, "r") as f:
self.contexts[session_id] = json.load(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): def create_context(self, context_id = None):
if not context_id: if not context_id:
context_id = str(uuid.uuid4()) context_id = str(uuid.uuid4())
system_context = [{"role": "system", "content": system_message}];
context = { context = {
"id": context_id, "id": context_id,
"system": system_context, "version": CONTEXT_VERSION,
"system_generate_resume": system_generate_resume, "sessions": {
"llm_history": [], "chat": {
"user_history": [], "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), "tools": default_tools(tools),
"resume_history": [],
"rags": rags.copy(), "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 "message_history_length": 5 # Number of messages to supply in context
} }
logging.info(f"{context_id} created and added to sessions.") logging.info(f"{context_id} created and added to sessions.")
@ -903,7 +891,7 @@ class WebServer:
logging.info(f"Context {context_id} not found. Creating new context.") logging.info(f"Context {context_id} not found. Creating new context.")
return self.load_context(context_id) return self.load_context(context_id)
async def chat(self, context, content): async def chat(self, context, type, content):
if not self.file_watcher: if not self.file_watcher:
return return
@ -917,62 +905,173 @@ class WebServer:
return return
self.processing = True 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"])
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()
}
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 try:
llm_history.append({"role": "user", "content": preamble + content}) llm_history = context["sessions"][type]["llm_history"]
user_history.append({"role": "user", "content": content}) 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,
}
if context["message_history_length"]: # Default to not using tools
messages = context["system"] + llm_history[-context["message_history_length"]:] enable_tools = False
else:
messages = context["system"] + llm_history # 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
try:
# Estimate token length of new messages # Estimate token length of new messages
ctx_size = self.get_optimal_ctx_size(context["context_tokens"], messages=llm_history[-1]["content"]) ctx_size = self.get_optimal_ctx_size(context["sessions"][type]["context_tokens"], messages=llm_history[-1]["content"])
yield {"status": "processing", "message": "Processing request...", "num_ctx": ctx_size}
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 # 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_count"] += response["eval_count"]
metadata["eval_duration"] += response["eval_duration"] metadata["eval_duration"] += response["eval_duration"]
metadata["prompt_eval_count"] += response["prompt_eval_count"] metadata["prompt_eval_count"] += response["prompt_eval_count"]
metadata["prompt_eval_duration"] += response["prompt_eval_duration"] 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 = [] tools_used = []
@ -1015,7 +1114,7 @@ class WebServer:
metadata["tools"] = tools_used metadata["tools"] = tools_used
# Estimate token length of new messages # 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 } yield {"status": "processing", "message": "Generating final response...", "num_ctx": ctx_size }
# Decrease creativity when processing tool call requests # 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 }) response = self.client.chat(model=self.model, messages=messages, stream=False, options={ "num_ctx": ctx_size }) #, "temperature": 0.5 })
@ -1023,7 +1122,7 @@ class WebServer:
metadata["eval_duration"] += response["eval_duration"] metadata["eval_duration"] += response["eval_duration"]
metadata["prompt_eval_count"] += response["prompt_eval_count"] metadata["prompt_eval_count"] += response["prompt_eval_count"]
metadata["prompt_eval_duration"] += response["prompt_eval_duration"] 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"] reply = response["message"]["content"]
final_message = {"role": "assistant", "content": reply } final_message = {"role": "assistant", "content": reply }
@ -1045,145 +1144,6 @@ class WebServer:
finally: finally:
self.processing = False 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): def run(self, host="0.0.0.0", port=WEB_PORT, **kwargs):
try: try:
if self.ssl_enabled: if self.ssl_enabled: