From e0ed154476a7383d6a17eb51b89b931fc8ce9281 Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Wed, 11 Jun 2025 23:04:43 -0700 Subject: [PATCH] Added JobViewer --- frontend/src/components/JobCreator.tsx | 46 ++- frontend/src/components/ResumeGenerator.tsx | 17 +- frontend/src/components/Scrollable.tsx | 1 + frontend/src/components/ui/JobInfo.tsx | 19 +- frontend/src/components/ui/JobPicker.tsx | 2 +- frontend/src/components/ui/JobViewer.tsx | 318 ++++++++++++++++++++ frontend/src/config/navigationConfig.tsx | 12 + frontend/src/pages/JobAnalysisPage.tsx | 2 +- frontend/src/services/api-client.ts | 10 +- src/backend/agents/generate_resume.py | 16 + src/backend/main.py | 69 ++++- 11 files changed, 479 insertions(+), 33 deletions(-) create mode 100644 frontend/src/components/ui/JobViewer.tsx diff --git a/frontend/src/components/JobCreator.tsx b/frontend/src/components/JobCreator.tsx index 27f79ee..471b3d6 100644 --- a/frontend/src/components/JobCreator.tsx +++ b/frontend/src/components/JobCreator.tsx @@ -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 ( {/* Upload Section */} @@ -477,8 +481,33 @@ const JobCreator = (props: JobCreatorProps) => { }}> {job === null && renderJobCreation()} {job && - - + + *:not(.Scrollable)": { + flexShrink: 0, /* Prevent shrinking */ + }, + position: "relative", + border: "3px solid purple", + }}> + + + + - - - - + } diff --git a/frontend/src/components/ResumeGenerator.tsx b/frontend/src/components/ResumeGenerator.tsx index 10e339a..90aa2a1 100644 --- a/frontend/src/components/ResumeGenerator.tsx +++ b/frontend/src/components/ResumeGenerator.tsx @@ -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 = (props: ResumeGeneratorP const [tabValue, setTabValue] = useState('resume'); const [status, setStatus] = useState(''); const [statusType, setStatusType] = useState(null); + const [error, setError] = useState(null); const handleTabChange = (event: React.SyntheticEvent, newValue: string) => { setTabValue(newValue); @@ -58,7 +61,7 @@ const ResumeGenerator: React.FC = (props: ResumeGeneratorP setStatusType("thinking"); setStatus("Starting resume generation..."); - const generateResumeHandlers = { + const generateResumeHandlers: StreamingOptions = { onMessage: (message: Types.ChatMessageResume) => { setSystemPrompt(message.systemPrompt || ''); setPrompt(message.prompt || ''); @@ -79,11 +82,17 @@ const ResumeGenerator: React.FC = (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 = (props: ResumeGeneratorP {status || 'Processing...'} - {status && } + {status && !error && } } diff --git a/frontend/src/components/Scrollable.tsx b/frontend/src/components/Scrollable.tsx index 7cb952a..48d2a42 100644 --- a/frontend/src/components/Scrollable.tsx +++ b/frontend/src/components/Scrollable.tsx @@ -29,6 +29,7 @@ const Scrollable = forwardRef((props: ScrollableProps, ref) => { p: 0, flexGrow: 1, overflow: 'auto', + position: 'relative', // backgroundColor: '#F5F5F5', ...sx, }} diff --git a/frontend/src/components/ui/JobInfo.tsx b/frontend/src/components/ui/JobInfo.tsx index b98d5be..f97387b 100644 --- a/frontend/src/components/ui/JobInfo.tsx +++ b/frontend/src/components/ui/JobInfo.tsx @@ -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 = (props: JobInfoProps) => { const { setSnack } = useAppState(); const { job } = props; @@ -50,8 +50,15 @@ const JobInfo: React.FC = (props: JobInfoProps) => { // State for description expansion const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false); + const [deleted, setDeleted] = useState(false); const descriptionRef = useRef(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 = (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 = (props: JobInfoProps) => { } Job ID: {job.id} } + {variant === 'all' && } {(variant !== 'small' && variant !== 'minimal') && <>{renderJobRequirements()}} @@ -324,7 +335,7 @@ const JobInfo: React.FC = (props: JobInfoProps) => { { e.stopPropagation(); deleteJob(job.id); }} + onClick={(e) => { e.stopPropagation(); deleteJob(job.id); setDeleted(true) }} > diff --git a/frontend/src/components/ui/JobPicker.tsx b/frontend/src/components/ui/JobPicker.tsx index 81e5344..b98be91 100644 --- a/frontend/src/components/ui/JobPicker.tsx +++ b/frontend/src/components/ui/JobPicker.tsx @@ -50,7 +50,7 @@ const JobPicker = (props: JobPickerProps) => { {jobs?.map((j, i) => { onSelect ? onSelect(j) : setSelectedJob(j); }} + onClick={() => { console.log('Selected job', j); onSelect && onSelect(j) }} sx={{ cursor: "pointer" }}> void; +} + +const JobViewer: React.FC = ({ onSelect }) => { + const navigate = useNavigate(); + const { apiClient } = useAuth(); + const { selectedJob, setSelectedJob } = useSelectedJob(); + const { setSnack } = useAppState(); + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(false); + const [sortField, setSortField] = useState('updatedAt'); + const [sortOrder, setSortOrder] = useState('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' ? : ; + }; + + return ( + + {/* Left Panel - Job List */} + + + + Jobs ({jobs.length}) + + + + Sort by + + + + + + + + + handleSort('company')} + > + + + Company + {getSortIcon('company')} + + + handleSort('title')} + > + + + Title + {getSortIcon('title')} + + + handleSort('updatedAt')} + > + + + Updated + {getSortIcon('updatedAt')} + + + Status + + + + {sortedJobs.map((job) => ( + handleJobSelect(job)} + sx={{ + cursor: 'pointer', + '&.Mui-selected': { + backgroundColor: 'action.selected', + } + }} + > + + + {job.company || 'N/A'} + + {job.details?.location && ( + + {job.details.location.city}, {job.details.location.state || job.details.location.country} + + )} + + + + {job.title || 'N/A'} + + {job.details?.employmentType && ( + + )} + + + + {formatDate(job.updatedAt)} + + {job.createdAt && ( + + Created: {formatDate(job.createdAt)} + + )} + + + + + + ))} + +
+
+
+ + {/* Right Panel - Job Details */} + + + + Job Details + + + + + {selectedJob ? ( + + ) : ( + + + Select a job to view details + + + )} + + +
+ ); +}; + +export { JobViewer }; \ No newline at end of file diff --git a/frontend/src/config/navigationConfig.tsx b/frontend/src/config/navigationConfig.tsx index 30ce38b..97b5f2d 100644 --- a/frontend/src/config/navigationConfig.tsx +++ b/frontend/src/config/navigationConfig.tsx @@ -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: , + path: "/candidate/jobs/:jobId?", + component: , + userTypes: ["candidate"], + userMenuGroup: "profile", + showInNavigation: false, + showInUserMenu: true, + }, { id: "candidate-docs", label: "Content", diff --git a/frontend/src/pages/JobAnalysisPage.tsx b/frontend/src/pages/JobAnalysisPage.tsx index 17c233d..9772a14 100644 --- a/frontend/src/pages/JobAnalysisPage.tsx +++ b/frontend/src/pages/JobAnalysisPage.tsx @@ -221,7 +221,7 @@ const JobAnalysisPage: React.FC = (props: BackstoryPageProps // Render function for the job description step const renderJobDescription = () => { - return ( + return ( } label="Select Job" /> diff --git a/frontend/src/services/api-client.ts b/frontend/src/services/api-client.ts index 27fd548..603dba1 100644 --- a/frontend/src/services/api-client.ts +++ b/frontend/src/services/api-client.ts @@ -663,7 +663,7 @@ class ApiClient { return this.streamify('/jobs/from-content', body, streamingOptions, "JobRequirementsMessage"); } - async createJob(job: Omit): Promise { + async createJob(job: Omit): Promise { 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(response, 'JobRequirementsMessage'); + return this.handleApiResponseWithConversion(response, 'Job'); } async getJob(id: string): Promise { @@ -920,14 +920,14 @@ class ApiClient { return this.streamify(`/jobs/requirements/${jobId}`, null, streamingOptions, "DocumentMessage"); } - generateResume(candidateId: string, skills: Types.SkillAssessment[], streamingOptions?: StreamingOptions): StreamingResponse { - const body = JSON.stringify(skills); + generateResume(candidateId: string, jobId: string, streamingOptions?: StreamingOptions): StreamingResponse { streamingOptions = { ...streamingOptions, headers: this.defaultHeaders, }; - return this.streamify(`/candidates/${candidateId}/generate-resume`, body, streamingOptions, "ChatMessageResume"); + return this.streamify(`/candidates/${candidateId}/${jobId}/generate-resume`, null, streamingOptions, "ChatMessageResume"); } + candidateMatchForRequirement(candidate_id: string, requirement: string, streamingOptions?: StreamingOptions) : StreamingResponse { diff --git a/src/backend/agents/generate_resume.py b/src/backend/agents/generate_resume.py index 2a02eac..417f0b0 100644 --- a/src/backend/agents/generate_resume.py +++ b/src/backend/agents/generate_resume.py @@ -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. diff --git a/src/backend/main.py b/src/backend/main.py index 2f1e6e7..96ca28c 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -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)