Compare commits

...

2 Commits

Author SHA1 Message Date
11c1c2b9b4 No more observer resize errors 2025-05-07 15:18:38 -07:00
6db749d21c No more observer resize errors 2025-05-07 14:39:40 -07:00
12 changed files with 531 additions and 448 deletions

View File

@ -25,7 +25,7 @@ import { VectorVisualizer } from './VectorVisualizer';
import { Controls } from './Controls'; import { Controls } from './Controls';
import { Conversation, ConversationHandle } from './Conversation'; import { Conversation, ConversationHandle } from './Conversation';
import { ChatQuery, QueryOptions } from './ChatQuery'; import { ChatQuery, QueryOptions } from './ChatQuery';
import { Scrollable } from './AutoScroll'; import { Scrollable } from './Scrollable';
import { BackstoryTab } from './BackstoryTab'; import { BackstoryTab } from './BackstoryTab';
import './App.css'; import './App.css';
@ -177,26 +177,23 @@ const App = () => {
iconPosition: "start" iconPosition: "start"
}, },
children: ( children: (
<Scrollable <Conversation
sx={{ sx={{
maxWidth: "1024px", maxWidth: "1024px",
height: "calc(100vh - 72px)", height: "calc(100vh - 72px)",
}} }}
> ref={chatRef}
<Conversation {...{
ref={chatRef} type: "chat",
{...{ prompt: "What would you like to know about James?",
type: "chat", resetLabel: "chat",
prompt: "What would you like to know about James?", sessionId,
resetLabel: "chat", connectionBase,
sessionId, setSnack,
connectionBase, preamble: backstoryPreamble,
setSnack, defaultPrompts: backstoryQuestions
preamble: backstoryPreamble, }}
defaultPrompts: backstoryQuestions />
}}
/>
</Scrollable>
) )
}, { }, {
label: "Resume Builder", label: "Resume Builder",
@ -323,7 +320,7 @@ const App = () => {
useEffect(() => { useEffect(() => {
const url = new URL(window.location.href); const url = new URL(window.location.href);
const pathParts = url.pathname.split('/').filter(Boolean); // [path, sessionId] const pathParts = url.pathname.split('/').filter(Boolean); // [path, sessionId]
console.log(tabs);
if (pathParts.length < 1) { if (pathParts.length < 1) {
console.log("No session id or path -- creating new session"); console.log("No session id or path -- creating new session");
fetchSession(); fetchSession();

View File

@ -1,156 +0,0 @@
import { useEffect, useRef, RefObject } from 'react';
import Box from '@mui/material/Box';
import { SxProps, Theme } from '@mui/material';
/**
* Hook that automatically scrolls a container to the bottom when content changes
* or when the container is resized, but only if the user is already near the bottom.
*
* @param threshold - Distance from bottom (px) to consider "near bottom" (default: 100)
* @param smooth - Whether to use smooth scrolling (default: true)
* @returns Ref to attach to the scrollable container
*/
const useAutoScrollToBottom = (
threshold: number = 0.33, // Percentage of viewport to trigger threshold
smooth: boolean = true
): RefObject<HTMLDivElement> => {
const containerRef = useRef<HTMLDivElement>(null);
const lastScrollTop = useRef<number>(0);
const scrollTimeout = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) {
// console.log("No ref");
return;
}
const lastScrollHeight = container.scrollHeight;
var isUserScrollingUp = false;
// Function to check if we should scroll to bottom
const checkAndScrollToBottom = (priorScrollHeight?: number | undefined): void => {
if (!container) return;
const scrollHeight = (priorScrollHeight !== undefined) ? priorScrollHeight : container.scrollHeight;
// Only auto-scroll if the user is near the bottom and not actively scrolling up
const isNearBottom: boolean =
scrollHeight - container.scrollTop - container.clientHeight <= container.clientHeight * threshold;
if (isNearBottom && !isUserScrollingUp) {
// console.log('Scrolling', {
// isNearBottom,
// isUserScrollingUp,
// scrollHeightToUser: scrollHeight,
// scrollHeight: container.scrollHeight,
// scrollTop: container.scrollTop,
// clientHeight: container.clientHeight,
// threshold,
// delta: container.scrollHeight - container.scrollTop - container.clientHeight
// });
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto'
});
} else {
// console.log('Not scrolling', {
// isNearBottom,
// isUserScrollingUp,
// scrollHeight: container.scrollHeight,
// scrollTop: container.scrollTop,
// clientHeight: container.clientHeight,
// threshold,
// delta: container.scrollHeight - container.scrollTop - container.clientHeight
// });
}
};
// Set up ResizeObserver to detect content size changes
const resizeObserver = new ResizeObserver(() => {
const container = containerRef.current;
if (!container) {
return;
}
checkAndScrollToBottom(lastScrollHeight);
});
// Observe the container and its children
resizeObserver.observe(container);
Array.from(container.children).forEach((child) => {
resizeObserver.observe(child);
});
// Track user scrolling behavior
const handleScroll = (): void => {
if (!container) {
// console.log("No ref in handleScroll");
return;
}
// Clear existing timeout
if (scrollTimeout.current) {
clearTimeout(scrollTimeout.current);
}
// Determine scroll direction
const currentScrollTop = container.scrollTop;
isUserScrollingUp = currentScrollTop < lastScrollTop.current;
lastScrollTop.current = currentScrollTop;
// Reset the scrolling flag after user stops scrolling
scrollTimeout.current = setTimeout(() => {
//setIsUserScrollingUp(false);
}, 500);
};
// Add scroll event listener
container.addEventListener('scroll', handleScroll);
// Run initial check
checkAndScrollToBottom();
// Cleanup
return () => {
if (resizeObserver) {
resizeObserver.disconnect();
}
if (container) {
container.removeEventListener('scroll', handleScroll);
}
if (scrollTimeout.current) {
clearTimeout(scrollTimeout.current);
}
};
}, [smooth, threshold]); // Re-run when dependencies change or scrolling state changes
return containerRef as RefObject<HTMLDivElement>;
};
interface ScrollableProps {
children?: React.ReactNode;
sx?: SxProps<Theme>,
autoscroll?: boolean,
}
const Scrollable = (props: ScrollableProps) => {
const { sx, children, autoscroll } = props;
const scrollRef = useAutoScrollToBottom();
return <Box
sx={{
display: "flex",
margin: "0 auto",
flexGrow: 1,
overflow: "auto",
backgroundColor: "#F5F5F5",
...sx
}}
ref={(autoscroll !== undefined && autoscroll !== false) ? scrollRef : undefined}
>{children}</Box>;
};
export {
useAutoScrollToBottom,
Scrollable,
};

View File

@ -0,0 +1,126 @@
import React, { useRef, useEffect, ChangeEvent, KeyboardEvent } from 'react';
import { useTheme } from '@mui/material/styles';
import './BackstoryTextField.css';
interface BackstoryTextFieldProps {
value: string;
disabled?: boolean;
multiline?: boolean;
placeholder?: string;
onChange?: (e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => void;
onKeyDown?: (e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) => void;
}
const BackstoryTextField: React.FC<BackstoryTextFieldProps> = ({
value,
disabled = false,
multiline = false,
placeholder,
onChange,
onKeyDown,
}) => {
const theme = useTheme();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const shadowRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (multiline && textareaRef.current && shadowRef.current) {
const shadow = shadowRef.current;
const textarea = textareaRef.current;
shadow.value = value || placeholder || '';
window.requestAnimationFrame(() => {
const computed = getComputedStyle(textarea);
const paddingTop = parseFloat(computed.paddingTop || '0');
const paddingBottom = parseFloat(computed.paddingBottom || '0');
const totalPadding = paddingTop + paddingBottom;
const newHeight = shadow.scrollHeight + totalPadding;
textarea.style.height = `${newHeight}px`;
});
}
}, [value, multiline, textareaRef, shadowRef, placeholder]);
const handleChange = (e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
if (onChange) {
onChange(e);
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) => {
if (onKeyDown) {
onKeyDown(e);
}
};
const sharedStyle = {
width: '100%',
padding: '16.5px 14px',
resize: 'none' as const,
overflow: 'hidden' as const,
boxSizing: 'border-box' as const,
minHeight: 'calc(1.5rem + 28px)', // lineHeight + padding-top + padding-bottom
lineHeight: '1.5',
borderRadius: '4px',
fontSize: '16px',
backgroundColor: 'rgba(0, 0, 0, 0)',
fontFamily: theme.typography.fontFamily,
};
if (!multiline) {
return (
<input
className="BackstoryTextField"
type="text"
value={value}
disabled={disabled}
placeholder={placeholder}
onChange={handleChange}
onKeyDown={handleKeyDown}
style={sharedStyle}
/>
);
}
return (
<>
<textarea
className="BackstoryTextField"
ref={textareaRef}
value={value}
disabled={disabled}
placeholder={placeholder}
onChange={handleChange}
onKeyDown={handleKeyDown}
style={{
...sharedStyle,
height: 'auto',
}}
/>
<textarea
className="BackgroundTextField"
ref={shadowRef}
aria-hidden="true"
value={placeholder || ""}
style={{
...sharedStyle,
position: 'relative',
top: '-9999px',
left: '-9999px',
visibility: 'hidden',
padding: '0px',
margin: '0px',
overflow: 'auto',
height: '0px',
minHeight: '0px',
}}
readOnly
tabIndex={-1}
/>
</>
);
};
export { BackstoryTextField };

View File

@ -10,4 +10,5 @@
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
overflow-y: auto; overflow-y: auto;
height: calc(100vh - 72px);
} }

View File

@ -1,5 +1,4 @@
import React, { useState, useImperativeHandle, forwardRef, useEffect, useRef, useCallback } from 'react'; import React, { useState, useImperativeHandle, forwardRef, useEffect, useRef, useCallback } from 'react';
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';
@ -7,14 +6,16 @@ import Box from '@mui/material/Box';
import SendIcon from '@mui/icons-material/Send'; import SendIcon from '@mui/icons-material/Send';
import { SxProps, Theme } from '@mui/material'; import { SxProps, Theme } from '@mui/material';
import PropagateLoader from "react-spinners/PropagateLoader"; import PropagateLoader from "react-spinners/PropagateLoader";
import { useTheme } from '@mui/material/styles';
import { Message, MessageList, MessageData } from './Message'; import { Message, MessageList, MessageData } from './Message';
import { SetSnackType } from './Snack'; import { SetSnackType } from './Snack';
import { ContextStatus } from './ContextStatus'; import { ContextStatus } from './ContextStatus';
import { useAutoScrollToBottom } from './AutoScroll'; import { Scrollable } from './Scrollable';
import { DeleteConfirmation } from './DeleteConfirmation'; import { DeleteConfirmation } from './DeleteConfirmation';
import { QueryOptions } from './ChatQuery'; import { QueryOptions } from './ChatQuery';
import './Conversation.css'; import './Conversation.css';
import { BackstoryTextField } from './BackstoryTextField';
const loadingMessage: MessageData = { "role": "status", "content": "Establishing connection with server..." }; const loadingMessage: MessageData = { "role": "status", "content": "Establishing connection with server..." };
@ -93,6 +94,7 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
const [conversation, setConversation] = useState<MessageList>([]); const [conversation, setConversation] = useState<MessageList>([]);
const [filteredConversation, setFilteredConversation] = useState<MessageList>([]); const [filteredConversation, setFilteredConversation] = useState<MessageList>([]);
const [processingMessage, setProcessingMessage] = useState<MessageData | undefined>(undefined); const [processingMessage, setProcessingMessage] = useState<MessageData | undefined>(undefined);
const [streamingMessage, setStreamingMessage] = useState<MessageData | undefined>(undefined);
const timerRef = useRef<any>(null); const timerRef = useRef<any>(null);
const [lastEvalTPS, setLastEvalTPS] = useState<number>(35); const [lastEvalTPS, setLastEvalTPS] = useState<number>(35);
const [lastPromptTPS, setLastPromptTPS] = useState<number>(430); const [lastPromptTPS, setLastPromptTPS] = useState<number>(430);
@ -100,7 +102,8 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
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 conversationRef = useRef<MessageList>([]); const conversationRef = useRef<MessageList>([]);
const scrollRef = useAutoScrollToBottom(); const viewableElementRef = useRef<HTMLDivElement>(null);
const theme = useTheme();
// Keep the ref updated whenever items changes // Keep the ref updated whenever items changes
useEffect(() => { useEffect(() => {
@ -214,6 +217,7 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
setNoInteractions(false); setNoInteractions(false);
} }
setProcessingMessage(undefined); setProcessingMessage(undefined);
setStreamingMessage(undefined);
updateContextStatus(); updateContextStatus();
} catch (error) { } catch (error) {
console.error('Error generating session ID:', error); console.error('Error generating session ID:', error);
@ -402,6 +406,7 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
if (onResponse) { if (onResponse) {
update = onResponse(update); update = onResponse(update);
} }
setStreamingMessage(undefined);
setProcessingMessage(undefined); setProcessingMessage(undefined);
const backstoryMessage: BackstoryMessage = update; const backstoryMessage: BackstoryMessage = update;
setConversation([ setConversation([
@ -425,10 +430,13 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
break; break;
case 'error': case 'error':
// Show error // Show error
setProcessingMessage({ role: 'error', content: update.response }); setConversation([
setTimeout(() => { ...conversationRef.current, {
setProcessingMessage(undefined); ...update,
}, 5000); role: 'error',
origin: type,
content: update.response || "",
}] as MessageList);
// Add a small delay to ensure React has time to update the UI // Add a small delay to ensure React has time to update the UI
await new Promise(resolve => setTimeout(resolve, 0)); await new Promise(resolve => setTimeout(resolve, 0));
@ -436,7 +444,11 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
default: default:
// Force an immediate state update based on the message type // Force an immediate state update based on the message type
// Update processing message with immediate re-render // Update processing message with immediate re-render
setProcessingMessage({ role: update.status, content: update.response }); if (update.status === "streaming") {
setStreamingMessage({ role: update.status, content: update.response });
} else {
setProcessingMessage({ role: update.status, content: update.response });
}
// Add a small delay to ensure React has time to update the UI // Add a small delay to ensure React has time to update the UI
await new Promise(resolve => setTimeout(resolve, 0)); await new Promise(resolve => setTimeout(resolve, 0));
break; break;
@ -494,13 +506,17 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
}; };
return ( return (
<Box className={className || "Conversation"} <Scrollable
ref={scrollRef} className={className || "Conversation"}
autoscroll
textFieldRef={viewableElementRef}
fallbackThreshold={0.5}
sx={{ sx={{
p: 1, p: 1,
mt: 0, mt: 0,
...sx ...sx
}}> }}
>
{ {
filteredConversation.map((message, index) => filteredConversation.map((message, index) =>
<Message key={index} {...{ sendQuery, message, connectionBase, sessionId, setSnack }} /> <Message key={index} {...{ sendQuery, message, connectionBase, sessionId, setSnack }} />
@ -510,6 +526,10 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
processingMessage !== undefined && processingMessage !== undefined &&
<Message {...{ sendQuery, connectionBase, sessionId, setSnack, message: processingMessage }} /> <Message {...{ sendQuery, connectionBase, sessionId, setSnack, message: processingMessage }} />
} }
{
streamingMessage !== undefined &&
<Message {...{ sendQuery, connectionBase, sessionId, setSnack, message: streamingMessage }} />
}
<Box sx={{ <Box sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@ -535,18 +555,17 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
</Box> </Box>
<Box className="Query" sx={{ display: "flex", flexDirection: "column", p: 1 }}> <Box className="Query" sx={{ display: "flex", flexDirection: "column", p: 1 }}>
{prompt && {prompt &&
<TextField <div
variant="outlined" ref={viewableElementRef}>
disabled={processing} <BackstoryTextField
fullWidth={true} disabled={processing}
multiline={multiline ? true : false} multiline={multiline ? true : false}
type="text" value={query}
value={query} onChange={(e: any) => setQuery(e.target.value)}
onChange={(e) => setQuery(e.target.value)} onKeyDown={handleKeyPress}
onKeyDown={handleKeyPress} placeholder={prompt}
placeholder={prompt} />
id="QueryInput" </div>
/>
} }
<Box key="jobActions" sx={{ display: "flex", justifyContent: "center", flexDirection: "row" }}> <Box key="jobActions" sx={{ display: "flex", justifyContent: "center", flexDirection: "row" }}>
@ -585,7 +604,7 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>(({
} }
</Box> </Box>
<Box sx={{ display: "flex", flexGrow: 1 }}></Box> <Box sx={{ display: "flex", flexGrow: 1 }}></Box>
</Box> </Scrollable>
); );
}); });

View File

@ -2,20 +2,9 @@ import React, { useState, useCallback, useRef } from 'react';
import { import {
Tabs, Tabs,
Tab, Tab,
Paper,
IconButton,
Box, Box,
useMediaQuery,
Divider,
Slider,
Stack,
} from '@mui/material'; } from '@mui/material';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import {
ChevronLeft,
ChevronRight,
SwapHoriz,
} from '@mui/icons-material';
import { SxProps, Theme } from '@mui/material'; import { SxProps, Theme } from '@mui/material';
import { ChatQuery } from './ChatQuery'; import { ChatQuery } from './ChatQuery';
@ -30,7 +19,6 @@ interface ResumeBuilderProps {
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
}; };
/** /**
* ResumeBuilder component * ResumeBuilder component
* *
@ -49,13 +37,11 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
const [hasResume, setHasResume] = useState<boolean>(false); const [hasResume, setHasResume] = useState<boolean>(false);
const [hasFacts, setHasFacts] = useState<boolean>(false); const [hasFacts, setHasFacts] = useState<boolean>(false);
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const jobConversationRef = useRef<any>(null); const jobConversationRef = useRef<any>(null);
const resumeConversationRef = useRef<any>(null); const resumeConversationRef = useRef<any>(null);
const factsConversationRef = useRef<any>(null); const factsConversationRef = useRef<any>(null);
const [activeTab, setActiveTab] = useState<number>(0); const [activeTab, setActiveTab] = useState<number>(0);
const [splitRatio, setSplitRatio] = useState<number>(100);
/** /**
* Handle tab change for mobile view * Handle tab change for mobile view
@ -64,20 +50,6 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
setActiveTab(newValue); setActiveTab(newValue);
}; };
/**
* Adjust split ratio for desktop view
*/
const handleSliderChange = (_event: Event, newValue: number | number[]): void => {
setSplitRatio(newValue as number);
};
/**
* Reset split ratio to default
*/
const resetSplit = (): void => {
setSplitRatio(50);
};
const handleJobQuery = (query: string) => { const handleJobQuery = (query: string) => {
console.log(`handleJobQuery: ${query} -- `, jobConversationRef.current ? ' sending' : 'no handler'); console.log(`handleJobQuery: ${query} -- `, jobConversationRef.current ? ' sending' : 'no handler');
jobConversationRef.current?.submitQuery(query); jobConversationRef.current?.submitQuery(query);
@ -250,10 +222,10 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
setHasFacts(false); setHasFacts(false);
}, [setHasFacts]); }, [setHasFacts]);
const renderJobDescriptionView = useCallback((small: boolean) => { const renderJobDescriptionView = useCallback((sx: SxProps) => {
console.log('renderJobDescriptionView'); console.log('renderJobDescriptionView');
const jobDescriptionQuestions = [ const jobDescriptionQuestions = [
<Box sx={{ display: "flex", flexDirection: small ? "column" : "row" }}> <Box sx={{ display: "flex", flexDirection: "column" }}>
<ChatQuery prompt="What are the key skills necessary for this position?" tunables={{ enable_tools: false }} submitQuery={handleJobQuery} /> <ChatQuery prompt="What are the key skills necessary for this position?" tunables={{ enable_tools: false }} submitQuery={handleJobQuery} />
<ChatQuery prompt="How much should this position pay (accounting for inflation)?" tunables={{ enable_tools: false }} submitQuery={handleJobQuery} /> <ChatQuery prompt="How much should this position pay (accounting for inflation)?" tunables={{ enable_tools: false }} submitQuery={handleJobQuery} />
</Box>, </Box>,
@ -274,6 +246,7 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
sessionId, sessionId,
connectionBase, connectionBase,
setSnack, setSnack,
sx,
}} }}
/> />
@ -292,6 +265,7 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
sessionId, sessionId,
connectionBase, connectionBase,
setSnack, setSnack,
sx,
}} }}
/> />
} }
@ -300,9 +274,9 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
/** /**
* Renders the resume view with loading indicator * Renders the resume view with loading indicator
*/ */
const renderResumeView = useCallback((small: boolean) => { const renderResumeView = useCallback((sx: SxProps) => {
const resumeQuestions = [ const resumeQuestions = [
<Box sx={{ display: "flex", flexDirection: small ? "column" : "row" }}> <Box sx={{ display: "flex", flexDirection: "column" }}>
<ChatQuery prompt="Is this resume a good fit for the provided job description?" tunables={{ enable_tools: false }} submitQuery={handleResumeQuery} /> <ChatQuery prompt="Is this resume a good fit for the provided job description?" tunables={{ enable_tools: false }} submitQuery={handleResumeQuery} />
<ChatQuery prompt="Provide a more concise resume." tunables={{ enable_tools: false }} submitQuery={handleResumeQuery} /> <ChatQuery prompt="Provide a more concise resume." tunables={{ enable_tools: false }} submitQuery={handleResumeQuery} />
</Box>, </Box>,
@ -322,6 +296,7 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
sessionId, sessionId,
connectionBase, connectionBase,
setSnack, setSnack,
sx,
}} }}
/> />
} else { } else {
@ -339,6 +314,7 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
connectionBase, connectionBase,
setSnack, setSnack,
defaultPrompts: resumeQuestions, defaultPrompts: resumeQuestions,
sx,
}} }}
/> />
} }
@ -347,9 +323,9 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
/** /**
* Renders the fact check view * Renders the fact check view
*/ */
const renderFactCheckView = useCallback((small: boolean) => { const renderFactCheckView = useCallback((sx: SxProps) => {
const factsQuestions = [ const factsQuestions = [
<Box sx={{ display: "flex", flexDirection: small ? "column" : "row" }}> <Box sx={{ display: "flex", flexDirection: "column" }}>
<ChatQuery prompt="Rewrite the resume to address any discrepancies." tunables={{ enable_tools: false }} submitQuery={handleFactsQuery} /> <ChatQuery prompt="Rewrite the resume to address any discrepancies." tunables={{ enable_tools: false }} submitQuery={handleFactsQuery} />
</Box>, </Box>,
]; ];
@ -368,169 +344,47 @@ const ResumeBuilder: React.FC<ResumeBuilderProps> = ({
sessionId, sessionId,
connectionBase, connectionBase,
setSnack, setSnack,
sx,
}} }}
/> />
}, [connectionBase, sessionId, setSnack, factsResponse, filterFactsMessages, resetFacts, hasResume, hasFacts]); }, [connectionBase, sessionId, setSnack, factsResponse, filterFactsMessages, resetFacts, hasResume, hasFacts]);
/** return (
* Gets the appropriate content based on active state for Desktop <Box sx={{
*/ p: 0,
const getActiveDesktopContent = useCallback(() => { m: 0,
const hasSlider = hasResume || hasFacts; display: "flex",
const ratio = 75 + 25 * splitRatio / 100; flexGrow: 1,
const otherRatio = hasResume ? ratio / (hasFacts ? 3 : 2) : 100; margin: "0 auto",
const resumeRatio = 100 - otherRatio * (hasFacts ? 2 : 1); overflow: "hidden",
const children = []; backgroundColor: "#F5F5F5",
children.push( flexDirection: "column",
<Box key="JobDescription" className="ChatBox" sx={{ maxWidth: "1024px",
display: 'flex', }}
flexDirection: 'column', >
minWidth: `${otherRatio}%`, {/* Tabs */}
width: `${otherRatio}%`, <Tabs
maxWidth: `${otherRatio}%`, value={activeTab}
p: 0, onChange={handleTabChange}
flexGrow: 1, variant="fullWidth"
}}> sx={{ bgcolor: 'background.paper' }}
{renderJobDescriptionView(false)} >
</Box>); <Tab value={0} label="Job Description" />
{hasResume && <Tab value={1} label="Resume" />}
{hasFacts && <Tab value={2} label="Fact Check" />}
</Tabs>
/* Resume panel - conditionally rendered if resume defined, or processing is in progress */ {/* Document display area */}
if (hasResume) {
children.push(
<Box key="ResumeView"
className="ChatBox"
sx={{
display: 'flex',
flexDirection: 'column',
minWidth: `${resumeRatio}%`,
width: `${resumeRatio}%`,
maxWidth: `${resumeRatio}%`,
p: 0,
flexGrow: 1
}}>
<Divider orientation="vertical" flexItem />
{renderResumeView(false)}
</Box>
);
}
/* Fact Check panel - conditionally rendered if facts defined, or processing is in progress */
if (hasFacts) {
children.push(
<Box key="FactCheckView"
className="ChatBox"
sx={{
display: 'flex',
flexDirection: 'column',
minWidth: `${otherRatio}%`,
width: `${otherRatio}%`,
maxWidth: `${otherRatio}%`,
p: 0,
flexGrow: 1,
}}>
<Divider orientation="vertical" flexItem />
{renderFactCheckView(false)}
</Box>
);
}
/* Split control panel - conditionally rendered if either facts or resume is set */
let slider = <Box key="slider"></Box>;
if (hasSlider) {
slider = (
<Paper key="slider" sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ width: '60%' }}>
<IconButton onClick={() => setSplitRatio(s => Math.max(0, s - 10))}>
<ChevronLeft />
</IconButton>
<Slider
value={splitRatio}
onChange={handleSliderChange}
aria-label="Split ratio"
min={0}
max={100}
/>
<IconButton onClick={() => setSplitRatio(s => Math.min(100, s + 10))}>
<ChevronRight />
</IconButton>
<IconButton onClick={resetSplit}>
<SwapHoriz />
</IconButton>
</Stack>
</Paper>
);
}
return (
<Box sx={{ <Box sx={{
p: 0, display: 'flex', flexDirection: 'column', flexGrow: 1, p: 0, width: "100%", ...sx,
m: 0, overflow: "hidden"
display: "flex",
flexGrow: 1,
flexDirection: "column",
}}> }}>
<Box sx={{ <Box sx={{ display: activeTab === 0 ? "flex" : "none" }}>{renderJobDescriptionView({ height: "calc(100vh - 72px - 48px)" })}</Box>
display: 'flex', <Box sx={{ display: activeTab === 1 ? "flex" : "none" }}>{renderResumeView({ height: "calc(100vh - 72px - 48px)" })}</Box>
flexGrow: 1, <Box sx={{ display: activeTab === 2 ? "flex" : "none" }}>{renderFactCheckView({ height: "calc(100vh - 72px - 48px)" })}</Box>
flexDirection: 'row',
overflow: 'hidden',
p: 0,
m: 0,
margin: "0 auto",
maxWidth: hasSlider ? "100%" : "1024px",
width: hasSlider ? "100%" : "1024px",
height: `calc(100vh - ${hasSlider ? 144 : 72}px)`,
backgroundColor: "#F5F5F5",
}}
>
{children}
</Box>
{slider}
</Box> </Box>
) </Box>
}, [renderFactCheckView, renderJobDescriptionView, renderResumeView, splitRatio, hasFacts, hasResume]); );
// Render mobile view
if (isMobile) {
return (
<Box sx={{
p: 0,
m: 0,
display: "flex",
flexGrow: 1,
margin: "0 auto",
overflow: "hidden",
height: "calc(100vh - 72px)",
backgroundColor: "#F5F5F5",
flexDirection: "column"
}}
>
{/* Tabs */}
<Tabs
value={activeTab}
onChange={handleTabChange}
variant="fullWidth"
sx={{ bgcolor: 'background.paper' }}
>
<Tab value={0} label="Job Description" />
{hasResume && <Tab value={1} label="Resume" />}
{hasFacts && <Tab value={2} label="Fact Check" />}
</Tabs>
{/* Document display area */}
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, p: 0, width: "100%", ...sx }}>
<Box sx={{ display: activeTab === 0 ? "flex" : "none" }}>{renderJobDescriptionView(true)}</Box>
<Box sx={{ display: activeTab === 1 ? "flex" : "none" }}>{renderResumeView(true)}</Box>
<Box sx={{ display: activeTab === 2 ? "flex" : "none" }}>{renderFactCheckView(true)}</Box>
</Box>
</Box>
);
}
return getActiveDesktopContent();
}; };
export type { export type {

View File

@ -0,0 +1,40 @@
import Box from '@mui/material/Box';
import { SxProps, Theme } from '@mui/material';
import { RefObject, useRef } from 'react';
import { useAutoScrollToBottom } from './useAutoScrollToBottom';
interface ScrollableProps {
children?: React.ReactNode;
sx?: SxProps<Theme>;
autoscroll?: boolean;
textFieldRef?: RefObject<HTMLElement | null>; // Reference to the element that triggers auto-scroll
fallbackThreshold?: number;
contentUpdateTrigger?: any;
className?: string;
}
const Scrollable = (props: ScrollableProps) => {
const { sx, className, children, autoscroll, textFieldRef, fallbackThreshold = 0.33, contentUpdateTrigger } = props;
// Create a default ref if textFieldRef is not provided
const defaultTextFieldRef = useRef<HTMLElement | null>(null);
const scrollRef = useAutoScrollToBottom(textFieldRef ?? defaultTextFieldRef, true, fallbackThreshold, contentUpdateTrigger);
return (
<Box
className={className || "Scrollable"}
sx={{
display: 'flex',
margin: '0 auto',
flexGrow: 1,
overflow: 'auto',
backgroundColor: '#F5F5F5',
...sx,
}}
ref={autoscroll !== undefined && autoscroll !== false ? scrollRef : undefined}
>
{children}
</Box>
);
};
export { useAutoScrollToBottom, Scrollable };

View File

@ -146,14 +146,6 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
fetchCollection(); fetchCollection();
}, [result, setResult, connectionBase, setSnack, sessionId, view2D]) }, [result, setResult, connectionBase, setSnack, sessionId, view2D])
/* Trigger a resize event to force Plotly to rescale */
useEffect(() => {
window.dispatchEvent(new Event('resize'));
if (plotlyRef.current) {
Plot.Plots.resize(plotlyRef.current);
}
}, [])
useEffect(() => { useEffect(() => {
if (!result || !result.embeddings) return; if (!result || !result.embeddings) return;

View File

@ -0,0 +1,173 @@
import { useEffect, useRef, RefObject, useCallback } from 'react';
type ResizeCallback = () => void;
const useResizeObserverAndMutationObserver = (
targetRef: RefObject<HTMLElement | null>,
scrollToRef: RefObject<HTMLElement | null>,
callback: ResizeCallback
) => {
const callbackRef = useRef(callback);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const mutationObserverRef = useRef<MutationObserver | null>(null);
const debounceTimeout = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
const container = targetRef.current;
const scrollTo = scrollToRef.current;
if (!container) return;
const debouncedCallback = (entries: ResizeObserverEntry[] | undefined) => {
if (debounceTimeout.current) clearTimeout(debounceTimeout.current);
debounceTimeout.current = setTimeout(() => {
requestAnimationFrame(() => callbackRef.current());
}, 50);
};
const resizeObserver = new ResizeObserver(debouncedCallback);
const mutationObserver = new MutationObserver(() => { debouncedCallback(undefined); });
// Observe container size
resizeObserver.observe(container);
// Observe TextField size if available
if (scrollTo) {
resizeObserver.observe(scrollTo);
}
// Observe child additions/removals and text content changes
mutationObserver.observe(container, {
childList: true,
subtree: true,
characterData: true,
});
resizeObserverRef.current = resizeObserver;
mutationObserverRef.current = mutationObserver;
return () => {
if (debounceTimeout.current) clearTimeout(debounceTimeout.current);
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, [targetRef, scrollToRef]);
};
/**
* Auto-scroll hook for scrollable containers.
* Scrolls to the bottom of the container on paste or when TextField is fully/partially visible.
*/
export const useAutoScrollToBottom = (
scrollToRef: RefObject<HTMLElement | null>,
smooth: boolean = true,
fallbackThreshold: number = 0.33,
contentUpdateTrigger?: any
): RefObject<HTMLDivElement | null> => {
const containerRef = useRef<HTMLDivElement | null>(null);
const lastScrollTop = useRef(0);
const scrollTimeout = useRef<NodeJS.Timeout | null>(null);
const isUserScrollingUpRef = useRef(false);
const checkAndScrollToBottom = useCallback((isPasteEvent: boolean = false) => {
const container = containerRef.current;
if (!container) return;
let shouldScroll = false;
const scrollTo = scrollToRef.current;
if (scrollTo) {
// Get positions
const containerRect = container.getBoundingClientRect();
const scrollToRect = scrollTo.getBoundingClientRect();
const containerTop = containerRect.top;
const containerBottom = containerTop + container.clientHeight;
// Check if TextField is fully or partially visible (for non-paste events)
const isTextFieldVisible =
scrollToRect.top < containerBottom && scrollToRect.bottom > containerTop;
// Scroll on paste or if TextField is visible and user isn't scrolling up
shouldScroll = (isPasteEvent || isTextFieldVisible) && !isUserScrollingUpRef.current;
if (shouldScroll) {
requestAnimationFrame(() => {
console.debug('Scrolling to container bottom:', {
scrollHeight: container.scrollHeight,
scrollToHeight: scrollToRect.height,
containerHeight: container.clientHeight,
isPasteEvent,
isTextFieldVisible,
isUserScrollingUp: isUserScrollingUpRef.current,
});
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto',
});
});
}
} else {
// Fallback to threshold-based check
const scrollHeight = container.scrollHeight;
const isNearBottom =
scrollHeight - container.scrollTop - container.clientHeight <=
container.clientHeight * fallbackThreshold;
shouldScroll = isNearBottom && !isUserScrollingUpRef.current;
if (shouldScroll) {
requestAnimationFrame(() => {
console.debug('Scrolling to container bottom (fallback):', { scrollHeight });
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto',
});
});
}
}
}, [fallbackThreshold, smooth, scrollToRef]);
useEffect(() => {
const container = containerRef.current;
const scrollTo = scrollToRef.current;
if (!container) return;
const handleScroll = () => {
const currentScrollTop = container.scrollTop;
isUserScrollingUpRef.current = currentScrollTop < lastScrollTop.current;
lastScrollTop.current = currentScrollTop;
if (scrollTimeout.current) clearTimeout(scrollTimeout.current);
scrollTimeout.current = setTimeout(() => {
isUserScrollingUpRef.current = false;
}, 500);
};
const handlePaste = () => {
// Delay scroll check to ensure DOM updates
setTimeout(() => {
requestAnimationFrame(() => checkAndScrollToBottom(true));
}, 0);
};
container.addEventListener('scroll', handleScroll);
if (scrollTo) {
scrollTo.addEventListener('paste', handlePaste);
}
checkAndScrollToBottom();
return () => {
container.removeEventListener('scroll', handleScroll);
if (scrollTo) {
scrollTo.removeEventListener('paste', handlePaste);
}
if (scrollTimeout.current) clearTimeout(scrollTimeout.current);
};
}, [smooth, scrollToRef, fallbackThreshold, contentUpdateTrigger, checkAndScrollToBottom]);
// Observe container and TextField size, plus DOM changes
useResizeObserverAndMutationObserver(containerRef, scrollToRef, checkAndScrollToBottom);
return containerRef;
};

