Add AI editing for resumes

This commit is contained in:
James Ketr 2025-07-15 10:24:21 -07:00
parent c31752e50f
commit 79372231eb
33 changed files with 2369 additions and 1697 deletions

View File

@ -1,7 +1,7 @@
# #
# Build Pyton 3.11 for use in later stages # Build Pyton 3.11 for use in later stages
# #
FROM ubuntu:oracular AS python FROM ubuntu:oracular AS python-local
SHELL [ "/bin/bash", "-c" ] SHELL [ "/bin/bash", "-c" ]
@ -62,7 +62,7 @@ RUN cmake .. \
# * ollama-ipex-llm # * ollama-ipex-llm
# * src/server.py - model server supporting RAG and fine-tuned models # * src/server.py - model server supporting RAG and fine-tuned models
# #
FROM python AS llm-base FROM python-local AS llm-base
# Install Intel graphics runtimes # Install Intel graphics runtimes
RUN apt-get update \ RUN apt-get update \
@ -180,8 +180,8 @@ FROM llm-base AS backstory
SHELL [ "/opt/backstory/shell" ] SHELL [ "/opt/backstory/shell" ]
#COPY /src/requirements.txt /opt/backstory/src/requirements.txt #COPY /src/requirements.txt /opt/backstory/requirements.txt
#RUN pip install -r /opt/backstory/src/requirements.txt #RUN pip install -r /opt/backstory/requirements.txt
RUN pip install 'markitdown[all]' pydantic 'pydantic[email]' RUN pip install 'markitdown[all]' pydantic 'pydantic[email]'
# Prometheus # Prometheus
@ -200,7 +200,7 @@ RUN pip install pyyaml user-agents cryptography
RUN pip install openapi-python-client RUN pip install openapi-python-client
# QR code generator # QR code generator
RUN pip install pyqrcode pypng RUN pip install setuptools pyqrcode pypng
# Anthropic and other backends # Anthropic and other backends
RUN pip install anthropic pydantic_ai RUN pip install anthropic pydantic_ai
@ -250,7 +250,7 @@ RUN { \
echo ' while true; do'; \ echo ' while true; do'; \
echo ' if [[ ! -e /opt/backstory/block-server ]]; then'; \ echo ' if [[ ! -e /opt/backstory/block-server ]]; then'; \
echo ' echo "Launching Backstory server..."'; \ echo ' echo "Launching Backstory server..."'; \
echo ' python src/backend/main.py "${@}" || echo "Backstory server died."'; \ echo ' python3 src/backend/main.py "${@}" || echo "Backstory server died."'; \
echo ' echo "Sleeping for 3 seconds."'; \ echo ' echo "Sleeping for 3 seconds."'; \
echo ' else'; \ echo ' else'; \
echo ' if [[ ${once} -eq 0 ]]; then' ; \ echo ' if [[ ${once} -eq 0 ]]; then' ; \
@ -470,7 +470,7 @@ ENV PATH=/opt/backstory:$PATH
ENTRYPOINT [ "/entrypoint-jupyter.sh" ] ENTRYPOINT [ "/entrypoint-jupyter.sh" ]
FROM python AS miniircd FROM python-local AS miniircd
# Get a couple prerequisites # Get a couple prerequisites
RUN apt-get update \ RUN apt-get update \
@ -657,7 +657,7 @@ RUN pip install huggingface_hub modelscope
ENTRYPOINT ["/bin/bash", "-c", "source /home/ollama_ov_server/openvino_genai_ubuntu24_2025.2.0.0.dev20250513_x86_64/setupvars.sh && /usr/bin/ollama serve"] ENTRYPOINT ["/bin/bash", "-c", "source /home/ollama_ov_server/openvino_genai_ubuntu24_2025.2.0.0.dev20250513_x86_64/setupvars.sh && /usr/bin/ollama serve"]
FROM python AS vllm FROM llm-base AS vllm
RUN apt-get update \ RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y \ && DEBIAN_FRONTEND=noninteractive apt-get install -y \
@ -676,7 +676,7 @@ ENV PATH=~/.local/bin:$PATH
RUN { \ RUN { \
echo '#!/bin/bash' ; \ echo '#!/bin/bash' ; \
echo 'source /opt/vllm/.venv/bin/activate' ; \ echo 'source /opt/backstory/venv/bin/activate'; \
echo 'if [[ "${1}" != "" ]]; then bash -c "${@}"; else bash -i; fi' ; \ echo 'if [[ "${1}" != "" ]]; then bash -c "${@}"; else bash -i; fi' ; \
} > /opt/vllm/shell ; \ } > /opt/vllm/shell ; \
chmod +x /opt/vllm/shell chmod +x /opt/vllm/shell
@ -695,13 +695,27 @@ RUN { \
echo '#!/bin/bash'; \ echo '#!/bin/bash'; \
echo 'echo "Container: vLLM"'; \ echo 'echo "Container: vLLM"'; \
echo 'set -e'; \ echo 'set -e'; \
echo 'python -m vllm.entrypoints.openai.api_server \'; \ echo 'source /opt/backstory/venv/bin/activate'; \
echo ' --model=facebook/opt-13b \' ; \ echo 'while true; do'; \
echo ' --dtype=bfloat16 \' ; \ echo ' if [[ ! -e /opt/backstory/block-server ]]; then'; \
echo ' --max_model_len=1024 \' ; \ echo ' echo "Launching vLLM server..."'; \
echo ' --distributed-executor-backend=ray \' ; \ echo ' python3 -m vllm.entrypoints.openai.api_server \'; \
echo ' --pipeline-parallel-size=2 \' ; \ echo ' --model=Qwen/Qwen3-8b \' ; \
echo ' -tp=8' ; \ echo ' --device xpu' ; \
# echo ' --dtype=bfloat16 \' ; \
# echo ' --max_model_len=1024 \' ; \
# echo ' --distributed-executor-backend=ray \' ; \
# echo ' --pipeline-parallel-size=2 \' ; \
# echo ' -tp=1' ; \
echo ' echo "Sleeping for 3 seconds."'; \
echo ' else'; \
echo ' if [[ ${once} -eq 0 ]]; then' ; \
echo ' echo "/opt/vllm/block-server exists. Sleeping for 3 seconds."'; \
echo ' once=1' ; \
echo ' fi' ; \
echo ' fi' ; \
echo ' sleep 3'; \
echo 'done' ; \
} > /entrypoint.sh \ } > /entrypoint.sh \
&& chmod +x /entrypoint.sh && chmod +x /entrypoint.sh

View File

@ -213,10 +213,11 @@ services:
vllm: vllm:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile.xpu
target: vllm target: vllm-openai
container_name: vllm container_name: vllm-openai
restart: "always" restart: "always"
shm_size: 10.24gb
env_file: env_file:
- .env - .env
environment: environment:

View File

@ -14,7 +14,15 @@
"plugins": ["@typescript-eslint", "react", "react-hooks"], "plugins": ["@typescript-eslint", "react", "react-hooks"],
"rules": { "rules": {
"react/react-in-jsx-scope": "off", "react/react-in-jsx-scope": "off",
"prettier/prettier": ["error", { "arrowParens": "avoid" }] "prettier/prettier": ["error", { "arrowParens": "avoid" }],
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}
]
}, },
"settings": { "settings": {
"react": { "react": {

View File

@ -14,30 +14,35 @@ module.exports = {
throw new Error('webpack-dev-server is not defined'); throw new Error('webpack-dev-server is not defined');
} }
devServer.app.use('/api', createProxyMiddleware({ devServer.app.use(
target: 'https://backstory:8911', '/api',
changeOrigin: true, createProxyMiddleware({
secure: false, target: 'https://backstory:8911',
buffer: false, changeOrigin: true,
proxyTimeout: 3600000, secure: false,
onProxyRes: function(proxyRes, req, res) { buffer: false,
proxyRes.headers['cache-control'] = 'no-cache'; proxyTimeout: 3600000,
onProxyRes: function (proxyRes, req, res) {
proxyRes.headers['cache-control'] = 'no-cache';
if (req.url.includes('/docs') || if (
req.url.includes('/docs') ||
req.url.includes('/redoc') || req.url.includes('/redoc') ||
req.url.includes('/openapi.json')) { req.url.includes('/openapi.json')
return; // Let original headers pass through ) {
} return; // Let original headers pass through
// Remove any header that might cause buffering }
proxyRes.headers['transfer-encoding'] = 'chunked'; // Remove any header that might cause buffering
delete proxyRes.headers['content-length']; proxyRes.headers['transfer-encoding'] = 'chunked';
delete proxyRes.headers['content-length'];
// Set proper streaming headers // Set proper streaming headers
proxyRes.headers['cache-control'] = 'no-cache'; proxyRes.headers['cache-control'] = 'no-cache';
proxyRes.headers['content-type'] = 'text/event-stream'; proxyRes.headers['content-type'] = 'text/event-stream';
proxyRes.headers['connection'] = 'keep-alive'; proxyRes.headers['connection'] = 'keep-alive';
}, },
})); })
);
return middlewares; return middlewares;
} }
@ -46,11 +51,9 @@ module.exports = {
configure: (webpackConfig) => { configure: (webpackConfig) => {
webpackConfig.devtool = 'source-map'; webpackConfig.devtool = 'source-map';
// Add .ts and .tsx to resolve.extensions // Add .ts and .tsx to resolve.extensions
webpackConfig.resolve.extensions = [ webpackConfig.resolve.extensions = [...webpackConfig.resolve.extensions, '.ts', '.tsx'];
...webpackConfig.resolve.extensions, // Ignore source map warnings for node_modules
'.ts', webpackConfig.ignoreWarnings = [/Failed to parse source map/];
'.tsx',
];
return webpackConfig; return webpackConfig;
}, },
}, },

File diff suppressed because it is too large Load Diff

View File

@ -18,11 +18,13 @@
"@types/lodash": "^4.17.17", "@types/lodash": "^4.17.17",
"@types/luxon": "^3.6.2", "@types/luxon": "^3.6.2",
"@types/node": "^16.18.126", "@types/node": "^16.18.126",
"@types/react": "^19.0.12", "@types/react": "^19.1.8",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.1.6",
"@uiw/react-json-view": "^2.0.0-alpha.31", "@uiw/react-json-view": "^2.0.0-alpha.31",
"@uiw/react-markdown-editor": "^6.1.4", "@uiw/react-markdown-editor": "^6.1.4",
"country-state-city": "^3.2.1", "country-state-city": "^3.2.1",
"diff": "^8.0.2",
"diff2html": "^3.4.52",
"jsonrepair": "^3.12.0", "jsonrepair": "^3.12.0",
"libphonenumber-js": "^1.12.9", "libphonenumber-js": "^1.12.9",
"lodash": "^4.17.21", "lodash": "^4.17.21",
@ -33,6 +35,7 @@
"mui-markdown": "^2.0.1", "mui-markdown": "^2.0.1",
"prism-react-renderer": "^2.4.1", "prism-react-renderer": "^2.4.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-diff-view": "^3.3.1",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-markdown-it": "^1.0.2", "react-markdown-it": "^1.0.2",

View File

@ -0,0 +1,9 @@
.d2h-file-side-diff .d2h-code-line pre {
white-space: pre-wrap !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
}
.d2h-file-header {
display: none;
}

View File

@ -0,0 +1,62 @@
import React, { useEffect, useRef } from 'react';
import { createPatch } from 'diff';
import { Box, SxProps } from '@mui/material';
import * as Diff2Html from 'diff2html';
import { Diff2HtmlUI } from 'diff2html/lib/ui/js/diff2html-ui';
import 'diff2html/bundles/css/diff2html.min.css';
import './DiffViewer.css';
interface DiffViewerProps {
original: string;
content: string;
sx?: SxProps;
outputFormat?: 'line-by-line' | 'side-by-side';
drawFileList?: boolean;
}
const DiffViewer: React.FC<DiffViewerProps> = (props: DiffViewerProps) => {
const { original, content, sx, outputFormat = 'line-by-line', drawFileList = false } = props;
const diffRef = useRef<HTMLDivElement>(null);
const diffString = createPatch('Resume', original || '', content || '', 'original', 'modified', {
context: 5,
});
useEffect(() => {
if (diffRef.current && diffString) {
// Clear previous content
diffRef.current.innerHTML = '';
diffRef.current.className = `diff-viewer`;
// Generate HTML from diff string
const diff2htmlUi = new Diff2HtmlUI(diffRef.current, diffString, {
drawFileList: false,
matching: 'lines',
outputFormat: 'side-by-side',
synchronisedScroll: true,
highlight: true,
fileContentToggle: false,
});
diff2htmlUi.draw();
diff2htmlUi.highlightCode();
diff2htmlUi.synchronisedScroll();
// diffRef.current.innerHTML = diff2htmlUi;
}
}, [diffString, outputFormat, drawFileList]);
return (
<Box
ref={diffRef}
className="diff-viewer"
sx={{
display: 'flex',
position: 'relative',
fontFamily: 'monospace',
fontSize: '14px',
maxWidth: '100%',
...sx,
}}
/>
);
};
export { DiffViewer };

View File

@ -196,6 +196,7 @@ interface MessageProps extends BackstoryElementProps {
chatSession?: ChatSession; chatSession?: ChatSession;
className?: string; className?: string;
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
viewAsMarkdown?: boolean; // Whether to render messages as markdown
expandable?: boolean; expandable?: boolean;
expanded?: boolean; expanded?: boolean;
onExpand?: (open: boolean) => void; onExpand?: (open: boolean) => void;
@ -463,7 +464,17 @@ const MessageContainer = (props: MessageContainerProps): JSX.Element => {
}; };
const Message = (props: MessageProps): JSX.Element => { const Message = (props: MessageProps): JSX.Element => {
const { message, title, sx, className, chatSession, onExpand, expanded, expandable } = props; const {
message,
title,
sx,
className,
chatSession,
onExpand,
expanded,
expandable,
viewAsMarkdown = true,
} = props;
const [metaExpanded, setMetaExpanded] = useState<boolean>(false); const [metaExpanded, setMetaExpanded] = useState<boolean>(false);
const theme = useTheme(); const theme = useTheme();
const type: ApiActivityType | ChatSenderType | 'error' = const type: ApiActivityType | ChatSenderType | 'error' =
@ -491,11 +502,20 @@ const Message = (props: MessageProps): JSX.Element => {
} }
const messageView = ( const messageView = (
<StyledMarkdown <>
chatSession={chatSession} {viewAsMarkdown && (
streaming={message.status === 'streaming'} <StyledMarkdown
content={content} chatSession={chatSession}
/> streaming={message.status === 'streaming'}
content={content}
/>
)}
{!viewAsMarkdown && (
<Box>
<pre>{content}</pre>
</Box>
)}
</>
); );
let metadataView = <></>; let metadataView = <></>;

View File

@ -0,0 +1,499 @@
import React, { forwardRef, useState, useEffect, useRef, JSX } from 'react';
import {
Box,
Button,
Tooltip,
SxProps,
Typography,
Tabs,
Tab,
Dialog,
DialogContent,
} from '@mui/material';
import { Send as SendIcon, Person as PersonIcon } from '@mui/icons-material';
// import PrecisionManufacturingIcon from '@mui/icons-material/PrecisionManufacturing';
import SaveIcon from '@mui/icons-material/Save';
import UndoIcon from '@mui/icons-material/Undo';
import { useAuth } from 'hooks/AuthContext';
import PreviewIcon from '@mui/icons-material/Preview';
import DifferenceIcon from '@mui/icons-material/Difference';
import EditDocumentIcon from '@mui/icons-material/EditDocument';
import {
ChatMessage,
ChatSession,
ChatMessageUser,
ChatMessageError,
ChatMessageStreaming,
ChatMessageStatus,
ChatMessageMetaData,
} from 'types/types';
import { ConversationHandle } from 'components/Conversation';
import { Message } from 'components/Message';
import { DeleteConfirmation } from 'components/DeleteConfirmation';
import { useAppState, useSelectedCandidate } from 'hooks/GlobalContext';
import PropagateLoader from 'react-spinners/PropagateLoader';
import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField';
import { Scrollable } from 'components/Scrollable';
import { StyledMarkdown } from './StyledMarkdown';
import { DiffViewer } from './DiffViewer';
const emptyMetadata: ChatMessageMetaData = {
model: 'qwen2.5',
temperature: 0,
maxTokens: 0,
topP: 0,
frequencyPenalty: 0,
presencePenalty: 0,
stopSequences: [],
usage: {
evalCount: 0,
evalDuration: 0,
promptEvalCount: 0,
promptEvalDuration: 0,
},
};
const defaultMessage: ChatMessage = {
status: 'done',
type: 'text',
sessionId: '',
timestamp: new Date(),
content: '',
role: 'user',
metadata: emptyMetadata,
};
interface ResumeChatProps {
onResumeChange: (resume: string) => void; // Callback when the resume changes
resume: string;
session: string; // Session identifier for the chat
sx?: SxProps; // Optional styles for the component
}
interface EditViewerProps {
original: string; // Original resume content
content: string; // Edited resume content
sx?: SxProps; // Optional styles for the component
onUpdate?: (content: string) => void; // Callback when the content is updated
}
const EditViewer = (props: EditViewerProps) => {
const { original, content, sx, onUpdate } = props;
const [tabValue, setTabValue] = useState<string>('raw');
const handleTabChange = (event: React.SyntheticEvent, newValue: string): void => {
if (newValue === 'diff') {
setTabValue(newValue);
} else {
setTabValue(newValue);
}
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, ...sx }}>
<Tabs value={tabValue} onChange={handleTabChange} sx={{ m: 0 }}>
<Tab value="raw" icon={<EditDocumentIcon sx={{ height: 16 }} />} sx={{ height: 20 }} />
<Tab value="markdown" icon={<PreviewIcon sx={{ height: 16 }} />} sx={{ height: 20 }} />
<Tab value="diff" icon={<DifferenceIcon sx={{ height: 16 }} />} sx={{ height: 20 }} />
</Tabs>
{tabValue === 'raw' && (
<Box>
<pre>{content}</pre>
</Box>
)}
{tabValue === 'markdown' && <StyledMarkdown content={content} />}
<Dialog
open={tabValue === 'diff'}
onClose={(): void => {
setTabValue('raw');
}}
maxWidth="lg"
fullWidth
disableEscapeKeyDown={false}
fullScreen={true}
>
<DialogContent>
<Scrollable
sx={{
position: 'relative',
boxSizing: 'border-box',
maxHeight: '100%',
maxWidth: '100%',
width: '100%',
display: 'flex',
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
overflowY: 'auto' /* Scroll if content overflows */,
p: 0,
gap: 1,
}}
>
<DiffViewer original={original} content={content} />
</Scrollable>
<Box
sx={{
display: 'flex',
width: '100%',
flexDirection: 'row',
justifyContent: 'space-between',
}}
>
<Tooltip title="Revert Changes">
<span
style={{
minWidth: 'auto',
maxHeight: 'min-content',
alignSelf: 'flex-end',
}}
>
<Button
variant="contained"
onClick={(): void => {
setTabValue('raw');
onUpdate && onUpdate(original);
}}
disabled={!onUpdate}
>
<UndoIcon />
</Button>
</span>
</Tooltip>
<Tooltip title="Save Changes">
<span
style={{
minWidth: 'auto',
maxHeight: 'min-content',
alignSelf: 'flex-end',
}}
>
<Button
variant="contained"
onClick={(): void => {
setTabValue('raw');
onUpdate && onUpdate(content);
}}
disabled={!onUpdate}
>
<SaveIcon />
</Button>
</span>
</Tooltip>
</Box>
</DialogContent>
</Dialog>
</Box>
);
};
const ResumeChat = forwardRef<ConversationHandle, ResumeChatProps>(
(props: ResumeChatProps, ref): JSX.Element => {
const {
session,
onResumeChange,
resume,
sx,
// tunables = {
// enableRAG: true,
// enableTools: true,
// enableContext: true,
// topK: 50,
// temperature: 0.7,
// threshold: 0.75,
// },
} = props;
const { apiClient } = useAuth();
const { selectedCandidate } = useSelectedCandidate();
const [processingMessage, setProcessingMessage] = useState<
ChatMessageStatus | ChatMessageError | null
>(null);
const [streamingMessage, setStreamingMessage] = useState<ChatMessage | null>(null);
const backstoryTextRef = useRef<BackstoryTextFieldRef>(null);
const { setSnack } = useAppState();
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [streaming, setStreaming] = useState<boolean>(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const onDelete = async (session: ChatSession): Promise<void> => {
if (!session.id) {
return;
}
try {
await apiClient.resetChatSession(session.id);
// If we're deleting the currently selected session, clear it
setMessages([]);
setSnack('Session reset succeeded', 'success');
} catch (error) {
console.error('Failed to delete session:', error);
setSnack('Failed to delete session', 'error');
}
};
// Send message
const sendMessage = async (message: string): Promise<void> => {
if (!message.trim() || !chatSession?.id || streaming || !selectedCandidate) return;
const messageContent = message;
setStreaming(true);
const chatMessage: ChatMessageUser = {
sessionId: chatSession.id,
role: 'user',
content: messageContent,
status: 'done',
type: 'text',
extraContext: { resume: resume },
timestamp: new Date(),
};
setProcessingMessage({
...defaultMessage,
status: 'status',
activity: 'info',
content: `Establishing connection with ${selectedCandidate.firstName}'s chat session.`,
});
setMessages(prev => {
return [...prev, chatMessage] as ChatMessage[];
});
try {
apiClient.sendMessageStream(chatMessage, {
onMessage: (msg: ChatMessage): void => {
setMessages(prev => {
const filtered = prev.filter(m => m.id !== msg.id);
return [...filtered, msg] as ChatMessage[];
});
setStreamingMessage(null);
setProcessingMessage(null);
},
onError: (error: string | ChatMessageError): void => {
console.log('onError:', error);
// Type-guard to determine if this is a ChatMessageBase or a string
if (typeof error === 'object' && error !== null && 'content' in error) {
setProcessingMessage(error);
} else {
setProcessingMessage({
...defaultMessage,
status: 'error',
content: error,
});
}
setStreaming(false);
},
onStreaming: (chunk: ChatMessageStreaming): void => {
// console.log("onStreaming:", chunk);
setStreamingMessage({
...chunk,
role: 'assistant',
metadata: emptyMetadata,
});
},
onStatus: (status: ChatMessageStatus): void => {
setProcessingMessage(status);
},
onComplete: (): void => {
console.log('onComplete');
setStreamingMessage(null);
setProcessingMessage(null);
setStreaming(false);
},
});
} catch (error) {
console.error('Failed to send message:', error);
setStreaming(false);
}
};
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Load sessions when username changes
useEffect(() => {
if (!selectedCandidate) {
setSnack('Candidate not selected', 'error');
return;
}
try {
setLoading(true);
apiClient
.getOrCreateChatSession(selectedCandidate, session, 'edit_resume')
.then(session => {
setChatSession(session);
setLoading(false);
});
} catch (error) {
setSnack('Unable to load chat session', 'error');
} finally {
setLoading(false);
}
}, [selectedCandidate, apiClient, setSnack]);
// Load messages when session changes
useEffect(() => {
const loadMessages = async (): Promise<void> => {
if (!chatSession?.id) return;
try {
const result = await apiClient.getChatMessages(chatSession.id);
const chatMessages: ChatMessage[] = result.data;
setMessages(chatMessages);
setProcessingMessage(null);
setStreamingMessage(null);
console.log(`getChatMessages returned ${chatMessages.length} messages.`, chatMessages);
} catch (error) {
console.error('Failed to load messages:', error);
}
};
if (chatSession?.id) {
loadMessages();
}
}, [chatSession, apiClient]);
return (
<Box
ref={ref}
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%' /* Restrict to main-container's height */,
width: '100%',
maxWidth: '100%',
minHeight: 0 /* Prevent flex overflow */,
maxHeight: 'min-content',
'& > *:not(.Scrollable)': {
flexShrink: 0 /* Prevent shrinking */,
},
position: 'relative',
...sx,
}}
>
{/* Chat Interface */}
{/* Scrollable Messages Area */}
{chatSession && (
<Scrollable
sx={{
position: 'relative',
boxSizing: 'border-box',
maxHeight: '100%',
maxWidth: '100%',
width: '100%',
display: 'flex',
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
overflowY: 'auto' /* Scroll if content overflows */,
p: 0,
gap: 1,
}}
>
{messages.map((message: ChatMessage) => (
<Box
key={message.id}
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'flex-start',
maxWidth: '100%',
position: 'relative',
'& pre, & > p': { border: 0, mt: '0.25rem', mb: 0 },
}}
>
{message.role === 'user' && (
<>
<PersonIcon />
<Typography>{message.content}</Typography>
</>
)}
{message.role === 'assistant' && (
<EditViewer
original={resume}
content={message.content}
onUpdate={(content: string): void => {
onResumeChange && onResumeChange(content);
}}
sx={{ maxWidth: '100%', boxSizing: 'border-box' }}
/>
)}
</Box>
))}
{processingMessage !== null && (
<Message {...{ chatSession, message: processingMessage }} />
)}
{streamingMessage !== null && (
<Box sx={{ '& pre': { border: 0 } }}>
<pre>{streamingMessage.content}</pre>
</Box>
)}
{streaming && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
m: 1,
}}
>
<PropagateLoader
size="10px"
loading={streaming}
aria-label="Loading Spinner"
data-testid="loader"
/>
</Box>
)}
<div ref={messagesEndRef} />
</Scrollable>
)}
{/* Fixed Message Input */}
<Box sx={{ display: 'flex', flexShrink: 1, gap: 1 }}>
<DeleteConfirmation
onDelete={(): void => {
chatSession && onDelete(chatSession);
}}
disabled={!chatSession}
sx={{ minWidth: 'auto', px: 2, maxHeight: 'min-content' }}
action="reset"
label="chat session"
title="Reset Chat Session"
message={`Are you sure you want to reset the session? This action cannot be undone.`}
/>
<BackstoryTextField
placeholder="What would you like to change about this resume?"
ref={backstoryTextRef}
onEnter={sendMessage}
disabled={streaming || loading}
/>
<Tooltip title="Send">
<span
style={{
minWidth: 'auto',
maxHeight: 'min-content',
alignSelf: 'center',
}}
>
<Button
variant="contained"
onClick={(): void => {
sendMessage(
(backstoryTextRef.current && backstoryTextRef.current.getAndResetValue()) || ''
);
}}
disabled={streaming || loading}
>
<SendIcon />
</Button>
</span>
</Tooltip>
</Box>
</Box>
);
}
);
ResumeChat.displayName = 'ResumeChat';
export { ResumeChat };

