Add AI editing for resumes
This commit is contained in:
parent
c31752e50f
commit
79372231eb
46
Dockerfile
46
Dockerfile
@ -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
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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": {
|
||||
|
@ -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;
|
||||
},
|
||||
},
|
||||
|
2108
frontend/package-lock.json
generated
2108
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
9
frontend/src/components/DiffViewer.css
Normal file
9
frontend/src/components/DiffViewer.css
Normal 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;
|
||||
}
|
62
frontend/src/components/DiffViewer.tsx
Normal file
62
frontend/src/components/DiffViewer.tsx
Normal 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 };
|
@ -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 = <></>;
|
||||
|
499
frontend/src/components/ResumeChat.tsx
Normal file
499
frontend/src/components/ResumeChat.tsx
Normal 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 };
|
@ -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>('');
|
||||
|
@ -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',
|
||||
});
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -147,6 +147,6 @@
|
||||
|
||||
.BackstoryResumeHeader p {
|
||||
/* border: 3px solid purple; */
|
||||
margin: 0;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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 }}>
|
||||
|
@ -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={{
|
||||
|
@ -2,9 +2,7 @@ import React, { useState } from 'react';
|
||||
import {
|
||||
SxProps,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
useTheme,
|
||||
useMediaQuery,
|
||||
|
@ -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">
|
||||
|
@ -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 {
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
|
255
src/backend/agents/edit_resume.py
Normal file
255
src/backend/agents/edit_resume.py
Normal 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)
|
@ -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]:
|
||||
|
@ -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]:
|
||||
|
@ -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]:
|
||||
|
@ -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]:
|
||||
|
@ -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]:
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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)))
|
||||
|
||||
|
@ -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}")
|
||||
|
Loading…
x
Reference in New Issue
Block a user