View File

@ -542,13 +542,14 @@ class WebServer:
agent = context.get_agent(agent_type) agent = context.get_agent(agent_type)
except Exception as e: except Exception as e:
logger.info(f"Attempt to create agent type: {agent_type} failed", e) logger.info(f"Attempt to create agent type: {agent_type} failed", e)
return JSONResponse({ "error": f"{agent_type} is not recognized", "context": context.id }, status_code=404) return JSONResponse({"error": f"{agent_type} is not recognized", "context": context.id}, status_code=404)
query = await request.json() query = await request.json()
prompt = query["prompt"] prompt = query["prompt"]
if not isinstance(prompt, str) or len(prompt) == 0: if not isinstance(prompt, str) or len(prompt) == 0:
logger.info(f"Prompt is empty") logger.info(f"Prompt is empty")
return JSONResponse({"error": "Prompt can not be empty"}, status_code=400) return JSONResponse({"error": "Prompt cannot be empty"}, status_code=400)
try: try:
options = Tunables(**query["options"]) if "options" in query else None options = Tunables(**query["options"]) if "options" in query else None
except Exception as e: except Exception as e:
@ -556,45 +557,76 @@ class WebServer:
return JSONResponse({"error": f"Invalid options: {query['options']}"}, status_code=400) return JSONResponse({"error": f"Invalid options: {query['options']}"}, status_code=400)
if not agent: if not agent:
# job_description is the only agent that is dynamically generated from a
# Rest API endpoint.
# - 'chat' is created on context creation.
# - 'resume' is created on actions by 'job_description'
# - 'fact_check' is created on ations by 'fact_check'
match agent_type: match agent_type:
case "job_description": case "job_description":
logger.info(f"Agent {agent_type} not found. Returning empty history.") logger.info(f"Agent {agent_type} not found. Returning empty history.")
agent = context.get_or_create_agent("job_description", job_description=prompt) agent = context.get_or_create_agent("job_description", job_description=prompt)
case _: case _:
logger.info(f"Invalid agent creation sequence for {agent_type}. Returning error.") logger.info(f"Invalid agent creation sequence for {agent_type}. Returning error.")
return JSONResponse({ "error": f"{agent_type} is not recognized", "context": context.id }, status_code=404) return JSONResponse({"error": f"{agent_type} is not recognized", "context": context.id}, status_code=404)
# Create a custom generator that ensures flushing
async def flush_generator(): async def flush_generator():
logging.info(f"Message starting. Streaming partial results.") logger.info(f"Message starting. Streaming partial results.")
async for message in self.generate_response(context=context, agent=agent, prompt=prompt, options=options): # Create a cancellable task to manage the generator
if message.status != "done": loop = asyncio.get_running_loop()
result = { stop_event = asyncio.Event()
"status": message.status,
"response": message.response async def process_generator():
} try:
else: async for message in self.generate_response(context=context, agent=agent, prompt=prompt, options=options):
logging.info(f"Message complete. Providing full response.") if stop_event.is_set():
try: logger.info("Stopping generator due to client disconnection.")
result = message.model_dump(by_alias=True, mode='json') return
except Exception as e: if message.status != "done":
result = { "status": "error", "response": e } result = {
exit(1) "status": message.status,
# Convert to JSON and add newline "response": message.response
result = json.dumps(result) + "\n" }
message.network_packets += 1 else:
message.network_bytes += len(result) logger.info(f"Message complete. Providing full response.")
yield result try:
# Explicitly flush after each yield result = message.model_dump(by_alias=True, mode='json')
await asyncio.sleep(0) # Allow the event loop to process the write except Exception as e:
# Save the history once completed result = {"status": "error", "response": str(e)}
self.save_context(context_id) yield json.dumps(result) + "\n"
return
# Convert to JSON and add newline
result = json.dumps(result) + "\n"
message.network_packets += 1
message.network_bytes += len(result)
yield result
# Allow the event loop to process the write
await asyncio.sleep(0)
except Exception as e:
logger.error(f"Error in process_generator: {e}")
yield json.dumps({"status": "error", "response": str(e)}) + "\n"
finally:
# Save context on completion or error
self.save_context(context_id)
# Create a generator iterator
gen = process_generator()
try:
async for result in gen:
# Check if client has disconnected
if await request.is_disconnected():
logger.info("Client disconnected, stopping generator.")
stop_event.set() # Signal the generator to stop
return
yield result
except Exception as e:
logger.error(f"Error in flush_generator: {e}")
yield json.dumps({"status": "error", "response": str(e)}) + "\n"
finally:
stop_event.set() # Ensure generator stops if not already stopped
# Ensure generator is fully closed
try:
await gen.aclose()
except Exception as e:
logger.warning(f"Error closing generator: {e}")
# Return StreamingResponse with appropriate headers # Return StreamingResponse with appropriate headers
return StreamingResponse( return StreamingResponse(
flush_generator(), flush_generator(),

View File

@ -421,7 +421,7 @@ class Agent(BaseModel, ABC):
**message.metadata["options"], **message.metadata["options"],
}, },
stream=True, stream=True,
): ):
if not response: if not response:
message.status = "error" message.status = "error"
message.response = "No response from LLM." message.response = "No response from LLM."

