384 lines
13 KiB
TypeScript
384 lines
13 KiB
TypeScript
import React, { useState, useCallback, useRef } from 'react';
|
|
import {
|
|
Tabs,
|
|
Tab,
|
|
Box,
|
|
} from '@mui/material';
|
|
import { SxProps } from '@mui/material';
|
|
|
|
import { ChatQuery, Query } from './ChatQuery';
|
|
import { MessageList, BackstoryMessage } from './Message';
|
|
import { Conversation } from './Conversation';
|
|
import { BackstoryPageProps } from './BackstoryTab';
|
|
|
|
import './ResumeBuilderPage.css';
|
|
|
|
/**
|
|
* ResumeBuilder component
|
|
*
|
|
* A responsive component that displays job descriptions, generated resumes and fact checks
|
|
* with different layouts for mobile and desktop views.
|
|
*/
|
|
const ResumeBuilderPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
|
|
const {
|
|
sx,
|
|
sessionId,
|
|
setSnack,
|
|
submitQuery,
|
|
} = props
|
|
// State for editing job description
|
|
const [hasJobDescription, setHasJobDescription] = useState<boolean>(false);
|
|
const [hasResume, setHasResume] = useState<boolean>(false);
|
|
const [hasFacts, setHasFacts] = useState<boolean>(false);
|
|
const jobConversationRef = useRef<any>(null);
|
|
const resumeConversationRef = useRef<any>(null);
|
|
const factsConversationRef = useRef<any>(null);
|
|
|
|
const [activeTab, setActiveTab] = useState<number>(0);
|
|
|
|
/**
|
|
* Handle tab change for mobile view
|
|
*/
|
|
const handleTabChange = (_event: React.SyntheticEvent, newValue: number): void => {
|
|
setActiveTab(newValue);
|
|
};
|
|
|
|
const handleJobQuery = (query: Query) => {
|
|
console.log(`handleJobQuery: ${query.prompt} -- `, jobConversationRef.current ? ' sending' : 'no handler');
|
|
jobConversationRef.current?.submitQuery(query);
|
|
};
|
|
|
|
const handleResumeQuery = (query: Query) => {
|
|
console.log(`handleResumeQuery: ${query.prompt} -- `, resumeConversationRef.current ? ' sending' : 'no handler');
|
|
resumeConversationRef.current?.submitQuery(query);
|
|
};
|
|
|
|
const handleFactsQuery = (query: Query) => {
|
|
console.log(`handleFactsQuery: ${query.prompt} -- `, factsConversationRef.current ? ' sending' : 'no handler');
|
|
factsConversationRef.current?.submitQuery(query);
|
|
};
|
|
|
|
const filterJobDescriptionMessages = useCallback((messages: MessageList): MessageList => {
|
|
if (messages === undefined || messages.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
if (messages.length > 0) {
|
|
messages[0].role = 'content';
|
|
messages[0].title = 'Job Description';
|
|
messages[0].disableCopy = false;
|
|
messages[0].expandable = true;
|
|
}
|
|
|
|
if (-1 !== messages.findIndex(m => m.status === 'done')) {
|
|
setHasResume(true);
|
|
setHasFacts(true);
|
|
}
|
|
|
|
return messages;
|
|
|
|
if (messages.length > 1) {
|
|
setHasResume(true);
|
|
setHasFacts(true);
|
|
}
|
|
|
|
|
|
if (messages.length > 3) {
|
|
// messages[2] is Show job requirements
|
|
messages[3].role = 'job-requirements';
|
|
messages[3].title = 'Job Requirements';
|
|
messages[3].disableCopy = false;
|
|
messages[3].expanded = false;
|
|
messages[3].expandable = true;
|
|
}
|
|
|
|
/* Filter out the 2nd and 3rd (0-based) */
|
|
const filtered = messages;//.filter((m, i) => i !== 1 && i !== 2);
|
|
console.warn("Set filtering back on");
|
|
|
|
return filtered;
|
|
}, [setHasResume, setHasFacts]);
|
|
|
|
const filterResumeMessages = useCallback((messages: MessageList): MessageList => {
|
|
if (messages === undefined || messages.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
return messages;
|
|
|
|
if (messages.length > 1) {
|
|
// messages[0] is Show Qualifications
|
|
messages[1].role = 'qualifications';
|
|
messages[1].title = 'Candidate qualifications';
|
|
messages[1].disableCopy = false;
|
|
messages[1].expanded = false;
|
|
messages[1].expandable = true;
|
|
}
|
|
|
|
if (messages.length > 3) {
|
|
// messages[2] is Show Resume
|
|
messages[3].role = 'resume';
|
|
messages[3].title = 'Generated Resume';
|
|
messages[3].disableCopy = false;
|
|
messages[3].expanded = true;
|
|
messages[3].expandable = true;
|
|
}
|
|
|
|
/* Filter out the 1st and 3rd messages (0-based) */
|
|
const filtered = messages.filter((m, i) => i !== 0 && i !== 2);
|
|
|
|
return filtered;
|
|
}, []);
|
|
|
|
const filterFactsMessages = useCallback((messages: MessageList): MessageList => {
|
|
if (messages === undefined || messages.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
if (messages.length > 1) {
|
|
// messages[0] is Show verification
|
|
messages[1].role = 'fact-check';
|
|
messages[1].title = 'Fact Check';
|
|
messages[1].disableCopy = false;
|
|
messages[1].expanded = true;
|
|
messages[1].expandable = true;
|
|
}
|
|
|
|
/* Filter out the 1st (0-based) */
|
|
const filtered = messages.filter((m, i) => i !== 0);
|
|
|
|
return filtered;
|
|
}, []);
|
|
|
|
const jobResponse = useCallback(async (message: BackstoryMessage) => {
|
|
console.log('onJobResponse', message);
|
|
if (message.actions && message.actions.includes("job_description")) {
|
|
await jobConversationRef.current.fetchHistory();
|
|
}
|
|
if (message.actions && message.actions.includes("resume_generated")) {
|
|
await resumeConversationRef.current.fetchHistory();
|
|
setHasResume(true);
|
|
setActiveTab(1); // Switch to Resume tab
|
|
}
|
|
if (message.actions && message.actions.includes("facts_checked")) {
|
|
await factsConversationRef.current.fetchHistory();
|
|
setHasFacts(true);
|
|
}
|
|
}, [setHasFacts, setHasResume, setActiveTab]);
|
|
|
|
const resumeResponse = useCallback((message: BackstoryMessage): void => {
|
|
console.log('onResumeResponse', message);
|
|
setHasFacts(true);
|
|
}, [setHasFacts]);
|
|
|
|
const factsResponse = useCallback((message: BackstoryMessage): void => {
|
|
console.log('onFactsResponse', message);
|
|
}, []);
|
|
|
|
const resetJobDescription = useCallback(() => {
|
|
setHasJobDescription(false);
|
|
setHasResume(false);
|
|
setHasFacts(false);
|
|
}, [setHasJobDescription, setHasResume, setHasFacts]);
|
|
|
|
const resetResume = useCallback(() => {
|
|
setHasResume(false);
|
|
setHasFacts(false);
|
|
}, [setHasResume, setHasFacts]);
|
|
|
|
const resetFacts = useCallback(() => {
|
|
setHasFacts(false);
|
|
}, [setHasFacts]);
|
|
|
|
const renderJobDescriptionView = useCallback((sx: SxProps) => {
|
|
console.log('renderJobDescriptionView');
|
|
const jobDescriptionQuestions = [
|
|
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
|
<ChatQuery query={{ prompt: "What are the key skills necessary for this position?", tunables: { enable_tools: false } }} submitQuery={handleJobQuery} />
|
|
<ChatQuery query={{ prompt: "How much should this position pay (accounting for inflation)?", tunables: { enable_tools: false } }} submitQuery={handleJobQuery} />
|
|
</Box>,
|
|
];
|
|
|
|
const jobDescriptionPreamble: MessageList = [{
|
|
role: 'info',
|
|
content: `Once you paste a job description and press **Generate Resume**, Backstory will perform the following actions:
|
|
|
|
1. **Job Analysis**: LLM extracts requirements from '\`Job Description\`' to generate a list of desired '\`Skills\`'.
|
|
2. **Candidate Analysis**: LLM determines candidate qualifications by performing skill assessments.
|
|
|
|
For each '\`Skill\`' from **Job Analysis** phase:
|
|
|
|
1. **RAG**: Retrieval Augmented Generation collection is queried for context related content for each '\`Skill\`'.
|
|
2. **Evidence Creation**: LLM is queried to generate supporting evidence of '\`Skill\`' from the '\`RAG\`' and '\`Candidate Resume\`'.
|
|
3. **Resume Generation**: LLM is provided the output from the **Candidate Analysis:Evidence Creation** phase and asked to generate a professional resume.
|
|
|
|
See [About > Resume Generation Architecture](/about/resume-generation) for more details.
|
|
`,
|
|
disableCopy: true
|
|
}];
|
|
|
|
|
|
if (!hasJobDescription) {
|
|
return <Conversation
|
|
ref={jobConversationRef}
|
|
{...{
|
|
type: "job_description",
|
|
actionLabel: "Generate Resume",
|
|
preamble: jobDescriptionPreamble,
|
|
hidePreamble: true,
|
|
placeholder: "Paste a job description, then click Generate...",
|
|
multiline: true,
|
|
resetLabel: `job description${hasFacts ? ", resume, and fact check" : hasResume ? " and resume" : ""}`,
|
|
messageFilter: filterJobDescriptionMessages,
|
|
resetAction: resetJobDescription,
|
|
onResponse: jobResponse,
|
|
sessionId,
|
|
setSnack,
|
|
submitQuery,
|
|
sx,
|
|
}}
|
|
/>
|
|
|
|
} else {
|
|
return <Conversation
|
|
ref={jobConversationRef}
|
|
{...{
|
|
type: "job_description",
|
|
actionLabel: "Send",
|
|
placeholder: "Ask a question about this job description...",
|
|
resetLabel: `job description${hasFacts ? ", resume, and fact check" : hasResume ? " and resume" : ""}`,
|
|
messageFilter: filterJobDescriptionMessages,
|
|
defaultPrompts: jobDescriptionQuestions,
|
|
resetAction: resetJobDescription,
|
|
onResponse: jobResponse,
|
|
sessionId,
|
|
setSnack,
|
|
submitQuery,
|
|
sx,
|
|
}}
|
|
/>
|
|
}
|
|
}, [filterJobDescriptionMessages, hasJobDescription, sessionId, setSnack, jobResponse, resetJobDescription, hasFacts, hasResume, submitQuery]);
|
|
|
|
/**
|
|
* Renders the resume view with loading indicator
|
|
*/
|
|
const renderResumeView = useCallback((sx: SxProps) => {
|
|
const resumeQuestions = [
|
|
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
|
<ChatQuery query={{ prompt: "Is this resume a good fit for the provided job description?", tunables: { enable_tools: false } }} submitQuery={handleResumeQuery} />
|
|
<ChatQuery query={{ prompt: "Provide a more concise resume.", tunables: { enable_tools: false } }} submitQuery={handleResumeQuery} />
|
|
</Box>,
|
|
];
|
|
|
|
if (!hasFacts) {
|
|
return <Conversation
|
|
ref={resumeConversationRef}
|
|
{...{
|
|
type: "resume",
|
|
actionLabel: "Fact Check",
|
|
defaultQuery: "Fact check the resume.",
|
|
resetLabel: `job description${hasFacts ? ", resume, and fact check" : hasResume ? " and resume" : ""}`,
|
|
messageFilter: filterResumeMessages,
|
|
onResponse: resumeResponse,
|
|
resetAction: resetResume,
|
|
sessionId,
|
|
setSnack,
|
|
submitQuery,
|
|
sx,
|
|
}}
|
|
/>
|
|
} else {
|
|
return <Conversation
|
|
ref={resumeConversationRef}
|
|
{...{
|
|
type: "resume",
|
|
actionLabel: "Send",
|
|
placeholder: "Ask a question about this job resume...",
|
|
resetLabel: `job description${hasFacts ? ", resume, and fact check" : hasResume ? " and resume" : ""}`,
|
|
messageFilter: filterResumeMessages,
|
|
onResponse: resumeResponse,
|
|
resetAction: resetResume,
|
|
sessionId,
|
|
setSnack,
|
|
defaultPrompts: resumeQuestions,
|
|
submitQuery,
|
|
sx,
|
|
}}
|
|
/>
|
|
}
|
|
}, [filterResumeMessages, hasFacts, sessionId, setSnack, resumeResponse, resetResume, hasResume, submitQuery]);
|
|
|
|
/**
|
|
* Renders the fact check view
|
|
*/
|
|
const renderFactCheckView = useCallback((sx: SxProps) => {
|
|
const factsQuestions = [
|
|
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
|
<ChatQuery query={{ prompt: "Rewrite the resume to address any discrepancies.", tunables: { enable_tools: false } }} submitQuery={handleFactsQuery} />
|
|
</Box>,
|
|
];
|
|
|
|
return <Conversation
|
|
ref={factsConversationRef}
|
|
{...{
|
|
type: "fact_check",
|
|
actionLabel: "Send",
|
|
placeholder: "Ask a question about any discrepencies...",
|
|
resetLabel: `job description${hasFacts ? ", resume, and fact check" : hasResume ? " and resume" : ""}`,
|
|
messageFilter: filterFactsMessages,
|
|
defaultPrompts: factsQuestions,
|
|
resetAction: resetFacts,
|
|
onResponse: factsResponse,
|
|
sessionId,
|
|
submitQuery,
|
|
setSnack,
|
|
sx,
|
|
}}
|
|
/>
|
|
}, [ sessionId, setSnack, factsResponse, filterFactsMessages, resetFacts, hasResume, hasFacts, submitQuery]);
|
|
|
|
return (
|
|
<Box className="ResumeBuilder"
|
|
sx={{
|
|
p: 0,
|
|
m: 0,
|
|
display: "flex",
|
|
flexGrow: 1,
|
|
margin: "0 auto",
|
|
overflow: "hidden",
|
|
backgroundColor: "#F5F5F5",
|
|
flexDirection: "column",
|
|
maxWidth: "1024px",
|
|
}}
|
|
>
|
|
{/* Tabs */}
|
|
<Tabs
|
|
value={activeTab}
|
|
onChange={handleTabChange}
|
|
variant="fullWidth"
|
|
sx={{ bgcolor: 'background.paper' }}
|
|
>
|
|
<Tab value={0} label="Job Description" />
|
|
{hasResume && <Tab value={1} label="Resume" />}
|
|
{hasFacts && <Tab value={2} label="Fact Check" />}
|
|
</Tabs>
|
|
|
|
{/* Document display area */}
|
|
<Box sx={{
|
|
display: 'flex', flexDirection: 'column', flexGrow: 1, p: 0, width: "100%", ...sx,
|
|
overflow: "hidden"
|
|
}}>
|
|
<Box sx={{ display: activeTab === 0 ? "flex" : "none" }}>{renderJobDescriptionView({ height: "calc(100vh - 72px - 48px)" })}</Box>
|
|
<Box sx={{ display: activeTab === 1 ? "flex" : "none" }}>{renderResumeView({ height: "calc(100vh - 72px - 48px)" })}</Box>
|
|
<Box sx={{ display: activeTab === 2 ? "flex" : "none" }}>{renderFactCheckView({ height: "calc(100vh - 72px - 48px)" })}</Box>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export {
|
|
ResumeBuilderPage
|
|
};
|
|
|