""" Job Routes """ import asyncio import io import json import pathlib import re import uuid from datetime import datetime, UTC from typing import Any, Dict, Optional from fastapi import APIRouter, File, Depends, Body, Path, Query, UploadFile from fastapi.responses import JSONResponse, StreamingResponse from markitdown import MarkItDown, StreamInfo import backstory_traceback as backstory_traceback import defines from agents.base import CandidateEntity from utils.helpers import filter_and_paginate, get_document_type_from_filename from database.manager import RedisDatabase from logger import logger from models import ( MOCK_UUID, ApiActivityType, ApiStatusType, ChatContextType, ChatMessage, ChatMessageError, ChatMessageStatus, DocumentType, Job, JobRequirementsMessage, Candidate, Employer, ) from utils.dependencies import get_current_admin, get_database, get_current_user from utils.responses import create_paginated_response, create_success_response, create_error_response import utils.llm_proxy as llm_manager import entities.entity_manager as entities # Create router for job endpoints router = APIRouter(prefix="/jobs", tags=["jobs"]) async def reformat_as_markdown(database: RedisDatabase, candidate_entity: CandidateEntity, content: 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="Reformatting job description as markdown...", activity=ApiActivityType.CONVERTING, ) yield status_message message = None 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. If the content is already in markdown format, return it as is. """, ): pass if not message or not isinstance(message, ChatMessage): logger.error("❌ Failed to reformat job description to markdown") error_message = ChatMessageError( session_id=MOCK_UUID, # No session ID for document uploads content="Failed to reformat job description", ) yield error_message return chat_message: ChatMessage = message try: chat_message.content = chat_agent.extract_markdown_from_text(chat_message.content) except Exception: pass logger.info("✅ Successfully converted content to markdown") yield chat_message return async def create_job_from_content(database: RedisDatabase, current_user: Candidate, content: str): status_message = ChatMessageStatus( session_id=MOCK_UUID, # No session ID for document uploads content=f"Initiating connection with {current_user.first_name}'s AI agent...", activity=ApiActivityType.INFO, ) yield status_message await asyncio.sleep(0) # Let the status message propagate async with entities.get_candidate_entity(candidate=current_user) as candidate_entity: message = None async for message in reformat_as_markdown(database, candidate_entity, content): # Only yield one final DONE message if message.status != ApiStatusType.DONE: yield message if not message or not isinstance(message, ChatMessage): error_message = ChatMessageError( session_id=MOCK_UUID, # No session ID for document uploads content="Failed to reformat job description", ) yield error_message 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(), model=defines.model, session_id=MOCK_UUID, prompt=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 return @router.post("") async def create_job( job_data: Dict[str, Any] = Body(...), current_user=Depends(get_current_user), database: RedisDatabase = Depends(get_database), ): """Create a new job""" try: job = Job.model_validate(job_data) # Add required fields job.id = str(uuid.uuid4()) job.owner_id = current_user.id job.owner = current_user await database.set_job(job.id, job.model_dump()) return create_success_response(job.model_dump(by_alias=True)) except Exception as e: logger.error(f"❌ Job creation error: {e}") return JSONResponse(status_code=400, content=create_error_response("CREATION_FAILED", str(e))) @router.post("") async def create_candidate_job( job_data: Dict[str, Any] = Body(...), current_user=Depends(get_current_user), database: RedisDatabase = Depends(get_database), ): """Create a new job""" isinstance(current_user, Employer) try: job = Job.model_validate(job_data) # Add required fields job.id = str(uuid.uuid4()) job.owner_id = current_user.id job.owner = current_user await database.set_job(job.id, job.model_dump()) return create_success_response(job.model_dump(by_alias=True)) except Exception as e: logger.error(f"❌ Job creation error: {e}") return JSONResponse(status_code=400, content=create_error_response("CREATION_FAILED", str(e))) @router.patch("/{job_id}") async def update_job( job_id: str = Path(...), updates: Dict[str, Any] = Body(...), current_user=Depends(get_current_user), database: RedisDatabase = Depends(get_database), ): """Update a candidate""" try: job_data = await database.get_job(job_id) if not job_data: logger.warning(f"⚠️ Job not found for update: {job_data}") return JSONResponse(status_code=404, content=create_error_response("NOT_FOUND", "Job not found")) job = Job.model_validate(job_data) # Check authorization (user can only update their own profile) if current_user.is_admin is False and job.owner_id != current_user.id: logger.warning(f"⚠️ Unauthorized update attempt by user {current_user.id} on job {job_id}") return JSONResponse( status_code=403, content=create_error_response("FORBIDDEN", "Cannot update another user's job") ) # Apply updates updates["updatedAt"] = datetime.now(UTC).isoformat() logger.info(f"🔄 Updating job {job_id} with data: {updates}") job_dict = job.model_dump() job_dict.update(updates) updated_job = Job.model_validate(job_dict) await database.set_job(job_id, updated_job.model_dump()) return create_success_response(updated_job.model_dump(by_alias=True)) except Exception as e: logger.error(f"❌ Update job error: {e}") return JSONResponse(status_code=400, content=create_error_response("UPDATE_FAILED", str(e))) @router.post("/from-content") async def create_job_from_description( content: str = Body(...), current_user=Depends(get_current_user), database: RedisDatabase = Depends(get_database) ): """Upload a document for the current candidate""" async def content_stream_generator(content): # 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 logger.info(f"📁 Received file content: size='{len(content)} bytes'") last_yield_was_streaming = False async for message in create_job_from_content(database=database, current_user=current_user, content=content): 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 creation message status: {message.status}") yield 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(content)), 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"❌ Document upload error: {e}") return StreamingResponse( iter( [ json.dumps( ChatMessageError( session_id=MOCK_UUID, # No session ID for document uploads content="Failed to upload document", ).model_dump(by_alias=True) ).encode("utf-8") ] ), media_type="text/event-stream", ) @router.post("/upload") async def create_job_from_file( file: UploadFile = File(...), current_user=Depends(get_current_user), database: RedisDatabase = Depends(get_database), ): """Upload a job document for the current candidate and create a Job""" # Check file size (limit to 10MB) max_size = 10 * 1024 * 1024 # 10MB file_content = await file.read() if len(file_content) > max_size: logger.info(f"⚠️ File too large: {file.filename} ({len(file_content)} bytes)") return StreamingResponse( iter( [ json.dumps( ChatMessageError( session_id=MOCK_UUID, # No session ID for document uploads content="File size exceeds 10MB limit", ).model_dump(by_alias=True) ).encode("utf-8") ] ), media_type="text/event-stream", ) if len(file_content) == 0: logger.info(f"⚠️ File is empty: {file.filename}") return StreamingResponse( iter( [ json.dumps( ChatMessageError( session_id=MOCK_UUID, # No session ID for document uploads content="File is empty", ).model_dump(by_alias=True) ).encode("utf-8") ] ), media_type="text/event-stream", ) """Upload a document for the current candidate""" async def upload_stream_generator(file_content): # 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 file.filename = re.sub(r"^.*/", "", file.filename) if file.filename else "" # Sanitize filename if not file.filename or file.filename.strip() == "": logger.warning("⚠️ File upload attempt with missing filename") error_message = ChatMessageError( session_id=MOCK_UUID, # No session ID for document uploads content="File must have a valid filename", ) yield error_message return logger.info( f"📁 Received file upload: filename='{file.filename}', content_type='{file.content_type}', size='{len(file_content)} bytes'" ) # Validate file type allowed_types = [".txt", ".md", ".docx", ".pdf", ".png", ".jpg", ".jpeg", ".gif"] file_extension = pathlib.Path(file.filename).suffix.lower() if file.filename else "" if file_extension not in allowed_types: logger.warning(f"⚠️ Invalid file type: {file_extension} for file {file.filename}") error_message = ChatMessageError( session_id=MOCK_UUID, # No session ID for document uploads content=f"File type {file_extension} not supported. Allowed types: {', '.join(allowed_types)}", ) yield error_message return document_type = get_document_type_from_filename(file.filename or "unknown.txt") if document_type != DocumentType.MARKDOWN and document_type != DocumentType.TXT: status_message = ChatMessageStatus( session_id=MOCK_UUID, # No session ID for document uploads content=f"Converting content from {document_type}...", activity=ApiActivityType.CONVERTING, ) yield status_message try: md = MarkItDown(enable_plugins=False) # Set to True to enable plugins stream = io.BytesIO(file_content) stream_info = StreamInfo( extension=file_extension, # e.g., ".pdf" url=file.filename, # optional, helps with logging and guessing ) result = md.convert_stream(stream, stream_info=stream_info, output_format="markdown") file_content = result.text_content logger.info(f"✅ Converted {file.filename} to Markdown format") except Exception as e: error_message = ChatMessageError( session_id=MOCK_UUID, # No session ID for document uploads content=f"Failed to convert {file.filename} to Markdown.", ) 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 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(upload_stream_generator(file_content)), 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"❌ Document upload error: {e}") return StreamingResponse( iter( [ json.dumps( ChatMessageError( session_id=MOCK_UUID, # No session ID for document uploads content="Failed to upload document", ).model_dump(mode="json", by_alias=True) ).encode("utf-8") ] ), media_type="text/event-stream", ) @router.get("") async def get_jobs( page: int = Query(1, ge=1), limit: int = Query(20, ge=1, le=100), sortBy: Optional[str] = Query(None, alias="sortBy"), sortOrder: str = Query("desc", pattern="^(asc|desc)$", alias="sortOrder"), filters: Optional[str] = Query(None), database: RedisDatabase = Depends(get_database), ): """Get paginated list of jobs""" logger.info("📄 Fetching jobs...") try: filter_dict = None if filters: filter_dict = json.loads(filters) # Get all jobs from Redis all_jobs_data = await database.get_all_jobs() jobs_list = [] for job in all_jobs_data.values(): jobs_list.append(Job.model_validate(job)) paginated_jobs, total = filter_and_paginate(jobs_list, page, limit, sortBy, sortOrder, filter_dict) paginated_response = create_paginated_response( [j.model_dump(by_alias=True) for j in paginated_jobs], page, limit, total ) return create_success_response(paginated_response) except Exception as e: logger.error(f"❌ Get jobs error: {e}") return JSONResponse(status_code=400, content=create_error_response("FETCH_FAILED", str(e))) @router.get("/search") async def search_jobs( query: str = Query(...), filters: Optional[str] = Query(None), page: int = Query(1, ge=1), limit: int = Query(20, ge=1, le=100), current_user=Depends(get_current_user), database: RedisDatabase = Depends(get_database), ): """Search jobs""" logger.info("🔍 Searching jobs...") try: filter_dict = {} if filters: filter_dict = json.loads(filters) # Get all jobs from Redis all_jobs_data = await database.get_all_jobs() jobs_list = [Job.model_validate(data) for data in all_jobs_data.values() if data.get("is_active", True)] if query: query_lower = query.lower() jobs_list = [ j for j in jobs_list if ( (j.title and query_lower in j.title.lower()) or (j.description and query_lower in j.description.lower()) or any(query_lower in skill.lower() for skill in getattr(j, "skills", []) or []) ) ] paginated_jobs, total = filter_and_paginate(jobs_list, page, limit, filters=filter_dict) paginated_response = create_paginated_response( [j.model_dump(by_alias=True) for j in paginated_jobs], page, limit, total ) return create_success_response(paginated_response) except Exception as e: logger.error(f"❌ Search jobs error: {e}") return JSONResponse(status_code=400, content=create_error_response("SEARCH_FAILED", str(e))) @router.get("/{job_id}") async def get_job(job_id: str = Path(...), database: RedisDatabase = Depends(get_database)): """Get a job by ID""" try: job_data = await database.get_job(job_id) if not job_data: return JSONResponse(status_code=404, content=create_error_response("NOT_FOUND", "Job not found")) # Increment view count job_data["views"] = job_data.get("views", 0) + 1 await database.set_job(job_id, job_data) job = Job.model_validate(job_data) return create_success_response(job.model_dump(by_alias=True)) except Exception as e: logger.error(f"❌ Get job error: {e}") return JSONResponse(status_code=500, content=create_error_response("FETCH_ERROR", str(e))) @router.delete("/{job_id}") async def delete_job( job_id: str = Path(...), admin_user=Depends(get_current_admin), database: RedisDatabase = Depends(get_database) ): """Delete a Job""" try: # Check if admin user if not admin_user.is_admin: logger.warning(f"⚠️ Unauthorized delete attempt by user {admin_user.id}") return JSONResponse(status_code=403, content=create_error_response("FORBIDDEN", "Only admins can delete")) # Get candidate data job_data = await database.get_job(job_id) if not job_data: logger.warning(f"⚠️ Candidate not found for deletion: {job_id}") return JSONResponse(status_code=404, content=create_error_response("NOT_FOUND", "Job not found")) # Delete job from database await database.delete_job(job_id) logger.info(f"🗑️ Job deleted: {job_id} by admin {admin_user.id}") return create_success_response({"message": "Job deleted successfully", "jobId": job_id}) except Exception as e: logger.error(f"❌ Delete job error: {e}") return JSONResponse(status_code=500, content=create_error_response("DELETE_ERROR", "Failed to delete job"))