View File

@ -25,7 +25,7 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
const { job, candidate, skills, onComplete } = props; const { job, candidate, skills, onComplete } = props;
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const navigate = useNavigate(); const navigate = useNavigate();
const { apiClient, user } = useAuth(); const { apiClient } = useAuth();
const [resume, setResume] = useState<string>(''); const [resume, setResume] = useState<string>('');
const [prompt, setPrompt] = useState<string>(''); const [prompt, setPrompt] = useState<string>('');
const [systemPrompt, setSystemPrompt] = useState<string>(''); const [systemPrompt, setSystemPrompt] = useState<string>('');

View File

@ -161,7 +161,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
id: item.id, id: item.id,
label: item.label as string, label: item.label as string,
icon: item.icon || null, icon: item.icon || null,
action: () => item.path && navigate(item.path.replace(/:.*$/, '')), action: () => item.path && navigate(item.path.replace(/:.*$/, ''), { replace: true }),
group: 'profile', group: 'profile',
}); });
} }

View File

@ -42,13 +42,11 @@ import {
Visibility as VisibilityIcon, Visibility as VisibilityIcon,
Edit as EditIcon, Edit as EditIcon,
Delete as DeleteIcon, Delete as DeleteIcon,
Close as CloseIcon,
ModelTraining, ModelTraining,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { TransitionProps } from '@mui/material/transitions'; import { TransitionProps } from '@mui/material/transitions';
import * as Types from 'types/types'; // Adjust the import path as necessary import * as Types from 'types/types'; // Adjust the import path as necessary
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from 'hooks/AuthContext';
import { StyledMarkdown } from 'components/StyledMarkdown';
import { Scrollable } from 'components/Scrollable'; import { Scrollable } from 'components/Scrollable';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { JobInfo } from './JobInfo'; import { JobInfo } from './JobInfo';

View File

@ -147,6 +147,6 @@
.BackstoryResumeHeader p { .BackstoryResumeHeader p {
/* border: 3px solid purple; */ /* border: 3px solid purple; */
margin: 0; margin: 0 !important;
} }

View File

@ -2,9 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { import {
Box, Box,
Typography, Typography,
Grid,
SxProps, SxProps,
Stack,
CardHeader, CardHeader,
Button, Button,
LinearProgress, LinearProgress,
@ -26,7 +24,6 @@ import {
Select, Select,
MenuItem, MenuItem,
InputLabel, InputLabel,
Chip,
Theme, Theme,
} from '@mui/material'; } from '@mui/material';
import PrintIcon from '@mui/icons-material/Print'; import PrintIcon from '@mui/icons-material/Print';
@ -40,7 +37,6 @@ import {
Person as PersonIcon, Person as PersonIcon,
Schedule as ScheduleIcon, Schedule as ScheduleIcon,
ModelTraining, ModelTraining,
Style as StyleIcon,
Email as EmailIcon, Email as EmailIcon,
Phone as PhoneIcon, Phone as PhoneIcon,
LocationOn as LocationIcon, LocationOn as LocationIcon,
@ -65,6 +61,7 @@ import { Scrollable } from 'components/Scrollable';
import * as Types from 'types/types'; import * as Types from 'types/types';
import { StreamingOptions } from 'services/api-client'; import { StreamingOptions } from 'services/api-client';
import { StatusBox, StatusIcon } from './StatusIcon'; import { StatusBox, StatusIcon } from './StatusIcon';
import { ResumeChat } from 'components/ResumeChat';
interface ResumeInfoProps { interface ResumeInfoProps {
resume: Resume; resume: Resume;
@ -91,266 +88,278 @@ interface ResumeStyle {
}; };
} }
const resumeStyles: Record<string, ResumeStyle> = { const generateResumeStyles = () => {
classic: { const defaultStyle = {
name: 'Classic', display: 'flex',
description: 'Traditional, professional serif design', flexDirection: 'row',
headerStyle: { };
fontFamily: '"Times New Roman", Times, serif',
borderBottom: '2px solid #2c3e50', return {
paddingBottom: 2, classic: {
marginBottom: 3, name: 'Classic',
}, description: 'Traditional, professional serif design',
footerStyle: { headerStyle: {
fontFamily: '"Times New Roman", Times, serif', ...defaultStyle,
borderTop: '2px solid #2c3e50',
paddingTop: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textTransform: 'uppercase',
alignContent: 'center',
fontSize: '0.8rem',
pb: 2,
mb: 2,
},
contentStyle: {
fontFamily: '"Times New Roman", Times, serif',
lineHeight: 1.6,
color: '#2c3e50',
},
markdownStyle: {
fontFamily: '"Times New Roman", Times, serif',
'& h1, & h2, & h3': {
fontFamily: '"Times New Roman", Times, serif', fontFamily: '"Times New Roman", Times, serif',
borderBottom: '2px solid #2c3e50',
paddingBottom: 2,
marginBottom: 3,
},
footerStyle: {
fontFamily: '"Times New Roman", Times, serif',
borderTop: '2px solid #2c3e50',
paddingTop: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textTransform: 'uppercase',
alignContent: 'center',
fontSize: '0.8rem',
pb: 2,
mb: 2,
},
contentStyle: {
fontFamily: '"Times New Roman", Times, serif',
lineHeight: 1.6,
color: '#2c3e50', color: '#2c3e50',
borderBottom: '1px solid #bdc3c7',
paddingBottom: 1,
marginBottom: 2,
}, },
'& p, & li': { markdownStyle: {
lineHeight: 1.6, fontFamily: '"Times New Roman", Times, serif',
marginBottom: 1, '& h1, & h2, & h3': {
fontFamily: '"Times New Roman", Times, serif',
color: '#2c3e50',
borderBottom: '1px solid #bdc3c7',
paddingBottom: 1,
marginBottom: 2,
},
'& p, & li': {
lineHeight: 1.6,
marginBottom: 1,
},
'& ul': {
paddingLeft: 3,
},
}, },
'& ul': { color: {
paddingLeft: 3, primary: '#2c3e50',
secondary: '#34495e',
accent: '#3498db',
text: '#2c3e50',
background: '#ffffff',
}, },
}, },
color: { modern: {
primary: '#2c3e50', name: 'Modern',
secondary: '#34495e', description: 'Clean, minimalist sans-serif layout',
accent: '#3498db', headerStyle: {
text: '#2c3e50', ...defaultStyle,
background: '#ffffff',
},
},
modern: {
name: 'Modern',
description: 'Clean, minimalist sans-serif layout',
headerStyle: {
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
borderLeft: '4px solid #3498db',
paddingLeft: 2,
marginBottom: 3,
backgroundColor: '#f8f9fa',
padding: 2,
borderRadius: 1,
},
footerStyle: {
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
borderLeft: '4px solid #3498db',
backgroundColor: '#f8f9fa',
paddingTop: 2,
borderRadius: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textTransform: 'uppercase',
alignContent: 'center',
fontSize: '0.8rem',
pb: 2,
mb: 2,
},
contentStyle: {
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
lineHeight: 1.5,
color: '#2c3e50',
},
markdownStyle: {
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
'& h1, & h2, & h3': {
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif', fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
color: '#3498db', borderLeft: '4px solid #3498db',
fontWeight: 300, paddingLeft: 2,
marginBottom: 1.5, marginBottom: 3,
backgroundColor: '#f8f9fa',
padding: 2,
borderRadius: 1,
}, },
'& h1': { footerStyle: {
fontSize: '1.75rem', fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
borderLeft: '4px solid #3498db',
backgroundColor: '#f8f9fa',
paddingTop: 2,
borderRadius: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textTransform: 'uppercase',
alignContent: 'center',
fontSize: '0.8rem',
pb: 2,
mb: 2,
}, },
'& h2': { contentStyle: {
fontSize: '1.5rem', fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
},
'& h3': {
fontSize: '1.25rem',
},
'& p, & li': {
lineHeight: 1.5, lineHeight: 1.5,
marginBottom: 0.75, color: '#2c3e50',
}, },
'& ul': { markdownStyle: {
paddingLeft: 2.5, fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
'& h1, & h2, & h3': {
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
color: '#3498db',
fontWeight: 300,
marginBottom: 1.5,
},
'& h1': {
fontSize: '1.75rem',
},
'& h2': {
fontSize: '1.5rem',
},
'& h3': {
fontSize: '1.25rem',
},
'& p, & li': {
lineHeight: 1.5,
marginBottom: 0.75,
},
'& ul': {
paddingLeft: 2.5,
},
},
color: {
primary: '#3498db',
secondary: '#2c3e50',
accent: '#e74c3c',
text: '#2c3e50',
background: '#ffffff',
}, },
}, },
color: { creative: {
primary: '#3498db', name: 'Creative',
secondary: '#2c3e50', description: 'Colorful, unique design with personality',
accent: '#e74c3c', headerStyle: {
text: '#2c3e50', ...defaultStyle,
background: '#ffffff',
},
},
creative: {
name: 'Creative',
description: 'Colorful, unique design with personality',
headerStyle: {
fontFamily: '"Montserrat", "Helvetica Neue", Arial, sans-serif',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: '#ffffff',
padding: 2.5,
borderRadius: 1.5,
marginBottom: 3,
},
footerStyle: {
fontFamily: '"Montserrat", "Helvetica Neue", Arial, sans-serif',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: '#ffffff',
paddingTop: 2,
borderRadius: 1.5,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textTransform: 'uppercase',
alignContent: 'center',
fontSize: '0.8rem',
pb: 2,
mb: 2,
},
contentStyle: {
fontFamily: '"Open Sans", Arial, sans-serif',
lineHeight: 1.6,
color: '#444444',
},
markdownStyle: {
fontFamily: '"Open Sans", Arial, sans-serif',
'& h1, & h2, & h3': {
fontFamily: '"Montserrat", "Helvetica Neue", Arial, sans-serif', fontFamily: '"Montserrat", "Helvetica Neue", Arial, sans-serif',
color: '#667eea', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
fontWeight: 600, color: '#ffffff',
marginBottom: 2, padding: 2.5,
borderRadius: 1.5,
marginBottom: 3,
}, },
'& h1': { footerStyle: {
fontSize: '1.5rem', fontFamily: '"Montserrat", "Helvetica Neue", Arial, sans-serif',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: '#ffffff',
paddingTop: 2,
borderRadius: 1.5,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textTransform: 'uppercase',
alignContent: 'center',
fontSize: '0.8rem',
pb: 2,
mb: 2,
}, },
'& h2': { contentStyle: {
fontSize: '1.25rem', fontFamily: '"Open Sans", Arial, sans-serif',
},
'& h3': {
fontSize: '1.1rem',
},
'& p, & li': {
lineHeight: 1.6, lineHeight: 1.6,
marginBottom: 1,
color: '#444444', color: '#444444',
}, },
'& strong': { markdownStyle: {
color: '#764ba2', fontFamily: '"Open Sans", Arial, sans-serif',
fontWeight: 600, '& h1, & h2, & h3': {
fontFamily: '"Montserrat", "Helvetica Neue", Arial, sans-serif',
color: '#667eea',
fontWeight: 600,
marginBottom: 2,
},
'& h1': {
fontSize: '1.5rem',
},
'& h2': {
fontSize: '1.25rem',
},
'& h3': {
fontSize: '1.1rem',
},
'& p, & li': {
lineHeight: 1.6,
marginBottom: 1,
color: '#444444',
},
'& strong': {
color: '#764ba2',
fontWeight: 600,
},
'& ul': {
paddingLeft: 3,
},
}, },
'& ul': { color: {
paddingLeft: 3, primary: '#667eea',
secondary: '#764ba2',
accent: '#f093fb',
text: '#444444',
background: '#ffffff',
}, },
}, },
color: { corporate: {
primary: '#667eea', name: 'Corporate',
secondary: '#764ba2', description: 'Formal, structured business format',
accent: '#f093fb', headerStyle: {
text: '#444444', ...defaultStyle,
background: '#ffffff',
},
},
corporate: {
name: 'Corporate',
description: 'Formal, structured business format',
headerStyle: {
fontFamily: '"Arial", sans-serif',
border: '2px solid #34495e',
padding: 2.5,
marginBottom: 3,
backgroundColor: '#ecf0f1',
},
footerStyle: {
fontFamily: '"Arial", sans-serif',
border: '2px solid #34495e',
backgroundColor: '#ecf0f1',
paddingTop: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textTransform: 'uppercase',
alignContent: 'center',
fontSize: '0.8rem',
pb: 2,
mb: 2,
},
contentStyle: {
fontFamily: '"Arial", sans-serif',
lineHeight: 1.4,
color: '#2c3e50',
},
markdownStyle: {
fontFamily: '"Arial", sans-serif',
'& h1, & h2, & h3': {
fontFamily: '"Arial", sans-serif', fontFamily: '"Arial", sans-serif',
color: '#34495e', border: '2px solid #34495e',
fontWeight: 'bold', padding: 2.5,
marginBottom: 3,
backgroundColor: '#ecf0f1',
},
footerStyle: {
fontFamily: '"Arial", sans-serif',
border: '2px solid #34495e',
backgroundColor: '#ecf0f1',
paddingTop: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textTransform: 'uppercase', textTransform: 'uppercase',
fontSize: '0.875rem', alignContent: 'center',
letterSpacing: '1px', fontSize: '0.8rem',
marginBottom: 1.5, pb: 2,
borderBottom: '1px solid #bdc3c7', mb: 2,
paddingBottom: 0.5,
}, },
'& h1': { contentStyle: {
fontSize: '1rem', fontFamily: '"Arial", sans-serif',
},
'& h2': {
fontSize: '0.875rem',
},
'& h3': {
fontSize: '0.75rem',
},
'& p, & li': {
lineHeight: 1.4, lineHeight: 1.4,
marginBottom: 0.75, color: '#2c3e50',
fontSize: '0.75rem',
}, },
'& ul': { markdownStyle: {
paddingLeft: 2, fontFamily: '"Arial", sans-serif',
'& h1, & h2, & h3': {
fontFamily: '"Arial", sans-serif',
color: '#34495e',
fontWeight: 'bold',
textTransform: 'uppercase',
fontSize: '0.875rem',
letterSpacing: '1px',
marginBottom: 1.5,
borderBottom: '1px solid #bdc3c7',
paddingBottom: 0.5,
},
'& h1': {
fontSize: '1rem',
},
'& h2': {
fontSize: '0.875rem',
},
'& h3': {
fontSize: '0.75rem',
},
'& p, & li': {
lineHeight: 1.4,
marginBottom: 0.75,
fontSize: '0.75rem',
},
'& ul': {
paddingLeft: 2,
},
},
color: {
primary: '#34495e',
secondary: '#2c3e50',
accent: '#95a5a6',
text: '#2c3e50',
background: '#ffffff',
}, },
}, },
color: { };
primary: '#34495e',
secondary: '#2c3e50',
accent: '#95a5a6',
text: '#2c3e50',
background: '#ffffff',
},
},
}; };
const resumeStyles: Record<string, ResumeStyle> = generateResumeStyles();
// Styled Header Component // Styled Header Component
interface BackstoryStyledResumeProps { interface BackstoryStyledResumeProps {
@ -400,102 +409,104 @@ const StyledHeader: React.FC<BackstoryStyledResumeProps> = ({ candidate, style }
const phone = parsePhoneNumberFromString(candidate.phone || '', 'US'); const phone = parsePhoneNumberFromString(candidate.phone || '', 'US');
return ( return (
<Box className="BackstoryResumeHeader" sx={style.headerStyle}> <Box className="BackstoryResumeHeader" sx={style.headerStyle}>
<Typography <Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
variant="h4"
sx={{
fontWeight: 'bold',
mb: 1,
color: style.name === 'creative' ? '#ffffff' : style.color.primary,
fontFamily: 'inherit',
}}
>
{candidate.fullName}
</Typography>
{/* {candidate.title && (
<Typography <Typography
variant="h6" variant="h4"
sx={{ sx={{
mb: 2, fontWeight: 'bold',
fontWeight: 300, mb: 1,
color: style.name === 'creative' ? '#ffffff' : style.color.secondary, color: style.name === 'creative' ? '#ffffff' : style.color.primary,
fontFamily: 'inherit', fontFamily: 'inherit',
}} }}
> >
{candidate.title} {candidate.fullName}
</Typography> </Typography>
)} */}
{candidate.description && (
<Typography
variant="h6"
sx={{
mb: 2,
fontWeight: 300,
color: style.name === 'creative' ? '#ffffff' : style.color.secondary,
fontFamily: 'inherit',
fontSize: '0.8rem !important',
}}
>
{candidate.description}
</Typography>
)}
</Box>
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'column',
justifyContent: 'space-between',
flexWrap: 'wrap', flexWrap: 'wrap',
alignContent: 'center', alignContent: 'center',
flexGrow: 1,
minWidth: 'fit-content',
gap: 1,
}} }}
> >
{candidate.email && ( {candidate.email && (
<Grid size={{ xs: 12, sm: 6, md: 3 }}> <Box sx={{ display: 'flex', alignItems: 'center', m: 0, p: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <EmailIcon
<EmailIcon fontSize="small"
fontSize="small" sx={{ mr: 1, color: style.name === 'creative' ? '#ffffff' : style.color.accent }}
sx={{ color: style.name === 'creative' ? '#ffffff' : style.color.accent }} />
/> <Typography
<Typography variant="body2"
variant="body2" sx={{
sx={{ color: style.name === 'creative' ? '#ffffff' : style.color.text,
color: style.name === 'creative' ? '#ffffff' : style.color.text, fontFamily: 'inherit',
fontFamily: 'inherit', }}
}} >
> {candidate.email}
{candidate.email} </Typography>
</Typography> </Box>
</Box>
</Grid>
)} )}
{phone?.isValid() && ( {phone?.isValid() && (
<Grid size={{ xs: 12, sm: 6, md: 3 }}> <Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <PhoneIcon
<PhoneIcon fontSize="small"
fontSize="small" sx={{ mr: 1, color: style.name === 'creative' ? '#ffffff' : style.color.accent }}
sx={{ color: style.name === 'creative' ? '#ffffff' : style.color.accent }} />
/> <Typography
<Typography variant="body2"
variant="body2" sx={{
sx={{ color: style.name === 'creative' ? '#ffffff' : style.color.text,
color: style.name === 'creative' ? '#ffffff' : style.color.text, fontFamily: 'inherit',
fontFamily: 'inherit', }}
}} >
> {phone.formatInternational()}
{phone.formatInternational()} </Typography>
</Typography> </Box>
</Box>
</Grid>
)} )}
{candidate.location && ( {candidate.location && (
<Grid size={{ xs: 12, sm: 6, md: 3 }}> <Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <LocationIcon
<LocationIcon fontSize="small"
fontSize="small" sx={{ mr: 1, color: style.name === 'creative' ? '#ffffff' : style.color.accent }}
sx={{ color: style.name === 'creative' ? '#ffffff' : style.color.accent }} />
/> <Typography
<Typography variant="body2"
variant="body2" sx={{
sx={{ color: style.name === 'creative' ? '#ffffff' : style.color.text,
color: style.name === 'creative' ? '#ffffff' : style.color.text, fontFamily: 'inherit',
fontFamily: 'inherit', }}
}} >
> {candidate.location.city
{candidate.location.city}, {candidate.location.state} ? `${candidate.location.city}, ${candidate.location.state}`
</Typography> : candidate.location.text}
</Box> </Typography>
</Grid> </Box>
)} )}
</Box>
{/* {(candidate.website || candidate.linkedin) && ( {/* {(candidate.website || candidate.linkedin) && (
<Grid size={{ xs: 12, sm: 6, md: 3 }}> <Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<WebsiteIcon <WebsiteIcon
@ -514,7 +525,6 @@ const StyledHeader: React.FC<BackstoryStyledResumeProps> = ({ candidate, style }
</Box> </Box>
</Grid> </Grid>
)} */} )} */}
</Box>
</Box> </Box>
); );
}; };
@ -535,6 +545,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
const [editPrompt, setEditPrompt] = useState<string>(''); const [editPrompt, setEditPrompt] = useState<string>('');
const [saving, setSaving] = useState<boolean>(false); const [saving, setSaving] = useState<boolean>(false);
const [tabValue, setTabValue] = useState('markdown'); const [tabValue, setTabValue] = useState('markdown');
const [jobTabValue, setJobTabValue] = useState('chat');
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [statusType, setStatusType] = useState<Types.ApiActivityType | null>(null); const [statusType, setStatusType] = useState<Types.ApiActivityType | null>(null);
const [error, setError] = useState<Types.ChatMessageError | null>(null); const [error, setError] = useState<Types.ChatMessageError | null>(null);
@ -677,6 +688,10 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
setTabValue(newValue); setTabValue(newValue);
}; };
const handleJobTabChange = (event: React.SyntheticEvent, newValue: string): void => {
setJobTabValue(newValue);
};
return ( return (
<Box <Box
sx={{ sx={{
@ -716,64 +731,61 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
mb: 2, mb: 2,
}} }}
> >
<Grid container spacing={2}> <Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
<Grid size={{ xs: 12, md: 6 }}> {activeResume.candidate && (
<Stack spacing={1}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{activeResume.candidate && ( <PersonIcon color="primary" fontSize="small" />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Typography variant="subtitle2" fontWeight="bold">
<PersonIcon color="primary" fontSize="small" /> Candidate
<Typography variant="subtitle2" fontWeight="bold">
Candidate
</Typography>
</Box>
)}
<Typography variant="body2" color="text.secondary">
{activeResume.candidate?.fullName || activeResume.candidateId}
</Typography> </Typography>
</Box>
)}
<Typography variant="body2" color="text.secondary">
{activeResume.candidate?.fullName || activeResume.candidateId}
</Typography>
{activeResume.job && ( {activeResume.job && (
<> <>
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 1, gap: 1,
mt: 1, mt: 1,
}} }}
> >
<WorkIcon color="primary" fontSize="small" /> <WorkIcon color="primary" fontSize="small" />
<Typography variant="subtitle2" fontWeight="bold">
Job
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
{activeResume.job.title} at {activeResume.job.company}
</Typography>
</>
)}
</Stack>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Stack spacing={1}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ScheduleIcon color="primary" fontSize="small" />
<Typography variant="subtitle2" fontWeight="bold"> <Typography variant="subtitle2" fontWeight="bold">
Timeline Job
</Typography> </Typography>
</Box> </Box>
<Typography variant="caption" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Created: {formatDate(activeResume.createdAt)} {activeResume.job.title} at {activeResume.job.company}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> </>
Updated: {formatDate(activeResume.updatedAt)} )}
</Typography> </Box>
<Typography variant="caption" color="text.secondary">
Resume ID: {activeResume.id} <Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
</Typography> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
</Stack> <ScheduleIcon color="primary" fontSize="small" />
</Grid> <Typography variant="subtitle2" fontWeight="bold">
</Grid> Timeline
</Typography>
</Box>
<Typography variant="caption" color="text.secondary">
Created: {formatDate(activeResume.createdAt)}
</Typography>
<Typography variant="caption" color="text.secondary">
Updated: {formatDate(activeResume.updatedAt)}
</Typography>
<Typography variant="caption" color="text.secondary">
Job ID: {activeResume.job?.id}
</Typography>
<Typography variant="caption" color="text.secondary">
Resume ID: {activeResume.id}
</Typography>
</Box>
</Box> </Box>
<Divider sx={{ mb: 2 }} /> <Divider sx={{ mb: 2 }} />
@ -793,35 +805,13 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
</Tooltip> </Tooltip>
} }
/> />
<CardContent sx={{ p: 0 }}> {variant === 'all' && activeResume.resume && (
<Box sx={{ position: 'relative' }}> <CardContent sx={{ p: 0 }}>
<Scrollable sx={{ maxHeight: '10rem', overflowY: 'auto' }}> <StyledMarkdown content={activeResume.resume} />
<Box </CardContent>
sx={{ )}
display: 'flex',
lineHeight: 1.6,
fontSize: '0.875rem !important',
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
backgroundColor: theme.palette.action.hover,
p: 2,
borderRadius: 1,
border: `1px solid ${theme.palette.divider}`,
}}
>
{activeResume.resume}
</Box>
</Scrollable>
</Box>
</CardContent>
</Card> </Card>
)} )}
{variant === 'all' && activeResume.resume && (
<Box sx={{ mt: 2 }}>
<StyledMarkdown content={activeResume.resume} />
</Box>
)}
</Box> </Box>
{/* Admin Controls */} {/* Admin Controls */}
@ -1142,19 +1132,42 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
position: 'relative', position: 'relative',
backgroundColor: '#f8f0e0', // backgroundColor: '#f8f0e0',
}} }}
> >
{activeResume.job !== undefined && ( <Tabs value={jobTabValue} onChange={handleJobTabChange}>
{activeResume.job !== undefined && (
<Tab value="job" icon={<WorkIcon />} label="Job" />
)}
<Tab value="chat" icon={<ModelTraining />} label="AI Edit" />
</Tabs>
{activeResume.job !== undefined && jobTabValue === 'job' && (
<JobInfo <JobInfo
variant={'all'} variant={'all'}
job={activeResume.job} job={activeResume.job}
sx={{ sx={{
mt: 2, m: 0,
p: 1,
backgroundColor: '#f8f0e0', backgroundColor: '#f8f0e0',
}} }}
/> />
)} )}
{jobTabValue === 'chat' && (
<ResumeChat
session={activeResume.id || ''}
resume={editContent}
onResumeChange={(newResume: string): void => {
setEditContent(newResume);
setActiveResume({ ...activeResume, resume: newResume });
}}
sx={{
m: 1,
p: 1,
flexGrow: 1,
}}
/>
)}
</Paper> </Paper>
</Scrollable> </Scrollable>
</Box> </Box>

