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
#
FROM ubuntu:oracular AS python
FROM ubuntu:oracular AS python-local
SHELL [ "/bin/bash", "-c" ]
@ -62,7 +62,7 @@ RUN cmake .. \
# * ollama-ipex-llm
# * 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
RUN apt-get update \
@ -180,8 +180,8 @@ FROM llm-base AS backstory
SHELL [ "/opt/backstory/shell" ]
#COPY /src/requirements.txt /opt/backstory/src/requirements.txt
#RUN pip install -r /opt/backstory/src/requirements.txt
#COPY /src/requirements.txt /opt/backstory/requirements.txt
#RUN pip install -r /opt/backstory/requirements.txt
RUN pip install 'markitdown[all]' pydantic 'pydantic[email]'
# Prometheus
@ -200,7 +200,7 @@ RUN pip install pyyaml user-agents cryptography
RUN pip install openapi-python-client
# QR code generator
RUN pip install pyqrcode pypng
RUN pip install setuptools pyqrcode pypng
# Anthropic and other backends
RUN pip install anthropic pydantic_ai
@ -250,7 +250,7 @@ RUN { \
echo ' while true; do'; \
echo ' if [[ ! -e /opt/backstory/block-server ]]; then'; \
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 ' else'; \
echo ' if [[ ${once} -eq 0 ]]; then' ; \
@ -470,7 +470,7 @@ ENV PATH=/opt/backstory:$PATH
ENTRYPOINT [ "/entrypoint-jupyter.sh" ]
FROM python AS miniircd
FROM python-local AS miniircd
# Get a couple prerequisites
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"]
FROM python AS vllm
FROM llm-base AS vllm
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y \
@ -676,7 +676,7 @@ ENV PATH=~/.local/bin:$PATH
RUN { \
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' ; \
} > /opt/vllm/shell ; \
chmod +x /opt/vllm/shell
@ -695,13 +695,27 @@ RUN { \
echo '#!/bin/bash'; \
echo 'echo "Container: vLLM"'; \
echo 'set -e'; \
echo 'python -m vllm.entrypoints.openai.api_server \'; \
echo ' --model=facebook/opt-13b \' ; \
echo ' --dtype=bfloat16 \' ; \
echo ' --max_model_len=1024 \' ; \
echo ' --distributed-executor-backend=ray \' ; \
echo ' --pipeline-parallel-size=2 \' ; \
echo ' -tp=8' ; \
echo 'source /opt/backstory/venv/bin/activate'; \
echo 'while true; do'; \
echo ' if [[ ! -e /opt/backstory/block-server ]]; then'; \
echo ' echo "Launching vLLM server..."'; \
echo ' python3 -m vllm.entrypoints.openai.api_server \'; \
echo ' --model=Qwen/Qwen3-8b \' ; \
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 \
&& chmod +x /entrypoint.sh

View File

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

View File

@ -14,7 +14,15 @@
"plugins": ["@typescript-eslint", "react", "react-hooks"],
"rules": {
"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": {
"react": {

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -18,11 +18,13 @@
"@types/lodash": "^4.17.17",
"@types/luxon": "^3.6.2",
"@types/node": "^16.18.126",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@uiw/react-json-view": "^2.0.0-alpha.31",
"@uiw/react-markdown-editor": "^6.1.4",
"country-state-city": "^3.2.1",
"diff": "^8.0.2",
"diff2html": "^3.4.52",
"jsonrepair": "^3.12.0",
"libphonenumber-js": "^1.12.9",
"lodash": "^4.17.21",
@ -33,6 +35,7 @@
"mui-markdown": "^2.0.1",
"prism-react-renderer": "^2.4.1",
"react": "^19.0.0",
"react-diff-view": "^3.3.1",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"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;
className?: string;
sx?: SxProps<Theme>;
viewAsMarkdown?: boolean; // Whether to render messages as markdown
expandable?: boolean;
expanded?: boolean;
onExpand?: (open: boolean) => void;
@ -463,7 +464,17 @@ const MessageContainer = (props: MessageContainerProps): 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 theme = useTheme();
const type: ApiActivityType | ChatSenderType | 'error' =
@ -491,11 +502,20 @@ const Message = (props: MessageProps): JSX.Element => {
}
const messageView = (
<StyledMarkdown
chatSession={chatSession}
streaming={message.status === 'streaming'}
content={content}
/>
<>
{viewAsMarkdown && (
<StyledMarkdown
chatSession={chatSession}
streaming={message.status === 'streaming'}
content={content}
/>
)}
{!viewAsMarkdown && (
<Box>
<pre>{content}</pre>
</Box>
)}
</>
);
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 { setSnack } = useAppState();
const navigate = useNavigate();
const { apiClient, user } = useAuth();
const { apiClient } = useAuth();
const [resume, setResume] = useState<string>('');
const [prompt, setPrompt] = useState<string>('');
const [systemPrompt, setSystemPrompt] = useState<string>('');

View File

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

View File

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

View File

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

View File

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

View File

@ -394,18 +394,6 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
</Box>
</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>
</TableHead>
<TableBody>
@ -494,22 +482,6 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
)}
</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>
))}
</TableBody>

View File

@ -1,5 +1,5 @@
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 { useAuth } from 'hooks/AuthContext';
import {
@ -13,7 +13,6 @@ import {
CandidateQuestion,
} from 'types/types';
import { ConversationHandle } from 'components/Conversation';
import { BackstoryPageProps } from 'components/BackstoryTab';
import { Message } from 'components/Message';
import { DeleteConfirmation } from 'components/DeleteConfirmation';
import { CandidateInfo } from 'components/ui/CandidateInfo';
@ -50,8 +49,13 @@ const defaultMessage: ChatMessage = {
metadata: emptyMetadata,
};
const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>(
(_props: BackstoryPageProps, ref): JSX.Element => {
interface CandidateChatPageProps {
sx?: SxProps; // Optional styles for the component
}
const CandidateChatPage = forwardRef<ConversationHandle, CandidateChatPageProps>(
(props: CandidateChatPageProps, ref): JSX.Element => {
const { sx } = props;
const { apiClient } = useAuth();
const { selectedCandidate } = useSelectedCandidate();
const [processingMessage, setProcessingMessage] = useState<
@ -240,6 +244,7 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>(
flexShrink: 0 /* Prevent shrinking */,
},
position: 'relative',
...sx,
}}
>
<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' }}>
Candidate{!isMobile && 'Selection'}
Candidate{!isMobile && ' Selection'}
</Box>
{selectedCandidate !== null && !isMobile && (
<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 && (
<Box
sx={{

View File

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

View File

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

View File

@ -1,6 +1,6 @@
// Generated TypeScript types from Pydantic models
// 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
// ============================
@ -19,7 +19,7 @@ export type ApiStatusType = "streaming" | "status" | "done" | "error";
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";
@ -284,7 +284,7 @@ export interface Certification {
}
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;
relatedEntityType?: "job" | "candidate" | "employer";
additionalContext?: Record<string, any>;
@ -299,6 +299,7 @@ export interface ChatMessage {
timestamp?: Date;
role: "user" | "assistant" | "system" | "information" | "warning" | "error";
content: string;
extraContext?: Record<string, any>;
tunables?: Tunables;
metadata: ChatMessageMetaData;
}
@ -349,6 +350,7 @@ export interface ChatMessageResume {
timestamp?: Date;
role: "user" | "assistant" | "system" | "information" | "warning" | "error";
content: string;
extraContext?: Record<string, any>;
tunables?: Tunables;
metadata: ChatMessageMetaData;
resume: Resume;
@ -363,6 +365,7 @@ export interface ChatMessageSkillAssessment {
timestamp?: Date;
role: "user" | "assistant" | "system" | "information" | "warning" | "error";
content: string;
extraContext?: Record<string, any>;
tunables?: Tunables;
metadata: ChatMessageMetaData;
skillAssessment: SkillAssessment;
@ -398,6 +401,7 @@ export interface ChatMessageUser {
timestamp?: Date;
role: "user" | "assistant" | "system" | "information" | "warning" | "error";
content: string;
extraContext?: Record<string, any>;
tunables?: Tunables;
}
@ -808,9 +812,10 @@ export interface Language {
}
export interface Location {
city: string;
text: string;
city?: string;
state?: string;
country: string;
country?: string;
postalCode?: string;
latitude?: number;
longitude?: number;
@ -1004,6 +1009,7 @@ export interface ResumeMessage {
timestamp?: Date;
role: "user" | "assistant" | "system" | "information" | "warning" | "error";
content: string;
extraContext?: Record<string, any>;
tunables?: Tunables;
resume: Resume;
}
@ -1094,6 +1100,9 @@ export interface Tunables {
enableRAG: boolean;
enableTools: boolean;
enableContext: boolean;
temperature: number;
topK: number;
threshold?: number;
}
export interface UsageStats {

View File

@ -644,6 +644,7 @@ Content: {content}
session_id: str,
prompt: str,
database: RedisDatabase,
extra_context: Optional[dict[str, str | int | float | bool]] = None,
tunables: Optional[Tunables] = None,
temperature=0.7,
) -> AsyncGenerator[ApiMessage, None]:
@ -692,6 +693,10 @@ Content: {content}
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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -186,6 +186,7 @@ class ChatContextType(str, Enum):
CANDIDATE_CHAT = "candidate_chat"
INTERVIEW_PREP = "interview_prep"
RESUME_REVIEW = "resume_review"
EDIT_RESUME = "edit_resume"
GENERAL = "general"
GENERATE_PERSONA = "generate_persona"
GENERATE_PROFILE = "generate_profile"
@ -373,6 +374,9 @@ class Tunables(BaseModel):
enable_rag: bool = Field(default=True, alias=str("enableRAG"))
enable_tools: bool = Field(default=True, alias=str("enableTools"))
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):
@ -381,15 +385,38 @@ class CandidateQuestion(BaseModel):
class Location(BaseModel):
city: str
text: str
city: Optional[str] = None
state: Optional[str] = None
country: str
country: Optional[str] = None
postal_code: Optional[str] = Field(default=None, alias=str("postalCode"))
latitude: Optional[float] = None
longitude: Optional[float] = None
remote: Optional[bool] = None
hybrid_options: Optional[List[str]] = Field(default=None, alias=str("hybridOptions"))
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):
@ -1141,11 +1168,13 @@ class SkillMatchRequest(BaseModel):
skill: str
regenerate: bool = Field(default=False, description="Whether to regenerate the skill match even if cached")
class ChatMessageUser(ApiMessage):
type: ApiMessageType = ApiMessageType.TEXT
status: ApiStatusType = ApiStatusType.DONE
role: ChatSenderType = ChatSenderType.USER
content: str = ""
extra_context: Optional[Dict[str, str | int | float | bool]] = Field(default=None, alias=str("extraContext"))
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))
except Exception as e:
logger.error(backstory_traceback.format_exc())
logger.error(f"❌ Update candidate error: {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,
prompt=user_message.content,
database=database,
extra_context=user_message.extra_context,
tunables=user_message.tunables,
):
if generated_message.status == ApiStatusType.ERROR:
logger.error(f"❌ AI generation error: {generated_message.content}")