Added job regenerate

This commit is contained in:
James Ketr 2025-07-10 17:27:38 -07:00
parent 80b63fe0e1
commit a46172d696
9 changed files with 352 additions and 156 deletions

View File

@ -12,10 +12,14 @@ services:
environment:
- PRODUCTION=0
- FRONTEND_URL=https://backstory-beta.ketrenos.com
- MODEL_NAME=${MODEL_NAME:-qwen2.5:7b}
- REDIS_URL=redis://redis:6379
- REDIS_DB=0
- SSL_ENABLED=true
# - DEFAULT_LLM_PROVIDER=anthropic
# - MODEL_NAME=claude-3-5-haiku-latest
- DEFAULT_LLM_PROVIDER=openai
- MODEL_NAME=${MODEL_NAME:-qwen2.5:7b}
- OPENAI_URL=http://ollama:11434
devices:
- /dev/dri:/dev/dri
depends_on:
@ -52,10 +56,12 @@ services:
environment:
- PRODUCTION=1
- FRONTEND_URL=https://backstory.ketrenos.com
- MODEL_NAME=${MODEL_NAME:-qwen2.5:7b}
- REDIS_URL=redis://redis:6379
- REDIS_DB=1
- SSL_ENABLED=false
- DEFAULT_LLM_PROVIDER=openai
- MODEL_NAME=${MODEL_NAME:-qwen2.5:7b}
- OPENAI_URL=http://ollama:11434
devices:
- /dev/dri:/dev/dri
depends_on:

View File

