From 4f4187eba49e83481386a2cadaf3eea0e08e7f36 Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Mon, 9 Jun 2025 19:57:08 -0700 Subject: [PATCH] Working on job creation flow --- frontend/src/components/JobCreator.tsx | 85 +++++++++++--------------- frontend/src/components/ui/JobInfo.tsx | 6 +- frontend/src/pages/LoginPage.tsx | 2 +- frontend/src/services/api-client.ts | 30 ++++----- src/backend/agents/base.py | 11 ++++ src/backend/agents/generate_persona.py | 9 --- src/backend/agents/job_requirements.py | 25 ++++++-- src/backend/main.py | 51 +++++++++++++--- src/backend/models.py | 28 ++++----- 9 files changed, 143 insertions(+), 104 deletions(-) diff --git a/frontend/src/components/JobCreator.tsx b/frontend/src/components/JobCreator.tsx index 82ccadd..39e15a3 100644 --- a/frontend/src/components/JobCreator.tsx +++ b/frontend/src/components/JobCreator.tsx @@ -1,27 +1,19 @@ -import React, { useState, useEffect, useRef, JSX } from 'react'; +import React, { useState, useRef, JSX } from 'react'; import { Box, Button, Typography, - Paper, TextField, Grid, - Dialog, - DialogTitle, - DialogContent, - DialogContentText, - DialogActions, - IconButton, useTheme, useMediaQuery, Chip, - Divider, Card, CardContent, CardHeader, LinearProgress, Stack, - Alert + Paper, } from '@mui/material'; import { SyncAlt, @@ -36,21 +28,22 @@ import { CloudUpload, Description, Business, - LocationOn, Work, CheckCircle, Star } from '@mui/icons-material'; import { styled } from '@mui/material/styles'; -import DescriptionIcon from '@mui/icons-material/Description'; import FileUploadIcon from '@mui/icons-material/FileUpload'; import { useAuth } from 'hooks/AuthContext'; -import { useAppState, useSelectedCandidate, useSelectedJob } from 'hooks/GlobalContext'; +import { useAppState, useSelectedJob } from 'hooks/GlobalContext'; import { BackstoryElementProps } from './BackstoryTab'; import { LoginRequired } from 'components/ui/LoginRequired'; import * as Types from 'types/types'; +import { StyledMarkdown } from './StyledMarkdown'; +import { JobInfo } from './ui/JobInfo'; +import { Scrollable } from './Scrollable'; const VisuallyHiddenInput = styled('input')({ clip: 'rect(0 0 0 0)', @@ -114,35 +107,27 @@ const getIcon = (type: Types.ApiActivityType) => { } }; -interface JobCreator extends BackstoryElementProps { +interface JobCreatorProps extends BackstoryElementProps { onSave?: (job: Types.Job) => void; } -const JobCreator = (props: JobCreator) => { +const JobCreator = (props: JobCreatorProps) => { const { user, apiClient } = useAuth(); - const { onSave } = props; - const { selectedCandidate } = useSelectedCandidate(); + const { onSave } = props; const { selectedJob, setSelectedJob } = useSelectedJob(); const { setSnack } = useAppState(); const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('sm')); - const isTablet = useMediaQuery(theme.breakpoints.down('md')); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); - const [openUploadDialog, setOpenUploadDialog] = useState(false); const [jobDescription, setJobDescription] = useState(''); const [jobRequirements, setJobRequirements] = useState(null); const [jobTitle, setJobTitle] = useState(''); const [company, setCompany] = useState(''); const [summary, setSummary] = useState(''); - const [jobLocation, setJobLocation] = useState(''); - const [jobId, setJobId] = useState(''); + const [job, setJob] = useState(null); const [jobStatus, setJobStatus] = useState(''); const [jobStatusIcon, setJobStatusIcon] = useState(<>); const [isProcessing, setIsProcessing] = useState(false); - useEffect(() => { - - }, [jobTitle, jobDescription, company]); - const fileInputRef = useRef(null); @@ -158,8 +143,10 @@ const JobCreator = (props: JobCreator) => { setJobStatusIcon(getIcon(status.activity)); setJobStatus(status.content); }, - onMessage: (job: Types.Job) => { + onMessage: (jobMessage: Types.JobRequirementsMessage) => { + const job: Types.Job = jobMessage.job console.log('onMessage - job', job); + setJob(job); setCompany(job.company || ''); setJobDescription(job.description); setSummary(job.summary || ''); @@ -333,8 +320,9 @@ const JobCreator = (props: JobCreator) => { updatedAt: new Date(), }; setIsProcessing(true); - const job = await apiClient.createJob(newJob); + const jobMessage = await apiClient.createJob(newJob); setIsProcessing(false); + const job: Types.Job = jobMessage.job; onSave ? onSave(job) : setSelectedJob(job); }; @@ -357,10 +345,6 @@ const JobCreator = (props: JobCreator) => { }; const renderJobCreation = () => { - if (!user) { - return You must be logged in; - } - return ( { }} /> */} - - - - - - @@ -548,7 +517,27 @@ const JobCreator = (props: JobCreator) => { width: "100%", display: "flex", flexDirection: "column" }}> - {selectedJob === null && renderJobCreation()} + {job === null && renderJobCreation()} + {job && + + + + + + + + + + } ); }; diff --git a/frontend/src/components/ui/JobInfo.tsx b/frontend/src/components/ui/JobInfo.tsx index 78da396..b1ea437 100644 --- a/frontend/src/components/ui/JobInfo.tsx +++ b/frontend/src/components/ui/JobInfo.tsx @@ -8,7 +8,7 @@ import { } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; import { useMediaQuery } from '@mui/material'; -import { JobFull } from 'types/types'; +import { Job, JobFull } from 'types/types'; import { CopyBubble } from "components/CopyBubble"; import { rest } from 'lodash'; import { AIBanner } from 'components/ui/AIBanner'; @@ -17,7 +17,7 @@ import { DeleteConfirmation } from '../DeleteConfirmation'; import { Build, CheckCircle, Description, Psychology, Star, Work } from '@mui/icons-material'; interface JobInfoProps { - job: JobFull; + job: Job | JobFull; sx?: SxProps; action?: string; elevation?: number; @@ -153,7 +153,7 @@ const JobInfo: React.FC = (props: JobInfoProps) => { > {variant !== "small" && <> - {job.location && + {'location' in job && Location: {job.location.city}, {job.location.state || job.location.country} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index ce0c802..4d2ac81 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -83,7 +83,7 @@ const LoginPage: React.FC = (props: BackstoryPageProps) => { Session ID: {guest.sessionId} - Created: {guest.createdAt.toLocaleString()} + Created: {guest.createdAt?.toLocaleString()} diff --git a/frontend/src/services/api-client.ts b/frontend/src/services/api-client.ts index 464a69a..62a998e 100644 --- a/frontend/src/services/api-client.ts +++ b/frontend/src/services/api-client.ts @@ -39,6 +39,7 @@ const TOKEN_STORAGE = { PENDING_VERIFICATION_EMAIL: 'pendingVerificationEmail' } as const; + // ============================ // Streaming Types and Interfaces // ============================ @@ -647,12 +648,12 @@ class ApiClient { // Job Methods with Date Conversion // ============================ - createJobFromDescription(job_description: string, streamingOptions?: StreamingOptions): StreamingResponse { + createJobFromDescription(job_description: string, streamingOptions?: StreamingOptions): StreamingResponse { const body = JSON.stringify(job_description); - return this.streamify('/jobs/from-content', body, streamingOptions); + 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', @@ -660,7 +661,7 @@ class ApiClient { body: body }); - return this.handleApiResponseWithConversion(response, 'Job'); + return this.handleApiResponseWithConversion(response, 'JobRequirementsMessage'); } async getJob(id: string): Promise { @@ -884,10 +885,10 @@ class ApiClient { 'Authorization': this.defaultHeaders['Authorization'] } }; - return this.streamify('/candidates/documents/upload', formData, streamingOptions); + return this.streamify('/candidates/documents/upload', formData, streamingOptions, "DocumentMessage"); } - createJobFromFile(file: File, streamingOptions?: StreamingOptions): StreamingResponse { + createJobFromFile(file: File, streamingOptions?: StreamingOptions): StreamingResponse { const formData = new FormData() formData.append('file', file); formData.append('filename', file.name); @@ -898,7 +899,7 @@ class ApiClient { 'Authorization': this.defaultHeaders['Authorization'] } }; - return this.streamify('/jobs/upload', formData, streamingOptions); + return this.streamify('/jobs/upload', formData, streamingOptions, "JobRequirementsMessage"); } getJobRequirements(jobId: string, streamingOptions?: StreamingOptions): StreamingResponse { @@ -906,7 +907,7 @@ class ApiClient { ...streamingOptions, headers: this.defaultHeaders, }; - return this.streamify(`/jobs/requirements/${jobId}`, null, streamingOptions); + return this.streamify(`/jobs/requirements/${jobId}`, null, streamingOptions, "DocumentMessage"); } generateResume(candidateId: string, skills: Types.SkillAssessment[], streamingOptions?: StreamingOptions): StreamingResponse { @@ -915,7 +916,7 @@ class ApiClient { ...streamingOptions, headers: this.defaultHeaders, }; - return this.streamify(`/candidates/${candidateId}/generate-resume`, body, streamingOptions); + return this.streamify(`/candidates/${candidateId}/generate-resume`, body, streamingOptions, "ChatMessageResume"); } candidateMatchForRequirement(candidate_id: string, requirement: string, streamingOptions?: StreamingOptions) @@ -925,7 +926,7 @@ class ApiClient { ...streamingOptions, headers: this.defaultHeaders, }; - return this.streamify(`/candidates/${candidate_id}/skill-match`, body, streamingOptions); + return this.streamify(`/candidates/${candidate_id}/skill-match`, body, streamingOptions, "ChatMessageSkillAssessment"); } async updateCandidateDocument(document: Types.Document) : Promise { @@ -1226,7 +1227,7 @@ class ApiClient { * @param options callbacks, headers, and method * @returns */ - streamify(api: string, data: BodyInit | null, options: StreamingOptions = {}) : StreamingResponse { + streamify(api: string, data: BodyInit | null, options: StreamingOptions = {}, modelType?: string) : StreamingResponse { const abortController = new AbortController(); const signal = options.signal || abortController.signal; const headers = options.headers || null; @@ -1308,8 +1309,8 @@ class ApiClient { break; case 'done': - const message = Types.convertApiMessageFromApi(incoming) as T; - finalMessage = message as any; + const message = (modelType ? convertFromApi(incoming, modelType) : incoming) as T; + finalMessage = message; try { options.onMessage?.(message); } catch (error) { @@ -1361,7 +1362,7 @@ class ApiClient { options: StreamingOptions = {} ): StreamingResponse { const body = JSON.stringify(formatApiRequest(chatMessage)); - return this.streamify(`/chat/sessions/${chatMessage.sessionId}/messages/stream`, body, options) + return this.streamify(`/chat/sessions/${chatMessage.sessionId}/messages/stream`, body, options, "ChatMessage") } /** @@ -1470,7 +1471,6 @@ class ApiClient { // ============================ // Error Handling Helper // ============================ - async handleRequest(requestFn: () => Promise, modelType?: string): Promise { try { const response = await requestFn(); diff --git a/src/backend/agents/base.py b/src/backend/agents/base.py index 08f6981..11ca727 100644 --- a/src/backend/agents/base.py +++ b/src/backend/agents/base.py @@ -823,5 +823,16 @@ Content: {content} raise ValueError("No JSON found in the response") + def extract_markdown_from_text(self, text: str) -> str: + """Extract Markdown string from text that may contain other content.""" + markdown_pattern = r"```(md|markdown)\s*([\s\S]*?)\s*```" + match = re.search(markdown_pattern, text) + if match: + return match.group(2).strip() + + raise ValueError("No Markdown found in the response") + + + # Register the base agent agent_registry.register(Agent._agent_type, Agent) diff --git a/src/backend/agents/generate_persona.py b/src/backend/agents/generate_persona.py index 67d4cf6..2fb6877 100644 --- a/src/backend/agents/generate_persona.py +++ b/src/backend/agents/generate_persona.py @@ -511,14 +511,5 @@ Make sure at least one of the candidate's job descriptions take into account the raise ValueError("No JSON found in the response") - def extract_markdown_from_text(self, text: str) -> str: - """Extract Markdown string from text that may contain other content.""" - markdown_pattern = r"```(md|markdown)\s*([\s\S]*?)\s*```" - match = re.search(markdown_pattern, text) - if match: - return match.group(2).strip() - - raise ValueError("No Markdown found in the response") - # Register the base agent agent_registry.register(GeneratePersona._agent_type, GeneratePersona) diff --git a/src/backend/agents/job_requirements.py b/src/backend/agents/job_requirements.py index 1eb79ea..b59aa6b 100644 --- a/src/backend/agents/job_requirements.py +++ b/src/backend/agents/job_requirements.py @@ -19,7 +19,7 @@ import asyncio import numpy as np # type: ignore from .base import Agent, agent_registry, LLMMessage -from models import ApiActivityType, Candidate, ChatMessage, ChatMessageError, ChatMessageMetaData, ApiMessageType, ChatMessageStatus, ChatMessageUser, ChatOptions, ChatSenderType, ApiStatusType, JobRequirements, JobRequirementsMessage, Tunables +from models import ApiActivityType, Candidate, ChatMessage, ChatMessageError, ChatMessageMetaData, ApiMessageType, ChatMessageStatus, ChatMessageUser, ChatOptions, ChatSenderType, ApiStatusType, Job, JobRequirements, JobRequirementsMessage, Tunables import model_cast from logger import logger import defines @@ -107,6 +107,15 @@ class JobRequirementsAgent(Agent): async def generate( self, llm: Any, model: str, session_id: str, prompt: str, tunables: Optional[Tunables] = None, temperature=0.7 ) -> AsyncGenerator[ChatMessage, None]: + if not self.user: + error_message = ChatMessageError( + session_id=session_id, + content="User is not set for this agent." + ) + logger.error(f"⚠️ {error_message.content}") + yield error_message + return + # Stage 1A: Analyze job requirements status_message = ChatMessageStatus( session_id=session_id, @@ -166,15 +175,21 @@ class JobRequirementsAgent(Agent): logger.error(f"⚠️ {status_message.content}") yield status_message return - job_requirements_message = JobRequirementsMessage( - session_id=session_id, - status=ApiStatusType.DONE, - requirements=requirements, + job = Job( + owner_id=self.user.id, + owner_type=self.user.user_type, company=company, title=title, summary=summary, + requirements=requirements, + session_id=session_id, description=prompt, ) + job_requirements_message = JobRequirementsMessage( + session_id=session_id, + status=ApiStatusType.DONE, + job=job, + ) yield job_requirements_message logger.info(f"✅ Job requirements analysis completed successfully.") return diff --git a/src/backend/main.py b/src/backend/main.py index 2a4573f..0cb0408 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -2357,7 +2357,6 @@ async def create_job_from_content(database: RedisDatabase, current_user: Candida activity=ApiActivityType.SEARCHING ) yield status_message - await asyncio.sleep(0) async for message in chat_agent.generate( llm=llm_manager.get_llm(), @@ -2366,16 +2365,52 @@ async def create_job_from_content(database: RedisDatabase, current_user: Candida prompt=content ): pass + if not message or not isinstance(message, JobRequirementsMessage): error_message = ChatMessageError( sessionId=MOCK_UUID, # No session ID for document uploads - content="Failed to process job description file" + content="Job extraction did not convert successfully" ) yield error_message return - logger.info(f"✅ Successfully saved job requirements job {message.id}") - yield message + status_message = ChatMessageStatus( + sessionId=MOCK_UUID, # No session ID for document uploads + content=f"Reformatting job description as markdown...", + activity=ApiActivityType.CONVERTING + ) + yield status_message + + job_requirements : JobRequirementsMessage = message + async for message in chat_agent.llm_one_shot( + llm=llm_manager.get_llm(), + model=defines.model, + session_id=MOCK_UUID, + prompt=content, + system_prompt=""" +You are a document editor. Take the provided job description and reformat as legible markdown. +Return only the markdown content, no other text. Make sure all content is included. +""" + ): + pass + + if not message or not isinstance(message, ChatMessage): + logger.error("❌ Failed to reformat job description to markdown") + error_message = ChatMessageError( + sessionId=MOCK_UUID, # No session ID for document uploads + content="Failed to reformat job description" + ) + yield error_message + return + chat_message : ChatMessage = message + markdown = chat_message.content + try: + markdown = chat_agent.extract_markdown_from_text(chat_message.content) + except Exception as e: + pass + job_requirements.job.description = markdown + logger.info(f"✅ Successfully saved job requirements job {job_requirements.id}") + yield job_requirements return @api_router.post("/candidates/profile/upload") @@ -3272,6 +3307,7 @@ async def create_job_from_description( logger.info(f"📁 Received file content: size='{len(content)} bytes'") async for message in create_job_from_content(database=database, current_user=current_user, content=content): + logger.info(f"📄 Yielding job creation message status: {message.status}") yield message return @@ -3403,9 +3439,10 @@ async def create_job_from_file( yield error_message logger.error(f"❌ Error converting {file.filename} to Markdown: {e}") return - async for message in create_job_from_content(database=database, current_user=current_user, content=file_content): - yield message - return + + async for message in create_job_from_content(database=database, current_user=current_user, content=file_content): + yield message + return try: async def to_json(method): diff --git a/src/backend/models.py b/src/backend/models.py index 563abd5..9030ac0 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -480,8 +480,8 @@ class BaseUser(BaseUserWithType): full_name: str = Field(..., alias="fullName") phone: Optional[str] = None location: Optional[Location] = None - created_at: datetime = Field(..., alias="createdAt") - updated_at: datetime = Field(..., alias="updatedAt") + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="createdAt") + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="updatedAt") last_login: Optional[datetime] = Field(None, alias="lastLogin") profile_image: Optional[str] = Field(None, alias="profileImage") status: UserStatus @@ -613,7 +613,7 @@ class Guest(BaseUser): username: str # Add username for consistency with other user types converted_to_user_id: Optional[str] = Field(None, alias="convertedToUserId") ip_address: Optional[str] = Field(None, alias="ipAddress") - created_at: datetime = Field(..., alias="createdAt") + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="createdAt") user_agent: Optional[str] = Field(None, alias="userAgent") rag_content_size: int = 0 model_config = { @@ -690,8 +690,8 @@ class Job(BaseModel): company: Optional[str] description: str requirements: Optional[JobRequirements] - created_at: datetime = Field(..., alias="createdAt") - updated_at: datetime = Field(..., alias="updatedAt") + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="createdAt") + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="updatedAt") model_config = { "populate_by_name": True # Allow both field names and aliases } @@ -700,7 +700,7 @@ class JobFull(Job): location: Location salary_range: Optional[SalaryRange] = Field(None, alias="salaryRange") employment_type: EmploymentType = Field(..., alias="employmentType") - date_posted: datetime = Field(..., alias="datePosted") + date_posted: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="datePosted") application_deadline: Optional[datetime] = Field(None, alias="applicationDeadline") is_active: bool = Field(..., alias="isActive") applicants: Optional[List["JobApplication"]] = None @@ -723,8 +723,8 @@ class InterviewFeedback(BaseModel): weaknesses: List[str] recommendation: InterviewRecommendation comments: str - created_at: datetime = Field(..., alias="createdAt") - updated_at: datetime = Field(..., alias="updatedAt") + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="createdAt") + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="updatedAt") is_visible: bool = Field(..., alias="isVisible") skill_assessments: Optional[List[SkillAssessment]] = Field(None, alias="skillAssessments") model_config = { @@ -954,11 +954,7 @@ class ChatMessageRagSearch(ApiMessage): class JobRequirementsMessage(ApiMessage): type: ApiMessageType = ApiMessageType.JSON - title: Optional[str] - summary: Optional[str] - company: Optional[str] - description: str - requirements: Optional[JobRequirements] + job: Job = Field(..., alias="job") class DocumentMessage(ApiMessage): type: ApiMessageType = ApiMessageType.JSON @@ -1079,8 +1075,8 @@ class RAGConfiguration(BaseModel): embedding_model: str = Field(..., alias="embeddingModel") vector_store_type: VectorStoreType = Field(..., alias="vectorStoreType") retrieval_parameters: RetrievalParameters = Field(..., alias="retrievalParameters") - created_at: datetime = Field(..., alias="createdAt") - updated_at: datetime = Field(..., alias="updatedAt") + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="createdAt") + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="updatedAt") version: int is_active: bool = Field(..., alias="isActive") model_config = { @@ -1251,7 +1247,7 @@ class EmployerResponse(BaseModel): class JobResponse(BaseModel): success: bool - data: Optional["Job"] = None + data: Optional[Job] = None error: Optional[ErrorDetail] = None meta: Optional[Dict[str, Any]] = None