Working on job creation flow

This commit is contained in:
James Ketr 2025-06-09 19:57:08 -07:00
parent 9edf5a5b23
commit 4f4187eba4
9 changed files with 143 additions and 104 deletions

View File

@ -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<boolean>(false);
const [jobDescription, setJobDescription] = useState<string>('');
const [jobRequirements, setJobRequirements] = useState<Types.JobRequirements | null>(null);
const [jobTitle, setJobTitle] = useState<string>('');
const [company, setCompany] = useState<string>('');
const [summary, setSummary] = useState<string>('');
const [jobLocation, setJobLocation] = useState<string>('');
const [jobId, setJobId] = useState<string>('');
const [job, setJob] = useState<Types.Job | null>(null);
const [jobStatus, setJobStatus] = useState<string>('');
const [jobStatusIcon, setJobStatusIcon] = useState<JSX.Element>(<></>);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
useEffect(() => {
}, [jobTitle, jobDescription, company]);
const fileInputRef = useRef<HTMLInputElement>(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 <Box>You must be logged in</Box>;
}
return (
<Box sx={{
mx: 'auto', p: { xs: 2, sm: 3 },
@ -500,21 +484,6 @@ const JobCreator = (props: JobCreator) => {
}}
/>
</Grid> */}
<Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-end', height: '100%' }}>
<Button
variant="contained"
onClick={handleSave}
disabled={!jobTitle || !company || !jobDescription || isProcessing}
fullWidth={isMobile}
size="large"
startIcon={<CheckCircle />}
>
Save Job
</Button>
</Box>
</Grid>
</Grid>
</CardContent>
</Card>
@ -548,7 +517,27 @@ const JobCreator = (props: JobCreator) => {
width: "100%",
display: "flex", flexDirection: "column"
}}>
{selectedJob === null && renderJobCreation()}
{job === null && renderJobCreation()}
{job &&
<Box sx={{ display: "flex", flexDirection: "column" }}>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-end', height: '100%' }}>
<Button
variant="contained"
onClick={handleSave}
disabled={!jobTitle || !company || !jobDescription || isProcessing}
fullWidth={isMobile}
size="large"
startIcon={<CheckCircle />}
>
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>
);
};

View File

@ -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<JobInfoProps> = (props: JobInfoProps) => {
>
<CardContent sx={{ display: "flex", flexGrow: 1, p: 3, height: '100%', flexDirection: 'column', alignItems: 'stretch', position: "relative" }}>
{variant !== "small" && <>
{job.location &&
{'location' in job &&
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Location:</strong> {job.location.city}, {job.location.state || job.location.country}
</Typography>

View File

@ -83,7 +83,7 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
Session ID: {guest.sessionId}
</Typography>
<Typography variant="body2" color="text.secondary">
Created: {guest.createdAt.toLocaleString()}
Created: {guest.createdAt?.toLocaleString()}
</Typography>
</CardContent>
</Card>

View File

@ -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<Types.Job>): StreamingResponse<Types.Job> {
createJobFromDescription(job_description: string, streamingOptions?: StreamingOptions<Types.JobRequirementsMessage>): StreamingResponse<Types.JobRequirementsMessage> {
const body = JSON.stringify(job_description);
return this.streamify<Types.Job>('/jobs/from-content', body, streamingOptions);
return this.streamify<Types.JobRequirementsMessage>('/jobs/from-content', body, streamingOptions, "JobRequirementsMessage");
}
async createJob(job: Omit<Types.Job, 'id' | 'datePosted' | 'views' | 'applicationCount'>): Promise<Types.Job> {
async createJob(job: Omit<Types.Job, 'id' | 'datePosted' | 'views' | 'applicationCount'>): Promise<Types.JobRequirementsMessage> {
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<Types.Job>(response, 'Job');
return this.handleApiResponseWithConversion<Types.JobRequirementsMessage>(response, 'JobRequirementsMessage');
}
async getJob(id: string): Promise<Types.Job> {
@ -884,10 +885,10 @@ class ApiClient {
'Authorization': this.defaultHeaders['Authorization']
}
};
return this.streamify<Types.DocumentMessage>('/candidates/documents/upload', formData, streamingOptions);
return this.streamify<Types.DocumentMessage>('/candidates/documents/upload', formData, streamingOptions, "DocumentMessage");
}
createJobFromFile(file: File, streamingOptions?: StreamingOptions<Types.Job>): StreamingResponse<Types.Job> {
createJobFromFile(file: File, streamingOptions?: StreamingOptions<Types.JobRequirementsMessage>): StreamingResponse<Types.JobRequirementsMessage> {
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<Types.Job>('/jobs/upload', formData, streamingOptions);
return this.streamify<Types.JobRequirementsMessage>('/jobs/upload', formData, streamingOptions, "JobRequirementsMessage");
}
getJobRequirements(jobId: string, streamingOptions?: StreamingOptions<Types.DocumentMessage>): StreamingResponse<Types.DocumentMessage> {
@ -906,7 +907,7 @@ class ApiClient {
...streamingOptions,
headers: this.defaultHeaders,
};
return this.streamify<Types.DocumentMessage>(`/jobs/requirements/${jobId}`, null, streamingOptions);
return this.streamify<Types.DocumentMessage>(`/jobs/requirements/${jobId}`, null, streamingOptions, "DocumentMessage");
}
generateResume(candidateId: string, skills: Types.SkillAssessment[], streamingOptions?: StreamingOptions<Types.ChatMessageResume>): StreamingResponse<Types.ChatMessageResume> {
@ -915,7 +916,7 @@ class ApiClient {
...streamingOptions,
headers: this.defaultHeaders,
};
return this.streamify<Types.ChatMessageResume>(`/candidates/${candidateId}/generate-resume`, body, streamingOptions);
return this.streamify<Types.ChatMessageResume>(`/candidates/${candidateId}/generate-resume`, body, streamingOptions, "ChatMessageResume");
}
candidateMatchForRequirement(candidate_id: string, requirement: string,
streamingOptions?: StreamingOptions<Types.ChatMessageSkillAssessment>)
@ -925,7 +926,7 @@ class ApiClient {
...streamingOptions,
headers: this.defaultHeaders,
};
return this.streamify<Types.ChatMessageSkillAssessment>(`/candidates/${candidate_id}/skill-match`, body, streamingOptions);
return this.streamify<Types.ChatMessageSkillAssessment>(`/candidates/${candidate_id}/skill-match`, body, streamingOptions, "ChatMessageSkillAssessment");
}
async updateCandidateDocument(document: Types.Document) : Promise<Types.Document> {
@ -1226,7 +1227,7 @@ class ApiClient {
* @param options callbacks, headers, and method
* @returns
*/
streamify<T = Types.ChatMessage[]>(api: string, data: BodyInit | null, options: StreamingOptions<T> = {}) : StreamingResponse<T> {
streamify<T = Types.ChatMessage[]>(api: string, data: BodyInit | null, options: StreamingOptions<T> = {}, modelType?: string) : StreamingResponse<T> {
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<T>(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<T>(requestFn: () => Promise<Response>, modelType?: string): Promise<T> {
try {
const response = await requestFn();

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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):

View File

@ -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