@ -13,6 +13,7 @@ import {
} from '@mui/material';
import { Card, CardContent, Divider, useTheme } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import CloseIcon from '@mui/icons-material/Close';
import { useMediaQuery } from '@mui/material';
import { Job } from 'types/types';
import { rest } from 'lodash';
@ -32,12 +33,14 @@ interface JobInfoProps {
action?: string;
elevation?: number;
variant?: 'minimal' | 'small' | 'normal' | 'all' | null;
onClose?: () => void;
inDialog?: boolean; // Whether this is rendered in a dialog
}
const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
const { setSnack } = useAppState();
const { user, apiClient } = useAuth();
const { sx, variant = 'normal', job } = props;
const { sx, variant = 'normal', job, onClose, inDialog = false } = props;
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')) || variant === 'minimal';
const isAdmin = user?.isAdmin;
@ -99,13 +102,9 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
setAdminStatusType(status.activity);
setAdminStatus(status.content);
},
onMessage: async (jobMessage: Types.JobRequirementsMessage): Promise<void> => {
const newJob: Types.Job = jobMessage.job;
console.log('onMessage - job', newJob);
newJob.id = job.id;
newJob.createdAt = job.createdAt;
const updatedJob: Types.Job = await apiClient.updateJob(job.id || '', newJob);
setActiveJob(updatedJob);
onMessage: async (jobRequirementsMessage: Types.JobRequirementsMessage): Promise<void> => {
console.log('onMessage - job', jobRequirementsMessage);
setActiveJob(jobRequirementsMessage.job);
},
onError: (error: Types.ChatMessageError): void => {
console.log('onError', error);
@ -117,7 +116,7 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
setAdminStatus(null);
},
};
apiClient.createJobFromDescription(activeJob.description, jobStatusHandlers);
apiClient.regenerateJob(activeJob, jobStatusHandlers);
};
const renderRequirementSection = (
@ -257,6 +256,14 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
position: 'relative',
}}
>
{onClose && !inDialog && (
<Box sx={{ position: 'absolute', top: 0, right: 0, zIndex: 1 }}>
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</Box>
)}
<Box
sx={{
display: 'flex',
@ -349,9 +356,26 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
{variant !== 'small' && variant !== 'minimal' && (
<>
{activeJob.details && (
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Location:</strong> {activeJob.details.location.city},{' '}
<Box sx={{ mb: 2 }}>
<Chip
label={job.details?.isActive ? 'Active' : 'Inactive'}
color={job.details?.isActive ? 'success' : 'default'}
size="small"
sx={{ mr: 1 }}
/>
{job.details?.employmentType && (
<Chip
label={job.details.employmentType}
variant="outlined"
size="small"
sx={{ mr: 1 }}
/>
)}
</Box>
{activeJob.details?.location && (
<Typography variant="body2" color="text.secondary" gutterBottom>
📍 {activeJob.details.location.city},{' '}
{activeJob.details.location.state || activeJob.details.location.country}
</Typography>
)}

View File

@ -43,6 +43,7 @@ import {
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
@ -50,6 +51,7 @@ 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';
// async searchJobs(query: string): Promise<Types.PaginatedResponse> {
// const results = await this.getJobs();
@ -73,6 +75,7 @@ interface JobsViewProps {
onJobSelect?: (selectedJobs: Types.Job[]) => void;
onJobView?: (job: Types.Job) => void;
onJobEdit?: (job: Types.Job) => void;
onJobRegenerate?: (job: Types.Job) => Promise<Types.Job>;
onJobDelete?: (job: Types.Job) => Promise<void>;
selectable?: boolean;
showActions?: boolean;
@ -90,88 +93,11 @@ const Transition = React.forwardRef(function Transition(
return <Slide direction="up" ref={ref} {...props} />;
});
const JobInfoPanel: React.FC<{ job: Types.Job; onClose?: () => void; inDialog?: boolean }> = ({
job,
onClose,
inDialog = false,
}) => (
<Box
sx={{
p: inDialog ? 2 : 1.5,
height: '100%',
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Typography variant="h5" component="h1" gutterBottom>
{job.title}
</Typography>
{onClose && !inDialog && (
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
)}
</Box>
<Typography variant="h6" color="primary" gutterBottom>
{job.company}
</Typography>
<Box sx={{ mb: 2 }}>
<Chip
label={job.details?.isActive ? 'Active' : 'Inactive'}
color={job.details?.isActive ? 'success' : 'default'}
size="small"
sx={{ mr: 1 }}
/>
{job.details?.employmentType && (
<Chip label={job.details.employmentType} variant="outlined" size="small" sx={{ mr: 1 }} />
)}
</Box>
{job.details?.location && (
<Typography variant="body2" color="text.secondary" gutterBottom>
📍 {job.details.location.city}, {job.details.location.state || job.details.location.country}
</Typography>
)}
<StyledMarkdown content={job.description} />
{job.requirements &&
job.requirements.technicalSkills &&
job.requirements.technicalSkills.required &&
job.requirements.technicalSkills.required.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Required Skills
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{job.requirements.technicalSkills.required.map(skill => (
<Chip key={skill} label={skill} size="small" variant="outlined" />
))}
</Box>
</Box>
)}
<Box sx={{ mt: 3, pt: 2, borderTop: 1, borderColor: 'divider' }}>
<Typography variant="body2" color="text.secondary">
Posted: {job.createdAt?.toLocaleDateString()}
</Typography>
<Typography variant="body2" color="text.secondary">
Updated: {job.updatedAt?.toLocaleDateString()}
</Typography>
{/* {job.views && (
<Typography variant="body2" color="text.secondary">
Views: {job.views}
</Typography>
)} */}
</Box>
</Box>
);
const JobsView: React.FC<JobsViewProps> = ({
onJobSelect,
onJobView,
onJobEdit,
onJobRegenerate,
onJobDelete,
selectable = true,
showActions = true,
@ -526,7 +452,7 @@ const JobsView: React.FC<JobsViewProps> = ({
</TableCell>
</TableRow>
) : (
jobs.map(job => {
jobs.map((job, index) => {
const isItemSelected = isSelected(job.id || '');
return (
<TableRow
@ -607,6 +533,20 @@ const JobsView: React.FC<JobsViewProps> = ({
</IconButton>
</Tooltip>
)}
{onJobRegenerate && (
<Tooltip title="Regenerate Requirements">
<IconButton
size="small"
onClick={async (): Promise<void> => {
const generatedJob = await onJobRegenerate(job);
jobs[index] = generatedJob;
setJobs([...jobs]);
}}
>
<ModelTraining fontSize="small" />
</IconButton>
</Tooltip>
)}
{onJobDelete && (
<Tooltip title="Delete Job">
<IconButton
@ -644,20 +584,50 @@ const JobsView: React.FC<JobsViewProps> = ({
);
return (
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'row', position: 'relative', ...sx }}>
<Box
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'row',
position: 'relative',
...sx,
}}
>
<Scrollable
sx={{ display: 'flex', flex: 1, flexDirection: 'column', height: '100%', width: '100%' }}
sx={{
display: 'flex',
flex: 1,
flexDirection: 'column',
height: '100%',
width: '100%',
}}
>
<Paper sx={{ flex: 1, display: 'flex', flexDirection: 'column' }}>{tableContent}</Paper>
<Paper sx={{ flex: 1, display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
{tableContent}
</Paper>
</Scrollable>
{detailsPanelOpen && !isMobile && (
<Scrollable
sx={{ display: 'flex', flex: 1, flexDirection: 'row', height: '100%', width: '100%' }}
sx={{
display: 'flex',
flex: 1,
flexDirection: 'row',
height: '100%',
width: '100%',
}}
>
<Paper sx={{ flex: 1, ml: 1 }}>
<Paper
sx={{
flex: 1,
ml: 1,
flexGrow: 1,
height: 'max-content',
}}
>
{selectedJob ? (
<JobInfoPanel
<JobInfo
variant="all"
job={selectedJob}
onClose={(): void => {
console.log('Closing JobInfoPanel');
@ -709,7 +679,7 @@ const JobsView: React.FC<JobsViewProps> = ({
</Box>
</Toolbar>
</AppBar>
{selectedJob && <JobInfoPanel job={selectedJob} inDialog />}
{selectedJob && <JobInfo variant="all" job={selectedJob} inDialog />}
</Dialog>
</Box>
);

View File

@ -55,7 +55,7 @@ import { parsePhoneNumberFromString } from 'libphonenumber-js';
import { useReactToPrint } from 'react-to-print';
import { useAuth } from 'hooks/AuthContext';
import { useAppState, useSelectedJob } from 'hooks/GlobalContext';
import { useAppState } from 'hooks/GlobalContext';
import { StyledMarkdown } from 'components/StyledMarkdown';
import { Resume } from 'types/types';
import { BackstoryTextField } from 'components/BackstoryTextField';

View File

@ -1,29 +1,106 @@
import React from 'react';
import { SxProps } from '@mui/material';
import React, { useState } from 'react';
import {
SxProps,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
useTheme,
useMediaQuery,
Typography,
LinearProgress,
} from '@mui/material';
import * as Types from 'types/types'; // Adjust the import path as necessary
import { useAuth } from 'hooks/AuthContext';
import { JobsView } from 'components/ui/JobsView';
import { StatusBox, StatusIcon } from 'components/ui/StatusIcon';
interface JobsViewPageProps {
sx?: SxProps;
}
interface ProgressDialogProps {
isOpen: boolean;
title: string;
status: string;
statusType: Types.ApiActivityType;
}
const ProgressDialog: React.FC<ProgressDialogProps> = (props: ProgressDialogProps) => {
const { isOpen, title, status, statusType } = props;
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down('md'));
return (
<Dialog fullScreen={fullScreen} open={isOpen} aria-labelledby="responsive-dialog-title">
<DialogTitle id="responsive-dialog-title">{title}</DialogTitle>
<DialogContent>
<StatusBox>
{statusType && <StatusIcon type={statusType} />}
<Typography variant="body2" sx={{ ml: 1, minWidth: '200px', maxWidth: '200px' }}>
{status || 'Processing...'}
</Typography>
</StatusBox>
{status && <LinearProgress sx={{ mt: 1 }} />}
</DialogContent>
</Dialog>
);
};
const JobsViewPage: React.FC<JobsViewPageProps> = (props: JobsViewPageProps) => {
const { sx } = props;
const { apiClient } = useAuth();
const [status, setStatus] = useState<string | null>(null);
const [statusType, setStatusType] = useState<Types.ApiActivityType | null>(null);
return (
<JobsView
onJobSelect={(selectedJobs: Types.Job[]): void => console.log('Selected:', selectedJobs)}
onJobView={(job: Types.Job): void => console.log('View job:', job)}
onJobEdit={(job: Types.Job): void => console.log('Edit job:', job)}
onJobDelete={async (job: Types.Job): Promise<void> => {
await apiClient.deleteJob(job.id || '');
}}
selectable={true}
showActions={true}
sx={sx}
/>
<>
<JobsView
onJobSelect={(selectedJobs: Types.Job[]): void => console.log('Selected:', selectedJobs)}
onJobView={(job: Types.Job): void => console.log('View job:', job)}
onJobEdit={(job: Types.Job): void => console.log('Edit job:', job)}
onJobRegenerate={async (job: Types.Job): Promise<Types.Job> => {
setStatus('Re-extracting Job information...');
const jobStatusHandlers = {
onStatus: (status: Types.ChatMessageStatus): void => {
console.log('status:', status.content);
setStatusType(status.activity);
setStatus(status.content);
},
onMessage: async (
jobRequirementsMessage: Types.JobRequirementsMessage
): Promise<void> => {
console.log('onMessage - job', jobRequirementsMessage);
setStatusType(null);
setStatus(null);
},
onError: (error: Types.ChatMessageError): void => {
console.log('onError', error);
setStatusType(null);
setStatus(null);
},
onComplete: (): void => {
setStatusType(null);
setStatus(null);
},
};
const result = apiClient.regenerateJob(job, jobStatusHandlers);
return (await result.promise).job;
}}
onJobDelete={async (job: Types.Job): Promise<void> => {
await apiClient.deleteJob(job.id || '');
}}
selectable={true}
showActions={true}
sx={{ maxHeight: '100%', ...sx }}
/>
<ProgressDialog
isOpen={!!status}
title="Job Processing"
status={status || ''}
statusType={statusType || 'info'}
/>
</>
);
};

View File

@ -592,6 +592,19 @@ class ApiClient {
return handleApiResponse<DeleteResponse>(response);
}
regenerateJob(
job: Types.Job,
streamingOptions?: StreamingOptions<Types.JobRequirementsMessage>
): StreamingResponse<Types.JobRequirementsMessage> {
const body = JSON.stringify(formatApiRequest(job));
return this.streamify<Types.JobRequirementsMessage>(
'/jobs/regenerate',
body,
streamingOptions,
'Job'
);
}
async uploadCandidateProfile(file: File): Promise<boolean> {
const formData = new FormData();
formData.append('file', file);

View File

@ -585,11 +585,6 @@ Content: {content}
LLMMessage(role="user", content=prompt),
]
status_message = ChatMessageStatus(
session_id=session_id, activity=ApiActivityType.GENERATING, content="Generating response..."
)
yield status_message
logger.info(f"Message options: {options.model_dump(exclude_unset=True)}")
response = None
content = ""

View File

@ -74,7 +74,10 @@ class GenerateResume(Agent):
# Build the system prompt
system_prompt = f"""You are a professional resume writer with expertise in highlighting candidate strengths and experiences.
Create a polished, concise, and ATS-friendly resume for the candidate based on the assessment data provided. Rephrase skills to avoid
Create a polished, concise, and ATS-friendly resume for the candidate based on the assessment data provided.
Rephrase skills in the SKILL ASSESSMENT RESULTS section to avoid direct duplication from the assessment.
Do not use the exact phrases or wording from the assessment, but rather integrate the skills naturally into the resume without any
direct duplication from the assessment.
Do not provide header information like name, email, or phone number in the resume, as that information will be added later.

View File

@ -90,6 +90,45 @@ content is already in markdown format, return it as is.
yield chat_message
return
async def generate_job_requirements(database: RedisDatabase, candidate_entity: CandidateEntity, markdown: str):
chat_agent = candidate_entity.get_or_create_agent(agent_type=ChatContextType.JOB_REQUIREMENTS)
if not chat_agent:
error_message = ChatMessageError(
session_id=MOCK_UUID, # No session ID for document uploads
content="No agent found for job requirements chat type",
)
yield error_message
return
status_message = ChatMessageStatus(
session_id=MOCK_UUID, # No session ID for document uploads
content="Analyzing document for company and requirement details...",
activity=ApiActivityType.SEARCHING,
)
yield status_message
message = None
async for message in chat_agent.generate(
llm=llm_manager.get_llm(database.redis),
model=defines.model,
session_id=MOCK_UUID,
prompt=markdown,
database=database,
):
if message.status != ApiStatusType.DONE:
yield message
if not message or not isinstance(message, JobRequirementsMessage):
error_message = ChatMessageError(
session_id=MOCK_UUID, # No session ID for document uploads
content="Job extraction did not convert successfully",
)
yield error_message
return
job_requirements: JobRequirementsMessage = message
logger.info(f"✅ Successfully generated job requirements for job {job_requirements.id}")
yield job_requirements
async def create_job_from_content(database: RedisDatabase, current_user: Candidate, content: str):
status_message = ChatMessageStatus(
@ -115,43 +154,10 @@ async def create_job_from_content(database: RedisDatabase, current_user: Candida
return
markdown_message = message
chat_agent = candidate_entity.get_or_create_agent(agent_type=ChatContextType.JOB_REQUIREMENTS)
if not chat_agent:
error_message = ChatMessageError(
session_id=MOCK_UUID, # No session ID for document uploads
content="No agent found for job requirements chat type",
)
yield error_message
return
status_message = ChatMessageStatus(
session_id=MOCK_UUID, # No session ID for document uploads
content="Analyzing document for company and requirement details...",
activity=ApiActivityType.SEARCHING,
)
yield status_message
message = None
async for message in chat_agent.generate(
llm=llm_manager.get_llm(database.redis),
model=defines.model,
session_id=MOCK_UUID,
prompt=markdown_message.content,
database=database,
async for message in generate_job_requirements(
database=database, candidate_entity=candidate_entity, markdown=markdown_message.content
):
if message.status != ApiStatusType.DONE:
yield message
if not message or not isinstance(message, JobRequirementsMessage):
error_message = ChatMessageError(
session_id=MOCK_UUID, # No session ID for document uploads
content="Job extraction did not convert successfully",
)
yield error_message
return
job_requirements: JobRequirementsMessage = message
logger.info(f"✅ Successfully generated job requirements for job {job_requirements.id}")
yield job_requirements
yield message
return
@ -242,6 +248,108 @@ async def update_job(
logger.error(f"❌ Update job error: {e}")
return JSONResponse(status_code=400, content=create_error_response("UPDATE_FAILED", str(e)))
@router.post("/regenerate")
async def regenerate_job(
job: Job = Body(...), current_user=Depends(get_current_user), database: RedisDatabase = Depends(get_database)
):
"""Regenerate requirements for a job (used if prompts are changed or to test different models)"""
async def content_stream_generator():
# Verify user is a candidate
if current_user.user_type != "candidate":
logger.warning(f"⚠️ Unauthorized upload attempt by user type: {current_user.user_type}")
error_message = ChatMessageError(
session_id=MOCK_UUID, # No session ID for document uploads
content="Only candidates can upload documents",
)
yield error_message
return
if job.owner_id != current_user.id:
logger.warning(f"⚠️ Unauthorized job regeneration attempt by user {current_user.id} on job {job.id}")
error_message = ChatMessageError(
session_id=MOCK_UUID, # No session ID for document uploads
content="Cannot regenerate another user's job",
)
yield error_message
return
last_yield_was_streaming = False
async with entities.get_candidate_entity(candidate=current_user) as candidate_entity:
message = None
async for message in generate_job_requirements(
database=database, candidate_entity=candidate_entity, markdown=job.description
):
if message.status != ApiStatusType.STREAMING:
last_yield_was_streaming = False
else:
if last_yield_was_streaming:
continue
last_yield_was_streaming = True
logger.info(f"📄 Yielding job regeneration message status: {message.status}")
if message.status != ApiStatusType.DONE:
yield message
if isinstance(message, JobRequirementsMessage):
job_requirements_message: JobRequirementsMessage = message
job_requirements_message.job.id = job.id
job_requirements_message.job.owner = job.owner
job_requirements_message.job.updated_at = datetime.now(UTC)
logger.info(f"🔄 Saving updated job {job.id}")
await database.set_job(job.id, job_requirements_message.job.model_dump())
yield job_requirements_message
else:
logger.error("❌ Failed to regenerate job requirements")
error_message = ChatMessageError(
session_id=MOCK_UUID, # No session ID for document uploads
content="Failed to regenerate job requirements",
)
yield error_message
return
try:
async def to_json(method):
try:
async for message in method:
json_data = message.model_dump(mode="json", by_alias=True)
json_str = json.dumps(json_data)
yield f"data: {json_str}\n\n".encode("utf-8")
except Exception as e:
logger.error(backstory_traceback.format_exc())
logger.error(f"Error in to_json conversion: {e}")
return
return StreamingResponse(
to_json(content_stream_generator()),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # Nginx
"X-Content-Type-Options": "nosniff",
"Access-Control-Allow-Origin": "*", # Adjust for your CORS needs
"Transfer-Encoding": "chunked",
},
)
except Exception as e:
logger.error(backstory_traceback.format_exc())
logger.error(f"❌ Job regeneration error: {e}")
return StreamingResponse(
iter(
[
json.dumps(
ChatMessageError(
session_id=MOCK_UUID, # No session ID for document uploads
content="Failed to regenerate job requirements",
).model_dump(by_alias=True)
).encode("utf-8")
]
),
media_type="text/event-stream",
)
@router.post("/from-content")
async def create_job_from_description(