Added JobViewer
This commit is contained in:
parent
53e0d4aafb
commit
e0ed154476
@ -278,10 +278,13 @@ const JobCreator = (props: JobCreatorProps) => {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
setIsProcessing(true);
|
||||
const jobMessage = await apiClient.createJob(newJob);
|
||||
const job = await apiClient.createJob(newJob);
|
||||
setIsProcessing(false);
|
||||
const job: Types.Job = jobMessage.job;
|
||||
onSave ? onSave(job) : setSelectedJob(job);
|
||||
if (!job) {
|
||||
setSnack('Failed to save job', 'error');
|
||||
return;
|
||||
}
|
||||
onSave && onSave(job);
|
||||
};
|
||||
|
||||
const handleExtractRequirements = async () => {
|
||||
@ -305,7 +308,8 @@ const JobCreator = (props: JobCreatorProps) => {
|
||||
const renderJobCreation = () => {
|
||||
return (
|
||||
<Box sx={{
|
||||
mx: 'auto', p: { xs: 2, sm: 3 },
|
||||
width: "100%",
|
||||
p: 1
|
||||
}}>
|
||||
{/* Upload Section */}
|
||||
<Card elevation={3} sx={{ mb: 4 }}>
|
||||
@ -477,8 +481,33 @@ const JobCreator = (props: JobCreatorProps) => {
|
||||
}}>
|
||||
{job === null && renderJobCreation()}
|
||||
{job &&
|
||||
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-end', height: '100%' }}>
|
||||
<Box sx={{
|
||||
display: "flex", flexDirection: "column",
|
||||
height: "100%", /* Restrict to main-container's height */
|
||||
width: "100%",
|
||||
minHeight: 0,/* Prevent flex overflow */
|
||||
maxHeight: "min-content",
|
||||
position: "relative",
|
||||
}}>
|
||||
<Box sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
flexGrow: 1,
|
||||
gap: 1,
|
||||
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",
|
||||
border: "3px solid purple",
|
||||
}}>
|
||||
<Scrollable sx={{ display: "flex", flexGrow: 1, position: "relative", maxHeight: "30rem" }}><JobInfo job={job} /></Scrollable>
|
||||
<Scrollable sx={{ display: "flex", flexGrow: 1, position: "relative", maxHeight: "30rem" }}><StyledMarkdown content={job.description} /></Scrollable>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-end' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
@ -490,10 +519,7 @@ const JobCreator = (props: JobCreatorProps) => {
|
||||
Save Job
|
||||
</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", flexDirection: "row", flexShrink: 1, gap: 1 }}>
|
||||
<Paper elevation={1} sx={{ p: 1, m: 1, flexGrow: 1 }}><Scrollable><JobInfo job={job} /></Scrollable></Paper>
|
||||
<Paper elevation={1} sx={{ p: 1, m: 1, flexGrow: 1 }}><Scrollable><StyledMarkdown content={job.description} /></Scrollable></Paper>
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
}
|
||||
</Box>
|
||||
|
@ -20,6 +20,8 @@ import ArticleIcon from '@mui/icons-material/Article';
|
||||
import { StatusBox, StatusIcon } from './ui/StatusIcon';
|
||||
import { CopyBubble } from './CopyBubble';
|
||||
import { useAppState } from 'hooks/GlobalContext';
|
||||
import { StreamingOptions } from 'services/api-client';
|
||||
import { setDefaultResultOrder } from 'dns';
|
||||
|
||||
interface ResumeGeneratorProps {
|
||||
job: Job;
|
||||
@ -43,6 +45,7 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
|
||||
const [tabValue, setTabValue] = useState<string>('resume');
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [statusType, setStatusType] = useState<Types.ApiActivityType | null>(null);
|
||||
const [error, setError] = useState<Types.ChatMessageError | null>(null);
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
|
||||
setTabValue(newValue);
|
||||
@ -58,7 +61,7 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
|
||||
setStatusType("thinking");
|
||||
setStatus("Starting resume generation...");
|
||||
|
||||
const generateResumeHandlers = {
|
||||
const generateResumeHandlers: StreamingOptions<Types.ChatMessageResume> = {
|
||||
onMessage: (message: Types.ChatMessageResume) => {
|
||||
setSystemPrompt(message.systemPrompt || '');
|
||||
setPrompt(message.prompt || '');
|
||||
@ -79,11 +82,17 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
|
||||
},
|
||||
onComplete: () => {
|
||||
onComplete && onComplete(resume);
|
||||
}
|
||||
},
|
||||
onError: (error: Types.ChatMessageError) => {
|
||||
console.log('error:', error);
|
||||
setStatusType(null);
|
||||
setStatus(error.content);
|
||||
setError(error);
|
||||
},
|
||||
};
|
||||
|
||||
const generateResume = async () => {
|
||||
const request: any = await apiClient.generateResume(candidate.id || '', skills, generateResumeHandlers);
|
||||
const request: any = await apiClient.generateResume(candidate.id || '', job.id || '', generateResumeHandlers);
|
||||
const result = await request.promise;
|
||||
};
|
||||
|
||||
@ -112,7 +121,7 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
|
||||
{status || 'Processing...'}
|
||||
</Typography>
|
||||
</StatusBox>
|
||||
{status && <LinearProgress sx={{ mt: 1 }} />}
|
||||
{status && !error && <LinearProgress sx={{ mt: 1 }} />}
|
||||
</Box>}
|
||||
|
||||
<Paper elevation={3} sx={{ p: 3, m: 1, mt: 0 }}><Scrollable autoscroll sx={{ display: "flex", flexGrow: 1, position: "relative" }}>
|
||||
|
@ -29,6 +29,7 @@ const Scrollable = forwardRef((props: ScrollableProps, ref) => {
|
||||
p: 0,
|
||||
flexGrow: 1,
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
// backgroundColor: '#F5F5F5',
|
||||
...sx,
|
||||
}}
|
||||
|
@ -21,16 +21,16 @@ import RestoreIcon from '@mui/icons-material/Restore';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import * as Types from "types/types";
|
||||
import { useAppState } from 'hooks/GlobalContext';
|
||||
import { StyledMarkdown } from 'components/StyledMarkdown';
|
||||
|
||||
interface JobInfoProps {
|
||||
job: Job;
|
||||
sx?: SxProps;
|
||||
action?: string;
|
||||
elevation?: number;
|
||||
variant?: "minimal" | "small" | "normal" | null
|
||||
variant?: "minimal" | "small" | "normal" | "all" | null
|
||||
};
|
||||
|
||||
|
||||
const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
|
||||
const { setSnack } = useAppState();
|
||||
const { job } = props;
|
||||
@ -50,8 +50,15 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
|
||||
// State for description expansion
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||
const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false);
|
||||
const [deleted, setDeleted] = useState<boolean>(false);
|
||||
const descriptionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (job && job.id !== activeJob?.id) {
|
||||
setActiveJob(job);
|
||||
}
|
||||
}, [job, activeJob, setActiveJob]);
|
||||
|
||||
// Check if description needs truncation
|
||||
useEffect(() => {
|
||||
if (descriptionRef.current && job.summary) {
|
||||
@ -212,8 +219,11 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
|
||||
borderStyle: 'solid',
|
||||
transition: 'all 0.3s ease',
|
||||
flexDirection: "column",
|
||||
...sx,
|
||||
minWidth: 0,
|
||||
opacity: deleted ? 0.5 : 1.0,
|
||||
backgroundColor: deleted ? theme.palette.action.disabledBackground : theme.palette.background.paper,
|
||||
pointerEvents: deleted ? "none" : "auto",
|
||||
...sx,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
@ -304,6 +314,7 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
|
||||
}
|
||||
<Typography variant="caption">Job ID: {job.id}</Typography>
|
||||
</>}
|
||||
{variant === 'all' && <StyledMarkdown content={activeJob.description} />}
|
||||
|
||||
{(variant !== 'small' && variant !== 'minimal') && <><Divider />{renderJobRequirements()}</>}
|
||||
|
||||
@ -324,7 +335,7 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
|
||||
<Tooltip title="Delete Job">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); deleteJob(job.id); }}
|
||||
onClick={(e) => { e.stopPropagation(); deleteJob(job.id); setDeleted(true) }}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
|
@ -50,7 +50,7 @@ const JobPicker = (props: JobPickerProps) => {
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center" }}>
|
||||
{jobs?.map((j, i) =>
|
||||
<Paper key={`${j.id}`}
|
||||
onClick={() => { onSelect ? onSelect(j) : setSelectedJob(j); }}
|
||||
onClick={() => { console.log('Selected job', j); onSelect && onSelect(j) }}
|
||||
sx={{ cursor: "pointer" }}>
|
||||
<JobInfo variant="small"
|
||||
sx={{
|
||||
|
318
frontend/src/components/ui/JobViewer.tsx
Normal file
318
frontend/src/components/ui/JobViewer.tsx
Normal file
@ -0,0 +1,318 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
FormControl,
|
||||
Select,
|
||||
MenuItem,
|
||||
InputLabel,
|
||||
Chip,
|
||||
Divider,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
KeyboardArrowUp as ArrowUpIcon,
|
||||
KeyboardArrowDown as ArrowDownIcon,
|
||||
Business as BusinessIcon,
|
||||
Work as WorkIcon,
|
||||
Schedule as ScheduleIcon
|
||||
} from '@mui/icons-material';
|
||||
import { JobInfo } from 'components/ui/JobInfo';
|
||||
import { Job } from "types/types";
|
||||
import { useAuth } from 'hooks/AuthContext';
|
||||
import { useAppState, useSelectedJob } from 'hooks/GlobalContext';
|
||||
import { Navigate, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
type SortField = 'updatedAt' | 'createdAt' | 'company' | 'title';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
interface JobViewerProps {
|
||||
onSelect?: (job: Job) => void;
|
||||
}
|
||||
|
||||
const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
|
||||
const navigate = useNavigate();
|
||||
const { apiClient } = useAuth();
|
||||
const { selectedJob, setSelectedJob } = useSelectedJob();
|
||||
const { setSnack } = useAppState();
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sortField, setSortField] = useState<SortField>('updatedAt');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
|
||||
const { jobId } = useParams<{ jobId?: string }>();
|
||||
|
||||
useEffect(() => {
|
||||
const getJobs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const results = await apiClient.getJobs();
|
||||
const jobsData: Job[] = results.data || [];
|
||||
setJobs(jobsData);
|
||||
if (jobId) {
|
||||
const job = jobsData.find(j => j.id === jobId);
|
||||
if (job) {
|
||||
setSelectedJob(job);
|
||||
onSelect?.(job);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-select first job if none selected
|
||||
if (jobsData.length > 0 && !selectedJob) {
|
||||
const firstJob = sortJobs(jobsData, sortField, sortOrder)[0];
|
||||
setSelectedJob(firstJob);
|
||||
onSelect?.(firstJob);
|
||||
}
|
||||
} catch (err) {
|
||||
setSnack("Failed to load jobs: " + err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
getJobs();
|
||||
}, [apiClient, setSnack]);
|
||||
|
||||
const sortJobs = (jobsList: Job[], field: SortField, order: SortOrder): Job[] => {
|
||||
return [...jobsList].sort((a, b) => {
|
||||
let aValue: any;
|
||||
let bValue: any;
|
||||
|
||||
switch (field) {
|
||||
case 'updatedAt':
|
||||
aValue = a.updatedAt?.getTime() || 0;
|
||||
bValue = b.updatedAt?.getTime() || 0;
|
||||
break;
|
||||
case 'createdAt':
|
||||
aValue = a.createdAt?.getTime() || 0;
|
||||
bValue = b.createdAt?.getTime() || 0;
|
||||
break;
|
||||
case 'company':
|
||||
aValue = a.company?.toLowerCase() || '';
|
||||
bValue = b.company?.toLowerCase() || '';
|
||||
break;
|
||||
case 'title':
|
||||
aValue = a.title?.toLowerCase() || '';
|
||||
bValue = b.title?.toLowerCase() || '';
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (aValue < bValue) return order === 'asc' ? -1 : 1;
|
||||
if (aValue > bValue) return order === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const handleJobSelect = (job: Job) => {
|
||||
setSelectedJob(job);
|
||||
onSelect?.(job);
|
||||
navigate(`/candidate/jobs/${job.id}`);
|
||||
};
|
||||
|
||||
const sortedJobs = sortJobs(jobs, sortField, sortOrder);
|
||||
|
||||
const formatDate = (date: Date | undefined) => {
|
||||
if (!date) return 'N/A';
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const getSortIcon = (field: SortField) => {
|
||||
if (sortField !== field) return null;
|
||||
return sortOrder === 'asc' ? <ArrowUpIcon fontSize="small" /> : <ArrowDownIcon fontSize="small" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '100%', gap: 2, p: 2, position: 'relative' }}>
|
||||
{/* Left Panel - Job List */}
|
||||
<Paper sx={{ width: '50%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Jobs ({jobs.length})
|
||||
</Typography>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>Sort by</InputLabel>
|
||||
<Select
|
||||
value={`${sortField}-${sortOrder}`}
|
||||
label="Sort by"
|
||||
onChange={(e) => {
|
||||
const [field, order] = e.target.value.split('-') as [SortField, SortOrder];
|
||||
setSortField(field);
|
||||
setSortOrder(order);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="updatedAt-desc">Updated (Newest)</MenuItem>
|
||||
<MenuItem value="updatedAt-asc">Updated (Oldest)</MenuItem>
|
||||
<MenuItem value="createdAt-desc">Created (Newest)</MenuItem>
|
||||
<MenuItem value="createdAt-asc">Created (Oldest)</MenuItem>
|
||||
<MenuItem value="company-asc">Company (A-Z)</MenuItem>
|
||||
<MenuItem value="company-desc">Company (Z-A)</MenuItem>
|
||||
<MenuItem value="title-asc">Title (A-Z)</MenuItem>
|
||||
<MenuItem value="title-desc">Title (Z-A)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<TableContainer sx={{ flex: 1, overflow: 'auto' }}>
|
||||
<Table stickyHeader size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
sx={{ cursor: 'pointer', userSelect: 'none' }}
|
||||
onClick={() => handleSort('company')}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<BusinessIcon fontSize="small" />
|
||||
Company
|
||||
{getSortIcon('company')}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{ cursor: 'pointer', userSelect: 'none' }}
|
||||
onClick={() => handleSort('title')}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<WorkIcon fontSize="small" />
|
||||
Title
|
||||
{getSortIcon('title')}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{ cursor: 'pointer', userSelect: 'none' }}
|
||||
onClick={() => handleSort('updatedAt')}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ScheduleIcon fontSize="small" />
|
||||
Updated
|
||||
{getSortIcon('updatedAt')}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{sortedJobs.map((job) => (
|
||||
<TableRow
|
||||
key={job.id}
|
||||
hover
|
||||
selected={selectedJob?.id === job.id}
|
||||
onClick={() => handleJobSelect(job)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: 'action.selected',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{job.company || 'N/A'}
|
||||
</Typography>
|
||||
{job.details?.location && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{job.details.location.city}, {job.details.location.state || job.details.location.country}
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{job.title || 'N/A'}
|
||||
</Typography>
|
||||
{job.details?.employmentType && (
|
||||
<Chip
|
||||
label={job.details.employmentType}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ mt: 0.5, fontSize: '0.7rem', height: 20 }}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">
|
||||
{formatDate(job.updatedAt)}
|
||||
</Typography>
|
||||
{job.createdAt && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Created: {formatDate(job.createdAt)}
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={job.details?.isActive ? "Active" : "Inactive"}
|
||||
color={job.details?.isActive ? "success" : "default"}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
|
||||
{/* Right Panel - Job Details */}
|
||||
<Paper sx={{ width: '50%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Typography variant="h6">
|
||||
Job Details
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, overflow: 'auto', p: 2 }}>
|
||||
{selectedJob ? (
|
||||
<JobInfo
|
||||
job={selectedJob}
|
||||
variant="all"
|
||||
sx={{
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
backgroundColor: 'transparent'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: 'text.secondary'
|
||||
}}>
|
||||
<Typography variant="body1">
|
||||
Select a job to view details
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export { JobViewer };
|
@ -43,6 +43,7 @@ import { VectorVisualizer } from "components/VectorVisualizer";
|
||||
import { DocumentManager } from "components/DocumentManager";
|
||||
import { useAuth } from "hooks/AuthContext";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { JobViewer } from "components/ui/JobViewer";
|
||||
|
||||
// Beta page components for placeholder routes
|
||||
const BackstoryPage = () => (
|
||||
@ -162,6 +163,17 @@ export const navigationConfig: NavigationConfig = {
|
||||
showInNavigation: false,
|
||||
showInUserMenu: true,
|
||||
},
|
||||
{
|
||||
id: "candidate-jobs",
|
||||
label: "Jobs",
|
||||
icon: <WorkIcon />,
|
||||
path: "/candidate/jobs/:jobId?",
|
||||
component: <JobViewer />,
|
||||
userTypes: ["candidate"],
|
||||
userMenuGroup: "profile",
|
||||
showInNavigation: false,
|
||||
showInUserMenu: true,
|
||||
},
|
||||
{
|
||||
id: "candidate-docs",
|
||||
label: "Content",
|
||||
|
@ -221,7 +221,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
|
||||
|
||||
// Render function for the job description step
|
||||
const renderJobDescription = () => {
|
||||
return (<Box sx={{ mt: 3 }}>
|
||||
return (<Box sx={{ mt: 3, width: "100%" }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||
<Tabs value={jobTab} onChange={handleTabChange} centered>
|
||||
<Tab value='select' icon={<WorkOutline />} label="Select Job" />
|
||||
|
@ -663,7 +663,7 @@ class ApiClient {
|
||||
return this.streamify<Types.JobRequirementsMessage>('/jobs/from-content', body, streamingOptions, "JobRequirementsMessage");
|
||||
}
|
||||
|
||||
async createJob(job: Omit<Types.Job, 'id' | 'datePosted' | 'views' | 'applicationCount'>): Promise<Types.JobRequirementsMessage> {
|
||||
async createJob(job: Omit<Types.Job, 'id' | 'datePosted' | 'views' | 'applicationCount'>): Promise<Types.Job> {
|
||||
const body = JSON.stringify(formatApiRequest(job));
|
||||
const response = await fetch(`${this.baseUrl}/jobs`, {
|
||||
method: 'POST',
|
||||
@ -671,7 +671,7 @@ class ApiClient {
|
||||
body: body
|
||||
});
|
||||
|
||||
return this.handleApiResponseWithConversion<Types.JobRequirementsMessage>(response, 'JobRequirementsMessage');
|
||||
return this.handleApiResponseWithConversion<Types.Job>(response, 'Job');
|
||||
}
|
||||
|
||||
async getJob(id: string): Promise<Types.Job> {
|
||||
@ -920,14 +920,14 @@ class ApiClient {
|
||||
return this.streamify<Types.DocumentMessage>(`/jobs/requirements/${jobId}`, null, streamingOptions, "DocumentMessage");
|
||||
}
|
||||
|
||||
generateResume(candidateId: string, skills: Types.SkillAssessment[], streamingOptions?: StreamingOptions<Types.ChatMessageResume>): StreamingResponse<Types.ChatMessageResume> {
|
||||
const body = JSON.stringify(skills);
|
||||
generateResume(candidateId: string, jobId: string, streamingOptions?: StreamingOptions<Types.ChatMessageResume>): StreamingResponse<Types.ChatMessageResume> {
|
||||
streamingOptions = {
|
||||
...streamingOptions,
|
||||
headers: this.defaultHeaders,
|
||||
};
|
||||
return this.streamify<Types.ChatMessageResume>(`/candidates/${candidateId}/generate-resume`, body, streamingOptions, "ChatMessageResume");
|
||||
return this.streamify<Types.ChatMessageResume>(`/candidates/${candidateId}/${jobId}/generate-resume`, null, streamingOptions, "ChatMessageResume");
|
||||
}
|
||||
|
||||
candidateMatchForRequirement(candidate_id: string, requirement: string,
|
||||
streamingOptions?: StreamingOptions<Types.ChatMessageSkillAssessment>)
|
||||
: StreamingResponse<Types.ChatMessageSkillAssessment> {
|
||||
|
@ -130,6 +130,10 @@ Phone: {self.user.phone or 'N/A'}
|
||||
|
||||
## INSTRUCTIONS:
|
||||
|
||||
CRITICAL: Do NOT invent, fabricate, or assume any information not explicitly provided.
|
||||
If information is missing, state what is missing rather than creating fictional details.
|
||||
When sections lack data, output "Information not provided" or use placeholder text.
|
||||
|
||||
1. Create a professional resume that emphasizes the candidate's strongest skills and most relevant experiences.
|
||||
2. Format the resume in a clean, concise, and modern style that will pass ATS systems.
|
||||
3. Include these sections:
|
||||
@ -145,6 +149,18 @@ Phone: {self.user.phone or 'N/A'}
|
||||
7. Be concise and impactful - the resume should be 1-2 pages MAXIMUM.
|
||||
8. Ensure all information is accurate to the original resume - do not embellish or fabricate experiences.
|
||||
|
||||
If SKILL ASSESSMENT RESULTS or EXPERIENCE EVIDENCE sections are empty:
|
||||
- Do not create fictional work history
|
||||
- Do not invent specific companies, dates, or achievements
|
||||
- Instead, create a template with placeholders like [Company Name], [Start Date - End Date]
|
||||
- Include a note: "Template requires candidate input for: [missing sections]"
|
||||
|
||||
IF sufficient experience data exists:
|
||||
Create full professional experience section
|
||||
ELSE:
|
||||
Output: "Professional Experience section requires candidate input"
|
||||
Provide template format only
|
||||
|
||||
## OUTPUT FORMAT:
|
||||
Provide the resume in clean markdown format, ready for the candidate to use.
|
||||
|
||||
|
@ -50,6 +50,7 @@ import os
|
||||
import backstory_traceback
|
||||
from rate_limiter import RateLimiter, RateLimitResult, RateLimitConfig
|
||||
from background_tasks import BackgroundTaskManager
|
||||
from get_requirements_list import get_requirements_list
|
||||
|
||||
# =============================
|
||||
# Import custom modules
|
||||
@ -4609,6 +4610,13 @@ def get_endpoint_rate_limiter(rate_limiter: RateLimiter = Depends(get_rate_limit
|
||||
|
||||
return endpoint_rate_limiter
|
||||
|
||||
def get_skill_cache_key(candidate_id: str, skill: str) -> str:
|
||||
"""Generate a unique cache key for skill match"""
|
||||
# Create cache key for this specific candidate + skill combination
|
||||
skill_hash = hashlib.md5(skill.lower().encode()).hexdigest()[:8]
|
||||
return f"skill_match:{candidate_id}:{skill_hash}"
|
||||
|
||||
|
||||
@api_router.post("/candidates/{candidate_id}/skill-match")
|
||||
async def get_candidate_skill_match(
|
||||
candidate_id: str = Path(...),
|
||||
@ -4630,9 +4638,7 @@ async def get_candidate_skill_match(
|
||||
|
||||
candidate = Candidate.model_validate(candidate_data)
|
||||
|
||||
# Create cache key for this specific candidate + skill combination
|
||||
skill_hash = hashlib.md5(skill.lower().encode()).hexdigest()[:8]
|
||||
cache_key = f"skill_match:{candidate.id}:{skill_hash}"
|
||||
cache_key = get_skill_cache_key(candidate.id, skill)
|
||||
|
||||
# Get cached assessment if it exists
|
||||
assessment : SkillAssessment | None = await database.get_cached_skill_match(cache_key)
|
||||
@ -4905,28 +4911,75 @@ async def get_candidate_job_score(
|
||||
},
|
||||
})
|
||||
|
||||
@api_router.post("/candidates/{candidate_id}/generate-resume")
|
||||
@api_router.post("/candidates/{candidate_id}/{job_id}/generate-resume")
|
||||
async def generate_resume(
|
||||
candidate_id: str = Path(...),
|
||||
skills: List[SkillAssessment] = Body(...),
|
||||
job_id: str = Path(...),
|
||||
current_user = Depends(get_current_user_or_guest),
|
||||
database: RedisDatabase = Depends(get_database)
|
||||
) -> StreamingResponse:
|
||||
skills: List[SkillAssessment] = []
|
||||
|
||||
"""Get skill match for a candidate against a requirement with caching"""
|
||||
async def message_stream_generator():
|
||||
logger.info(f"🔍 Looking up candidate and job details for {candidate_id}/{job_id}")
|
||||
|
||||
candidate_data = await database.get_candidate(candidate_id)
|
||||
if not candidate_data:
|
||||
logger.error(f"❌ Candidate with ID '{candidate_id}' not found")
|
||||
error_message = ChatMessageError(
|
||||
sessionId=MOCK_UUID, # No session ID for document uploads
|
||||
content=f"Candidate with ID '{candidate_id}' not found"
|
||||
)
|
||||
yield error_message
|
||||
return
|
||||
candidate = Candidate.model_validate(candidate_data)
|
||||
|
||||
job_data = await database.get_job(job_id)
|
||||
if not job_data:
|
||||
logger.error(f"❌ Job with ID '{job_id}' not found")
|
||||
error_message = ChatMessageError(
|
||||
sessionId=MOCK_UUID, # No session ID for document uploads
|
||||
content=f"Job with ID '{job_id}' not found"
|
||||
)
|
||||
yield error_message
|
||||
return
|
||||
job = Job.model_validate(job_data)
|
||||
|
||||
uninitalized = False
|
||||
requirements = get_requirements_list(job)
|
||||
|
||||
logger.info(f"🔍 Checking skill match for candidate {candidate.username} against job {job.id}'s {len(requirements)} requirements.")
|
||||
for req in requirements:
|
||||
skill = req.get('requirement', None)
|
||||
if not skill:
|
||||
logger.warning(f"⚠️ No 'requirement' found in entry: {req}")
|
||||
continue
|
||||
cache_key = get_skill_cache_key(candidate.id, skill)
|
||||
assessment : SkillAssessment | None = await database.get_cached_skill_match(cache_key)
|
||||
if not assessment:
|
||||
logger.info(f"💾 No cached skill match data: {cache_key}, {candidate.id}, {skill}")
|
||||
uninitalized = True
|
||||
break
|
||||
|
||||
if assessment and assessment.skill.lower() != skill.lower():
|
||||
logger.warning(f"❌ Cached skill match for {candidate.username} does not match requested skill: {assessment.skill} != {skill} ({cache_key}).")
|
||||
uninitalized = True
|
||||
break
|
||||
|
||||
logger.info(f"✅ Assessment found for {candidate.username} skill {assessment.skill}: {cache_key}")
|
||||
skills.append(assessment)
|
||||
|
||||
if uninitalized:
|
||||
logger.error("❌ Uninitialized skill match data, cannot generate resume")
|
||||
error_message = ChatMessageError(
|
||||
sessionId=MOCK_UUID, # No session ID for document uploads
|
||||
content=f"Uninitialized skill match data, cannot generate resume"
|
||||
)
|
||||
yield error_message
|
||||
return
|
||||
|
||||
candidate = Candidate.model_validate(candidate_data)
|
||||
|
||||
logger.info(f"🔍 Generating resume for candidate {candidate.username}")
|
||||
logger.info(f"🔍 Generating resume for candidate {candidate.username}, job {job.id}, with {len(skills)} skills.")
|
||||
|
||||
async with entities.get_candidate_entity(candidate=candidate) as candidate_entity:
|
||||
agent = candidate_entity.get_or_create_agent(agent_type=ChatContextType.GENERATE_RESUME)
|
||||
|
Loading…
x
Reference in New Issue
Block a user