From a46172d696c72d1ba5c45f7db0865309ad8e382f Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Thu, 10 Jul 2025 17:27:38 -0700 Subject: [PATCH] Added job regenerate --- docker-compose.yml | 10 +- frontend/src/components/ui/JobInfo.tsx | 48 ++++-- frontend/src/components/ui/JobsView.tsx | 142 +++++++---------- frontend/src/components/ui/ResumeInfo.tsx | 2 +- frontend/src/pages/JobsViewPage.tsx | 103 +++++++++++-- frontend/src/services/api-client.ts | 13 ++ src/backend/agents/base.py | 5 - src/backend/agents/generate_resume.py | 5 +- src/backend/routes/jobs.py | 180 +++++++++++++++++----- 9 files changed, 352 insertions(+), 156 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index fdc591d..73fd5bf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/src/components/ui/JobInfo.tsx b/frontend/src/components/ui/JobInfo.tsx index 83ef0f9..54d9809 100644 --- a/frontend/src/components/ui/JobInfo.tsx +++ b/frontend/src/components/ui/JobInfo.tsx @@ -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 = (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 = (props: JobInfoProps) => { setAdminStatusType(status.activity); setAdminStatus(status.content); }, - onMessage: async (jobMessage: Types.JobRequirementsMessage): Promise => { - 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 => { + 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 = (props: JobInfoProps) => { setAdminStatus(null); }, }; - apiClient.createJobFromDescription(activeJob.description, jobStatusHandlers); + apiClient.regenerateJob(activeJob, jobStatusHandlers); }; const renderRequirementSection = ( @@ -257,6 +256,14 @@ const JobInfo: React.FC = (props: JobInfoProps) => { position: 'relative', }} > + {onClose && !inDialog && ( + + + + + + )} + = (props: JobInfoProps) => { {variant !== 'small' && variant !== 'minimal' && ( <> - {activeJob.details && ( - - Location: {activeJob.details.location.city},{' '} + + + {job.details?.employmentType && ( + + )} + + + {activeJob.details?.location && ( + + 📍 {activeJob.details.location.city},{' '} {activeJob.details.location.state || activeJob.details.location.country} )} diff --git a/frontend/src/components/ui/JobsView.tsx b/frontend/src/components/ui/JobsView.tsx index a82760a..4da9b77 100644 --- a/frontend/src/components/ui/JobsView.tsx +++ b/frontend/src/components/ui/JobsView.tsx @@ -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 { // 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; onJobDelete?: (job: Types.Job) => Promise; selectable?: boolean; showActions?: boolean; @@ -90,88 +93,11 @@ const Transition = React.forwardRef(function Transition( return ; }); -const JobInfoPanel: React.FC<{ job: Types.Job; onClose?: () => void; inDialog?: boolean }> = ({ - job, - onClose, - inDialog = false, -}) => ( - - - - {job.title} - - {onClose && !inDialog && ( - - - - )} - - - - {job.company} - - - - - {job.details?.employmentType && ( - - )} - - - {job.details?.location && ( - - 📍 {job.details.location.city}, {job.details.location.state || job.details.location.country} - - )} - - - - {job.requirements && - job.requirements.technicalSkills && - job.requirements.technicalSkills.required && - job.requirements.technicalSkills.required.length > 0 && ( - - - Required Skills - - - {job.requirements.technicalSkills.required.map(skill => ( - - ))} - - - )} - - - - Posted: {job.createdAt?.toLocaleDateString()} - - - Updated: {job.updatedAt?.toLocaleDateString()} - - {/* {job.views && ( - - Views: {job.views} - - )} */} - - -); - const JobsView: React.FC = ({ onJobSelect, onJobView, onJobEdit, + onJobRegenerate, onJobDelete, selectable = true, showActions = true, @@ -526,7 +452,7 @@ const JobsView: React.FC = ({ ) : ( - jobs.map(job => { + jobs.map((job, index) => { const isItemSelected = isSelected(job.id || ''); return ( = ({ )} + {onJobRegenerate && ( + + => { + const generatedJob = await onJobRegenerate(job); + jobs[index] = generatedJob; + setJobs([...jobs]); + }} + > + + + + )} {onJobDelete && ( = ({ ); return ( - + - {tableContent} + + {tableContent} + {detailsPanelOpen && !isMobile && ( - + {selectedJob ? ( - { console.log('Closing JobInfoPanel'); @@ -709,7 +679,7 @@ const JobsView: React.FC = ({ - {selectedJob && } + {selectedJob && } ); diff --git a/frontend/src/components/ui/ResumeInfo.tsx b/frontend/src/components/ui/ResumeInfo.tsx index b5c3577..9e7866b 100644 --- a/frontend/src/components/ui/ResumeInfo.tsx +++ b/frontend/src/components/ui/ResumeInfo.tsx @@ -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'; diff --git a/frontend/src/pages/JobsViewPage.tsx b/frontend/src/pages/JobsViewPage.tsx index 0e23fef..4533929 100644 --- a/frontend/src/pages/JobsViewPage.tsx +++ b/frontend/src/pages/JobsViewPage.tsx @@ -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 = (props: ProgressDialogProps) => { + const { isOpen, title, status, statusType } = props; + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down('md')); + return ( + + {title} + + + {statusType && } + + {status || 'Processing...'} + + + {status && } + + + ); +}; + const JobsViewPage: React.FC = (props: JobsViewPageProps) => { const { sx } = props; const { apiClient } = useAuth(); + const [status, setStatus] = useState(null); + const [statusType, setStatusType] = useState(null); return ( - 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 => { - await apiClient.deleteJob(job.id || ''); - }} - selectable={true} - showActions={true} - sx={sx} - /> + <> + 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 => { + 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 => { + 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 => { + await apiClient.deleteJob(job.id || ''); + }} + selectable={true} + showActions={true} + sx={{ maxHeight: '100%', ...sx }} + /> + + ); }; diff --git a/frontend/src/services/api-client.ts b/frontend/src/services/api-client.ts index 0ccba4d..3ffae80 100644 --- a/frontend/src/services/api-client.ts +++ b/frontend/src/services/api-client.ts @@ -592,6 +592,19 @@ class ApiClient { return handleApiResponse(response); } + regenerateJob( + job: Types.Job, + streamingOptions?: StreamingOptions + ): StreamingResponse { + const body = JSON.stringify(formatApiRequest(job)); + return this.streamify( + '/jobs/regenerate', + body, + streamingOptions, + 'Job' + ); + } + async uploadCandidateProfile(file: File): Promise { const formData = new FormData(); formData.append('file', file); diff --git a/src/backend/agents/base.py b/src/backend/agents/base.py index d3b4094..b733298 100644 --- a/src/backend/agents/base.py +++ b/src/backend/agents/base.py @@ -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 = "" diff --git a/src/backend/agents/generate_resume.py b/src/backend/agents/generate_resume.py index 1b15145..0d26b0f 100644 --- a/src/backend/agents/generate_resume.py +++ b/src/backend/agents/generate_resume.py @@ -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. diff --git a/src/backend/routes/jobs.py b/src/backend/routes/jobs.py index 10dd98f..ae6ce94 100644 --- a/src/backend/routes/jobs.py +++ b/src/backend/routes/jobs.py @@ -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(