View File

@ -394,18 +394,6 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
</Box> </Box>
</TableCell> </TableCell>
)} )}
<TableCell
sx={{
py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1,
width: isMobile ? '30%' : 'auto',
backgroundColor: 'background.paper',
}}
>
<Typography variant="caption" fontWeight="bold" noWrap>
ID
</Typography>
</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
@ -494,22 +482,6 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
)} )}
</TableCell> </TableCell>
)} )}
<TableCell
sx={{
py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1,
overflow: 'hidden',
}}
>
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{ fontSize: isMobile ? '0.65rem' : '0.7rem' }}
>
{resume.id}
</Typography>
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

View File

@ -1,5 +1,5 @@
import React, { forwardRef, useState, useEffect, useRef, JSX } from 'react'; import React, { forwardRef, useState, useEffect, useRef, JSX } from 'react';
import { Box, Paper, Button, Tooltip } from '@mui/material'; import { Box, Paper, Button, Tooltip, SxProps } from '@mui/material';
import { Send as SendIcon } from '@mui/icons-material'; import { Send as SendIcon } from '@mui/icons-material';
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from 'hooks/AuthContext';
import { import {
@ -13,7 +13,6 @@ import {
CandidateQuestion, CandidateQuestion,
} from 'types/types'; } from 'types/types';
import { ConversationHandle } from 'components/Conversation'; import { ConversationHandle } from 'components/Conversation';
import { BackstoryPageProps } from 'components/BackstoryTab';
import { Message } from 'components/Message'; import { Message } from 'components/Message';
import { DeleteConfirmation } from 'components/DeleteConfirmation'; import { DeleteConfirmation } from 'components/DeleteConfirmation';
import { CandidateInfo } from 'components/ui/CandidateInfo'; import { CandidateInfo } from 'components/ui/CandidateInfo';
@ -50,8 +49,13 @@ const defaultMessage: ChatMessage = {
metadata: emptyMetadata, metadata: emptyMetadata,
}; };
const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>( interface CandidateChatPageProps {
(_props: BackstoryPageProps, ref): JSX.Element => { sx?: SxProps; // Optional styles for the component
}
const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>(
(props: CandidateChatPageProps, ref): JSX.Element => {
const { sx } = props;
const { apiClient } = useAuth(); const { apiClient } = useAuth();
const { selectedCandidate } = useSelectedCandidate(); const { selectedCandidate } = useSelectedCandidate();
const [processingMessage, setProcessingMessage] = useState< const [processingMessage, setProcessingMessage] = useState<
@ -240,6 +244,7 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>(
flexShrink: 0 /* Prevent shrinking */, flexShrink: 0 /* Prevent shrinking */,
}, },
position: 'relative', position: 'relative',
...sx,
}} }}
> >
<Paper elevation={2} sx={{ m: 1, p: 1 }}> <Paper elevation={2} sx={{ m: 1, p: 1 }}>