View File

@ -374,6 +374,7 @@ class JobDescription(Agent):
2. Create a comprehensive inventory of the candidate's actual qualifications. 2. Create a comprehensive inventory of the candidate's actual qualifications.
3. DO NOT consider any job requirements - this is a pure candidate analysis task. 3. DO NOT consider any job requirements - this is a pure candidate analysis task.
4. For each qualification, cite exactly where in the materials it appears. 4. For each qualification, cite exactly where in the materials it appears.
5. DO NOT duplicate or repeat time periods or skills once listed.
## OUTPUT FORMAT: ## OUTPUT FORMAT:
@ -778,7 +779,6 @@ class JobDescription(Agent):
# Extract JSON from response # Extract JSON from response
json_str = self.extract_json_from_text(message.response) json_str = self.extract_json_from_text(message.response)
verification_results = json.loads(json_str)
message.status = "done" message.status = "done"
message.response = json_str message.response = json_str
@ -862,12 +862,11 @@ class JobDescription(Agent):
""" """
try: try:
message.status = "thinking" message.status = "thinking"
message.response = "Starting multi-stage RAG resume generation process"
logger.info(message.response) logger.info(message.response)
yield message yield message
# Stage 1A: Analyze job requirements # Stage 1A: Analyze job requirements
message.response = "Stage 1A: Analyzing job requirements" message.response = "Multi-stage RAG resume generation process: Stage 1A: Analyzing job requirements"
logger.info(message.response) logger.info(message.response)
yield message yield message
@ -878,10 +877,11 @@ class JobDescription(Agent):
return return
job_requirements = json.loads(message.response) job_requirements = json.loads(message.response)
message.metadata["job_requirements"] = job_requirements
# Stage 1B: Analyze candidate qualifications # Stage 1B: Analyze candidate qualifications
message.status = "thinking" message.status = "thinking"
message.response = "Stage 1B: Analyzing candidate qualifications" message.response = "Multi-stage RAG resume generation process: Stage 1B: Analyzing candidate qualifications"
logger.info(message.response) logger.info(message.response)
yield message yield message
@ -892,10 +892,11 @@ class JobDescription(Agent):
return return
candidate_qualifications = json.loads(message.response) candidate_qualifications = json.loads(message.response)
message.metadata["candidate_qualifications"] = job_requirements
# Stage 1C: Create skills mapping # Stage 1C: Create skills mapping
message.status = "thinking" message.status = "thinking"
message.response = "Stage 1C: Creating skills mapping" message.response = "Multi-stage RAG resume generation process: Stage 1C: Creating skills mapping"
logger.info(message.response) logger.info(message.response)
yield message yield message
@ -906,13 +907,14 @@ class JobDescription(Agent):
return return
skills_mapping = json.loads(message.response) skills_mapping = json.loads(message.response)
message.metadata["skills_mapping"] = skills_mapping
# Extract header from original resume # Extract header from original resume
original_header = self.extract_header_from_resume(resume) original_header = self.extract_header_from_resume(resume)
# Stage 2: Generate tailored resume # Stage 2: Generate tailored resume
message.status = "thinking" message.status = "thinking"
message.response = "Stage 2: Generating tailored resume" message.response = "Multi-stage RAG resume generation process: Stage 2: Generating tailored resume"
logger.info(message.response) logger.info(message.response)
yield message yield message
@ -923,10 +925,13 @@ class JobDescription(Agent):
return return
generated_resume = message.response generated_resume = message.response
message.metadata["generated_resume"] = {
"first_pass": generated_resume
}
# Stage 3: Verify resume # Stage 3: Verify resume
message.status = "thinking" message.status = "thinking"
message.response = "Stage 3: Verifying resume for accuracy" message.response = "Multi-stage RAG resume generation process: Stage 3: Verifying resume for accuracy"
logger.info(message.response) logger.info(message.response)
yield message yield message
@ -937,6 +942,9 @@ class JobDescription(Agent):
return return
verification_results = json.loads(message.response) verification_results = json.loads(message.response)
message.metadata["verification_results"] = {
"first_pass": verification_results
}
# Handle corrections if needed # Handle corrections if needed
if verification_results["verification_results"]["overall_assessment"] == "NEEDS REVISION": if verification_results["verification_results"]["overall_assessment"] == "NEEDS REVISION":
@ -959,7 +967,8 @@ class JobDescription(Agent):
return return
generated_resume = message.response generated_resume = message.response
message.metadata["generated_resume"]["second_pass"] = generated_resume
# Re-verify after corrections # Re-verify after corrections
message.status = "thinking" message.status = "thinking"
message.response = "Re-verifying corrected resume" message.response = "Re-verifying corrected resume"
@ -973,16 +982,12 @@ class JobDescription(Agent):
yield message yield message
if message.status == "error": if message.status == "error":
return return
verification_results = json.loads(message.response)
message.metadata["verification_results"]["second_pass"] = verification_results
# Return the final results # Return the final results
message.status = "done" message.status = "done"
message.response = json.dumps({ message.response = generated_resume
"job_requirements": job_requirements,
"candidate_qualifications": candidate_qualifications,
"skills_mapping": skills_mapping,
"generated_resume": generated_resume,
"verification_results": verification_results
})
yield message yield message
logger.info("Resume generation process completed successfully") logger.info("Resume generation process completed successfully")
@ -1006,9 +1011,9 @@ class JobDescription(Agent):
self.metrics.generate_count.labels(agent=self.agent_type).inc() self.metrics.generate_count.labels(agent=self.agent_type).inc()
with self.metrics.generate_duration.labels(agent=self.agent_type).time(): with self.metrics.generate_duration.labels(agent=self.agent_type).time():
job_description = "You write C and C++ code for 4 years." job_description = message.preamble["job_description"]
resume = "I have worked on Cobol and QuickBasic for 18 years." resume = message.preamble["resume"]
additional_context = "" additional_context = message.preamble["context"]
async for message in self.generate_factual_tailored_resume(message=message, job_description=job_description, resume=resume, additional_context=additional_context): async for message in self.generate_factual_tailored_resume(message=message, job_description=job_description, resume=resume, additional_context=additional_context):
if message.status != "done": if message.status != "done":