Full initial flow for guest working

This commit is contained in:
James Ketr 2025-06-10 15:10:26 -07:00
parent 3a21f2e510
commit bb4017b835
17 changed files with 456 additions and 388 deletions

BIN
frontend/public/final-resume.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
frontend/public/select-a-job.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
frontend/public/wait.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -95,13 +95,6 @@ const JobCreator = (props: JobCreatorProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);
if (!user?.id) {
return (
<LoginRequired asset="job creation" />
);
}
const jobStatusHandlers = {
onStatus: (status: Types.ChatMessageStatus) => {
console.log('status:', status.content);

View File

@ -32,10 +32,12 @@ import { useAppState } from 'hooks/GlobalContext';
import * as Types from 'types/types';
import JsonView from '@uiw/react-json-view';
import { VectorVisualizer } from './VectorVisualizer';
import { JobInfo } from './ui/JobInfo';
interface JobAnalysisProps extends BackstoryPageProps {
job: Job;
candidate: Candidate;
variant?: "small" | "normal";
onAnalysisComplete: (skills: SkillAssessment[]) => void;
}
@ -54,6 +56,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
job,
candidate,
onAnalysisComplete,
variant = "normal",
} = props
const { apiClient } = useAuth();
const { setSnack } = useAppState();
@ -69,6 +72,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
const [startAnalysis, setStartAnalysis] = useState<boolean>(false);
const [analyzing, setAnalyzing] = useState<boolean>(false);
const [matchStatus, setMatchStatus] = useState<string>('');
const [matchStatusType, setMatchStatusType] = useState<Types.ApiActivityType | null>(null);
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
@ -133,6 +137,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
const skillMatchHandlers = {
onStatus: (status: Types.ChatMessageStatus) => {
setMatchStatusType(status.activity);
setMatchStatus(status.content.toLowerCase());
},
};
@ -238,67 +243,30 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
};
return (
<Box>
<Paper elevation={3} sx={{ p: 3, mb: 4 }}>
<Grid container spacing={2}>
<Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row" }}>
<Grid size={{ xs: 12, md: 6 }} sx={{ display: "flex", flexGrow: 1, flexDirection: "column" }}>
<Typography variant="h6" component="h2">
Company:
</Typography>
<Typography variant="body1" component="h2">
{job.company || "N/A"}
</Typography>
<Box sx={{ display: "flex", flexDirection: "column" }}>
{variant !== "small" &&
<JobInfo job={job} variant="normal" />
}
<Typography variant="h6" component="h2">
Job Title:
</Typography>
<Typography variant="body1" component="h2">
{job.title || "N/A"}
</Typography>
<Typography variant="h6" component="h2">
Backstory Generated Job Summary:
</Typography>
<Typography variant="body1" component="h2">
{job.summary || "N/A"}
</Typography>
<Typography variant="caption">Job ID: {job.id}</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }} sx={{ display: "flex", flexShrink: 1, flexDirection: "column" }}>
<Typography variant="h6" component="h2">
Original Job Description:
</Typography>
<Paper sx={{ p: 2, maxHeight: "22rem" }}>
<Scrollable sx={{ display: "flex", maxHeight: "100%" }}>
<StyledMarkdown content={job.description} />
</Scrollable>
</Paper>
</Grid>
</Box>
<Grid size={{ xs: 12 }} sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, gap: 1 }}>
{<Button disabled={analyzing || startAnalysis} onClick={beginAnalysis} variant="contained">Start Skill Assessment</Button>}
{overallScore !== 0 && <>
<Box sx={{ display: 'flex', flexDirection: "row", alignItems: 'center', mb: 2, gap: 1, justifyContent: "space-between" }}>
<Box sx={{ display: "flex", flexDirection: "row", flexGrow: 1 }}>
{overallScore !== 0 && <>
<Typography variant="h5" component="h2" sx={{ mr: 2 }}>
Overall Match:
</Typography>
<Box sx={{
position: 'relative',
display: 'inline-flex',
mr: 2
<Box sx={{
position: 'relative',
display: 'inline-flex',
mr: 2
}}>
<CircularProgress
variant="determinate"
value={overallScore}
size={60}
thickness={5}
sx={{
<CircularProgress
variant="determinate"
value={overallScore}
size={60}
thickness={5}
sx={{
color: getMatchColor(overallScore),
}}
}}
/>
<Box
sx={{
@ -317,23 +285,24 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
</Typography>
</Box>
</Box>
<Chip
<Chip
label={
overallScore >= 80 ? "Excellent Match" :
overallScore >= 60 ? "Good Match" :
overallScore >= 40 ? "Partial Match" : "Low Match"
}
sx={{
overallScore >= 40 ? "Partial Match" : "Low Match"
}
sx={{
bgcolor: getMatchColor(overallScore),
color: 'white',
fontWeight: 'bold'
}}
}}
/>
</>}
</Box>
</Grid>
</Grid>
</Paper>
</>}
</Box>
<Button sx={{ marginLeft: "auto" }} disabled={analyzing || startAnalysis} onClick={beginAnalysis} variant="contained">
{analyzing ? "Assessment in Progress" : "Start Skill Assessment"}
</Button>
</Box>
{loadingRequirements ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>

View File

@ -373,7 +373,7 @@ const Message = (props: MessageProps) => {
if (typeof (message.content) === "string") {
content = message.content.trim();
} else {
console.error(`message content is not a string`);
console.error(`message content is not a string, it is a ${typeof message.content}`);
return (<></>)
}

View File

@ -30,16 +30,14 @@ const defaultMessage: Types.ChatMessageStatus = {
const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorProps) => {
const { job, candidate, skills, onComplete } = props;
const { apiClient } = useAuth();
const { apiClient, user } = useAuth();
const [resume, setResume] = useState<string>('');
const [prompt, setPrompt] = useState<string>('');
const [systemPrompt, setSystemPrompt] = useState<string>('');
const [generating, setGenerating] = useState<boolean>(false);
const [statusMessage, setStatusMessage] = useState<Types.ChatMessageStatus | null>(null);
const [tabValue, setTabValue] = useState<string>('resume');
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
setTabValue(newValue);
}
@ -47,7 +45,7 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
const generateResumeHandlers = {
onStatus: (status: Types.ChatMessageStatus) => {
setStatusMessage({...defaultMessage, content: status.content.toLowerCase});
setStatusMessage({ ...defaultMessage, content: status.content.toLowerCase() });
},
onStreaming: (chunk: Types.ChatMessageStreaming) =>{
setResume(chunk.content);
@ -82,20 +80,20 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
display: "flex",
flexDirection: "column",
}}>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab sx={{display: systemPrompt ? "flex" : "none"}} value="system" icon={<TuneIcon />} label="System" />
<Tab sx={{display: prompt ? "flex" : "none"}}value="prompt" icon={<InputIcon />} label="Prompt" />
<Tab sx={{display: resume ? "flex" : "none"}}value="resume" icon={<ArticleIcon />} label="Resume" />
</Tabs>
</Box>
{ statusMessage && <Message message={statusMessage} />}
<Paper elevation={3} sx={{ p: 3, m: 4, mt: 0 }}><Scrollable autoscroll sx={{display: "flex", flexGrow: 1}}>
{ tabValue === 'system' && <pre>{systemPrompt}</pre> }
{ tabValue === 'prompt' && <pre>{prompt}</pre> }
{ tabValue === 'resume' && <StyledMarkdown content={resume} />}
</Scrollable></Paper>
</Box>
{user?.isAdmin && <Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab sx={{ display: systemPrompt ? "flex" : "none" }} value="system" icon={<TuneIcon />} label="System" />
<Tab sx={{ display: prompt ? "flex" : "none" }} value="prompt" icon={<InputIcon />} label="Prompt" />
<Tab sx={{ display: resume ? "flex" : "none" }} value="resume" icon={<ArticleIcon />} label="Resume" />
</Tabs>
</Box>}
{statusMessage && <Message message={statusMessage} />}
<Paper elevation={3} sx={{ p: 3, m: 4, mt: 0 }}><Scrollable autoscroll sx={{ display: "flex", flexGrow: 1 }}>
{tabValue === 'system' && <pre>{systemPrompt}</pre>}
{tabValue === 'prompt' && <pre>{prompt}</pre>}
{tabValue === 'resume' && <StyledMarkdown content={resume} />}
</Scrollable></Paper>
</Box>
)
};

View File

@ -1,6 +1,6 @@
import Box from '@mui/material/Box';
import { SxProps, Theme } from '@mui/material';
import { RefObject, useRef } from 'react';
import { RefObject, useRef, forwardRef, useImperativeHandle } from 'react';
import { useAutoScrollToBottom } from '../hooks/useAutoScrollToBottom';
interface ScrollableProps {
@ -13,7 +13,7 @@ interface ScrollableProps {
className?: string;
}
const Scrollable = (props: ScrollableProps) => {
const Scrollable = forwardRef((props: ScrollableProps, ref) => {
const { sx, className, children, autoscroll, textFieldRef, fallbackThreshold = 0.33, contentUpdateTrigger } = props;
// Create a default ref if textFieldRef is not provided
const defaultTextFieldRef = useRef<HTMLElement | null>(null);
@ -32,11 +32,11 @@ const Scrollable = (props: ScrollableProps) => {
// backgroundColor: '#F5F5F5',
...sx,
}}
ref={autoscroll !== undefined && autoscroll !== false ? scrollRef : undefined}
ref={autoscroll !== undefined && autoscroll !== false ? scrollRef : ref}
>
{children}
</Box>
);
};
});
export { useAutoScrollToBottom, Scrollable };

View File

@ -48,114 +48,111 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
}
return (
<Card
elevation={elevation}
<Box
sx={{
display: "flex",
borderColor: 'transparent',
borderWidth: 2,
borderStyle: 'solid',
transition: 'all 0.3s ease',
flexGrow: 1,
p: 3,
height: '100%',
flexDirection: 'column',
alignItems: 'stretch',
position: "relative",
overflow: "hidden",
...sx
}}
{...rest}
>
<CardContent sx={{ display: "flex", flexGrow: 1, p: 3, height: '100%', flexDirection: 'column', alignItems: 'stretch', position: "relative" }}>
{ai && <AIBanner />}
<Grid container spacing={2}>
<Grid
size={{ xs: 12, sm: 2 }}
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minWidth: "80px",
maxWidth: "80px"
}}>
<Avatar
src={candidate.profileImage ? `/api/1.0/candidates/profile/${candidate.username}` : ''}
alt={`${candidate.fullName}'s profile`}
{ai && <AIBanner />}
<Box sx={{ display: "flex", flexDirection: "row" }}>
<Avatar
src={candidate.profileImage ? `/api/1.0/candidates/profile/${candidate.username}` : ''}
alt={`${candidate.fullName}'s profile`}
sx={{
alignSelf: "flex-start",
width: 80,
height: 80,
border: '2px solid #e0e0e0',
}}
/>
</Grid>
{isAdmin && ai &&
<DeleteConfirmation
onDelete={() => { deleteCandidate(candidate.id); }}
sx={{ minWidth: 'auto', px: 2, maxHeight: "min-content", color: "red" }}
action="delete"
label="user"
title="Delete AI user"
icon=<DeleteIcon />
message={`Are you sure you want to delete ${candidate.username}? This action cannot be undone.`}
/>}
/>
<Grid size={{ xs: 12, sm: 10 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
mb: 1 }}>
<Box sx={{ ml: 1 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
mb: 1
}}>
<Box>
<Box sx={{
display: "flex",
flexDirection: isMobile ? "column" : "row",
alignItems: "left",
gap: 1, "& > .MuiTypography-root": { m: 0 }
<Box sx={{
display: "flex",
flexDirection: isMobile ? "column" : "row",
alignItems: "left",
gap: 1, "& > .MuiTypography-root": { m: 0 }
}}>
{
action !== '' &&
<Typography variant="body1">{action}</Typography>
action !== '' &&
<Typography variant="body1">{action}</Typography>
}
<Typography variant="h5" component="h1"
sx={{
fontWeight: 'bold',
whiteSpace: 'nowrap'
{action === '' &&
<Typography variant="h5" component="h1"
sx={{
fontWeight: 'bold',
whiteSpace: 'nowrap'
}}>
{candidate.fullName}
</Typography>
}
</Box>
<Box sx={{ fontSize: "0.75rem", alignItems: "center" }} >
<Link href={`/u/${candidate.username}`}>{`/u/${candidate.username}`}</Link>
<CopyBubble
onClick={(event: any) => { event.stopPropagation() }}
tooltip="Copy link" content={`${window.location.origin}/u/{candidate.username}`} />
{isAdmin && ai &&
<DeleteConfirmation
onDelete={() => { deleteCandidate(candidate.id); }}
sx={{ minWidth: 'auto', px: 2, maxHeight: "min-content", color: "red" }}
action="delete"
label="user"
title="Delete AI user"
icon=<DeleteIcon />
message={`Are you sure you want to delete ${candidate.username}? This action cannot be undone.`}
/>}
</Box>
</Box>
</Box>
</Box>
<Typography variant="body1" color="text.secondary">
{candidate.description}
</Typography>
{variant !== "small" && <>
<Divider sx={{ my: 2 }} />
{candidate.location &&
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Location:</strong> {candidate.location.city}, {candidate.location.state || candidate.location.country}
</Typography>
}
{candidate.email &&
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Email:</strong> {candidate.email}
</Typography>
}
{candidate.phone && <Typography variant="body2">
<strong>Phone:</strong> {candidate.phone}
{variant !== "small" && <>
<Divider sx={{ my: 2 }} />
{candidate.location &&
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Location:</strong> {candidate.location.city}, {candidate.location.state || candidate.location.country}
</Typography>
}
</>}
</Grid>
</Grid>
</CardContent>
</Card>
}
{candidate.email &&
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Email:</strong> {candidate.email}
</Typography>
}
{candidate.phone && <Typography variant="body2">
<strong>Phone:</strong> {candidate.phone}
</Typography>
}
</>}
</Box>
</Box>
</Box>
);
};

View File

@ -5,16 +5,16 @@ import Box from '@mui/material/Box';
import { BackstoryElementProps } from 'components/BackstoryTab';
import { CandidateInfo } from 'components/ui/CandidateInfo';
import { Candidate } from "types/types";
import { Candidate, CandidateAI } from "types/types";
import { useAuth } from 'hooks/AuthContext';
import { useAppState, useSelectedCandidate } from 'hooks/GlobalContext';
interface CandidatePickerProps extends BackstoryElementProps {
onSelect?: (candidate: Candidate) => void
onSelect?: (candidate: Candidate) => void;
};
const CandidatePicker = (props: CandidatePickerProps) => {
const { onSelect } = props;
const { onSelect, sx } = props;
const { apiClient, user } = useAuth();
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate();
const navigate = useNavigate();
@ -30,7 +30,12 @@ const CandidatePicker = (props: CandidatePickerProps) => {
const results = await apiClient.getCandidates();
const candidates: Candidate[] = results.data;
candidates.sort((a, b) => {
let result = a.lastName.localeCompare(b.lastName);
const aIsAi = 'isAI' in a ? 1 : 0;
const bIsAi = 'isAI' in b ? 1 : 0;
let result = aIsAi - bIsAi;
if (result === 0) {
result = a.lastName.localeCompare(b.lastName);
}
if (result === 0) {
result = a.firstName.localeCompare(b.firstName);
}
@ -49,7 +54,7 @@ const CandidatePicker = (props: CandidatePickerProps) => {
}, [candidates, setSnack]);
return (
<Box sx={{display: "flex", flexDirection: "column"}}>
<Box sx={{ display: "flex", flexDirection: "column", ...sx }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center" }}>
{candidates?.map((u, i) =>
<Box key={`${u.username}`}

View File

@ -59,7 +59,7 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
}
if (!job) {
return <Box>No user loaded.</Box>;
return <Box>No job provided.</Box>;
}
const handleSave = async () => {
@ -191,8 +191,7 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
};
return (
<Card
elevation={elevation}
<Box
sx={{
display: "flex",
borderColor: 'transparent',
@ -204,26 +203,37 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
}}
{...rest}
>
<CardContent sx={{ display: "flex", flexGrow: 1, p: 3, height: '100%', flexDirection: 'column', alignItems: 'stretch', position: "relative" }}>
<Box sx={{ display: "flex", flexGrow: 1, p: 3, height: '100%', flexDirection: 'column', alignItems: 'stretch', position: "relative" }}>
<Box sx={{
display: "flex", flexDirection: (isMobile || variant !== "small") ? "column" : "row",
"& > div > div > :first-of-type": { fontWeight: "bold" },
"& > div > div > :last-of-type": { mb: 0.75, mr: 1 }
}}>
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 1 }}>
{
activeJob.title && <Box sx={{ fontSize: "0.8rem" }}>
<Box>Title</Box>
<Box>{activeJob.title}</Box>
</Box>
}
{activeJob.company && <Box sx={{ fontSize: "0.8rem" }}>
<Box>Company</Box>
<Box>{activeJob.company}</Box>
</Box>}
</Box>
<Box sx={{ display: "flex", flexDirection: "column", width: "75%" }}>
{activeJob.summary && <Box sx={{ fontSize: "0.8rem" }}>
<Box>Summary</Box>
<Box>{activeJob.summary}</Box>
</Box>}
</Box>
</Box>
{variant !== "small" && <>
{activeJob.details &&
{activeJob.details &&
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Location:</strong> {activeJob.details.location.city}, {activeJob.details.location.state || activeJob.details.location.country}
</Typography>
}
{activeJob.title &&
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Title:</strong> {activeJob.title}
</Typography>
}
{activeJob.company &&
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Company:</strong> {activeJob.company}
</Typography>
}
{activeJob.summary && <Typography variant="body2">
<strong>Summary:</strong> {activeJob.summary}
</Typography>
}
{activeJob.owner && <Typography variant="body2">
<strong>Created by:</strong> {activeJob.owner.fullName}
@ -237,12 +247,11 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
<Typography variant="caption">Job ID: {job.id}</Typography>
</>}
<Divider />
{renderJobRequirements()}
{variant !== 'small' && <><Divider />{renderJobRequirements()}</>}
</CardContent>
</Box >
{isAdmin &&
<CardActions sx={{ display: "flex", flexDirection: "column", p: 1 }}>
<Box sx={{ display: "flex", flexDirection: "column", p: 1 }}>
<Box sx={{ display: "flex", flexDirection: "row", pl: 1, pr: 1, gap: 1, alignContent: "center", height: "32px" }}>
{(job.updatedAt && job.updatedAt.toISOString()) !== (activeJob.updatedAt && activeJob.updatedAt.toISOString()) &&
<Tooltip title="Save Job">
@ -290,9 +299,9 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
{adminStatus && <LinearProgress sx={{ mt: 1 }} />}
</Box>
}
</CardActions>
</Box>
}
</Card>
</Box >
);
};

View File

@ -24,6 +24,7 @@ import PropagateLoader from 'react-spinners/PropagateLoader';
import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField';
import { BackstoryQuery } from 'components/BackstoryQuery';
import { CandidatePicker } from 'components/ui/CandidatePicker';
import { Scrollable } from 'components/Scrollable';
const defaultMessage: ChatMessage = {
status: "done", type: "text", sessionId: "", timestamp: new Date(), content: "", role: "user", metadata: null as any
@ -186,55 +187,44 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
};
return (
<Box ref={ref} sx={{
width: "100%",
display: "flex",
flexDirection: "column",
gap: 1,
}}>
<Box ref={ref}
sx={{
display: "flex", flexDirection: "column",
height: "100%", /* Restrict to main-container's height */
width: "100%",
minHeight: 0,/* Prevent flex overflow */
maxHeight: "min-content",
"& > *:not(.Scrollable)": {
flexShrink: 0, /* Prevent shrinking */
},
position: "relative",
}}>
<Paper elevation={2} sx={{ m: 1, p: 1 }}>
<CandidateInfo
key={selectedCandidate.username}
action={`Chat with Backstory about ${selectedCandidate.firstName}`}
elevation={4}
candidate={selectedCandidate}
variant="small"
sx={{ flexShrink: 0 }} // Prevent header from shrinking
sx={{ flexShrink: 1, width: "100%", maxHeight: 0, minHeight: "min-content" }} // Prevent header from shrinking
/>
<Button onClick={() => { setSelectedCandidate(null); }} variant="contained">Change Candidates</Button>
<Button sx={{ maxWidth: "max-content" }} onClick={() => { setSelectedCandidate(null); }} variant="contained">Change Candidates</Button>
</Paper>
{/* Chat Interface */}
<Paper
sx={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
width: '100%',
minHeight: 'max-content'
}}
>
{/* Scrollable Messages Area */}
{chatSession && <>
<Box sx={{
flexGrow: 1,
p: 2,
display: 'flex',
flexDirection: 'column',
minHeight: 'max-content', // Important for flex child
// Custom scrollbar styling
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
backgroundColor: theme.palette.grey[100],
borderRadius: '4px',
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: theme.palette.grey[400],
borderRadius: '4px',
'&:hover': {
backgroundColor: theme.palette.grey[600],
},
},
{/* Scrollable Messages Area */}
{chatSession &&
<Scrollable
sx={{
position: "relative",
maxHeight: "100%",
width: "100%",
display: "flex", flexGrow: 1,
flex: 1, /* Take remaining space in some-container */
overflowY: "auto", /* Scroll if content overflows */
pt: 2,
pl: 1,
pr: 1,
pb: 2,
}}>
{messages.length === 0 && <Message {...{ chatSession, message: welcomeMessage, }} />}
{messages.map((message: ChatMessage) => (
@ -263,12 +253,11 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
</Box>
)}
<div ref={messagesEndRef} />
</Box>
</>}
</Paper>
</Scrollable>
}
{selectedCandidate.questions?.length !== 0 && selectedCandidate.questions?.map(q => <BackstoryQuery question={q} />)}
{/* Fixed Message Input */}
<Box sx={{ display: 'flex', flexShrink: 0, gap: 1 }}>
<Box sx={{ display: 'flex', flexShrink: 1, gap: 1 }}>
<DeleteConfirmation
onDelete={() => { chatSession && onDelete(chatSession); }}
disabled={!chatSession}

View File

@ -1,22 +1,61 @@
import React from 'react';
import { Box, Paper, Typography } from '@mui/material';
import { BackstoryLogo } from 'components/ui/BackstoryLogo';
import { StyledMarkdown } from 'components/StyledMarkdown';
const HowItWorks = () => {
const content = `\
Welcome to the Backstory Beta!
Here are your steps from zero-to-hero to see Backstory in action.
![select-job-analysis](/select-job-analysis.png)
Select 'Job Analysis' from the menu. This takes you to the interactive Job Analysis page,
where you will get to evaluate a candidate for a selected job.
Once on the Job Analysis Page, explore a little bit and then select one of the jobs. The
requirements and information provided on Backstory are extracted from job postings that users have pasted as a
job description or uploaded from a PDF. You can create your own job postings once you create an account. Until then,
you need to select one that already exists.
![select-a-job](/select-a-job.png)
Now that you have a Job selected, you need to select a candidate. In addition to myself (James), there are several
candidates which AI has generated. Each has a unique skillset and can be used to test out the system. If you create an account,
you can opt-in to have your account show up for others to view as well, or keep it private for just your own resume
generation and job research.
![select-a-candidate](/select-a-candidate.png)
After selecting a candidate, you are ready to have Backstory perform the Job Analysis. During this phase, Backstory will
take each of requirements that were extracted from the Job and match it against any information available about the selected candidate.
This could be as little as a simple resume, or as complete as a full work history. Backstory performs similarity searches to
identify key elements from the candidate that pertain to a given skill and provides a graded response.
![select-start-analysis](/select-start-analysis.png)
To see that in action, click the "Start Skill Assessment". Once you begin that action, the Start Skill Assessment button will grey out and
the page will begin updating as it discovers information about the candidate. As it does its thing, you can monitor the progress and explore the different
identified skills to see how or why a candidate does or does not have that skill.
![Wait](/wait.png)
Once it is done, you can see the final Overall Match. This is a weighted score based on amount of evidence a skill had, whether the skill was required or preferred, and other metrics.
The final step is creating the custom resume for the Candidate tailored to the particular Job. On the bottom right you can click "Next" to have Backstory
generate the custom resume.
![final-resume](/final-resume.png)
Note that the resume focuses on identifying key areas from the Candidate's work history that align with skills which were extracted from the original job posting.
You can then click the Copy button to copy the resume into your editor, adjust, and apply for your dream job!
`;
return (<Paper sx={{ m: 1, p: 1 }}>
<Box sx={{ display: "flex", flexDirection: "column" }}>
<Box sx={{ display: "flex", alignContent: "center", verticalAlign: "center", flexDirection: "row" }}>
<Typography>Job Description </Typography><BackstoryLogo /><Typography> (Company Info, Job Summary, Job Requirements) <strong>Job</strong></Typography>
</Box>
<Box sx={{ display: "flex", alignContent: "center", verticalAlign: "center", flexDirection: "row" }}>
<Typography>User Content </Typography><BackstoryLogo /><Typography> RAG Vector Database <strong>Candidate</strong></Typography>
</Box>
<Box sx={{ display: "flex", alignContent: "center", verticalAlign: "center", flexDirection: "row" }}>
<Typography><strong>Job</strong> + <strong>Candidate</strong> </Typography><BackstoryLogo /><Typography> <strong>Skill Match</strong></Typography>
</Box>
<Box sx={{ display: "flex", alignContent: "center", verticalAlign: "center", flexDirection: "row" }}>
<Typography><strong>Skill Match</strong> + <strong>Candidate</strong> </Typography><BackstoryLogo /><Typography> <strong>Resume</strong></Typography>
</Box>
<StyledMarkdown content={content} />
</Box>
</Paper>);
}

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Box,
Stepper,
@ -13,6 +13,8 @@ import {
Tabs,
Tab,
Avatar,
useMediaQuery,
Divider,
} from '@mui/material';
import {
Add,
@ -37,6 +39,7 @@ import { JobCreator } from 'components/JobCreator';
import { LoginRestricted } from 'components/ui/LoginRestricted';
import JsonView from '@uiw/react-json-view';
import { ResumeGenerator } from 'components/ResumeGenerator';
import { JobInfo } from 'components/ui/JobInfo';
function WorkAddIcon() {
return (
@ -63,108 +66,153 @@ function WorkAddIcon() {
);
}
interface AnalysisState {
job: Job | null;
candidate: Candidate | null;
analysis: SkillAssessment[] | null;
resume: string | null;
};
interface Step {
index: number;
label: string;
requiredState: string[];
title: string;
icon: React.ReactNode;
};
const initialState: AnalysisState = {
job: null,
candidate: null,
analysis: null,
resume: null,
};
// Steps in our process
const steps: Step[] = [
{ requiredState: [], title: 'Job Selection', icon: <WorkIcon /> },
{ requiredState: ['job'], title: 'Select Candidate', icon: <PersonIcon /> },
{ requiredState: ['job', 'candidate'], title: 'Job Analysis', icon: <WorkIcon /> },
{ requiredState: ['job', 'candidate', 'analysis'], title: 'Generated Resume', icon: <AssessmentIcon /> }
].map((item, index) => { return { ...item, index, label: item.title.toLowerCase().replace(/ /g, '-') } });
const capitalize = (str: string) => {
return str.charAt(0).toUpperCase() + str.slice(1);
}
// Main component
const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
const theme = useTheme();
const { user, guest } = useAuth();
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate()
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate();
const { selectedJob, setSelectedJob } = useSelectedJob();
// State management
const [activeStep, setActiveStep] = useState(0);
const [activeStep, setActiveStep] = useState<Step>(steps[0]);
const [error, setError] = useState<string | null>(null);
const [jobTab, setJobTab] = useState<string>('load');
const [skills, setSkills] = useState<SkillAssessment[] | null>(null)
const [jobTab, setJobTab] = useState<string>('select');
const [analysisState, setAnalysisState] = useState<AnalysisState | null>(null);
const [canAdvance, setCanAdvance] = useState<boolean>(false);
const scrollRef = useRef<HTMLDivElement>(null);
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const canAccessStep = useCallback((step: Step) => {
if (!analysisState) {
return;
}
const missing = step.requiredState.find(f => !(analysisState as any)[f])
return missing;
}, [analysisState]);
useEffect(() => {
if (!selectedCandidate) {
if (activeStep !== 0) {
setActiveStep(0);
}
} else if (!selectedJob) {
if (activeStep !== 1) {
setActiveStep(1);
if (analysisState !== null) {
return;
}
const analysis = { ...initialState, candidate: selectedCandidate, job: selectedJob }
setAnalysisState(analysis);
for (let i = steps.length - 1; i >= 0; i--) {
const missing = steps[i].requiredState.find(f => !(analysis as any)[f])
if (!missing) {
setActiveStep(steps[i]);
return;
}
}
}, [selectedCandidate, selectedJob, activeStep])
}, [analysisState, selectedCandidate, selectedJob, setActiveStep, canAccessStep]);
// Steps in our process
const steps = [
{ index: 0, label: 'Select Candidate', icon: <PersonIcon /> },
{ index: 1, label: 'Job Selection', icon: <WorkIcon /> },
{ index: 2, label: 'Job Analysis', icon: <WorkIcon /> },
{ index: 3, label: 'Generated Resume', icon: <AssessmentIcon /> }
];
useEffect(() => {
if (activeStep.index === steps.length - 1) {
setCanAdvance(false);
return;
}
const blocked = canAccessStep(steps[activeStep.index + 1]);
if (blocked) {
setCanAdvance(false);
} else {
setCanAdvance(true);
}
if (scrollRef.current) {
scrollRef.current.scrollTo({
top: 0,
behavior: "smooth",
});
}
}, [setCanAdvance, analysisState, activeStep]);
// Navigation handlers
const handleNext = () => {
if (activeStep === 0 && !selectedCandidate) {
setError('Please select a candidate before continuing.');
if (activeStep.index === steps.length - 1) {
return;
}
if (activeStep === 1 && !selectedJob) {
setError('Please select a job before continuing.');
return;
const missing = canAccessStep(steps[activeStep.index + 1]);
if (missing) {
setError(`${capitalize(missing)} is necessary before continuing.`);
return missing;
}
if (activeStep === 2 && !skills) {
setError('Skill assessment must be complete before continuing.');
return;
if (activeStep.index < steps.length - 1) {
setActiveStep((prevActiveStep) => steps[prevActiveStep.index + 1]);
}
setActiveStep((prevActiveStep) => prevActiveStep + 1);
};
const handleBack = () => {
console.log(activeStep);
if (activeStep === 1) {
setSelectedCandidate(null);
if (activeStep.index === 0) {
return;
}
if (activeStep === 2) {
setSelectedJob(null);
}
setActiveStep((prevActiveStep) => prevActiveStep - 1);
setActiveStep((prevActiveStep) => steps[prevActiveStep.index - 1]);
};
const moveToStep = (step: number) => {
console.log(`Move to ${step}`)
switch (step) {
case 0: /* Select candidate */
setSelectedCandidate(null);
setSelectedJob(null);
setSkills(null);
break;
case 1: /* Select Job */
setSelectedJob(null);
setSkills(null);
break;
case 2: /* Job Analysis */
setSkills(null);
break;
case 3: /* Generate Resume */
break;
const missing = canAccessStep(steps[step]);
if (missing) {
setError(`${capitalize(missing)} is needed to access this step.`);
return;
}
setActiveStep(step);
setActiveStep(steps[step]);
}
const onCandidateSelect = (candidate: Candidate) => {
if (!analysisState) {
return;
}
analysisState.candidate = candidate;
setAnalysisState({ ...analysisState });
setSelectedCandidate(candidate);
setActiveStep(1);
handleNext();
}
const onJobSelect = (job: Job) => {
setSelectedJob(job)
setActiveStep(2);
if (!analysisState) {
return;
}
analysisState.job = job;
setAnalysisState({ ...analysisState });
setSelectedJob(job);
handleNext();
}
// Render function for the candidate selection step
const renderCandidateSelection = () => (
<Paper elevation={3} sx={{ p: 3, mt: 3, mb: 4, borderRadius: 2 }}>
<Typography variant="h5" gutterBottom>
Select a Candidate
</Typography>
<CandidatePicker onSelect={onCandidateSelect} />
</Paper>
<CandidatePicker sx={{ pt: 1 }} onSelect={onCandidateSelect} />
);
const handleTabChange = (event: React.SyntheticEvent, value: string) => {
@ -173,19 +221,15 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
// Render function for the job description step
const renderJobDescription = () => {
if (!selectedCandidate) {
return;
}
return (<Box sx={{ mt: 3 }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={jobTab} onChange={handleTabChange} centered>
<Tab value='load' icon={<WorkOutline />} label="Load" />
<Tab value='create' icon={<WorkAddIcon />} label="Create" />
<Tab value='select' icon={<WorkOutline />} label="Select Job" />
<Tab value='create' icon={<WorkAddIcon />} label="Create Job" />
</Tabs>
</Box>
{jobTab === 'load' &&
{jobTab === 'select' &&
<JobPicker onSelect={onJobSelect} />
}
{jobTab === 'create' && user &&
@ -201,32 +245,47 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
}
const onAnalysisComplete = (skills: SkillAssessment[]) => {
setSkills(skills);
if (!analysisState) {
return;
}
analysisState.analysis = skills;
setAnalysisState({ ...analysisState });
};
// Render function for the analysis step
const renderAnalysis = () => (
<Box sx={{ mt: 3 }}>
{selectedCandidate && selectedJob && (
<JobMatchAnalysis
job={selectedJob}
candidate={selectedCandidate}
onAnalysisComplete={onAnalysisComplete}
/>
)}
</Box>
);
const renderAnalysis = () => {
if (!analysisState) {
return;
}
if (!analysisState.job || !analysisState.candidate) {
return <Box>{JSON.stringify({ job: analysisState.job, candidate: analysisState.candidate })}</Box>
}
return (<Box sx={{ mt: 3 }}>
<JobMatchAnalysis
variant="small"
job={analysisState.job}
candidate={analysisState.candidate}
onAnalysisComplete={onAnalysisComplete}
/>
</Box>);
};
const renderResume = () => (
<Box sx={{ mt: 3 }}>
{skills && selectedCandidate && selectedJob &&
const renderResume = () => {
if (!analysisState) {
return;
}
if (!analysisState.job || !analysisState.candidate || !analysisState.analysis) {
return <></>;
}
return (<Box sx={{ mt: 3 }}>
<ResumeGenerator
job={selectedJob}
candidate={selectedCandidate}
skills={skills}
/>}
</Box>
);
job={analysisState.job}
candidate={analysisState.candidate}
skills={analysisState.analysis}
/>
</Box>);
};
return (
<Box sx={{
@ -240,8 +299,48 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
},
position: "relative",
}}>
<Paper elevation={4} sx={{ m: 0, borderRadius: 0, mb: 1, p: 0 }}>{selectedCandidate && <CandidateInfo variant="small" candidate={selectedCandidate} sx={{ width: "100%" }} />}</Paper>
<Paper elevation={4} sx={{ m: 0, borderRadius: 0, mb: 1, p: 0, gap: 1 }}>
<Stepper activeStep={activeStep.index} alternativeLabel sx={{ mt: 2, mb: 2 }}>
{steps.map((step, index) => (
<Step>
<StepLabel sx={{ cursor: "pointer" }} onClick={() => { moveToStep(index); }}
slots={{
stepIcon: () => (
<Avatar key={step.index}
sx={{
bgcolor: activeStep.index >= step.index ? theme.palette.primary.main : theme.palette.grey[300],
color: 'white'
}}
>
{step.icon}
</Avatar>
)
}}
>
{step.title}
</StepLabel>
</Step>
))}
</Stepper>
<Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row" }}>
{analysisState && analysisState.job &&
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
<Box sx={{ ml: 3, fontWeight: "bold" }}>Selected Job</Box>
<JobInfo variant="small" job={analysisState.job} />
</Box>
}
{isMobile && <Box sx={{ display: "flex", borderBottom: "1px solid grey" }} />}
{!isMobile && <Box sx={{ display: "flex", borderLeft: "1px solid lightgrey" }} />}
{analysisState && analysisState.candidate &&
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
<Box sx={{ ml: 3, fontWeight: "bold" }}>Selected Candidate</Box>
<CandidateInfo variant="small" candidate={analysisState.candidate} sx={{}} />
</Box>
}
</Box>
</Paper>
<Scrollable
ref={scrollRef}
sx={{
position: "relative",
maxHeight: "100%",
@ -250,46 +349,16 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
flex: 1, /* Take remaining space in some-container */
overflowY: "auto", /* Scroll if content overflows */
}}>
<Box sx={{ display: "flex", justifyContent: "center" }}>
<Typography variant="subtitle1" color="text.secondary" gutterBottom>
Match candidates to job requirements with AI-powered analysis
</Typography>
</Box>
<Box sx={{ mt: 4, mb: 4 }}>
<Stepper activeStep={activeStep} alternativeLabel>
{steps.map((step, index) => (
<Step>
<StepLabel sx={{ cursor: "pointer" }} onClick={() => { moveToStep(index); }}
slots={{
stepIcon: () => (
<Avatar key={step.index}
sx={{
bgcolor: activeStep >= step.index ? theme.palette.primary.main : theme.palette.grey[300],
color: 'white'
}}
>
{step.icon}
</Avatar>
)
}}
>
{step.label}
</StepLabel>
</Step>
))}
</Stepper>
</Box>
{activeStep === 0 && renderCandidateSelection()}
{activeStep === 1 && renderJobDescription()}
{activeStep === 2 && renderAnalysis()}
{activeStep === 3 && renderResume()}
{activeStep.label === 'job-selection' && renderJobDescription()}
{activeStep.label === 'select-candidate' && renderCandidateSelection()}
{activeStep.label === 'job-analysis' && renderAnalysis()}
{activeStep.label === 'generated-resume' && renderResume()}
</Scrollable>
<Box sx={{ display: 'flex', flexDirection: 'row', pt: 2 }}>
<Button
color="inherit"
disabled={activeStep === steps[0].index}
disabled={activeStep.index === steps[0].index}
onClick={handleBack}
sx={{ mr: 1 }}
>
@ -297,13 +366,13 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
</Button>
<Box sx={{ flex: '1 1 auto' }} />
{activeStep === steps[steps.length - 1].index ? (
<Button onClick={() => { moveToStep(0) }} variant="outlined">
{activeStep.index === steps[steps.length - 1].index ? (
<Button disabled={!canAdvance} onClick={() => { moveToStep(0) }} variant="outlined">
Start New Analysis
</Button>
) : (
<Button onClick={handleNext} variant="contained">
{activeStep === steps.length - 1 ? 'Done' : 'Next'}
<Button disabled={!canAdvance} onClick={handleNext} variant="contained">
{activeStep.index === steps.length - 1 ? 'Done' : 'Next'}
</Button>
)}
</Box>