View File

@ -491,7 +491,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
}} }}
> >
<Box sx={{ mb: 1, justifyContent: 'center' }}> <Box sx={{ mb: 1, justifyContent: 'center' }}>
Candidate{!isMobile && 'Selection'} Candidate{!isMobile && ' Selection'}
</Box> </Box>
{selectedCandidate !== null && !isMobile && ( {selectedCandidate !== null && !isMobile && (
<Box <Box
@ -557,7 +557,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
}, },
}} }}
> >
<Box sx={{ mb: 1, justifyContent: 'center' }}>Job{!isMobile && 'Selection'}</Box> <Box sx={{ mb: 1, justifyContent: 'center' }}>Job{!isMobile && ' Selection'}</Box>
{selectedJob !== null && !isMobile && ( {selectedJob !== null && !isMobile && (
<Box <Box
sx={{ sx={{

View File

@ -2,9 +2,7 @@ import React, { useState } from 'react';
import { import {
SxProps, SxProps,
Dialog, Dialog,
DialogActions,
DialogContent, DialogContent,
DialogContentText,
DialogTitle, DialogTitle,
useTheme, useTheme,
useMediaQuery, useMediaQuery,

View File

@ -141,7 +141,7 @@ const CandidateProfile: React.FC = () => {
isCurrent: false, isCurrent: false,
description: '', description: '',
skills: [], skills: [],
location: { city: '', country: '' }, location: { text: '' },
}); });
useEffect(() => { useEffect(() => {
@ -176,7 +176,7 @@ const CandidateProfile: React.FC = () => {
}; };
// Handle form input changes // Handle form input changes
const handleInputChange = (field: string, value: boolean | string): void => { const handleInputChange = (field: string, value: boolean | string | Types.Location): void => {
setFormData({ setFormData({
...formData, ...formData,
[field]: value, [field]: value,
@ -301,7 +301,7 @@ const CandidateProfile: React.FC = () => {
isCurrent: false, isCurrent: false,
description: '', description: '',
skills: [], skills: [],
location: { city: '', country: '' }, location: { text: '' },
}); });
setExperienceDialog(false); setExperienceDialog(false);
setSnack('Experience added successfully!'); setSnack('Experience added successfully!');
@ -494,13 +494,34 @@ const CandidateProfile: React.FC = () => {
</Box> </Box>
<Box className="entry"> <Box className="entry">
<Box className="title"> {editMode.basic ? (
<LocationOn sx={{ mr: 1, verticalAlign: 'middle' }} /> <TextField
Location fullWidth
</Box> multiline
<Box className="value"> rows={3}
{candidate.location?.city || 'Not specified'} {candidate.location?.country || ''} label="Location"
</Box> value={formData.location?.text || ''}
onChange={(e): void => {
setFormData({
...formData,
location: { ...formData.location, text: e.target.value },
});
}}
variant="outlined"
/>
) : (
<>
<Box className="title">
<LocationOn sx={{ mr: 1, verticalAlign: 'middle' }} />
Location
</Box>
<Box className="value">
{candidate.location?.city
? `${candidate.location?.city} ${candidate.location?.country}`
: candidate.location?.text || 'Not provided'}
</Box>
</>
)}
</Box> </Box>
<Box className="entry"> <Box className="entry">

View File

@ -1,6 +1,6 @@
// Generated TypeScript types from Pydantic models // Generated TypeScript types from Pydantic models
// Source: src/backend/models.py // Source: src/backend/models.py
// Generated on: 2025-07-11T20:02:47.037054 // Generated on: 2025-07-15T16:43:21.492940
// DO NOT EDIT MANUALLY - This file is auto-generated // DO NOT EDIT MANUALLY - This file is auto-generated
// ============================ // ============================
@ -19,7 +19,7 @@ export type ApiStatusType = "streaming" | "status" | "done" | "error";
export type ApplicationStatus = "applied" | "reviewing" | "interview" | "offer" | "rejected" | "accepted" | "withdrawn"; export type ApplicationStatus = "applied" | "reviewing" | "interview" | "offer" | "rejected" | "accepted" | "withdrawn";
export type ChatContextType = "job_search" | "job_requirements" | "candidate_chat" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile" | "generate_resume" | "generate_image" | "rag_search" | "skill_match"; export type ChatContextType = "job_search" | "job_requirements" | "candidate_chat" | "interview_prep" | "resume_review" | "edit_resume" | "general" | "generate_persona" | "generate_profile" | "generate_resume" | "generate_image" | "rag_search" | "skill_match";
export type ChatSenderType = "user" | "assistant" | "system" | "information" | "warning" | "error"; export type ChatSenderType = "user" | "assistant" | "system" | "information" | "warning" | "error";
@ -284,7 +284,7 @@ export interface Certification {
} }
export interface ChatContext { export interface ChatContext {
type: "job_search" | "job_requirements" | "candidate_chat" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile" | "generate_resume" | "generate_image" | "rag_search" | "skill_match"; type: "job_search" | "job_requirements" | "candidate_chat" | "interview_prep" | "resume_review" | "edit_resume" | "general" | "generate_persona" | "generate_profile" | "generate_resume" | "generate_image" | "rag_search" | "skill_match";
relatedEntityId?: string; relatedEntityId?: string;
relatedEntityType?: "job" | "candidate" | "employer"; relatedEntityType?: "job" | "candidate" | "employer";
additionalContext?: Record<string, any>; additionalContext?: Record<string, any>;
@ -299,6 +299,7 @@ export interface ChatMessage {
timestamp?: Date; timestamp?: Date;
role: "user" | "assistant" | "system" | "information" | "warning" | "error"; role: "user" | "assistant" | "system" | "information" | "warning" | "error";
content: string; content: string;
extraContext?: Record<string, any>;
tunables?: Tunables; tunables?: Tunables;
metadata: ChatMessageMetaData; metadata: ChatMessageMetaData;
} }
@ -349,6 +350,7 @@ export interface ChatMessageResume {
timestamp?: Date; timestamp?: Date;
role: "user" | "assistant" | "system" | "information" | "warning" | "error"; role: "user" | "assistant" | "system" | "information" | "warning" | "error";
content: string; content: string;
extraContext?: Record<string, any>;
tunables?: Tunables; tunables?: Tunables;
metadata: ChatMessageMetaData; metadata: ChatMessageMetaData;
resume: Resume; resume: Resume;
@ -363,6 +365,7 @@ export interface ChatMessageSkillAssessment {
timestamp?: Date; timestamp?: Date;
role: "user" | "assistant" | "system" | "information" | "warning" | "error"; role: "user" | "assistant" | "system" | "information" | "warning" | "error";
content: string; content: string;
extraContext?: Record<string, any>;
tunables?: Tunables; tunables?: Tunables;
metadata: ChatMessageMetaData; metadata: ChatMessageMetaData;
skillAssessment: SkillAssessment; skillAssessment: SkillAssessment;
@ -398,6 +401,7 @@ export interface ChatMessageUser {
timestamp?: Date; timestamp?: Date;
role: "user" | "assistant" | "system" | "information" | "warning" | "error"; role: "user" | "assistant" | "system" | "information" | "warning" | "error";
content: string; content: string;
extraContext?: Record<string, any>;
tunables?: Tunables; tunables?: Tunables;
} }
@ -808,9 +812,10 @@ export interface Language {
} }
export interface Location { export interface Location {
city: string; text: string;
city?: string;
state?: string; state?: string;
country: string; country?: string;
postalCode?: string; postalCode?: string;
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
@ -1004,6 +1009,7 @@ export interface ResumeMessage {
timestamp?: Date; timestamp?: Date;
role: "user" | "assistant" | "system" | "information" | "warning" | "error"; role: "user" | "assistant" | "system" | "information" | "warning" | "error";
content: string; content: string;
extraContext?: Record<string, any>;
tunables?: Tunables; tunables?: Tunables;
resume: Resume; resume: Resume;
} }
@ -1094,6 +1100,9 @@ export interface Tunables {
enableRAG: boolean; enableRAG: boolean;
enableTools: boolean; enableTools: boolean;
enableContext: boolean; enableContext: boolean;
temperature: number;
topK: number;
threshold?: number;
} }
export interface UsageStats { export interface UsageStats {

View File

@ -644,6 +644,7 @@ Content: {content}
session_id: str, session_id: str,
prompt: str, prompt: str,
database: RedisDatabase, database: RedisDatabase,
extra_context: Optional[dict[str, str | int | float | bool]] = None,
tunables: Optional[Tunables] = None, tunables: Optional[Tunables] = None,
temperature=0.7, temperature=0.7,
) -> AsyncGenerator[ApiMessage, None]: ) -> AsyncGenerator[ApiMessage, None]:
@ -692,6 +693,10 @@ Content: {content}
rag_message = message rag_message = message
context = self.get_rag_context(rag_message) context = self.get_rag_context(rag_message)
if extra_context:
# Add extra context to the messages if provided
context = f"{context}\n\n".join(f"<{key}>\n{value}</{key}>" for key, value in extra_context.items())
# Add the RAG context to the messages if available # Add the RAG context to the messages if available
if context: if context:
messages.append( messages.append(

View File

@ -1,14 +1,13 @@
from __future__ import annotations from __future__ import annotations
from typing import Literal, AsyncGenerator, ClassVar, Optional, Any from typing import Literal, AsyncGenerator, ClassVar, Optional, Any
from pydantic import Field
from database.core import RedisDatabase from database.core import RedisDatabase
from .base import Agent, agent_registry from .base import Agent, agent_registry
from logger import logger from logger import logger
from models import ApiMessage, Tunables, ApiStatusType, LLMMessage from models import ApiMessage, Tunables, ApiStatusType
system_message = """ system_message = """
@ -34,7 +33,6 @@ class CandidateChat(Agent):
_agent_type: ClassVar[str] = agent_type # Add this for registration _agent_type: ClassVar[str] = agent_type # Add this for registration
system_prompt: str = system_message system_prompt: str = system_message
sessions: dict[str, list[LLMMessage]] = Field(default_factory=dict)
async def generate( async def generate(
self, self,
@ -43,6 +41,7 @@ class CandidateChat(Agent):
session_id: str, session_id: str,
prompt: str, prompt: str,
database: RedisDatabase, database: RedisDatabase,
extra_context: Optional[dict[str, str | int | float | bool]] = None,
tunables: Optional[Tunables] = None, tunables: Optional[Tunables] = None,
temperature=0.7, temperature=0.7,
) -> AsyncGenerator[ApiMessage, None]: ) -> AsyncGenerator[ApiMessage, None]:
@ -62,9 +61,6 @@ Use that spelling instead of any spelling you may find in the <|context|>.
{system_message} {system_message}
""" """
if session_id not in self.sessions:
self.sessions[session_id] = [LLMMessage(role="user", content=prompt)]
async for message in super().generate( async for message in super().generate(
llm=llm, llm=llm,
model=model, model=model,

View File

@ -0,0 +1,255 @@
from __future__ import annotations
import time
from typing import List, Literal, AsyncGenerator, ClassVar, Optional, Any
from database.core import RedisDatabase
from .base import Agent, agent_registry
from logger import logger
from models import (
ApiActivityType,
ApiMessage,
ChatMessage,
ChatMessageError,
ChatMessageMetaData,
ChatMessageRagSearch,
ChatMessageStatus,
ChatMessageStreaming,
ChatMessageUser,
ChatOptions,
Tunables,
ApiStatusType,
UsageStats,
LLMMessage,
)
class EditResume(Agent):
"""
EditResume Agent
"""
agent_type: Literal["edit_resume"] = "edit_resume" # type: ignore
_agent_type: ClassVar[str] = agent_type # Add this for registration
async def edit_resume(
self,
llm: Any,
model: str,
session_id: str,
prompt: str,
database: RedisDatabase,
extra_context: Optional[dict[str, str | int | float | bool]] = None,
tunables: Optional[Tunables] = None,
temperature=0.2,
):
if not self.user:
error_message = ChatMessageError(session_id=session_id, content="No user set for chat generation.")
yield error_message
return
user_message = ChatMessageUser(
session_id=session_id,
content=prompt,
)
await database.add_chat_message(session_id, user_message.model_dump())
logger.info(f"💬 User message saved to database for session {session_id}")
# Create a pruned down message list based purely on the prompt and responses,
# discarding the full preamble generated by prepare_message
messages: List[LLMMessage] = [LLMMessage(role="system", content=self.system_prompt)]
# Add the conversation history to the messages
messages.extend(
[
LLMMessage(role=m["role"], content=m["content"])
for m in await database.get_recent_chat_messages(session_id=session_id)
]
)
self.user.metrics.generate_count.labels(agent=self.agent_type).inc()
with self.user.metrics.generate_duration.labels(agent=self.agent_type).time():
context = None
rag_message: ChatMessageRagSearch | None = None
if self.user:
logger.info("Generating resume enhanced RAG results")
rag_prompt = ""
if extra_context:
# Add extra context to the messages if provided
rag_prompt = f"{context}\n\n".join(f"<{key}>\n{value}</{key}>" for key, value in extra_context.items())
rag_prompt += f"\n\nPrompt to respond to:\n{prompt}\n"
else:
rag_prompt = prompt
message = None
async for message in self.generate_rag_results(session_id=session_id, prompt=rag_prompt, top_k=10):
if message.status == ApiStatusType.ERROR:
yield message
return
# Only yield messages that are in a streaming state
if message.status == ApiStatusType.STATUS:
yield message
if not isinstance(message, ChatMessageRagSearch):
raise ValueError(f"Expected ChatMessageRagSearch, got {type(rag_message)}")
rag_message = message
context = self.get_rag_context(rag_message)
if extra_context:
# Add extra context to the messages if provided
context = f"{context}\n\n".join(f"<{key}>\n{value}</{key}>" for key, value in extra_context.items())
# Add the RAG context to the messages if available
if context:
messages.append(
LLMMessage(
role="user",
content=f"<|context|>\nThe following is context information about {self.user.full_name}:\n{context}\n</|context|>\n\nPrompt to respond to:\n{prompt}\n",
)
)
else:
# Only the actual user query is provided with the full context message
messages.append(LLMMessage(role="user", content=prompt))
# not use_tools
status_message = ChatMessageStatus(
session_id=session_id, activity=ApiActivityType.GENERATING, content="Generating response..."
)
yield status_message
# Set the response for streaming
self.set_optimal_context_size(llm, model, prompt=prompt)
options = ChatOptions(
seed=8911,
num_ctx=self.context_size,
temperature=temperature,
)
logger.info(f"Message options: {options.model_dump(exclude_unset=True)} with {len(messages)} messages")
content = ""
start_time = time.perf_counter()
response = None
async for response in llm.chat_stream(
model=model,
messages=messages,
options={
**options.model_dump(exclude_unset=True),
},
stream=True,
):
if not response:
error_message = ChatMessageError(session_id=session_id, content="No response from LLM.")
yield error_message
return
content += response.content
if not response.finish_reason:
streaming_message = ChatMessageStreaming(
session_id=session_id,
content=response.content,
)
yield streaming_message
if not response:
error_message = ChatMessageError(session_id=session_id, content="No response from LLM.")
yield error_message
return
self.user.collect_metrics(agent=self, response=response)
end_time = time.perf_counter()
chat_message = ChatMessage(
session_id=session_id,
tunables=tunables,
status=ApiStatusType.DONE,
content=content,
metadata=ChatMessageMetaData(
options=options,
usage=UsageStats(
eval_count=response.usage.eval_count,
eval_duration=response.usage.eval_duration,
prompt_eval_count=response.usage.prompt_eval_count,
prompt_eval_duration=response.usage.prompt_eval_duration,
),
rag_results=rag_message.content if rag_message else [],
llm_history=messages,
timers={
"llm_streamed": end_time - start_time,
"llm_with_tools": 0, # Placeholder for tool processing time
},
),
)
await database.add_chat_message(session_id, chat_message.model_dump())
logger.info(f"🤖 Assistent response saved to database for session {session_id}")
# Add the user and chat messages to the conversation
yield chat_message
return
async def generate(
self,
llm: Any,
model: str,
session_id: str,
prompt: str,
database: RedisDatabase,
extra_context: Optional[dict[str, str | int | float | bool]] = None,
tunables: Optional[Tunables] = None,
temperature=0.2,
) -> AsyncGenerator[ApiMessage, None]:
user = self.user
if not user:
logger.error("User is not set for Edit Resume agent.")
raise ValueError("User must be set before generating candidate chat responses.")
self.system_prompt = """
You are a professional copy editor. Your task is to edit and enhance the provided resume content based on the requested edits.
**CRITICAL: NEVER INVENT OR FABRICATE ANY INFORMATION**
- DO NOT create any metrics, percentages, dollar amounts, timeframes, or statistics that are not explicitly stated in the original resume or <|context|>
- DO NOT add quantitative claims like "increased by X%", "reduced by X hours", "saved $X", "improved by X%" unless these exact figures are provided
- DO NOT estimate, approximate, or infer numerical data
**Guidelines:**
- You are provided the current resume content in the <resume> section
- Only make edits that are requested by the user
- Do not add any additional information that is not present in the original resume
- Only add factual information supported by <|context|> or the <resume> content
- DO NOT make assumptions about the candidate's experience or skills
**For impact summaries specifically:**
- Focus on describing actions and responsibilities using strong action verbs
- Emphasize qualitative improvements like "streamlined processes", "enhanced efficiency", "improved workflow" WITHOUT adding invented metrics
- If no specific metrics are provided, describe the nature of the impact in general terms
- Example of ACCEPTABLE impact: "Streamlined resume analysis processes and reduced manual review requirements"
- Example of UNACCEPTABLE impact: "Streamlined resume analysis processes with a 40% reduction in manual review time" (unless that 40% is stated in the original content)
If the user asks a question about the resume, provide a brief answer based only on the content of the resume or the context provided.
If the user did not ask a question, return the entire resume with the requested edits applied while maintaining the current formatting.
"""
logger.info(
f"Generating resume edits for session {session_id} with prompt: {prompt} and context: {extra_context} and tunables: {tunables}"
)
async for message in self.edit_resume(
llm=llm,
model=model,
session_id=session_id,
prompt=prompt,
database=database,
temperature=temperature,
tunables=tunables,
extra_context=extra_context or {},
):
if message.status == ApiStatusType.ERROR:
yield message
return
yield message
# Register the base agent
agent_registry.register(EditResume._agent_type, EditResume)

View File

@ -47,6 +47,7 @@ class ImageGenerator(Agent):
session_id: str, session_id: str,
prompt: str, prompt: str,
database: RedisDatabase, database: RedisDatabase,
extra_context: Optional[dict[str, str | int | float | bool]] = None,
tunables: Optional[Tunables] = None, tunables: Optional[Tunables] = None,
temperature=0.7, temperature=0.7,
) -> AsyncGenerator[ChatMessage | ChatMessageStatus | ChatMessageError | ChatMessageStreaming, None]: ) -> AsyncGenerator[ChatMessage | ChatMessageStatus | ChatMessageError | ChatMessageStreaming, None]:

View File

@ -311,6 +311,7 @@ class GeneratePersona(Agent):
session_id: str, session_id: str,
prompt: str, prompt: str,
database: RedisDatabase, database: RedisDatabase,
extra_context: Optional[dict[str, str | int | float | bool]] = None,
tunables: Optional[Tunables] = None, tunables: Optional[Tunables] = None,
temperature=0.7, temperature=0.7,
) -> AsyncGenerator[ChatMessage | ChatMessageStatus | ChatMessageError | ChatMessageStreaming, None]: ) -> AsyncGenerator[ChatMessage | ChatMessageStatus | ChatMessageError | ChatMessageStreaming, None]:

View File

@ -163,6 +163,7 @@ Avoid vague categorizations and be precise about whether skills are explicitly r
session_id: str, session_id: str,
prompt: str, prompt: str,
database: RedisDatabase, database: RedisDatabase,
extra_context: Optional[dict[str, str | int | float | bool]] = None,
tunables: Optional[Tunables] = None, tunables: Optional[Tunables] = None,
temperature=0.7, temperature=0.7,
) -> AsyncGenerator[ApiMessage, None]: ) -> AsyncGenerator[ApiMessage, None]:

View File

@ -24,6 +24,7 @@ class RagSearchChat(Agent):
session_id: str, session_id: str,
prompt: str, prompt: str,
database: RedisDatabase, database: RedisDatabase,
extra_context: Optional[dict[str, str | int | float | bool]] = None,
tunables: Optional[Tunables] = None, tunables: Optional[Tunables] = None,
temperature=0.7, temperature=0.7,
) -> AsyncGenerator[ApiMessage, None]: ) -> AsyncGenerator[ApiMessage, None]:

View File

@ -116,6 +116,7 @@ JSON RESPONSE:"""
session_id: str, session_id: str,
prompt: str, prompt: str,
database: RedisDatabase, database: RedisDatabase,
extra_context: Optional[dict[str, str | int | float | bool]] = None,
tunables: Optional[Tunables] = None, tunables: Optional[Tunables] = None,
temperature=0.7, temperature=0.7,
) -> AsyncGenerator[ApiMessage, None]: ) -> AsyncGenerator[ApiMessage, None]:

View File

@ -14,7 +14,7 @@ def test_model_creation():
print("🧪 Testing model creation...") print("🧪 Testing model creation...")
# Create supporting objects # Create supporting objects
location = Location(city="Austin", country="USA") location = Location(text="Austin, TX USA")
skill = Skill(name="Python", category="Programming", level=SkillLevel.ADVANCED) skill = Skill(name="Python", category="Programming", level=SkillLevel.ADVANCED)
# Create candidate # Create candidate

View File

@ -186,6 +186,7 @@ class ChatContextType(str, Enum):
CANDIDATE_CHAT = "candidate_chat" CANDIDATE_CHAT = "candidate_chat"
INTERVIEW_PREP = "interview_prep" INTERVIEW_PREP = "interview_prep"
RESUME_REVIEW = "resume_review" RESUME_REVIEW = "resume_review"
EDIT_RESUME = "edit_resume"
GENERAL = "general" GENERAL = "general"
GENERATE_PERSONA = "generate_persona" GENERATE_PERSONA = "generate_persona"
GENERATE_PROFILE = "generate_profile" GENERATE_PROFILE = "generate_profile"
@ -373,6 +374,9 @@ class Tunables(BaseModel):
enable_rag: bool = Field(default=True, alias=str("enableRAG")) enable_rag: bool = Field(default=True, alias=str("enableRAG"))
enable_tools: bool = Field(default=True, alias=str("enableTools")) enable_tools: bool = Field(default=True, alias=str("enableTools"))
enable_context: bool = Field(default=True, alias=str("enableContext")) enable_context: bool = Field(default=True, alias=str("enableContext"))
temperature: float = Field(default=0.7, alias=str("temperature"))
top_k: int = Field(default=5, alias=str("topK"))
threshold: Optional[float] = Field(default=None, alias=str("threshold"))
class CandidateQuestion(BaseModel): class CandidateQuestion(BaseModel):
@ -381,15 +385,38 @@ class CandidateQuestion(BaseModel):
class Location(BaseModel): class Location(BaseModel):
city: str text: str
city: Optional[str] = None
state: Optional[str] = None state: Optional[str] = None
country: str country: Optional[str] = None
postal_code: Optional[str] = Field(default=None, alias=str("postalCode")) postal_code: Optional[str] = Field(default=None, alias=str("postalCode"))
latitude: Optional[float] = None latitude: Optional[float] = None
longitude: Optional[float] = None longitude: Optional[float] = None
remote: Optional[bool] = None remote: Optional[bool] = None
hybrid_options: Optional[List[str]] = Field(default=None, alias=str("hybridOptions")) hybrid_options: Optional[List[str]] = Field(default=None, alias=str("hybridOptions"))
address: Optional[str] = None address: Optional[str] = None
model_config = ConfigDict(populate_by_name=True, use_enum_values=True)
@model_validator(mode="before")
@classmethod
def set_text_from_location(cls, data: Any) -> Any:
if isinstance(data, dict):
# Check if text is missing, None, or empty string
if not data.get("text"):
city = data.get("city", "")
country = data.get("country", "")
# Build text from available location components
location_parts = []
if city:
location_parts.append(city)
if country:
location_parts.append(country)
# Set text to combined location or a default if both are empty
data["text"] = ", ".join(location_parts) if location_parts else "Unknown Location"
return data
class Skill(BaseModel): class Skill(BaseModel):
@ -1141,11 +1168,13 @@ class SkillMatchRequest(BaseModel):
skill: str skill: str
regenerate: bool = Field(default=False, description="Whether to regenerate the skill match even if cached") regenerate: bool = Field(default=False, description="Whether to regenerate the skill match even if cached")
class ChatMessageUser(ApiMessage): class ChatMessageUser(ApiMessage):
type: ApiMessageType = ApiMessageType.TEXT type: ApiMessageType = ApiMessageType.TEXT
status: ApiStatusType = ApiStatusType.DONE status: ApiStatusType = ApiStatusType.DONE
role: ChatSenderType = ChatSenderType.USER role: ChatSenderType = ChatSenderType.USER
content: str = "" content: str = ""
extra_context: Optional[Dict[str, str | int | float | bool]] = Field(default=None, alias=str("extraContext"))
tunables: Optional[Tunables] = None tunables: Optional[Tunables] = None

View File

@ -1249,6 +1249,7 @@ async def update_candidate(
return create_success_response(updated_candidate.model_dump(by_alias=True)) return create_success_response(updated_candidate.model_dump(by_alias=True))
except Exception as e: except Exception as e:
logger.error(backstory_traceback.format_exc())
logger.error(f"❌ Update candidate error: {e}") logger.error(f"❌ Update candidate error: {e}")
return JSONResponse(status_code=400, content=create_error_response("UPDATE_FAILED", str(e))) return JSONResponse(status_code=400, content=create_error_response("UPDATE_FAILED", str(e)))

View File

@ -82,6 +82,8 @@ async def stream_agent_response(
session_id=user_message.session_id, session_id=user_message.session_id,
prompt=user_message.content, prompt=user_message.content,
database=database, database=database,
extra_context=user_message.extra_context,
tunables=user_message.tunables,
): ):
if generated_message.status == ApiStatusType.ERROR: if generated_message.status == ApiStatusType.ERROR:
logger.error(f"❌ AI generation error: {generated_message.content}") logger.error(f"❌ AI generation error: {generated_message.content}")