diff --git a/.gitignore b/.gitignore index 84bbe28..efa0518 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +venv/** users/** !users/eliza users-prod/** diff --git a/Dockerfile b/Dockerfile index 3e54918..647dce3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -193,6 +193,9 @@ RUN pip install fastapi uvicorn "python-jose[cryptography]" bcrypt python-multip # Needed for email verification RUN pip install pyyaml user-agents cryptography +# OpenAPI CLI generator +RUN pip install openapi-python-client + # Automatic type conversion pydantic -> typescript RUN pip install pydantic typing-inspect jinja2 RUN apt-get update \ diff --git a/docker-compose.yml b/docker-compose.yml index 8df9249..8ff4e76 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,7 @@ services: - ./users:/opt/backstory/users:rw # Live mount of user data - ./src:/opt/backstory/src:rw # Live mount server src - ./frontend/src/types:/opt/backstory/frontend/src/types # Live mount of types for pydantic->ts + - ./venv:/opt/backstory/venv:rw # Live mount for python venv cap_add: # used for running ze-monitor within container - CAP_DAC_READ_SEARCH # Bypass all filesystem read access checks - CAP_PERFMON # Access to perf_events (vs. overloaded CAP_SYS_ADMIN) diff --git a/frontend/src/components/ui/JobInfo.tsx b/frontend/src/components/ui/JobInfo.tsx index deeebe0..76167d0 100644 --- a/frontend/src/components/ui/JobInfo.tsx +++ b/frontend/src/components/ui/JobInfo.tsx @@ -100,12 +100,13 @@ const JobInfo: React.FC = (props: JobInfoProps) => { setAdminStatusType(status.activity); setAdminStatus(status.content); }, - onMessage: (jobMessage: Types.JobRequirementsMessage) => { + onMessage: async (jobMessage: Types.JobRequirementsMessage) => { const newJob: Types.Job = jobMessage.job console.log('onMessage - job', newJob); newJob.id = job.id; newJob.createdAt = job.createdAt; - setActiveJob(newJob); + const updatedJob: Types.Job = await apiClient.updateJob(job.id || '', newJob); + setActiveJob(updatedJob); }, onError: (error: Types.ChatMessageError) => { console.log('onError', error); diff --git a/frontend/src/services/api-client.ts b/frontend/src/services/api-client.ts index fa04317..1fab132 100644 --- a/frontend/src/services/api-client.ts +++ b/frontend/src/services/api-client.ts @@ -1446,7 +1446,7 @@ class ApiClient { options: StreamingOptions = {} ): StreamingResponse { const body = JSON.stringify(formatApiRequest(chatMessage)); - return this.streamify(`/chat/sessions/${chatMessage.sessionId}/messages/stream`, body, options, "ChatMessage") + return this.streamify(`/chat/sessions/messages/stream`, body, options, "ChatMessage") } /** diff --git a/frontend/src/types/types.ts b/frontend/src/types/types.ts index 5b9b6d5..837a9dc 100644 --- a/frontend/src/types/types.ts +++ b/frontend/src/types/types.ts @@ -1,6 +1,6 @@ // Generated TypeScript types from Pydantic models // Source: src/backend/models.py -// Generated on: 2025-06-12T15:58:49.974420 +// Generated on: 2025-06-13T18:33:37.696910 // DO NOT EDIT MANUALLY - This file is auto-generated // ============================ @@ -770,6 +770,7 @@ export interface JobRequirements { education?: Array; certifications?: Array; preferredAttributes?: Array; + companyValues?: Array; } export interface JobRequirementsMessage { @@ -967,7 +968,6 @@ export interface ResendVerificationRequest { export interface Resume { id?: string; - resumeId: string; jobId: string; candidateId: string; resume: string; diff --git a/src/backend/agents/__init__.py b/src/backend/agents/__init__.py index 4a204af..3bcd11b 100644 --- a/src/backend/agents/__init__.py +++ b/src/backend/agents/__init__.py @@ -1,4 +1,5 @@ from __future__ import annotations + import traceback from pydantic import BaseModel, Field from typing import ( @@ -17,44 +18,9 @@ from typing import ( import importlib import pathlib import inspect -from prometheus_client import CollectorRegistry -from . base import Agent +from .base import Agent, get_or_create_agent from logger import logger -from models import Candidate - -_agents: List[Agent] = [] - -def get_or_create_agent(agent_type: str, prometheus_collector: CollectorRegistry, user: Optional[Candidate]=None) -> Agent: - """ - Get or create and append a new agent of the specified type, ensuring only one agent per type exists. - - Args: - agent_type: The type of agent to create (e.g., 'general', 'candidate_chat', 'image_generation'). - **kwargs: Additional fields required by the specific agent subclass. - - Returns: - The created agent instance. - - Raises: - ValueError: If no matching agent type is found or if a agent of this type already exists. - """ - # Check if a global (non-user) agent with the given agent_type already exists - if not user: - for agent in _agents: - if agent.agent_type == agent_type: - return agent - - # Find the matching subclass - for agent_cls in Agent.__subclasses__(): - if agent_cls.model_fields["agent_type"].default == agent_type: - # Create the agent instance with provided kwargs - agent = agent_cls(agent_type=agent_type, user=user, prometheus_collector=prometheus_collector) -# if agent.agent_persist: # If an agent is not set to persist, do not add it to the list - _agents.append(agent) - return agent - - raise ValueError(f"No agent class found for agent_type: {agent_type}") # Type alias for Agent or any subclass AnyAgent: TypeAlias = Agent # BaseModel covers Agent and subclasses diff --git a/src/backend/agents/base.py b/src/backend/agents/base.py index 000eca4..0138bf8 100644 --- a/src/backend/agents/base.py +++ b/src/backend/agents/base.py @@ -23,16 +23,204 @@ from datetime import datetime, UTC from prometheus_client import Counter, Summary, CollectorRegistry # type: ignore import numpy as np # type: ignore import json_extractor as json_extractor +from pydantic import BaseModel, Field, model_validator # type: ignore +from uuid import uuid4 +from typing import List, Optional, Generator, ClassVar, Any, Dict, TYPE_CHECKING, Literal -from models import ( ApiActivityType, ChatMessageError, ChatMessageRagSearch, ChatMessageStatus, ChatMessageStreaming, LLMMessage, ChatQuery, ChatMessage, ChatOptions, ChatMessageUser, Tunables, ApiMessageType, ChatSenderType, ApiStatusType, ChatMessageMetaData, Candidate) +from datetime import datetime, date, UTC +from typing_extensions import Annotated, Union +import numpy as np # type: ignore + +from uuid import uuid4 +from prometheus_client import CollectorRegistry, Counter # type: ignore +import traceback +import os +import json +import re +from pathlib import Path + +from rag import start_file_watcher, ChromaDBFileWatcher +import defines +from logger import logger +from models import (Tunables, CandidateQuestion, ChatMessageUser, ChatMessage, RagEntry, ChatMessageMetaData, ApiStatusType, Candidate, ChatContextType) +import utils.llm_proxy as llm_manager +from database import RedisDatabase +from models import ChromaDBGetResponse +from utils.metrics import Metrics + + +from models import ( ApiActivityType, ApiMessage, ChatMessageError, ChatMessageRagSearch, ChatMessageStatus, ChatMessageStreaming, LLMMessage, ChatQuery, ChatMessage, ChatOptions, ChatMessageUser, Tunables, ApiMessageType, ChatSenderType, ApiStatusType, ChatMessageMetaData, Candidate) from logger import logger import defines from .registry import agent_registry -from metrics import Metrics -import model_cast -import backstory_traceback as traceback from models import ( ChromaDBGetResponse ) +class CandidateEntity(Candidate): + model_config = {"arbitrary_types_allowed": True} # Allow ChromaDBFileWatcher, etc + + id: str = Field(default_factory=lambda: str(uuid4()), description="Unique identifier for the entity") + last_accessed: datetime = Field(default_factory=lambda: datetime.now(UTC), description="Last accessed timestamp") + reference_count: int = Field(default=0, description="Number of active references to this entity") + + async def cleanup(self): + """Cleanup resources associated with this entity""" + pass + + # Internal instance members + CandidateEntity__agents: List[Agent] = [] + CandidateEntity__observer: Optional[Any] = Field(default=None, exclude=True) + CandidateEntity__file_watcher: Optional[ChromaDBFileWatcher] = Field(default=None, exclude=True) + CandidateEntity__prometheus_collector: Optional[CollectorRegistry] = Field( + default=None, exclude=True + ) + + CandidateEntity__metrics: Optional[Metrics] = Field( + default=None, + description="Metrics collector for this agent, used to track performance and usage." + ) + + def __init__(self, candidate=None): + if candidate is not None: + # Copy attributes from the candidate instance + super().__init__(**vars(candidate)) + else: + raise ValueError("CandidateEntity must be initialized with a Candidate instance or attributes") + + @classmethod + def exists(cls, username: str): + # Validate username format (only allow safe characters) + if not re.match(r'^[a-zA-Z0-9_-]+$', username): + return False # Invalid username characters + + # Check for minimum and maximum length + if not (3 <= len(username) <= 32): + return False # Invalid username length + + # Use Path for safe path handling and normalization + user_dir = Path(defines.user_dir) / username + user_info_path = user_dir / defines.user_info_file + + # Ensure the final path is actually within the intended parent directory + # to help prevent directory traversal attacks + try: + if not user_dir.resolve().is_relative_to(Path(defines.user_dir).resolve()): + return False # Path traversal attempt detected + except (ValueError, RuntimeError): # Potential exceptions from resolve() + return False + + # Check if file exists + return user_info_path.is_file() + + def get_or_create_agent(self, agent_type: ChatContextType) -> Agent: + """ + Get or create an agent of the specified type for this candidate. + + Args: + agent_type: The type of agent to create (default is 'candidate_chat'). + **kwargs: Additional fields required by the specific agent subclass. + + Returns: + The created agent instance. + """ + + # Only instantiate one agent of each type per user + for agent in self.CandidateEntity__agents: + if agent.agent_type == agent_type: + return agent + + return get_or_create_agent( + agent_type=agent_type, + user=self, + prometheus_collector=self.prometheus_collector + ) + + # Wrapper properties that map into file_watcher + @property + def umap_collection(self) -> ChromaDBGetResponse: + if not self.CandidateEntity__file_watcher: + raise ValueError("initialize() has not been called.") + return self.CandidateEntity__file_watcher.umap_collection + + # Fields managed by initialize() + CandidateEntity__initialized: bool = Field(default=False, exclude=True) + @property + def metrics(self) -> Metrics: + if not self.CandidateEntity__metrics: + raise ValueError("initialize() has not been called.") + return self.CandidateEntity__metrics + + @property + def file_watcher(self) -> ChromaDBFileWatcher: + if not self.CandidateEntity__file_watcher: + raise ValueError("initialize() has not been called.") + return self.CandidateEntity__file_watcher + + @property + def prometheus_collector(self) -> CollectorRegistry: + if not self.CandidateEntity__prometheus_collector: + raise ValueError("initialize() has not been called with a prometheus_collector.") + return self.CandidateEntity__prometheus_collector + + @property + def observer(self) -> Any: + if not self.CandidateEntity__observer: + raise ValueError("initialize() has not been called.") + return self.CandidateEntity__observer + + def collect_metrics(self, agent: Agent, response): + if not self.metrics: + logger.warning("No metrics collector set for this agent.") + return + self.metrics.tokens_prompt.labels(agent=agent.agent_type).inc( + response.usage.prompt_eval_count + ) + self.metrics.tokens_eval.labels(agent=agent.agent_type).inc(response.usage.eval_count) + + async def initialize( + self, + prometheus_collector: CollectorRegistry, + database: RedisDatabase): + if self.CandidateEntity__initialized: + # Initialization can only be attempted once; if there are multiple attempts, it means + # a subsystem is failing or there is a logic bug in the code. + # + # NOTE: It is intentional that self.CandidateEntity__initialize = True regardless of whether it + # succeeded. This prevents server loops on failure + raise ValueError("initialize can only be attempted once") + self.CandidateEntity__initialized = True + + if not self.username: + raise ValueError("username can not be empty") + + if not prometheus_collector: + raise ValueError("prometheus_collector can not be None") + + self.CandidateEntity__prometheus_collector = prometheus_collector + self.CandidateEntity__metrics = Metrics(prometheus_collector=self.prometheus_collector) + + user_dir = os.path.join(defines.user_dir, self.username) + vector_db_dir=os.path.join(user_dir, defines.persist_directory) + rag_content_dir=os.path.join(user_dir, defines.rag_content_dir) + + os.makedirs(vector_db_dir, exist_ok=True) + os.makedirs(rag_content_dir, exist_ok=True) + + self.CandidateEntity__observer, self.CandidateEntity__file_watcher = start_file_watcher( + llm=llm_manager.get_llm(), + user_id=self.id, + collection_name=self.username, + persist_directory=vector_db_dir, + watch_directory=rag_content_dir, + database=database, + recreate=False, # Don't recreate if exists + ) + has_username_rag = any(item.name == self.username for item in self.rags) + if not has_username_rag: + self.rags.append(RagEntry( + name=self.username, + description=f"Expert data about {self.full_name}.", + )) + self.rag_content_size = self.file_watcher.collection.count() class Agent(BaseModel, ABC): """ @@ -46,21 +234,11 @@ class Agent(BaseModel, ABC): agent_type: Literal["base"] = "base" _agent_type: ClassVar[str] = agent_type # Add this for registration - user: Optional[Candidate] = None - prometheus_collector: CollectorRegistry = Field(..., description="Prometheus collector for this agent, used to track metrics.", exclude=True) + user: Optional[CandidateEntity] = None # Tunables (sets default for new Messages attached to this agent) tunables: Tunables = Field(default_factory=Tunables) - metrics: Metrics = Field( - None, description="Metrics collector for this agent, used to track performance and usage." - ) - @model_validator(mode="after") - def initialize_metrics(self) -> "Agent": - if self.metrics is None: - self.metrics = Metrics(prometheus_collector=self.prometheus_collector) - return self - # Agent properties system_prompt: str = "" context_tokens: int = 0 @@ -68,7 +246,7 @@ class Agent(BaseModel, ABC): # context_size is shared across all subclasses _context_size: ClassVar[int] = int(defines.max_context * 0.5) - conversation: List[ChatMessage] = Field( + conversation: List[ChatMessageUser] = Field( default_factory=list, description="Conversation history for this agent, used to maintain context across messages." ) @@ -295,12 +473,6 @@ class Agent(BaseModel, ABC): # message.metadata.timers["llm_with_tools"] = end_time - start_time # return - def collect_metrics(self, response): - self.metrics.tokens_prompt.labels(agent=self.agent_type).inc( - response.usage.prompt_eval_count - ) - self.metrics.tokens_eval.labels(agent=self.agent_type).inc(response.usage.eval_count) - def get_rag_context(self, rag_message: ChatMessageRagSearch) -> str: """ Extracts the RAG context from the rag_message. @@ -329,7 +501,7 @@ Content: {content} prompt: str, top_k: int=defines.default_rag_top_k, threshold: float=defines.default_rag_threshold, - ) -> AsyncGenerator[ChatMessageRagSearch | ChatMessageError | ChatMessageStatus, None]: + ) -> AsyncGenerator[ApiMessage, None]: """ Generate RAG results for the given query. @@ -349,7 +521,7 @@ Content: {content} results : List[ChromaDBGetResponse] = [] entries: int = 0 - user: Candidate = self.user + user: CandidateEntity = self.user for rag in user.rags: if not rag.enabled: continue @@ -367,10 +539,10 @@ Content: {content} ) if not chroma_results: continue - query_embedding = np.array(chroma_results["query_embedding"]).flatten() + query_embedding = np.array(chroma_results["query_embedding"]).flatten() # type: ignore - umap_2d = user.file_watcher.umap_model_2d.transform([query_embedding])[0] - umap_3d = user.file_watcher.umap_model_3d.transform([query_embedding])[0] + umap_2d = user.file_watcher.umap_model_2d.transform([query_embedding])[0] # type: ignore + umap_3d = user.file_watcher.umap_model_3d.transform([query_embedding])[0] # type: ignore rag_metadata = ChromaDBGetResponse( name=rag.name, @@ -405,8 +577,16 @@ Content: {content} llm: Any, model: str, session_id: str, prompt: str, system_prompt: str, tunables: Optional[Tunables] = None, - temperature=0.7) -> AsyncGenerator[ChatMessageStatus | ChatMessageError | ChatMessageStreaming | ChatMessage, None]: + temperature=0.7) -> AsyncGenerator[ChatMessageStatus | ChatMessageError | ChatMessageStreaming | ChatMessage, None]: + if not self.user: + error_message = ChatMessageError( + session_id=session_id, + content="No user set for chat generation." + ) + yield error_message + return + self.set_optimal_context_size( llm=llm, model=model, prompt=prompt+system_prompt ) @@ -466,7 +646,7 @@ Content: {content} yield error_message return - self.collect_metrics(response) + self.user.collect_metrics(agent=self, response=response) self.context_tokens = ( response.usage.prompt_eval_count + response.usage.eval_count ) @@ -493,7 +673,7 @@ Content: {content} session_id: str, prompt: str, tunables: Optional[Tunables] = None, temperature=0.7 - ) -> AsyncGenerator[ChatMessage | ChatMessageStatus | ChatMessageError | ChatMessageStreaming | ChatMessageRagSearch, None]: + ) -> AsyncGenerator[ApiMessage, None]: if not self.user: error_message = ChatMessageError( session_id=session_id, @@ -508,8 +688,8 @@ Content: {content} ) user = self.user - self.metrics.generate_count.labels(agent=self.agent_type).inc() - with self.metrics.generate_duration.labels(agent=self.agent_type).time(): + self.user.metrics.generate_count.labels(agent=self.agent_type).inc() + with self.user.metrics.generate_duration.labels(agent=self.agent_type).time(): context = None rag_message : ChatMessageRagSearch | None = None if self.user: @@ -711,7 +891,7 @@ Content: {content} yield error_message return - self.collect_metrics(response) + self.user.collect_metrics(agent=self, response=response) self.context_tokens = ( response.usage.prompt_eval_count + response.usage.eval_count ) @@ -829,6 +1009,44 @@ Content: {content} return match.group(2).strip() raise ValueError("No Markdown found in the response") - + +_agents: List[Agent] = [] + +def get_or_create_agent( + agent_type: str, + prometheus_collector: CollectorRegistry, + user: Optional[CandidateEntity]=None) -> Agent: + """ + Get or create and append a new agent of the specified type, ensuring only one agent per type exists. + + Args: + agent_type: The type of agent to create (e.g., 'general', 'candidate_chat', 'image_generation'). + **kwargs: Additional fields required by the specific agent subclass. + + Returns: + The created agent instance. + + Raises: + ValueError: If no matching agent type is found or if a agent of this type already exists. + """ + # Check if a global (non-user) agent with the given agent_type already exists + if not user: + for agent in _agents: + if agent.agent_type == agent_type: + return agent + + # Find the matching subclass + for agent_cls in Agent.__subclasses__(): + if agent_cls.model_fields["agent_type"].default == agent_type: + # Create the agent instance with provided kwargs + agent = agent_cls(agent_type=agent_type, # type: ignore[call-arg] + user=user) + _agents.append(agent) + return agent + + raise ValueError(f"No agent class found for agent_type: {agent_type}") + # Register the base agent agent_registry.register(Agent._agent_type, Agent) +CandidateEntity.model_rebuild() + diff --git a/src/backend/agents/candidate_chat.py b/src/backend/agents/candidate_chat.py index 43872ce..bfdc2b8 100644 --- a/src/backend/agents/candidate_chat.py +++ b/src/backend/agents/candidate_chat.py @@ -7,7 +7,7 @@ from .base import Agent, agent_registry from logger import logger from .registry import agent_registry -from models import ( ChatMessageError, ChatMessageStatus, ChatMessageStreaming, ChatQuery, ChatMessage, Tunables, ApiStatusType, ChatMessageUser, Candidate) +from models import ( ApiMessage, ChatMessageError, ChatMessageStatus, ChatMessageStreaming, ChatQuery, ChatMessage, Tunables, ApiStatusType, ChatMessageUser, Candidate) system_message = f""" @@ -38,7 +38,7 @@ class CandidateChat(Agent): session_id: str, prompt: str, tunables: Optional[Tunables] = None, temperature=0.7 - ) -> AsyncGenerator[ChatMessage | ChatMessageStatus | ChatMessageError | ChatMessageStreaming, None]: + ) -> AsyncGenerator[ApiMessage, None]: user = self.user if not user: logger.error("User is not set for CandidateChat agent.") diff --git a/src/backend/agents/generate_image.py b/src/backend/agents/generate_image.py index fa0b87e..2d6b0f5 100644 --- a/src/backend/agents/generate_image.py +++ b/src/backend/agents/generate_image.py @@ -37,7 +37,7 @@ seed = int(time.time()) random.seed(seed) class ImageGenerator(Agent): - agent_type: Literal["generate_image"] = "generate_image" + agent_type: Literal["generate_image"] = "generate_image" # type: ignore _agent_type: ClassVar[str] = agent_type # Add this for registration agent_persist: bool = False diff --git a/src/backend/agents/generate_persona.py b/src/backend/agents/generate_persona.py index df1100a..5d895e4 100644 --- a/src/backend/agents/generate_persona.py +++ b/src/backend/agents/generate_persona.py @@ -303,7 +303,7 @@ class GeneratePersona(Agent): generator: Any = Field(default=EthnicNameGenerator(), exclude=True) llm: Any = Field(default=None, exclude=True) - model: str = Field(default=None, exclude=True) + model: Optional[str] = Field(default=None, exclude=True) def randomize(self): self.age = random.randint(22, 67) diff --git a/src/backend/agents/generate_resume.py b/src/backend/agents/generate_resume.py index 617e617..bb12ca9 100644 --- a/src/backend/agents/generate_resume.py +++ b/src/backend/agents/generate_resume.py @@ -21,7 +21,7 @@ import numpy as np # type: ignore from logger import logger from .base import Agent, agent_registry -from models import (ApiActivityType, ApiStatusType, Candidate, ChatMessage, ChatMessageError, ChatMessageResume, ChatMessageStatus, JobRequirements, JobRequirementsMessage, SkillAssessment, SkillStrength, Tunables) +from models import (ApiActivityType, ApiMessage, ApiStatusType, Candidate, ChatMessage, ChatMessageError, ChatMessageResume, ChatMessageStatus, JobRequirements, JobRequirementsMessage, SkillAssessment, SkillStrength, Tunables) class GenerateResume(Agent): agent_type: Literal["generate_resume"] = "generate_resume" # type: ignore @@ -174,7 +174,7 @@ Format it in clean, ATS-friendly markdown. Provide ONLY the resume with no comme async def generate_resume( self, llm: Any, model: str, session_id: str, skills: List[SkillAssessment] - ) -> AsyncGenerator[ChatMessage | ChatMessageError, None]: + ) -> AsyncGenerator[ApiMessage, None]: # Stage 1A: Analyze job requirements status_message = ChatMessageStatus( session_id=session_id, @@ -202,6 +202,15 @@ Format it in clean, ATS-friendly markdown. Provide ONLY the resume with no comme yield error_message return + if not isinstance(generated_message, ChatMessage): + error_message = ChatMessageError( + session_id=session_id, + content="Job requirements analysis did not return a valid message." + ) + logger.error(f"āš ļø {error_message.content}") + yield error_message + return + resume_message = ChatMessageResume( session_id=session_id, status=ApiStatusType.DONE, diff --git a/src/backend/agents/job_requirements.py b/src/backend/agents/job_requirements.py index 55d5f28..785805b 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, Job, JobRequirements, JobRequirementsMessage, Tunables +from models import ApiActivityType, ApiMessage, Candidate, ChatMessage, ChatMessageError, ChatMessageMetaData, ApiMessageType, ChatMessageStatus, ChatMessageStreaming, ChatMessageUser, ChatOptions, ChatSenderType, ApiStatusType, Job, JobRequirements, JobRequirementsMessage, Tunables import model_cast from logger import logger import defines @@ -34,55 +34,69 @@ class JobRequirementsAgent(Agent): """Create the prompt for job requirements analysis.""" logger.info(f"{self.agent_type} - {inspect.stack()[0].function}") system_prompt = """ - You are an objective job requirements analyzer. Your task is to extract and categorize the specific skills, - experiences, and qualifications required in a job description WITHOUT any reference to any candidate. - - ## INSTRUCTIONS: - - 1. Analyze ONLY the job description provided. - 2. Extract company information, job title, and all requirements. - 3. Extract and categorize all requirements and preferences. - 4. DO NOT consider any candidate information - this is a pure job analysis task. - - ## OUTPUT FORMAT: - - ```json - { - "company_name": "Company Name", - "job_title": "Job Title", - "job_summary": "Brief summary of the job", - "job_requirements": { - "technical_skills": { - "required": ["skill1", "skill2"], - "preferred": ["skill1", "skill2"] - }, - "experience_requirements": { - "required": ["exp1", "exp2"], - "preferred": ["exp1", "exp2"] - }, - "education_requirements": ["req1", "req2"], - "soft_skills": ["skill1", "skill2"], - "industry_knowledge": ["knowledge1", "knowledge2"], - "responsibilities": ["resp1", "resp2"], - "company_values": ["value1", "value2"] - } - } - ``` - - Be specific and detailed in your extraction. Break down compound requirements into individual components. - For example, "5+ years experience with React, Node.js and MongoDB" should be separated into: - - Experience: "5+ years software development" - - Technical skills: "React", "Node.js", "MongoDB" - - Avoid vague categorizations and be precise about whether skills are explicitly required or just preferred. - """ +You are an objective job requirements analyzer. Your task is to extract and categorize the specific skills, +experiences, and qualifications required in a job description WITHOUT any reference to any candidate. + +## INSTRUCTIONS: + +1. Analyze ONLY the job description provided. +2. Extract company information, job title, and all requirements. +3. If a requirement is compound (e.g., "5+ years experience with React, Node.js and MongoDB" or "FastAPI/Django/React"), break it down into individual components. +4. Categorize requirements into: +- Technical skills (required and preferred) +- Experience requirements (required and preferred) +- Education requirements +- Soft skills +- Industry knowledge +- Responsibilities +- Company values +5. Extract and categorize all requirements and preferences. +6. DO NOT consider any candidate information - this is a pure job analysis task. +7. Provide the output in a structured JSON format as specified below. + +## OUTPUT FORMAT: + +```json +{ +"company_name": "Company Name", +"job_title": "Job Title", +"job_summary": "Brief summary of the job", +"job_requirements": { +"technical_skills": { +"required": ["skill1", "skill2"], +"preferred": ["skill1", "skill2"] +}, +"experience_requirements": { +"required": ["exp1", "exp2"], +"preferred": ["exp1", "exp2"] +}, +"soft_skills": ["skill1", "skill2"], +"experience": ["exp1", "exp2"], +"education": ["req1", "req2"], +"certifications": ["knowledge1", "knowledge2"], +"preferred_attributes": ["resp1", "resp2"], +"company_values": ["value1", "value2"] +} +} +``` + +Be specific and detailed in your extraction. +If a requirement can be broken down into several separate requirements, split them. +For example, the technical_skill of "Python/Django/FastAPI" should be separated into different requirements: Python, Django, and FastAPI. + +For example, if the job description mentions: "Python/Django/FastAPI", you should extract it as: + +"technical_skills": { "required": [ "Python", "Django", "FastAPI" ] }, + +Avoid vague categorizations and be precise about whether skills are explicitly required or just preferred. +""" prompt = f"Job Description:\n{job_description}" return system_prompt, prompt async def analyze_job_requirements( self, llm: Any, model: str, session_id: str, prompt: str - ) -> AsyncGenerator[ChatMessage | ChatMessageError, None]: + ) -> AsyncGenerator[ChatMessageStreaming | ChatMessage | ChatMessageError | ChatMessageStatus, None]: """Analyze job requirements from job description.""" system_prompt, prompt = self.create_job_analysis_prompt(prompt) status_message = ChatMessageStatus( @@ -111,9 +125,38 @@ class JobRequirementsAgent(Agent): yield generated_message return + def gather_requirements(self, reqs: JobRequirements) -> Dict[str, Any]: + # technical_skills: Requirements = Field(..., alias="technicalSkills") + # experience_requirements: Requirements = Field(..., alias="experienceRequirements") + # soft_skills: Optional[List[str]] = Field(default_factory=list, alias="softSkills") + # experience: Optional[List[str]] = [] + # education: Optional[List[str]] = [] + # certifications: Optional[List[str]] = [] + # preferred_attributes: Optional[List[str]] = Field(None, alias="preferredAttributes") + # company_values: Optional[List[str]] = Field(None, alias="companyValues") + """Gather and format job requirements for display.""" + display = { + "technical_skills": { + "required": reqs.technical_skills.required, + "preferred": reqs.technical_skills.preferred + }, + "experience_requirements": { + "required": reqs.experience_requirements.required, + "preferred": reqs.experience_requirements.preferred + }, + "soft_skills": reqs.soft_skills, + "experience": reqs.experience, + "education": reqs.education, + "certifications": reqs.certifications, + "preferred_attributes": reqs.preferred_attributes, + "company_values": reqs.company_values + } + + return display + async def generate( self, llm: Any, model: str, session_id: str, prompt: str, tunables: Optional[Tunables] = None, temperature=0.7 - ) -> AsyncGenerator[ChatMessage, None]: + ) -> AsyncGenerator[ApiMessage, None]: if not self.user: error_message = ChatMessageError( session_id=session_id, @@ -182,6 +225,10 @@ class JobRequirementsAgent(Agent): logger.error(f"āš ļø {status_message.content}") yield status_message return + + # Gather and format requirements for display + display = self.gather_requirements(requirements) + logger.info(f"šŸ“‹ Job requirements extracted: {json.dumps(display, indent=2)}") job = Job( owner_id=self.user.id, owner_type=self.user.user_type, @@ -189,7 +236,6 @@ class JobRequirementsAgent(Agent): title=title, summary=summary, requirements=requirements, - session_id=session_id, description=prompt, ) job_requirements_message = JobRequirementsMessage( diff --git a/src/backend/agents/rag_search.py b/src/backend/agents/rag_search.py index 37628c7..8431218 100644 --- a/src/backend/agents/rag_search.py +++ b/src/backend/agents/rag_search.py @@ -7,7 +7,7 @@ from .base import Agent, agent_registry from logger import logger from .registry import agent_registry -from models import ( ChatMessage, ChromaDBGetResponse, ApiStatusType, ChatMessage, ChatMessageError, ChatMessageRagSearch, ChatMessageStatus, ChatMessageStreaming, ChatOptions, ApiMessageType, ChatSenderType, ApiStatusType, ChatMessageMetaData, Candidate, Tunables ) +from models import ( ApiMessage, ChatMessage, ChromaDBGetResponse, ApiStatusType, ChatMessage, ChatMessageError, ChatMessageRagSearch, ChatMessageStatus, ChatMessageStreaming, ChatOptions, ApiMessageType, ChatSenderType, ApiStatusType, ChatMessageMetaData, Candidate, Tunables ) class Chat(Agent): """ @@ -21,7 +21,7 @@ class Chat(Agent): session_id: str, prompt: str, tunables: Optional[Tunables] = None, temperature=0.7 - ) -> AsyncGenerator[ChatMessage | ChatMessageStatus | ChatMessageError | ChatMessageStreaming, None]: + ) -> AsyncGenerator[ApiMessage, None]: """ Generate a response based on the user message and the provided LLM. diff --git a/src/backend/agents/skill_match.py b/src/backend/agents/skill_match.py index 60f7ede..4d0c6cd 100644 --- a/src/backend/agents/skill_match.py +++ b/src/backend/agents/skill_match.py @@ -19,7 +19,7 @@ import asyncio import numpy as np # type: ignore from .base import Agent, agent_registry, LLMMessage -from models import (Candidate, ChatMessage, ChatMessageError, ChatMessageMetaData, ApiMessageType, +from models import (ApiMessage, Candidate, ChatMessage, ChatMessageError, ChatMessageMetaData, ApiMessageType, ChatMessageRagSearch, ChatMessageSkillAssessment, ChatMessageStatus, ChatMessageStreaming, ChatMessageUser, ChatOptions, ChatSenderType, ApiStatusType, EvidenceDetail, SkillAssessment, Tunables) import model_cast @@ -107,43 +107,12 @@ JSON RESPONSE:""" return system_prompt, prompt - async def analyze_job_requirements( - self, llm: Any, model: str, session_id: str, requirement: str - ) -> AsyncGenerator[ChatMessage, None]: - """Analyze job requirements from job description.""" - system_prompt, prompt = self.create_job_analysis_prompt(requirement) - - generated_message = None - async for generated_message in self.llm_one_shot(llm, model, session_id=session_id, prompt=prompt, system_prompt=system_prompt): - if generated_message.status == ApiStatusType.ERROR: - generated_message.content = "Error analyzing job requirements." - yield generated_message - return - if generated_message.status != ApiStatusType.DONE: - yield generated_message - - if not generated_message: - error_message = ChatMessageError( - session_id=session_id, - content = "Job requirements analysis failed to generate a response.") - yield error_message - return - - chat_message = ChatMessage( - session_id=session_id, - status=ApiStatusType.DONE, - content=generated_message.content, - metadata=generated_message.metadata, - ) - yield chat_message - return - async def generate( self, llm: Any, model: str, session_id: str, prompt: str, tunables: Optional[Tunables] = None, temperature=0.7 - ) -> AsyncGenerator[ChatMessage | ChatMessageStatus | ChatMessageError | ChatMessageStreaming, None]: + ) -> AsyncGenerator[ApiMessage, None]: if not self.user: error_message = ChatMessageError( session_id=session_id, @@ -163,15 +132,15 @@ JSON RESPONSE:""" yield error_message return - rag_message = None - async for rag_message in self.generate_rag_results(session_id=session_id, prompt=skill): - if rag_message.status == ApiStatusType.ERROR: - yield rag_message + generated_message = None + async for generated_message in self.generate_rag_results(session_id=session_id, prompt=skill): + if generated_message.status == ApiStatusType.ERROR: + yield generated_message return - if rag_message.status != ApiStatusType.DONE: - yield rag_message + if generated_message.status != ApiStatusType.DONE: + yield generated_message - if rag_message is None: + if generated_message is None: error_message = ChatMessageError( session_id=session_id, content="RAG search did not return a valid response." @@ -180,20 +149,30 @@ JSON RESPONSE:""" yield error_message return + if not isinstance(generated_message, ChatMessageRagSearch): + logger.error(f"Expected ChatMessageRagSearch, got {type(generated_message)}") + error_message = ChatMessageError( + session_id=session_id, + content="RAG search did not return a valid response." + ) + yield error_message + return + rag_message : ChatMessageRagSearch = generated_message + rag_context = self.get_rag_context(rag_message) logger.info(f"šŸ” RAG content retrieved {len(rag_context)} bytes of context") system_prompt, prompt = self.generate_skill_assessment_prompt(skill=skill, rag_context=rag_context) - skill_message = None - async for skill_message in self.llm_one_shot(llm=llm, model=model, session_id=session_id, prompt=prompt, system_prompt=system_prompt, temperature=0.7): - if skill_message.status == ApiStatusType.ERROR: - logger.error(f"āš ļø {skill_message.content}") - yield skill_message + generated_message = None + async for generated_message in self.llm_one_shot(llm=llm, model=model, session_id=session_id, prompt=prompt, system_prompt=system_prompt, temperature=0.7): + if generated_message.status == ApiStatusType.ERROR: + logger.error(f"āš ļø {generated_message.content}") + yield generated_message return - if skill_message.status != ApiStatusType.DONE: - yield skill_message + if generated_message.status != ApiStatusType.DONE: + yield generated_message - if skill_message is None: + if generated_message is None: error_message = ChatMessageError( session_id=session_id, content="Skill assessment failed to generate a response." @@ -202,7 +181,15 @@ JSON RESPONSE:""" yield error_message return - json_str = self.extract_json_from_text(skill_message.content) + if not isinstance(generated_message, ChatMessage): + error_message = ChatMessageError( + session_id=session_id, + content="Skill assessment did not return a valid message." + ) + logger.error(f"āš ļø {error_message.content}") + yield error_message + return + json_str = self.extract_json_from_text(generated_message.content) skill_assessment_data = "" skill_assessment = None try: @@ -227,7 +214,7 @@ JSON RESPONSE:""" except Exception as e: error_message = ChatMessageError( session_id=session_id, - content=f"Failed to parse Skill assessment JSON: {str(e)}\n\n{skill_message.content}\n\nJSON:\n{json_str}\n\n" + content=f"Failed to parse Skill assessment JSON: {str(e)}\n\n{generated_message.content}\n\nJSON:\n{json_str}\n\n" ) logger.error(traceback.format_exc()) logger.error(f"āš ļø {error_message.content}") @@ -250,7 +237,7 @@ JSON RESPONSE:""" session_id=session_id, status=ApiStatusType.DONE, content=json.dumps(skill_assessment_data), - metadata=skill_message.metadata, + metadata=generated_message.metadata, skill_assessment=skill_assessment, ) yield skill_assessment_message diff --git a/src/backend/database.py b/src/backend/database.py index c3d5c12..ce9c9eb 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -1,5 +1,6 @@ -import redis.asyncio as redis # type: ignore -from typing import Optional, Dict, List, Optional, Any +from pydantic import BaseModel, ConfigDict, Field +from redis.asyncio import (Redis, ConnectionPool) +from typing import Any, Optional, Dict, List, Optional, TypeGuard, Union import json import logging import os @@ -15,7 +16,7 @@ logger = logging.getLogger(__name__) class _RedisManager: def __init__(self): - self.redis: Optional[redis.Redis] = None + self.redis: Optional[Redis] = None self.redis_url = os.getenv("REDIS_URL", "redis://redis:6379") self.redis_db = int(os.getenv("REDIS_DB", "0")) @@ -23,7 +24,7 @@ class _RedisManager: if not self.redis_url.endswith(f"/{self.redis_db}"): self.redis_url = f"{self.redis_url}/{self.redis_db}" - self._connection_pool: Optional[redis.ConnectionPool] = None + self._connection_pool: Optional[ConnectionPool] = None self._is_connected = False async def connect(self): @@ -34,7 +35,7 @@ class _RedisManager: try: # Create connection pool for better resource management - self._connection_pool = redis.ConnectionPool.from_url( + self._connection_pool = ConnectionPool.from_url( self.redis_url, encoding="utf-8", decode_responses=True, @@ -45,7 +46,7 @@ class _RedisManager: health_check_interval=30 ) - self.redis = redis.Redis( + self.redis = Redis( connection_pool=self._connection_pool ) @@ -101,7 +102,7 @@ class _RedisManager: self.redis = None self._connection_pool = None - def get_client(self) -> redis.Redis: + def get_client(self) -> Redis: """Get Redis client instance""" if not self._is_connected or not self.redis: raise RuntimeError("Redis client not initialized or disconnected") @@ -174,7 +175,7 @@ class _RedisManager: return None class RedisDatabase: - def __init__(self, redis: redis.Redis): + def __init__(self, redis: Redis): self.redis = redis # Redis key prefixes for different data types @@ -227,7 +228,7 @@ class RedisDatabase: # Add resume_id to user's resume list user_resumes_key = f"{self.KEY_PREFIXES['user_resumes']}{user_id}" - await self.redis.rpush(user_resumes_key, resume_id) + await self.redis.rpush(user_resumes_key, resume_id) # type: ignore logger.info(f"šŸ“„ Saved resume {resume_id} for user {user_id}") return True @@ -255,7 +256,7 @@ class RedisDatabase: try: # Get all resume IDs for this user user_resumes_key = f"{self.KEY_PREFIXES['user_resumes']}{user_id}" - resume_ids = await self.redis.lrange(user_resumes_key, 0, -1) + resume_ids = await self.redis.lrange(user_resumes_key, 0, -1)# type: ignore if not resume_ids: logger.info(f"šŸ“„ No resumes found for user {user_id}") @@ -275,7 +276,7 @@ class RedisDatabase: resumes.append(resume_data) else: # Clean up orphaned resume ID - await self.redis.lrem(user_resumes_key, 0, resume_id) + await self.redis.lrem(user_resumes_key, 0, resume_id)# type: ignore logger.warning(f"Removed orphaned resume ID {resume_id} for user {user_id}") # Sort by created_at timestamp (most recent first) @@ -296,7 +297,7 @@ class RedisDatabase: # Remove from user's resume list user_resumes_key = f"{self.KEY_PREFIXES['user_resumes']}{user_id}" - await self.redis.lrem(user_resumes_key, 0, resume_id) + await self.redis.lrem(user_resumes_key, 0, resume_id)# type: ignore if result > 0: logger.info(f"šŸ—‘ļø Deleted resume {resume_id} for user {user_id}") @@ -313,7 +314,7 @@ class RedisDatabase: try: # Get all resume IDs for this user user_resumes_key = f"{self.KEY_PREFIXES['user_resumes']}{user_id}" - resume_ids = await self.redis.lrange(user_resumes_key, 0, -1) + resume_ids = await self.redis.lrange(user_resumes_key, 0, -1)# type: ignore if not resume_ids: logger.info(f"šŸ“„ No resumes found for user {user_id}") @@ -513,7 +514,7 @@ class RedisDatabase: try: # Get all document IDs for this candidate key = f"{self.KEY_PREFIXES['candidate_documents']}{candidate_id}" - document_ids = await self.redis.lrange(key, 0, -1) + document_ids = await self.redis.lrange(key, 0, -1)# type: ignore if not document_ids: logger.info(f"No documents found for candidate {candidate_id}") @@ -656,7 +657,7 @@ class RedisDatabase: async def get_candidate_documents(self, candidate_id: str) -> List[Dict]: """Get all documents for a specific candidate""" key = f"{self.KEY_PREFIXES['candidate_documents']}{candidate_id}" - document_ids = await self.redis.lrange(key, 0, -1) + document_ids = await self.redis.lrange(key, 0, -1) # type: ignore if not document_ids: return [] @@ -675,7 +676,7 @@ class RedisDatabase: documents.append(doc_data) else: # Clean up orphaned document ID - await self.redis.lrem(key, 0, doc_id) + await self.redis.lrem(key, 0, doc_id)# type: ignore logger.warning(f"Removed orphaned document ID {doc_id} for candidate {candidate_id}") return documents @@ -683,12 +684,12 @@ class RedisDatabase: async def add_document_to_candidate(self, candidate_id: str, document_id: str): """Add a document ID to a candidate's document list""" key = f"{self.KEY_PREFIXES['candidate_documents']}{candidate_id}" - await self.redis.rpush(key, document_id) + await self.redis.rpush(key, document_id)# type: ignore async def remove_document_from_candidate(self, candidate_id: str, document_id: str): """Remove a document ID from a candidate's document list""" key = f"{self.KEY_PREFIXES['candidate_documents']}{candidate_id}" - await self.redis.lrem(key, 0, document_id) + await self.redis.lrem(key, 0, document_id)# type: ignore async def update_document(self, document_id: str, updates: Dict): """Update document metadata""" @@ -720,7 +721,7 @@ class RedisDatabase: async def get_document_count_for_candidate(self, candidate_id: str) -> int: """Get total number of documents for a candidate""" key = f"{self.KEY_PREFIXES['candidate_documents']}{candidate_id}" - return await self.redis.llen(key) + return await self.redis.llen(key)# type: ignore async def search_candidate_documents(self, candidate_id: str, query: str) -> List[Dict]: """Search documents by filename for a candidate""" @@ -1712,7 +1713,7 @@ class RedisDatabase: try: # Remove from the session's message list key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}" - await self.redis.lrem(key, 0, message_id) + await self.redis.lrem(key, 0, message_id)# type: ignore # Delete the message data itself result = await self.redis.delete(f"chat_message:{message_id}") return result > 0 @@ -1724,13 +1725,13 @@ class RedisDatabase: async def get_chat_messages(self, session_id: str) -> List[Dict]: """Get chat messages for a session""" key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}" - messages = await self.redis.lrange(key, 0, -1) + messages = await self.redis.lrange(key, 0, -1)# type: ignore return [self._deserialize(msg) for msg in messages if msg] async def add_chat_message(self, session_id: str, message_data: Dict): """Add a chat message to a session""" key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}" - await self.redis.rpush(key, self._serialize(message_data)) + await self.redis.rpush(key, self._serialize(message_data))# type: ignore async def set_chat_messages(self, session_id: str, messages: List[Dict]): """Set all chat messages for a session (replaces existing)""" @@ -1742,7 +1743,7 @@ class RedisDatabase: # Add new messages if messages: serialized_messages = [self._serialize(msg) for msg in messages] - await self.redis.rpush(key, *serialized_messages) + await self.redis.rpush(key, *serialized_messages)# type: ignore async def get_all_chat_messages(self) -> Dict[str, List[Dict]]: """Get all chat messages grouped by session""" @@ -1755,7 +1756,7 @@ class RedisDatabase: result = {} for key in keys: session_id = key.replace(self.KEY_PREFIXES['chat_messages'], '') - messages = await self.redis.lrange(key, 0, -1) + messages = await self.redis.lrange(key, 0, -1)# type: ignore result[session_id] = [self._deserialize(msg) for msg in messages if msg] return result @@ -1810,7 +1811,7 @@ class RedisDatabase: async def get_chat_message_count(self, session_id: str) -> int: """Get the total number of messages in a chat session""" key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}" - return await self.redis.llen(key) + return await self.redis.llen(key)# type: ignore async def search_chat_messages(self, session_id: str, query: str) -> List[Dict]: """Search for messages containing specific text in a session""" @@ -2178,7 +2179,7 @@ class RedisDatabase: async def get_user_by_id(self, user_id: str) -> Optional[Dict[str, Any]]: """Get user lookup data by user ID""" try: - data = await self.redis.hget("user_lookup_by_id", user_id) + data = await self.redis.hget("user_lookup_by_id", user_id)# type: ignore if data: return json.loads(data) return None @@ -2396,10 +2397,10 @@ class RedisDatabase: } # Add to list (latest events first) - await self.redis.lpush(key, json.dumps(event_data, default=str)) + await self.redis.lpush(key, json.dumps(event_data, default=str))# type: ignore # Keep only last 100 events per day - await self.redis.ltrim(key, 0, 99) + await self.redis.ltrim(key, 0, 99)# type: ignore # Set expiration for 30 days await self.redis.expire(key, 30 * 24 * 60 * 60) @@ -2418,7 +2419,7 @@ class RedisDatabase: date = (datetime.now(timezone.utc) - timedelta(days=i)).strftime('%Y-%m-%d') key = f"security_log:{user_id}:{date}" - daily_events = await self.redis.lrange(key, 0, -1) + daily_events = await self.redis.lrange(key, 0, -1)# type: ignore for event_json in daily_events: events.append(json.loads(event_json)) @@ -2440,7 +2441,7 @@ class RedisDatabase: guest_data["last_activity"] = datetime.now(UTC).isoformat() # Store in Redis with both hash and individual key for redundancy - await self.redis.hset("guests", guest_id, json.dumps(guest_data)) + await self.redis.hset("guests", guest_id, json.dumps(guest_data))# type: ignore # Also store with a longer TTL as backup await self.redis.setex( @@ -2458,7 +2459,7 @@ class RedisDatabase: """Get guest data with fallback to backup""" try: # Try primary storage first - data = await self.redis.hget("guests", guest_id) + data = await self.redis.hget("guests", guest_id)# type: ignore if data: guest_data = json.loads(data) # Update last activity when accessed @@ -2499,7 +2500,7 @@ class RedisDatabase: async def get_all_guests(self) -> Dict[str, Dict[str, Any]]: """Get all guests""" try: - data = await self.redis.hgetall("guests") + data = await self.redis.hgetall("guests")# type: ignore return { guest_id: json.loads(guest_json) for guest_id, guest_json in data.items() @@ -2511,7 +2512,7 @@ class RedisDatabase: async def delete_guest(self, guest_id: str) -> bool: """Delete a guest""" try: - result = await self.redis.hdel("guests", guest_id) + result = await self.redis.hdel("guests", guest_id)# type: ignore if result: logger.info(f"šŸ—‘ļø Guest deleted: {guest_id}") return True diff --git a/src/backend/entities/__init__.py b/src/backend/entities/__init__.py index 3a3b875..e69de29 100644 --- a/src/backend/entities/__init__.py +++ b/src/backend/entities/__init__.py @@ -1,3 +0,0 @@ -from .candidate_entity import CandidateEntity -from .entity_manager import entity_manager, get_candidate_entity - \ No newline at end of file diff --git a/src/backend/entities/candidate_entity.py b/src/backend/entities/candidate_entity.py deleted file mode 100644 index e693c55..0000000 --- a/src/backend/entities/candidate_entity.py +++ /dev/null @@ -1,160 +0,0 @@ -from __future__ import annotations -from pydantic import BaseModel, Field, model_validator # type: ignore -from uuid import uuid4 -from typing import List, Optional, Generator, ClassVar, Any, Dict, TYPE_CHECKING, Literal - -from typing_extensions import Annotated, Union -import numpy as np # type: ignore - -from uuid import uuid4 -from prometheus_client import CollectorRegistry, Counter # type: ignore -import traceback -import os -import json -import re -from pathlib import Path - -from rag import start_file_watcher, ChromaDBFileWatcher -import defines -from logger import logger -import agents as agents -from models import (Tunables, CandidateQuestion, ChatMessageUser, ChatMessage, RagEntry, ChatMessageMetaData, ApiStatusType, Candidate, ChatContextType) -import llm_proxy as llm_manager -from agents.base import Agent -from database import RedisDatabase -from models import ChromaDBGetResponse - -class CandidateEntity(Candidate): - model_config = {"arbitrary_types_allowed": True} # Allow ChromaDBFileWatcher, etc - - # Internal instance members - CandidateEntity__agents: List[Agent] = [] - CandidateEntity__observer: Optional[Any] = Field(default=None, exclude=True) - CandidateEntity__file_watcher: Optional[ChromaDBFileWatcher] = Field(default=None, exclude=True) - CandidateEntity__prometheus_collector: Optional[CollectorRegistry] = Field( - default=None, exclude=True - ) - - def __init__(self, candidate=None): - if candidate is not None: - # Copy attributes from the candidate instance - super().__init__(**vars(candidate)) - else: - super().__init__() - - @classmethod - def exists(cls, username: str): - # Validate username format (only allow safe characters) - if not re.match(r'^[a-zA-Z0-9_-]+$', username): - return False # Invalid username characters - - # Check for minimum and maximum length - if not (3 <= len(username) <= 32): - return False # Invalid username length - - # Use Path for safe path handling and normalization - user_dir = Path(defines.user_dir) / username - user_info_path = user_dir / defines.user_info_file - - # Ensure the final path is actually within the intended parent directory - # to help prevent directory traversal attacks - try: - if not user_dir.resolve().is_relative_to(Path(defines.user_dir).resolve()): - return False # Path traversal attempt detected - except (ValueError, RuntimeError): # Potential exceptions from resolve() - return False - - # Check if file exists - return user_info_path.is_file() - - def get_or_create_agent(self, agent_type: ChatContextType) -> agents.Agent: - """ - Get or create an agent of the specified type for this candidate. - - Args: - agent_type: The type of agent to create (default is 'candidate_chat'). - **kwargs: Additional fields required by the specific agent subclass. - - Returns: - The created agent instance. - """ - - # Only instantiate one agent of each type per user - for agent in self.CandidateEntity__agents: - if agent.agent_type == agent_type: - return agent - - return agents.get_or_create_agent( - agent_type=agent_type, - user=self, - prometheus_collector=self.prometheus_collector - ) - - # Wrapper properties that map into file_watcher - @property - def umap_collection(self) -> ChromaDBGetResponse: - if not self.CandidateEntity__file_watcher: - raise ValueError("initialize() has not been called.") - return self.CandidateEntity__file_watcher.umap_collection - - # Fields managed by initialize() - CandidateEntity__initialized: bool = Field(default=False, exclude=True) - @property - def file_watcher(self) -> ChromaDBFileWatcher: - if not self.CandidateEntity__file_watcher: - raise ValueError("initialize() has not been called.") - return self.CandidateEntity__file_watcher - - @property - def prometheus_collector(self) -> CollectorRegistry: - if not self.CandidateEntity__prometheus_collector: - raise ValueError("initialize() has not been called with a prometheus_collector.") - return self.CandidateEntity__prometheus_collector - - @property - def observer(self) -> Any: - if not self.CandidateEntity__observer: - raise ValueError("initialize() has not been called.") - return self.CandidateEntity__observer - - async def initialize(self, prometheus_collector: CollectorRegistry, database: RedisDatabase): - if self.CandidateEntity__initialized: - # Initialization can only be attempted once; if there are multiple attempts, it means - # a subsystem is failing or there is a logic bug in the code. - # - # NOTE: It is intentional that self.CandidateEntity__initialize = True regardless of whether it - # succeeded. This prevents server loops on failure - raise ValueError("initialize can only be attempted once") - self.CandidateEntity__initialized = True - - if not self.username: - raise ValueError("username can not be empty") - - user_dir = os.path.join(defines.user_dir, self.username) - vector_db_dir=os.path.join(user_dir, defines.persist_directory) - rag_content_dir=os.path.join(user_dir, defines.rag_content_dir) - - os.makedirs(vector_db_dir, exist_ok=True) - os.makedirs(rag_content_dir, exist_ok=True) - - if prometheus_collector: - self.CandidateEntity__prometheus_collector = prometheus_collector - - self.CandidateEntity__observer, self.CandidateEntity__file_watcher = start_file_watcher( - llm=llm_manager.get_llm(), - user_id=self.id, - collection_name=self.username, - persist_directory=vector_db_dir, - watch_directory=rag_content_dir, - database=database, - recreate=False, # Don't recreate if exists - ) - has_username_rag = any(item["name"] == self.username for item in self.rags) - if not has_username_rag: - self.rags.append(RagEntry( - name=self.username, - description=f"Expert data about {self.full_name}.", - )) - self.rag_content_size = self.file_watcher.collection.count() - -CandidateEntity.model_rebuild() diff --git a/src/backend/entities/entity_manager.py b/src/backend/entities/entity_manager.py index 4b535b4..6e5c6bc 100644 --- a/src/backend/entities/entity_manager.py +++ b/src/backend/entities/entity_manager.py @@ -1,16 +1,18 @@ +from __future__ import annotations import asyncio +from uuid import uuid4 import weakref from datetime import datetime, timedelta from typing import Dict, Optional, Any from contextlib import asynccontextmanager from pydantic import BaseModel, Field # type: ignore -from models import ( Candidate ) -from .candidate_entity import CandidateEntity +from models import Candidate +from agents.base import CandidateEntity from database import RedisDatabase from prometheus_client import CollectorRegistry # type: ignore -class EntityManager: +class EntityManager(BaseModel): """Manages lifecycle of CandidateEntity instances""" def __init__(self, default_ttl_minutes: int = 30): @@ -19,7 +21,8 @@ class EntityManager: self._ttl_minutes = default_ttl_minutes self._cleanup_task: Optional[asyncio.Task] = None self._prometheus_collector: Optional[CollectorRegistry] = None - + self._database: Optional[RedisDatabase] = None + async def start_cleanup_task(self): """Start background cleanup task""" if self._cleanup_task is None: @@ -35,7 +38,10 @@ class EntityManager: pass self._cleanup_task = None - def initialize(self, prometheus_collector: CollectorRegistry, database: RedisDatabase): + def initialize( + self, + prometheus_collector: CollectorRegistry, + database: RedisDatabase): """Initialize the EntityManager with Prometheus collector""" self._prometheus_collector = prometheus_collector self._database = database @@ -44,21 +50,26 @@ class EntityManager: """Get or create CandidateEntity with proper reference tracking""" # Check if entity exists and is still valid - if candidate.id in self._entities: + if id in self._entities: entity = self._entities[candidate.id] - entity._last_accessed = datetime.now() - entity._reference_count += 1 + entity.last_accessed = datetime.now() + entity.reference_count += 1 return entity + if not self._prometheus_collector or not self._database: + raise ValueError("EntityManager has not been initialized with required components.") + entity = CandidateEntity(candidate=candidate) - await entity.initialize(prometheus_collector=self._prometheus_collector, database=self._database) + await entity.initialize( + prometheus_collector=self._prometheus_collector, + database=self._database) # Store with reference tracking self._entities[candidate.id] = entity self._weak_refs[candidate.id] = weakref.ref(entity, self._on_entity_deleted(candidate.id)) - entity._reference_count = 1 - entity._last_accessed = datetime.now() + entity.reference_count = 1 + entity.last_accessed = datetime.now() return entity @@ -106,8 +117,8 @@ class EntityManager: """Explicitly release reference to entity""" if user_id in self._entities: entity = self._entities[user_id] - entity._reference_count = max(0, entity._reference_count - 1) - entity._last_accessed = datetime.now() + entity.reference_count = max(0, entity.reference_count - 1) + entity.last_accessed = datetime.now() async def _periodic_cleanup(self): """Background task to clean up expired entities""" @@ -126,11 +137,11 @@ class EntityManager: expired_entities = [] for user_id, entity in list(self._entities.items()): - time_since_access = current_time - entity._last_accessed + time_since_access = current_time - entity.last_accessed # Remove if TTL exceeded and no active references if (time_since_access > timedelta(minutes=self._ttl_minutes) - and entity._reference_count == 0): + and entity.reference_count == 0): expired_entities.append(user_id) for user_id in expired_entities: @@ -153,4 +164,6 @@ async def get_candidate_entity(candidate: Candidate): try: yield entity finally: - await entity_manager.release_entity(candidate.id) \ No newline at end of file + await entity_manager.release_entity(candidate.id) + +EntityManager.model_rebuild() diff --git a/src/backend/main.py b/src/backend/main.py index d4d1545..e578c26 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -1,18 +1,18 @@ import hashlib import time import traceback -from fastapi import FastAPI, HTTPException, Depends, Query, Path, Body, status, APIRouter, Request, BackgroundTasks, File, UploadFile, Form# type: ignore -from fastapi.middleware.cors import CORSMiddleware # type: ignore -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials# type: ignore -from fastapi.exceptions import RequestValidationError # type: ignore -from fastapi.responses import JSONResponse, StreamingResponse, FileResponse # type: ignore -from fastapi.staticfiles import StaticFiles # type: ignore -from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY # type: ignore +from fastapi import FastAPI, HTTPException, Depends, Query, Path, Body, status, APIRouter, Request, BackgroundTasks, File, UploadFile, Form +from fastapi.middleware.cors import CORSMiddleware +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse, StreamingResponse, FileResponse +from fastapi.staticfiles import StaticFiles +from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY from functools import wraps from typing import Callable, Any, Optional -from rate_limiter import RateLimiter, RateLimitResult +from utils.rate_limiter import RateLimiter, RateLimitResult -import schedule # type: ignore +import schedule import os import shutil @@ -21,17 +21,17 @@ import uuid import defines import pathlib -from markitdown import MarkItDown, StreamInfo # type: ignore +from markitdown import MarkItDown, StreamInfo import io -import uvicorn # type: ignore +import uvicorn from typing import List, Optional, Dict, Any from datetime import datetime, timedelta, UTC import uuid import jwt import os from contextlib import asynccontextmanager -import redis.asyncio as redis # type: ignore +import redis.asyncio as redis import re import asyncio import signal @@ -41,17 +41,15 @@ import uuid import logging from datetime import datetime, timezone, timedelta from typing import Dict, Any, Optional -from pydantic import BaseModel, EmailStr, field_validator, ValidationError # type: ignore +from pydantic import BaseModel, EmailStr, field_validator, ValidationError # Prometheus -from prometheus_client import Summary # type: ignore -from prometheus_fastapi_instrumentator import Instrumentator # type: ignore -from prometheus_client import CollectorRegistry, Counter # type: ignore +from prometheus_client import Summary +from prometheus_fastapi_instrumentator import Instrumentator +from prometheus_client import CollectorRegistry, Counter import secrets 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 @@ -66,13 +64,33 @@ import model_cast import defines from logger import logger from database import RedisDatabase, redis_manager, DatabaseManager -from metrics import Metrics -import llm_proxy as llm_manager import entities from email_service import VerificationEmailRateLimiter, email_service from device_manager import DeviceManager import agents -from entities.candidate_entity import CandidateEntity +from entities.entity_manager import entity_manager + +# ============================= +# Import utilities +# ============================= +from utils.dependencies import get_database, set_db_manager +from utils.responses import create_success_response, create_error_response +from utils.helpers import filter_and_paginate + +# ============================= +# Import route modules +# ============================= +from routes import ( + auth, + candidates, + resumes, + jobs, + chat, + users, + employers, + admin, + system +) # ============================= # Import Pydantic models @@ -123,212 +141,6 @@ def signal_handler(signum, frame): # Global background task manager background_task_manager: Optional[BackgroundTaskManager] = None -# ============================ -# Helper Functions -# ============================ -def get_database() -> RedisDatabase: - """ - Safe database dependency that checks for availability - Raises HTTP 503 if database is not available - """ - global db_manager - - if db_manager is None: - logger.error("Database manager not initialized") - raise HTTPException( - status_code=503, - detail="Database not available - service starting up" - ) - - if db_manager.is_shutting_down: - logger.warning("Database is shutting down") - raise HTTPException( - status_code=503, - detail="Service is shutting down" - ) - - try: - return db_manager.get_database() - except RuntimeError as e: - logger.error(f"Database not available: {e}") - raise HTTPException( - status_code=503, - detail="Database connection not available" - ) - -async def get_last_item(generator): - last_item = None - async for item in generator: - last_item = item - return last_item - -def create_success_response(data: Any, meta: Optional[Dict] = None) -> Dict: - return { - "success": True, - "data": data, - "meta": meta - } - -def create_error_response(code: str, message: str, details: Any = None) -> Dict: - return { - "success": False, - "error": { - "code": code, - "message": message, - "details": details - } - } - -def create_paginated_response( - data: List[Any], - page: int, - limit: int, - total: int -) -> Dict: - total_pages = (total + limit - 1) // limit - has_more = page < total_pages - - return { - "data": data, - "total": total, - "page": page, - "limit": limit, - "totalPages": total_pages, - "hasMore": has_more - } - -def filter_and_paginate( - items: List[Any], - page: int = 1, - limit: int = 20, - sort_by: Optional[str] = None, - sort_order: str = "desc", - filters: Optional[Dict] = None -) -> tuple: - """Filter, sort, and paginate items""" - filtered_items = items.copy() - - # Apply filters (simplified filtering logic) - if filters: - for key, value in filters.items(): - if isinstance(filtered_items[0], dict) and key in filtered_items[0]: - filtered_items = [item for item in filtered_items if item.get(key) == value] - elif hasattr(filtered_items[0], key) if filtered_items else False: - filtered_items = [item for item in filtered_items - if getattr(item, key, None) == value] - - # Sort items - if sort_by and filtered_items: - reverse = sort_order.lower() == "desc" - try: - if isinstance(filtered_items[0], dict): - filtered_items.sort(key=lambda x: x.get(sort_by, ""), reverse=reverse) - else: - filtered_items.sort(key=lambda x: getattr(x, sort_by, ""), reverse=reverse) - except (AttributeError, TypeError): - pass # Skip sorting if attribute doesn't exist or isn't comparable - - # Paginate - total = len(filtered_items) - start = (page - 1) * limit - end = start + limit - paginated_items = filtered_items[start:end] - - return paginated_items, total - -async def stream_agent_response(chat_agent: agents.Agent, - user_message: ChatMessageUser, - chat_session_data: Dict[str, Any] | None = None, - database: RedisDatabase | None = None) -> StreamingResponse: - async def message_stream_generator(): - """Generator to stream messages with persistence""" - last_log = None - final_message = None - - async for generated_message in chat_agent.generate( - llm=llm_manager.get_llm(), - model=defines.model, - session_id=user_message.session_id, - prompt=user_message.content, - ): - if generated_message.status == ApiStatusType.ERROR: - logger.error(f"āŒ AI generation error: {generated_message.content}") - yield f"data: {json.dumps({'status': 'error'})}\n\n" - return - - # Store reference to the complete AI message - if generated_message.status == ApiStatusType.DONE: - final_message = generated_message - - # If the message is not done, convert it to a ChatMessageBase to remove - # metadata and other unnecessary fields for streaming - if generated_message.status != ApiStatusType.DONE: - if not isinstance(generated_message, ChatMessageStreaming) and not isinstance(generated_message, ChatMessageStatus): - raise TypeError( - f"Expected ChatMessageStreaming or ChatMessageStatus, got {type(generated_message)}" - ) - - json_data = generated_message.model_dump(mode='json', by_alias=True) - json_str = json.dumps(json_data) - - yield f"data: {json_str}\n\n" - - # After streaming is complete, persist the final AI message to database - if final_message and final_message.status == ApiStatusType.DONE: - try: - if database and chat_session_data: - await database.add_chat_message(final_message.session_id, final_message.model_dump()) - logger.info(f"šŸ¤– Message saved to database for session {final_message.session_id}") - - # Update session last activity again - chat_session_data["lastActivity"] = datetime.now(UTC).isoformat() - await database.set_chat_session(final_message.session_id, chat_session_data) - - except Exception as e: - logger.error(f"āŒ Failed to save message to database: {e}") - - return StreamingResponse( - message_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", - }, - ) - - -# Helper functions -def get_candidate_files_dir(username: str) -> pathlib.Path: - """Get the files directory for a candidate""" - files_dir = pathlib.Path(defines.user_dir) / username / "files" - files_dir.mkdir(parents=True, exist_ok=True) - return files_dir - -def get_document_type_from_filename(filename: str) -> DocumentType: - """Determine document type from filename extension""" - extension = pathlib.Path(filename).suffix.lower() - - type_mapping = { - '.pdf': DocumentType.PDF, - '.docx': DocumentType.DOCX, - '.doc': DocumentType.DOCX, - '.txt': DocumentType.TXT, - '.md': DocumentType.MARKDOWN, - '.markdown': DocumentType.MARKDOWN, - '.png': DocumentType.IMAGE, - '.jpg': DocumentType.IMAGE, - '.jpeg': DocumentType.IMAGE, - '.gif': DocumentType.IMAGE, - } - - return type_mapping.get(extension, DocumentType.TXT) - - - @asynccontextmanager async def lifespan(app: FastAPI): # Startup @@ -341,7 +153,11 @@ async def lifespan(app: FastAPI): # Initialize database db_manager = DatabaseManager() await db_manager.initialize() - entities.entity_manager.initialize(prometheus_collector, database=db_manager.get_database()) + + # Set the database manager in dependencies + set_db_manager(db_manager) + + entity_manager.initialize(prometheus_collector=prometheus_collector, database=db_manager.get_database()) # Initialize background task manager background_task_manager = BackgroundTaskManager(db_manager) @@ -400,14 +216,6 @@ app.add_middleware( allow_headers=["*"], ) -# Security -security = HTTPBearer() -JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "") -if JWT_SECRET_KEY == "": - raise ValueError("JWT_SECRET_KEY environment variable is not set") -ALGORITHM = "HS256" - - # ============================ # Debug data type failures # ============================ @@ -423,5340 +231,23 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE ) # ============================ -# Authentication Utilities -# ============================ - -# Request/Response Models - - -def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): - to_encode = data.copy() - if expires_delta: - expire = datetime.now(UTC) + expires_delta - else: - expire = datetime.now(UTC) + timedelta(hours=24) - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=ALGORITHM) - return encoded_jwt - -async def verify_token_with_blacklist(credentials: HTTPAuthorizationCredentials = Depends(security)): - """Enhanced token verification with guest session recovery""" - try: - if not db_manager: - raise HTTPException(status_code=500, detail="Database not initialized") - # First decode the token - payload = jwt.decode(credentials.credentials, JWT_SECRET_KEY, algorithms=[ALGORITHM]) - user_id: str = payload.get("sub") - token_type: str = payload.get("type", "access") - - if user_id is None: - raise HTTPException(status_code=401, detail="Invalid authentication credentials") - - # Check if token is blacklisted - redis = redis_manager.get_client() - blacklist_key = f"blacklisted_token:{credentials.credentials}" - - is_blacklisted = await redis.exists(blacklist_key) - if is_blacklisted: - logger.warning(f"🚫 Attempt to use blacklisted token for user {user_id}") - raise HTTPException(status_code=401, detail="Token has been revoked") - - # For guest tokens, verify guest still exists and update activity - if token_type == "guest" or payload.get("type") == "guest": - database = db_manager.get_database() - guest_data = await database.get_guest(user_id) - - if not guest_data: - logger.warning(f"🚫 Guest session not found for token: {user_id}") - raise HTTPException(status_code=401, detail="Guest session expired") - - # Update guest activity - guest_data["last_activity"] = datetime.now(UTC).isoformat() - await database.set_guest(user_id, guest_data) - logger.debug(f"šŸ”„ Guest activity updated: {user_id}") - - return user_id - - except jwt.PyJWTError as e: - logger.warning(f"āš ļø JWT decode error: {e}") - raise HTTPException(status_code=401, detail="Invalid authentication credentials") - except HTTPException: - raise - except Exception as e: - logger.error(f"āŒ Token verification error: {e}") - raise HTTPException(status_code=401, detail="Token verification failed") - -async def get_current_user( - user_id: str = Depends(verify_token_with_blacklist), - database: RedisDatabase = Depends(get_database) -) -> BaseUserWithType: - """Get current user from database""" - try: - # Check candidates - candidate_data = await database.get_candidate(user_id) - if candidate_data: - # logger.info(f"šŸ”‘ Current user is candidate: {candidate['id']}") - return Candidate.model_validate(candidate_data) if not candidate_data.get("is_AI") else CandidateAI.model_validate(candidate_data) # type: ignore[return-value] - # Check candidates - candidate_data = await database.get_candidate(user_id) - if candidate_data: - # logger.info(f"šŸ”‘ Current user is candidate: {candidate['id']}") - if candidate_data.get("is_AI"): - return model_cast.cast_to_base_user_with_type(CandidateAI.model_validate(candidate_data)) - else: - return model_cast.cast_to_base_user_with_type(Candidate.model_validate(candidate_data)) - # Check employers - employer = await database.get_employer(user_id) - if employer: - # logger.info(f"šŸ”‘ Current user is employer: {employer['id']}") - return Employer.model_validate(employer) - - logger.warning(f"āš ļø User {user_id} not found in database") - raise HTTPException(status_code=404, detail="User not found") - - except Exception as e: - logger.error(f"āŒ Error getting current user: {e}") - raise HTTPException(status_code=404, detail="User not found") - -async def get_current_user_or_guest( - user_id: str = Depends(verify_token_with_blacklist), - database: RedisDatabase = Depends(get_database) -) -> BaseUserWithType: - """Get current user (including guests) from database""" - try: - # Check candidates first - candidate_data = await database.get_candidate(user_id) - if candidate_data: - return Candidate.model_validate(candidate_data) if not candidate_data.get("is_AI") else CandidateAI.model_validate(candidate_data) - - # Check employers - employer_data = await database.get_employer(user_id) - if employer_data: - return Employer.model_validate(employer_data) - - # Check guests - guest_data = await database.get_guest(user_id) - if guest_data: - return Guest.model_validate(guest_data) - - logger.warning(f"āš ļø User {user_id} not found in database") - raise HTTPException(status_code=404, detail="User not found") - - except Exception as e: - logger.error(f"āŒ Error getting current user: {e}") - raise HTTPException(status_code=404, detail="User not found") - -async def get_current_admin( - user_id: str = Depends(verify_token_with_blacklist), - database: RedisDatabase = Depends(get_database) -) -> BaseUserWithType: - user = await get_current_user(user_id=user_id, database=database) - if isinstance(user, Candidate) and user.is_admin: - return user - elif isinstance(user, Employer) and user.is_admin: - return user - else: - logger.warning(f"āš ļø User {user_id} is not an admin") - raise HTTPException(status_code=403, detail="Admin access required") - - -# ============================ -# Rate Limiting Dependencies -# ============================ - -async def get_rate_limiter(database: RedisDatabase = Depends(get_database)) -> RateLimiter: - """Dependency to get rate limiter instance""" - return RateLimiter(database) - -async def apply_rate_limiting( - request: Request, - rate_limiter: RateLimiter = Depends(get_rate_limiter), - current_user: Optional[BaseUserWithType] = None -) -> RateLimitResult: - """ - Apply rate limiting based on user type - Can be used as a dependency in endpoints - """ - try: - # Determine user info for rate limiting - if current_user: - user_id = current_user.id - user_type = current_user.user_type - is_admin = getattr(current_user, 'is_admin', False) - else: - # For unauthenticated requests, use IP address as identifier - user_id = request.client.host if request.client else "unknown" - user_type = "anonymous" - is_admin = False - - # Extract endpoint for specific rate limiting if needed - endpoint = request.url.path - - # Check rate limits - result = await rate_limiter.check_rate_limit( - user_id=user_id, - user_type=user_type, - is_admin=is_admin, - endpoint=endpoint - ) - - if not result.allowed: - logger.warning(f"🚫 Rate limit exceeded for {user_type} {user_id}: {result.reason}") - raise HTTPException( - status_code=429, - detail={ - "error": "Rate limit exceeded", - "message": result.reason, - "retryAfter": result.retry_after_seconds, - "remaining": result.remaining_requests - }, - headers={"Retry-After": str(result.retry_after_seconds or 60)} - ) - - return result - - except HTTPException: - raise - except Exception as e: - logger.error(f"āŒ Rate limiting error: {e}") - # Fail open - allow request if rate limiting fails - return RateLimitResult(allowed=True, reason="Rate limiting system error") - -async def rate_limit_dependency( - request: Request, - rate_limiter: RateLimiter = Depends(get_rate_limiter) -): - """ - Rate limiting dependency that can be applied to any endpoint - Usage: dependencies=[Depends(rate_limit_dependency)] - """ - try: - # Try to get current user from token if present - current_user = None - if "authorization" in request.headers: - try: - auth_header = request.headers["authorization"] - if auth_header.startswith("Bearer "): - token = auth_header[7:] - payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[ALGORITHM]) - user_id = payload.get("sub") - if user_id: - database = get_database() - # Quick user lookup for rate limiting - candidate_data = await database.get_candidate(user_id) - if candidate_data: - current_user = Candidate.model_validate(candidate_data) - else: - employer_data = await database.get_employer(user_id) - if employer_data: - current_user = Employer.model_validate(employer_data) - except: - # Ignore auth errors for rate limiting - treat as anonymous - pass - - await apply_rate_limiting(request, rate_limiter, current_user) - - except HTTPException: - raise - except Exception as e: - logger.error(f"āŒ Rate limit dependency error: {e}") - # Fail open - -# ============================ -# API Router Setup -# ============================ - # Create API router with prefix +# ============================ api_router = APIRouter(prefix="/api/1.0") # ============================ -# Authentication Endpoints +# Include all route modules # ============================ -@api_router.post("/auth/guest") -async def create_guest_session_enhanced( - request: Request, - database: RedisDatabase = Depends(get_database), - rate_limiter: RateLimiter = Depends(get_rate_limiter) -): - """Create a guest session with enhanced validation and persistence""" - try: - # Apply rate limiting for guest creation - ip_address = request.client.host if request.client else "unknown" - - # Check rate limits for guest session creation - rate_result = await rate_limiter.check_rate_limit( - user_id=ip_address, - user_type="guest_creation", - is_admin=False, - endpoint="/auth/guest" - ) - - if not rate_result.allowed: - logger.warning(f"🚫 Guest creation rate limit exceeded for IP {ip_address}") - return JSONResponse( - status_code=429, - content=create_error_response( - "RATE_LIMITED", - rate_result.reason or "Too many guest sessions created" - ), - headers={"Retry-After": str(rate_result.retry_after_seconds or 300)} - ) - - # Generate unique guest identifier with timestamp for uniqueness - current_time = datetime.now(UTC) - guest_id = str(uuid.uuid4()) - session_id = f"guest_{int(current_time.timestamp())}_{secrets.token_hex(8)}" - guest_username = f"guest-{session_id[-12:]}" - - # Verify username is unique (unlikely but possible collision) - while True: - existing_user = await database.get_user(guest_username) - if existing_user: - # Regenerate if collision - session_id = f"guest_{int(current_time.timestamp())}_{secrets.token_hex(12)}" - guest_username = f"guest-{session_id[-16:]}" - else: - break - - # Create guest user data with comprehensive info - guest_data = { - "id": guest_id, - "session_id": session_id, - "username": guest_username, - "email": f"{guest_username}@guest.backstory.ketrenos.com", - "first_name": "Guest", - "last_name": "User", - "full_name": "Guest User", - "user_type": "guest", - "created_at": current_time.isoformat(), - "updated_at": current_time.isoformat(), - "last_activity": current_time.isoformat(), - "last_login": current_time.isoformat(), - "status": "active", - "is_admin": False, - "ip_address": ip_address, - "user_agent": request.headers.get("user-agent", "Unknown"), - "converted_to_user_id": None, - "browser_session": True, # Mark as browser session - "persistent": True, # Mark as persistent - } - - # Store guest with enhanced persistence - await database.set_guest(guest_id, guest_data) - - # Create user lookup records - user_auth_data = { - "id": guest_id, - "type": "guest", - "email": guest_data["email"], - "username": guest_username, - "session_id": session_id, - "created_at": current_time.isoformat() - } - - await database.set_user(guest_data["email"], user_auth_data) - await database.set_user(guest_username, user_auth_data) - await database.set_user_by_id(guest_id, user_auth_data) - - # Create authentication tokens with longer expiry for guests - access_token = create_access_token( - data={"sub": guest_id, "type": "guest"}, - expires_delta=timedelta(hours=48) # Longer expiry for guests - ) - refresh_token = create_access_token( - data={"sub": guest_id, "type": "refresh_guest"}, - expires_delta=timedelta(days=14) # 2 weeks refresh for guests - ) - - # Verify guest was stored correctly - verification = await database.get_guest(guest_id) - if not verification: - logger.error(f"āŒ Failed to verify guest storage: {guest_id}") - return JSONResponse( - status_code=500, - content=create_error_response("STORAGE_ERROR", "Failed to create guest session") - ) - - # Create guest object for response - guest = Guest.model_validate(guest_data) - - # Log successful creation - logger.info(f"šŸ‘¤ Guest session created and verified: {guest_username} (ID: {guest_id}) from IP: {ip_address}") - - # Create auth response - auth_response = { - "accessToken": access_token, - "refreshToken": refresh_token, - "user": guest.model_dump(by_alias=True), - "expiresAt": int((current_time + timedelta(hours=48)).timestamp()), - "userType": "guest", - "isGuest": True - } - - return create_success_response(auth_response) - - except Exception as e: - logger.error(f"āŒ Guest session creation error: {e}") - import traceback - logger.error(traceback.format_exc()) - return JSONResponse( - status_code=500, - content=create_error_response("GUEST_CREATION_FAILED", "Failed to create guest session") - ) - -@api_router.post("/auth/guest/convert") -async def convert_guest_to_user( - registration_data: Dict[str, Any] = Body(...), - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Convert a guest session to a permanent user account""" - try: - # Verify current user is a guest - if current_user.user_type != "guest": - return JSONResponse( - status_code=400, - content=create_error_response("NOT_GUEST", "Only guest users can be converted") - ) - - guest: Guest = current_user - account_type = registration_data.get("accountType", "candidate") - - if account_type == "candidate": - # Validate candidate registration data - try: - candidate_request = CreateCandidateRequest.model_validate(registration_data) - except ValidationError as e: - return JSONResponse( - status_code=400, - content=create_error_response("VALIDATION_ERROR", str(e)) - ) - - # Check if email/username already exists - auth_manager = AuthenticationManager(database) - user_exists, conflict_field = await auth_manager.check_user_exists( - candidate_request.email, - candidate_request.username - ) - - if user_exists: - return JSONResponse( - status_code=409, - content=create_error_response( - "USER_EXISTS", - f"A user with this {conflict_field} already exists" - ) - ) - - # Create candidate - candidate_id = str(uuid.uuid4()) - current_time = datetime.now(timezone.utc) - - candidate_data = { - "id": candidate_id, - "user_type": "candidate", - "email": candidate_request.email, - "username": candidate_request.username, - "first_name": candidate_request.first_name, - "last_name": candidate_request.last_name, - "full_name": f"{candidate_request.first_name} {candidate_request.last_name}", - "phone": candidate_request.phone, - "created_at": current_time.isoformat(), - "updated_at": current_time.isoformat(), - "status": "active", - "is_admin": False, - "converted_from_guest": guest.id - } - - candidate = Candidate.model_validate(candidate_data) - - # Create authentication - await auth_manager.create_user_authentication(candidate_id, candidate_request.password) - - # Store candidate - await database.set_candidate(candidate_id, candidate.model_dump()) - - # Update user lookup records - user_auth_data = { - "id": candidate_id, - "type": "candidate", - "email": candidate.email, - "username": candidate.username - } - - await database.set_user(candidate.email, user_auth_data) - await database.set_user(candidate.username, user_auth_data) - await database.set_user_by_id(candidate_id, user_auth_data) - - # Mark guest as converted - guest_data = guest.model_dump() - guest_data["converted_to_user_id"] = candidate_id - guest_data["updated_at"] = current_time.isoformat() - await database.set_guest(guest.id, guest_data) - - # Create new tokens for the candidate - access_token = create_access_token(data={"sub": candidate_id}) - refresh_token = create_access_token( - data={"sub": candidate_id, "type": "refresh"}, - expires_delta=timedelta(days=SecurityConfig.REFRESH_TOKEN_EXPIRY_DAYS) - ) - - auth_response = AuthResponse( - accessToken=access_token, - refreshToken=refresh_token, - user=candidate, - expiresAt=int((current_time + timedelta(hours=SecurityConfig.TOKEN_EXPIRY_HOURS)).timestamp()) - ) - - logger.info(f"āœ… Guest {guest.session_id} converted to candidate {candidate.username}") - - return create_success_response({ - "message": "Guest account successfully converted to candidate", - "auth": auth_response.model_dump(by_alias=True), - "conversionType": "candidate" - }) - - else: - return JSONResponse( - status_code=400, - content=create_error_response("INVALID_TYPE", "Only candidate conversion is currently supported") - ) - - except Exception as e: - logger.error(f"āŒ Guest conversion error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("CONVERSION_FAILED", "Failed to convert guest account") - ) - -@api_router.post("/auth/logout") -async def logout( - access_token: str = Body(..., alias="accessToken"), - refresh_token: str = Body(..., alias="refreshToken"), - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Logout endpoint - revokes both access and refresh tokens""" - logger.info(f"šŸ”‘ User {current_user.id} is logging out") - try: - # Verify refresh token - try: - refresh_payload = jwt.decode(refresh_token, JWT_SECRET_KEY, algorithms=[ALGORITHM]) - user_id = refresh_payload.get("sub") - token_type = refresh_payload.get("type") - refresh_exp = refresh_payload.get("exp") - - if not user_id or token_type != "refresh": - return JSONResponse( - status_code=401, - content=create_error_response("INVALID_TOKEN", "Invalid refresh token") - ) - except jwt.PyJWTError as e: - logger.warning(f"āš ļø Invalid refresh token during logout: {e}") - return JSONResponse( - status_code=401, - content=create_error_response("INVALID_TOKEN", "Invalid refresh token") - ) - - # Verify that the refresh token belongs to the current user - if user_id != current_user.id: - return JSONResponse( - status_code=403, - content=create_error_response("FORBIDDEN", "Token does not belong to current user") - ) - - # Get Redis client - redis = redis_manager.get_client() - - # Revoke refresh token (blacklist it until its natural expiration) - refresh_ttl = max(0, refresh_exp - int(datetime.now(UTC).timestamp())) - if refresh_ttl > 0: - await redis.setex( - f"blacklisted_token:{refresh_token}", - refresh_ttl, - json.dumps({ - "user_id": user_id, - "token_type": "refresh", - "revoked_at": datetime.now(UTC).isoformat(), - "reason": "user_logout" - }) - ) - logger.info(f"šŸ”’ Blacklisted refresh token for user {user_id}") - - # If access token is provided, revoke it too - if access_token: - try: - access_payload = jwt.decode(access_token, JWT_SECRET_KEY, algorithms=[ALGORITHM]) - access_user_id = access_payload.get("sub") - access_exp = access_payload.get("exp") - - # Verify access token belongs to same user - if access_user_id == user_id: - access_ttl = max(0, access_exp - int(datetime.now(UTC).timestamp())) - if access_ttl > 0: - await redis.setex( - f"blacklisted_token:{access_token}", - access_ttl, - json.dumps({ - "user_id": user_id, - "token_type": "access", - "revoked_at": datetime.now(UTC).isoformat(), - "reason": "user_logout" - }) - ) - logger.info(f"šŸ”’ Blacklisted access token for user {user_id}") - else: - logger.warning(f"āš ļø Access token user mismatch during logout: {access_user_id} != {user_id}") - except jwt.PyJWTError as e: - logger.warning(f"āš ļø Invalid access token during logout (non-critical): {e}") - # Don't fail logout if access token is invalid - - # Optional: Revoke all tokens for this user (for "logout from all devices") - # Uncomment the following lines if you want to implement this feature: - # - # await redis.setex( - # f"user_tokens_revoked:{user_id}", - # timedelta(days=30).total_seconds(), # Max refresh token lifetime - # datetime.now(UTC).isoformat() - # ) - - logger.info(f"šŸ”‘ User {user_id} logged out successfully") - return create_success_response({ - "message": "Logged out successfully", - "tokensRevoked": { - "refreshToken": True, - "accessToken": bool(access_token) - } - }) - - except Exception as e: - logger.error(f"āŒ Logout error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("LOGOUT_ERROR", str(e)) - ) +api_router.include_router(auth.router) +api_router.include_router(candidates.router) +api_router.include_router(resumes.router) +api_router.include_router(jobs.router) +api_router.include_router(chat.router) +api_router.include_router(users.router) +api_router.include_router(employers.router) +api_router.include_router(admin.router) +api_router.include_router(system.router) -@api_router.post("/auth/logout-all") -async def logout_all_devices( - current_user = Depends(get_current_admin), - database: RedisDatabase = Depends(get_database) -): - """Logout from all devices by revoking all tokens for the user""" - try: - redis = redis_manager.get_client() - - # Set a timestamp that invalidates all tokens issued before this moment - await redis.setex( - f"user_tokens_revoked:{current_user.id}", - int(timedelta(days=30).total_seconds()), # Max refresh token lifetime - datetime.now(UTC).isoformat() - ) - - logger.info(f"šŸ”’ All tokens revoked for user {current_user.id}") - return create_success_response({ - "message": "Logged out from all devices successfully" - }) - - except Exception as e: - logger.error(f"āŒ Logout all devices error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("LOGOUT_ALL_ERROR", str(e)) - ) - -@api_router.post("/auth/refresh") -async def refresh_token_endpoint( - refreshToken: str = Body(..., alias="refreshToken"), - database: RedisDatabase = Depends(get_database) -): - """Refresh token endpoint""" - try: - # Verify refresh token - payload = jwt.decode(refreshToken, JWT_SECRET_KEY, algorithms=[ALGORITHM]) - user_id = payload.get("sub") - token_type = payload.get("type") - - if not user_id or token_type != "refresh": - return JSONResponse( - status_code=401, - content=create_error_response("INVALID_TOKEN", "Invalid refresh token") - ) - - # Create new access token - access_token = create_access_token(data={"sub": user_id}) - - # Get user - user = None - candidate_data = await database.get_candidate(user_id) - if candidate_data: - user = Candidate.model_validate(candidate_data) - else: - employer_data = await database.get_employer(user_id) - if employer_data: - user = Employer.model_validate(employer_data) - - if not user: - return JSONResponse( - status_code=404, - content=create_error_response("USER_NOT_FOUND", "User not found") - ) - - auth_response = AuthResponse( - accessToken=access_token, - refreshToken=refreshToken, # Keep same refresh token - user=user, - expiresAt=int((datetime.now(UTC) + timedelta(hours=24)).timestamp()) - ) - - return create_success_response(auth_response.model_dump(by_alias=True)) - - except jwt.PyJWTError: - return JSONResponse( - status_code=401, - content=create_error_response("INVALID_TOKEN", "Invalid refresh token") - ) - except Exception as e: - logger.error(f"āŒ Token refresh error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("REFRESH_ERROR", str(e)) - ) - -# ============================ -# Candidate Endpoints -# ============================ -@api_router.post("/candidates/ai") -async def create_candidate_ai( - background_tasks: BackgroundTasks, - user_message: ChatMessageUser = Body(...), - admin: Candidate = Depends(get_current_admin), - database: RedisDatabase = Depends(get_database) -): - """Create a new candidate using AI-generated data""" - try: - generate_agent = agents.get_or_create_agent( - agent_type=ChatContextType.GENERATE_PERSONA, - prometheus_collector=prometheus_collector) - - if not generate_agent: - logger.warning(f"āš ļø Unable to create AI generation agent.") - return JSONResponse( - status_code=400, - content=create_error_response("AGENT_NOT_FOUND", "Unable to create AI generation agent") - ) - - persona_message = None - resume_message = None - state = 0 # 0 -- create persona, 1 -- create resume - async for generated_message in generate_agent.generate( - llm=llm_manager.get_llm(), - model=defines.model, - session_id=user_message.session_id, - prompt=user_message.content, - ): - if isinstance(generated_message, ChatMessageError): - error_message : ChatMessageError = generated_message - logger.error(f"āŒ AI generation error: {error_message.content}") - return JSONResponse( - status_code=500, - content=create_error_response("AI_GENERATION_ERROR", error_message.content) - ) - if isinstance(generated_message, ChatMessageRagSearch): - raise ValueError("AI generation returned a RAG search message instead of a persona") - - if generated_message.status == ApiStatusType.DONE and state == 0: - persona_message = generated_message - state = 1 # Switch to resume generation - elif generated_message.status == ApiStatusType.DONE and state == 1: - resume_message = generated_message - - - if not persona_message: - logger.error(f"āŒ AI generation failed: {persona_message.content if persona_message else 'No message generated'}") - return JSONResponse( - status_code=500, - content=create_error_response("AI_GENERATION_FAILED", "Failed to generate AI candidate data") - ) - - try: - current_time = datetime.now(timezone.utc) - candidate_data = json.loads(persona_message.content) - candidate_data.update({ - "user_type": "candidate", - "created_at": current_time.isoformat(), - "updated_at": current_time.isoformat(), - "status": "active", # Directly active for AI-generated candidates - "is_admin": False, # Default to non-admin - "is_AI": True, # Mark as AI-generated - }) - candidate = CandidateAI.model_validate(candidate_data) - except ValidationError as e: - logger.error(f"āŒ AI candidate data validation failed") - for lines in backstory_traceback.format_exc().splitlines(): - logger.error(lines) - logger.error(json.dumps(persona_message.content, indent=2)) - for error in e.errors(): - print(f"Field: {error['loc'][0]}, Error: {error['msg']}") - return JSONResponse( - status_code=400, - content=create_error_response("AI_VALIDATION_FAILED", "AI-generated data validation failed") - ) - except Exception as e: - # Log the error and return a validation error response - for lines in backstory_traceback.format_exc().splitlines(): - logger.error(lines) - logger.error(json.dumps(persona_message.content, indent=2)) - return JSONResponse( - status_code=400, - content=create_error_response("AI_VALIDATION_FAILED", "AI-generated data validation failed") - ) - - logger.info(f"šŸ¤– AI-generated candidate {candidate.username} created with email {candidate.email}") - candidate_data = candidate.model_dump(by_alias=False, exclude_unset=False) - # Store in database - await database.set_candidate(candidate.id, candidate_data) - - user_auth_data = { - "id": candidate.id, - "type": "candidate", - "email": candidate.email, - "username": candidate.username - } - - await database.set_user(candidate.email, user_auth_data) - await database.set_user(candidate.username, user_auth_data) - await database.set_user_by_id(candidate.id, user_auth_data) - - document_content = None - if resume_message: - document_id = str(uuid.uuid4()) - document_type = DocumentType.MARKDOWN - document_content = resume_message.content.encode('utf-8') - document_filename = f"resume.md" - - document_data = Document( - id=document_id, - filename=document_filename, - originalName=document_filename, - type=document_type, - size=len(document_content), - uploadDate=datetime.now(UTC), - ownerId=candidate.id - ) - file_path = os.path.join(defines.user_dir, candidate.username, "rag-content", document_filename) - # Ensure the directory exists - rag_content_dir = pathlib.Path(defines.user_dir) / candidate.username / "rag-content" - rag_content_dir.mkdir(parents=True, exist_ok=True) - try: - with open(file_path, "wb") as f: - f.write(document_content) - - logger.info(f"šŸ“ File saved to disk: {file_path}") - - except Exception as e: - logger.error(f"āŒ Failed to save file to disk: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("FILE_SAVE_ERROR", "Failed to resume file to disk") - ) - - # Store document metadata in database - await database.set_document(document_id, document_data.model_dump()) - await database.add_document_to_candidate(candidate.id, document_id) - logger.info(f"šŸ“„ Document metadata saved for candidate {candidate.id}: {document_id}") - - logger.info(f"āœ… AI-generated candidate created: {candidate_data['email']}, resume is {len(document_content) if document_content else 0} bytes") - - return create_success_response({ - "message": "AI-generated candidate created successfully", - "candidate": candidate_data, - "resume": document_content, - }) - - except Exception as e: - logger.error(backstory_traceback.format_exc()) - logger.error(f"āŒ AI Candidate creation error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("AI_CREATION_FAILED", "Failed to create AI-generated candidate") - ) - -@api_router.post("/candidates") -async def create_candidate_with_verification( - request: CreateCandidateRequest, - background_tasks: BackgroundTasks, - database: RedisDatabase = Depends(get_database) -): - """Create a new candidate with email verification""" - try: - # Initialize authentication manager - auth_manager = AuthenticationManager(database) - - # Check if user already exists - user_exists, conflict_field = await auth_manager.check_user_exists( - request.email, - request.username - ) - - if user_exists and conflict_field: - logger.warning(f"āš ļø Attempted to create user with existing {conflict_field}: {getattr(request, conflict_field)}") - return JSONResponse( - status_code=409, - content=create_error_response( - "USER_EXISTS", - f"A user with this {conflict_field} already exists" - ) - ) - - # Generate candidate data (but don't activate yet) - candidate_id = str(uuid.uuid4()) - current_time = datetime.now(timezone.utc) - all_candidates = await database.get_all_candidates() - is_admin = False - if len(all_candidates) == 0: - is_admin = True - - candidate_data = { - "id": candidate_id, - "userType": "candidate", - "email": request.email, - "username": request.username, - "firstName": request.first_name, - "lastName": request.last_name, - "fullName": f"{request.first_name} {request.last_name}", - "phone": request.phone, - "createdAt": current_time.isoformat(), - "updatedAt": current_time.isoformat(), - "status": "pending", # Not active until email verified - "isAdmin": is_admin, - } - - # Generate verification token - verification_token = secrets.token_urlsafe(32) - - # Store verification token with user data - await database.store_email_verification_token( - request.email, - verification_token, - "candidate", - { - "candidate_data": candidate_data, - "password": request.password, # Store temporarily for verification - "username": request.username - } - ) - - # Send verification email in background - background_tasks.add_task( - email_service.send_verification_email, - request.email, - verification_token, - f"{request.first_name} {request.last_name}" - ) - - logger.info(f"āœ… Candidate registration initiated for: {request.email}") - - return create_success_response({ - "message": f"Registration successful! Please check your email to verify your account. {'As the first user on this sytem, you have admin priveledges.' if is_admin else ''}", - "email": request.email, - "verificationRequired": True - }) - - except Exception as e: - logger.error(f"āŒ Candidate creation error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("CREATION_FAILED", "Failed to create candidate account") - ) - -@api_router.post("/employers") -async def create_employer_with_verification( - request: CreateEmployerRequest, - background_tasks: BackgroundTasks, - database: RedisDatabase = Depends(get_database) -): - """Create a new employer with email verification""" - try: - # Similar to candidate creation but for employer - auth_manager = AuthenticationManager(database) - - user_exists, conflict_field = await auth_manager.check_user_exists( - request.email, - request.username - ) - - if user_exists and conflict_field: - return JSONResponse( - status_code=409, - content=create_error_response( - "USER_EXISTS", - f"A user with this {conflict_field} already exists" - ) - ) - - employer_id = str(uuid.uuid4()) - current_time = datetime.now(timezone.utc) - - employer_data = { - "id": employer_id, - "email": request.email, - "companyName": request.company_name, - "industry": request.industry, - "companySize": request.company_size, - "companyDescription": request.company_description, - "websiteUrl": request.website_url, - "phone": request.phone, - "createdAt": current_time.isoformat(), - "updatedAt": current_time.isoformat(), - "status": "pending", # Not active until verified - "userType": "employer", - "location": { - "city": "", - "country": "", - "remote": False - }, - "socialLinks": [] - } - - verification_token = secrets.token_urlsafe(32) - - await database.store_email_verification_token( - request.email, - verification_token, - "employer", - { - "employer_data": employer_data, - "password": request.password, - "username": request.username - } - ) - - background_tasks.add_task( - email_service.send_verification_email, - request.email, - verification_token, - request.company_name - ) - - logger.info(f"āœ… Employer registration initiated for: {request.email}") - - return create_success_response({ - "message": "Registration successful! Please check your email to verify your account.", - "email": request.email, - "verificationRequired": True - }) - - except Exception as e: - logger.error(f"āŒ Employer creation error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("CREATION_FAILED", "Failed to create employer account") - ) - -@api_router.post("/auth/verify-email") -async def verify_email( - request: EmailVerificationRequest, - database: RedisDatabase = Depends(get_database) -): - """Verify email address and activate account""" - try: - # Get verification data - verification_data = await database.get_email_verification_token(request.token) - - if not verification_data: - logger.warning(f"āš ļø Invalid verification token: {request.token}") - return JSONResponse( - status_code=400, - content=create_error_response("INVALID_TOKEN", "Invalid or expired verification token") - ) - - if verification_data.get("verified"): - logger.warning(f"āš ļø Attempt to verify already verified email: {verification_data['email']}") - return JSONResponse( - status_code=400, - content=create_error_response("ALREADY_VERIFIED", "Email already verified") - ) - - # Check expiration - expires_at = datetime.fromisoformat(verification_data["expires_at"]) - if datetime.now(timezone.utc) > expires_at: - logger.warning(f"āš ļø Verification token expired for: {verification_data['email']}") - return JSONResponse( - status_code=400, - content=create_error_response("TOKEN_EXPIRED", "Verification token has expired") - ) - - # Extract user data - user_type = verification_data["user_type"] - user_data_container = verification_data["user_data"] - - if user_type == "candidate": - candidate_data = user_data_container["candidate_data"] - password = user_data_container["password"] - username = user_data_container["username"] - - # Activate candidate - candidate_data["status"] = "active" - candidate = Candidate.model_validate(candidate_data) - - # Create authentication record - auth_manager = AuthenticationManager(database) - await auth_manager.create_user_authentication(candidate.id, password) - - # Store in database - await database.set_candidate(candidate.id, candidate.model_dump()) - - # Add user lookup records - user_auth_data = { - "id": candidate.id, - "type": "candidate", - "email": candidate.email, - "username": username - } - - await database.set_user(candidate.email, user_auth_data) - await database.set_user(username, user_auth_data) - await database.set_user_by_id(candidate.id, user_auth_data) - - elif user_type == "employer": - employer_data = user_data_container["employer_data"] - password = user_data_container["password"] - username = user_data_container["username"] - - # Activate employer - employer_data["status"] = "active" - employer = Employer.model_validate(employer_data) - - # Create authentication record - auth_manager = AuthenticationManager(database) - await auth_manager.create_user_authentication(employer.id, password) - - # Store in database - await database.set_employer(employer.id, employer.model_dump()) - - # Add user lookup records - user_auth_data = { - "id": employer.id, - "type": "employer", - "email": employer.email, - "username": username - } - - await database.set_user(employer.email, user_auth_data) - await database.set_user(username, user_auth_data) - await database.set_user_by_id(employer.id, user_auth_data) - - # Mark as verified - await database.mark_email_verified(request.token) - - logger.info(f"āœ… Email verified and account activated for: {verification_data['email']}") - - return create_success_response({ - "message": "Email verified successfully! Your account is now active.", - "accountActivated": True, - "userType": user_type - }) - - except Exception as e: - logger.error(f"āŒ Email verification error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("VERIFICATION_FAILED", "Failed to verify email") - ) - -@api_router.post("/auth/resend-verification") -async def resend_verification_email( - request: ResendVerificationRequest, - background_tasks: BackgroundTasks, - database: RedisDatabase = Depends(get_database) -): - """Resend verification email with comprehensive rate limiting and validation""" - try: - email_lower = request.email.lower().strip() - - # Initialize rate limiter - rate_limiter = VerificationEmailRateLimiter(database) - - # Check rate limiting - can_send, reason = await rate_limiter.can_send_verification_email(email_lower) - if not can_send: - logger.warning(f"āš ļø Verification email rate limit exceeded for {email_lower}: {reason}") - return JSONResponse( - status_code=429, - content=create_error_response("RATE_LIMITED", reason) - ) - - # Clean up expired tokens first - await database.cleanup_expired_verification_tokens() - - # Check if user already exists and is verified - user_data = await database.get_user(email_lower) - if user_data: - # User exists and is verified - don't reveal this for security - logger.info(f"šŸ” Resend verification requested for already verified user: {email_lower}") - await rate_limiter.record_email_sent(email_lower) # Record attempt to prevent abuse - return create_success_response({ - "message": "If your email is in our system and pending verification, a new verification email has been sent." - }) - - # Look for pending verification token - verification_data = await database.find_verification_token_by_email(email_lower) - - if not verification_data: - # No pending verification found - don't reveal this for security - logger.info(f"šŸ” Resend verification requested for non-existent pending verification: {email_lower}") - await rate_limiter.record_email_sent(email_lower) # Record attempt to prevent abuse - return create_success_response({ - "message": "If your email is in our system and pending verification, a new verification email has been sent." - }) - - # Check if verification token has expired - expires_at = datetime.fromisoformat(verification_data["expires_at"]) - current_time = datetime.now(timezone.utc) - - if current_time > expires_at: - # Token expired - clean it up and inform user - await database.redis.delete(f"email_verification:{verification_data['token']}") - logger.info(f"🧹 Cleaned up expired verification token for {email_lower}") - return JSONResponse( - status_code=400, - content=create_error_response( - "TOKEN_EXPIRED", - "Your verification link has expired. Please register again to create a new account." - ) - ) - - # Generate new verification token (invalidate old one) - old_token = verification_data["token"] - new_token = secrets.token_urlsafe(32) - - # Update verification data with new token and reset attempts - verification_data.update({ - "token": new_token, - "expires_at": (current_time + timedelta(hours=24)).isoformat(), - "resent_at": current_time.isoformat(), - "resend_count": verification_data.get("resend_count", 0) + 1 - }) - - # Store new token and remove old one - await database.redis.delete(f"email_verification:{old_token}") - await database.store_email_verification_token( - email_lower, - new_token, - verification_data["user_type"], - verification_data["user_data"] - ) - - # Get user name for email - user_data_container = verification_data["user_data"] - user_type = verification_data["user_type"] - - if user_type == "candidate": - candidate_data = user_data_container["candidate_data"] - user_name = candidate_data.get("fullName", "User") - elif user_type == "employer": - employer_data = user_data_container["employer_data"] - user_name = employer_data.get("companyName", "User") - else: - user_name = "User" - - # Record email attempt - await rate_limiter.record_email_sent(email_lower) - - # Send new verification email in background - background_tasks.add_task( - email_service.send_verification_email, - email_lower, - new_token, - user_name, - user_type - ) - - # Log security event - await database.log_security_event( - verification_data["user_data"].get("candidate_data", {}).get("id") or - verification_data["user_data"].get("employer_data", {}).get("id") or "unknown", - "verification_resend", - { - "email": email_lower, - "user_type": user_type, - "resend_count": verification_data.get("resend_count", 1), - "old_token_invalidated": old_token[:8] + "...", # Log partial token for debugging - "ip_address": "unknown" # You can extract this from request if needed - } - ) - - logger.info(f"āœ… Verification email resent to {email_lower} (attempt #{verification_data.get('resend_count', 1)})") - - return create_success_response({ - "message": "A new verification email has been sent to your email address. Please check your inbox and spam folder.", - "resendCount": verification_data.get("resend_count", 1) - }) - - except ValueError as ve: - logger.warning(f"āš ļø Invalid resend verification request: {ve}") - return JSONResponse( - status_code=400, - content=create_error_response("VALIDATION_ERROR", str(ve)) - ) - except Exception as e: - logger.error(f"āŒ Resend verification email error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("RESEND_FAILED", "An error occurred while processing your request. Please try again later.") - ) - -@api_router.post("/auth/mfa/request") -async def request_mfa( - request: MFARequest, - background_tasks: BackgroundTasks, - http_request: Request, - database: RedisDatabase = Depends(get_database) -): - """Request MFA for login from new device""" - try: - # Verify credentials first - auth_manager = AuthenticationManager(database) - is_valid, user_data, error_message = await auth_manager.verify_user_credentials( - request.email, - request.password - ) - - if not is_valid or not user_data: - return JSONResponse( - status_code=401, - content=create_error_response("AUTH_FAILED", "Invalid credentials") - ) - - # Check if device is trusted - device_manager = DeviceManager(database) - device_info = device_manager.parse_device_info(http_request) - - is_trusted = await device_manager.is_trusted_device(user_data["id"], request.device_id) - - if is_trusted: - # Device is trusted, proceed with normal login - await device_manager.update_device_last_used(user_data["id"], request.device_id) - - return create_success_response({ - "mfaRequired": False, - "message": "Device is trusted, proceed with login" - }) - - # Generate MFA code - mfa_code = f"{secrets.randbelow(1000000):06d}" # 6-digit code - - # Store MFA code - # Get user name for email - user_name = "User" - email = None - if user_data["type"] == "candidate": - candidate_data = await database.get_candidate(user_data["id"]) - if candidate_data: - user_name = candidate_data.get("fullName", "User") - email = candidate_data.get("email", None) - elif user_data["type"] == "employer": - employer_data = await database.get_employer(user_data["id"]) - if employer_data: - user_name = employer_data.get("companyName", "User") - email = employer_data.get("email", None) - - if not email: - return JSONResponse( - status_code=400, - content=create_error_response("EMAIL_NOT_FOUND", "User email not found for MFA") - ) - - # Store MFA code - await database.store_mfa_code(email, mfa_code, request.device_id) - logger.info(f"šŸ” MFA code generated for {email} on device {request.device_id}") - - # Send MFA code via email - background_tasks.add_task( - email_service.send_mfa_email, - email, - mfa_code, - request.device_name, - user_name - ) - - logger.info(f"šŸ” MFA requested for {request.email} from new device {request.device_name}") - - mfa_data = MFAData( - message="New device detected. We've sent a security code to your email address.", - codeSent=mfa_code, - email=request.email, - deviceId=request.device_id, - deviceName=request.device_name, - ) - mfa_response = MFARequestResponse( - mfaRequired=True, - mfaData=mfa_data - ) - return create_success_response(mfa_response) - - except Exception as e: - logger.error(f"āŒ MFA request error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("MFA_REQUEST_FAILED", "Failed to process MFA request") - ) - -@api_router.post("/auth/login") -async def login( - request: LoginRequest, - http_request: Request, - background_tasks: BackgroundTasks, - database: RedisDatabase = Depends(get_database) -): - """login with automatic MFA email sending for new devices""" - try: - # Initialize managers - auth_manager = AuthenticationManager(database) - device_manager = DeviceManager(database) - - # Parse device information - device_info = device_manager.parse_device_info(http_request) - device_id = device_info["device_id"] - - # Verify credentials first - is_valid, user_data, error_message = await auth_manager.verify_user_credentials( - request.login, - request.password - ) - - if not is_valid or not user_data: - logger.warning(f"āš ļø Failed login attempt for: {request.login}") - return JSONResponse( - status_code=401, - content=create_error_response("AUTH_FAILED", error_message or "Invalid credentials") - ) - - # Check if device is trusted - is_trusted = await device_manager.is_trusted_device(user_data["id"], device_id) - - if not is_trusted: - # New device detected - automatically send MFA email - logger.info(f"šŸ” New device detected for {request.login}, sending MFA email") - - # Generate MFA code - mfa_code = f"{secrets.randbelow(1000000):06d}" # 6-digit code - - # Get user name and details for email - user_name = "User" - email = None - if user_data["type"] == "candidate": - candidate_data = await database.get_candidate(user_data["id"]) - if candidate_data: - user_name = candidate_data.get("full_name", "User") - email = candidate_data.get("email", None) - elif user_data["type"] == "employer": - employer_data = await database.get_employer(user_data["id"]) - if employer_data: - user_name = employer_data.get("company_name", "User") - email = employer_data.get("email", None) - - if not email: - return JSONResponse( - status_code=400, - content=create_error_response("EMAIL_NOT_FOUND", "User email not found for MFA") - ) - - # Store MFA code - await database.store_mfa_code(email, mfa_code, device_id) - - # Ensure email is lowercase - # Get IP address for security info - ip_address = http_request.client.host if http_request.client else "Unknown" - - # Send MFA code via email in background - background_tasks.add_task( - email_service.send_mfa_email, - email, - mfa_code, - device_info["device_name"], - user_name, - ip_address - ) - - # Log security event - await database.log_security_event( - user_data["id"], - "mfa_request", - { - "device_id": device_id, - "device_name": device_info["device_name"], - "ip_address": ip_address, - "user_agent": device_info.get("user_agent", ""), - "auto_sent": True - } - ) - - logger.info(f"šŸ” MFA code automatically sent to {request.login} for device {device_info['device_name']}") - - mfa_response = MFARequestResponse( - mfaRequired=True, - mfaData=MFAData( - message="New device detected. We've sent a security code to your email address.", - email=email, - deviceId=device_id, - deviceName=device_info["device_name"], - codeSent=mfa_code - ) - ) - return create_success_response(mfa_response.model_dump(by_alias=True)) - - # Trusted device - proceed with normal login - await device_manager.update_device_last_used(user_data["id"], device_id) - await auth_manager.update_last_login(user_data["id"]) - - # Create tokens - access_token = create_access_token(data={"sub": user_data["id"]}) - refresh_token = create_access_token( - data={"sub": user_data["id"], "type": "refresh"}, - expires_delta=timedelta(days=SecurityConfig.REFRESH_TOKEN_EXPIRY_DAYS) - ) - - # Get user object - user = None - if user_data["type"] == "candidate": - candidate_data = await database.get_candidate(user_data["id"]) - if candidate_data: - user = Candidate.model_validate(candidate_data) - elif user_data["type"] == "employer": - employer_data = await database.get_employer(user_data["id"]) - if employer_data: - user = Employer.model_validate(employer_data) - - if not user: - return JSONResponse( - status_code=404, - content=create_error_response("USER_NOT_FOUND", "User profile not found") - ) - - # Log successful login from trusted device - await database.log_security_event( - user_data["id"], - "login", - { - "device_id": device_id, - "device_name": device_info["device_name"], - "ip_address": http_request.client.host if http_request.client else "Unknown", - "trusted_device": True - } - ) - - # Create response - auth_response = AuthResponse( - accessToken=access_token, - refreshToken=refresh_token, - user=user, - expiresAt=int((datetime.now(timezone.utc) + timedelta(hours=SecurityConfig.TOKEN_EXPIRY_HOURS)).timestamp()) - ) - - logger.info(f"šŸ”‘ User {request.login} logged in successfully from trusted device") - - return create_success_response(auth_response.model_dump(by_alias=True)) - - except Exception as e: - logger.error(backstory_traceback.format_exc()) - logger.error(f"āŒ Login error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("LOGIN_ERROR", "An error occurred during login") - ) - - -@api_router.post("/auth/mfa/verify") -async def verify_mfa( - request: MFAVerifyRequest, - http_request: Request, - database: RedisDatabase = Depends(get_database) -): - """Verify MFA code and complete login with error handling""" - try: - # Get MFA data - mfa_data = await database.get_mfa_code(request.email, request.device_id) - - if not mfa_data: - logger.warning(f"āš ļø No MFA session found for {request.email} on device {request.device_id}") - return JSONResponse( - status_code=404, - content=create_error_response("NO_MFA_SESSION", "No active MFA session found. Please try logging in again.") - ) - - if mfa_data.get("verified"): - return JSONResponse( - status_code=400, - content=create_error_response("ALREADY_VERIFIED", "This MFA code has already been used. Please login again.") - ) - - # Check expiration - expires_at = datetime.fromisoformat(mfa_data["expires_at"]) - if datetime.now(timezone.utc) > expires_at: - # Clean up expired MFA session - await database.redis.delete(f"mfa_code:{request.email.lower()}:{request.device_id}") - return JSONResponse( - status_code=400, - content=create_error_response("MFA_EXPIRED", "MFA code has expired. Please try logging in again.") - ) - - # Check attempts - current_attempts = mfa_data.get("attempts", 0) - if current_attempts >= 5: - # Clean up after too many attempts - await database.redis.delete(f"mfa_code:{request.email.lower()}:{request.device_id}") - return JSONResponse( - status_code=429, - content=create_error_response("TOO_MANY_ATTEMPTS", "Too many incorrect attempts. Please try logging in again.") - ) - - # Verify code - if mfa_data["code"] != request.code: - await database.increment_mfa_attempts(request.email, request.device_id) - remaining_attempts = 5 - (current_attempts + 1) - - return JSONResponse( - status_code=400, - content=create_error_response( - "INVALID_CODE", - f"Invalid MFA code. {remaining_attempts} attempts remaining." - ) - ) - - # Mark as verified - await database.mark_mfa_verified(request.email, request.device_id) - - # Get user data - user_data = await database.get_user(request.email) - if not user_data: - return JSONResponse( - status_code=404, - content=create_error_response("USER_NOT_FOUND", "User not found") - ) - - # Add device to trusted devices if requested - if request.remember_device: - device_manager = DeviceManager(database) - device_info = device_manager.parse_device_info(http_request) - await device_manager.add_trusted_device( - user_data["id"], - request.device_id, - device_info - ) - logger.info(f"šŸ”’ Device {request.device_id} added to trusted devices for user {user_data['id']}") - - # Update last login - auth_manager = AuthenticationManager(database) - await auth_manager.update_last_login(user_data["id"]) - - # Create tokens - access_token = create_access_token(data={"sub": user_data["id"]}) - refresh_token = create_access_token( - data={"sub": user_data["id"], "type": "refresh"}, - expires_delta=timedelta(days=SecurityConfig.REFRESH_TOKEN_EXPIRY_DAYS) - ) - - # Get user object - user = None - if user_data["type"] == "candidate": - candidate_data = await database.get_candidate(user_data["id"]) - if candidate_data: - user = Candidate.model_validate(candidate_data) - elif user_data["type"] == "employer": - employer_data = await database.get_employer(user_data["id"]) - if employer_data: - user = Employer.model_validate(employer_data) - - if not user: - return JSONResponse( - status_code=404, - content=create_error_response("USER_NOT_FOUND", "User profile not found") - ) - - # Log successful MFA verification and login - await database.log_security_event( - user_data["id"], - "mfa_verify_success", - { - "device_id": request.device_id, - "ip_address": http_request.client.host if http_request.client else "Unknown", - "device_remembered": request.remember_device, - "attempts_used": current_attempts + 1 - } - ) - - await database.log_security_event( - user_data["id"], - "login", - { - "device_id": request.device_id, - "ip_address": http_request.client.host if http_request.client else "Unknown", - "mfa_verified": True, - "new_device": True - } - ) - - # Clean up MFA session - await database.redis.delete(f"mfa_code:{request.email.lower()}:{request.device_id}") - - # Create response - auth_response = AuthResponse( - accessToken=access_token, - refreshToken=refresh_token, - user=user, - expiresAt=int((datetime.now(timezone.utc) + timedelta(hours=SecurityConfig.TOKEN_EXPIRY_HOURS)).timestamp()) - ) - - logger.info(f"āœ… MFA verified and login completed for {request.email}") - - return create_success_response(auth_response.model_dump(by_alias=True)) - - except Exception as e: - logger.error(backstory_traceback.format_exc()) - logger.error(f"āŒ MFA verification error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("MFA_VERIFICATION_FAILED", "Failed to verify MFA") - ) - -class DebugStreamingResponse(StreamingResponse): - async def stream_response(self, send): - logger.debug("=== DEBUG STREAMING RESPONSE ===") - logger.debug(f"Body iterator: {self.body_iterator}") - logger.debug(f"Media type: {self.media_type}") - logger.debug(f"Charset: {self.charset}") - - chunk_count = 0 - async for chunk in self.body_iterator: - chunk_count += 1 - logger.debug(f"Chunk {chunk_count}: type={type(chunk)}, repr={repr(chunk)[:200]}") - - if not isinstance(chunk, (str, bytes)): - logger.error(f"PROBLEM FOUND! Chunk {chunk_count} is type {type(chunk)}, not str/bytes") - logger.error(f"Chunk content: {chunk}") - if hasattr(chunk, '__dict__'): - logger.error(f"Chunk attributes: {chunk.__dict__}") - - # Try to help with conversion - if hasattr(chunk, 'model_dump_json'): - logger.error("Chunk appears to be a Pydantic model - should call .model_dump_json()") - elif hasattr(chunk, 'json'): - logger.error("Chunk appears to be a Pydantic model - should call .json()") - - raise AttributeError(f"'{type(chunk).__name__}' object has no attribute 'encode'") - - if isinstance(chunk, str): - chunk = chunk.encode(self.charset) - - await send({ - "type": "http.response.body", - "body": chunk, - "more_body": True, - }) - - await send({"type": "http.response.body", "body": b"", "more_body": False}) - -@api_router.post("/candidates/documents/upload") -async def upload_candidate_document( - file: UploadFile = File(...), - options_data: str = Form(..., alias="options"), - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - try: - # Parse the JSON string and create DocumentOptions object - options_dict = json.loads(options_data) - options : DocumentOptions = DocumentOptions.model_validate(options_dict) - except (json.JSONDecodeError, ValidationError) as e: - return StreamingResponse( - iter([json.dumps(ChatMessageError( - sessionId=MOCK_UUID, # No session ID for document uploads - content="Invalid options format. Please provide valid JSON." - ).model_dump(mode='json', by_alias=True))]), - media_type="text/event-stream" - ) - - # 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( - sessionId=MOCK_UUID, # No session ID for document uploads - content="File size exceeds 10MB limit" - ).model_dump(mode='json', by_alias=True))]), - media_type="text/event-stream" - ) - if len(file_content) == 0: - logger.info(f"āš ļø File is empty: {file.filename}") - return StreamingResponse( - iter([json.dumps(ChatMessageError( - sessionId=MOCK_UUID, # No session ID for document uploads - content="File is empty" - ).model_dump(mode='json', by_alias=True))]), - 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( - sessionId=MOCK_UUID, # No session ID for document uploads - content="Only candidates can upload documents" - ) - yield error_message - return - - candidate: Candidate = current_user - 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( - sessionId=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'") - - directory = "rag-content" if options.include_in_rag else "files" - directory = "jobs" if options.is_job_document else directory - - # Ensure the file does not already exist either in 'files' or in 'rag-content' - dir_path = os.path.join(defines.user_dir, candidate.username, directory) - if not os.path.exists(dir_path): - os.makedirs(dir_path, exist_ok=True) - file_path = os.path.join(dir_path, file.filename) - if os.path.exists(file_path): - if not options.overwrite: - logger.warning(f"āš ļø File already exists: {file_path}") - error_message = ChatMessageError( - sessionId=MOCK_UUID, # No session ID for document uploads - content=f"File with this name already exists in the '{directory}' directory" - ) - yield error_message - return - else: - logger.info(f"šŸ”„ Overwriting existing file: {file_path}") - status_message = ChatMessageStatus( - sessionId=MOCK_UUID, # No session ID for document uploads - content=f"Overwriting existing file: {file.filename}", - activity=ApiActivityType.INFO - ) - yield status_message - - # 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( - sessionId=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 - - # Create document metadata - document_id = str(uuid.uuid4()) - document_type = get_document_type_from_filename(file.filename or "unknown.txt") - - document_data = Document( - id=document_id, - filename=file.filename or f"document_{document_id}", - originalName=file.filename or f"document_{document_id}", - type=document_type, - size=len(file_content), - uploadDate=datetime.now(UTC), - options=options, - ownerId=candidate.id - ) - - # Save file to disk - directory = os.path.join(defines.user_dir, candidate.username, directory) - file_path = os.path.join(directory, file.filename) - - try: - with open(file_path, "wb") as f: - f.write(file_content) - - logger.info(f"šŸ“ File saved to disk: {file_path}") - - except Exception as e: - logger.error(f"āŒ Failed to save file to disk: {e}") - error_message = ChatMessageError( - sessionId=MOCK_UUID, # No session ID for document uploads - content="Failed to save file to disk", - ) - yield error_message - return - - converted = False - if document_type != DocumentType.MARKDOWN and document_type != DocumentType.TXT: - p = pathlib.Path(file_path) - p_as_md = p.with_suffix(".md") - # If file_path.md doesn't exist or file_path is newer than file_path.md, - # fire off markitdown - if (not p_as_md.exists()) or ( - p.stat().st_mtime > p_as_md.stat().st_mtime - ): - status_message = ChatMessageStatus( - sessionId=MOCK_UUID, # No session ID for document uploads - content=f"Converting content from {document_type}...", - activity=ApiActivityType.CONVERTING - ) - yield status_message - try: - from markitdown import MarkItDown # type: ignore - md = MarkItDown(enable_plugins=False) # Set to True to enable plugins - result = md.convert(file_path, output_format="markdown") - p_as_md.write_text(result.text_content) - file_content = result.text_content - converted = True - logger.info(f"āœ… Converted {file.filename} to Markdown format: {p_as_md}") - file_path = p_as_md - except Exception as e: - error_message = ChatMessageError( - sessionId=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_path} to Markdown: {e}") - return - - # Store document metadata in database - await database.set_document(document_id, document_data.model_dump()) - await database.add_document_to_candidate(candidate.id, document_id) - logger.info(f"šŸ“„ Document uploaded: {file.filename} for candidate {candidate.username}") - chat_message = DocumentMessage( - sessionId=MOCK_UUID, # No session ID for document uploads - type=ApiMessageType.JSON, - status=ApiStatusType.DONE, - document=document_data, - converted=converted, - content=file_content, - ) - yield chat_message - 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( - sessionId=MOCK_UUID, # No session ID for document uploads - content="Failed to upload document" - ).model_dump(mode='json', by_alias=True))]), - media_type="text/event-stream" - ) - -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( - sessionId=MOCK_UUID, # No session ID for document uploads - content="No agent found for job requirements chat type" - ) - yield error_message - return - 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 - - 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. -""" - ): - 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 - try: - chat_message.content = chat_agent.extract_markdown_from_text(chat_message.content) - except Exception as e: - pass - logger.info(f"āœ… 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( - sessionId=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( - sessionId=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( - sessionId=MOCK_UUID, # No session ID for document uploads - content="No agent found for job requirements chat type" - ) - yield error_message - return - status_message = ChatMessageStatus( - sessionId=MOCK_UUID, # No session ID for document uploads - content=f"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( - sessionId=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 - -@api_router.post("/candidates/profile/upload") -async def upload_candidate_profile( - file: UploadFile = File(...), - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Upload a document for the current candidate""" - try: - # Verify user is a candidate - if current_user.user_type != "candidate": - logger.warning(f"āš ļø Unauthorized upload attempt by user type: {current_user.user_type}") - return JSONResponse( - status_code=403, - content=create_error_response("FORBIDDEN", "Only candidates can upload their profile") - ) - - candidate: Candidate = current_user - # Validate file type - allowed_types = ['.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}") - return JSONResponse( - status_code=400, - content=create_error_response( - "INVALID_FILE_TYPE", - f"File type {file_extension} not supported. Allowed types: {', '.join(allowed_types)}" - ) - ) - - # Check file size (limit to 5MB) - max_size = 5 * 1024 * 1024 # 2MB - file_content = await file.read() - if len(file_content) > max_size: - logger.info(f"āš ļø File too large: {file.filename} ({len(file_content)} bytes)") - return JSONResponse( - status_code=400, - content=create_error_response("FILE_TOO_LARGE", "File size exceeds 10MB limit") - ) - - # Save file to disk as "profile." - _, extension = os.path.splitext(file.filename or "") - file_path = os.path.join(defines.user_dir, candidate.username) - os.makedirs(file_path, exist_ok=True) - file_path = os.path.join(file_path, f"profile{extension}") - - try: - with open(file_path, "wb") as f: - f.write(file_content) - - logger.info(f"šŸ“ File saved to disk: {file_path}") - - except Exception as e: - logger.error(f"āŒ Failed to save file to disk: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("FILE_SAVE_ERROR", "Failed to save file to disk") - ) - - updates = { - "updated_at": datetime.now(UTC).isoformat(), - "profile_image": f"profile{extension}" - } - candidate_dict = candidate.model_dump() - candidate_dict.update(updates) - updated_candidate = Candidate.model_validate(candidate_dict) - await database.set_candidate(candidate.id, updated_candidate.model_dump()) - logger.info(f"šŸ“„ Profile image uploaded: {updated_candidate.profile_image} for candidate {candidate.id}") - - return create_success_response(True) - - except Exception as e: - logger.error(backstory_traceback.format_exc()) - logger.error(f"āŒ Document upload error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("UPLOAD_ERROR", "Failed to upload document") - ) - -@api_router.get("/candidates/profile/{username}") -async def get_candidate_profile_image( - username: str = Path(..., description="Username of the candidate"), - # current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Get profile image of a candidate by username""" - try: - all_candidates_data = await database.get_all_candidates() - candidates_list = [Candidate.model_validate(data) for data in all_candidates_data.values()] - - # Normalize username to lowercase for case-insensitive search - query_lower = username.lower() - - # Filter by search query - candidates_list = [ - c for c in candidates_list - if (query_lower == c.email.lower() or - query_lower == c.username.lower()) - ] - - if not len(candidates_list): - return JSONResponse( - status_code=404, - content=create_error_response("NOT_FOUND", "Candidate not found") - ) - - candidate = Candidate.model_validate(candidates_list[0]) - if not candidate.profile_image: - logger.warning(f"āš ļø Candidate {candidate.username} has no profile image set") - return JSONResponse( - status_code=404, - content=create_error_response("NOT_FOUND", "Profile image not found") - ) - file_path = os.path.join(defines.user_dir, candidate.username, candidate.profile_image) - file_path = pathlib.Path(file_path) - if not file_path.exists(): - logger.error(f"āŒ Profile image file not found on disk: {file_path}") - return JSONResponse( - status_code=404, - content=create_error_response("FILE_NOT_FOUND", "Profile image file not found on disk") - ) - return FileResponse( - file_path, - media_type=f"image/{file_path.suffix[1:]}", # Get extension without dot - filename=candidate.profile_image - ) - except Exception as e: - logger.error(backstory_traceback.format_exc()) - logger.error(f"āŒ Get candidate profile image failed: {str(e)}") - return JSONResponse( - status_code=500, - content=create_error_response("FETCH_ERROR", "Failed to retrieve profile image") - ) - -@api_router.get("/candidates/documents") -async def get_candidate_documents( - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Get all documents for the current candidate""" - try: - # Verify user is a candidate - if current_user.user_type != "candidate": - logger.warning(f"āš ļø Unauthorized access attempt by user type: {current_user.user_type}") - return JSONResponse( - status_code=403, - content=create_error_response("FORBIDDEN", "Only candidates can access documents") - ) - - candidate: Candidate = current_user - - # Get documents from database - documents_data = await database.get_candidate_documents(candidate.id) - documents = [Document.model_validate(doc_data) for doc_data in documents_data] - - # Sort by upload date (newest first) - documents.sort(key=lambda x: x.upload_date, reverse=True) - - response_data = DocumentListResponse( - documents=documents, - total=len(documents) - ) - - return create_success_response(response_data.model_dump(by_alias=True)) - - except Exception as e: - logger.error(backstory_traceback.format_exc()) - logger.error(f"āŒ Get candidate documents error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("FETCH_ERROR", "Failed to retrieve documents") - ) - -@api_router.get("/candidates/documents/{document_id}/content") -async def get_document_content( - document_id: str = Path(...), - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Get document content by ID""" - try: - # Verify user is a candidate - if current_user.user_type != "candidate": - return JSONResponse( - status_code=403, - content=create_error_response("FORBIDDEN", "Only candidates can access documents") - ) - - candidate: Candidate = current_user - - # Get document metadata - document_data = await database.get_document(document_id) - if not document_data: - return JSONResponse( - status_code=404, - content=create_error_response("NOT_FOUND", "Document not found") - ) - - document = Document.model_validate(document_data) - - # Verify document belongs to current candidate - if document.owner_id != candidate.id: - return JSONResponse( - status_code=403, - content=create_error_response("FORBIDDEN", "Cannot access another candidate's document") - ) - - file_path = os.path.join(defines.user_dir, candidate.username, "rag-content" if document.options.include_in_rag else "files", document.originalName) - file_path = pathlib.Path(file_path) - if not document.type in [DocumentType.TXT, DocumentType.MARKDOWN]: - file_path = file_path.with_suffix('.md') - - if not file_path.exists(): - logger.error(f"āŒ Document file not found on disk: {file_path}") - return JSONResponse( - status_code=404, - content=create_error_response("FILE_NOT_FOUND", "Document file not found on disk") - ) - - try: - with open(file_path, "r", encoding="utf-8") as f: - content = f.read() - - response = DocumentContentResponse( - documentId=document_id, - filename=document.filename, - type=document.type, - content=content, - size=document.size - ) - return create_success_response(response.model_dump(by_alias=True)); - - except Exception as e: - logger.error(f"āŒ Failed to read document file: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("READ_ERROR", "Failed to read document content") - ) - - except Exception as e: - logger.error(backstory_traceback.format_exc()) - logger.error(f"āŒ Get document content error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("FETCH_ERROR", "Failed to retrieve document content") - ) - -@api_router.patch("/candidates/documents/{document_id}") -async def update_document( - document_id: str = Path(...), - updates: DocumentUpdateRequest = Body(...), - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Update document metadata (filename, RAG status)""" - try: - # Verify user is a candidate - if current_user.user_type != "candidate": - return JSONResponse( - status_code=403, - content=create_error_response("FORBIDDEN", "Only candidates can update documents") - ) - - candidate: Candidate = current_user - - # Get document metadata - document_data = await database.get_document(document_id) - if not document_data: - return JSONResponse( - status_code=404, - content=create_error_response("NOT_FOUND", "Document not found") - ) - - document = Document.model_validate(document_data) - - # Verify document belongs to current candidate - if document.owner_id != candidate.id: - return JSONResponse( - status_code=403, - content=create_error_response("FORBIDDEN", "Cannot update another candidate's document") - ) - update_options = updates.options if updates.options else DocumentOptions() - if document.options.include_in_rag != update_options.include_in_rag: - # If RAG status is changing, we need to handle file movement - rag_dir = os.path.join(defines.user_dir, candidate.username, "rag-content") - file_dir = os.path.join(defines.user_dir, candidate.username, "files") - os.makedirs(rag_dir, exist_ok=True) - os.makedirs(file_dir, exist_ok=True) - rag_path = os.path.join(rag_dir, document.originalName) - file_path = os.path.join(file_dir, document.originalName) - - if update_options.include_in_rag: - src = pathlib.Path(file_path) - dst = pathlib.Path(rag_path) - # Move to RAG directory - src.rename(dst) - logger.info(f"šŸ“ Moved file to RAG directory") - if document.type != DocumentType.MARKDOWN and document.type != DocumentType.TXT: - src = pathlib.Path(file_path) - src_as_md = src.with_suffix(".md") - if src_as_md.exists(): - dst = pathlib.Path(rag_path).with_suffix(".md") - src_as_md.rename(dst) - else: - src = pathlib.Path(rag_path) - dst = pathlib.Path(file_path) - # Move to regular files directory - src.rename(dst) - logger.info(f"šŸ“ Moved file to regular files directory") - if document.type != DocumentType.MARKDOWN and document.type != DocumentType.TXT: - src_as_md = src.with_suffix(".md") - if src_as_md.exists(): - dst = pathlib.Path(file_path).with_suffix(".md") - src_as_md.rename(dst) - - # Apply updates - update_dict = {} - if updates.filename is not None: - update_dict["filename"] = updates.filename.strip() - if update_options.include_in_rag is not None: - update_dict["include_in_rag"] = update_options.include_in_rag - - if not update_dict: - return JSONResponse( - status_code=400, - content=create_error_response("NO_UPDATES", "No valid updates provided") - ) - - # Add timestamp - update_dict["updatedAt"] = datetime.now(UTC).isoformat() - - # Update in database - updated_data = await database.update_document(document_id, update_dict) - if not updated_data: - return JSONResponse( - status_code=500, - content=create_error_response("UPDATE_FAILED", "Failed to update document") - ) - - updated_document = Document.model_validate(updated_data) - - logger.info(f"šŸ“„ Document updated: {document_id} for candidate {candidate.username}") - - return create_success_response(updated_document.model_dump(by_alias=True)) - - except Exception as e: - logger.error(f"āŒ Update document error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("UPDATE_ERROR", "Failed to update document") - ) - -@api_router.delete("/candidates/documents/{document_id}") -async def delete_document( - document_id: str = Path(...), - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Delete a document and its file""" - try: - # Verify user is a candidate - if current_user.user_type != "candidate": - logger.warning(f"āš ļø Unauthorized delete attempt by user type: {current_user.user_type}") - return JSONResponse( - status_code=403, - content=create_error_response("FORBIDDEN", "Only candidates can delete documents") - ) - - candidate: Candidate = current_user - - # Get document metadata - document_data = await database.get_document(document_id) - if not document_data: - logger.warning(f"āš ļø Document not found for deletion: {document_id}") - return JSONResponse( - status_code=404, - content=create_error_response("NOT_FOUND", "Document not found") - ) - - document = Document.model_validate(document_data) - - # Verify document belongs to current candidate - if document.owner_id != candidate.id: - logger.warning(f"āš ļø Unauthorized delete attempt on document {document_id} by candidate {candidate.username}") - return JSONResponse( - status_code=403, - content=create_error_response("FORBIDDEN", "Cannot delete another candidate's document") - ) - - # Delete file from disk - file_path = os.path.join(defines.user_dir, candidate.username, "rag-content" if document.options.include_in_rag else "files", document.originalName) - file_path = pathlib.Path(file_path) - - try: - if file_path.exists(): - file_path.unlink() - logger.info(f"šŸ—‘ļø File deleted from disk: {file_path}") - else: - logger.warning(f"āš ļø File not found on disk during deletion: {file_path}") - - # Delete side-car file if it exists - if document.type != DocumentType.MARKDOWN and document.type != DocumentType.TXT: - p = pathlib.Path(file_path) - p_as_md = p.with_suffix(".md") - if p_as_md.exists(): - p_as_md.unlink() - - except Exception as e: - logger.error(f"āŒ Failed to delete file from disk: {e}") - # Continue with metadata deletion even if file deletion fails - - # Remove from database - await database.remove_document_from_candidate(candidate.id, document_id) - await database.delete_document(document_id) - - logger.info(f"šŸ—‘ļø Document deleted: {document_id} for candidate {candidate.username}") - - return create_success_response({ - "message": "Document deleted successfully", - "documentId": document_id - }) - - except Exception as e: - logger.error(backstory_traceback.format_exc()) - logger.error(f"āŒ Delete document error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("DELETE_ERROR", "Failed to delete document") - ) - -@api_router.get("/candidates/documents/search") -async def search_candidate_documents( - query: str = Query(..., min_length=1), - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Search candidate documents by filename""" - try: - # Verify user is a candidate - if current_user.user_type != "candidate": - return JSONResponse( - status_code=403, - content=create_error_response("FORBIDDEN", "Only candidates can search documents") - ) - - candidate: Candidate = current_user - - # Search documents - documents_data = await database.search_candidate_documents(candidate.id, query) - documents = [Document.model_validate(doc_data) for doc_data in documents_data] - - # Sort by upload date (newest first) - documents.sort(key=lambda x: x.upload_date, reverse=True) - - response_data = DocumentListResponse( - documents=documents, - total=len(documents) - ) - - return create_success_response(response_data.model_dump(by_alias=True)) - - except Exception as e: - logger.error(f"āŒ Search documents error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("SEARCH_ERROR", "Failed to search documents") - ) - -class RAGDocumentRequest(BaseModel): - """Request model for RAG document content""" - id: str - -@api_router.post("/candidates/rag-content") -async def post_candidate_vector_content( - rag_document: RAGDocumentRequest = Body(...), - current_user = Depends(get_current_user) -): - try: - if current_user.user_type != "candidate": - logger.warning(f"āš ļø Unauthorized access attempt by user type: {current_user.user_type}") - return JSONResponse( - status_code=403, - content=create_error_response("FORBIDDEN", "Only candidates can access this endpoint") - ) - candidate : Candidate = current_user - - async with entities.get_candidate_entity(candidate=candidate) as candidate_entity: - collection = candidate_entity.umap_collection - if not collection: - logger.warning(f"āš ļø No UMAP collection found for candidate {candidate.username}") - return JSONResponse( - {"error": "No UMAP collection found"}, status_code=404 - ) - - for index, id in enumerate(collection.ids): - if id == rag_document.id: - metadata = collection.metadatas[index].copy() - rag_metadata = RagContentMetadata.model_validate(metadata) - content = candidate_entity.file_watcher.prepare_metadata(metadata) - if content: - rag_response = RagContentResponse(id=id, content=content, metadata=rag_metadata) - logger.info(f"āœ… Fetched RAG content for document id {id} for candidate {candidate.username}") - else: - logger.warning(f"āš ļø No content found for document id {id} for candidate {candidate.username}") - return JSONResponse(f"No content found for document id {rag_document.id}.", 404) - return create_success_response(rag_response.model_dump(by_alias=True)) - - logger.warning(f"āš ļø Document id {rag_document.id} not found in UMAP collection for candidate {candidate.username}") - return JSONResponse(f"Document id {rag_document.id} not found.", 404) - except Exception as e: - logger.error(backstory_traceback.format_exc()) - logger.error(f"āŒ Post candidate content error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("FETCH_ERROR", str(e)) - ) - -@api_router.post("/candidates/rag-vectors") -async def post_candidate_vectors( - dimensions: int = Body(...), - current_user = Depends(get_current_user) -): - try: - if current_user.user_type != "candidate": - return JSONResponse( - status_code=403, - content=create_error_response("FORBIDDEN", "Only candidates can access this endpoint") - ) - candidate : Candidate = current_user - - async with entities.get_candidate_entity(candidate=candidate) as candidate_entity: - collection = candidate_entity.umap_collection - if not collection: - results = { - "ids": [], - "metadatas": [], - "documents": [], - "embeddings": [], - "size": 0 - } - return create_success_response(results) - if dimensions == 2: - umap_embedding = candidate_entity.file_watcher.umap_embedding_2d - else: - umap_embedding = candidate_entity.file_watcher.umap_embedding_3d - - if len(umap_embedding) == 0: - results = { - "ids": [], - "metadatas": [], - "documents": [], - "embeddings": [], - "size": 0 - } - return create_success_response(results) - - result = { - "ids": collection.ids, - "metadatas": collection.metadatas, - "documents": collection.documents, - "embeddings": umap_embedding.tolist(), - "size": candidate_entity.file_watcher.collection.count() - } - - return create_success_response(result) - - except Exception as e: - logger.error(backstory_traceback.format_exc()) - logger.error(f"āŒ Post candidate vectors error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("FETCH_ERROR", str(e)) - ) - -@api_router.delete("/candidates/{candidate_id}") -async def delete_candidate( - candidate_id: str = Path(...), - admin_user = Depends(get_current_admin), - database: RedisDatabase = Depends(get_database) -): - """Delete a candidate""" - 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 candidates") - ) - - # Get candidate data - candidate_data = await database.get_candidate(candidate_id) - if not candidate_data: - logger.warning(f"āš ļø Candidate not found for deletion: {candidate_id}") - return JSONResponse( - status_code=404, - content=create_error_response("NOT_FOUND", "Candidate not found") - ) - - await entities.entity_manager.remove_entity(candidate_id) - - # Delete candidate from database - await database.delete_candidate(candidate_id) - - # Optionally delete files and documents associated with the candidate - await database.delete_all_candidate_documents(candidate_id) - - file_path = os.path.join(defines.user_dir, candidate_data["username"]) - if os.path.exists(file_path): - try: - shutil.rmtree(file_path) - logger.info(f"šŸ—‘ļø Deleted candidate files directory: {file_path}") - except Exception as e: - logger.error(f"āŒ Failed to delete candidate files directory: {e}") - - logger.info(f"šŸ—‘ļø Candidate deleted: {candidate_id} by admin {admin_user.id}") - - return create_success_response({ - "message": "Candidate deleted successfully", - "candidateId": candidate_id - }) - - except Exception as e: - logger.error(f"āŒ Delete candidate error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("DELETE_ERROR", "Failed to delete candidate") - ) - -@api_router.patch("/candidates/{candidate_id}") -async def update_candidate( - candidate_id: str = Path(...), - updates: Dict[str, Any] = Body(...), - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Update a candidate""" - try: - candidate_data = await database.get_candidate(candidate_id) - if not candidate_data: - logger.warning(f"āš ļø Candidate not found for update: {candidate_id}") - return JSONResponse( - status_code=404, - content=create_error_response("NOT_FOUND", "Candidate not found") - ) - - is_AI = candidate_data.get("is_AI", False) - candidate = CandidateAI.model_validate(candidate_data) if is_AI else Candidate.model_validate(candidate_data) - - # Check authorization (user can only update their own profile) - if current_user.is_admin is False and candidate.id != current_user.id: - logger.warning(f"āš ļø Unauthorized update attempt by user {current_user.id} on candidate {candidate_id}") - return JSONResponse( - status_code=403, - content=create_error_response("FORBIDDEN", "Cannot update another user's profile") - ) - - # Apply updates - updates["updatedAt"] = datetime.now(UTC).isoformat() - logger.info(f"šŸ”„ Updating candidate {candidate_id} with data: {updates}") - candidate_dict = candidate.model_dump() - candidate_dict.update(updates) - updated_candidate = CandidateAI.model_validate(candidate_dict) if is_AI else Candidate.model_validate(candidate_dict) - await database.set_candidate(candidate_id, updated_candidate.model_dump()) - - return create_success_response(updated_candidate.model_dump(by_alias=True)) - - except Exception as e: - logger.error(f"āŒ Update candidate error: {e}") - return JSONResponse( - status_code=400, - content=create_error_response("UPDATE_FAILED", str(e)) - ) - -@api_router.get("/candidates") -async def get_candidates( - 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), - current_user = Depends(get_current_user_or_guest), - database: RedisDatabase = Depends(get_database) -): - """Get paginated list of candidates""" - try: - # Parse filters if provided - filter_dict = None - if filters: - filter_dict = json.loads(filters) - - # Get all candidates from Redis - all_candidates_data = await database.get_all_candidates() - candidates_list = [Candidate.model_validate(data) if not data.get("is_AI") else CandidateAI.model_validate(data) for data in all_candidates_data.values()] - candidates_list = [c for c in candidates_list if c.is_public or (current_user.userType != UserType.GUEST and c.id == current_user.id)] - - paginated_candidates, total = filter_and_paginate( - candidates_list, page, limit, sortBy, sortOrder, filter_dict - ) - - paginated_response = create_paginated_response( - [c.model_dump(by_alias=True) for c in paginated_candidates], - page, limit, total - ) - - return create_success_response(paginated_response) - - except Exception as e: - logger.error(f"āŒ Get candidates error: {e}") - return JSONResponse( - status_code=400, - content=create_error_response("FETCH_FAILED", str(e)) - ) - -@api_router.get("/candidates/search") -async def search_candidates( - query: str = Query(...), - filters: Optional[str] = Query(None), - page: int = Query(1, ge=1), - limit: int = Query(20, ge=1, le=100), - database: RedisDatabase = Depends(get_database) -): - """Search candidates""" - try: - # Parse filters - filter_dict = {} - if filters: - filter_dict = json.loads(filters) - - # Get all candidates from Redis - all_candidates_data = await database.get_all_candidates() - candidates_list = [Candidate.model_validate(data) for data in all_candidates_data.values()] - - # Filter by search query - if query: - query_lower = query.lower() - candidates_list = [ - c for c in candidates_list - if (query_lower in c.first_name.lower() or - query_lower in c.last_name.lower() or - query_lower in c.email.lower() or - query_lower in c.username.lower() or - any(query_lower in skill.name.lower() for skill in c.skills or [])) - ] - - paginated_candidates, total = filter_and_paginate( - candidates_list, page, limit, filters=filter_dict - ) - - paginated_response = create_paginated_response( - [c.model_dump(by_alias=True) for c in paginated_candidates], - page, limit, total - ) - - return create_success_response(paginated_response) - - except Exception as e: - logger.error(f"āŒ Search candidates error: {e}") - return JSONResponse( - status_code=400, - content=create_error_response("SEARCH_FAILED", str(e)) - ) - -# ============================ -# Password Reset Endpoints -# ============================ -class PasswordResetRequest(BaseModel): - email: EmailStr - -class PasswordResetConfirm(BaseModel): - token: str - new_password: str - - @field_validator('new_password') - def validate_password_strength(cls, v): - is_valid, issues = validate_password_strength(v) - if not is_valid: - raise ValueError('; '.join(issues)) - return v - -@api_router.post("/auth/password-reset/request") -async def request_password_reset( - request: PasswordResetRequest, - database: RedisDatabase = Depends(get_database) -): - """Request password reset""" - try: - # Check if user exists - user_data = await database.get_user(request.email) - if not user_data: - # Don't reveal whether email exists or not - return create_success_response({"message": "If the email exists, a reset link will be sent"}) - - auth_manager = AuthenticationManager(database) - - # Generate reset token - reset_token = auth_manager.password_security.generate_secure_token() - reset_expiry = datetime.now(timezone.utc) + timedelta(hours=1) # 1 hour expiry - - # Update authentication record - auth_record = await database.get_authentication(user_data["id"]) - if auth_record: - auth_record["resetPasswordToken"] = reset_token - auth_record["resetPasswordExpiry"] = reset_expiry.isoformat() - await database.set_authentication(user_data["id"], auth_record) - - # TODO: Send email with reset token - logger.info(f"šŸ” Password reset requested for: {request.email}") - - return create_success_response({"message": "If the email exists, a reset link will be sent"}) - - except Exception as e: - logger.error(f"āŒ Password reset request error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("RESET_ERROR", "An error occurred processing the request") - ) - -@api_router.post("/auth/password-reset/confirm") -async def confirm_password_reset( - request: PasswordResetConfirm, - database: RedisDatabase = Depends(get_database) -): - """Confirm password reset with token""" - try: - # Find user by reset token - # This would require a way to lookup by token - you might need to modify your database structure - - # For now, this is a placeholder - you'd need to implement token lookup - # in your Redis database structure - - return create_success_response({"message": "Password reset successfully"}) - - except Exception as e: - logger.error(f"āŒ Password reset confirm error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("RESET_ERROR", "An error occurred resetting the password") - ) - -# ============================ -# Resume Endpoints -# ============================ - -@api_router.post("/resumes/{candidate_id}/{job_id}") -async def create_candidate_resume( - candidate_id: str = Path(..., description="ID of the candidate"), - job_id: str = Path(..., description="ID of the job"), - resume_content: str = Body(...), - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Create a new resume for a candidate/job combination""" - 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) - - logger.info(f"šŸ“„ Saving resume for candidate {candidate.first_name} {candidate.last_name} for job '{job.title}'") - - # Job and Candidate are valid. Save the resume - resume = Resume( - job_id=job_id, - candidate_id=candidate_id, - resume=resume_content, - ) - resume_message: ResumeMessage = ResumeMessage( - sessionId=MOCK_UUID, # No session ID for document uploads - resume=resume - ) - - # Save to database - success = await database.set_resume(current_user.id, resume.model_dump()) - if not success: - error_message = ChatMessageError( - sessionId=MOCK_UUID, - content="Failed to save resume to database" - ) - yield error_message - return - - logger.info(f"āœ… Successfully saved resume {resume_message.resume.id} for user {current_user.id}") - yield resume_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(message_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"āŒ Resume creation error: {e}") - return StreamingResponse( - iter([json.dumps(ChatMessageError( - sessionId=MOCK_UUID, # No session ID for document uploads - content="Failed to create resume" - ).model_dump(mode='json', by_alias=True))]), - media_type="text/event-stream" - ) - -@api_router.get("/resumes") -async def get_user_resumes( - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Get all resumes for the current user""" - try: - resumes_data = await database.get_all_resumes_for_user(current_user.id) - resumes : List[Resume] = [Resume.model_validate(data) for data in resumes_data] - for resume in resumes: - job_data = await database.get_job(resume.job_id) - if job_data: - resume.job = Job.model_validate(job_data) - candidate_data = await database.get_candidate(resume.candidate_id) - if candidate_data: - resume.candidate = Candidate.model_validate(candidate_data) - resumes.sort(key=lambda x: x.updated_at, reverse=True) # Sort by creation date - return create_success_response({ - "resumes": resumes, - "count": len(resumes) - }) - except Exception as e: - logger.error(f"āŒ Error retrieving resumes for user {current_user.id}: {e}") - raise HTTPException(status_code=500, detail="Failed to retrieve resumes") - -@api_router.get("/resumes/{resume_id}") -async def get_resume( - resume_id: str = Path(..., description="ID of the resume"), - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Get a specific resume by ID""" - try: - resume = await database.get_resume(current_user.id, resume_id) - if not resume: - raise HTTPException(status_code=404, detail="Resume not found") - - return { - "success": True, - "resume": resume - } - except HTTPException: - raise - except Exception as e: - logger.error(f"āŒ Error retrieving resume {resume_id} for user {current_user.id}: {e}") - raise HTTPException(status_code=500, detail="Failed to retrieve resume") - -@api_router.delete("/resumes/{resume_id}") -async def delete_resume( - resume_id: str = Path(..., description="ID of the resume"), - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Delete a specific resume""" - try: - success = await database.delete_resume(current_user.id, resume_id) - if not success: - raise HTTPException(status_code=404, detail="Resume not found") - - return { - "success": True, - "message": f"Resume {resume_id} deleted successfully" - } - except HTTPException: - raise - except Exception as e: - logger.error(f"āŒ Error deleting resume {resume_id} for user {current_user.id}: {e}") - raise HTTPException(status_code=500, detail="Failed to delete resume") - -@api_router.get("/resumes/candidate/{candidate_id}") -async def get_resumes_by_candidate( - candidate_id: str = Path(..., description="ID of the candidate"), - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Get all resumes for a specific candidate""" - try: - resumes = await database.get_resumes_by_candidate(current_user.id, candidate_id) - return { - "success": True, - "candidate_id": candidate_id, - "resumes": resumes, - "count": len(resumes) - } - except Exception as e: - logger.error(f"āŒ Error retrieving resumes for candidate {candidate_id}: {e}") - raise HTTPException(status_code=500, detail="Failed to retrieve candidate resumes") - -@api_router.get("/resumes/job/{job_id}") -async def get_resumes_by_job( - job_id: str = Path(..., description="ID of the job"), - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Get all resumes for a specific job""" - try: - resumes = await database.get_resumes_by_job(current_user.id, job_id) - return { - "success": True, - "job_id": job_id, - "resumes": resumes, - "count": len(resumes) - } - except Exception as e: - logger.error(f"āŒ Error retrieving resumes for job {job_id}: {e}") - raise HTTPException(status_code=500, detail="Failed to retrieve job resumes") - -@api_router.get("/resumes/search") -async def search_resumes( - q: str = Query(..., description="Search query"), - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Search resumes by content""" - try: - resumes = await database.search_resumes_for_user(current_user.id, q) - return { - "success": True, - "query": q, - "resumes": resumes, - "count": len(resumes) - } - except Exception as e: - logger.error(f"āŒ Error searching resumes for user {current_user.id}: {e}") - raise HTTPException(status_code=500, detail="Failed to search resumes") - -@api_router.get("/resumes/stats") -async def get_resume_statistics( - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Get resume statistics for the current user""" - try: - stats = await database.get_resume_statistics(current_user.id) - return { - "success": True, - "statistics": stats - } - except Exception as e: - logger.error(f"āŒ Error retrieving resume statistics for user {current_user.id}: {e}") - raise HTTPException(status_code=500, detail="Failed to retrieve resume statistics") - -@api_router.put("/resumes/{resume_id}") -async def update_resume( - resume_id: str = Path(..., description="ID of the resume"), - resume: str = Body(..., description="Updated resume content"), - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Update the content of a specific resume""" - try: - updates = { - "resume": resume, - "updated_at": datetime.now(UTC).isoformat() - } - - updated_resume_data = await database.update_resume(current_user.id, resume_id, updates) - if not updated_resume_data: - logger.warning(f"āš ļø Resume {resume_id} not found for user {current_user.id}") - raise HTTPException(status_code=404, detail="Resume not found") - updated_resume = Resume.model_validate(updated_resume_data) if updated_resume_data else None - - return create_success_response({ - "success": True, - "message": f"Resume {resume_id} updated successfully", - "resume": updated_resume - }) - except HTTPException: - raise - except Exception as e: - logger.error(f"āŒ Error updating resume {resume_id} for user {current_user.id}: {e}") - raise HTTPException(status_code=500, detail="Failed to update resume") - -# ============================ -# Job Endpoints -# ============================ - -@api_router.post("/jobs") -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""" - is_employer = 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)) - ) - - -@api_router.patch("/jobs/{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)) - ) - -@api_router.post("/jobs/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( - sessionId=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( - sessionId=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" - ) - -@api_router.post("/jobs/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( - sessionId=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( - sessionId=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( - sessionId=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( - sessionId=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( - sessionId=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( - sessionId=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( - sessionId=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( - sessionId=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" - ) - -@api_router.get("/jobs/{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)) - ) - -@api_router.get("/jobs") -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""" - 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)) - ) - -@api_router.get("/jobs/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), - database: RedisDatabase = Depends(get_database) -): - """Search 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)) - ) - - -@api_router.delete("/jobs/{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") - ) - -# ============================ -# Chat Endpoints -# ============================ -# Chat Session Endpoints with Username Association -@api_router.get("/chat/statistics") -async def get_chat_statistics( - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Get chat statistics (admin/analytics endpoint)""" - try: - stats = await database.get_chat_statistics() - return create_success_response(stats) - except Exception as e: - logger.error(f"āŒ Get chat statistics error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("STATS_ERROR", str(e)) - ) - -@api_router.post("/candidates/rag-search") -async def post_candidate_rag_search( - query: str = Body(...), - current_user = Depends(get_current_user) -): - """Get chat activity summary for a candidate""" - try: - if current_user.user_type != "candidate": - logger.warning(f"āš ļø Unauthorized RAG search attempt by user {current_user.id}") - return JSONResponse( - status_code=403, - content=create_error_response("FORBIDDEN", "Only candidates can access this endpoint") - ) - - candidate : Candidate = current_user - chat_type = ChatContextType.RAG_SEARCH - # Get RAG search data - async with entities.get_candidate_entity(candidate=candidate) as candidate_entity: - # Entity automatically released when done - chat_agent = candidate_entity.get_or_create_agent(agent_type=chat_type) - if not chat_agent: - return JSONResponse( - status_code=400, - content=create_error_response("AGENT_NOT_FOUND", "No agent found for this chat type") - ) - - user_message = ChatMessageUser(senderId=candidate.id, sessionId=MOCK_UUID, content=query, timestamp=datetime.now(UTC)) - rag_message : Any = None - async for generated_message in chat_agent.generate( - llm=llm_manager.get_llm(), - model=defines.model, - session_id=user_message.session_id, - prompt=user_message.content, - ): - rag_message = generated_message - - if not rag_message: - return JSONResponse( - status_code=500, - content=create_error_response("NO_RESPONSE", "No response generated for the RAG search") - ) - final_message : ChatMessageRagSearch = rag_message - return create_success_response(final_message.content[0].model_dump(by_alias=True)) - - except Exception as e: - logger.error(f"āŒ Get candidate chat summary error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("SUMMARY_ERROR", str(e)) - ) - -# reference can be candidateId, username, or email -@api_router.get("/users/{reference}") -async def get_user( - reference: str = Path(...), - database: RedisDatabase = Depends(get_database) -): - """Get a candidate by username""" - try: - # Normalize reference to lowercase for case-insensitive search - query_lower = reference.lower() - - all_candidate_data = await database.get_all_candidates() - if not all_candidate_data: - logger.warning(f"āš ļø No users found in database") - return JSONResponse( - status_code=404, - content=create_error_response("NOT_FOUND", "No users found") - ) - - user_data = None - for user in all_candidate_data.values(): - if (user.get("id", "").lower() == query_lower or - user.get("username", "").lower() == query_lower or - user.get("email", "").lower() == query_lower): - user_data = user - break - - if not user_data: - all_guest_data = await database.get_all_guests() - if not all_guest_data: - logger.warning(f"āš ļø No guests found in database") - return JSONResponse( - status_code=404, - content=create_error_response("NOT_FOUND", "No users found") - ) - for user in all_guest_data.values(): - if (user.get("id", "").lower() == query_lower or - user.get("username", "").lower() == query_lower or - user.get("email", "").lower() == query_lower): - user_data = user - break - - if not user_data: - logger.warning(f"āš ļø User nor Guest found for reference: {reference}") - return JSONResponse( - status_code=404, - content=create_error_response("NOT_FOUND", "User not found") - ) - - user = BaseUserWithType.model_validate(user_data) - - return create_success_response(user.model_dump(by_alias=True)) - - except Exception as e: - logger.error(f"āŒ Get user error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("FETCH_ERROR", str(e)) - ) - -# reference can be candidateId, username, or email -@api_router.get("/candidates/{reference}") -async def get_candidate( - reference: str = Path(...), - database: RedisDatabase = Depends(get_database) -): - """Get a candidate by username""" - try: - # Normalize reference to lowercase for case-insensitive search - query_lower = reference.lower() - - all_candidates_data = await database.get_all_candidates() - if not all_candidates_data: - logger.warning(f"āš ļø No candidates found in database") - return JSONResponse( - status_code=404, - content=create_error_response("NOT_FOUND", "No candidates found") - ) - - candidate_data = None - for candidate in all_candidates_data.values(): - if (candidate.get("id", "").lower() == query_lower or - candidate.get("username", "").lower() == query_lower or - candidate.get("email", "").lower() == query_lower): - candidate_data = candidate - break - - if not candidate_data: - logger.warning(f"āš ļø Candidate not found for reference: {reference}") - return JSONResponse( - status_code=404, - content=create_error_response("NOT_FOUND", "Candidate not found") - ) - - candidate = Candidate.model_validate(candidate_data) if not candidate_data.get("is_AI") else CandidateAI.model_validate(candidate_data) - - return create_success_response(candidate.model_dump(by_alias=True)) - - except Exception as e: - logger.error(f"āŒ Get candidate error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("FETCH_ERROR", str(e)) - ) - -@api_router.get("/candidates/{username}/chat-summary") -async def get_candidate_chat_summary( - username: str = Path(...), - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Get chat activity summary for a candidate""" - try: - # Find candidate by username - candidate_data = await database.find_candidate_by_username(username) - if not candidate_data: - return JSONResponse( - status_code=404, - content=create_error_response("CANDIDATE_NOT_FOUND", f"Candidate with username '{username}' not found") - ) - - summary = await database.get_candidate_chat_summary(candidate_data["id"]) - summary["candidate"] = { - "username": candidate_data.get("username"), - "fullName": candidate_data.get("fullName"), - "email": candidate_data.get("email") - } - - return create_success_response(summary) - - except Exception as e: - logger.error(f"āŒ Get candidate chat summary error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("SUMMARY_ERROR", str(e)) - ) - -@api_router.post("/chat/sessions/{session_id}/archive") -async def archive_chat_session( - session_id: str = Path(...), - current_user = Depends(get_current_user), - database: RedisDatabase = Depends(get_database) -): - """Archive a chat session""" - try: - session_data = await database.get_chat_session(session_id) - if not session_data: - return JSONResponse( - status_code=404, - content=create_error_response("NOT_FOUND", "Chat session not found") - ) - - # Check if user owns this session or is admin - if session_data.get("userId") != current_user.id: - return JSONResponse( - status_code=403, - content=create_error_response("FORBIDDEN", "Cannot archive another user's session") - ) - - await database.archive_chat_session(session_id) - - return create_success_response({ - "message": "Chat session archived successfully", - "sessionId": session_id - }) - - except Exception as e: - logger.error(f"āŒ Archive chat session error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("ARCHIVE_ERROR", str(e)) - ) - -# ============================ -# Chat Endpoints -# ============================ - -@api_router.post("/chat/sessions") -async def create_chat_session( - session_data: Dict[str, Any] = Body(...), - current_user: BaseUserWithType = Depends(get_current_user_or_guest), - database: RedisDatabase = Depends(get_database) -): - """Create a new chat session with optional candidate username association""" - try: - # Extract username if provided - username = session_data.get("username") - candidate_id = None - candidate_data = None - - # If username is provided, look up the candidate - if username: - logger.info(f"šŸ” Looking up candidate with username: {username}") - - # Get all candidates and find by username - all_candidates_data = await database.get_all_candidates() - candidates_list = [Candidate.model_validate(data) for data in all_candidates_data.values()] - - # Find candidate by username (case-insensitive) - matching_candidates = [ - c for c in candidates_list - if c.username.lower() == username.lower() - ] - - if not matching_candidates: - return JSONResponse( - status_code=404, - content=create_error_response("CANDIDATE_NOT_FOUND", f"Candidate with username '{username}' not found") - ) - - candidate_data = matching_candidates[0] - candidate_id = candidate_data.id - logger.info(f"āœ… Found candidate: {candidate_data.full_name} (ID: {candidate_id})") - - # Add required fields - session_id = str(uuid.uuid4()) - session_data["id"] = session_id - session_data["userId"] = current_user.id - session_data["createdAt"] = datetime.now(UTC).isoformat() - session_data["lastActivity"] = datetime.now(UTC).isoformat() - - # Set up context with candidate association if username was provided - context = session_data.get("context", {}) - if candidate_id and candidate_data: - context["relatedEntityId"] = candidate_id - context["relatedEntityType"] = "candidate" - - # Add candidate info to additional context for AI reference - additional_context = context.get("additionalContext", {}) - additional_context["candidateInfo"] = { - "id": candidate_data.id, - "name": candidate_data.full_name, - "email": candidate_data.email, - "username": candidate_data.username, - "skills": [skill.name for skill in candidate_data.skills] if candidate_data.skills else [], - "experience": len(candidate_data.experience) if candidate_data.experience else 0, - "location": candidate_data.location.city if candidate_data.location else "Unknown" - } - context["additionalContext"] = additional_context - - # Set a descriptive title if not provided - if not session_data.get("title"): - session_data["title"] = f"Chat about {candidate_data.full_name}" - - session_data["context"] = context - - # Create chat session - chat_session = ChatSession.model_validate(session_data) - await database.set_chat_session(chat_session.id, chat_session.model_dump()) - - logger.info(f"āœ… Chat session created: {chat_session.id} for user {current_user.id}" + - (f" about candidate {candidate_data.full_name}" if candidate_data else "")) - - return create_success_response(chat_session.model_dump(by_alias=True)) - - except Exception as e: - logger.error(backstory_traceback.format_exc()) - logger.error(f"āŒ Chat session creation error: {e}") - logger.info(json.dumps(session_data, indent=2)) - return JSONResponse( - status_code=400, - content=create_error_response("CREATION_FAILED", str(e)) - ) - -@api_router.post("/chat/sessions/{session_id}/messages/stream") -async def post_chat_session_message_stream( - user_message: ChatMessageUser = Body(...), - current_user = Depends(get_current_user_or_guest), - database: RedisDatabase = Depends(get_database) -): - """Post a message to a chat session and stream the response with persistence""" - try: - chat_session_data = await database.get_chat_session(user_message.session_id) - if not chat_session_data: - logger.info("šŸ”— Chat session not found for session ID: " + user_message.session_id) - return JSONResponse( - status_code=404, - content=create_error_response("NOT_FOUND", "Chat session not found") - ) - chat_session = ChatSession.model_validate(chat_session_data) - chat_type = chat_session.context.type - candidate_info = chat_session.context.additional_context.get("candidateInfo", {}) if chat_session.context and chat_session.context.additional_context else None - - # Get candidate info if this chat is about a specific candidate - if candidate_info: - logger.info(f"šŸ”— Chat session {user_message.session_id} about candidate {candidate_info['name']} accessed by user {current_user.id}") - else: - logger.info(f"šŸ”— Chat session {user_message.session_id} type {chat_type} accessed by user {current_user.id}") - return JSONResponse( - status_code=400, - content=create_error_response("CANDIDATE_REQUIRED", "This chat session requires a candidate association") - ) - - candidate_data = await database.get_candidate(candidate_info["id"]) if candidate_info else None - candidate : Candidate | None = Candidate.model_validate(candidate_data) if candidate_data else None - if not candidate: - logger.info(f"šŸ”— Candidate not found for chat session {user_message.session_id} with ID {candidate_info['id']}") - return JSONResponse( - status_code=404, - content=create_error_response("CANDIDATE_NOT_FOUND", "Candidate not found for this chat session") - ) - logger.info(f"šŸ”— User {current_user.id} posting message to chat session {user_message.session_id} with query length: {len(user_message.content)}") - - async with entities.get_candidate_entity(candidate=candidate) as candidate_entity: - # Entity automatically released when done - chat_agent = candidate_entity.get_or_create_agent(agent_type=chat_type) - if not chat_agent: - logger.info(f"šŸ”— No chat agent found for session {user_message.session_id} with type {chat_type}") - return JSONResponse( - status_code=400, - content=create_error_response("AGENT_NOT_FOUND", "No agent found for this chat type") - ) - - # Persist user message to database - await database.add_chat_message(user_message.session_id, user_message.model_dump()) - logger.info(f"šŸ’¬ User message saved to database for session {user_message.session_id}") - - # Update session last activity - chat_session_data["lastActivity"] = datetime.now(UTC).isoformat() - await database.set_chat_session(user_message.session_id, chat_session_data) - - return await stream_agent_response( - chat_agent=chat_agent, - user_message=user_message, - database=database, - chat_session_data=chat_session_data, - ) - - except Exception as e: - logger.error(backstory_traceback.format_exc()) - logger.error(f"āŒ Chat message streaming error") - return JSONResponse( - status_code=500, - content=create_error_response("STREAMING_ERROR", "") - ) - -@api_router.get("/chat/sessions/{session_id}/messages") -async def get_chat_session_messages( - session_id: str = Path(...), - current_user = Depends(get_current_user_or_guest), - page: int = Query(1, ge=1), - limit: int = Query(50, ge=1, le=100), # Increased default for chat messages - database: RedisDatabase = Depends(get_database) -): - """Get persisted chat messages for a session""" - try: - chat_session_data = await database.get_chat_session(session_id) - if not chat_session_data: - return JSONResponse( - status_code=404, - content=create_error_response("NOT_FOUND", "Chat session not found") - ) - - # Get messages from database - chat_messages = await database.get_chat_messages(session_id) - - # Convert to ChatMessage objects and sort by timestamp - messages_list = [] - for msg_data in chat_messages: - try: - message = ChatMessage.model_validate(msg_data) - messages_list.append(message) - except Exception as e: - logger.warning(f"āš ļø Failed to validate message: {e}") - continue - - # Sort by timestamp (oldest first for chat history) - messages_list.sort(key=lambda x: x.timestamp) - - # Apply pagination - total = len(messages_list) - start = (page - 1) * limit - end = start + limit - paginated_messages = messages_list[start:end] - - paginated_response = create_paginated_response( - [m.model_dump(by_alias=True) for m in paginated_messages], - page, limit, total - ) - - return create_success_response(paginated_response) - - except Exception as e: - logger.error(f"āŒ Get chat messages error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("FETCH_ERROR", str(e)) - ) - -@api_router.patch("/chat/sessions/{session_id}") -async def update_chat_session( - session_id: str = Path(...), - updates: Dict[str, Any] = Body(...), - current_user = Depends(get_current_user_or_guest), - database: RedisDatabase = Depends(get_database) -): - """Update a chat session's properties""" - try: - # Get the existing session - session_data = await database.get_chat_session(session_id) - if not session_data: - return JSONResponse( - status_code=404, - content=create_error_response("NOT_FOUND", "Chat session not found") - ) - - session = ChatSession.model_validate(session_data) - - # Check authorization - user can only update their own sessions - if session.user_id != current_user.id: - return JSONResponse( - status_code=403, - content=create_error_response("FORBIDDEN", "Cannot update another user's chat session") - ) - - # Validate and apply updates - allowed_fields = {"title", "context", "isArchived", "systemPrompt"} - filtered_updates = {k: v for k, v in updates.items() if k in allowed_fields} - - if not filtered_updates: - return JSONResponse( - status_code=400, - content=create_error_response("INVALID_UPDATES", "No valid fields provided for update") - ) - - # Apply updates to session data - session_dict = session.model_dump() - - # Handle special field mappings (camelCase to snake_case) - if "isArchived" in filtered_updates: - session_dict["is_archived"] = filtered_updates["isArchived"] - if "systemPrompt" in filtered_updates: - session_dict["system_prompt"] = filtered_updates["systemPrompt"] - if "title" in filtered_updates: - session_dict["title"] = filtered_updates["title"] - if "context" in filtered_updates: - # Merge context updates with existing context - existing_context = session_dict.get("context", {}) - context_updates = filtered_updates["context"] - - # Update specific context fields while preserving others - for context_key, context_value in context_updates.items(): - if context_key == "additionalContext": - # Merge additional context - existing_additional = existing_context.get("additional_context", {}) - existing_additional.update(context_value) - existing_context["additional_context"] = existing_additional - else: - # Convert camelCase to snake_case for context fields - snake_key = context_key - if context_key == "relatedEntityId": - snake_key = "related_entity_id" - elif context_key == "relatedEntityType": - snake_key = "related_entity_type" - elif context_key == "aiParameters": - snake_key = "ai_parameters" - - existing_context[snake_key] = context_value - - session_dict["context"] = existing_context - - # Update last activity timestamp - session_dict["last_activity"] = datetime.now(UTC).isoformat() - - # Validate the updated session - updated_session = ChatSession.model_validate(session_dict) - - # Save to database - await database.set_chat_session(session_id, updated_session.model_dump()) - - logger.info(f"āœ… Chat session {session_id} updated by user {current_user.id}") - - return create_success_response(updated_session.model_dump(by_alias=True)) - - except ValueError as ve: - logger.warning(f"āš ļø Validation error updating chat session: {ve}") - return JSONResponse( - status_code=400, - content=create_error_response("VALIDATION_ERROR", str(ve)) - ) - except Exception as e: - logger.error(f"āŒ Update chat session error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("UPDATE_ERROR", str(e)) - ) - -@api_router.delete("/chat/sessions/{session_id}") -async def delete_chat_session( - session_id: str = Path(...), - current_user = Depends(get_current_user_or_guest), - database: RedisDatabase = Depends(get_database) -): - """Delete a chat session and all its messages""" - try: - # Get the session to verify it exists and check ownership - session_data = await database.get_chat_session(session_id) - if not session_data: - return JSONResponse( - status_code=404, - content=create_error_response("NOT_FOUND", "Chat session not found") - ) - - session = ChatSession.model_validate(session_data) - - # Check authorization - user can only delete their own sessions - if session.user_id != current_user.id: - return JSONResponse( - status_code=403, - content=create_error_response("FORBIDDEN", "Cannot delete another user's chat session") - ) - - # Delete all messages associated with this session - try: - await database.delete_chat_messages(session_id) - chat_messages = await database.get_chat_messages(session_id) - message_count = len(chat_messages) - logger.info(f"šŸ—‘ļø Deleted {message_count} messages from session {session_id}") - - except Exception as e: - logger.warning(f"āš ļø Error deleting messages for session {session_id}: {e}") - # Continue with session deletion even if message deletion fails - - # Delete the session itself - await database.delete_chat_session(session_id) - - logger.info(f"šŸ—‘ļø Chat session {session_id} deleted by user {current_user.id}") - - return create_success_response({ - "success": True, - "message": "Chat session deleted successfully", - "sessionId": session_id - }) - - except Exception as e: - logger.error(f"āŒ Delete chat session error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("DELETE_ERROR", str(e)) - ) - -@api_router.patch("/chat/sessions/{session_id}/reset") -async def reset_chat_session( - session_id: str = Path(...), - current_user = Depends(get_current_user_or_guest), - database: RedisDatabase = Depends(get_database) -): - """Delete a chat session and all its messages""" - try: - # Get the session to verify it exists and check ownership - session_data = await database.get_chat_session(session_id) - if not session_data: - return JSONResponse( - status_code=404, - content=create_error_response("NOT_FOUND", "Chat session not found") - ) - - session = ChatSession.model_validate(session_data) - - # Check authorization - user can only delete their own sessions - if session.user_id != current_user.id: - return JSONResponse( - status_code=403, - content=create_error_response("FORBIDDEN", "Cannot reset another user's chat session") - ) - - # Delete all messages associated with this session - try: - await database.delete_chat_messages(session_id) - chat_messages = await database.get_chat_messages(session_id) - message_count = len(chat_messages) - logger.info(f"šŸ—‘ļø Deleted {message_count} messages from session {session_id}") - - except Exception as e: - logger.warning(f"āš ļø Error deleting messages for session {session_id}: {e}") - # Continue with session deletion even if message deletion fails - - - logger.info(f"šŸ—‘ļø Chat session {session_id} reset by user {current_user.id}") - - return create_success_response({ - "success": True, - "message": "Chat session reset successfully", - "sessionId": session_id - }) - - except Exception as e: - logger.error(f"āŒ Reset chat session error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("RESET_ERROR", str(e)) - ) - - -# ============================ -# Rate Limited Decorator -# ============================ - -def rate_limited( - guest_per_minute: int = 10, - user_per_minute: int = 60, - admin_per_minute: int = 120, - endpoint_specific: bool = True -): - """ - Decorator to easily apply rate limiting to endpoints - - Args: - guest_per_minute: Rate limit for guest users - user_per_minute: Rate limit for authenticated users - admin_per_minute: Rate limit for admin users - endpoint_specific: Whether to apply endpoint-specific limits - - Usage: - @rate_limited(guest_per_minute=5, user_per_minute=30) - @api_router.post("/my-endpoint") - async def my_endpoint( - request: Request, - current_user = Depends(get_current_user_or_guest), - database: RedisDatabase = Depends(get_database) - ): - return {"message": "Rate limited endpoint"} - """ - def decorator(func: Callable) -> Callable: - @wraps(func) - async def wrapper(*args, **kwargs): - # Extract dependencies from function signature - import inspect - sig = inspect.signature(func) - - # Get request, current_user, and rate_limiter from kwargs or args - request = None - current_user = None - rate_limiter = None - - # Try to find dependencies in kwargs first - for param_name, param_value in kwargs.items(): - if isinstance(param_value, Request): - request = param_value - elif hasattr(param_value, 'user_type'): # User-like object - current_user = param_value - elif isinstance(param_value, RateLimiter): - rate_limiter = param_value - - # If not found in kwargs, check if they're provided via Depends - if not rate_limiter: - # Create rate limiter instance (this should ideally come from DI) - database = get_database() - rate_limiter = RateLimiter(database) - - # Apply rate limiting if we have the required components - if request and current_user and rate_limiter: - await apply_custom_rate_limiting( - request, current_user, rate_limiter, - guest_per_minute, user_per_minute, admin_per_minute - ) - - # Call the original function - return await func(*args, **kwargs) - - return wrapper - return decorator - -async def apply_custom_rate_limiting( - request: Request, - current_user, - rate_limiter: RateLimiter, - guest_per_minute: int, - user_per_minute: int, - admin_per_minute: int -): - """Apply custom rate limiting with specified limits""" - try: - # Determine user info - user_id = current_user.id - user_type = current_user.user_type.value if hasattr(current_user.user_type, 'value') else str(current_user.user_type) - is_admin = getattr(current_user, 'is_admin', False) - - # Determine appropriate limit - if is_admin: - requests_per_minute = admin_per_minute - elif user_type == "guest": - requests_per_minute = guest_per_minute - else: - requests_per_minute = user_per_minute - - # Create custom rate limit key - current_time = datetime.now(UTC) - custom_key = f"custom_rate_limit:{request.url.path}:{user_type}:{user_id}:minute:{current_time.strftime('%Y%m%d%H%M')}" - - # Check current usage - current_count = int(await rate_limiter.redis.get(custom_key) or 0) - - if current_count >= requests_per_minute: - logger.warning(f"🚫 Custom rate limit exceeded for {user_type} {user_id}: {current_count}/{requests_per_minute}") - raise HTTPException( - status_code=429, - detail={ - "error": "Rate limit exceeded", - "message": f"Custom rate limit exceeded: {current_count}/{requests_per_minute} requests per minute", - "retryAfter": 60 - current_time.second, - "userType": user_type, - "endpoint": request.url.path - }, - headers={"Retry-After": str(60 - current_time.second)} - ) - - # Increment counter - pipe = rate_limiter.redis.pipeline() - pipe.incr(custom_key) - pipe.expire(custom_key, 120) # 2 minutes TTL - await pipe.execute() - - logger.debug(f"āœ… Custom rate limit check passed for {user_type} {user_id}: {current_count + 1}/{requests_per_minute}") - - except HTTPException: - raise - except Exception as e: - logger.error(f"āŒ Custom rate limiting error: {e}") - # Fail open - -# ============================ -# Alternative: FastAPI Dependency-Based Rate Limiting -# ============================ - -def create_rate_limit_dependency( - guest_per_minute: int = 10, - user_per_minute: int = 60, - admin_per_minute: int = 120 -): - """ - Create a FastAPI dependency for rate limiting - - Usage: - rate_limit_5_30 = create_rate_limit_dependency(guest_per_minute=5, user_per_minute=30) - - @api_router.post("/my-endpoint") - async def my_endpoint( - rate_check = Depends(rate_limit_5_30), - current_user = Depends(get_current_user_or_guest), - database: RedisDatabase = Depends(get_database) - ): - return {"message": "Rate limited endpoint"} - """ - async def rate_limit_dependency( - request: Request, - current_user = Depends(get_current_user_or_guest), - rate_limiter: RateLimiter = Depends(get_rate_limiter) - ): - await apply_custom_rate_limiting( - request, current_user, rate_limiter, - guest_per_minute, user_per_minute, admin_per_minute - ) - return True - - return rate_limit_dependency - -# ============================ -# Rate Limiting Utilities -# ============================ - -class EndpointRateLimiter: - """Utility class for endpoint-specific rate limiting""" - - def __init__(self, rate_limiter: RateLimiter): - self.rate_limiter = rate_limiter - self.custom_limits = {} - - def set_endpoint_limits(self, endpoint: str, limits: dict): - """Set custom limits for an endpoint""" - self.custom_limits[endpoint] = limits - - async def check_endpoint_limit(self, request: Request, current_user) -> bool: - """Check if request exceeds endpoint-specific limits""" - endpoint = request.url.path - - if endpoint not in self.custom_limits: - return True # No custom limits set - - limits = self.custom_limits[endpoint] - user_type = current_user.user_type.value if hasattr(current_user.user_type, 'value') else str(current_user.user_type) - - if getattr(current_user, 'is_admin', False): - user_type = "admin" - - limit = limits.get(user_type, limits.get("default", 60)) - - current_time = datetime.now(UTC) - key = f"endpoint_limit:{endpoint}:{user_type}:{current_user.id}:minute:{current_time.strftime('%Y%m%d%H%M')}" - - current_count = int(await self.rate_limiter.redis.get(key) or 0) - - if current_count >= limit: - raise HTTPException( - status_code=429, - detail=f"Endpoint rate limit exceeded: {current_count}/{limit} for {endpoint}" - ) - - # Increment counter - await self.rate_limiter.redis.incr(key) - await self.rate_limiter.redis.expire(key, 120) - - return True - -# Global endpoint rate limiter instance -endpoint_rate_limiter = None - -def get_endpoint_rate_limiter(rate_limiter: RateLimiter = Depends(get_rate_limiter)) -> EndpointRateLimiter: - """Get endpoint rate limiter instance""" - global endpoint_rate_limiter - if endpoint_rate_limiter is None: - endpoint_rate_limiter = EndpointRateLimiter(rate_limiter) - - # Configure endpoint-specific limits - endpoint_rate_limiter.set_endpoint_limits("/api/1.0/chat/sessions/*/messages/stream", { - "guest": 5, "candidate": 30, "employer": 30, "admin": 100 - }) - endpoint_rate_limiter.set_endpoint_limits("/api/1.0/candidates/documents/upload", { - "guest": 2, "candidate": 10, "employer": 10, "admin": 50 - }) - endpoint_rate_limiter.set_endpoint_limits("/api/1.0/jobs", { - "guest": 1, "candidate": 5, "employer": 20, "admin": 50 - }) - - 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(...), - skill: str = Body(...), - current_user = Depends(get_current_user_or_guest), - database: RedisDatabase = Depends(get_database) -) -> StreamingResponse: - - """Get skill match for a candidate against a skill with caching""" - async def message_stream_generator(): - candidate_data = await database.get_candidate(candidate_id) - if not candidate_data: - 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) - - 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) - - 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}). Regenerating...") - assessment = None - - # Determine if we need to regenerate the assessment - if assessment: - # Get the latest RAG data update time for the current user - user_rag_update_time = await database.get_user_rag_update_time(candidate.id) - - updated = assessment.updated_at if "updated_at" in assessment else assessment.created_at - # Check if cached result is still valid - # Regenerate if user's RAG data was updated after cache date - if user_rag_update_time and user_rag_update_time >= updated: - logger.info(f"šŸ”„ Out-of-date cached entry for {candidate.username} skill {assessment.skill}") - assessment = None - else: - logger.info(f"āœ… Using cached skill match for {candidate.username} skill {assessment.skill}: {cache_key}") - else: - logger.info(f"šŸ’¾ No cached skill match data: {cache_key}, {candidate.id}, {skill}") - - if assessment: - # Return cached assessment - skill_message = ChatMessageSkillAssessment( - sessionId=MOCK_UUID, # No session ID for document uploads - content=f"Cached skill match found for {candidate.username}", - skill_assessment=assessment - ) - yield skill_message - return - - logger.info(f"šŸ” Generating skill match for candidate {candidate.username} for skill: {skill}") - - async with entities.get_candidate_entity(candidate=candidate) as candidate_entity: - agent = candidate_entity.get_or_create_agent(agent_type=ChatContextType.SKILL_MATCH) - if not agent: - error_message = ChatMessageError( - sessionId=MOCK_UUID, # No session ID for document uploads - content=f"No skill match agent found for this candidate" - ) - yield error_message - return - - # Generate new skill match - final_message = None - async for generated_message in agent.generate( - llm=llm_manager.get_llm(), - model=defines.model, - session_id=MOCK_UUID, - prompt=skill, - ): - if generated_message.status == ApiStatusType.ERROR: - error_message = ChatMessageError( - sessionId=MOCK_UUID, # No session ID for document uploads - content=f"AI generation error: {generated_message.content}" - ) - logger.error(f"āŒ AI generation error: {generated_message.content}") - yield error_message - return - - # If the message is not done, convert it to a ChatMessageBase to remove - # metadata and other unnecessary fields for streaming - if generated_message.status != ApiStatusType.DONE: - if not isinstance(generated_message, ChatMessageStreaming) and not isinstance(generated_message, ChatMessageStatus): - raise TypeError( - f"Expected ChatMessageStreaming or ChatMessageStatus, got {type(generated_message)}" - ) - yield generated_message# Convert to ChatMessageBase for streaming - - # Store reference to the complete AI message - if generated_message.status == ApiStatusType.DONE: - final_message = generated_message - break - - if final_message is None: - error_message = ChatMessageError( - sessionId=MOCK_UUID, # No session ID for document uploads - content=f"No match found for the given skill" - ) - yield error_message - return - - if not isinstance(final_message, ChatMessageSkillAssessment): - error_message = ChatMessageError( - sessionId=MOCK_UUID, # No session ID for document uploads - content=f"Skill match response is not valid" - ) - yield error_message - return - - skill_match : ChatMessageSkillAssessment = final_message - assessment = skill_match.skill_assessment - if not assessment: - error_message = ChatMessageError( - sessionId=MOCK_UUID, # No session ID for document uploads - content=f"Skill assessment could not be generated" - ) - yield error_message - return - - await database.cache_skill_match(cache_key, assessment) - logger.info(f"šŸ’¾ Cached new skill match for candidate {candidate.id} as {cache_key}") - logger.info(f"āœ… Skill match: {assessment.evidence_strength} {skill}") - yield skill_match - 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(message_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"āŒ Document upload error: {e}") - return StreamingResponse( - iter([json.dumps(ChatMessageError( - sessionId=MOCK_UUID, # No session ID for document uploads - content="Failed to generate skill assessment" - ).model_dump(mode='json', by_alias=True))]), - media_type="text/event-stream" - ) - -@api_router.post("/candidates/job-score") -async def get_candidate_job_score( - job_requirements: JobRequirements = Body(...), - skills: List[SkillAssessment] = Body(...), - current_user = Depends(get_current_user_or_guest), - database: RedisDatabase = Depends(get_database) -) -> StreamingResponse: - # Initialize counters - required_skills_total = 0 - required_skills_matched = 0 - preferred_skills_total = 0 - preferred_skills_matched = 0 - - # Count required technical skills - tech_required = job_requirements.technical_skills.required - required_skills_total += len(tech_required) - - # Count preferred technical skills - tech_preferred = job_requirements.technical_skills.preferred - preferred_skills_total += len(tech_preferred) - - # Count required experience - exp_required = job_requirements.experience_requirements.required - required_skills_total += len(exp_required) - - # Count preferred experience - exp_preferred = job_requirements.experience_requirements.preferred - preferred_skills_total += len(exp_preferred) - - # Education requirements count toward required - edu_required = job_requirements.education or [] - required_skills_total += len(edu_required) - - # Soft skills count toward preferred - soft_skills = job_requirements.soft_skills or [] - preferred_skills_total += len(soft_skills) - - # Industry knowledge counts toward preferred - certifications = job_requirements.certifications or [] - preferred_skills_total += len(certifications) - - preferred_attributes = job_requirements.preferred_attributes or [] - preferred_skills_total += len(preferred_attributes) - - # Check matches in assessment results - for assessment in skills: - evidence_found = assessment.evidence_found - evidence_strength = assessment.evidence_strength - - # Consider STRONG and MODERATE evidence as matches - is_match = evidence_found and evidence_strength in ["STRONG", "MODERATE"] - - if not is_match: - continue - - # Loop through each of the job requirements categories - # and see if the skill matches the required or preferred skills - if assessment.skill in tech_required: - required_skills_matched += 1 - elif assessment.skill in tech_preferred: - preferred_skills_matched += 1 - elif assessment.skill in exp_required: - required_skills_matched += 1 - elif assessment.skill in exp_preferred: - preferred_skills_matched += 1 - elif assessment.skill in edu_required: - required_skills_matched += 1 - elif assessment.skill in soft_skills: - preferred_skills_matched += 1 - elif assessment.skill in certifications: - preferred_skills_matched += 1 - elif assessment.skill in preferred_attributes: - preferred_skills_matched += 1 - # If no skills were found, return empty statistics - if required_skills_total == 0 and preferred_skills_total == 0: - return create_success_response({ - "required_skills": { - "total": 0, - "matched": 0, - "percentage": 0.0, - }, - "preferred_skills": { - "total": 0, - "matched": 0, - "percentage": 0.0, - }, - "overall_match": { - "total": 0, - "matched": 0, - "percentage": 0.0, - }, - }) - - # Calculate percentages - required_match_percent = ( - (required_skills_matched / required_skills_total * 100) - if required_skills_total > 0 - else 0 - ) - preferred_match_percent = ( - (preferred_skills_matched / preferred_skills_total * 100) - if preferred_skills_total > 0 - else 0 - ) - overall_total = required_skills_total + preferred_skills_total - overall_matched = required_skills_matched + preferred_skills_matched - overall_match_percent = ( - (overall_matched / overall_total * 100) if overall_total > 0 else 0 - ) - - return create_success_response({ - "required_skills": { - "total": required_skills_total, - "matched": required_skills_matched, - "percentage": round(required_match_percent, 1), - }, - "preferred_skills": { - "total": preferred_skills_total, - "matched": preferred_skills_matched, - "percentage": round(preferred_match_percent, 1), - }, - "overall_match": { - "total": overall_total, - "matched": overall_matched, - "percentage": round(overall_match_percent, 1), - }, - }) - -@api_router.post("/candidates/{candidate_id}/{job_id}/generate-resume") -async def generate_resume( - candidate_id: str = Path(...), - 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 - - 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) - if not agent: - error_message = ChatMessageError( - sessionId=MOCK_UUID, # No session ID for document uploads - content=f"No skill match agent found for this candidate" - ) - yield error_message - return - - final_message = None - async for generated_message in agent.generate_resume( - llm=llm_manager.get_llm(), - model=defines.model, - session_id=MOCK_UUID, - skills=skills, - ): - if generated_message.status == ApiStatusType.ERROR: - logger.error(f"āŒ AI generation error: {generated_message.content}") - yield f"data: {json.dumps({'status': 'error'})}\n\n" - return - - # If the message is not done, convert it to a ChatMessageBase to remove - # metadata and other unnecessary fields for streaming - if generated_message.status != ApiStatusType.DONE: - if not isinstance(generated_message, ChatMessageStreaming) and not isinstance(generated_message, ChatMessageStatus): - raise TypeError( - f"Expected ChatMessageStreaming or ChatMessageStatus, got {type(generated_message)}" - ) - yield generated_message# Convert to ChatMessageBase for streaming - - # Store reference to the complete AI message - if generated_message.status == ApiStatusType.DONE: - final_message = generated_message - break - - if final_message is None: - error_message = ChatMessageError( - sessionId=MOCK_UUID, # No session ID for document uploads - content=f"No skill match found for the given requirement" - ) - yield error_message - return - - if not isinstance(final_message, ChatMessageResume): - error_message = ChatMessageError( - sessionId=MOCK_UUID, # No session ID for document uploads - content=f"Skill match response is not valid" - ) - yield error_message - return - - resume : ChatMessageResume = final_message - yield resume - 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(message_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"āŒ Document upload error: {e}") - return StreamingResponse( - iter([json.dumps(ChatMessageError( - sessionId=MOCK_UUID, # No session ID for document uploads - content="Failed to generate skill assessment" - ).model_dump(mode='json', by_alias=True))]), - media_type="text/event-stream" - ) - -@rate_limited(guest_per_minute=5, user_per_minute=30, admin_per_minute=100) -@api_router.get("/candidates/{username}/chat-sessions") -async def get_candidate_chat_sessions( - username: str = Path(...), - current_user = Depends(get_current_user_or_guest), - page: int = Query(1, ge=1), - limit: int = Query(20, ge=1, le=100), - database: RedisDatabase = Depends(get_database) -): - """Get all chat sessions related to a specific candidate""" - try: - logger.info(f"šŸ” Fetching chat sessions for candidate with username: {username}") - # Find candidate by username - all_candidates_data = await database.get_all_candidates() - candidates_list = [Candidate.model_validate(data) for data in all_candidates_data.values()] - - matching_candidates = [ - c for c in candidates_list - if c.username.lower() == username.lower() - ] - - if not matching_candidates: - return JSONResponse( - status_code=404, - content=create_error_response("CANDIDATE_NOT_FOUND", f"Candidate with username '{username}' not found") - ) - - candidate = matching_candidates[0] - - # Get all chat sessions - all_sessions_data = await database.get_all_chat_sessions() - sessions_list = [] - - for index, session_data in enumerate(all_sessions_data.values()): - try: - session = ChatSession.model_validate(session_data) - if session.user_id != current_user.id: - # User can only access their own sessions - logger.info(f"šŸ”— Skipping session {session.id} - not owned by user {current_user.id} (created by {session.user_id})") - continue - # Check if this session is related to the candidate - context = session.context - if (context and - context.related_entity_type == "candidate" and - context.related_entity_id == candidate.id): - sessions_list.append(session) - except Exception as e: - logger.error(backstory_traceback.format_exc()) - logger.error(f"āŒ Failed to validate session ({index}): {e}") - logger.error(f"āŒ Session data: {session_data}") - continue - - # Sort by last activity (most recent first) - sessions_list.sort(key=lambda x: x.last_activity, reverse=True) - - # Apply pagination - total = len(sessions_list) - start = (page - 1) * limit - end = start + limit - paginated_sessions = sessions_list[start:end] - - paginated_response = create_paginated_response( - [s.model_dump(by_alias=True) for s in paginated_sessions], - page, limit, total - ) - - return create_success_response({ - "candidate": { - "id": candidate.id, - "username": candidate.username, - "fullName": candidate.full_name, - "email": candidate.email - }, - "sessions": paginated_response - }) - - except Exception as e: - logger.error(f"āŒ Get candidate chat sessions error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("FETCH_ERROR", str(e)) - ) - -# ============================ -# Admin Endpoints -# ============================ -# @api_router.get("/admin/verification-stats") -async def get_verification_statistics( - current_user = Depends(get_current_admin), - database: RedisDatabase = Depends(get_database) -): - """Get verification statistics (admin only)""" - try: - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin access required") - - stats = { - "pending_verifications": await database.get_pending_verifications_count(), - "expired_tokens_cleaned": await database.cleanup_expired_verification_tokens() - } - - return create_success_response(stats) - - except Exception as e: - logger.error(f"āŒ Error getting verification stats: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("STATS_ERROR", str(e)) - ) - -@api_router.post("/admin/cleanup-verifications") -async def cleanup_verification_tokens( - current_user = Depends(get_current_admin), - database: RedisDatabase = Depends(get_database) -): - """Manually trigger cleanup of expired verification tokens (admin only)""" - try: - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin access required") - - cleaned_count = await database.cleanup_expired_verification_tokens() - - logger.info(f"🧹 Manual cleanup completed by admin {current_user.id}: {cleaned_count} tokens cleaned") - - return create_success_response({ - "message": f"Cleanup completed. Removed {cleaned_count} expired verification tokens.", - "cleaned_count": cleaned_count - }) - - except Exception as e: - logger.error(f"āŒ Error in manual cleanup: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("CLEANUP_ERROR", str(e)) - ) - -@api_router.get("/admin/pending-verifications") -async def get_pending_verifications( - current_user = Depends(get_current_admin), - page: int = Query(1, ge=1), - limit: int = Query(20, ge=1, le=100), - database: RedisDatabase = Depends(get_database) -): - """Get list of pending email verifications (admin only)""" - try: - if not current_user.is_admin: - raise HTTPException(status_code=403, detail="Admin access required") - - pattern = "email_verification:*" - cursor = 0 - pending_verifications = [] - current_time = datetime.now(timezone.utc) - - while True: - cursor, keys = await database.redis.scan(cursor, match=pattern, count=100) - - for key in keys: - token_data = await database.redis.get(key) - if token_data: - verification_info = json.loads(token_data) - if not verification_info.get("verified", False): - expires_at = datetime.fromisoformat(verification_info.get("expires_at", "")) - - pending_verifications.append({ - "email": verification_info.get("email"), - "user_type": verification_info.get("user_type"), - "created_at": verification_info.get("created_at"), - "expires_at": verification_info.get("expires_at"), - "is_expired": current_time > expires_at, - "resend_count": verification_info.get("resend_count", 0) - }) - - if cursor == 0: - break - - # Sort by creation date (newest first) - pending_verifications.sort(key=lambda x: x["created_at"], reverse=True) - - # Apply pagination - total = len(pending_verifications) - start = (page - 1) * limit - end = start + limit - paginated_verifications = pending_verifications[start:end] - - paginated_response = create_paginated_response( - paginated_verifications, - page, limit, total - ) - - return create_success_response(paginated_response) - - except Exception as e: - logger.error(f"āŒ Error getting pending verifications: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("FETCH_ERROR", str(e)) - ) - -@api_router.get("/admin/rate-limits/info") -async def get_user_rate_limit_status( - current_user = Depends(get_current_user_or_guest), - rate_limiter: RateLimiter = Depends(get_rate_limiter), - database: RedisDatabase = Depends(get_database) -): - """Get rate limit status for a user (admin only)""" - try: - # Get user to determine type - user_data = await database.get_user_by_id(current_user.id) - if not user_data: - return JSONResponse( - status_code=404, - content=create_error_response("USER_NOT_FOUND", "User not found") - ) - - user_type = user_data.get("type", "unknown") - is_admin = False - - if user_type == "candidate": - candidate_data = await database.get_candidate(current_user.id) - if candidate_data: - is_admin = candidate_data.get("is_admin", False) - elif user_type == "employer": - employer_data = await database.get_employer(current_user.id) - if employer_data: - is_admin = employer_data.get("is_admin", False) - - status = await rate_limiter.get_user_rate_limit_status(current_user.id, user_type, is_admin) - - return create_success_response(status) - - except Exception as e: - logger.error(f"āŒ Get rate limit status error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("STATUS_ERROR", str(e)) - ) - -@api_router.get("/admin/rate-limits/{user_id}") -async def get_anyone_rate_limit_status( - user_id: str = Path(...), - admin_user = Depends(get_current_admin), - rate_limiter: RateLimiter = Depends(get_rate_limiter), - database: RedisDatabase = Depends(get_database) -): - """Get rate limit status for a user (admin only)""" - try: - # Get user to determine type - user_data = await database.get_user_by_id(user_id) - if not user_data: - return JSONResponse( - status_code=404, - content=create_error_response("USER_NOT_FOUND", "User not found") - ) - - user_type = user_data.get("type", "unknown") - is_admin = False - - if user_type == "candidate": - candidate_data = await database.get_candidate(user_id) - if candidate_data: - is_admin = candidate_data.get("is_admin", False) - elif user_type == "employer": - employer_data = await database.get_employer(user_id) - if employer_data: - is_admin = employer_data.get("is_admin", False) - - status = await rate_limiter.get_user_rate_limit_status(user_id, user_type, is_admin) - - return create_success_response(status) - - except Exception as e: - logger.error(f"āŒ Get rate limit status error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("STATUS_ERROR", str(e)) - ) - -@api_router.post("/admin/rate-limits/{user_id}/reset") -async def reset_user_rate_limits( - user_id: str = Path(...), - admin_user = Depends(get_current_admin), - rate_limiter: RateLimiter = Depends(get_rate_limiter), - database: RedisDatabase = Depends(get_database) -): - """Reset rate limits for a user (admin only)""" - try: - # Get user to determine type - user_data = await database.get_user_by_id(user_id) - if not user_data: - return JSONResponse( - status_code=404, - content=create_error_response("USER_NOT_FOUND", "User not found") - ) - - user_type = user_data.get("type", "unknown") - success = await rate_limiter.reset_user_rate_limits(user_id, user_type) - - if success: - logger.info(f"šŸ”„ Rate limits reset for {user_type} {user_id} by admin {admin_user.id}") - return create_success_response({ - "message": f"Rate limits reset for {user_type} {user_id}", - "resetBy": admin_user.id - }) - else: - return JSONResponse( - status_code=500, - content=create_error_response("RESET_FAILED", "Failed to reset rate limits") - ) - - except Exception as e: - logger.error(f"āŒ Reset rate limits error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("RESET_ERROR", str(e)) - ) - -# ============================ -# Debugging Endpoints -# ============================ -@api_router.get("/debug/guest/{guest_id}") -async def debug_guest_session( - guest_id: str = Path(...), - admin_user = Depends(get_current_admin), - database: RedisDatabase = Depends(get_database) -): - """Debug guest session issues (admin only)""" - try: - # Check primary storage - primary_data = await database.redis.hget("guests", guest_id) - primary_exists = primary_data is not None - - # Check backup storage - backup_data = await database.redis.get(f"guest_backup:{guest_id}") - backup_exists = backup_data is not None - - # Check user lookup - user_lookup = await database.get_user_by_id(guest_id) - - # Get TTL info - primary_ttl = await database.redis.ttl(f"guests") - backup_ttl = await database.redis.ttl(f"guest_backup:{guest_id}") - - debug_info = { - "guest_id": guest_id, - "primary_storage": { - "exists": primary_exists, - "data": json.loads(primary_data) if primary_data else None, - "ttl": primary_ttl - }, - "backup_storage": { - "exists": backup_exists, - "data": json.loads(backup_data) if backup_data else None, - "ttl": backup_ttl - }, - "user_lookup": user_lookup, - "timestamp": datetime.now(UTC).isoformat() - } - - return create_success_response(debug_info) - - except Exception as e: - logger.error(f"āŒ Debug guest session error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("DEBUG_ERROR", str(e)) - ) # ============================ # Health Check and Info Endpoints # ============================ @@ -5805,598 +296,7 @@ async def health_check( except Exception as e: logger.error(f"āŒ Health check failed: {e}") return {"status": "error", "message": str(e)} - -@api_router.get("/redis/stats") -async def redis_stats(redis: redis.Redis = Depends(get_redis)): - try: - info = await redis.info() - return { - "connected_clients": info.get("connected_clients"), - "used_memory_human": info.get("used_memory_human"), - "total_commands_processed": info.get("total_commands_processed"), - "keyspace_hits": info.get("keyspace_hits"), - "keyspace_misses": info.get("keyspace_misses"), - "uptime_in_seconds": info.get("uptime_in_seconds") - } - except Exception as e: - raise HTTPException(status_code=503, detail=f"Redis stats unavailable: {e}") -@api_router.get("/system-info") -async def get_system_info(request: Request): - """Get system information""" - from system_info import system_info # Import system_info function from system_info module - system = system_info() - - return create_success_response(system.model_dump(mode='json')) - -@api_router.get("/") -async def api_info(): - """API information endpoint""" - return { - "message": "Backstory API", - "version": "1.0.0", - "prefix": defines.api_prefix, - "documentation": f"{defines.api_prefix}/docs", - "health": f"{defines.api_prefix}/health" - } - -# ============================ -# Task Monitoring and Metrics -# ============================ - -@api_router.post("/admin/tasks/cleanup-guests") -async def manual_guest_cleanup( - inactive_hours: int = Body(24, embed=True), - current_user = Depends(get_current_admin), - admin_user = Depends(get_current_admin) -): - """Manually trigger guest cleanup (admin only)""" - try: - global background_task_manager - - if not background_task_manager: - return JSONResponse( - status_code=500, - content=create_error_response("TASK_MANAGER_NOT_AVAILABLE", "Background task manager not available") - ) - - cleaned_count = await background_task_manager.cleanup_inactive_guests(inactive_hours) - - logger.info(f"🧹 Manual guest cleanup triggered by admin {admin_user.id}: {cleaned_count} guests cleaned") - - return create_success_response({ - "message": f"Guest cleanup completed. Removed {cleaned_count} inactive sessions.", - "cleaned_count": cleaned_count, - "triggered_by": admin_user.id - }) - - except Exception as e: - logger.error(f"āŒ Manual guest cleanup error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("CLEANUP_ERROR", str(e)) - ) - -@api_router.post("/admin/tasks/cleanup-tokens") -async def manual_token_cleanup( - admin_user = Depends(get_current_admin) -): - """Manually trigger verification token cleanup (admin only)""" - try: - global background_task_manager - - if not background_task_manager: - return JSONResponse( - status_code=500, - content=create_error_response("TASK_MANAGER_NOT_AVAILABLE", "Background task manager not available") - ) - - cleaned_count = await background_task_manager.cleanup_expired_verification_tokens() - - logger.info(f"🧹 Manual token cleanup triggered by admin {admin_user.id}: {cleaned_count} tokens cleaned") - - return create_success_response({ - "message": f"Token cleanup completed. Removed {cleaned_count} expired tokens.", - "cleaned_count": cleaned_count, - "triggered_by": admin_user.id - }) - - except Exception as e: - logger.error(f"āŒ Manual token cleanup error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("CLEANUP_ERROR", str(e)) - ) - -@api_router.post("/admin/tasks/cleanup-rate-limits") -async def manual_rate_limit_cleanup( - days_old: int = Body(7, embed=True), - admin_user = Depends(get_current_admin) -): - """Manually trigger rate limit data cleanup (admin only)""" - try: - global background_task_manager - - if not background_task_manager: - return JSONResponse( - status_code=500, - content=create_error_response("TASK_MANAGER_NOT_AVAILABLE", "Background task manager not available") - ) - - cleaned_count = await background_task_manager.cleanup_old_rate_limit_data(days_old) - - logger.info(f"🧹 Manual rate limit cleanup triggered by admin {admin_user.id}: {cleaned_count} keys cleaned") - - return create_success_response({ - "message": f"Rate limit cleanup completed. Removed {cleaned_count} old keys.", - "cleaned_count": cleaned_count, - "triggered_by": admin_user.id - }) - - except Exception as e: - logger.error(f"āŒ Manual rate limit cleanup error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("CLEANUP_ERROR", str(e)) - ) - -# ======================================== -# System Health and Maintenance Endpoints -# ======================================== - -@api_router.get("/admin/system/health") -async def get_system_health( - request: Request, - admin_user = Depends(get_current_admin) -): - """Get comprehensive system health status (admin only)""" - try: - # Database health - database_manager = getattr(request.app.state, 'database_manager', None) - db_health = {"status": "unavailable", "healthy": False} - - if database_manager: - try: - database = database_manager.get_database() - from database import redis_manager - redis_health = await redis_manager.health_check() - db_health = { - "status": redis_health.get("status", "unknown"), - "healthy": redis_health.get("status") == "healthy", - "details": redis_health - } - except Exception as e: - db_health = { - "status": "error", - "healthy": False, - "error": str(e) - } - - # Background task health - background_task_manager = getattr(request.app.state, 'background_task_manager', None) - task_health = {"status": "unavailable", "healthy": False} - - if background_task_manager: - try: - task_status = await background_task_manager.get_task_status() - running_tasks = len([t for t in task_status["tasks"] if t["status"] == "running"]) - failed_tasks = len([t for t in task_status["tasks"] if t["status"] == "failed"]) - - task_health = { - "status": "healthy" if task_status["running"] and failed_tasks == 0 else "degraded", - "healthy": task_status["running"] and failed_tasks == 0, - "running_tasks": running_tasks, - "failed_tasks": failed_tasks, - "total_tasks": task_status["task_count"] - } - except Exception as e: - task_health = { - "status": "error", - "healthy": False, - "error": str(e) - } - - # Overall health - overall_healthy = db_health["healthy"] and task_health["healthy"] - - return create_success_response({ - "timestamp": datetime.now(UTC).isoformat(), - "overall_healthy": overall_healthy, - "components": { - "database": db_health, - "background_tasks": task_health - } - }) - - except Exception as e: - logger.error(f"āŒ Error getting system health: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("HEALTH_CHECK_ERROR", str(e)) - ) - -@api_router.post("/admin/maintenance/cleanup") -async def run_maintenance_cleanup( - request: Request, - admin_user = Depends(get_current_admin), - database: RedisDatabase = Depends(get_database) -): - """Run comprehensive maintenance cleanup (admin only)""" - try: - cleanup_results = {} - - # Run various cleanup operations - cleanup_operations = [ - ("inactive_guests", lambda: database.cleanup_inactive_guests(72)), # 3 days - ("expired_tokens", lambda: database.cleanup_expired_verification_tokens()), - ("orphaned_job_requirements", lambda: database.cleanup_orphaned_job_requirements()), - ] - - for operation_name, operation_func in cleanup_operations: - try: - result = await operation_func() - cleanup_results[operation_name] = { - "success": True, - "cleaned_count": result, - "message": f"Cleaned {result} items" - } - except Exception as e: - cleanup_results[operation_name] = { - "success": False, - "error": str(e), - "message": f"Failed: {str(e)}" - } - - # Calculate totals - total_cleaned = sum( - result.get("cleaned_count", 0) - for result in cleanup_results.values() - if result.get("success", False) - ) - - successful_operations = len([ - r for r in cleanup_results.values() - if r.get("success", False) - ]) - - return create_success_response({ - "message": f"Maintenance cleanup completed. {total_cleaned} items cleaned across {successful_operations} operations.", - "total_cleaned": total_cleaned, - "successful_operations": successful_operations, - "details": cleanup_results - }) - - except Exception as e: - logger.error(f"āŒ Error in maintenance cleanup: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("CLEANUP_ERROR", str(e)) - ) - -# ======================================== -# Background Task Statistics -# ======================================== - -@api_router.get("/admin/tasks/stats") -async def get_task_statistics( - request: Request, - admin_user = Depends(get_current_admin), - database: RedisDatabase = Depends(get_database) -): - """Get background task execution statistics (admin only)""" - try: - # Get guest statistics - guest_stats = await database.get_guest_statistics() - - # Get background task manager status - background_task_manager = getattr(request.app.state, 'background_task_manager', None) - task_manager_stats = {} - - if background_task_manager: - task_status = await background_task_manager.get_task_status() - task_manager_stats = { - "running": task_status["running"], - "task_count": task_status["task_count"], - "task_breakdown": {} - } - - # Count tasks by status - for task in task_status["tasks"]: - status = task["status"] - task_manager_stats["task_breakdown"][status] = task_manager_stats["task_breakdown"].get(status, 0) + 1 - - return create_success_response({ - "guest_statistics": guest_stats, - "task_manager": task_manager_stats, - "timestamp": datetime.now(UTC).isoformat() - }) - - except Exception as e: - logger.error(f"āŒ Error getting task statistics: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("STATS_ERROR", str(e)) - ) - -# ======================================== -# Background Task Status Endpoints -# ======================================== - -@api_router.get("/admin/tasks/status") -async def get_background_task_status( - request: Request, - admin_user = Depends(get_current_admin) -): - """Get background task manager status (admin only)""" - try: - # Get background task manager from app state - background_task_manager = getattr(request.app.state, 'background_task_manager', None) - - if not background_task_manager: - return create_success_response({ - "running": False, - "message": "Background task manager not initialized", - "tasks": [], - "task_count": 0 - }) - - # Get comprehensive task status using the new method - task_status = await background_task_manager.get_task_status() - - # Add additional system info - system_info = { - "uptime_seconds": None, # Could calculate from start time if stored - "last_cleanup": None, # Could track last cleanup time - } - - # Format the response - return create_success_response({ - "running": task_status["running"], - "task_count": task_status["task_count"], - "loop_status": { - "main_loop_id": task_status["main_loop_id"], - "current_loop_id": task_status["current_loop_id"], - "loop_matches": task_status.get("loop_matches", False) - }, - "tasks": task_status["tasks"], - "system_info": system_info - }) - - except Exception as e: - logger.error(f"āŒ Get task status error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("STATUS_ERROR", str(e)) - ) - -@api_router.post("/admin/tasks/run/{task_name}") -async def run_background_task( - task_name: str, - request: Request, - admin_user = Depends(get_current_admin) -): - """Manually trigger a specific background task (admin only)""" - try: - background_task_manager = getattr(request.app.state, 'background_task_manager', None) - - if not background_task_manager: - return JSONResponse( - status_code=503, - content=create_error_response( - "MANAGER_UNAVAILABLE", - "Background task manager not initialized" - ) - ) - - # List of available tasks - available_tasks = [ - "guest_cleanup", - "token_cleanup", - "guest_stats", - "rate_limit_cleanup", - "orphaned_cleanup" - ] - - if task_name not in available_tasks: - return JSONResponse( - status_code=400, - content=create_error_response( - "INVALID_TASK", - f"Unknown task: {task_name}. Available: {available_tasks}" - ) - ) - - # Run the task - result = await background_task_manager.force_run_task(task_name) - - return create_success_response({ - "task_name": task_name, - "result": result, - "message": f"Task {task_name} completed successfully" - }) - - except ValueError as e: - return JSONResponse( - status_code=400, - content=create_error_response("INVALID_TASK", str(e)) - ) - except Exception as e: - logger.error(f"āŒ Error running task {task_name}: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("TASK_EXECUTION_ERROR", str(e)) - ) - -@api_router.get("/admin/tasks/list") -async def list_available_tasks( - admin_user = Depends(get_current_admin) -): - """List all available background tasks (admin only)""" - try: - tasks = [ - { - "name": "guest_cleanup", - "description": "Clean up inactive guest sessions", - "interval": "6 hours", - "parameters": ["inactive_hours (default: 48)"] - }, - { - "name": "token_cleanup", - "description": "Clean up expired email verification tokens", - "interval": "12 hours", - "parameters": [] - }, - { - "name": "guest_stats", - "description": "Update guest usage statistics", - "interval": "1 hour", - "parameters": [] - }, - { - "name": "rate_limit_cleanup", - "description": "Clean up old rate limiting data", - "interval": "24 hours", - "parameters": ["days_old (default: 7)"] - }, - { - "name": "orphaned_cleanup", - "description": "Clean up orphaned database records", - "interval": "6 hours", - "parameters": [] - } - ] - - return create_success_response({ - "total_tasks": len(tasks), - "tasks": tasks - }) - - except Exception as e: - logger.error(f"āŒ Error listing tasks: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("LIST_ERROR", str(e)) - ) - -@api_router.post("/admin/tasks/restart") -async def restart_background_tasks( - request: Request, - admin_user = Depends(get_current_admin) -): - """Restart the background task manager (admin only)""" - try: - database_manager = getattr(request.app.state, 'database_manager', None) - background_task_manager = getattr(request.app.state, 'background_task_manager', None) - - if not database_manager: - return JSONResponse( - status_code=503, - content=create_error_response( - "DATABASE_UNAVAILABLE", - "Database manager not available" - ) - ) - - # Stop existing background tasks - if background_task_manager: - await background_task_manager.stop() - logger.info("šŸ›‘ Stopped existing background task manager") - - # Create and start new background task manager - from background_tasks import BackgroundTaskManager - new_background_task_manager = BackgroundTaskManager(database_manager) - await new_background_task_manager.start() - - # Update app state - request.app.state.background_task_manager = new_background_task_manager - - # Get status of new manager - status = await new_background_task_manager.get_task_status() - - return create_success_response({ - "message": "Background task manager restarted successfully", - "new_status": status - }) - - except Exception as e: - logger.error(f"āŒ Error restarting background tasks: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("RESTART_ERROR", str(e)) - ) - - -# ============================ -# Task Monitoring and Metrics -# ============================ - -class TaskMetrics: - """Collect metrics for background tasks""" - - def __init__(self): - self.task_runs = {} - self.task_durations = {} - self.task_errors = {} - - def record_task_run(self, task_name: str, duration: float, success: bool = True): - """Record a task execution""" - if task_name not in self.task_runs: - self.task_runs[task_name] = 0 - self.task_durations[task_name] = [] - self.task_errors[task_name] = 0 - - self.task_runs[task_name] += 1 - self.task_durations[task_name].append(duration) - - if not success: - self.task_errors[task_name] += 1 - - # Keep only last 100 durations to prevent memory growth - if len(self.task_durations[task_name]) > 100: - self.task_durations[task_name] = self.task_durations[task_name][-100:] - - def get_metrics(self) -> dict: - """Get task metrics summary""" - metrics = {} - - for task_name in self.task_runs: - durations = self.task_durations[task_name] - avg_duration = sum(durations) / len(durations) if durations else 0 - - metrics[task_name] = { - "total_runs": self.task_runs[task_name], - "total_errors": self.task_errors[task_name], - "success_rate": (self.task_runs[task_name] - self.task_errors[task_name]) / self.task_runs[task_name] if self.task_runs[task_name] > 0 else 0, - "average_duration": avg_duration, - "last_runs": durations[-10:] if durations else [] - } - - return metrics - -# Global task metrics -task_metrics = TaskMetrics() - -@api_router.get("/admin/tasks/metrics") -async def get_task_metrics( - admin_user = Depends(get_current_admin) -): - """Get background task metrics (admin only)""" - try: - global task_metrics - metrics = task_metrics.get_metrics() - - return create_success_response({ - "metrics": metrics, - "timestamp": datetime.now(UTC).isoformat() - }) - - except Exception as e: - logger.error(f"āŒ Get task metrics error: {e}") - return JSONResponse( - status_code=500, - content=create_error_response("METRICS_ERROR", str(e)) - ) - # ============================ # Include Router in App # ============================ @@ -6515,6 +415,7 @@ if __name__ == "__main__": ssl_keyfile=defines.key_path, ssl_certfile=defines.cert_path, reload=True, + reload_excludes=["src/cli/**"], ) else: logger.info(f"Starting web server at http://{host}:{port}") diff --git a/src/backend/models.py b/src/backend/models.py index ad11061..c669a72 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -1,6 +1,6 @@ from typing import List, Dict, Optional, Any, Union, Literal, TypeVar, Generic, Annotated -from pydantic import BaseModel, Field, EmailStr, HttpUrl, model_validator, field_validator # type: ignore -from pydantic.types import constr, conint # type: ignore +from pydantic import BaseModel, Field, EmailStr, HttpUrl, model_validator, field_validator, ConfigDict +from pydantic.types import constr, conint from datetime import datetime, date, UTC from enum import Enum import uuid @@ -91,12 +91,10 @@ class SkillStrength(str, Enum): NONE = "none" class EvidenceDetail(BaseModel): - source: str = Field(..., alias="source", description="The source of the evidence (e.g., resume section, position, project)") - quote: str = Field(..., alias="quote", description="Exact text from the resume or other source showing evidence") - context: str = Field(..., alias="context", description="Brief explanation of how this demonstrates the skill") - model_config = { - "populate_by_name": True, # Allow both field names and aliases - } + source: str = Field(..., alias=str("source"), description="The source of the evidence (e.g., resume section, position, project)") + quote: str = Field(..., alias=str("quote"), description="Exact text from the resume or other source showing evidence") + context: str = Field(..., alias=str("context"), description="Brief explanation of how this demonstrates the skill") + model_config = ConfigDict(populate_by_name=True) class ChromaDBGetResponse(BaseModel): # Chroma fields @@ -110,25 +108,23 @@ class ChromaDBGetResponse(BaseModel): size: int = 0 dimensions: int = 2 | 3 query: str = "" - query_embedding: Optional[List[float]] = Field(default=None, alias="queryEmbedding") - umap_embedding_2d: Optional[List[float]] = Field(default=None, alias="umapEmbedding2D") - umap_embedding_3d: Optional[List[float]] = Field(default=None, alias="umapEmbedding3D") + query_embedding: Optional[List[float]] = Field(default=None, alias=str("queryEmbedding")) + umap_embedding_2d: Optional[List[float]] = Field(default=None, alias=str("umapEmbedding2D")) + umap_embedding_3d: Optional[List[float]] = Field(default=None, alias=str("umapEmbedding3D")) class SkillAssessment(BaseModel): - candidate_id: str = Field(..., alias='candidateId') - skill: str = Field(..., alias="skill", description="The skill being assessed") - skill_modified: Optional[str] = Field(default="", alias="skillModified", description="The skill rephrased by LLM during skill match") - evidence_found: bool = Field(..., alias="evidenceFound", description="Whether evidence was found for the skill") - evidence_strength: SkillStrength = Field(..., alias="evidenceStrength", description="Strength of evidence found for the skill") - assessment: str = Field(..., alias="assessment", description="Short (one to two sentence) assessment of the candidate's proficiency with the skill") - description: str = Field(..., alias="description", description="Short (two to three sentence) description of what the skill is, independent of whether the candidate has that skill or not") - evidence_details: List[EvidenceDetail] = Field(default_factory=list, alias="evidenceDetails", description="List of evidence details supporting the skill assessment") - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias='createdAt') - updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias='updatedAt') - rag_results: List[ChromaDBGetResponse] = Field(default_factory=list, alias="ragResults") - model_config = { - "populate_by_name": True, # Allow both field names and aliases - } + candidate_id: str = Field(..., alias=str('candidateId')) + skill: str = Field(..., alias=str("skill"), description="The skill being assessed") + skill_modified: Optional[str] = Field(default="", alias=str("skillModified"), description="The skill rephrased by LLM during skill match") + evidence_found: bool = Field(..., alias=str("evidenceFound"), description="Whether evidence was found for the skill") + evidence_strength: SkillStrength = Field(..., alias=str("evidenceStrength"), description="Strength of evidence found for the skill") + assessment: str = Field(..., alias=str("assessment"), description="Short (one to two sentence) assessment of the candidate's proficiency with the skill") + description: str = Field(..., alias=str("description"), description="Short (two to three sentence) description of what the skill is, independent of whether the candidate has that skill or not") + evidence_details: List[EvidenceDetail] = Field(default_factory=list, alias=str("evidenceDetails"), description="List of evidence details supporting the skill assessment") + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("createdAt")) + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("updatedAt")) + rag_results: List[ChromaDBGetResponse] = Field(default_factory=list, alias=str("ragResults")) + model_config = ConfigDict(populate_by_name=True) class ApiMessageType(str, Enum): BINARY = "binary" @@ -274,38 +270,30 @@ class EmailVerificationRequest(BaseModel): class MFARequest(BaseModel): username: str password: str - device_id: str = Field(..., alias="deviceId") - device_name: str = Field(..., alias="deviceName") - email: str = Field(..., alias="email") - model_config = { - "populate_by_name": True, # Allow both field names and aliases - } + device_id: str = Field(..., alias=str("deviceId")) + device_name: str = Field(..., alias=str("deviceName")) + email: str = Field(..., alias=str("email")) + model_config = ConfigDict(populate_by_name=True) class MFAVerifyRequest(BaseModel): email: EmailStr code: str - device_id: str = Field(..., alias="deviceId") - remember_device: bool = Field(False, alias="rememberDevice") - model_config = { - "populate_by_name": True, # Allow both field names and aliases - } + device_id: str = Field(..., alias=str("deviceId")) + remember_device: bool = Field(False, alias=str("rememberDevice")) + model_config = ConfigDict(populate_by_name=True) class MFAData(BaseModel): message: str - device_id: str = Field(..., alias="deviceId") - device_name: str = Field(..., alias="deviceName") - code_sent: str = Field(..., alias="codeSent") + device_id: str = Field(..., alias=str("deviceId")) + device_name: str = Field(..., alias=str("deviceName")) + code_sent: str = Field(..., alias=str("codeSent")) email: str - model_config = { - "populate_by_name": True, # Allow both field names and aliases - } + model_config = ConfigDict(populate_by_name=True) class MFARequestResponse(BaseModel): - mfa_required: bool = Field(..., alias="mfaRequired") - mfa_data: Optional[MFAData] = Field(None, alias="mfaData") - model_config = { - "populate_by_name": True, # Allow both field names and aliases - } + mfa_required: bool = Field(..., alias=str("mfaRequired")) + mfa_data: Optional[MFAData] = Field(None, alias=str("mfaData")) + model_config = ConfigDict(populate_by_name=True) class ResendVerificationRequest(BaseModel): email: EmailStr @@ -315,9 +303,9 @@ class ResendVerificationRequest(BaseModel): # ============================ class Tunables(BaseModel): - enable_rag: bool = Field(True, alias="enableRAG") - enable_tools: bool = Field(True, alias="enableTools") - enable_context: bool = Field(True, alias="enableContext") + enable_rag: bool = Field(default=True, alias=str("enableRAG")) + enable_tools: bool = Field(default=True, alias=str("enableTools")) + enable_context: bool = Field(default=True, alias=str("enableContext")) class CandidateQuestion(BaseModel): question: str @@ -327,11 +315,11 @@ class Location(BaseModel): city: str state: Optional[str] = None country: str - postal_code: Optional[str] = Field(None, alias="postalCode") + postal_code: Optional[str] = Field(None, alias=str("postalCode")) latitude: Optional[float] = None longitude: Optional[float] = None remote: Optional[bool] = None - hybrid_options: Optional[List[str]] = Field(None, alias="hybridOptions") + hybrid_options: Optional[List[str]] = Field(None, alias=str("hybridOptions")) address: Optional[str] = None class Skill(BaseModel): @@ -339,15 +327,15 @@ class Skill(BaseModel): name: str category: str level: SkillLevel - years_of_experience: Optional[int] = Field(None, alias="yearsOfExperience") + years_of_experience: Optional[int] = Field(None, alias=str("yearsOfExperience")) class WorkExperience(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())) - company_name: str = Field(..., alias="companyName") + company_name: str = Field(..., alias=str("companyName")) position: str - start_date: datetime = Field(..., alias="startDate") - end_date: Optional[datetime] = Field(None, alias="endDate") - is_current: bool = Field(..., alias="isCurrent") + start_date: datetime = Field(..., alias=str("startDate")) + end_date: Optional[datetime] = Field(None, alias=str("endDate")) + is_current: bool = Field(..., alias=str("isCurrent")) description: str skills: List[str] location: Location @@ -357,10 +345,10 @@ class Education(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())) institution: str degree: str - field_of_study: str = Field(..., alias="fieldOfStudy") - start_date: datetime = Field(..., alias="startDate") - end_date: Optional[datetime] = Field(None, alias="endDate") - is_current: bool = Field(..., alias="isCurrent") + field_of_study: str = Field(..., alias=str("fieldOfStudy")) + start_date: datetime = Field(..., alias=str("startDate")) + end_date: Optional[datetime] = Field(None, alias=str("endDate")) + is_current: bool = Field(..., alias=str("isCurrent")) gpa: Optional[float] = None achievements: Optional[List[str]] = None location: Optional[Location] = None @@ -372,11 +360,11 @@ class Language(BaseModel): class Certification(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())) name: str - issuing_organization: str = Field(..., alias="issuingOrganization") - issue_date: datetime = Field(..., alias="issueDate") - expiration_date: Optional[datetime] = Field(None, alias="expirationDate") - credential_id: Optional[str] = Field(None, alias="credentialId") - credential_url: Optional[HttpUrl] = Field(None, alias="credentialUrl") + issuing_organization: str = Field(..., alias=str("issuingOrganization")) + issue_date: datetime = Field(..., alias=str("issueDate")) + expiration_date: Optional[datetime] = Field(None, alias=str("expirationDate")) + credential_id: Optional[str] = Field(None, alias=str("credentialId")) + credential_url: Optional[HttpUrl] = Field(None, alias=str("credentialUrl")) class SocialLink(BaseModel): platform: SocialPlatform @@ -392,7 +380,7 @@ class SalaryRange(BaseModel): max: float currency: str period: SalaryPeriod - is_visible: bool = Field(..., alias="isVisible") + is_visible: bool = Field(..., alias=str("isVisible")) class PointOfContact(BaseModel): name: str @@ -402,32 +390,32 @@ class PointOfContact(BaseModel): class RefreshToken(BaseModel): token: str - expires_at: datetime = Field(..., alias="expiresAt") + expires_at: datetime = Field(..., alias=str("expiresAt")) device: str - ip_address: str = Field(..., alias="ipAddress") - is_revoked: bool = Field(..., alias="isRevoked") - revoked_reason: Optional[str] = Field(None, alias="revokedReason") + ip_address: str = Field(..., alias=str("ipAddress")) + is_revoked: bool = Field(..., alias=str("isRevoked")) + revoked_reason: Optional[str] = Field(None, alias=str("revokedReason")) class Attachment(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())) - file_name: str = Field(..., alias="fileName") - file_type: str = Field(..., alias="fileType") - file_size: int = Field(..., alias="fileSize") - file_url: str = Field(..., alias="fileUrl") - uploaded_at: datetime = Field(..., alias="uploadedAt") - is_processed: bool = Field(..., alias="isProcessed") - processing_result: Optional[Any] = Field(None, alias="processingResult") - thumbnail_url: Optional[str] = Field(None, alias="thumbnailUrl") + file_name: str = Field(..., alias=str("fileName")) + file_type: str = Field(..., alias=str("fileType")) + file_size: int = Field(..., alias=str("fileSize")) + file_url: str = Field(..., alias=str("fileUrl")) + uploaded_at: datetime = Field(..., alias=str("uploadedAt")) + is_processed: bool = Field(..., alias=str("isProcessed")) + processing_result: Optional[Any] = Field(None, alias=str("processingResult")) + thumbnail_url: Optional[str] = Field(None, alias=str("thumbnailUrl")) class MessageReaction(BaseModel): - user_id: str = Field(..., alias="userId") + user_id: str = Field(..., alias=str("userId")) reaction: str timestamp: datetime class EditHistory(BaseModel): content: str - edited_at: datetime = Field(..., alias="editedAt") - edited_by: str = Field(..., alias="editedBy") + edited_at: datetime = Field(..., alias=str("editedAt")) + edited_by: str = Field(..., alias=str("editedBy")) class CustomQuestion(BaseModel): question: str @@ -446,30 +434,30 @@ class ApplicationDecision(BaseModel): class NotificationPreference(BaseModel): type: NotificationType events: List[str] - is_enabled: bool = Field(..., alias="isEnabled") + is_enabled: bool = Field(..., alias=str("isEnabled")) class AccessibilitySettings(BaseModel): - font_size: FontSize = Field(..., alias="fontSize") - high_contrast: bool = Field(..., alias="highContrast") - reduce_motion: bool = Field(..., alias="reduceMotion") - screen_reader: bool = Field(..., alias="screenReader") - color_blind_mode: Optional[ColorBlindMode] = Field(None, alias="colorBlindMode") + font_size: FontSize = Field(..., alias=str("fontSize")) + high_contrast: bool = Field(..., alias=str("highContrast")) + reduce_motion: bool = Field(..., alias=str("reduceMotion")) + screen_reader: bool = Field(..., alias=str("screenReader")) + color_blind_mode: Optional[ColorBlindMode] = Field(None, alias=str("colorBlindMode")) class ProcessingStep(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())) type: ProcessingStepType parameters: Dict[str, Any] order: int - depends_on: Optional[List[str]] = Field(None, alias="dependsOn") + depends_on: Optional[List[str]] = Field(None, alias=str("dependsOn")) class RetrievalParameters(BaseModel): - search_type: SearchType = Field(..., alias="searchType") - top_k: int = Field(..., alias="topK") - similarity_threshold: Optional[float] = Field(None, alias="similarityThreshold") - reranker_model: Optional[str] = Field(None, alias="rerankerModel") - use_keyword_boost: bool = Field(..., alias="useKeywordBoost") - filter_options: Optional[Dict[str, Any]] = Field(None, alias="filterOptions") - context_window: int = Field(..., alias="contextWindow") + search_type: SearchType = Field(..., alias=str("searchType")) + top_k: int = Field(..., alias=str("topK")) + similarity_threshold: Optional[float] = Field(None, alias=str("similarityThreshold")) + reranker_model: Optional[str] = Field(None, alias=str("rerankerModel")) + use_keyword_boost: bool = Field(..., alias=str("useKeywordBoost")) + filter_options: Optional[Dict[str, Any]] = Field(None, alias=str("filterOptions")) + context_window: int = Field(..., alias=str("contextWindow")) class ErrorDetail(BaseModel): code: str @@ -482,33 +470,27 @@ class ErrorDetail(BaseModel): # Generic base user with user_type for API responses class BaseUserWithType(BaseModel): - user_type: UserType = Field(..., alias="userType") + user_type: UserType = Field(..., alias=str("userType")) id: str = Field(default_factory=lambda: str(uuid.uuid4())) - last_activity: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="lastActivity") - model_config = { - "populate_by_name": True, # Allow both field names and aliases - "use_enum_values": True # Use enum values instead of names - } + last_activity: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("lastActivity")) + model_config = ConfigDict(populate_by_name=True, use_enum_values=True) # Base user model without user_type field class BaseUser(BaseUserWithType): email: EmailStr - first_name: str = Field(..., alias="firstName") - last_name: str = Field(..., alias="lastName") - full_name: str = Field(..., alias="fullName") + first_name: str = Field(..., alias=str("firstName")) + last_name: str = Field(..., alias=str("lastName")) + full_name: str = Field(..., alias=str("fullName")) phone: Optional[str] = None location: Optional[Location] = None - 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") + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("createdAt")) + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("updatedAt")) + last_login: Optional[datetime] = Field(None, alias=str("lastLogin")) + profile_image: Optional[str] = Field(None, alias=str("profileImage")) status: UserStatus - is_admin: bool = Field(default=False, alias="isAdmin") + is_admin: bool = Field(default=False, alias=str("isAdmin")) - model_config = { - "populate_by_name": True, # Allow both field names and aliases - "use_enum_values": True # Use enum values instead of names - } + model_config = ConfigDict(populate_by_name=True, use_enum_values=True) class RagEntry(BaseModel): @@ -517,16 +499,14 @@ class RagEntry(BaseModel): enabled: bool = True class RagContentMetadata(BaseModel): - source_file: str = Field(..., alias="sourceFile") - line_begin: int = Field(..., alias="lineBegin") - line_end: int = Field(..., alias="lineEnd") + source_file: str = Field(..., alias=str("sourceFile")) + line_begin: int = Field(..., alias=str("lineBegin")) + line_end: int = Field(..., alias=str("lineEnd")) lines: int - chunk_begin: Optional[int] = Field(None, alias="chunkBegin") - chunk_end: Optional[int] = Field(None, alias="chunkEnd") + chunk_begin: Optional[int] = Field(None, alias=str("chunkBegin")) + chunk_end: Optional[int] = Field(None, alias=str("chunkEnd")) metadata: Dict[str, Any] = Field(default_factory=dict) - model_config = { - "populate_by_name": True, # Allow both field names and aliases - } + model_config = ConfigDict(populate_by_name=True) class RagContentResponse(BaseModel): id: str @@ -541,53 +521,47 @@ class DocumentType(str, Enum): IMAGE = "image" class DocumentOptions(BaseModel): - include_in_rag: bool = Field(default=True, alias="includeInRag") - is_job_document: Optional[bool] = Field(default=False, alias="isJobDocument") - overwrite: Optional[bool] = Field(default=False, alias="overwrite") - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + include_in_rag: bool = Field(default=True, alias=str("includeInRag")) + is_job_document: Optional[bool] = Field(default=False, alias=str("isJobDocument")) + overwrite: Optional[bool] = Field(default=False, alias=str("overwrite")) + model_config = ConfigDict(populate_by_name=True) + +class RAGDocumentRequest(BaseModel): + """Request model for RAG document content""" + id: str class Document(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())) - owner_id: str = Field(..., alias="ownerId") + owner_id: str = Field(..., alias=str("ownerId")) filename: str - originalName: str + original_name: str = Field(..., alias=str("originalName")) type: DocumentType size: int - upload_date: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="uploadDate") - options: DocumentOptions = Field(default_factory=lambda: DocumentOptions(), alias="options") - rag_chunks: Optional[int] = Field(default=0, alias="ragChunks") - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + upload_date: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("uploadDate")) + options: DocumentOptions = Field(default_factory=lambda: DocumentOptions(), alias=str("options")) + rag_chunks: Optional[int] = Field(default=0, alias=str("ragChunks")) + model_config = ConfigDict(populate_by_name=True) class DocumentContentResponse(BaseModel): - document_id: str = Field(..., alias="documentId") + document_id: str = Field(..., alias=str("documentId")) filename: str type: DocumentType content: str size: int - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + model_config = ConfigDict(populate_by_name=True) class DocumentListResponse(BaseModel): documents: List[Document] total: int - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + model_config = ConfigDict(populate_by_name=True) class DocumentUpdateRequest(BaseModel): filename: Optional[str] = None options: Optional[DocumentOptions] = None - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + model_config = ConfigDict(populate_by_name=True) class Candidate(BaseUser): - user_type: UserType = Field(UserType.CANDIDATE, alias="userType") + user_type: UserType = Field(UserType.CANDIDATE, alias=str("userType")) username: str description: Optional[str] = None resume: Optional[str] = None @@ -595,88 +569,77 @@ class Candidate(BaseUser): experience: Optional[List[WorkExperience]] = None questions: Optional[List[CandidateQuestion]] = None education: Optional[List[Education]] = None - preferred_job_types: Optional[List[EmploymentType]] = Field(None, alias="preferredJobTypes") - desired_salary: Optional[DesiredSalary] = Field(None, alias="desiredSalary") - availability_date: Optional[datetime] = Field(None, alias="availabilityDate") + preferred_job_types: Optional[List[EmploymentType]] = Field(None, alias=str("preferredJobTypes")) + desired_salary: Optional[DesiredSalary] = Field(None, alias=str("desiredSalary")) + availability_date: Optional[datetime] = Field(None, alias=str("availabilityDate")) summary: Optional[str] = None languages: Optional[List[Language]] = None certifications: Optional[List[Certification]] = None - job_applications: Optional[List["JobApplication"]] = Field(None, alias="jobApplications") + job_applications: Optional[List["JobApplication"]] = Field(None, alias=str("jobApplications")) rags: List[RagEntry] = Field(default_factory=list) rag_content_size : int = 0 - is_public: bool = Field(default=True, alias="isPublic") + is_public: bool = Field(default=True, alias=str("isPublic")) class CandidateAI(Candidate): - user_type: UserType = Field(UserType.CANDIDATE, alias="userType") - is_AI: bool = Field(True, alias="isAI") + user_type: UserType = Field(UserType.CANDIDATE, alias=str("userType")) + is_AI: bool = Field(True, alias=str("isAI")) age: Optional[int] = None gender: Optional[UserGender] = None ethnicity: Optional[str] = None class Employer(BaseUser): - user_type: UserType = Field(UserType.EMPLOYER, alias="userType") - company_name: str = Field(..., alias="companyName") + user_type: UserType = Field(UserType.EMPLOYER, alias=str("userType")) + company_name: str = Field(..., alias=str("companyName")) industry: str description: Optional[str] = None - company_size: str = Field(..., alias="companySize") - company_description: str = Field(..., alias="companyDescription") - website_url: Optional[HttpUrl] = Field(None, alias="websiteUrl") + company_size: str = Field(..., alias=str("companySize")) + company_description: str = Field(..., alias=str("companyDescription")) + website_url: Optional[HttpUrl] = Field(None, alias=str("websiteUrl")) jobs: Optional[List["Job"]] = None - company_logo: Optional[str] = Field(None, alias="companyLogo") - social_links: Optional[List[SocialLink]] = Field(None, alias="socialLinks") + company_logo: Optional[str] = Field(None, alias=str("companyLogo")) + social_links: Optional[List[SocialLink]] = Field(None, alias=str("socialLinks")) poc: Optional[PointOfContact] = None class Guest(BaseUser): - user_type: UserType = Field(UserType.GUEST, alias="userType") - session_id: str = Field(..., alias="sessionId") + user_type: UserType = Field(UserType.GUEST, alias=str("userType")) + session_id: str = Field(..., alias=str("sessionId")) 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(default_factory=lambda: datetime.now(UTC), alias="createdAt") - user_agent: Optional[str] = Field(None, alias="userAgent") + converted_to_user_id: Optional[str] = Field(None, alias=str("convertedToUserId")) + ip_address: Optional[str] = Field(None, alias=str("ipAddress")) + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("createdAt")) + user_agent: Optional[str] = Field(None, alias=str("userAgent")) rag_content_size: int = 0 - is_public: bool = Field(default=False, alias="isPublic") - model_config = { - "populate_by_name": True, # Allow both field names and aliases - "use_enum_values": True # Use enum values instead of names - } + is_public: bool = Field(default=False, alias=str("isPublic")) + model_config = ConfigDict(populate_by_name=True, use_enum_values=True) class Authentication(BaseModel): - user_id: str = Field(..., alias="userId") - password_hash: str = Field(..., alias="passwordHash") + user_id: str = Field(..., alias=str("userId")) + password_hash: str = Field(..., alias=str("passwordHash")) salt: str - refresh_tokens: List[RefreshToken] = Field(..., alias="refreshTokens") - reset_password_token: Optional[str] = Field(None, alias="resetPasswordToken") - reset_password_expiry: Optional[datetime] = Field(None, alias="resetPasswordExpiry") - last_password_change: datetime = Field(..., alias="lastPasswordChange") - mfa_enabled: bool = Field(..., alias="mfaEnabled") - mfa_method: Optional[MFAMethod] = Field(None, alias="mfaMethod") - mfa_secret: Optional[str] = Field(None, alias="mfaSecret") - login_attempts: int = Field(..., alias="loginAttempts") - locked_until: Optional[datetime] = Field(None, alias="lockedUntil") - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + refresh_tokens: List[RefreshToken] = Field(..., alias=str("refreshTokens")) + reset_password_token: Optional[str] = Field(None, alias=str("resetPasswordToken")) + reset_password_expiry: Optional[datetime] = Field(None, alias=str("resetPasswordExpiry")) + last_password_change: datetime = Field(..., alias=str("lastPasswordChange")) + mfa_enabled: bool = Field(..., alias=str("mfaEnabled")) + mfa_method: Optional[MFAMethod] = Field(None, alias=str("mfaMethod")) + mfa_secret: Optional[str] = Field(None, alias=str("mfaSecret")) + login_attempts: int = Field(..., alias=str("loginAttempts")) + locked_until: Optional[datetime] = Field(None, alias=str("lockedUntil")) + model_config = ConfigDict(populate_by_name=True) class AuthResponse(BaseModel): - access_token: str = Field(..., alias="accessToken") - refresh_token: str = Field(..., alias="refreshToken") + access_token: str = Field(..., alias=str("accessToken")) + refresh_token: str = Field(..., alias=str("refreshToken")) user: Union[Candidate, Employer, Guest] # Add Guest support - expires_at: int = Field(..., alias="expiresAt") - user_type: Optional[str] = Field(default=UserType.GUEST, alias="userType") # Explicit user type - is_guest: Optional[bool] = Field(default=True, alias="isGuest") # Guest indicator - - model_config = { - "populate_by_name": True - } + expires_at: int = Field(..., alias=str("expiresAt")) + user_type: Optional[str] = Field(default=UserType.GUEST, alias=str("userType")) # Explicit user type + is_guest: Optional[bool] = Field(default=True, alias=str("isGuest")) # Guest indicator + model_config = ConfigDict(populate_by_name=True) class GuestCleanupRequest(BaseModel): """Request to cleanup inactive guests""" - inactive_hours: int = Field(24, alias="inactiveHours") - - model_config = { - "populate_by_name": True - } + inactive_hours: int = Field(24, alias=str("inactiveHours")) + model_config = ConfigDict(populate_by_name=True) class Requirements(BaseModel): required: List[str] = Field(default_factory=list) @@ -689,196 +652,167 @@ class Requirements(BaseModel): return values class JobRequirements(BaseModel): - technical_skills: Requirements = Field(..., alias="technicalSkills") - experience_requirements: Requirements = Field(..., alias="experienceRequirements") - soft_skills: Optional[List[str]] = Field(default_factory=list, alias="softSkills") + technical_skills: Requirements = Field(..., alias=str("technicalSkills")) + experience_requirements: Requirements = Field(..., alias=str("experienceRequirements")) + soft_skills: Optional[List[str]] = Field(default_factory=list, alias=str("softSkills")) experience: Optional[List[str]] = [] education: Optional[List[str]] = [] certifications: Optional[List[str]] = [] - preferred_attributes: Optional[List[str]] = Field(None, alias="preferredAttributes") - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + preferred_attributes: Optional[List[str]] = Field(None, alias=str("preferredAttributes")) + company_values: Optional[List[str]] = Field(None, alias=str("companyValues")) + model_config = ConfigDict(populate_by_name=True) class JobDetails(BaseModel): location: Location - salary_range: Optional[SalaryRange] = Field(None, alias="salaryRange") - employment_type: EmploymentType = Field(..., alias="employmentType") - 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") + salary_range: Optional[SalaryRange] = Field(None, alias=str("salaryRange")) + employment_type: EmploymentType = Field(..., alias=str("employmentType")) + date_posted: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("datePosted")) + application_deadline: Optional[datetime] = Field(None, alias=str("applicationDeadline")) + is_active: bool = Field(..., alias=str("isActive")) applicants: Optional[List["JobApplication"]] = None department: Optional[str] = None - reports_to: Optional[str] = Field(None, alias="reportsTo") + reports_to: Optional[str] = Field(None, alias=str("reportsTo")) benefits: Optional[List[str]] = None - visa_sponsorship: Optional[bool] = Field(None, alias="visaSponsorship") - featured_until: Optional[datetime] = Field(None, alias="featuredUntil") + visa_sponsorship: Optional[bool] = Field(None, alias=str("visaSponsorship")) + featured_until: Optional[datetime] = Field(None, alias=str("featuredUntil")) views: int = 0 - application_count: int = Field(0, alias="applicationCount") + application_count: int = Field(0, alias=str("applicationCount")) class Job(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())) - owner_id: str = Field(..., alias="ownerId") - owner_type: UserType = Field(..., alias="ownerType") + owner_id: str = Field(..., alias=str("ownerId")) + owner_type: UserType = Field(..., alias=str("ownerType")) owner: Optional[BaseUser] = None title: Optional[str] summary: Optional[str] company: Optional[str] description: str requirements: Optional[JobRequirements] - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="createdAt") - updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="updatedAt") - details: Optional[JobDetails] = Field(None, alias="details") - model_config = { - "populate_by_name": True # Allow both field names and aliases - } - + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("createdAt")) + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("updatedAt")) + details: Optional[JobDetails] = None + model_config = ConfigDict(populate_by_name=True) class InterviewFeedback(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())) - interview_id: str = Field(..., alias="interviewId") - reviewer_id: str = Field(..., alias="reviewerId") - technical_score: Annotated[float, Field(ge=0, le=10)] = Field(..., alias="technicalScore") - cultural_score: Annotated[float, Field(ge=0, le=10)] = Field(..., alias="culturalScore") - overall_score: Annotated[float, Field(ge=0, le=10)] = Field(..., alias="overallScore") + interview_id: str = Field(..., alias=str("interviewId")) + reviewer_id: str = Field(..., alias=str("reviewerId")) + technical_score: Annotated[float, Field(ge=0, le=10)] = Field(..., alias=str("technicalScore")) + cultural_score: Annotated[float, Field(ge=0, le=10)] = Field(..., alias=str("culturalScore")) + overall_score: Annotated[float, Field(ge=0, le=10)] = Field(..., alias=str("overallScore")) strengths: List[str] weaknesses: List[str] recommendation: InterviewRecommendation comments: str - 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 = { - "populate_by_name": True # Allow both field names and aliases - } + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("createdAt")) + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("updatedAt")) + is_visible: bool = Field(..., alias=str("isVisible")) + skill_assessments: Optional[List[SkillAssessment]] = Field(None, alias=str("skillAssessments")) + model_config = ConfigDict(populate_by_name=True) class InterviewSchedule(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())) - application_id: str = Field(..., alias="applicationId") - scheduled_date: datetime = Field(..., alias="scheduledDate") - end_date: datetime = Field(..., alias="endDate") - interview_type: InterviewType = Field(..., alias="interviewType") + application_id: str = Field(..., alias=str("applicationId")) + scheduled_date: datetime = Field(..., alias=str("scheduledDate")) + end_date: datetime = Field(..., alias=str("endDate")) + interview_type: InterviewType = Field(..., alias=str("interviewType")) interviewers: List[str] location: Optional[Union[str, Location]] = None notes: Optional[str] = None feedback: Optional[InterviewFeedback] = None status: Literal["scheduled", "completed", "cancelled", "rescheduled"] - meeting_link: Optional[HttpUrl] = Field(None, alias="meetingLink") - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + meeting_link: Optional[HttpUrl] = Field(None, alias=str("meetingLink")) + model_config = ConfigDict(populate_by_name=True) class JobApplication(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())) - job_id: str = Field(..., alias="jobId") - candidate_id: str = Field(..., alias="candidateId") + job_id: str = Field(..., alias=str("jobId")) + candidate_id: str = Field(..., alias=str("candidateId")) status: ApplicationStatus - applied_date: datetime = Field(..., alias="appliedDate") - updated_date: datetime = Field(..., alias="updatedDate") - resume_version: str = Field(..., alias="resumeVersion") - cover_letter: Optional[str] = Field(None, alias="coverLetter") + applied_date: datetime = Field(..., alias=str("appliedDate")) + updated_date: datetime = Field(..., alias=str("updatedDate")) + resume_version: str = Field(..., alias=str("resumeVersion")) + cover_letter: Optional[str] = Field(None, alias=str("coverLetter")) notes: Optional[str] = None - interview_schedules: Optional[List[InterviewSchedule]] = Field(None, alias="interviewSchedules") - custom_questions: Optional[List[CustomQuestion]] = Field(None, alias="customQuestions") - candidate_contact: Optional[CandidateContact] = Field(None, alias="candidateContact") + interview_schedules: Optional[List[InterviewSchedule]] = Field(None, alias=str("interviewSchedules")) + custom_questions: Optional[List[CustomQuestion]] = Field(None, alias=str("customQuestions")) + candidate_contact: Optional[CandidateContact] = Field(None, alias=str("candidateContact")) decision: Optional[ApplicationDecision] = None - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + model_config = ConfigDict(populate_by_name=True) class GuestSessionResponse(BaseModel): """Response for guest session creation""" - access_token: str = Field(..., alias="accessToken") - refresh_token: str = Field(..., alias="refreshToken") + access_token: str = Field(..., alias=str("accessToken")) + refresh_token: str = Field(..., alias=str("refreshToken")) user: Guest - expires_at: int = Field(..., alias="expiresAt") - user_type: Literal["guest"] = Field("guest", alias="userType") - is_guest: bool = Field(True, alias="isGuest") - - model_config = { - "populate_by_name": True - } + expires_at: int = Field(..., alias=str("expiresAt")) + user_type: Literal["guest"] = Field("guest", alias=str("userType")) + is_guest: bool = Field(True, alias=str("isGuest")) + model_config = ConfigDict(populate_by_name=True) class ChatContext(BaseModel): type: ChatContextType - related_entity_id: Optional[str] = Field(None, alias="relatedEntityId") - related_entity_type: Optional[Literal["job", "candidate", "employer"]] = Field(None, alias="relatedEntityType") - additional_context: Optional[Dict[str, Any]] = Field({}, alias="additionalContext") - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + related_entity_id: Optional[str] = Field(None, alias=str("relatedEntityId")) + related_entity_type: Optional[Literal["job", "candidate", "employer"]] = Field(None, alias=str("relatedEntityType")) + additional_context: Optional[Dict[str, Any]] = Field({}, alias=str("additionalContext")) + model_config = ConfigDict(populate_by_name=True) class ChatOptions(BaseModel): seed: Optional[int] = 8911 - num_ctx: Optional[int] = Field(default=None, alias="numCtx") # Number of context tokens + num_ctx: Optional[int] = Field(default=None, alias=str("numCtx")) # Number of context tokens temperature: Optional[float] = Field(default=0.7) # Higher temperature to encourage tool usage - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + model_config = ConfigDict(populate_by_name=True) # Add rate limiting configuration models class RateLimitConfig(BaseModel): """Rate limit configuration""" - requests_per_minute: int = Field(..., alias="requestsPerMinute") - requests_per_hour: int = Field(..., alias="requestsPerHour") - requests_per_day: int = Field(..., alias="requestsPerDay") - burst_limit: int = Field(..., alias="burstLimit") - burst_window_seconds: int = Field(60, alias="burstWindowSeconds") - - model_config = { - "populate_by_name": True - } + requests_per_minute: int = Field(..., alias=str("requestsPerMinute")) + requests_per_hour: int = Field(..., alias=str("requestsPerHour")) + requests_per_day: int = Field(..., alias=str("requestsPerDay")) + burst_limit: int = Field(..., alias=str("burstLimit")) + burst_window_seconds: int = Field(60, alias=str("burstWindowSeconds")) + model_config = ConfigDict(populate_by_name=True) class RateLimitResult(BaseModel): """Result of rate limit check""" allowed: bool reason: Optional[str] = None - retry_after_seconds: Optional[int] = Field(None, alias="retryAfterSeconds") - remaining_requests: Dict[str, int] = Field(default_factory=dict, alias="remainingRequests") - reset_times: Dict[str, datetime] = Field(default_factory=dict, alias="resetTimes") - - model_config = { - "populate_by_name": True - } + retry_after_seconds: Optional[int] = Field(None, alias=str("retryAfterSeconds")) + remaining_requests: Dict[str, int] = Field(default_factory=dict, alias=str("remainingRequests")) + reset_times: Dict[str, datetime] = Field(default_factory=dict, alias=str("resetTimes")) + model_config = ConfigDict(populate_by_name=True) class RateLimitStatus(BaseModel): """Rate limit status for a user""" - user_id: str = Field(..., alias="userId") - user_type: str = Field(..., alias="userType") - is_admin: bool = Field(..., alias="isAdmin") - current_usage: Dict[str, int] = Field(..., alias="currentUsage") - limits: Dict[str, int] = Field(..., alias="limits") - remaining: Dict[str, int] = Field(..., alias="remaining") - reset_times: Dict[str, datetime] = Field(..., alias="resetTimes") + user_id: str = Field(..., alias=str("userId")) + user_type: str = Field(..., alias=str("userType")) + is_admin: bool = Field(..., alias=str("isAdmin")) + current_usage: Dict[str, int] = Field(..., alias=str("currentUsage")) + limits: Dict[str, int] = Field(..., alias=str("limits")) + remaining: Dict[str, int] = Field(..., alias=str("remaining")) + reset_times: Dict[str, datetime] = Field(..., alias=str("resetTimes")) config: RateLimitConfig - - model_config = { - "populate_by_name": True - } + model_config = ConfigDict(populate_by_name=True) # Add guest conversion request models class GuestConversionRequest(BaseModel): """Request to convert guest to permanent user""" - account_type: Literal["candidate", "employer"] = Field(..., alias="accountType") + account_type: Literal["candidate", "employer"] = Field(..., alias=str("accountType")) email: EmailStr username: str password: str - first_name: str = Field(..., alias="firstName") - last_name: str = Field(..., alias="lastName") + first_name: str = Field(..., alias=str("firstName")) + last_name: str = Field(..., alias=str("lastName")) phone: Optional[str] = None # Employer-specific fields (optional) - company_name: Optional[str] = Field(None, alias="companyName") + company_name: Optional[str] = Field(None, alias=str("companyName")) industry: Optional[str] = None - company_size: Optional[str] = Field(None, alias="companySize") - company_description: Optional[str] = Field(None, alias="companyDescription") - website_url: Optional[HttpUrl] = Field(None, alias="websiteUrl") - - model_config = { - "populate_by_name": True - } + company_size: Optional[str] = Field(None, alias=str("companySize")) + company_description: Optional[str] = Field(None, alias=str("companyDescription")) + website_url: Optional[HttpUrl] = Field(None, alias=str("websiteUrl")) + model_config = ConfigDict(populate_by_name=True) @field_validator('username') def validate_username(cls, v): @@ -898,29 +832,24 @@ class GuestConversionRequest(BaseModel): # Add guest statistics response model class GuestStatistics(BaseModel): """Guest usage statistics""" - total_guests: int = Field(..., alias="totalGuests") - active_last_hour: int = Field(..., alias="activeLastHour") - active_last_day: int = Field(..., alias="activeLastDay") - converted_guests: int = Field(..., alias="convertedGuests") - by_ip: Dict[str, int] = Field(..., alias="byIp") - creation_timeline: Dict[str, int] = Field(..., alias="creationTimeline") - - model_config = { - "populate_by_name": True - } + total_guests: int = Field(..., alias=str("totalGuests")) + active_last_hour: int = Field(..., alias=str("activeLastHour")) + active_last_day: int = Field(..., alias=str("activeLastDay")) + converted_guests: int = Field(..., alias=str("convertedGuests")) + by_ip: Dict[str, int] = Field(..., alias=str("byIp")) + creation_timeline: Dict[str, int] = Field(..., alias=str("creationTimeline")) + model_config = ConfigDict(populate_by_name=True) -from llm_proxy import (LLMMessage) +from utils.llm_proxy import (LLMMessage) class ApiMessage(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())) - session_id: str = Field(..., alias="sessionId") - sender_id: Optional[str] = Field(None, alias="senderId") + session_id: str = Field(..., alias=str("sessionId")) + sender_id: Optional[str] = Field(default=None, alias=str("senderId")) status: ApiStatusType type: ApiMessageType - timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="timestamp") - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("timestamp")) + model_config = ConfigDict(populate_by_name=True) MOCK_UUID = str(uuid.uuid4()) @@ -941,47 +870,45 @@ class ApiActivityType(str, Enum): HEARTBEAT = "heartbeat" # Used for periodic updates class ChatMessageStatus(ApiMessage): - sender_id: Optional[str] = Field(default=MOCK_UUID, alias="senderId") + sender_id: Optional[str] = Field(default=MOCK_UUID, alias=str("senderId")) status: ApiStatusType = ApiStatusType.STATUS type: ApiMessageType = ApiMessageType.TEXT activity: ApiActivityType content: Any class ChatMessageError(ApiMessage): - sender_id: Optional[str] = Field(default=MOCK_UUID, alias="senderId") + sender_id: Optional[str] = Field(default=MOCK_UUID, alias=str("senderId")) status: ApiStatusType = ApiStatusType.ERROR type: ApiMessageType = ApiMessageType.TEXT content: str class ChatMessageRagSearch(ApiMessage): - type: ApiMessageType = ApiMessageType.JSON + type: ApiMessageType = Field(default=ApiMessageType.JSON) dimensions: int = 2 | 3 content: List[ChromaDBGetResponse] = [] class JobRequirementsMessage(ApiMessage): type: ApiMessageType = ApiMessageType.JSON - job: Job = Field(..., alias="job") + job: Job = Field(..., alias=str("job")) class DocumentMessage(ApiMessage): type: ApiMessageType = ApiMessageType.JSON - sender_id: Optional[str] = Field(default=MOCK_UUID, alias="senderId") - document: Document = Field(..., alias="document") + sender_id: Optional[str] = Field(default=MOCK_UUID, alias=str("senderId")) + document: Document = Field(..., alias=str("document")) content: Optional[str] = "" - converted: bool = Field(False, alias="converted") - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + converted: bool = Field(False, alias=str("converted")) + model_config = ConfigDict(populate_by_name=True) class ChatMessageMetaData(BaseModel): model: AIModelType = AIModelType.QWEN2_5 temperature: float = 0.7 - max_tokens: int = Field(default=8092, alias="maxTokens") - top_p: float = Field(default=1, alias="topP") - frequency_penalty: float = Field(default=0, alias="frequencyPenalty") - presence_penalty: float = Field(default=0, alias="presencePenalty") - stop_sequences: List[str] = Field(default=[], alias="stopSequences") - rag_results: List[ChromaDBGetResponse] = Field(default_factory=list, alias="ragResults") - llm_history: List[LLMMessage] = Field(default_factory=list, alias="llmHistory") + max_tokens: int = Field(default=8092, alias=str("maxTokens")) + top_p: float = Field(default=1, alias=str("topP")) + frequency_penalty: float = Field(default=0, alias=str("frequencyPenalty")) + presence_penalty: float = Field(default=0, alias=str("presencePenalty")) + stop_sequences: List[str] = Field(default=[], alias=str("stopSequences")) + rag_results: List[ChromaDBGetResponse] = Field(default_factory=list, alias=str("ragResults")) + llm_history: List[LLMMessage] = Field(default_factory=list, alias=str("llmHistory")) eval_count: int = 0 eval_duration: int = 0 prompt_eval_count: int = 0 @@ -989,9 +916,7 @@ class ChatMessageMetaData(BaseModel): options: Optional[ChatOptions] = None tools: Dict[str, Any] = Field(default_factory=dict) timers: Dict[str, float] = Field(default_factory=dict) - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + model_config = ConfigDict(populate_by_name=True) class ChatMessageUser(ApiMessage): type: ApiMessageType = ApiMessageType.TEXT @@ -1005,43 +930,37 @@ class ChatMessage(ChatMessageUser): metadata: ChatMessageMetaData = Field(default=ChatMessageMetaData()) #attachments: Optional[List[Attachment]] = None #reactions: Optional[List[MessageReaction]] = None - #is_edited: bool = Field(False, alias="isEdited") - #edit_history: Optional[List[EditHistory]] = Field(None, alias="editHistory") + #is_edited: bool = Field(False, alias=str("isEdited")) + #edit_history: Optional[List[EditHistory]] = Field(None, alias=str("editHistory")) class ChatMessageSkillAssessment(ChatMessageUser): role: ChatSenderType = ChatSenderType.ASSISTANT metadata: ChatMessageMetaData = Field(default=ChatMessageMetaData()) - skill_assessment: SkillAssessment = Field(..., alias="skillAssessment") + skill_assessment: SkillAssessment = Field(..., alias=str("skillAssessment")) class ChatMessageResume(ChatMessageUser): role: ChatSenderType = ChatSenderType.ASSISTANT metadata: ChatMessageMetaData = Field(default=ChatMessageMetaData()) - resume: str = Field(..., alias="resume") - system_prompt: Optional[str] = Field(None, alias="systemPrompt") - prompt: Optional[str] = Field(None, alias="prompt") - model_config = { - "populate_by_name": True, # Allow both field names and aliases - } + resume: str = Field(..., alias=str("resume")) + system_prompt: Optional[str] = Field(None, alias=str("systemPrompt")) + prompt: Optional[str] = Field(None, alias=str("prompt")) + model_config = ConfigDict(populate_by_name=True) class Resume(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())) - job_id: str = Field(..., alias="jobId") - candidate_id: str = Field(..., alias="candidateId") - resume: str = Field(..., alias="resume") - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="createdAt") - updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="updatedAt") + job_id: str = Field(..., alias=str("jobId")) + candidate_id: str = Field(..., alias=str("candidateId")) + resume: str = Field(..., alias=str("resume")) + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("createdAt")) + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("updatedAt")) job: Optional[Job] = None candidate: Optional[Candidate] = None - model_config = { - "populate_by_name": True, # Allow both field names and aliases - } + model_config = ConfigDict(populate_by_name=True) class ResumeMessage(ChatMessageUser): role: ChatSenderType = ChatSenderType.ASSISTANT - resume: Resume = Field(..., alias="resume") - model_config = { - "populate_by_name": True, # Allow both field names and aliases - } + resume: Resume = Field(..., alias=str("resume")) + model_config = ConfigDict(populate_by_name=True) class GPUInfo(BaseModel): name: str @@ -1049,29 +968,26 @@ class GPUInfo(BaseModel): discrete: bool class SystemInfo(BaseModel): - installed_RAM: str = Field(..., alias="installedRAM") - graphics_cards: List[GPUInfo] = Field(..., alias="graphicsCards") + installed_RAM: str = Field(..., alias=str("installedRAM")) + graphics_cards: List[GPUInfo] = Field(..., alias=str("graphicsCards")) CPU: str - llm_model: str = Field(default=defines.model, alias="llmModel") - embedding_model: str = Field(default=defines.embedding_model, alias="embeddingModel") - max_context_length: int = Field(default=defines.max_context, alias="maxContextLength") - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + llm_model: str = Field(default=defines.model, alias=str("llmModel")) + embedding_model: str = Field(default=defines.embedding_model, alias=str("embeddingModel")) + max_context_length: int = Field(default=defines.max_context, alias=str("maxContextLength")) + model_config = ConfigDict(populate_by_name=True) class ChatSession(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())) - user_id: Optional[str] = Field(None, alias="userId") - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="createdAt") - last_activity: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="lastActivity") + user_id: Optional[str] = Field(None, alias=str("userId")) + guest_id: Optional[str] = Field(None, alias=str("guestId")) + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("createdAt")) + last_activity: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("lastActivity")) title: Optional[str] = None context: ChatContext # messages: Optional[List[ChatMessage]] = None - is_archived: bool = Field(False, alias="isArchived") - system_prompt: Optional[str] = Field(None, alias="systemPrompt") - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + is_archived: bool = Field(False, alias=str("isArchived")) + system_prompt: Optional[str] = Field(None, alias=str("systemPrompt")) + model_config = ConfigDict(populate_by_name=True) @model_validator(mode="after") def check_user_or_guest(self) -> "ChatSession": @@ -1081,50 +997,44 @@ class ChatSession(BaseModel): class DataSourceConfiguration(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())) - rag_config_id: str = Field(..., alias="ragConfigId") + rag_config_id: str = Field(..., alias=str("ragConfigId")) name: str - source_type: DataSourceType = Field(..., alias="sourceType") - connection_details: Dict[str, Any] = Field(..., alias="connectionDetails") - processing_pipeline: List[ProcessingStep] = Field(..., alias="processingPipeline") - refresh_schedule: Optional[str] = Field(None, alias="refreshSchedule") - last_refreshed: Optional[datetime] = Field(None, alias="lastRefreshed") + source_type: DataSourceType = Field(..., alias=str("sourceType")) + connection_details: Dict[str, Any] = Field(..., alias=str("connectionDetails")) + processing_pipeline: List[ProcessingStep] = Field(..., alias=str("processingPipeline")) + refresh_schedule: Optional[str] = Field(None, alias=str("refreshSchedule")) + last_refreshed: Optional[datetime] = Field(None, alias=str("lastRefreshed")) status: Literal["active", "pending", "error", "processing"] - error_details: Optional[str] = Field(None, alias="errorDetails") + error_details: Optional[str] = Field(None, alias=str("errorDetails")) metadata: Optional[Dict[str, Any]] = None - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + model_config = ConfigDict(populate_by_name=True) class RAGConfiguration(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())) - user_id: str = Field(..., alias="userId") + user_id: str = Field(..., alias=str("userId")) name: str description: Optional[str] = None - data_source_configurations: List[DataSourceConfiguration] = Field(..., alias="dataSourceConfigurations") - embedding_model: str = Field(..., alias="embeddingModel") - vector_store_type: VectorStoreType = Field(..., alias="vectorStoreType") - retrieval_parameters: RetrievalParameters = Field(..., alias="retrievalParameters") - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="createdAt") - updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="updatedAt") + data_source_configurations: List[DataSourceConfiguration] = Field(..., alias=str("dataSourceConfigurations")) + embedding_model: str = Field(..., alias=str("embeddingModel")) + vector_store_type: VectorStoreType = Field(..., alias=str("vectorStoreType")) + retrieval_parameters: RetrievalParameters = Field(..., alias=str("retrievalParameters")) + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("createdAt")) + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("updatedAt")) version: int - is_active: bool = Field(..., alias="isActive") - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + is_active: bool = Field(..., alias=str("isActive")) + model_config = ConfigDict(populate_by_name=True) class UserActivity(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())) - user_id: Optional[str] = Field(None, alias="userId") - guest_id: Optional[str] = Field(None, alias="guestId") - activity_type: ActivityType = Field(..., alias="activityType") + user_id: Optional[str] = Field(None, alias=str("userId")) + guest_id: Optional[str] = Field(None, alias=str("guestId")) + activity_type: ActivityType = Field(..., alias=str("activityType")) timestamp: datetime metadata: Dict[str, Any] - ip_address: Optional[str] = Field(None, alias="ipAddress") - user_agent: Optional[str] = Field(None, alias="userAgent") - session_id: Optional[str] = Field(None, alias="sessionId") - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + ip_address: Optional[str] = Field(None, alias=str("ipAddress")) + user_agent: Optional[str] = Field(None, alias=str("userAgent")) + session_id: Optional[str] = Field(None, alias=str("sessionId")) + model_config = ConfigDict(populate_by_name=True) @model_validator(mode="after") def check_user_or_guest(self): @@ -1134,29 +1044,25 @@ class UserActivity(BaseModel): class Analytics(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4())) - entity_type: Literal["job", "candidate", "chat", "system", "employer"] = Field(..., alias="entityType") - entity_id: str = Field(..., alias="entityId") - metric_type: str = Field(..., alias="metricType") + entity_type: Literal["job", "candidate", "chat", "system", "employer"] = Field(..., alias=str("entityType")) + entity_id: str = Field(..., alias=str("entityId")) + metric_type: str = Field(..., alias=str("metricType")) value: float timestamp: datetime dimensions: Optional[Dict[str, Any]] = None segment: Optional[str] = None - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + model_config = ConfigDict(populate_by_name=True) class UserPreference(BaseModel): - user_id: str = Field(..., alias="userId") + user_id: str = Field(..., alias=str("userId")) theme: ThemePreference notifications: List[NotificationPreference] accessibility: AccessibilitySettings - dashboard_layout: Optional[Dict[str, Any]] = Field(None, alias="dashboardLayout") + dashboard_layout: Optional[Dict[str, Any]] = Field(None, alias=str("dashboardLayout")) language: str timezone: str - email_frequency: Literal["immediate", "daily", "weekly", "never"] = Field(..., alias="emailFrequency") - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + email_frequency: Literal["immediate", "daily", "weekly", "never"] = Field(..., alias=str("emailFrequency")) + model_config = ConfigDict(populate_by_name=True) # ============================ # API Request/Response Models @@ -1165,13 +1071,11 @@ class CreateCandidateRequest(BaseModel): email: EmailStr username: str password: str - first_name: str = Field(..., alias="firstName") - last_name: str = Field(..., alias="lastName") + first_name: str = Field(..., alias=str("firstName")) + last_name: str = Field(..., alias=str("lastName")) # Add other required candidate fields as needed phone: Optional[str] = None - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + model_config = ConfigDict(populate_by_name=True) @field_validator('username') def validate_username(cls, v): @@ -1191,16 +1095,14 @@ class CreateEmployerRequest(BaseModel): email: EmailStr username: str password: str - company_name: str = Field(..., alias="companyName") + company_name: str = Field(..., alias=str("companyName")) industry: str - company_size: str = Field(..., alias="companySize") - company_description: str = Field(..., alias="companyDescription") + company_size: str = Field(..., alias=str("companySize")) + company_description: str = Field(..., alias=str("companyDescription")) # Add other required employer fields - website_url: Optional[str] = Field(None, alias="websiteUrl") + website_url: Optional[str] = Field(None, alias=str("websiteUrl")) phone: Optional[str] = None - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + model_config = ConfigDict(populate_by_name=True) @field_validator('username') def validate_username(cls, v): @@ -1218,42 +1120,34 @@ class CreateEmployerRequest(BaseModel): class ChatQuery(BaseModel): prompt: str tunables: Optional[Tunables] = None - agent_options: Optional[Dict[str, Any]] = Field(None, alias="agentOptions") - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + agent_options: Optional[Dict[str, Any]] = Field(None, alias=str("agentOptions")) + model_config = ConfigDict(populate_by_name=True) class PaginatedRequest(BaseModel): page: Annotated[int, Field(ge=1)] = 1 limit: Annotated[int, Field(ge=1, le=100)] = 20 - sort_by: Optional[str] = Field(None, alias="sortBy") - sort_order: Optional[SortOrder] = Field(None, alias="sortOrder") + sort_by: Optional[str] = Field(None, alias=str("sortBy")) + sort_order: Optional[SortOrder] = Field(None, alias=str("sortOrder")) filters: Optional[Dict[str, Any]] = None - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + model_config = ConfigDict(populate_by_name=True) class SearchQuery(BaseModel): query: str filters: Optional[Dict[str, Any]] = None page: Annotated[int, Field(ge=1)] = 1 limit: Annotated[int, Field(ge=1, le=100)] = 20 - sort_by: Optional[str] = Field(None, alias="sortBy") - sort_order: Optional[SortOrder] = Field(None, alias="sortOrder") - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + sort_by: Optional[str] = Field(None, alias=str("sortBy")) + sort_order: Optional[SortOrder] = Field(None, alias=str("sortOrder")) + model_config = ConfigDict(populate_by_name=True) class PaginatedResponse(BaseModel): data: List[Any] # Will be typed specifically when used total: int page: int limit: int - total_pages: int = Field(..., alias="totalPages") - has_more: bool = Field(..., alias="hasMore") - model_config = { - "populate_by_name": True # Allow both field names and aliases - } + total_pages: int = Field(..., alias=str("totalPages")) + has_more: bool = Field(..., alias=str("hasMore")) + model_config = ConfigDict(populate_by_name=True) class ApiResponse(BaseModel): success: bool diff --git a/src/backend/routes/__init__.py b/src/backend/routes/__init__.py new file mode 100644 index 0000000..cc5bf08 --- /dev/null +++ b/src/backend/routes/__init__.py @@ -0,0 +1,26 @@ +""" +Routes package - API route modules +""" + +# Import all route modules for easy access +from . import auth +from . import candidates +from . import resumes +from . import jobs +from . import chat +from . import users +from . import employers +from . import admin +from . import system + +__all__ = [ + "auth", + "candidates", + "resumes", + "jobs", + "chat", + "users", + "employers", + "admin", + "system" +] \ No newline at end of file diff --git a/src/backend/routes/admin.py b/src/backend/routes/admin.py new file mode 100644 index 0000000..71387c6 --- /dev/null +++ b/src/backend/routes/admin.py @@ -0,0 +1,816 @@ +""" +Chat routes +""" +import json +import jwt +import secrets +import uuid +from datetime import datetime, timedelta, timezone, UTC +from typing import (Optional, List, Dict, Any) + +from fastapi import ( + APIRouter, HTTPException, Depends, Body, Request, BackgroundTasks, + FastAPI, HTTPException, Depends, Query, Path, Body, status, + APIRouter, Request, BackgroundTasks, File, UploadFile, Form +) + +from fastapi.responses import JSONResponse + +from utils.rate_limiter import RateLimiter, get_rate_limiter +from database import RedisDatabase +from logger import logger +from utils.dependencies import ( + get_current_admin, get_current_user_or_guest, get_database, background_task_manager +) +from utils.responses import ( + create_paginated_response, create_success_response, create_error_response, +) + + +# Create router for authentication endpoints +router = APIRouter(prefix="/admin", tags=["admin"]) + +@router.post("/tasks/cleanup-guests") +async def manual_guest_cleanup( + inactive_hours: int = Body(24, embed=True), + current_user = Depends(get_current_admin), + admin_user = Depends(get_current_admin) +): + """Manually trigger guest cleanup (admin only)""" + try: + if not background_task_manager: + return JSONResponse( + status_code=500, + content=create_error_response("TASK_MANAGER_NOT_AVAILABLE", "Background task manager not available") + ) + + cleaned_count = await background_task_manager.cleanup_inactive_guests(inactive_hours) + + logger.info(f"🧹 Manual guest cleanup triggered by admin {admin_user.id}: {cleaned_count} guests cleaned") + + return create_success_response({ + "message": f"Guest cleanup completed. Removed {cleaned_count} inactive sessions.", + "cleaned_count": cleaned_count, + "triggered_by": admin_user.id + }) + + except Exception as e: + logger.error(f"āŒ Manual guest cleanup error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("CLEANUP_ERROR", str(e)) + ) + +@router.post("/tasks/cleanup-tokens") +async def manual_token_cleanup( + admin_user = Depends(get_current_admin) +): + """Manually trigger verification token cleanup (admin only)""" + try: + global background_task_manager + + if not background_task_manager: + return JSONResponse( + status_code=500, + content=create_error_response("TASK_MANAGER_NOT_AVAILABLE", "Background task manager not available") + ) + + cleaned_count = await background_task_manager.cleanup_expired_verification_tokens() + + logger.info(f"🧹 Manual token cleanup triggered by admin {admin_user.id}: {cleaned_count} tokens cleaned") + + return create_success_response({ + "message": f"Token cleanup completed. Removed {cleaned_count} expired tokens.", + "cleaned_count": cleaned_count, + "triggered_by": admin_user.id + }) + + except Exception as e: + logger.error(f"āŒ Manual token cleanup error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("CLEANUP_ERROR", str(e)) + ) + +@router.post("/tasks/cleanup-rate-limits") +async def manual_rate_limit_cleanup( + days_old: int = Body(7, embed=True), + admin_user = Depends(get_current_admin) +): + """Manually trigger rate limit data cleanup (admin only)""" + try: + global background_task_manager + + if not background_task_manager: + return JSONResponse( + status_code=500, + content=create_error_response("TASK_MANAGER_NOT_AVAILABLE", "Background task manager not available") + ) + + cleaned_count = await background_task_manager.cleanup_old_rate_limit_data(days_old) + + logger.info(f"🧹 Manual rate limit cleanup triggered by admin {admin_user.id}: {cleaned_count} keys cleaned") + + return create_success_response({ + "message": f"Rate limit cleanup completed. Removed {cleaned_count} old keys.", + "cleaned_count": cleaned_count, + "triggered_by": admin_user.id + }) + + except Exception as e: + logger.error(f"āŒ Manual rate limit cleanup error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("CLEANUP_ERROR", str(e)) + ) + +# ======================================== +# System Health and Maintenance Endpoints +# ======================================== + +@router.get("/system/health") +async def get_system_health( + request: Request, + admin_user = Depends(get_current_admin) +): + """Get comprehensive system health status (admin only)""" + try: + # Database health + database_manager = getattr(request.app.state, 'database_manager', None) + db_health = {"status": "unavailable", "healthy": False} + + if database_manager: + try: + database = database_manager.get_database() + from database import redis_manager + redis_health = await redis_manager.health_check() + db_health = { + "status": redis_health.get("status", "unknown"), + "healthy": redis_health.get("status") == "healthy", + "details": redis_health + } + except Exception as e: + db_health = { + "status": "error", + "healthy": False, + "error": str(e) + } + + # Background task health + background_task_manager = getattr(request.app.state, 'background_task_manager', None) + task_health = {"status": "unavailable", "healthy": False} + + if background_task_manager: + try: + task_status = await background_task_manager.get_task_status() + running_tasks = len([t for t in task_status["tasks"] if t["status"] == "running"]) + failed_tasks = len([t for t in task_status["tasks"] if t["status"] == "failed"]) + + task_health = { + "status": "healthy" if task_status["running"] and failed_tasks == 0 else "degraded", + "healthy": task_status["running"] and failed_tasks == 0, + "running_tasks": running_tasks, + "failed_tasks": failed_tasks, + "total_tasks": task_status["task_count"] + } + except Exception as e: + task_health = { + "status": "error", + "healthy": False, + "error": str(e) + } + + # Overall health + overall_healthy = db_health["healthy"] and task_health["healthy"] + + return create_success_response({ + "timestamp": datetime.now(UTC).isoformat(), + "overall_healthy": overall_healthy, + "components": { + "database": db_health, + "background_tasks": task_health + } + }) + + except Exception as e: + logger.error(f"āŒ Error getting system health: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("HEALTH_CHECK_ERROR", str(e)) + ) + +@router.post("/maintenance/cleanup") +async def run_maintenance_cleanup( + request: Request, + admin_user = Depends(get_current_admin), + database: RedisDatabase = Depends(get_database) +): + """Run comprehensive maintenance cleanup (admin only)""" + try: + cleanup_results = {} + + # Run various cleanup operations + cleanup_operations = [ + ("inactive_guests", lambda: database.cleanup_inactive_guests(72)), # 3 days + ("expired_tokens", lambda: database.cleanup_expired_verification_tokens()), + ("orphaned_job_requirements", lambda: database.cleanup_orphaned_job_requirements()), + ] + + for operation_name, operation_func in cleanup_operations: + try: + result = await operation_func() + cleanup_results[operation_name] = { + "success": True, + "cleaned_count": result, + "message": f"Cleaned {result} items" + } + except Exception as e: + cleanup_results[operation_name] = { + "success": False, + "error": str(e), + "message": f"Failed: {str(e)}" + } + + # Calculate totals + total_cleaned = sum( + result.get("cleaned_count", 0) + for result in cleanup_results.values() + if result.get("success", False) + ) + + successful_operations = len([ + r for r in cleanup_results.values() + if r.get("success", False) + ]) + + return create_success_response({ + "message": f"Maintenance cleanup completed. {total_cleaned} items cleaned across {successful_operations} operations.", + "total_cleaned": total_cleaned, + "successful_operations": successful_operations, + "details": cleanup_results + }) + + except Exception as e: + logger.error(f"āŒ Error in maintenance cleanup: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("CLEANUP_ERROR", str(e)) + ) + +# ======================================== +# Background Task Statistics +# ======================================== + +@router.get("/tasks/stats") +async def get_task_statistics( + request: Request, + admin_user = Depends(get_current_admin), + database: RedisDatabase = Depends(get_database) +): + """Get background task execution statistics (admin only)""" + try: + # Get guest statistics + guest_stats = await database.get_guest_statistics() + + # Get background task manager status + background_task_manager = getattr(request.app.state, 'background_task_manager', None) + task_manager_stats = {} + + if background_task_manager: + task_status = await background_task_manager.get_task_status() + task_manager_stats = { + "running": task_status["running"], + "task_count": task_status["task_count"], + "task_breakdown": {} + } + + # Count tasks by status + for task in task_status["tasks"]: + status = task["status"] + task_manager_stats["task_breakdown"][status] = task_manager_stats["task_breakdown"].get(status, 0) + 1 + + return create_success_response({ + "guest_statistics": guest_stats, + "task_manager": task_manager_stats, + "timestamp": datetime.now(UTC).isoformat() + }) + + except Exception as e: + logger.error(f"āŒ Error getting task statistics: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("STATS_ERROR", str(e)) + ) + +# ======================================== +# Background Task Status Endpoints +# ======================================== + +@router.get("/tasks/status") +async def get_background_task_status( + request: Request, + admin_user = Depends(get_current_admin) +): + """Get background task manager status (admin only)""" + try: + # Get background task manager from app state + background_task_manager = getattr(request.app.state, 'background_task_manager', None) + + if not background_task_manager: + return create_success_response({ + "running": False, + "message": "Background task manager not initialized", + "tasks": [], + "task_count": 0 + }) + + # Get comprehensive task status using the new method + task_status = await background_task_manager.get_task_status() + + # Add additional system info + system_info = { + "uptime_seconds": None, # Could calculate from start time if stored + "last_cleanup": None, # Could track last cleanup time + } + + # Format the response + return create_success_response({ + "running": task_status["running"], + "task_count": task_status["task_count"], + "loop_status": { + "main_loop_id": task_status["main_loop_id"], + "current_loop_id": task_status["current_loop_id"], + "loop_matches": task_status.get("loop_matches", False) + }, + "tasks": task_status["tasks"], + "system_info": system_info + }) + + except Exception as e: + logger.error(f"āŒ Get task status error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("STATUS_ERROR", str(e)) + ) + +@router.post("/tasks/run/{task_name}") +async def run_background_task( + task_name: str, + request: Request, + admin_user = Depends(get_current_admin) +): + """Manually trigger a specific background task (admin only)""" + try: + background_task_manager = getattr(request.app.state, 'background_task_manager', None) + + if not background_task_manager: + return JSONResponse( + status_code=503, + content=create_error_response( + "MANAGER_UNAVAILABLE", + "Background task manager not initialized" + ) + ) + + # List of available tasks + available_tasks = [ + "guest_cleanup", + "token_cleanup", + "guest_stats", + "rate_limit_cleanup", + "orphaned_cleanup" + ] + + if task_name not in available_tasks: + return JSONResponse( + status_code=400, + content=create_error_response( + "INVALID_TASK", + f"Unknown task: {task_name}. Available: {available_tasks}" + ) + ) + + # Run the task + result = await background_task_manager.force_run_task(task_name) + + return create_success_response({ + "task_name": task_name, + "result": result, + "message": f"Task {task_name} completed successfully" + }) + + except ValueError as e: + return JSONResponse( + status_code=400, + content=create_error_response("INVALID_TASK", str(e)) + ) + except Exception as e: + logger.error(f"āŒ Error running task {task_name}: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("TASK_EXECUTION_ERROR", str(e)) + ) + +@router.get("/tasks/list") +async def list_available_tasks( + admin_user = Depends(get_current_admin) +): + """List all available background tasks (admin only)""" + try: + tasks = [ + { + "name": "guest_cleanup", + "description": "Clean up inactive guest sessions", + "interval": "6 hours", + "parameters": ["inactive_hours (default: 48)"] + }, + { + "name": "token_cleanup", + "description": "Clean up expired email verification tokens", + "interval": "12 hours", + "parameters": [] + }, + { + "name": "guest_stats", + "description": "Update guest usage statistics", + "interval": "1 hour", + "parameters": [] + }, + { + "name": "rate_limit_cleanup", + "description": "Clean up old rate limiting data", + "interval": "24 hours", + "parameters": ["days_old (default: 7)"] + }, + { + "name": "orphaned_cleanup", + "description": "Clean up orphaned database records", + "interval": "6 hours", + "parameters": [] + } + ] + + return create_success_response({ + "total_tasks": len(tasks), + "tasks": tasks + }) + + except Exception as e: + logger.error(f"āŒ Error listing tasks: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("LIST_ERROR", str(e)) + ) + +@router.post("/tasks/restart") +async def restart_background_tasks( + request: Request, + admin_user = Depends(get_current_admin) +): + """Restart the background task manager (admin only)""" + try: + database_manager = getattr(request.app.state, 'database_manager', None) + background_task_manager = getattr(request.app.state, 'background_task_manager', None) + + if not database_manager: + return JSONResponse( + status_code=503, + content=create_error_response( + "DATABASE_UNAVAILABLE", + "Database manager not available" + ) + ) + + # Stop existing background tasks + if background_task_manager: + await background_task_manager.stop() + logger.info("šŸ›‘ Stopped existing background task manager") + + # Create and start new background task manager + from background_tasks import BackgroundTaskManager + new_background_task_manager = BackgroundTaskManager(database_manager) + await new_background_task_manager.start() + + # Update app state + request.app.state.background_task_manager = new_background_task_manager + + # Get status of new manager + status = await new_background_task_manager.get_task_status() + + return create_success_response({ + "message": "Background task manager restarted successfully", + "new_status": status + }) + + except Exception as e: + logger.error(f"āŒ Error restarting background tasks: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("RESTART_ERROR", str(e)) + ) + +# ============================ +# Task Monitoring and Metrics +# ============================ + +class TaskMetrics: + """Collect metrics for background tasks""" + + def __init__(self): + self.task_runs = {} + self.task_durations = {} + self.task_errors = {} + + def record_task_run(self, task_name: str, duration: float, success: bool = True): + """Record a task execution""" + if task_name not in self.task_runs: + self.task_runs[task_name] = 0 + self.task_durations[task_name] = [] + self.task_errors[task_name] = 0 + + self.task_runs[task_name] += 1 + self.task_durations[task_name].append(duration) + + if not success: + self.task_errors[task_name] += 1 + + # Keep only last 100 durations to prevent memory growth + if len(self.task_durations[task_name]) > 100: + self.task_durations[task_name] = self.task_durations[task_name][-100:] + + def get_metrics(self) -> dict: + """Get task metrics summary""" + metrics = {} + + for task_name in self.task_runs: + durations = self.task_durations[task_name] + avg_duration = sum(durations) / len(durations) if durations else 0 + + metrics[task_name] = { + "total_runs": self.task_runs[task_name], + "total_errors": self.task_errors[task_name], + "success_rate": (self.task_runs[task_name] - self.task_errors[task_name]) / self.task_runs[task_name] if self.task_runs[task_name] > 0 else 0, + "average_duration": avg_duration, + "last_runs": durations[-10:] if durations else [] + } + + return metrics + +# Global task metrics +task_metrics = TaskMetrics() + +@router.get("/tasks/metrics") +async def get_task_metrics( + admin_user = Depends(get_current_admin) +): + """Get background task metrics (admin only)""" + try: + global task_metrics + metrics = task_metrics.get_metrics() + + return create_success_response({ + "metrics": metrics, + "timestamp": datetime.now(UTC).isoformat() + }) + + except Exception as e: + logger.error(f"āŒ Get task metrics error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("METRICS_ERROR", str(e)) + ) + + +# ============================ +# Admin Endpoints +# ============================ +# @router.get("/verification-stats") +async def get_verification_statistics( + current_user = Depends(get_current_admin), + database: RedisDatabase = Depends(get_database) +): + """Get verification statistics (admin only)""" + try: + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin access required") + + stats = { + "pending_verifications": await database.get_pending_verifications_count(), + "expired_tokens_cleaned": await database.cleanup_expired_verification_tokens() + } + + return create_success_response(stats) + + except Exception as e: + logger.error(f"āŒ Error getting verification stats: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("STATS_ERROR", str(e)) + ) + +@router.post("/cleanup-verifications") +async def cleanup_verification_tokens( + current_user = Depends(get_current_admin), + database: RedisDatabase = Depends(get_database) +): + """Manually trigger cleanup of expired verification tokens (admin only)""" + try: + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin access required") + + cleaned_count = await database.cleanup_expired_verification_tokens() + + logger.info(f"🧹 Manual cleanup completed by admin {current_user.id}: {cleaned_count} tokens cleaned") + + return create_success_response({ + "message": f"Cleanup completed. Removed {cleaned_count} expired verification tokens.", + "cleaned_count": cleaned_count + }) + + except Exception as e: + logger.error(f"āŒ Error in manual cleanup: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("CLEANUP_ERROR", str(e)) + ) + +@router.get("/pending-verifications") +async def get_pending_verifications( + current_user = Depends(get_current_admin), + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=100), + database: RedisDatabase = Depends(get_database) +): + """Get list of pending email verifications (admin only)""" + try: + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin access required") + + pattern = "email_verification:*" + cursor = 0 + pending_verifications = [] + current_time = datetime.now(timezone.utc) + + while True: + cursor, keys = await database.redis.scan(cursor, match=pattern, count=100) + + for key in keys: + token_data = await database.redis.get(key) + if token_data: + verification_info = json.loads(token_data) + if not verification_info.get("verified", False): + expires_at = datetime.fromisoformat(verification_info.get("expires_at", "")) + + pending_verifications.append({ + "email": verification_info.get("email"), + "user_type": verification_info.get("user_type"), + "created_at": verification_info.get("created_at"), + "expires_at": verification_info.get("expires_at"), + "is_expired": current_time > expires_at, + "resend_count": verification_info.get("resend_count", 0) + }) + + if cursor == 0: + break + + # Sort by creation date (newest first) + pending_verifications.sort(key=lambda x: x["created_at"], reverse=True) + + # Apply pagination + total = len(pending_verifications) + start = (page - 1) * limit + end = start + limit + paginated_verifications = pending_verifications[start:end] + + paginated_response = create_paginated_response( + paginated_verifications, + page, limit, total + ) + + return create_success_response(paginated_response) + + except Exception as e: + logger.error(f"āŒ Error getting pending verifications: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("FETCH_ERROR", str(e)) + ) + +@router.get("/rate-limits/info") +async def get_user_rate_limit_status( + current_user = Depends(get_current_user_or_guest), + rate_limiter: RateLimiter = Depends(get_rate_limiter), + database: RedisDatabase = Depends(get_database) +): + """Get rate limit status for a user (admin only)""" + try: + # Get user to determine type + user_data = await database.get_user_by_id(current_user.id) + if not user_data: + return JSONResponse( + status_code=404, + content=create_error_response("USER_NOT_FOUND", "User not found") + ) + + user_type = user_data.get("type", "unknown") + is_admin = False + + if user_type == "candidate": + candidate_data = await database.get_candidate(current_user.id) + if candidate_data: + is_admin = candidate_data.get("is_admin", False) + elif user_type == "employer": + employer_data = await database.get_employer(current_user.id) + if employer_data: + is_admin = employer_data.get("is_admin", False) + + status = await rate_limiter.get_user_rate_limit_status(current_user.id, user_type, is_admin) + + return create_success_response(status) + + except Exception as e: + logger.error(f"āŒ Get rate limit status error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("STATUS_ERROR", str(e)) + ) + +@router.get("/rate-limits/{user_id}") +async def get_anyone_rate_limit_status( + user_id: str = Path(...), + admin_user = Depends(get_current_admin), + rate_limiter: RateLimiter = Depends(get_rate_limiter), + database: RedisDatabase = Depends(get_database) +): + """Get rate limit status for a user (admin only)""" + try: + # Get user to determine type + user_data = await database.get_user_by_id(user_id) + if not user_data: + return JSONResponse( + status_code=404, + content=create_error_response("USER_NOT_FOUND", "User not found") + ) + + user_type = user_data.get("type", "unknown") + is_admin = False + + if user_type == "candidate": + candidate_data = await database.get_candidate(user_id) + if candidate_data: + is_admin = candidate_data.get("is_admin", False) + elif user_type == "employer": + employer_data = await database.get_employer(user_id) + if employer_data: + is_admin = employer_data.get("is_admin", False) + + status = await rate_limiter.get_user_rate_limit_status(user_id, user_type, is_admin) + + return create_success_response(status) + + except Exception as e: + logger.error(f"āŒ Get rate limit status error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("STATUS_ERROR", str(e)) + ) + +@router.post("/rate-limits/{user_id}/reset") +async def reset_user_rate_limits( + user_id: str = Path(...), + admin_user = Depends(get_current_admin), + rate_limiter: RateLimiter = Depends(get_rate_limiter), + database: RedisDatabase = Depends(get_database) +): + """Reset rate limits for a user (admin only)""" + try: + # Get user to determine type + user_data = await database.get_user_by_id(user_id) + if not user_data: + return JSONResponse( + status_code=404, + content=create_error_response("USER_NOT_FOUND", "User not found") + ) + + user_type = user_data.get("type", "unknown") + success = await rate_limiter.reset_user_rate_limits(user_id, user_type) + + if success: + logger.info(f"šŸ”„ Rate limits reset for {user_type} {user_id} by admin {admin_user.id}") + return create_success_response({ + "message": f"Rate limits reset for {user_type} {user_id}", + "resetBy": admin_user.id + }) + else: + return JSONResponse( + status_code=500, + content=create_error_response("RESET_FAILED", "Failed to reset rate limits") + ) + + except Exception as e: + logger.error(f"āŒ Reset rate limits error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("RESET_ERROR", str(e)) + ) + diff --git a/src/backend/routes/auth.py b/src/backend/routes/auth.py new file mode 100644 index 0000000..9424c2e --- /dev/null +++ b/src/backend/routes/auth.py @@ -0,0 +1,1134 @@ +""" +Authentication routes +""" +import json +import jwt +import secrets +import uuid +import os +from datetime import datetime, timedelta, timezone, UTC +from typing import Any, Dict, Optional + +from fastapi import APIRouter, HTTPException, Depends, Body, Request, BackgroundTasks +from fastapi.responses import JSONResponse +from pydantic import BaseModel, EmailStr, ValidationError, field_validator + +from auth_utils import AuthenticationManager, SecurityConfig +import backstory_traceback as backstory_traceback +from utils.rate_limiter import RateLimiter +from database import RedisDatabase, redis_manager +from device_manager import DeviceManager +from email_service import VerificationEmailRateLimiter, email_service +from logger import logger +from models import ( + LoginRequest, CreateCandidateRequest, CreateEmployerRequest, + Candidate, Employer, Guest, AuthResponse, + MFARequest, MFAData, MFAVerifyRequest, + EmailVerificationRequest, ResendVerificationRequest, + MFARequestResponse, MFARequestResponse +) +from utils.dependencies import ( + get_current_admin, get_database, get_current_user, get_current_user_or_guest, + create_access_token, verify_token_with_blacklist +) +from utils.responses import create_success_response, create_error_response +from utils.rate_limiter import get_rate_limiter +from auth_utils import ( + AuthenticationManager, + validate_password_strength, + sanitize_login_input, + SecurityConfig +) + +# Create router for authentication endpoints +router = APIRouter(prefix="/auth", tags=["authentication"]) + +JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "") +if JWT_SECRET_KEY == "": + raise ValueError("JWT_SECRET_KEY environment variable is not set") +ALGORITHM = "HS256" + +# ============================ +# Password Reset Endpoints +# ============================ +class PasswordResetRequest(BaseModel): + email: EmailStr + +class PasswordResetConfirm(BaseModel): + token: str + new_password: str + + @field_validator('new_password') + def validate_password_strength(cls, v): + is_valid, issues = validate_password_strength(v) + if not is_valid: + raise ValueError('; '.join(issues)) + return v + +@router.post("/guest") +async def create_guest_session_enhanced( + request: Request, + database: RedisDatabase = Depends(get_database), + rate_limiter: RateLimiter = Depends(get_rate_limiter) +): + """Create a guest session with enhanced validation and persistence""" + try: + # Apply rate limiting for guest creation + ip_address = request.client.host if request.client else "unknown" + + # Check rate limits for guest session creation + rate_result = await rate_limiter.check_rate_limit( + user_id=ip_address, + user_type="guest_creation", + is_admin=False, + endpoint="/guest" + ) + + if not rate_result.allowed: + logger.warning(f"🚫 Guest creation rate limit exceeded for IP {ip_address}") + return JSONResponse( + status_code=429, + content=create_error_response( + "RATE_LIMITED", + rate_result.reason or "Too many guest sessions created" + ), + headers={"Retry-After": str(rate_result.retry_after_seconds or 300)} + ) + + # Generate unique guest identifier with timestamp for uniqueness + current_time = datetime.now(UTC) + guest_id = str(uuid.uuid4()) + session_id = f"guest_{int(current_time.timestamp())}_{secrets.token_hex(8)}" + guest_username = f"guest-{session_id[-12:]}" + + # Verify username is unique (unlikely but possible collision) + while True: + existing_user = await database.get_user(guest_username) + if existing_user: + # Regenerate if collision + session_id = f"guest_{int(current_time.timestamp())}_{secrets.token_hex(12)}" + guest_username = f"guest-{session_id[-16:]}" + else: + break + + # Create guest user data with comprehensive info + guest_data = { + "id": guest_id, + "session_id": session_id, + "username": guest_username, + "email": f"{guest_username}@guest.backstory.ketrenos.com", + "first_name": "Guest", + "last_name": "User", + "full_name": "Guest User", + "user_type": "guest", + "created_at": current_time.isoformat(), + "updated_at": current_time.isoformat(), + "last_activity": current_time.isoformat(), + "last_login": current_time.isoformat(), + "status": "active", + "is_admin": False, + "ip_address": ip_address, + "user_agent": request.headers.get("user-agent", "Unknown"), + "converted_to_user_id": None, + "browser_session": True, # Mark as browser session + "persistent": True, # Mark as persistent + } + + # Store guest with enhanced persistence + await database.set_guest(guest_id, guest_data) + + # Create user lookup records + user_auth_data = { + "id": guest_id, + "type": "guest", + "email": guest_data["email"], + "username": guest_username, + "session_id": session_id, + "created_at": current_time.isoformat() + } + + await database.set_user(guest_data["email"], user_auth_data) + await database.set_user(guest_username, user_auth_data) + await database.set_user_by_id(guest_id, user_auth_data) + + # Create authentication tokens with longer expiry for guests + access_token = create_access_token( + data={"sub": guest_id, "type": "guest"}, + expires_delta=timedelta(hours=48) # Longer expiry for guests + ) + refresh_token = create_access_token( + data={"sub": guest_id, "type": "refresh_guest"}, + expires_delta=timedelta(days=14) # 2 weeks refresh for guests + ) + + # Verify guest was stored correctly + verification = await database.get_guest(guest_id) + if not verification: + logger.error(f"āŒ Failed to verify guest storage: {guest_id}") + return JSONResponse( + status_code=500, + content=create_error_response("STORAGE_ERROR", "Failed to create guest session") + ) + + # Create guest object for response + guest = Guest.model_validate(guest_data) + + # Log successful creation + logger.info(f"šŸ‘¤ Guest session created and verified: {guest_username} (ID: {guest_id}) from IP: {ip_address}") + + # Create auth response + auth_response = { + "accessToken": access_token, + "refreshToken": refresh_token, + "user": guest.model_dump(by_alias=True), + "expiresAt": int((current_time + timedelta(hours=48)).timestamp()), + "userType": "guest", + "isGuest": True + } + + return create_success_response(auth_response) + + except Exception as e: + logger.error(f"āŒ Guest session creation error: {e}") + import traceback + logger.error(traceback.format_exc()) + return JSONResponse( + status_code=500, + content=create_error_response("GUEST_CREATION_FAILED", "Failed to create guest session") + ) + +@router.post("/guest/convert") +async def convert_guest_to_user( + registration_data: Dict[str, Any] = Body(...), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Convert a guest session to a permanent user account""" + try: + # Verify current user is a guest + if current_user.user_type != "guest": + return JSONResponse( + status_code=400, + content=create_error_response("NOT_GUEST", "Only guest users can be converted") + ) + + guest: Guest = current_user + account_type = registration_data.get("accountType", "candidate") + + if account_type == "candidate": + # Validate candidate registration data + try: + candidate_request = CreateCandidateRequest.model_validate(registration_data) + except ValidationError as e: + return JSONResponse( + status_code=400, + content=create_error_response("VALIDATION_ERROR", str(e)) + ) + + # Check if email/username already exists + auth_manager = AuthenticationManager(database) + user_exists, conflict_field = await auth_manager.check_user_exists( + candidate_request.email, + candidate_request.username + ) + + if user_exists: + return JSONResponse( + status_code=409, + content=create_error_response( + "USER_EXISTS", + f"A user with this {conflict_field} already exists" + ) + ) + + # Create candidate + candidate_id = str(uuid.uuid4()) + current_time = datetime.now(timezone.utc) + + candidate_data = { + "id": candidate_id, + "user_type": "candidate", + "email": candidate_request.email, + "username": candidate_request.username, + "first_name": candidate_request.first_name, + "last_name": candidate_request.last_name, + "full_name": f"{candidate_request.first_name} {candidate_request.last_name}", + "phone": candidate_request.phone, + "created_at": current_time.isoformat(), + "updated_at": current_time.isoformat(), + "status": "active", + "is_admin": False, + "converted_from_guest": guest.id + } + + candidate = Candidate.model_validate(candidate_data) + + # Create authentication + await auth_manager.create_user_authentication(candidate_id, candidate_request.password) + + # Store candidate + await database.set_candidate(candidate_id, candidate.model_dump()) + + # Update user lookup records + user_auth_data = { + "id": candidate_id, + "type": "candidate", + "email": candidate.email, + "username": candidate.username + } + + await database.set_user(candidate.email, user_auth_data) + await database.set_user(candidate.username, user_auth_data) + await database.set_user_by_id(candidate_id, user_auth_data) + + # Mark guest as converted + guest_data = guest.model_dump() + guest_data["converted_to_user_id"] = candidate_id + guest_data["updated_at"] = current_time.isoformat() + await database.set_guest(guest.id, guest_data) + + # Create new tokens for the candidate + access_token = create_access_token(data={"sub": candidate_id}) + refresh_token = create_access_token( + data={"sub": candidate_id, "type": "refresh"}, + expires_delta=timedelta(days=SecurityConfig.REFRESH_TOKEN_EXPIRY_DAYS) + ) + + auth_response = AuthResponse( + access_token=access_token, + refresh_token=refresh_token, + user=candidate, + expires_at=int((current_time + timedelta(hours=SecurityConfig.TOKEN_EXPIRY_HOURS)).timestamp()) + ) + + logger.info(f"āœ… Guest {guest.session_id} converted to candidate {candidate.username}") + + return create_success_response({ + "message": "Guest account successfully converted to candidate", + "auth": auth_response.model_dump(by_alias=True), + "conversionType": "candidate" + }) + + else: + return JSONResponse( + status_code=400, + content=create_error_response("INVALID_TYPE", "Only candidate conversion is currently supported") + ) + + except Exception as e: + logger.error(f"āŒ Guest conversion error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("CONVERSION_FAILED", "Failed to convert guest account") + ) + +@router.post("/logout") +async def logout( + access_token: str = Body(..., alias="accessToken"), + refresh_token: str = Body(..., alias="refreshToken"), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Logout endpoint - revokes both access and refresh tokens""" + logger.info(f"šŸ”‘ User {current_user.id} is logging out") + try: + # Verify refresh token + try: + refresh_payload = jwt.decode(refresh_token, JWT_SECRET_KEY, algorithms=[ALGORITHM]) + user_id = refresh_payload.get("sub") + token_type = refresh_payload.get("type") + refresh_exp = refresh_payload.get("exp") + + if not user_id or token_type != "refresh": + return JSONResponse( + status_code=401, + content=create_error_response("INVALID_TOKEN", "Invalid refresh token") + ) + except jwt.PyJWTError as e: + logger.warning(f"āš ļø Invalid refresh token during logout: {e}") + return JSONResponse( + status_code=401, + content=create_error_response("INVALID_TOKEN", "Invalid refresh token") + ) + + # Verify that the refresh token belongs to the current user + if user_id != current_user.id: + return JSONResponse( + status_code=403, + content=create_error_response("FORBIDDEN", "Token does not belong to current user") + ) + + # Get Redis client + redis = redis_manager.get_client() + + # Revoke refresh token (blacklist it until its natural expiration) + refresh_ttl = max(0, refresh_exp - int(datetime.now(UTC).timestamp())) + if refresh_ttl > 0: + await redis.setex( + f"blacklisted_token:{refresh_token}", + refresh_ttl, + json.dumps({ + "user_id": user_id, + "token_type": "refresh", + "revoked_at": datetime.now(UTC).isoformat(), + "reason": "user_logout" + }) + ) + logger.info(f"šŸ”’ Blacklisted refresh token for user {user_id}") + + # If access token is provided, revoke it too + if access_token: + try: + access_payload = jwt.decode(access_token, JWT_SECRET_KEY, algorithms=[ALGORITHM]) + access_user_id = access_payload.get("sub") + access_exp = access_payload.get("exp") + + # Verify access token belongs to same user + if access_user_id == user_id: + access_ttl = max(0, access_exp - int(datetime.now(UTC).timestamp())) + if access_ttl > 0: + await redis.setex( + f"blacklisted_token:{access_token}", + access_ttl, + json.dumps({ + "user_id": user_id, + "token_type": "access", + "revoked_at": datetime.now(UTC).isoformat(), + "reason": "user_logout" + }) + ) + logger.info(f"šŸ”’ Blacklisted access token for user {user_id}") + else: + logger.warning(f"āš ļø Access token user mismatch during logout: {access_user_id} != {user_id}") + except jwt.PyJWTError as e: + logger.warning(f"āš ļø Invalid access token during logout (non-critical): {e}") + # Don't fail logout if access token is invalid + + # Optional: Revoke all tokens for this user (for "logout from all devices") + # Uncomment the following lines if you want to implement this feature: + # + # await redis.setex( + # f"user_tokens_revoked:{user_id}", + # timedelta(days=30).total_seconds(), # Max refresh token lifetime + # datetime.now(UTC).isoformat() + # ) + + logger.info(f"šŸ”‘ User {user_id} logged out successfully") + return create_success_response({ + "message": "Logged out successfully", + "tokensRevoked": { + "refreshToken": True, + "accessToken": bool(access_token) + } + }) + + except Exception as e: + logger.error(f"āŒ Logout error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("LOGOUT_ERROR", str(e)) + ) + +@router.post("/logout-all") +async def logout_all_devices( + current_user = Depends(get_current_admin), + database: RedisDatabase = Depends(get_database) +): + """Logout from all devices by revoking all tokens for the user""" + try: + redis = redis_manager.get_client() + + # Set a timestamp that invalidates all tokens issued before this moment + await redis.setex( + f"user_tokens_revoked:{current_user.id}", + int(timedelta(days=30).total_seconds()), # Max refresh token lifetime + datetime.now(UTC).isoformat() + ) + + logger.info(f"šŸ”’ All tokens revoked for user {current_user.id}") + return create_success_response({ + "message": "Logged out from all devices successfully" + }) + + except Exception as e: + logger.error(f"āŒ Logout all devices error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("LOGOUT_ALL_ERROR", str(e)) + ) + +@router.post("/refresh") +async def refresh_token_endpoint( + refresh_token: str = Body(..., alias="refreshToken"), + database: RedisDatabase = Depends(get_database) +): + """Refresh token endpoint""" + try: + # Verify refresh token + payload = jwt.decode(refresh_token, JWT_SECRET_KEY, algorithms=[ALGORITHM]) + user_id = payload.get("sub") + token_type = payload.get("type") + + if not user_id or token_type != "refresh": + return JSONResponse( + status_code=401, + content=create_error_response("INVALID_TOKEN", "Invalid refresh token") + ) + + # Create new access token + access_token = create_access_token(data={"sub": user_id}) + + # Get user + user = None + candidate_data = await database.get_candidate(user_id) + if candidate_data: + user = Candidate.model_validate(candidate_data) + else: + employer_data = await database.get_employer(user_id) + if employer_data: + user = Employer.model_validate(employer_data) + + if not user: + return JSONResponse( + status_code=404, + content=create_error_response("USER_NOT_FOUND", "User not found") + ) + + auth_response = AuthResponse( + access_token=access_token, + refresh_token=refresh_token, # Keep same refresh token + user=user, + expires_at=int((datetime.now(UTC) + timedelta(hours=24)).timestamp()) + ) + + return create_success_response(auth_response.model_dump(by_alias=True)) + + except jwt.PyJWTError: + return JSONResponse( + status_code=401, + content=create_error_response("INVALID_TOKEN", "Invalid refresh token") + ) + except Exception as e: + logger.error(f"āŒ Token refresh error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("REFRESH_ERROR", str(e)) + ) + +@router.post("/resend-verification") +async def resend_verification_email( + request: ResendVerificationRequest, + background_tasks: BackgroundTasks, + database: RedisDatabase = Depends(get_database) +): + """Resend verification email with comprehensive rate limiting and validation""" + try: + email_lower = request.email.lower().strip() + + # Initialize rate limiter + rate_limiter = VerificationEmailRateLimiter(database) + + # Check rate limiting + can_send, reason = await rate_limiter.can_send_verification_email(email_lower) + if not can_send: + logger.warning(f"āš ļø Verification email rate limit exceeded for {email_lower}: {reason}") + return JSONResponse( + status_code=429, + content=create_error_response("RATE_LIMITED", reason) + ) + + # Clean up expired tokens first + await database.cleanup_expired_verification_tokens() + + # Check if user already exists and is verified + user_data = await database.get_user(email_lower) + if user_data: + # User exists and is verified - don't reveal this for security + logger.info(f"šŸ” Resend verification requested for already verified user: {email_lower}") + await rate_limiter.record_email_sent(email_lower) # Record attempt to prevent abuse + return create_success_response({ + "message": "If your email is in our system and pending verification, a new verification email has been sent." + }) + + # Look for pending verification token + verification_data = await database.find_verification_token_by_email(email_lower) + + if not verification_data: + # No pending verification found - don't reveal this for security + logger.info(f"šŸ” Resend verification requested for non-existent pending verification: {email_lower}") + await rate_limiter.record_email_sent(email_lower) # Record attempt to prevent abuse + return create_success_response({ + "message": "If your email is in our system and pending verification, a new verification email has been sent." + }) + + # Check if verification token has expired + expires_at = datetime.fromisoformat(verification_data["expires_at"]) + current_time = datetime.now(timezone.utc) + + if current_time > expires_at: + # Token expired - clean it up and inform user + await database.redis.delete(f"email_verification:{verification_data['token']}") + logger.info(f"🧹 Cleaned up expired verification token for {email_lower}") + return JSONResponse( + status_code=400, + content=create_error_response( + "TOKEN_EXPIRED", + "Your verification link has expired. Please register again to create a new account." + ) + ) + + # Generate new verification token (invalidate old one) + old_token = verification_data["token"] + new_token = secrets.token_urlsafe(32) + + # Update verification data with new token and reset attempts + verification_data.update({ + "token": new_token, + "expires_at": (current_time + timedelta(hours=24)).isoformat(), + "resent_at": current_time.isoformat(), + "resend_count": verification_data.get("resend_count", 0) + 1 + }) + + # Store new token and remove old one + await database.redis.delete(f"email_verification:{old_token}") + await database.store_email_verification_token( + email_lower, + new_token, + verification_data["user_type"], + verification_data["user_data"] + ) + + # Get user name for email + user_data_container = verification_data["user_data"] + user_type = verification_data["user_type"] + + if user_type == "candidate": + candidate_data = user_data_container["candidate_data"] + user_name = candidate_data.get("fullName", "User") + elif user_type == "employer": + employer_data = user_data_container["employer_data"] + user_name = employer_data.get("companyName", "User") + else: + user_name = "User" + + # Record email attempt + await rate_limiter.record_email_sent(email_lower) + + # Send new verification email in background + background_tasks.add_task( + email_service.send_verification_email, + email_lower, + new_token, + user_name, + user_type + ) + + # Log security event + await database.log_security_event( + verification_data["user_data"].get("candidate_data", {}).get("id") or + verification_data["user_data"].get("employer_data", {}).get("id") or "unknown", + "verification_resend", + { + "email": email_lower, + "user_type": user_type, + "resend_count": verification_data.get("resend_count", 1), + "old_token_invalidated": old_token[:8] + "...", # Log partial token for debugging + "ip_address": "unknown" # You can extract this from request if needed + } + ) + + logger.info(f"āœ… Verification email resent to {email_lower} (attempt #{verification_data.get('resend_count', 1)})") + + return create_success_response({ + "message": "A new verification email has been sent to your email address. Please check your inbox and spam folder.", + "resendCount": verification_data.get("resend_count", 1) + }) + + except ValueError as ve: + logger.warning(f"āš ļø Invalid resend verification request: {ve}") + return JSONResponse( + status_code=400, + content=create_error_response("VALIDATION_ERROR", str(ve)) + ) + except Exception as e: + logger.error(f"āŒ Resend verification email error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("RESEND_FAILED", "An error occurred while processing your request. Please try again later.") + ) + +@router.post("/mfa/request") +async def request_mfa( + request: MFARequest, + background_tasks: BackgroundTasks, + http_request: Request, + database: RedisDatabase = Depends(get_database) +): + """Request MFA for login from new device""" + try: + # Verify credentials first + auth_manager = AuthenticationManager(database) + is_valid, user_data, error_message = await auth_manager.verify_user_credentials( + request.email, + request.password + ) + + if not is_valid or not user_data: + return JSONResponse( + status_code=401, + content=create_error_response("AUTH_FAILED", "Invalid credentials") + ) + + # Check if device is trusted + device_manager = DeviceManager(database) + device_info = device_manager.parse_device_info(http_request) + + is_trusted = await device_manager.is_trusted_device(user_data["id"], request.device_id) + + if is_trusted: + # Device is trusted, proceed with normal login + await device_manager.update_device_last_used(user_data["id"], request.device_id) + + return create_success_response({ + "mfa_required": False, + "message": "Device is trusted, proceed with login" + }) + + # Generate MFA code + mfa_code = f"{secrets.randbelow(1000000):06d}" # 6-digit code + + # Store MFA code + # Get user name for email + user_name = "User" + email = None + if user_data["type"] == "candidate": + candidate_data = await database.get_candidate(user_data["id"]) + if candidate_data: + user_name = candidate_data.get("fullName", "User") + email = candidate_data.get("email", None) + elif user_data["type"] == "employer": + employer_data = await database.get_employer(user_data["id"]) + if employer_data: + user_name = employer_data.get("companyName", "User") + email = employer_data.get("email", None) + + if not email: + return JSONResponse( + status_code=400, + content=create_error_response("EMAIL_NOT_FOUND", "User email not found for MFA") + ) + + # Store MFA code + await database.store_mfa_code(email, mfa_code, request.device_id) + logger.info(f"šŸ” MFA code generated for {email} on device {request.device_id}") + + # Send MFA code via email + background_tasks.add_task( + email_service.send_mfa_email, + email, + mfa_code, + request.device_name, + user_name + ) + + logger.info(f"šŸ” MFA requested for {request.email} from new device {request.device_name}") + + mfa_data = MFAData( + message="New device detected. We've sent a security code to your email address.", + code_sent=mfa_code, + email=request.email, + device_id=request.device_id, + device_name=request.device_name, + ) + mfa_response = MFARequestResponse( + mfa_required=True, + mfa_data=mfa_data + ) + return create_success_response(mfa_response) + + except Exception as e: + logger.error(f"āŒ MFA request error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("MFA_REQUEST_FAILED", "Failed to process MFA request") + ) + +@router.post("/login") +async def login( + request: LoginRequest, + http_request: Request, + background_tasks: BackgroundTasks, + database: RedisDatabase = Depends(get_database) +): + """login with automatic MFA email sending for new devices""" + try: + # Initialize managers + auth_manager = AuthenticationManager(database) + device_manager = DeviceManager(database) + + # Parse device information + device_info = device_manager.parse_device_info(http_request) + device_id = device_info["device_id"] + + # Verify credentials first + is_valid, user_data, error_message = await auth_manager.verify_user_credentials( + request.login, + request.password + ) + + if not is_valid or not user_data: + logger.warning(f"āš ļø Failed login attempt for: {request.login}") + return JSONResponse( + status_code=401, + content=create_error_response("AUTH_FAILED", error_message or "Invalid credentials") + ) + + # Check if device is trusted + is_trusted = await device_manager.is_trusted_device(user_data["id"], device_id) + + if not is_trusted: + # New device detected - automatically send MFA email + logger.info(f"šŸ” New device detected for {request.login}, sending MFA email") + + # Generate MFA code + mfa_code = f"{secrets.randbelow(1000000):06d}" # 6-digit code + + # Get user name and details for email + user_name = "User" + email = None + if user_data["type"] == "candidate": + candidate_data = await database.get_candidate(user_data["id"]) + if candidate_data: + user_name = candidate_data.get("full_name", "User") + email = candidate_data.get("email", None) + elif user_data["type"] == "employer": + employer_data = await database.get_employer(user_data["id"]) + if employer_data: + user_name = employer_data.get("company_name", "User") + email = employer_data.get("email", None) + + if not email: + return JSONResponse( + status_code=400, + content=create_error_response("EMAIL_NOT_FOUND", "User email not found for MFA") + ) + + # Store MFA code + await database.store_mfa_code(email, mfa_code, device_id) + + # Ensure email is lowercase + # Get IP address for security info + ip_address = http_request.client.host if http_request.client else "Unknown" + + # Send MFA code via email in background + background_tasks.add_task( + email_service.send_mfa_email, + email, + mfa_code, + device_info["device_name"], + user_name, + ip_address + ) + + # Log security event + await database.log_security_event( + user_data["id"], + "mfa_request", + { + "device_id": device_id, + "device_name": device_info["device_name"], + "ip_address": ip_address, + "user_agent": device_info.get("user_agent", ""), + "auto_sent": True + } + ) + + logger.info(f"šŸ” MFA code automatically sent to {request.login} for device {device_info['device_name']}") + + mfa_response = MFARequestResponse( + mfa_required=True, + mfa_data=MFAData( + message="New device detected. We've sent a security code to your email address.", + email=email, + device_id=device_id, + device_name=device_info["device_name"], + code_sent=mfa_code + ) + ) + return create_success_response(mfa_response.model_dump(by_alias=True)) + + # Trusted device - proceed with normal login + await device_manager.update_device_last_used(user_data["id"], device_id) + await auth_manager.update_last_login(user_data["id"]) + + # Create tokens + access_token = create_access_token(data={"sub": user_data["id"]}) + refresh_token = create_access_token( + data={"sub": user_data["id"], "type": "refresh"}, + expires_delta=timedelta(days=SecurityConfig.REFRESH_TOKEN_EXPIRY_DAYS) + ) + + # Get user object + user = None + if user_data["type"] == "candidate": + candidate_data = await database.get_candidate(user_data["id"]) + if candidate_data: + user = Candidate.model_validate(candidate_data) + elif user_data["type"] == "employer": + employer_data = await database.get_employer(user_data["id"]) + if employer_data: + user = Employer.model_validate(employer_data) + + if not user: + return JSONResponse( + status_code=404, + content=create_error_response("USER_NOT_FOUND", "User profile not found") + ) + + # Log successful login from trusted device + await database.log_security_event( + user_data["id"], + "login", + { + "device_id": device_id, + "device_name": device_info["device_name"], + "ip_address": http_request.client.host if http_request.client else "Unknown", + "trusted_device": True + } + ) + + # Create response + auth_response = AuthResponse( + access_token=access_token, + refresh_token=refresh_token, + user=user, + expires_at=int((datetime.now(timezone.utc) + timedelta(hours=SecurityConfig.TOKEN_EXPIRY_HOURS)).timestamp()) + ) + + logger.info(f"šŸ”‘ User {request.login} logged in successfully from trusted device") + + return create_success_response(auth_response.model_dump(by_alias=True)) + + except Exception as e: + logger.error(backstory_traceback.format_exc()) + logger.error(f"āŒ Login error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("LOGIN_ERROR", "An error occurred during login") + ) + + +@router.post("/mfa/verify") +async def verify_mfa( + request: MFAVerifyRequest, + http_request: Request, + database: RedisDatabase = Depends(get_database) +): + """Verify MFA code and complete login with error handling""" + try: + # Get MFA data + mfa_data = await database.get_mfa_code(request.email, request.device_id) + + if not mfa_data: + logger.warning(f"āš ļø No MFA session found for {request.email} on device {request.device_id}") + return JSONResponse( + status_code=404, + content=create_error_response("NO_MFA_SESSION", "No active MFA session found. Please try logging in again.") + ) + + if mfa_data.get("verified"): + return JSONResponse( + status_code=400, + content=create_error_response("ALREADY_VERIFIED", "This MFA code has already been used. Please login again.") + ) + + # Check expiration + expires_at = datetime.fromisoformat(mfa_data["expires_at"]) + if datetime.now(timezone.utc) > expires_at: + # Clean up expired MFA session + await database.redis.delete(f"mfa_code:{request.email.lower()}:{request.device_id}") + return JSONResponse( + status_code=400, + content=create_error_response("MFA_EXPIRED", "MFA code has expired. Please try logging in again.") + ) + + # Check attempts + current_attempts = mfa_data.get("attempts", 0) + if current_attempts >= 5: + # Clean up after too many attempts + await database.redis.delete(f"mfa_code:{request.email.lower()}:{request.device_id}") + return JSONResponse( + status_code=429, + content=create_error_response("TOO_MANY_ATTEMPTS", "Too many incorrect attempts. Please try logging in again.") + ) + + # Verify code + if mfa_data["code"] != request.code: + await database.increment_mfa_attempts(request.email, request.device_id) + remaining_attempts = 5 - (current_attempts + 1) + + return JSONResponse( + status_code=400, + content=create_error_response( + "INVALID_CODE", + f"Invalid MFA code. {remaining_attempts} attempts remaining." + ) + ) + + # Mark as verified + await database.mark_mfa_verified(request.email, request.device_id) + + # Get user data + user_data = await database.get_user(request.email) + if not user_data: + return JSONResponse( + status_code=404, + content=create_error_response("USER_NOT_FOUND", "User not found") + ) + + # Add device to trusted devices if requested + if request.remember_device: + device_manager = DeviceManager(database) + device_info = device_manager.parse_device_info(http_request) + await device_manager.add_trusted_device( + user_data["id"], + request.device_id, + device_info + ) + logger.info(f"šŸ”’ Device {request.device_id} added to trusted devices for user {user_data['id']}") + + # Update last login + auth_manager = AuthenticationManager(database) + await auth_manager.update_last_login(user_data["id"]) + + # Create tokens + access_token = create_access_token(data={"sub": user_data["id"]}) + refresh_token = create_access_token( + data={"sub": user_data["id"], "type": "refresh"}, + expires_delta=timedelta(days=SecurityConfig.REFRESH_TOKEN_EXPIRY_DAYS) + ) + + # Get user object + user = None + if user_data["type"] == "candidate": + candidate_data = await database.get_candidate(user_data["id"]) + if candidate_data: + user = Candidate.model_validate(candidate_data) + elif user_data["type"] == "employer": + employer_data = await database.get_employer(user_data["id"]) + if employer_data: + user = Employer.model_validate(employer_data) + + if not user: + return JSONResponse( + status_code=404, + content=create_error_response("USER_NOT_FOUND", "User profile not found") + ) + + # Log successful MFA verification and login + await database.log_security_event( + user_data["id"], + "mfa_verify_success", + { + "device_id": request.device_id, + "ip_address": http_request.client.host if http_request.client else "Unknown", + "device_remembered": request.remember_device, + "attempts_used": current_attempts + 1 + } + ) + + await database.log_security_event( + user_data["id"], + "login", + { + "device_id": request.device_id, + "ip_address": http_request.client.host if http_request.client else "Unknown", + "mfa_verified": True, + "new_device": True + } + ) + + # Clean up MFA session + await database.redis.delete(f"mfa_code:{request.email.lower()}:{request.device_id}") + + # Create response + auth_response = AuthResponse( + access_token=access_token, + refresh_token=refresh_token, + user=user, + expires_at=int((datetime.now(timezone.utc) + timedelta(hours=SecurityConfig.TOKEN_EXPIRY_HOURS)).timestamp()) + ) + + logger.info(f"āœ… MFA verified and login completed for {request.email}") + + return create_success_response(auth_response.model_dump(by_alias=True)) + + except Exception as e: + logger.error(backstory_traceback.format_exc()) + logger.error(f"āŒ MFA verification error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("MFA_VERIFICATION_FAILED", "Failed to verify MFA") + ) + +@router.post("/password-reset/request") +async def request_password_reset( + request: PasswordResetRequest, + database: RedisDatabase = Depends(get_database) +): + """Request password reset""" + try: + # Check if user exists + user_data = await database.get_user(request.email) + if not user_data: + # Don't reveal whether email exists or not + return create_success_response({"message": "If the email exists, a reset link will be sent"}) + + auth_manager = AuthenticationManager(database) + + # Generate reset token + reset_token = auth_manager.password_security.generate_secure_token() + reset_expiry = datetime.now(timezone.utc) + timedelta(hours=1) # 1 hour expiry + + # Update authentication record + auth_record = await database.get_authentication(user_data["id"]) + if auth_record: + auth_record["resetPasswordToken"] = reset_token + auth_record["resetPasswordExpiry"] = reset_expiry.isoformat() + await database.set_authentication(user_data["id"], auth_record) + + # TODO: Send email with reset token + logger.info(f"šŸ” Password reset requested for: {request.email}") + + return create_success_response({"message": "If the email exists, a reset link will be sent"}) + + except Exception as e: + logger.error(f"āŒ Password reset request error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("RESET_ERROR", "An error occurred processing the request") + ) + +@router.post("/password-reset/confirm") +async def confirm_password_reset( + request: PasswordResetConfirm, + database: RedisDatabase = Depends(get_database) +): + """Confirm password reset with token""" + try: + # Find user by reset token + # This would require a way to lookup by token - you might need to modify your database structure + + # For now, this is a placeholder - you'd need to implement token lookup + # in your Redis database structure + + return create_success_response({"message": "Password reset successfully"}) + + except Exception as e: + logger.error(f"āŒ Password reset confirm error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("RESET_ERROR", "An error occurred resetting the password") + ) + + diff --git a/src/backend/routes/candidates.py b/src/backend/routes/candidates.py new file mode 100644 index 0000000..be6351d --- /dev/null +++ b/src/backend/routes/candidates.py @@ -0,0 +1,1970 @@ +""" +Candidate routes +""" +import json +import pathlib +import re +import shutil +import jwt +import secrets +import uuid +import os +from datetime import datetime, timedelta, timezone, UTC +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, File, Form, HTTPException, Depends, Body, Path, Query, Request, BackgroundTasks, UploadFile +from fastapi.responses import FileResponse, JSONResponse, StreamingResponse +from pydantic import BaseModel, ValidationError + +from auth_utils import AuthenticationManager, SecurityConfig +import backstory_traceback as backstory_traceback +from agents.generate_resume import GenerateResume +import agents.base as agents +from utils.rate_limiter import RateLimiter, rate_limited +from utils.helpers import filter_and_paginate, get_document_type_from_filename, get_skill_cache_key, get_requirements_list +from database import RedisDatabase, redis_manager +from device_manager import DeviceManager +from email_service import VerificationEmailRateLimiter, email_service +from logger import logger +from models import ( + MOCK_UUID, ApiActivityType, ApiMessageType, ApiStatusType, CandidateAI, ChatContextType, ChatMessageError, ChatMessageRagSearch, ChatMessageResume, ChatMessageSkillAssessment, ChatMessageStatus, ChatMessageStreaming, ChatMessageUser, ChatSession, Document, DocumentContentResponse, DocumentListResponse, DocumentMessage, DocumentOptions, DocumentType, DocumentUpdateRequest, Job, JobRequirements, LoginRequest, CreateCandidateRequest, CreateEmployerRequest, + Candidate, Employer, Guest, AuthResponse, + MFARequest, MFAData, MFAVerifyRequest, + EmailVerificationRequest, RAGDocumentRequest, RagContentMetadata, RagContentResponse, ResendVerificationRequest, + MFARequestResponse, MFARequestResponse, SkillAssessment, UserType +) +from utils.dependencies import ( + get_current_admin, get_database, get_current_user, get_current_user_or_guest, + create_access_token, verify_token_with_blacklist, prometheus_collector +) +from utils.rate_limiter import rate_limited +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 +import defines + +# Create router for authentication endpoints +router = APIRouter(prefix="/candidates", tags=["candidates"]) + +# ============================ +# Candidate Endpoints +# ============================ +@router.post("/ai") +async def create_candidate_ai( + background_tasks: BackgroundTasks, + user_message: ChatMessageUser = Body(...), + admin: Candidate = Depends(get_current_admin), + database: RedisDatabase = Depends(get_database) +): + """Create a new candidate using AI-generated data""" + try: + + generate_agent = agents.get_or_create_agent( + agent_type=ChatContextType.GENERATE_PERSONA, + prometheus_collector=prometheus_collector) + + if not generate_agent: + logger.warning(f"āš ļø Unable to create AI generation agent.") + return JSONResponse( + status_code=400, + content=create_error_response("AGENT_NOT_FOUND", "Unable to create AI generation agent") + ) + + persona_message = None + resume_message = None + state = 0 # 0 -- create persona, 1 -- create resume + async for generated_message in generate_agent.generate( + llm=llm_manager.get_llm(), + model=defines.model, + session_id=user_message.session_id, + prompt=user_message.content, + ): + if isinstance(generated_message, ChatMessageError): + error_message : ChatMessageError = generated_message + logger.error(f"āŒ AI generation error: {error_message.content}") + return JSONResponse( + status_code=500, + content=create_error_response("AI_GENERATION_ERROR", error_message.content) + ) + if isinstance(generated_message, ChatMessageRagSearch): + raise ValueError("AI generation returned a RAG search message instead of a persona") + + if generated_message.status == ApiStatusType.DONE and state == 0: + persona_message = generated_message + state = 1 # Switch to resume generation + elif generated_message.status == ApiStatusType.DONE and state == 1: + resume_message = generated_message + + if not persona_message: + logger.error(f"āŒ AI generation failed: No message generated") + return JSONResponse( + status_code=500, + content=create_error_response("AI_GENERATION_FAILED", "Failed to generate AI candidate data") + ) + + if not isinstance(persona_message, ChatMessageUser): + logger.error(f"āŒ AI generation returned unexpected message type: {type(persona_message)}") + return JSONResponse( + status_code=500, + content=create_error_response("AI_GENERATION_ERROR", "AI generation did not return a valid user message") + ) + + if not isinstance(resume_message, ChatMessageUser): + logger.error(f"āŒ AI generation returned unexpected resume message type: {type(resume_message)}") + return JSONResponse( + status_code=500, + content=create_error_response("AI_GENERATION_ERROR", "AI generation did not return a valid resume message") + ) + + try: + current_time = datetime.now(timezone.utc) + candidate_data = json.loads(persona_message.content) + candidate_data.update({ + "user_type": "candidate", + "created_at": current_time.isoformat(), + "updated_at": current_time.isoformat(), + "status": "active", # Directly active for AI-generated candidates + "is_admin": False, # Default to non-admin + "is_AI": True, # Mark as AI-generated + }) + candidate = CandidateAI.model_validate(candidate_data) + except ValidationError as e: + logger.error(f"āŒ AI candidate data validation failed") + for lines in backstory_traceback.format_exc().splitlines(): + logger.error(lines) + logger.error(json.dumps(persona_message.content, indent=2)) + for error in e.errors(): + print(f"Field: {error['loc'][0]}, Error: {error['msg']}") + return JSONResponse( + status_code=400, + content=create_error_response("AI_VALIDATION_FAILED", "AI-generated data validation failed") + ) + except Exception as e: + # Log the error and return a validation error response + for lines in backstory_traceback.format_exc().splitlines(): + logger.error(lines) + logger.error(json.dumps(persona_message.content, indent=2)) + return JSONResponse( + status_code=400, + content=create_error_response("AI_VALIDATION_FAILED", "AI-generated data validation failed") + ) + + logger.info(f"šŸ¤– AI-generated candidate {candidate.username} created with email {candidate.email}") + candidate_data = candidate.model_dump(by_alias=False, exclude_unset=False) + # Store in database + await database.set_candidate(candidate.id, candidate_data) + + user_auth_data = { + "id": candidate.id, + "type": "candidate", + "email": candidate.email, + "username": candidate.username + } + + await database.set_user(candidate.email, user_auth_data) + await database.set_user(candidate.username, user_auth_data) + await database.set_user_by_id(candidate.id, user_auth_data) + + document_content = None + if resume_message: + document_id = str(uuid.uuid4()) + document_type = DocumentType.MARKDOWN + document_content = resume_message.content.encode('utf-8') + document_filename = f"resume.md" + + document_data = Document( + id=document_id, + filename=document_filename, + original_name=document_filename, + type=document_type, + size=len(document_content), + upload_date=datetime.now(UTC), + owner_id=candidate.id + ) + file_path = os.path.join(defines.user_dir, candidate.username, "rag-content", document_filename) + # Ensure the directory exists + rag_content_dir = pathlib.Path(defines.user_dir) / candidate.username / "rag-content" + rag_content_dir.mkdir(parents=True, exist_ok=True) + try: + with open(file_path, "wb") as f: + f.write(document_content) + + logger.info(f"šŸ“ File saved to disk: {file_path}") + + except Exception as e: + logger.error(f"āŒ Failed to save file to disk: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("FILE_SAVE_ERROR", "Failed to resume file to disk") + ) + + # Store document metadata in database + await database.set_document(document_id, document_data.model_dump()) + await database.add_document_to_candidate(candidate.id, document_id) + logger.info(f"šŸ“„ Document metadata saved for candidate {candidate.id}: {document_id}") + + logger.info(f"āœ… AI-generated candidate created: {candidate_data['email']}, resume is {len(document_content) if document_content else 0} bytes") + + return create_success_response({ + "message": "AI-generated candidate created successfully", + "candidate": candidate_data, + "resume": document_content, + }) + + except Exception as e: + logger.error(backstory_traceback.format_exc()) + logger.error(f"āŒ AI Candidate creation error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("AI_CREATION_FAILED", "Failed to create AI-generated candidate") + ) + +@router.post("") +async def create_candidate_with_verification( + request: CreateCandidateRequest, + background_tasks: BackgroundTasks, + database: RedisDatabase = Depends(get_database) +): + """Create a new candidate with email verification""" + try: + # Initialize authentication manager + auth_manager = AuthenticationManager(database) + + # Check if user already exists + user_exists, conflict_field = await auth_manager.check_user_exists( + request.email, + request.username + ) + + if user_exists and conflict_field: + logger.warning(f"āš ļø Attempted to create user with existing {conflict_field}: {getattr(request, conflict_field)}") + return JSONResponse( + status_code=409, + content=create_error_response( + "USER_EXISTS", + f"A user with this {conflict_field} already exists" + ) + ) + + # Generate candidate data (but don't activate yet) + candidate_id = str(uuid.uuid4()) + current_time = datetime.now(timezone.utc) + all_candidates = await database.get_all_candidates() + is_admin = False + if len(all_candidates) == 0: + is_admin = True + + candidate_data = { + "id": candidate_id, + "userType": "candidate", + "email": request.email, + "username": request.username, + "firstName": request.first_name, + "lastName": request.last_name, + "fullName": f"{request.first_name} {request.last_name}", + "phone": request.phone, + "createdAt": current_time.isoformat(), + "updatedAt": current_time.isoformat(), + "status": "pending", # Not active until email verified + "isAdmin": is_admin, + } + + # Generate verification token + verification_token = secrets.token_urlsafe(32) + + # Store verification token with user data + await database.store_email_verification_token( + request.email, + verification_token, + "candidate", + { + "candidate_data": candidate_data, + "password": request.password, # Store temporarily for verification + "username": request.username + } + ) + + # Send verification email in background + background_tasks.add_task( + email_service.send_verification_email, + request.email, + verification_token, + f"{request.first_name} {request.last_name}" + ) + + logger.info(f"āœ… Candidate registration initiated for: {request.email}") + + return create_success_response({ + "message": f"Registration successful! Please check your email to verify your account. {'As the first user on this sytem, you have admin priveledges.' if is_admin else ''}", + "email": request.email, + "verificationRequired": True + }) + + except Exception as e: + logger.error(f"āŒ Candidate creation error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("CREATION_FAILED", "Failed to create candidate account") + ) + +@router.post("/documents/upload") +async def upload_candidate_document( + file: UploadFile = File(...), + options_data: str = Form(..., alias="options"), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + try: + # Parse the JSON string and create DocumentOptions object + options_dict = json.loads(options_data) + options : DocumentOptions = DocumentOptions.model_validate(options_dict) + except (json.JSONDecodeError, ValidationError) as e: + return StreamingResponse( + iter([json.dumps(ChatMessageError( + session_id=MOCK_UUID, # No session ID for document uploads + content="Invalid options format. Please provide valid JSON." + ).model_dump(mode='json', by_alias=True))]), + media_type="text/event-stream" + ) + + # 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(mode='json', by_alias=True))]), + 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(mode='json', by_alias=True))]), + 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 + + candidate: Candidate = current_user + 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'") + + directory = "rag-content" if options.include_in_rag else "files" + directory = "jobs" if options.is_job_document else directory + + # Ensure the file does not already exist either in 'files' or in 'rag-content' + dir_path = os.path.join(defines.user_dir, candidate.username, directory) + if not os.path.exists(dir_path): + os.makedirs(dir_path, exist_ok=True) + file_path = os.path.join(dir_path, file.filename) + if os.path.exists(file_path): + if not options.overwrite: + logger.warning(f"āš ļø File already exists: {file_path}") + error_message = ChatMessageError( + session_id=MOCK_UUID, # No session ID for document uploads + content=f"File with this name already exists in the '{directory}' directory" + ) + yield error_message + return + else: + logger.info(f"šŸ”„ Overwriting existing file: {file_path}") + status_message = ChatMessageStatus( + session_id=MOCK_UUID, # No session ID for document uploads + content=f"Overwriting existing file: {file.filename}", + activity=ApiActivityType.INFO + ) + yield status_message + + # 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 + + # Create document metadata + document_id = str(uuid.uuid4()) + document_type = get_document_type_from_filename(file.filename or "unknown.txt") + + document_data = Document( + id=document_id, + filename=file.filename or f"document_{document_id}", + original_name=file.filename or f"document_{document_id}", + type=document_type, + size=len(file_content), + upload_date=datetime.now(UTC), + options=options, + owner_id=candidate.id + ) + + # Save file to disk + directory = os.path.join(defines.user_dir, candidate.username, directory) + file_path = os.path.join(directory, file.filename) + + try: + with open(file_path, "wb") as f: + f.write(file_content) + + logger.info(f"šŸ“ File saved to disk: {file_path}") + + except Exception as e: + logger.error(f"āŒ Failed to save file to disk: {e}") + error_message = ChatMessageError( + session_id=MOCK_UUID, # No session ID for document uploads + content="Failed to save file to disk", + ) + yield error_message + return + + converted = False + if document_type != DocumentType.MARKDOWN and document_type != DocumentType.TXT: + p = pathlib.Path(file_path) + p_as_md = p.with_suffix(".md") + # If file_path.md doesn't exist or file_path is newer than file_path.md, + # fire off markitdown + if (not p_as_md.exists()) or ( + p.stat().st_mtime > p_as_md.stat().st_mtime + ): + 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: + from markitdown import MarkItDown # type: ignore + md = MarkItDown(enable_plugins=False) # Set to True to enable plugins + result = md.convert(file_path, output_format="markdown") + p_as_md.write_text(result.text_content) + file_content = result.text_content + converted = True + logger.info(f"āœ… Converted {file.filename} to Markdown format: {p_as_md}") + file_path = p_as_md + 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_path} to Markdown: {e}") + return + + # Store document metadata in database + await database.set_document(document_id, document_data.model_dump()) + await database.add_document_to_candidate(candidate.id, document_id) + logger.info(f"šŸ“„ Document uploaded: {file.filename} for candidate {candidate.username}") + chat_message = DocumentMessage( + session_id=MOCK_UUID, # No session ID for document uploads + type=ApiMessageType.JSON, + status=ApiStatusType.DONE, + document=document_data, + converted=converted, + content=file_content, + ) + yield chat_message + 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))]), + media_type="text/event-stream" + ) + +@router.post("/profile/upload") +async def upload_candidate_profile( + file: UploadFile = File(...), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Upload a document for the current candidate""" + try: + # Verify user is a candidate + if current_user.user_type != "candidate": + logger.warning(f"āš ļø Unauthorized upload attempt by user type: {current_user.user_type}") + return JSONResponse( + status_code=403, + content=create_error_response("FORBIDDEN", "Only candidates can upload their profile") + ) + + candidate: Candidate = current_user + # Validate file type + allowed_types = ['.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}") + return JSONResponse( + status_code=400, + content=create_error_response( + "INVALID_FILE_TYPE", + f"File type {file_extension} not supported. Allowed types: {', '.join(allowed_types)}" + ) + ) + + # Check file size (limit to 5MB) + max_size = 5 * 1024 * 1024 # 2MB + file_content = await file.read() + if len(file_content) > max_size: + logger.info(f"āš ļø File too large: {file.filename} ({len(file_content)} bytes)") + return JSONResponse( + status_code=400, + content=create_error_response("FILE_TOO_LARGE", "File size exceeds 10MB limit") + ) + + # Save file to disk as "profile." + _, extension = os.path.splitext(file.filename or "") + file_path = os.path.join(defines.user_dir, candidate.username) + os.makedirs(file_path, exist_ok=True) + file_path = os.path.join(file_path, f"profile{extension}") + + try: + with open(file_path, "wb") as f: + f.write(file_content) + + logger.info(f"šŸ“ File saved to disk: {file_path}") + + except Exception as e: + logger.error(f"āŒ Failed to save file to disk: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("FILE_SAVE_ERROR", "Failed to save file to disk") + ) + + updates = { + "updated_at": datetime.now(UTC).isoformat(), + "profile_image": f"profile{extension}" + } + candidate_dict = candidate.model_dump() + candidate_dict.update(updates) + updated_candidate = Candidate.model_validate(candidate_dict) + await database.set_candidate(candidate.id, updated_candidate.model_dump()) + logger.info(f"šŸ“„ Profile image uploaded: {updated_candidate.profile_image} for candidate {candidate.id}") + + return create_success_response(True) + + except Exception as e: + logger.error(backstory_traceback.format_exc()) + logger.error(f"āŒ Document upload error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("UPLOAD_ERROR", "Failed to upload document") + ) + +@router.get("/profile/{username}") +async def get_candidate_profile_image( + username: str = Path(..., description="Username of the candidate"), + # current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Get profile image of a candidate by username""" + try: + all_candidates_data = await database.get_all_candidates() + candidates_list = [Candidate.model_validate(data) for data in all_candidates_data.values()] + + # Normalize username to lowercase for case-insensitive search + query_lower = username.lower() + + # Filter by search query + candidates_list = [ + c for c in candidates_list + if (query_lower == c.email.lower() or + query_lower == c.username.lower()) + ] + + if not len(candidates_list): + return JSONResponse( + status_code=404, + content=create_error_response("NOT_FOUND", "Candidate not found") + ) + + candidate = Candidate.model_validate(candidates_list[0]) + if not candidate.profile_image: + logger.warning(f"āš ļø Candidate {candidate.username} has no profile image set") + return JSONResponse( + status_code=404, + content=create_error_response("NOT_FOUND", "Profile image not found") + ) + file_path = os.path.join(defines.user_dir, candidate.username, candidate.profile_image) + file_path = pathlib.Path(file_path) + if not file_path.exists(): + logger.error(f"āŒ Profile image file not found on disk: {file_path}") + return JSONResponse( + status_code=404, + content=create_error_response("FILE_NOT_FOUND", "Profile image file not found on disk") + ) + return FileResponse( + file_path, + media_type=f"image/{file_path.suffix[1:]}", # Get extension without dot + filename=candidate.profile_image + ) + except Exception as e: + logger.error(backstory_traceback.format_exc()) + logger.error(f"āŒ Get candidate profile image failed: {str(e)}") + return JSONResponse( + status_code=500, + content=create_error_response("FETCH_ERROR", "Failed to retrieve profile image") + ) + +@router.get("/documents") +async def get_candidate_documents( + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Get all documents for the current candidate""" + try: + # Verify user is a candidate + if current_user.user_type != "candidate": + logger.warning(f"āš ļø Unauthorized access attempt by user type: {current_user.user_type}") + return JSONResponse( + status_code=403, + content=create_error_response("FORBIDDEN", "Only candidates can access documents") + ) + + candidate: Candidate = current_user + + # Get documents from database + documents_data = await database.get_candidate_documents(candidate.id) + documents = [Document.model_validate(doc_data) for doc_data in documents_data] + + # Sort by upload date (newest first) + documents.sort(key=lambda x: x.upload_date, reverse=True) + + response_data = DocumentListResponse( + documents=documents, + total=len(documents) + ) + + return create_success_response(response_data.model_dump(by_alias=True)) + + except Exception as e: + logger.error(backstory_traceback.format_exc()) + logger.error(f"āŒ Get candidate documents error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("FETCH_ERROR", "Failed to retrieve documents") + ) + +@router.get("/documents/{document_id}/content") +async def get_document_content( + document_id: str = Path(...), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Get document content by ID""" + try: + # Verify user is a candidate + if current_user.user_type != "candidate": + return JSONResponse( + status_code=403, + content=create_error_response("FORBIDDEN", "Only candidates can access documents") + ) + + candidate: Candidate = current_user + + # Get document metadata + document_data = await database.get_document(document_id) + if not document_data: + return JSONResponse( + status_code=404, + content=create_error_response("NOT_FOUND", "Document not found") + ) + + document = Document.model_validate(document_data) + + # Verify document belongs to current candidate + if document.owner_id != candidate.id: + return JSONResponse( + status_code=403, + content=create_error_response("FORBIDDEN", "Cannot access another candidate's document") + ) + + file_path = os.path.join(defines.user_dir, candidate.username, "rag-content" if document.options.include_in_rag else "files", document.original_name) + file_path = pathlib.Path(file_path) + if not document.type in [DocumentType.TXT, DocumentType.MARKDOWN]: + file_path = file_path.with_suffix('.md') + + if not file_path.exists(): + logger.error(f"āŒ Document file not found on disk: {file_path}") + return JSONResponse( + status_code=404, + content=create_error_response("FILE_NOT_FOUND", "Document file not found on disk") + ) + + try: + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + response = DocumentContentResponse( + document_id=document_id, + filename=document.filename, + type=document.type, + content=content, + size=document.size + ) + return create_success_response(response.model_dump(by_alias=True)); + + except Exception as e: + logger.error(f"āŒ Failed to read document file: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("READ_ERROR", "Failed to read document content") + ) + + except Exception as e: + logger.error(backstory_traceback.format_exc()) + logger.error(f"āŒ Get document content error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("FETCH_ERROR", "Failed to retrieve document content") + ) + +@router.patch("/documents/{document_id}") +async def update_document( + document_id: str = Path(...), + updates: DocumentUpdateRequest = Body(...), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Update document metadata (filename, RAG status)""" + try: + # Verify user is a candidate + if current_user.user_type != "candidate": + return JSONResponse( + status_code=403, + content=create_error_response("FORBIDDEN", "Only candidates can update documents") + ) + + candidate: Candidate = current_user + + # Get document metadata + document_data = await database.get_document(document_id) + if not document_data: + return JSONResponse( + status_code=404, + content=create_error_response("NOT_FOUND", "Document not found") + ) + + document = Document.model_validate(document_data) + + # Verify document belongs to current candidate + if document.owner_id != candidate.id: + return JSONResponse( + status_code=403, + content=create_error_response("FORBIDDEN", "Cannot update another candidate's document") + ) + update_options = updates.options if updates.options else DocumentOptions() + if document.options.include_in_rag != update_options.include_in_rag: + # If RAG status is changing, we need to handle file movement + rag_dir = os.path.join(defines.user_dir, candidate.username, "rag-content") + file_dir = os.path.join(defines.user_dir, candidate.username, "files") + os.makedirs(rag_dir, exist_ok=True) + os.makedirs(file_dir, exist_ok=True) + rag_path = os.path.join(rag_dir, document.original_name) + file_path = os.path.join(file_dir, document.original_name) + + if update_options.include_in_rag: + src = pathlib.Path(file_path) + dst = pathlib.Path(rag_path) + # Move to RAG directory + src.rename(dst) + logger.info(f"šŸ“ Moved file to RAG directory") + if document.type != DocumentType.MARKDOWN and document.type != DocumentType.TXT: + src = pathlib.Path(file_path) + src_as_md = src.with_suffix(".md") + if src_as_md.exists(): + dst = pathlib.Path(rag_path).with_suffix(".md") + src_as_md.rename(dst) + else: + src = pathlib.Path(rag_path) + dst = pathlib.Path(file_path) + # Move to regular files directory + src.rename(dst) + logger.info(f"šŸ“ Moved file to regular files directory") + if document.type != DocumentType.MARKDOWN and document.type != DocumentType.TXT: + src_as_md = src.with_suffix(".md") + if src_as_md.exists(): + dst = pathlib.Path(file_path).with_suffix(".md") + src_as_md.rename(dst) + + # Apply updates + update_dict = {} + if updates.filename is not None: + update_dict["filename"] = updates.filename.strip() + if update_options.include_in_rag is not None: + update_dict["include_in_rag"] = update_options.include_in_rag + + if not update_dict: + return JSONResponse( + status_code=400, + content=create_error_response("NO_UPDATES", "No valid updates provided") + ) + + # Add timestamp + update_dict["updatedAt"] = datetime.now(UTC).isoformat() + + # Update in database + updated_data = await database.update_document(document_id, update_dict) + if not updated_data: + return JSONResponse( + status_code=500, + content=create_error_response("UPDATE_FAILED", "Failed to update document") + ) + + updated_document = Document.model_validate(updated_data) + + logger.info(f"šŸ“„ Document updated: {document_id} for candidate {candidate.username}") + + return create_success_response(updated_document.model_dump(by_alias=True)) + + except Exception as e: + logger.error(f"āŒ Update document error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("UPDATE_ERROR", "Failed to update document") + ) + +@router.delete("/documents/{document_id}") +async def delete_document( + document_id: str = Path(...), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Delete a document and its file""" + try: + # Verify user is a candidate + if current_user.user_type != "candidate": + logger.warning(f"āš ļø Unauthorized delete attempt by user type: {current_user.user_type}") + return JSONResponse( + status_code=403, + content=create_error_response("FORBIDDEN", "Only candidates can delete documents") + ) + + candidate: Candidate = current_user + + # Get document metadata + document_data = await database.get_document(document_id) + if not document_data: + logger.warning(f"āš ļø Document not found for deletion: {document_id}") + return JSONResponse( + status_code=404, + content=create_error_response("NOT_FOUND", "Document not found") + ) + + document = Document.model_validate(document_data) + + # Verify document belongs to current candidate + if document.owner_id != candidate.id: + logger.warning(f"āš ļø Unauthorized delete attempt on document {document_id} by candidate {candidate.username}") + return JSONResponse( + status_code=403, + content=create_error_response("FORBIDDEN", "Cannot delete another candidate's document") + ) + + # Delete file from disk + file_path = os.path.join(defines.user_dir, candidate.username, "rag-content" if document.options.include_in_rag else "files", document.original_name) + file_path = pathlib.Path(file_path) + + try: + if file_path.exists(): + file_path.unlink() + logger.info(f"šŸ—‘ļø File deleted from disk: {file_path}") + else: + logger.warning(f"āš ļø File not found on disk during deletion: {file_path}") + + # Delete side-car file if it exists + if document.type != DocumentType.MARKDOWN and document.type != DocumentType.TXT: + p = pathlib.Path(file_path) + p_as_md = p.with_suffix(".md") + if p_as_md.exists(): + p_as_md.unlink() + + except Exception as e: + logger.error(f"āŒ Failed to delete file from disk: {e}") + # Continue with metadata deletion even if file deletion fails + + # Remove from database + await database.remove_document_from_candidate(candidate.id, document_id) + await database.delete_document(document_id) + + logger.info(f"šŸ—‘ļø Document deleted: {document_id} for candidate {candidate.username}") + + return create_success_response({ + "message": "Document deleted successfully", + "documentId": document_id + }) + + except Exception as e: + logger.error(backstory_traceback.format_exc()) + logger.error(f"āŒ Delete document error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("DELETE_ERROR", "Failed to delete document") + ) + +@router.get("/documents/search") +async def search_candidate_documents( + query: str = Query(..., min_length=1), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Search candidate documents by filename""" + try: + # Verify user is a candidate + if current_user.user_type != "candidate": + return JSONResponse( + status_code=403, + content=create_error_response("FORBIDDEN", "Only candidates can search documents") + ) + + candidate: Candidate = current_user + + # Search documents + documents_data = await database.search_candidate_documents(candidate.id, query) + documents = [Document.model_validate(doc_data) for doc_data in documents_data] + + # Sort by upload date (newest first) + documents.sort(key=lambda x: x.upload_date, reverse=True) + + response_data = DocumentListResponse( + documents=documents, + total=len(documents) + ) + + return create_success_response(response_data.model_dump(by_alias=True)) + + except Exception as e: + logger.error(f"āŒ Search documents error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("SEARCH_ERROR", "Failed to search documents") + ) + +@router.post("/rag-content") +async def post_candidate_vector_content( + rag_document: RAGDocumentRequest = Body(...), + current_user = Depends(get_current_user) +): + try: + if current_user.user_type != "candidate": + logger.warning(f"āš ļø Unauthorized access attempt by user type: {current_user.user_type}") + return JSONResponse( + status_code=403, + content=create_error_response("FORBIDDEN", "Only candidates can access this endpoint") + ) + candidate : Candidate = current_user + + async with entities.get_candidate_entity(candidate=candidate) as candidate_entity: + collection = candidate_entity.umap_collection + if not collection: + logger.warning(f"āš ļø No UMAP collection found for candidate {candidate.username}") + return JSONResponse( + {"error": "No UMAP collection found"}, status_code=404 + ) + + for index, id in enumerate(collection.ids): + if id == rag_document.id: + metadata = collection.metadatas[index].copy() + rag_metadata = RagContentMetadata.model_validate(metadata) + content = candidate_entity.file_watcher.prepare_metadata(metadata) + if content: + rag_response = RagContentResponse(id=id, content=content, metadata=rag_metadata) + logger.info(f"āœ… Fetched RAG content for document id {id} for candidate {candidate.username}") + else: + logger.warning(f"āš ļø No content found for document id {id} for candidate {candidate.username}") + return JSONResponse(f"No content found for document id {rag_document.id}.", 404) + return create_success_response(rag_response.model_dump(by_alias=True)) + + logger.warning(f"āš ļø Document id {rag_document.id} not found in UMAP collection for candidate {candidate.username}") + return JSONResponse(f"Document id {rag_document.id} not found.", 404) + except Exception as e: + logger.error(backstory_traceback.format_exc()) + logger.error(f"āŒ Post candidate content error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("FETCH_ERROR", str(e)) + ) + +@router.post("/rag-vectors") +async def post_candidate_vectors( + dimensions: int = Body(...), + current_user = Depends(get_current_user) +): + try: + if current_user.user_type != "candidate": + return JSONResponse( + status_code=403, + content=create_error_response("FORBIDDEN", "Only candidates can access this endpoint") + ) + candidate : Candidate = current_user + + async with entities.get_candidate_entity(candidate=candidate) as candidate_entity: + collection = candidate_entity.umap_collection + if not collection: + results = { + "ids": [], + "metadatas": [], + "documents": [], + "embeddings": [], + "size": 0 + } + return create_success_response(results) + if dimensions == 2: + umap_embedding = candidate_entity.file_watcher.umap_embedding_2d + else: + umap_embedding = candidate_entity.file_watcher.umap_embedding_3d + + if len(umap_embedding) == 0: + results = { + "ids": [], + "metadatas": [], + "documents": [], + "embeddings": [], + "size": 0 + } + return create_success_response(results) + + result = { + "ids": collection.ids, + "metadatas": collection.metadatas, + "documents": collection.documents, + "embeddings": umap_embedding.tolist(), + "size": candidate_entity.file_watcher.collection.count() + } + + return create_success_response(result) + + except Exception as e: + logger.error(backstory_traceback.format_exc()) + logger.error(f"āŒ Post candidate vectors error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("FETCH_ERROR", str(e)) + ) + +@router.delete("/{candidate_id}") +async def delete_candidate( + candidate_id: str = Path(...), + admin_user = Depends(get_current_admin), + database: RedisDatabase = Depends(get_database) +): + """Delete a candidate""" + 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 candidates") + ) + + # Get candidate data + candidate_data = await database.get_candidate(candidate_id) + if not candidate_data: + logger.warning(f"āš ļø Candidate not found for deletion: {candidate_id}") + return JSONResponse( + status_code=404, + content=create_error_response("NOT_FOUND", "Candidate not found") + ) + + await entities.entity_manager.remove_entity(candidate_id) + + # Delete candidate from database + await database.delete_candidate(candidate_id) + + # Optionally delete files and documents associated with the candidate + await database.delete_all_candidate_documents(candidate_id) + + file_path = os.path.join(defines.user_dir, candidate_data["username"]) + if os.path.exists(file_path): + try: + shutil.rmtree(file_path) + logger.info(f"šŸ—‘ļø Deleted candidate files directory: {file_path}") + except Exception as e: + logger.error(f"āŒ Failed to delete candidate files directory: {e}") + + logger.info(f"šŸ—‘ļø Candidate deleted: {candidate_id} by admin {admin_user.id}") + + return create_success_response({ + "message": "Candidate deleted successfully", + "candidateId": candidate_id + }) + + except Exception as e: + logger.error(f"āŒ Delete candidate error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("DELETE_ERROR", "Failed to delete candidate") + ) + +@router.patch("/{candidate_id}") +async def update_candidate( + candidate_id: str = Path(...), + updates: Dict[str, Any] = Body(...), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Update a candidate""" + try: + candidate_data = await database.get_candidate(candidate_id) + if not candidate_data: + logger.warning(f"āš ļø Candidate not found for update: {candidate_id}") + return JSONResponse( + status_code=404, + content=create_error_response("NOT_FOUND", "Candidate not found") + ) + + is_AI = candidate_data.get("is_AI", False) + candidate = CandidateAI.model_validate(candidate_data) if is_AI else Candidate.model_validate(candidate_data) + + # Check authorization (user can only update their own profile) + if current_user.is_admin is False and candidate.id != current_user.id: + logger.warning(f"āš ļø Unauthorized update attempt by user {current_user.id} on candidate {candidate_id}") + return JSONResponse( + status_code=403, + content=create_error_response("FORBIDDEN", "Cannot update another user's profile") + ) + + # Apply updates + updates["updatedAt"] = datetime.now(UTC).isoformat() + logger.info(f"šŸ”„ Updating candidate {candidate_id} with data: {updates}") + candidate_dict = candidate.model_dump() + candidate_dict.update(updates) + updated_candidate = CandidateAI.model_validate(candidate_dict) if is_AI else Candidate.model_validate(candidate_dict) + await database.set_candidate(candidate_id, updated_candidate.model_dump()) + + return create_success_response(updated_candidate.model_dump(by_alias=True)) + + except Exception as e: + logger.error(f"āŒ Update candidate error: {e}") + return JSONResponse( + status_code=400, + content=create_error_response("UPDATE_FAILED", str(e)) + ) + +@router.get("") +async def get_candidates( + 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), + current_user = Depends(get_current_user_or_guest), + database: RedisDatabase = Depends(get_database) +): + """Get paginated list of candidates""" + try: + # Parse filters if provided + filter_dict = None + if filters: + filter_dict = json.loads(filters) + + # Get all candidates from Redis + all_candidates_data = await database.get_all_candidates() + candidates_list = [Candidate.model_validate(data) if not data.get("is_AI") else CandidateAI.model_validate(data) for data in all_candidates_data.values()] + candidates_list = [c for c in candidates_list if c.is_public or (current_user.userType != UserType.GUEST and c.id == current_user.id)] + + paginated_candidates, total = filter_and_paginate( + candidates_list, page, limit, sortBy, sortOrder, filter_dict + ) + + paginated_response = create_paginated_response( + [c.model_dump(by_alias=True) for c in paginated_candidates], + page, limit, total + ) + + return create_success_response(paginated_response) + + except Exception as e: + logger.error(f"āŒ Get candidates error: {e}") + return JSONResponse( + status_code=400, + content=create_error_response("FETCH_FAILED", str(e)) + ) + +@router.get("/search") +async def search_candidates( + query: str = Query(...), + filters: Optional[str] = Query(None), + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=100), + database: RedisDatabase = Depends(get_database) +): + """Search candidates""" + try: + # Parse filters + filter_dict = {} + if filters: + filter_dict = json.loads(filters) + + # Get all candidates from Redis + all_candidates_data = await database.get_all_candidates() + candidates_list = [Candidate.model_validate(data) for data in all_candidates_data.values()] + + # Filter by search query + if query: + query_lower = query.lower() + candidates_list = [ + c for c in candidates_list + if (query_lower in c.first_name.lower() or + query_lower in c.last_name.lower() or + query_lower in c.email.lower() or + query_lower in c.username.lower() or + any(query_lower in skill.name.lower() for skill in c.skills or [])) + ] + + paginated_candidates, total = filter_and_paginate( + candidates_list, page, limit, filters=filter_dict + ) + + paginated_response = create_paginated_response( + [c.model_dump(by_alias=True) for c in paginated_candidates], + page, limit, total + ) + + return create_success_response(paginated_response) + + except Exception as e: + logger.error(f"āŒ Search candidates error: {e}") + return JSONResponse( + status_code=400, + content=create_error_response("SEARCH_FAILED", str(e)) + ) + +@router.post("/rag-search") +async def post_candidate_rag_search( + query: str = Body(...), + current_user = Depends(get_current_user) +): + """Get chat activity summary for a candidate""" + try: + if current_user.user_type != "candidate": + logger.warning(f"āš ļø Unauthorized RAG search attempt by user {current_user.id}") + return JSONResponse( + status_code=403, + content=create_error_response("FORBIDDEN", "Only candidates can access this endpoint") + ) + + candidate : Candidate = current_user + chat_type = ChatContextType.RAG_SEARCH + # Get RAG search data + async with entities.get_candidate_entity(candidate=candidate) as candidate_entity: + # Entity automatically released when done + chat_agent = candidate_entity.get_or_create_agent(agent_type=chat_type) + if not chat_agent: + return JSONResponse( + status_code=400, + content=create_error_response("AGENT_NOT_FOUND", "No agent found for this chat type") + ) + + user_message = ChatMessageUser(sender_id=candidate.id, session_id=MOCK_UUID, content=query, timestamp=datetime.now(UTC)) + rag_message : Any = None + async for generated_message in chat_agent.generate( + llm=llm_manager.get_llm(), + model=defines.model, + session_id=user_message.session_id, + prompt=user_message.content, + ): + rag_message = generated_message + + if not rag_message: + return JSONResponse( + status_code=500, + content=create_error_response("NO_RESPONSE", "No response generated for the RAG search") + ) + final_message : ChatMessageRagSearch = rag_message + return create_success_response(final_message.content[0].model_dump(by_alias=True)) + + except Exception as e: + logger.error(f"āŒ Get candidate chat summary error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("SUMMARY_ERROR", str(e)) + ) + +# reference can be candidateId, username, or email +@router.get("/{reference}") +async def get_candidate( + reference: str = Path(...), + database: RedisDatabase = Depends(get_database) +): + """Get a candidate by username""" + try: + # Normalize reference to lowercase for case-insensitive search + query_lower = reference.lower() + + all_candidates_data = await database.get_all_candidates() + if not all_candidates_data: + logger.warning(f"āš ļø No candidates found in database") + return JSONResponse( + status_code=404, + content=create_error_response("NOT_FOUND", "No candidates found") + ) + + candidate_data = None + for candidate in all_candidates_data.values(): + if (candidate.get("id", "").lower() == query_lower or + candidate.get("username", "").lower() == query_lower or + candidate.get("email", "").lower() == query_lower): + candidate_data = candidate + break + + if not candidate_data: + logger.warning(f"āš ļø Candidate not found for reference: {reference}") + return JSONResponse( + status_code=404, + content=create_error_response("NOT_FOUND", "Candidate not found") + ) + + candidate = Candidate.model_validate(candidate_data) if not candidate_data.get("is_AI") else CandidateAI.model_validate(candidate_data) + + return create_success_response(candidate.model_dump(by_alias=True)) + + except Exception as e: + logger.error(f"āŒ Get candidate error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("FETCH_ERROR", str(e)) + ) + +@router.get("/{username}/chat-summary") +async def get_candidate_chat_summary( + username: str = Path(...), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Get chat activity summary for a candidate""" + try: + # Find candidate by username + candidate_data = await database.find_candidate_by_username(username) + if not candidate_data: + return JSONResponse( + status_code=404, + content=create_error_response("CANDIDATE_NOT_FOUND", f"Candidate with username '{username}' not found") + ) + + summary = await database.get_candidate_chat_summary(candidate_data["id"]) + summary["candidate"] = { + "username": candidate_data.get("username"), + "fullName": candidate_data.get("fullName"), + "email": candidate_data.get("email") + } + + return create_success_response(summary) + + except Exception as e: + logger.error(f"āŒ Get candidate chat summary error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("SUMMARY_ERROR", str(e)) + ) + +@router.post("/{candidate_id}/skill-match") +async def get_candidate_skill_match( + candidate_id: str = Path(...), + skill: str = Body(...), + current_user = Depends(get_current_user_or_guest), + database: RedisDatabase = Depends(get_database) +) -> StreamingResponse: + + """Get skill match for a candidate against a skill with caching""" + async def message_stream_generator(): + candidate_data = await database.get_candidate(candidate_id) + if not candidate_data: + error_message = ChatMessageError( + session_id=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) + + 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) + + 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}). Regenerating...") + assessment = None + + # Determine if we need to regenerate the assessment + if assessment: + # Get the latest RAG data update time for the current user + user_rag_update_time = await database.get_user_rag_update_time(candidate.id) + + updated = assessment.updated_at + # Check if cached result is still valid + # Regenerate if user's RAG data was updated after cache date + if user_rag_update_time and user_rag_update_time >= updated: + logger.info(f"šŸ”„ Out-of-date cached entry for {candidate.username} skill {assessment.skill}") + assessment = None + else: + logger.info(f"āœ… Using cached skill match for {candidate.username} skill {assessment.skill}: {cache_key}") + else: + logger.info(f"šŸ’¾ No cached skill match data: {cache_key}, {candidate.id}, {skill}") + + if assessment: + # Return cached assessment + skill_message = ChatMessageSkillAssessment( + session_id=MOCK_UUID, # No session ID for document uploads + content=f"Cached skill match found for {candidate.username}", + skill_assessment=assessment + ) + yield skill_message + return + + logger.info(f"šŸ” Generating skill match for candidate {candidate.username} for skill: {skill}") + + async with entities.get_candidate_entity(candidate=candidate) as candidate_entity: + agent = candidate_entity.get_or_create_agent(agent_type=ChatContextType.SKILL_MATCH) + if not agent: + error_message = ChatMessageError( + session_id=MOCK_UUID, # No session ID for document uploads + content=f"No skill match agent found for this candidate" + ) + yield error_message + return + + # Generate new skill match + final_message = None + async for generated_message in agent.generate( + llm=llm_manager.get_llm(), + model=defines.model, + session_id=MOCK_UUID, + prompt=skill, + ): + if generated_message.status == ApiStatusType.ERROR: + if isinstance(generated_message, ChatMessageError): + content = generated_message.content + else: + content = "An error occurred during AI generation" + error_message = ChatMessageError( + session_id=MOCK_UUID, # No session ID for document uploads + content=f"AI generation error: {content}" + ) + logger.error(f"āŒ {error_message.content}") + yield error_message + return + + # If the message is not done, convert it to a ChatMessageBase to remove + # metadata and other unnecessary fields for streaming + if generated_message.status != ApiStatusType.DONE: + if not isinstance(generated_message, ChatMessageStreaming) and not isinstance(generated_message, ChatMessageStatus): + raise TypeError( + f"Expected ChatMessageStreaming or ChatMessageStatus, got {type(generated_message)}" + ) + yield generated_message# Convert to ChatMessageBase for streaming + + # Store reference to the complete AI message + if generated_message.status == ApiStatusType.DONE: + final_message = generated_message + break + + if final_message is None: + error_message = ChatMessageError( + session_id=MOCK_UUID, # No session ID for document uploads + content=f"No match found for the given skill" + ) + yield error_message + return + + if not isinstance(final_message, ChatMessageSkillAssessment): + error_message = ChatMessageError( + session_id=MOCK_UUID, # No session ID for document uploads + content=f"Skill match response is not valid" + ) + yield error_message + return + + skill_match : ChatMessageSkillAssessment = final_message + assessment = skill_match.skill_assessment + if not assessment: + error_message = ChatMessageError( + session_id=MOCK_UUID, # No session ID for document uploads + content=f"Skill assessment could not be generated" + ) + yield error_message + return + + await database.cache_skill_match(cache_key, assessment) + logger.info(f"šŸ’¾ Cached new skill match for candidate {candidate.id} as {cache_key}") + logger.info(f"āœ… Skill match: {assessment.evidence_strength} {skill}") + yield skill_match + 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(message_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"āŒ Document upload error: {e}") + return StreamingResponse( + iter([json.dumps(ChatMessageError( + session_id=MOCK_UUID, # No session ID for document uploads + content="Failed to generate skill assessment" + ).model_dump(mode='json', by_alias=True))]), + media_type="text/event-stream" + ) + +@router.post("/job-score") +async def get_candidate_job_score( + job_requirements: JobRequirements = Body(...), + skills: List[SkillAssessment] = Body(...), + current_user = Depends(get_current_user_or_guest), + database: RedisDatabase = Depends(get_database) +): + # Initialize counters + required_skills_total = 0 + required_skills_matched = 0 + preferred_skills_total = 0 + preferred_skills_matched = 0 + + # Count required technical skills + tech_required = job_requirements.technical_skills.required + required_skills_total += len(tech_required) + + # Count preferred technical skills + tech_preferred = job_requirements.technical_skills.preferred + preferred_skills_total += len(tech_preferred) + + # Count required experience + exp_required = job_requirements.experience_requirements.required + required_skills_total += len(exp_required) + + # Count preferred experience + exp_preferred = job_requirements.experience_requirements.preferred + preferred_skills_total += len(exp_preferred) + + # Education requirements count toward required + edu_required = job_requirements.education or [] + required_skills_total += len(edu_required) + + # Soft skills count toward preferred + soft_skills = job_requirements.soft_skills or [] + preferred_skills_total += len(soft_skills) + + # Industry knowledge counts toward preferred + certifications = job_requirements.certifications or [] + preferred_skills_total += len(certifications) + + preferred_attributes = job_requirements.preferred_attributes or [] + preferred_skills_total += len(preferred_attributes) + + # Check matches in assessment results + for assessment in skills: + evidence_found = assessment.evidence_found + evidence_strength = assessment.evidence_strength + + # Consider STRONG and MODERATE evidence as matches + is_match = evidence_found and evidence_strength in ["STRONG", "MODERATE"] + + if not is_match: + continue + + # Loop through each of the job requirements categories + # and see if the skill matches the required or preferred skills + if assessment.skill in tech_required: + required_skills_matched += 1 + elif assessment.skill in tech_preferred: + preferred_skills_matched += 1 + elif assessment.skill in exp_required: + required_skills_matched += 1 + elif assessment.skill in exp_preferred: + preferred_skills_matched += 1 + elif assessment.skill in edu_required: + required_skills_matched += 1 + elif assessment.skill in soft_skills: + preferred_skills_matched += 1 + elif assessment.skill in certifications: + preferred_skills_matched += 1 + elif assessment.skill in preferred_attributes: + preferred_skills_matched += 1 + # If no skills were found, return empty statistics + if required_skills_total == 0 and preferred_skills_total == 0: + return create_success_response({ + "required_skills": { + "total": 0, + "matched": 0, + "percentage": 0.0, + }, + "preferred_skills": { + "total": 0, + "matched": 0, + "percentage": 0.0, + }, + "overall_match": { + "total": 0, + "matched": 0, + "percentage": 0.0, + }, + }) + + # Calculate percentages + required_match_percent = ( + (required_skills_matched / required_skills_total * 100) + if required_skills_total > 0 + else 0 + ) + preferred_match_percent = ( + (preferred_skills_matched / preferred_skills_total * 100) + if preferred_skills_total > 0 + else 0 + ) + overall_total = required_skills_total + preferred_skills_total + overall_matched = required_skills_matched + preferred_skills_matched + overall_match_percent = ( + (overall_matched / overall_total * 100) if overall_total > 0 else 0 + ) + + return create_success_response({ + "required_skills": { + "total": required_skills_total, + "matched": required_skills_matched, + "percentage": round(required_match_percent, 1), + }, + "preferred_skills": { + "total": preferred_skills_total, + "matched": preferred_skills_matched, + "percentage": round(preferred_match_percent, 1), + }, + "overall_match": { + "total": overall_total, + "matched": overall_matched, + "percentage": round(overall_match_percent, 1), + }, + }) + +@router.post("/{candidate_id}/{job_id}/generate-resume") +async def generate_resume( + candidate_id: str = Path(...), + 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( + session_id=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( + session_id=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( + session_id=MOCK_UUID, # No session ID for document uploads + content=f"Uninitialized skill match data, cannot generate resume" + ) + yield error_message + return + + 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) + if not agent: + error_message = ChatMessageError( + session_id=MOCK_UUID, # No session ID for document uploads + content=f"No skill match agent found for this candidate" + ) + yield error_message + return + + final_message = None + if not isinstance(agent, GenerateResume): + error_message = ChatMessageError( + session_id=MOCK_UUID, # No session ID for document uploads + content=f"Agent is not a GenerateResume instance" + ) + yield error_message + return + async for generated_message in agent.generate_resume( + llm=llm_manager.get_llm(), + model=defines.model, + session_id=MOCK_UUID, + skills=skills, + ): + if generated_message.status == ApiStatusType.ERROR: + if isinstance(generated_message, ChatMessageError): + content = generated_message.content + else: + content = "An error occurred during AI generation" + logger.error(f"āŒ AI generation error: {content}") + yield f"data: {json.dumps({'status': 'error'})}\n\n" + return + + # If the message is not done, convert it to a ChatMessageBase to remove + # metadata and other unnecessary fields for streaming + if generated_message.status != ApiStatusType.DONE: + if not isinstance(generated_message, ChatMessageStreaming) and not isinstance(generated_message, ChatMessageStatus): + raise TypeError( + f"Expected ChatMessageStreaming or ChatMessageStatus, got {type(generated_message)}" + ) + yield generated_message# Convert to ChatMessageBase for streaming + + # Store reference to the complete AI message + if generated_message.status == ApiStatusType.DONE: + final_message = generated_message + break + + if final_message is None: + error_message = ChatMessageError( + session_id=MOCK_UUID, # No session ID for document uploads + content=f"No skill match found for the given requirement" + ) + yield error_message + return + + if not isinstance(final_message, ChatMessageResume): + error_message = ChatMessageError( + session_id=MOCK_UUID, # No session ID for document uploads + content=f"Skill match response is not valid" + ) + yield error_message + return + + resume : ChatMessageResume = final_message + yield resume + 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(message_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"āŒ Document upload error: {e}") + return StreamingResponse( + iter([json.dumps(ChatMessageError( + session_id=MOCK_UUID, # No session ID for document uploads + content="Failed to generate skill assessment" + ).model_dump(mode='json', by_alias=True))]), + media_type="text/event-stream" + ) + +@rate_limited(guest_per_minute=5, user_per_minute=30, admin_per_minute=100) +@router.get("/{username}/chat-sessions") +async def get_candidate_chat_sessions( + username: str = Path(...), + current_user = Depends(get_current_user_or_guest), + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=100), + database: RedisDatabase = Depends(get_database) +): + """Get all chat sessions related to a specific candidate""" + try: + logger.info(f"šŸ” Fetching chat sessions for candidate with username: {username}") + # Find candidate by username + all_candidates_data = await database.get_all_candidates() + candidates_list = [Candidate.model_validate(data) for data in all_candidates_data.values()] + + matching_candidates = [ + c for c in candidates_list + if c.username.lower() == username.lower() + ] + + if not matching_candidates: + return JSONResponse( + status_code=404, + content=create_error_response("CANDIDATE_NOT_FOUND", f"Candidate with username '{username}' not found") + ) + + candidate = matching_candidates[0] + + # Get all chat sessions + all_sessions_data = await database.get_all_chat_sessions() + sessions_list = [] + + for index, session_data in enumerate(all_sessions_data.values()): + try: + session = ChatSession.model_validate(session_data) + if session.user_id != current_user.id: + # User can only access their own sessions + logger.info(f"šŸ”— Skipping session {session.id} - not owned by user {current_user.id} (created by {session.user_id})") + continue + # Check if this session is related to the candidate + context = session.context + if (context and + context.related_entity_type == "candidate" and + context.related_entity_id == candidate.id): + sessions_list.append(session) + except Exception as e: + logger.error(backstory_traceback.format_exc()) + logger.error(f"āŒ Failed to validate session ({index}): {e}") + logger.error(f"āŒ Session data: {session_data}") + continue + + # Sort by last activity (most recent first) + sessions_list.sort(key=lambda x: x.last_activity, reverse=True) + + # Apply pagination + total = len(sessions_list) + start = (page - 1) * limit + end = start + limit + paginated_sessions = sessions_list[start:end] + + paginated_response = create_paginated_response( + [s.model_dump(by_alias=True) for s in paginated_sessions], + page, limit, total + ) + + return create_success_response({ + "candidate": { + "id": candidate.id, + "username": candidate.username, + "fullName": candidate.full_name, + "email": candidate.email + }, + "sessions": paginated_response + }) + + except Exception as e: + logger.error(f"āŒ Get candidate chat sessions error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("FETCH_ERROR", str(e)) + ) diff --git a/src/backend/routes/chat.py b/src/backend/routes/chat.py new file mode 100644 index 0000000..72dca48 --- /dev/null +++ b/src/backend/routes/chat.py @@ -0,0 +1,545 @@ +""" +Chat routes +""" +import json +import jwt +import secrets +import uuid +from datetime import datetime, timedelta, timezone, UTC +from typing import (Optional, List, Dict, Any) + +from fastapi import ( + APIRouter, HTTPException, Depends, Body, Request, BackgroundTasks, + FastAPI, HTTPException, Depends, Query, Path, Body, status, + APIRouter, Request, BackgroundTasks, File, UploadFile, Form +) + +from fastapi.responses import JSONResponse +from pydantic import ValidationError + +from auth_utils import AuthenticationManager, SecurityConfig +from database import RedisDatabase, redis_manager +from device_manager import DeviceManager +from email_service import email_service +from logger import logger +from utils.dependencies import ( + get_database, get_current_user, get_current_user_or_guest, + create_access_token, verify_token_with_blacklist +) +from utils.responses import ( + create_success_response, create_error_response, create_paginated_response +) +from utils.helpers import ( + stream_agent_response +) +import backstory_traceback + +import entities.entity_manager as entities + +from models import ( + LoginRequest, CreateCandidateRequest, CreateEmployerRequest, + Candidate, Employer, Guest, AuthResponse, + MFARequest, MFAData, MFARequestResponse, MFAVerifyRequest, + EmailVerificationRequest, ResendVerificationRequest, + # API + MOCK_UUID, ApiActivityType, ChatMessageError, ChatMessageResume, + ChatMessageSkillAssessment, ChatMessageStatus, ChatMessageStreaming, + ChatMessageUser, DocumentMessage, DocumentOptions, Job, + JobRequirements, JobRequirementsMessage, LoginRequest, + CreateCandidateRequest, CreateEmployerRequest, + + # User models + Candidate, Employer, BaseUserWithType, BaseUser, Guest, + Authentication, AuthResponse, CandidateAI, + + # Job models + JobApplication, ApplicationStatus, + + # Chat models + ChatSession, ChatMessage, ChatContext, ChatQuery, ApiStatusType, ChatSenderType, ApiMessageType, ChatContextType, + ChatMessageRagSearch, + + # Document models + Document, DocumentType, DocumentListResponse, DocumentUpdateRequest, DocumentContentResponse, + + # Supporting models + Location, MFARequest, MFAData, MFARequestResponse, MFAVerifyRequest, RagContentMetadata, RagContentResponse, ResendVerificationRequest, Resume, ResumeMessage, Skill, SkillAssessment, SystemInfo, UserType, WorkExperience, Education, + + # Email + EmailVerificationRequest +) + + +# Create router for authentication endpoints +router = APIRouter(prefix="/chat", tags=["chat"]) + +@router.post("/sessions/{session_id}/archive") +async def archive_chat_session( + session_id: str = Path(...), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Archive a chat session""" + try: + session_data = await database.get_chat_session(session_id) + if not session_data: + return JSONResponse( + status_code=404, + content=create_error_response("NOT_FOUND", "Chat session not found") + ) + + # Check if user owns this session or is admin + if session_data.get("userId") != current_user.id: + return JSONResponse( + status_code=403, + content=create_error_response("FORBIDDEN", "Cannot archive another user's session") + ) + + await database.archive_chat_session(session_id) + + return create_success_response({ + "message": "Chat session archived successfully", + "sessionId": session_id + }) + + except Exception as e: + logger.error(f"āŒ Archive chat session error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("ARCHIVE_ERROR", str(e)) + ) + +@router.get("/statistics") +async def get_chat_statistics( + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Get chat statistics (admin/analytics endpoint)""" + try: + stats = await database.get_chat_statistics() + return create_success_response(stats) + except Exception as e: + logger.error(f"āŒ Get chat statistics error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("STATS_ERROR", str(e)) + ) + + +@router.post("/sessions") +async def create_chat_session( + session_data: Dict[str, Any] = Body(...), + current_user: BaseUserWithType = Depends(get_current_user_or_guest), + database: RedisDatabase = Depends(get_database) +): + """Create a new chat session with optional candidate username association""" + try: + # Extract username if provided + username = session_data.get("username") + candidate_id = None + candidate_data = None + + # If username is provided, look up the candidate + if username: + logger.info(f"šŸ” Looking up candidate with username: {username}") + + # Get all candidates and find by username + all_candidates_data = await database.get_all_candidates() + candidates_list = [Candidate.model_validate(data) for data in all_candidates_data.values()] + + # Find candidate by username (case-insensitive) + matching_candidates = [ + c for c in candidates_list + if c.username.lower() == username.lower() + ] + + if not matching_candidates: + return JSONResponse( + status_code=404, + content=create_error_response("CANDIDATE_NOT_FOUND", f"Candidate with username '{username}' not found") + ) + + candidate_data = matching_candidates[0] + candidate_id = candidate_data.id + logger.info(f"āœ… Found candidate: {candidate_data.full_name} (ID: {candidate_id})") + + # Add required fields + session_id = str(uuid.uuid4()) + session_data["id"] = session_id + session_data["userId"] = current_user.id + session_data["createdAt"] = datetime.now(UTC).isoformat() + session_data["lastActivity"] = datetime.now(UTC).isoformat() + + # Set up context with candidate association if username was provided + context = session_data.get("context", {}) + if candidate_id and candidate_data: + context["relatedEntityId"] = candidate_id + context["relatedEntityType"] = "candidate" + + # Add candidate info to additional context for AI reference + additional_context = context.get("additionalContext", {}) + additional_context["candidateInfo"] = { + "id": candidate_data.id, + "name": candidate_data.full_name, + "email": candidate_data.email, + "username": candidate_data.username, + "skills": [skill.name for skill in candidate_data.skills] if candidate_data.skills else [], + "experience": len(candidate_data.experience) if candidate_data.experience else 0, + "location": candidate_data.location.city if candidate_data.location else "Unknown" + } + context["additionalContext"] = additional_context + + # Set a descriptive title if not provided + if not session_data.get("title"): + session_data["title"] = f"Chat about {candidate_data.full_name}" + + session_data["context"] = context + + # Create chat session + chat_session = ChatSession.model_validate(session_data) + await database.set_chat_session(chat_session.id, chat_session.model_dump()) + + logger.info(f"āœ… Chat session created: {chat_session.id} for user {current_user.id}" + + (f" about candidate {candidate_data.full_name}" if candidate_data else "")) + + return create_success_response(chat_session.model_dump(by_alias=True)) + + except Exception as e: + logger.error(backstory_traceback.format_exc()) + logger.error(f"āŒ Chat session creation error: {e}") + logger.info(json.dumps(session_data, indent=2)) + return JSONResponse( + status_code=400, + content=create_error_response("CREATION_FAILED", str(e)) + ) + +@router.post("/sessions/messages/stream") +async def post_chat_session_message_stream( + user_message: ChatMessageUser = Body(...), + current_user = Depends(get_current_user_or_guest), + database: RedisDatabase = Depends(get_database) +): + """Post a message to a chat session and stream the response with persistence""" + try: + chat_session_data = await database.get_chat_session(user_message.session_id) + if not chat_session_data: + logger.info("šŸ”— Chat session not found for session ID: " + user_message.session_id) + return JSONResponse( + status_code=404, + content=create_error_response("NOT_FOUND", "Chat session not found") + ) + chat_session = ChatSession.model_validate(chat_session_data) + chat_type = chat_session.context.type + candidate_info = chat_session.context.additional_context.get("candidateInfo", {}) if chat_session.context and chat_session.context.additional_context else None + + # Get candidate info if this chat is about a specific candidate + if candidate_info: + logger.info(f"šŸ”— Chat session {user_message.session_id} about candidate {candidate_info['name']} accessed by user {current_user.id}") + else: + logger.info(f"šŸ”— Chat session {user_message.session_id} type {chat_type} accessed by user {current_user.id}") + return JSONResponse( + status_code=400, + content=create_error_response("CANDIDATE_REQUIRED", "This chat session requires a candidate association") + ) + + candidate_data = await database.get_candidate(candidate_info["id"]) if candidate_info else None + candidate : Candidate | None = Candidate.model_validate(candidate_data) if candidate_data else None + if not candidate: + logger.info(f"šŸ”— Candidate not found for chat session {user_message.session_id} with ID {candidate_info['id']}") + return JSONResponse( + status_code=404, + content=create_error_response("CANDIDATE_NOT_FOUND", "Candidate not found for this chat session") + ) + logger.info(f"šŸ”— User {current_user.id} posting message to chat session {user_message.session_id} with query length: {len(user_message.content)}") + + async with entities.get_candidate_entity(candidate=candidate) as candidate_entity: + # Entity automatically released when done + chat_agent = candidate_entity.get_or_create_agent(agent_type=chat_type) + if not chat_agent: + logger.info(f"šŸ”— No chat agent found for session {user_message.session_id} with type {chat_type}") + return JSONResponse( + status_code=400, + content=create_error_response("AGENT_NOT_FOUND", "No agent found for this chat type") + ) + + # Persist user message to database + await database.add_chat_message(user_message.session_id, user_message.model_dump()) + logger.info(f"šŸ’¬ User message saved to database for session {user_message.session_id}") + + # Update session last activity + chat_session_data["lastActivity"] = datetime.now(UTC).isoformat() + await database.set_chat_session(user_message.session_id, chat_session_data) + + return await stream_agent_response( + chat_agent=chat_agent, + user_message=user_message, + database=database, + chat_session_data=chat_session_data, + ) + + except Exception as e: + logger.error(backstory_traceback.format_exc()) + logger.error(f"āŒ Chat message streaming error") + return JSONResponse( + status_code=500, + content=create_error_response("STREAMING_ERROR", "") + ) + +@router.get("/sessions/{session_id}/messages") +async def get_chat_session_messages( + session_id: str = Path(...), + current_user = Depends(get_current_user_or_guest), + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=100), # Increased default for chat messages + database: RedisDatabase = Depends(get_database) +): + """Get persisted chat messages for a session""" + try: + chat_session_data = await database.get_chat_session(session_id) + if not chat_session_data: + return JSONResponse( + status_code=404, + content=create_error_response("NOT_FOUND", "Chat session not found") + ) + + # Get messages from database + chat_messages = await database.get_chat_messages(session_id) + + # Convert to ChatMessage objects and sort by timestamp + messages_list = [] + for msg_data in chat_messages: + try: + message = ChatMessage.model_validate(msg_data) + messages_list.append(message) + except Exception as e: + logger.warning(f"āš ļø Failed to validate message: {e}") + continue + + # Sort by timestamp (oldest first for chat history) + messages_list.sort(key=lambda x: x.timestamp) + + # Apply pagination + total = len(messages_list) + start = (page - 1) * limit + end = start + limit + paginated_messages = messages_list[start:end] + + paginated_response = create_paginated_response( + [m.model_dump(by_alias=True) for m in paginated_messages], + page, limit, total + ) + + return create_success_response(paginated_response) + + except Exception as e: + logger.error(f"āŒ Get chat messages error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("FETCH_ERROR", str(e)) + ) + +@router.patch("/sessions/{session_id}") +async def update_chat_session( + session_id: str = Path(...), + updates: Dict[str, Any] = Body(...), + current_user = Depends(get_current_user_or_guest), + database: RedisDatabase = Depends(get_database) +): + """Update a chat session's properties""" + try: + # Get the existing session + session_data = await database.get_chat_session(session_id) + if not session_data: + return JSONResponse( + status_code=404, + content=create_error_response("NOT_FOUND", "Chat session not found") + ) + + session = ChatSession.model_validate(session_data) + + # Check authorization - user can only update their own sessions + if session.user_id != current_user.id: + return JSONResponse( + status_code=403, + content=create_error_response("FORBIDDEN", "Cannot update another user's chat session") + ) + + # Validate and apply updates + allowed_fields = {"title", "context", "isArchived", "systemPrompt"} + filtered_updates = {k: v for k, v in updates.items() if k in allowed_fields} + + if not filtered_updates: + return JSONResponse( + status_code=400, + content=create_error_response("INVALID_UPDATES", "No valid fields provided for update") + ) + + # Apply updates to session data + session_dict = session.model_dump() + + # Handle special field mappings (camelCase to snake_case) + if "isArchived" in filtered_updates: + session_dict["is_archived"] = filtered_updates["isArchived"] + if "systemPrompt" in filtered_updates: + session_dict["system_prompt"] = filtered_updates["systemPrompt"] + if "title" in filtered_updates: + session_dict["title"] = filtered_updates["title"] + if "context" in filtered_updates: + # Merge context updates with existing context + existing_context = session_dict.get("context", {}) + context_updates = filtered_updates["context"] + + # Update specific context fields while preserving others + for context_key, context_value in context_updates.items(): + if context_key == "additionalContext": + # Merge additional context + existing_additional = existing_context.get("additional_context", {}) + existing_additional.update(context_value) + existing_context["additional_context"] = existing_additional + else: + # Convert camelCase to snake_case for context fields + snake_key = context_key + if context_key == "relatedEntityId": + snake_key = "related_entity_id" + elif context_key == "relatedEntityType": + snake_key = "related_entity_type" + elif context_key == "aiParameters": + snake_key = "ai_parameters" + + existing_context[snake_key] = context_value + + session_dict["context"] = existing_context + + # Update last activity timestamp + session_dict["last_activity"] = datetime.now(UTC).isoformat() + + # Validate the updated session + updated_session = ChatSession.model_validate(session_dict) + + # Save to database + await database.set_chat_session(session_id, updated_session.model_dump()) + + logger.info(f"āœ… Chat session {session_id} updated by user {current_user.id}") + + return create_success_response(updated_session.model_dump(by_alias=True)) + + except ValueError as ve: + logger.warning(f"āš ļø Validation error updating chat session: {ve}") + return JSONResponse( + status_code=400, + content=create_error_response("VALIDATION_ERROR", str(ve)) + ) + except Exception as e: + logger.error(f"āŒ Update chat session error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("UPDATE_ERROR", str(e)) + ) + +@router.delete("/sessions/{session_id}") +async def delete_chat_session( + session_id: str = Path(...), + current_user = Depends(get_current_user_or_guest), + database: RedisDatabase = Depends(get_database) +): + """Delete a chat session and all its messages""" + try: + # Get the session to verify it exists and check ownership + session_data = await database.get_chat_session(session_id) + if not session_data: + return JSONResponse( + status_code=404, + content=create_error_response("NOT_FOUND", "Chat session not found") + ) + + session = ChatSession.model_validate(session_data) + + # Check authorization - user can only delete their own sessions + if session.user_id != current_user.id: + return JSONResponse( + status_code=403, + content=create_error_response("FORBIDDEN", "Cannot delete another user's chat session") + ) + + # Delete all messages associated with this session + try: + await database.delete_chat_messages(session_id) + chat_messages = await database.get_chat_messages(session_id) + message_count = len(chat_messages) + logger.info(f"šŸ—‘ļø Deleted {message_count} messages from session {session_id}") + + except Exception as e: + logger.warning(f"āš ļø Error deleting messages for session {session_id}: {e}") + # Continue with session deletion even if message deletion fails + + # Delete the session itself + await database.delete_chat_session(session_id) + + logger.info(f"šŸ—‘ļø Chat session {session_id} deleted by user {current_user.id}") + + return create_success_response({ + "success": True, + "message": "Chat session deleted successfully", + "sessionId": session_id + }) + + except Exception as e: + logger.error(f"āŒ Delete chat session error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("DELETE_ERROR", str(e)) + ) + +@router.patch("/sessions/{session_id}/reset") +async def reset_chat_session( + session_id: str = Path(...), + current_user = Depends(get_current_user_or_guest), + database: RedisDatabase = Depends(get_database) +): + """Delete a chat session and all its messages""" + try: + # Get the session to verify it exists and check ownership + session_data = await database.get_chat_session(session_id) + if not session_data: + return JSONResponse( + status_code=404, + content=create_error_response("NOT_FOUND", "Chat session not found") + ) + + session = ChatSession.model_validate(session_data) + + # Check authorization - user can only delete their own sessions + if session.user_id != current_user.id: + return JSONResponse( + status_code=403, + content=create_error_response("FORBIDDEN", "Cannot reset another user's chat session") + ) + + # Delete all messages associated with this session + try: + await database.delete_chat_messages(session_id) + chat_messages = await database.get_chat_messages(session_id) + message_count = len(chat_messages) + logger.info(f"šŸ—‘ļø Deleted {message_count} messages from session {session_id}") + + except Exception as e: + logger.warning(f"āš ļø Error deleting messages for session {session_id}: {e}") + # Continue with session deletion even if message deletion fails + + + logger.info(f"šŸ—‘ļø Chat session {session_id} reset by user {current_user.id}") + + return create_success_response({ + "success": True, + "message": "Chat session reset successfully", + "sessionId": session_id + }) + + except Exception as e: + logger.error(f"āŒ Reset chat session error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("RESET_ERROR", str(e)) + ) + + diff --git a/src/backend/routes/debug.py b/src/backend/routes/debug.py new file mode 100644 index 0000000..e389acd --- /dev/null +++ b/src/backend/routes/debug.py @@ -0,0 +1,87 @@ +""" +Debugging Endpoints +""" +import json +from datetime import datetime, UTC + +from fastapi import APIRouter, Depends, Body, Path +from fastapi.responses import JSONResponse +from pydantic import BaseModel, EmailStr, ValidationError, field_validator + +from auth_utils import AuthenticationManager, SecurityConfig +import backstory_traceback as backstory_traceback +from utils.rate_limiter import RateLimiter +from database import RedisDatabase, redis_manager +from device_manager import DeviceManager +from email_service import VerificationEmailRateLimiter, email_service +from logger import logger +from models import ( + LoginRequest, CreateCandidateRequest, CreateEmployerRequest, + Candidate, Employer, Guest, AuthResponse, + MFARequest, MFAData, MFAVerifyRequest, + EmailVerificationRequest, ResendVerificationRequest, + MFARequestResponse, MFARequestResponse +) +from utils.dependencies import ( + get_current_admin, get_database, get_current_user, get_current_user_or_guest, + create_access_token, verify_token_with_blacklist +) +from utils.responses import create_success_response, create_error_response +from utils.rate_limiter import get_rate_limiter +from auth_utils import ( + AuthenticationManager, + validate_password_strength, + sanitize_login_input, + SecurityConfig +) + +# Create router for authentication endpoints +router = APIRouter(prefix="/auth", tags=["authentication"]) + +@router.get("/guest/{guest_id}") +async def debug_guest_session( + guest_id: str = Path(...), + admin_user = Depends(get_current_admin), + database: RedisDatabase = Depends(get_database) +): + """Debug guest session issues (admin only)""" + try: + # Check primary storage + primary_data = await database.redis.hget("guests", guest_id) # type: ignore + primary_exists = primary_data is not None + + # Check backup storage + backup_data = await database.redis.get(f"guest_backup:{guest_id}") + backup_exists = backup_data is not None + + # Check user lookup + user_lookup = await database.get_user_by_id(guest_id) + + # Get TTL info + primary_ttl = await database.redis.ttl(f"guests") + backup_ttl = await database.redis.ttl(f"guest_backup:{guest_id}") + + debug_info = { + "guest_id": guest_id, + "primary_storage": { + "exists": primary_exists, + "data": json.loads(primary_data) if primary_data else None, + "ttl": primary_ttl + }, + "backup_storage": { + "exists": backup_exists, + "data": json.loads(backup_data) if backup_data else None, + "ttl": backup_ttl + }, + "user_lookup": user_lookup, + "timestamp": datetime.now(UTC).isoformat() + } + + return create_success_response(debug_info) + + except Exception as e: + logger.error(f"āŒ Debug guest session error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("DEBUG_ERROR", str(e)) + ) diff --git a/src/backend/routes/employers.py b/src/backend/routes/employers.py new file mode 100644 index 0000000..2c23a18 --- /dev/null +++ b/src/backend/routes/employers.py @@ -0,0 +1,128 @@ +""" +Employer Routes +""" +import asyncio +import io +import json +import pathlib +import re +import shutil +import jwt +import secrets +import uuid +import os +from datetime import datetime, timedelta, timezone, UTC +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, File, Form, HTTPException, Depends, Body, Path, Query, Request, BackgroundTasks, UploadFile +from fastapi.responses import FileResponse, JSONResponse, StreamingResponse +from markitdown import MarkItDown, StreamInfo +from pydantic import BaseModel, ValidationError + +from auth_utils import AuthenticationManager +from utils.helpers import create_job_from_content, filter_and_paginate, get_document_type_from_filename, get_skill_cache_key, get_requirements_list +from database import RedisDatabase, redis_manager +from logger import logger +from models import ( + MOCK_UUID, ApiActivityType, ApiMessageType, ApiStatusType, CandidateAI, ChatContextType, ChatMessage, ChatMessageError, ChatMessageRagSearch, ChatMessageResume, ChatMessageSkillAssessment, ChatMessageStatus, ChatMessageStreaming, ChatMessageUser, ChatSession, Document, DocumentContentResponse, DocumentListResponse, DocumentMessage, DocumentOptions, DocumentType, DocumentUpdateRequest, Job, JobRequirements, JobRequirementsMessage, LoginRequest, CreateCandidateRequest, CreateEmployerRequest, + Candidate, Employer, Guest, AuthResponse, + MFARequest, MFAData, MFAVerifyRequest, + EmailVerificationRequest, RAGDocumentRequest, RagContentMetadata, RagContentResponse, ResendVerificationRequest, + MFARequestResponse, MFARequestResponse, Resume, ResumeMessage, SkillAssessment, UserType +) +from utils.dependencies import ( + get_current_admin, get_database, get_current_user, get_current_user_or_guest, + create_access_token, verify_token_with_blacklist, prometheus_collector +) +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 +from email_service import email_service + +# Create router for job endpoints +router = APIRouter(prefix="/employers", tags=["employers"]) + +@router.post("") +async def create_employer_with_verification( + request: CreateEmployerRequest, + background_tasks: BackgroundTasks, + database: RedisDatabase = Depends(get_database) +): + """Create a new employer with email verification""" + try: + # Similar to candidate creation but for employer + auth_manager = AuthenticationManager(database) + + user_exists, conflict_field = await auth_manager.check_user_exists( + request.email, + request.username + ) + + if user_exists and conflict_field: + return JSONResponse( + status_code=409, + content=create_error_response( + "USER_EXISTS", + f"A user with this {conflict_field} already exists" + ) + ) + + employer_id = str(uuid.uuid4()) + current_time = datetime.now(timezone.utc) + + employer_data = { + "id": employer_id, + "email": request.email, + "companyName": request.company_name, + "industry": request.industry, + "companySize": request.company_size, + "companyDescription": request.company_description, + "websiteUrl": request.website_url, + "phone": request.phone, + "createdAt": current_time.isoformat(), + "updatedAt": current_time.isoformat(), + "status": "pending", # Not active until verified + "userType": "employer", + "location": { + "city": "", + "country": "", + "remote": False + }, + "socialLinks": [] + } + + verification_token = secrets.token_urlsafe(32) + + await database.store_email_verification_token( + request.email, + verification_token, + "employer", + { + "employer_data": employer_data, + "password": request.password, + "username": request.username + } + ) + + background_tasks.add_task( + email_service.send_verification_email, + request.email, + verification_token, + request.company_name + ) + + logger.info(f"āœ… Employer registration initiated for: {request.email}") + + return create_success_response({ + "message": "Registration successful! Please check your email to verify your account.", + "email": request.email, + "verificationRequired": True + }) + + except Exception as e: + logger.error(f"āŒ Employer creation error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("CREATION_FAILED", "Failed to create employer account") + ) + diff --git a/src/backend/routes/jobs.py b/src/backend/routes/jobs.py new file mode 100644 index 0000000..6845aaf --- /dev/null +++ b/src/backend/routes/jobs.py @@ -0,0 +1,610 @@ +""" +Job Routes +""" +import asyncio +import io +import json +import pathlib +import re +import shutil +import jwt +import secrets +import uuid +import os +from datetime import datetime, timedelta, timezone, UTC +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, File, Form, HTTPException, Depends, Body, Path, Query, Request, BackgroundTasks, UploadFile +from fastapi.responses import FileResponse, JSONResponse, StreamingResponse +from markitdown import MarkItDown, StreamInfo + +import backstory_traceback as backstory_traceback +import defines +from agents.generate_resume import GenerateResume +from agents.base import CandidateEntity +from utils.helpers import create_job_from_content, filter_and_paginate, get_document_type_from_filename, get_skill_cache_key, get_requirements_list +from database import RedisDatabase, redis_manager +from logger import logger +from models import ( + MOCK_UUID, ApiActivityType, ApiMessageType, ApiStatusType, CandidateAI, ChatContextType, ChatMessage, ChatMessageError, ChatMessageRagSearch, ChatMessageResume, ChatMessageSkillAssessment, ChatMessageStatus, ChatMessageStreaming, ChatMessageUser, ChatSession, Document, DocumentContentResponse, DocumentListResponse, DocumentMessage, DocumentOptions, DocumentType, DocumentUpdateRequest, Job, JobRequirements, JobRequirementsMessage, LoginRequest, CreateCandidateRequest, CreateEmployerRequest, + Candidate, Employer, Guest, AuthResponse, + MFARequest, MFAData, MFAVerifyRequest, + EmailVerificationRequest, RAGDocumentRequest, RagContentMetadata, RagContentResponse, ResendVerificationRequest, + MFARequestResponse, MFARequestResponse, Resume, ResumeMessage, SkillAssessment, UserType +) +from utils.dependencies import ( + get_current_admin, get_database, get_current_user, get_current_user_or_guest, + create_access_token, verify_token_with_blacklist, prometheus_collector +) +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=f"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. +""" + ): + 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 as e: + pass + logger.info(f"āœ… 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=f"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""" + is_employer = 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("/{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.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""" + 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), + database: RedisDatabase = Depends(get_database) +): + """Search 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.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") + ) diff --git a/src/backend/routes/resumes.py b/src/backend/routes/resumes.py new file mode 100644 index 0000000..217d46f --- /dev/null +++ b/src/backend/routes/resumes.py @@ -0,0 +1,308 @@ +""" +Resume Routes +""" +import json +import pathlib +import re +import shutil +import jwt +import secrets +import uuid +import os +from datetime import datetime, timedelta, timezone, UTC +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, File, Form, HTTPException, Depends, Body, Path, Query, Request, BackgroundTasks, UploadFile +from fastapi.responses import FileResponse, JSONResponse, StreamingResponse +from pydantic import BaseModel, ValidationError + +import backstory_traceback as backstory_traceback +from utils.helpers import filter_and_paginate, get_document_type_from_filename, get_skill_cache_key, get_requirements_list +from database import RedisDatabase, redis_manager +from logger import logger +from models import ( + MOCK_UUID, ApiActivityType, ApiMessageType, ApiStatusType, CandidateAI, ChatContextType, ChatMessageError, ChatMessageRagSearch, ChatMessageResume, ChatMessageSkillAssessment, ChatMessageStatus, ChatMessageStreaming, ChatMessageUser, ChatSession, Document, DocumentContentResponse, DocumentListResponse, DocumentMessage, DocumentOptions, DocumentType, DocumentUpdateRequest, Job, JobRequirements, LoginRequest, CreateCandidateRequest, CreateEmployerRequest, + Candidate, Employer, Guest, AuthResponse, + MFARequest, MFAData, MFAVerifyRequest, + EmailVerificationRequest, RAGDocumentRequest, RagContentMetadata, RagContentResponse, ResendVerificationRequest, + MFARequestResponse, MFARequestResponse, Resume, ResumeMessage, SkillAssessment, UserType +) +from utils.dependencies import ( + get_current_admin, get_database, get_current_user, get_current_user_or_guest, + create_access_token, verify_token_with_blacklist, prometheus_collector +) +from utils.responses import create_paginated_response, create_success_response, create_error_response +import utils.llm_proxy as llm_manager +from utils.rate_limiter import get_rate_limiter + +# Create router for authentication endpoints +router = APIRouter(prefix="/resumes", tags=["resumes"]) + +@router.post("/{candidate_id}/{job_id}") +async def create_candidate_resume( + candidate_id: str = Path(..., description="ID of the candidate"), + job_id: str = Path(..., description="ID of the job"), + resume_content: str = Body(...), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Create a new resume for a candidate/job combination""" + 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( + session_id=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( + session_id=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) + + logger.info(f"šŸ“„ Saving resume for candidate {candidate.first_name} {candidate.last_name} for job '{job.title}'") + + # Job and Candidate are valid. Save the resume + resume = Resume( + job_id=job_id, + candidate_id=candidate_id, + resume=resume_content, + ) + resume_message: ResumeMessage = ResumeMessage( + session_id=MOCK_UUID, # No session ID for document uploads + resume=resume + ) + + # Save to database + success = await database.set_resume(current_user.id, resume.model_dump()) + if not success: + error_message = ChatMessageError( + session_id=MOCK_UUID, + content="Failed to save resume to database" + ) + yield error_message + return + + logger.info(f"āœ… Successfully saved resume {resume_message.resume.id} for user {current_user.id}") + yield resume_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(message_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"āŒ Resume creation error: {e}") + return StreamingResponse( + iter([json.dumps(ChatMessageError( + session_id=MOCK_UUID, # No session ID for document uploads + content="Failed to create resume" + ).model_dump(mode='json', by_alias=True))]), + media_type="text/event-stream" + ) + +@router.get("") +async def get_user_resumes( + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Get all resumes for the current user""" + try: + resumes_data = await database.get_all_resumes_for_user(current_user.id) + resumes : List[Resume] = [Resume.model_validate(data) for data in resumes_data] + for resume in resumes: + job_data = await database.get_job(resume.job_id) + if job_data: + resume.job = Job.model_validate(job_data) + candidate_data = await database.get_candidate(resume.candidate_id) + if candidate_data: + resume.candidate = Candidate.model_validate(candidate_data) + resumes.sort(key=lambda x: x.updated_at, reverse=True) # Sort by creation date + return create_success_response({ + "resumes": resumes, + "count": len(resumes) + }) + except Exception as e: + logger.error(f"āŒ Error retrieving resumes for user {current_user.id}: {e}") + raise HTTPException(status_code=500, detail="Failed to retrieve resumes") + +@router.get("/{resume_id}") +async def get_resume( + resume_id: str = Path(..., description="ID of the resume"), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Get a specific resume by ID""" + try: + resume = await database.get_resume(current_user.id, resume_id) + if not resume: + raise HTTPException(status_code=404, detail="Resume not found") + + return { + "success": True, + "resume": resume + } + except HTTPException: + raise + except Exception as e: + logger.error(f"āŒ Error retrieving resume {resume_id} for user {current_user.id}: {e}") + raise HTTPException(status_code=500, detail="Failed to retrieve resume") + +@router.delete("/{resume_id}") +async def delete_resume( + resume_id: str = Path(..., description="ID of the resume"), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Delete a specific resume""" + try: + success = await database.delete_resume(current_user.id, resume_id) + if not success: + raise HTTPException(status_code=404, detail="Resume not found") + + return { + "success": True, + "message": f"Resume {resume_id} deleted successfully" + } + except HTTPException: + raise + except Exception as e: + logger.error(f"āŒ Error deleting resume {resume_id} for user {current_user.id}: {e}") + raise HTTPException(status_code=500, detail="Failed to delete resume") + +@router.get("/candidate/{candidate_id}") +async def get_resumes_by_candidate( + candidate_id: str = Path(..., description="ID of the candidate"), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Get all resumes for a specific candidate""" + try: + resumes = await database.get_resumes_by_candidate(current_user.id, candidate_id) + return { + "success": True, + "candidate_id": candidate_id, + "resumes": resumes, + "count": len(resumes) + } + except Exception as e: + logger.error(f"āŒ Error retrieving resumes for candidate {candidate_id}: {e}") + raise HTTPException(status_code=500, detail="Failed to retrieve candidate resumes") + +@router.get("/job/{job_id}") +async def get_resumes_by_job( + job_id: str = Path(..., description="ID of the job"), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Get all resumes for a specific job""" + try: + resumes = await database.get_resumes_by_job(current_user.id, job_id) + return { + "success": True, + "job_id": job_id, + "resumes": resumes, + "count": len(resumes) + } + except Exception as e: + logger.error(f"āŒ Error retrieving resumes for job {job_id}: {e}") + raise HTTPException(status_code=500, detail="Failed to retrieve job resumes") + +@router.get("/search") +async def search_resumes( + q: str = Query(..., description="Search query"), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Search resumes by content""" + try: + resumes = await database.search_resumes_for_user(current_user.id, q) + return { + "success": True, + "query": q, + "resumes": resumes, + "count": len(resumes) + } + except Exception as e: + logger.error(f"āŒ Error searching resumes for user {current_user.id}: {e}") + raise HTTPException(status_code=500, detail="Failed to search resumes") + +@router.get("/stats") +async def get_resume_statistics( + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Get resume statistics for the current user""" + try: + stats = await database.get_resume_statistics(current_user.id) + return { + "success": True, + "statistics": stats + } + except Exception as e: + logger.error(f"āŒ Error retrieving resume statistics for user {current_user.id}: {e}") + raise HTTPException(status_code=500, detail="Failed to retrieve resume statistics") + +@router.put("/{resume_id}") +async def update_resume( + resume_id: str = Path(..., description="ID of the resume"), + resume: str = Body(..., description="Updated resume content"), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Update the content of a specific resume""" + try: + updates = { + "resume": resume, + "updated_at": datetime.now(UTC).isoformat() + } + + updated_resume_data = await database.update_resume(current_user.id, resume_id, updates) + if not updated_resume_data: + logger.warning(f"āš ļø Resume {resume_id} not found for user {current_user.id}") + raise HTTPException(status_code=404, detail="Resume not found") + updated_resume = Resume.model_validate(updated_resume_data) if updated_resume_data else None + + return create_success_response({ + "success": True, + "message": f"Resume {resume_id} updated successfully", + "resume": updated_resume + }) + except HTTPException: + raise + except Exception as e: + logger.error(f"āŒ Error updating resume {resume_id} for user {current_user.id}: {e}") + raise HTTPException(status_code=500, detail="Failed to update resume") diff --git a/src/backend/routes/system.py b/src/backend/routes/system.py new file mode 100644 index 0000000..ff13f01 --- /dev/null +++ b/src/backend/routes/system.py @@ -0,0 +1,67 @@ +""" +Health/system routes +""" +import json +from datetime import datetime, UTC + +from fastapi import APIRouter, Depends, Body, HTTPException, Path, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel, EmailStr, ValidationError, field_validator + +from auth_utils import AuthenticationManager, SecurityConfig +import backstory_traceback as backstory_traceback +from utils.rate_limiter import RateLimiter +from database import RedisDatabase, redis_manager, Redis +from device_manager import DeviceManager +from email_service import VerificationEmailRateLimiter, email_service +from logger import logger +from models import ( + LoginRequest, CreateCandidateRequest, CreateEmployerRequest, + Candidate, Employer, Guest, AuthResponse, + MFARequest, MFAData, MFAVerifyRequest, + EmailVerificationRequest, ResendVerificationRequest, + MFARequestResponse, MFARequestResponse +) +from utils.dependencies import ( + get_current_admin, get_database, get_current_user, get_current_user_or_guest, + create_access_token, verify_token_with_blacklist +) +from utils.responses import create_success_response, create_error_response +from utils.rate_limiter import get_rate_limiter +from auth_utils import ( + AuthenticationManager, + validate_password_strength, + sanitize_login_input, + SecurityConfig +) + +# Create router for authentication endpoints +router = APIRouter(prefix="/system", tags=["system"]) + +async def get_redis() -> Redis: + """Dependency to get Redis client""" + return redis_manager.get_client() + + +@router.get("/info") +async def get_system_info(request: Request): + """Get system information""" + from system_info import system_info # Import system_info function from system_info module + system = system_info() + + return create_success_response(system.model_dump(mode='json')) + +@router.get("/redis/stats") +async def redis_stats(redis: Redis = Depends(get_redis)): + try: + info = await redis.info() + return { + "connected_clients": info.get("connected_clients"), + "used_memory_human": info.get("used_memory_human"), + "total_commands_processed": info.get("total_commands_processed"), + "keyspace_hits": info.get("keyspace_hits"), + "keyspace_misses": info.get("keyspace_misses"), + "uptime_in_seconds": info.get("uptime_in_seconds") + } + except Exception as e: + raise HTTPException(status_code=503, detail=f"Redis stats unavailable: {e}") diff --git a/src/backend/routes/users.py b/src/backend/routes/users.py new file mode 100644 index 0000000..750e854 --- /dev/null +++ b/src/backend/routes/users.py @@ -0,0 +1,99 @@ +""" +Job Routes +""" +import io +import json +import pathlib +import re +import shutil +import jwt +import secrets +import uuid +import os +from datetime import datetime, timedelta, timezone, UTC +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, File, Form, HTTPException, Depends, Body, Path, Query, Request, BackgroundTasks, UploadFile +from fastapi.responses import FileResponse, JSONResponse, StreamingResponse + +from database import RedisDatabase, redis_manager +from device_manager import DeviceManager +from email_service import VerificationEmailRateLimiter, email_service +from logger import logger +from models import ( + MOCK_UUID, ApiActivityType, ApiMessageType, ApiStatusType, BaseUserWithType, CandidateAI, ChatContextType, ChatMessageError, ChatMessageRagSearch, ChatMessageResume, ChatMessageSkillAssessment, ChatMessageStatus, ChatMessageStreaming, ChatMessageUser, ChatSession, Document, DocumentContentResponse, DocumentListResponse, DocumentMessage, DocumentOptions, DocumentType, DocumentUpdateRequest, Job, JobRequirements, LoginRequest, CreateCandidateRequest, CreateEmployerRequest, + Candidate, Employer, Guest, AuthResponse, + MFARequest, MFAData, MFAVerifyRequest, + EmailVerificationRequest, RAGDocumentRequest, RagContentMetadata, RagContentResponse, ResendVerificationRequest, + MFARequestResponse, MFARequestResponse, Resume, ResumeMessage, SkillAssessment, UserType +) +from utils.dependencies import ( + get_current_admin, get_database, get_current_user, get_current_user_or_guest, + create_access_token, verify_token_with_blacklist, prometheus_collector +) +from utils.rate_limiter import get_rate_limiter +from utils.responses import create_paginated_response, create_success_response, create_error_response + +# Create router for job endpoints +router = APIRouter(prefix="/users", tags=["users"]) + +# reference can be candidateId, username, or email +@router.get("/users/{reference}") +async def get_user( + reference: str = Path(...), + database: RedisDatabase = Depends(get_database) +): + """Get a candidate by username""" + try: + # Normalize reference to lowercase for case-insensitive search + query_lower = reference.lower() + + all_candidate_data = await database.get_all_candidates() + if not all_candidate_data: + logger.warning(f"āš ļø No users found in database") + return JSONResponse( + status_code=404, + content=create_error_response("NOT_FOUND", "No users found") + ) + + user_data = None + for user in all_candidate_data.values(): + if (user.get("id", "").lower() == query_lower or + user.get("username", "").lower() == query_lower or + user.get("email", "").lower() == query_lower): + user_data = user + break + + if not user_data: + all_guest_data = await database.get_all_guests() + if not all_guest_data: + logger.warning(f"āš ļø No guests found in database") + return JSONResponse( + status_code=404, + content=create_error_response("NOT_FOUND", "No users found") + ) + for user in all_guest_data.values(): + if (user.get("id", "").lower() == query_lower or + user.get("username", "").lower() == query_lower or + user.get("email", "").lower() == query_lower): + user_data = user + break + + if not user_data: + logger.warning(f"āš ļø User nor Guest found for reference: {reference}") + return JSONResponse( + status_code=404, + content=create_error_response("NOT_FOUND", "User not found") + ) + + user = BaseUserWithType.model_validate(user_data) + + return create_success_response(user.model_dump(by_alias=True)) + + except Exception as e: + logger.error(f"āŒ Get user error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("FETCH_ERROR", str(e)) + ) + diff --git a/src/backend/utils/__init__.py b/src/backend/utils/__init__.py new file mode 100644 index 0000000..7752e2e --- /dev/null +++ b/src/backend/utils/__init__.py @@ -0,0 +1,54 @@ +""" +Utils package - Utility functions and dependencies +""" + +# Import commonly used utilities for easy access +from .dependencies import ( + get_database, + get_current_user, + get_current_user_or_guest, + get_current_admin, + create_access_token, + verify_token_with_blacklist +) + +from .responses import ( + create_success_response, + create_error_response, + create_paginated_response +) + +from .helpers import ( + filter_and_paginate, + stream_agent_response, + get_candidate_files_dir, + get_document_type_from_filename, + get_skill_cache_key +) + +from .rate_limiter import ( + get_rate_limiter +) + +__all__ = [ + # Dependencies + "get_database", + "get_current_user", + "get_current_user_or_guest", + "get_current_admin", + "get_rate_limiter", + "create_access_token", + "verify_token_with_blacklist", + + # Responses + "create_success_response", + "create_error_response", + "create_paginated_response", + + # Helpers + "filter_and_paginate", + "stream_agent_response", + "get_candidate_files_dir", + "get_document_type_from_filename", + "get_skill_cache_key" +] \ No newline at end of file diff --git a/src/backend/utils/dependencies.py b/src/backend/utils/dependencies.py new file mode 100644 index 0000000..8775017 --- /dev/null +++ b/src/backend/utils/dependencies.py @@ -0,0 +1,206 @@ +""" +Shared dependencies for FastAPI routes +""" +from __future__ import annotations +import jwt +import os +from datetime import datetime, timedelta, timezone, UTC +from typing import Optional + +from fastapi import HTTPException, Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from prometheus_client import CollectorRegistry +from prometheus_fastapi_instrumentator import Instrumentator + +import defines + +from database import RedisDatabase, redis_manager, DatabaseManager +from models import BaseUserWithType, Candidate, CandidateAI, Employer, Guest +from logger import logger +from background_tasks import BackgroundTaskManager + +#from . rate_limiter import RateLimiter + +# Security +security = HTTPBearer() +JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "") +if JWT_SECRET_KEY == "": + raise ValueError("JWT_SECRET_KEY environment variable is not set") +ALGORITHM = "HS256" + +background_task_manager: Optional[BackgroundTaskManager] = None + +# Global database manager reference +db_manager = None + +def set_db_manager(manager: DatabaseManager): + """Set the global database manager reference""" + global db_manager + db_manager = manager + +def get_database() -> RedisDatabase: + """ + Safe database dependency that checks for availability + Raises HTTP 503 if database is not available + """ + global db_manager + + if db_manager is None: + logger.error("Database manager not initialized") + raise HTTPException( + status_code=503, + detail="Database not available - service starting up" + ) + + if db_manager.is_shutting_down: + logger.warning("Database is shutting down") + raise HTTPException( + status_code=503, + detail="Service is shutting down" + ) + + try: + return db_manager.get_database() + except RuntimeError as e: + logger.error(f"Database not available: {e}") + raise HTTPException( + status_code=503, + detail="Database connection not available" + ) + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.now(UTC) + expires_delta + else: + expire = datetime.now(UTC) + timedelta(hours=24) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +async def verify_token_with_blacklist(credentials: HTTPAuthorizationCredentials = Depends(security)): + """Enhanced token verification with guest session recovery""" + try: + if not db_manager: + raise HTTPException(status_code=500, detail="Database not initialized") + # First decode the token + payload = jwt.decode(credentials.credentials, JWT_SECRET_KEY, algorithms=[ALGORITHM]) + user_id: str = payload.get("sub") + token_type: str = payload.get("type", "access") + + if user_id is None: + raise HTTPException(status_code=401, detail="Invalid authentication credentials") + + # Check if token is blacklisted + redis = redis_manager.get_client() + blacklist_key = f"blacklisted_token:{credentials.credentials}" + + is_blacklisted = await redis.exists(blacklist_key) + if is_blacklisted: + logger.warning(f"🚫 Attempt to use blacklisted token for user {user_id}") + raise HTTPException(status_code=401, detail="Token has been revoked") + + # For guest tokens, verify guest still exists and update activity + if token_type == "guest" or payload.get("type") == "guest": + database = db_manager.get_database() + guest_data = await database.get_guest(user_id) + + if not guest_data: + logger.warning(f"🚫 Guest session not found for token: {user_id}") + raise HTTPException(status_code=401, detail="Guest session expired") + + # Update guest activity + guest_data["last_activity"] = datetime.now(UTC).isoformat() + await database.set_guest(user_id, guest_data) + logger.debug(f"šŸ”„ Guest activity updated: {user_id}") + + return user_id + + except jwt.PyJWTError as e: + logger.warning(f"āš ļø JWT decode error: {e}") + raise HTTPException(status_code=401, detail="Invalid authentication credentials") + except HTTPException: + raise + except Exception as e: + logger.error(f"āŒ Token verification error: {e}") + raise HTTPException(status_code=401, detail="Token verification failed") + +async def get_current_user( + user_id: str = Depends(verify_token_with_blacklist), + database: RedisDatabase = Depends(get_database) +) -> BaseUserWithType: + """Get current user from database""" + try: + # Check candidates + candidate_data = await database.get_candidate(user_id) + if candidate_data: + if candidate_data.get("is_AI"): + from model_cast import cast_to_base_user_with_type + return cast_to_base_user_with_type(CandidateAI.model_validate(candidate_data)) + else: + from model_cast import cast_to_base_user_with_type + return cast_to_base_user_with_type(Candidate.model_validate(candidate_data)) + + # Check employers + employer = await database.get_employer(user_id) + if employer: + return Employer.model_validate(employer) + + logger.warning(f"āš ļø User {user_id} not found in database") + raise HTTPException(status_code=404, detail="User not found") + + except Exception as e: + logger.error(f"āŒ Error getting current user: {e}") + raise HTTPException(status_code=404, detail="User not found") + +async def get_current_user_or_guest( + user_id: str = Depends(verify_token_with_blacklist), + database: RedisDatabase = Depends(get_database) +) -> BaseUserWithType: + """Get current user (including guests) from database""" + try: + # Check candidates first + candidate_data = await database.get_candidate(user_id) + if candidate_data: + return Candidate.model_validate(candidate_data) if not candidate_data.get("is_AI") else CandidateAI.model_validate(candidate_data) + + # Check employers + employer_data = await database.get_employer(user_id) + if employer_data: + return Employer.model_validate(employer_data) + + # Check guests + guest_data = await database.get_guest(user_id) + if guest_data: + return Guest.model_validate(guest_data) + + logger.warning(f"āš ļø User {user_id} not found in database") + raise HTTPException(status_code=404, detail="User not found") + + except Exception as e: + logger.error(f"āŒ Error getting current user: {e}") + raise HTTPException(status_code=404, detail="User not found") + +async def get_current_admin( + user_id: str = Depends(verify_token_with_blacklist), + database: RedisDatabase = Depends(get_database) +) -> BaseUserWithType: + user = await get_current_user(user_id=user_id, database=database) + if isinstance(user, Candidate) and user.is_admin: + return user + elif isinstance(user, Employer) and user.is_admin: + return user + else: + logger.warning(f"āš ļø User {user_id} is not an admin") + raise HTTPException(status_code=403, detail="Admin access required") + +prometheus_collector = CollectorRegistry() + +# Keep the Instrumentator instance alive +instrumentator = Instrumentator( + should_group_status_codes=True, + should_ignore_untemplated=True, + should_group_untemplated=True, + excluded_handlers=[f"{defines.api_prefix}/metrics"], + registry=prometheus_collector +) diff --git a/src/backend/get_requirements_list.py b/src/backend/utils/get_requirements_list.py similarity index 100% rename from src/backend/get_requirements_list.py rename to src/backend/utils/get_requirements_list.py diff --git a/src/backend/utils/helpers.py b/src/backend/utils/helpers.py new file mode 100644 index 0000000..a58ed93 --- /dev/null +++ b/src/backend/utils/helpers.py @@ -0,0 +1,389 @@ +""" +Helper functions shared across routes +""" +import asyncio +import hashlib +import json +import os +import pathlib +from datetime import datetime, UTC +from typing import Any, Dict, List, Optional, Tuple + +from fastapi.responses import StreamingResponse + +import defines +from logger import logger +from models import DocumentType +from models import ( + LoginRequest, CreateCandidateRequest, CreateEmployerRequest, + Candidate, Employer, Guest, AuthResponse, + MFARequest, MFAData, MFARequestResponse, MFAVerifyRequest, + EmailVerificationRequest, ResendVerificationRequest, + # API + MOCK_UUID, ApiActivityType, ChatMessageError, ChatMessageResume, + ChatMessageSkillAssessment, ChatMessageStatus, ChatMessageStreaming, + ChatMessageUser, DocumentMessage, DocumentOptions, Job, + JobRequirements, JobRequirementsMessage, LoginRequest, + CreateCandidateRequest, CreateEmployerRequest, + + # User models + Candidate, Employer, BaseUserWithType, BaseUser, Guest, + Authentication, AuthResponse, CandidateAI, + + # Job models + JobApplication, ApplicationStatus, + + # Chat models + ChatSession, ChatMessage, ChatContext, ChatQuery, ChatSenderType, ApiMessageType, ChatContextType, + ChatMessageRagSearch, + + # Document models + Document, DocumentType, DocumentListResponse, DocumentUpdateRequest, DocumentContentResponse, + + # Supporting models + Location, MFARequest, MFAData, MFARequestResponse, MFAVerifyRequest, RagContentMetadata, RagContentResponse, ResendVerificationRequest, Resume, ResumeMessage, Skill, SkillAssessment, SystemInfo, UserType, WorkExperience, Education, + + # Email + EmailVerificationRequest, + ApiStatusType +) + +from typing import List, Dict +from models import (Job) +import utils.llm_proxy as llm_manager + +async def get_last_item(generator): + """Get the last item from an async generator""" + last_item = None + async for item in generator: + last_item = item + return last_item + + +def filter_and_paginate( + items: List[Any], + page: int = 1, + limit: int = 20, + sort_by: Optional[str] = None, + sort_order: str = "desc", + filters: Optional[Dict] = None +) -> Tuple[List[Any], int]: + """Filter, sort, and paginate items""" + filtered_items = items.copy() + + # Apply filters (simplified filtering logic) + if filters: + for key, value in filters.items(): + if isinstance(filtered_items[0], dict) and key in filtered_items[0]: + filtered_items = [item for item in filtered_items if item.get(key) == value] + elif hasattr(filtered_items[0], key) if filtered_items else False: + filtered_items = [item for item in filtered_items + if getattr(item, key, None) == value] + + # Sort items + if sort_by and filtered_items: + reverse = sort_order.lower() == "desc" + try: + if isinstance(filtered_items[0], dict): + filtered_items.sort(key=lambda x: x.get(sort_by, ""), reverse=reverse) + else: + filtered_items.sort(key=lambda x: getattr(x, sort_by, ""), reverse=reverse) + except (AttributeError, TypeError): + pass # Skip sorting if attribute doesn't exist or isn't comparable + + # Paginate + total = len(filtered_items) + start = (page - 1) * limit + end = start + limit + paginated_items = filtered_items[start:end] + + return paginated_items, total + + +async def stream_agent_response(chat_agent, user_message, chat_session_data=None, database=None) -> StreamingResponse: + """Stream agent response with proper formatting""" + async def message_stream_generator(): + """Generator to stream messages with persistence""" + last_log = None + final_message = None + + import utils.llm_proxy as llm_manager + import agents + + async for generated_message in chat_agent.generate( + llm=llm_manager.get_llm(), + model=defines.model, + session_id=user_message.session_id, + prompt=user_message.content, + ): + if generated_message.status == ApiStatusType.ERROR: + logger.error(f"āŒ AI generation error: {generated_message.content}") + yield f"data: {json.dumps({'status': 'error'})}\n\n" + return + + # Store reference to the complete AI message + if generated_message.status == ApiStatusType.DONE: + final_message = generated_message + + # If the message is not done, convert it to a ChatMessageBase to remove + # metadata and other unnecessary fields for streaming + if generated_message.status != ApiStatusType.DONE: + from models import ChatMessageStreaming, ChatMessageStatus + if not isinstance(generated_message, ChatMessageStreaming) and not isinstance(generated_message, ChatMessageStatus): + raise TypeError( + f"Expected ChatMessageStreaming or ChatMessageStatus, got {type(generated_message)}" + ) + + json_data = generated_message.model_dump(mode='json', by_alias=True) + json_str = json.dumps(json_data) + + yield f"data: {json_str}\n\n" + + # After streaming is complete, persist the final AI message to database + if final_message and final_message.status == ApiStatusType.DONE: + try: + if database and chat_session_data: + await database.add_chat_message(final_message.session_id, final_message.model_dump()) + logger.info(f"šŸ¤– Message saved to database for session {final_message.session_id}") + + # Update session last activity again + chat_session_data["lastActivity"] = datetime.now(UTC).isoformat() + await database.set_chat_session(final_message.session_id, chat_session_data) + + except Exception as e: + logger.error(f"āŒ Failed to save message to database: {e}") + + return StreamingResponse( + message_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", + }, + ) + + +def get_candidate_files_dir(username: str) -> pathlib.Path: + """Get the files directory for a candidate""" + files_dir = pathlib.Path(defines.user_dir) / username / "files" + files_dir.mkdir(parents=True, exist_ok=True) + return files_dir + + +def get_document_type_from_filename(filename: str) -> DocumentType: + """Determine document type from filename extension""" + extension = pathlib.Path(filename).suffix.lower() + + type_mapping = { + '.pdf': DocumentType.PDF, + '.docx': DocumentType.DOCX, + '.doc': DocumentType.DOCX, + '.txt': DocumentType.TXT, + '.md': DocumentType.MARKDOWN, + '.markdown': DocumentType.MARKDOWN, + '.png': DocumentType.IMAGE, + '.jpg': DocumentType.IMAGE, + '.jpeg': DocumentType.IMAGE, + '.gif': DocumentType.IMAGE, + } + + return type_mapping.get(extension, DocumentType.TXT) + + +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}" + + +async def reformat_as_markdown(database, candidate_entity, content: str): + """Reformat content as markdown using AI agent""" + from models import ChatContextType, MOCK_UUID, ApiStatusType, ChatMessageError, ChatMessageStatus, ApiActivityType, ChatMessage + import utils.llm_proxy as llm_manager + + chat_agent = candidate_entity.get_or_create_agent(agent_type=ChatContextType.JOB_REQUIREMENTS) + if not chat_agent: + error_message = ChatMessageError( + session_id=MOCK_UUID, + content="No agent found for job requirements chat type" + ) + yield error_message + return + + status_message = ChatMessageStatus( + session_id=MOCK_UUID, + content=f"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. +""" + ): + 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, + 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 as e: + pass + + logger.info(f"āœ… Successfully converted content to markdown") + yield chat_message + return + + +async def create_job_from_content(database, current_user, content: str): + """Create a job from content using AI analysis""" + from models import ( + MOCK_UUID, ApiStatusType, ChatMessageError, ChatMessageStatus, + ApiActivityType, ChatContextType, JobRequirementsMessage + ) + + status_message = ChatMessageStatus( + session_id=MOCK_UUID, + 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 + + import entities.entity_manager as entities + + 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, + 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, + content="No agent found for job requirements chat type" + ) + yield error_message + return + + status_message = ChatMessageStatus( + session_id=MOCK_UUID, + content=f"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, + 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 + +def get_requirements_list(job: Job) -> List[Dict[str, str]]: + requirements: List[Dict[str, str]] = [] + + if job.requirements: + if job.requirements.technical_skills: + if job.requirements.technical_skills.required: + requirements.extend([ + {"requirement": req, "domain": "Technical Skills (required)"} + for req in job.requirements.technical_skills.required + ]) + if job.requirements.technical_skills.preferred: + requirements.extend([ + {"requirement": req, "domain": "Technical Skills (preferred)"} + for req in job.requirements.technical_skills.preferred + ]) + + if job.requirements.experience_requirements: + if job.requirements.experience_requirements.required: + requirements.extend([ + {"requirement": req, "domain": "Experience (required)"} + for req in job.requirements.experience_requirements.required + ]) + if job.requirements.experience_requirements.preferred: + requirements.extend([ + {"requirement": req, "domain": "Experience (preferred)"} + for req in job.requirements.experience_requirements.preferred + ]) + + if job.requirements.soft_skills: + requirements.extend([ + {"requirement": req, "domain": "Soft Skills"} + for req in job.requirements.soft_skills + ]) + + if job.requirements.experience: + requirements.extend([ + {"requirement": req, "domain": "Experience"} + for req in job.requirements.experience + ]) + + if job.requirements.education: + requirements.extend([ + {"requirement": req, "domain": "Education"} + for req in job.requirements.education + ]) + + if job.requirements.certifications: + requirements.extend([ + {"requirement": req, "domain": "Certifications"} + for req in job.requirements.certifications + ]) + + if job.requirements.preferred_attributes: + requirements.extend([ + {"requirement": req, "domain": "Preferred Attributes"} + for req in job.requirements.preferred_attributes + ]) + + return requirements \ No newline at end of file diff --git a/src/backend/llm_proxy.py b/src/backend/utils/llm_proxy.py similarity index 100% rename from src/backend/llm_proxy.py rename to src/backend/utils/llm_proxy.py diff --git a/src/backend/metrics.py b/src/backend/utils/metrics.py similarity index 100% rename from src/backend/metrics.py rename to src/backend/utils/metrics.py diff --git a/src/backend/rate_limiter.py b/src/backend/utils/rate_limiter.py similarity index 54% rename from src/backend/rate_limiter.py rename to src/backend/utils/rate_limiter.py index 23e1f0a..e4988a3 100644 --- a/src/backend/rate_limiter.py +++ b/src/backend/utils/rate_limiter.py @@ -1,15 +1,23 @@ """ Rate limiting utilities for guest and authenticated users """ - +from __future__ import annotations +from functools import wraps import json import time from datetime import datetime, timedelta, UTC -from typing import Dict, Optional, Tuple, Any +from typing import Callable, Dict, Optional, Tuple, Any +from fastapi import Depends, HTTPException, Request from pydantic import BaseModel # type: ignore from database import RedisDatabase from logger import logger +from . dependencies import get_current_user_or_guest, get_database + +async def get_rate_limiter(database: RedisDatabase = Depends(get_database)) -> RateLimiter: + """Dependency to get rate limiter instance""" + return RateLimiter(database) + class RateLimitConfig(BaseModel): """Rate limit configuration""" requests_per_minute: int @@ -284,4 +292,236 @@ class RateLimiter: logger.error(f"āŒ Failed to reset rate limits for {user_id}: {e}") return False - \ No newline at end of file + +# ============================ +# Rate Limited Decorator +# ============================ + +def rate_limited( + guest_per_minute: int = 10, + user_per_minute: int = 60, + admin_per_minute: int = 120, + endpoint_specific: bool = True +): + """ + Decorator to easily apply rate limiting to endpoints + + Args: + guest_per_minute: Rate limit for guest users + user_per_minute: Rate limit for authenticated users + admin_per_minute: Rate limit for admin users + endpoint_specific: Whether to apply endpoint-specific limits + + Usage: + @rate_limited(guest_per_minute=5, user_per_minute=30) + @api_router.post("/my-endpoint") + async def my_endpoint( + request: Request, + current_user = Depends(get_current_user_or_guest), + database: RedisDatabase = Depends(get_database) + ): + return {"message": "Rate limited endpoint"} + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + async def wrapper(*args, **kwargs): + # Extract dependencies from function signature + import inspect + sig = inspect.signature(func) + + # Get request, current_user, and rate_limiter from kwargs or args + request = None + current_user = None + rate_limiter = None + + # Try to find dependencies in kwargs first + for param_name, param_value in kwargs.items(): + if isinstance(param_value, Request): + request = param_value + elif hasattr(param_value, 'user_type'): # User-like object + current_user = param_value + elif isinstance(param_value, RateLimiter): + rate_limiter = param_value + + # If not found in kwargs, check if they're provided via Depends + if not rate_limiter: + # Create rate limiter instance (this should ideally come from DI) + database = get_database() + rate_limiter = RateLimiter(database) + + # Apply rate limiting if we have the required components + if request and current_user and rate_limiter: + await apply_custom_rate_limiting( + request, current_user, rate_limiter, + guest_per_minute, user_per_minute, admin_per_minute + ) + + # Call the original function + return await func(*args, **kwargs) + + return wrapper + return decorator + +async def apply_custom_rate_limiting( + request: Request, + current_user, + rate_limiter: RateLimiter, + guest_per_minute: int, + user_per_minute: int, + admin_per_minute: int +): + """Apply custom rate limiting with specified limits""" + try: + # Determine user info + user_id = current_user.id + user_type = current_user.user_type.value if hasattr(current_user.user_type, 'value') else str(current_user.user_type) + is_admin = getattr(current_user, 'is_admin', False) + + # Determine appropriate limit + if is_admin: + requests_per_minute = admin_per_minute + elif user_type == "guest": + requests_per_minute = guest_per_minute + else: + requests_per_minute = user_per_minute + + # Create custom rate limit key + current_time = datetime.now(UTC) + custom_key = f"custom_rate_limit:{request.url.path}:{user_type}:{user_id}:minute:{current_time.strftime('%Y%m%d%H%M')}" + + # Check current usage + current_count = int(await rate_limiter.redis.get(custom_key) or 0) + + if current_count >= requests_per_minute: + logger.warning(f"🚫 Custom rate limit exceeded for {user_type} {user_id}: {current_count}/{requests_per_minute}") + raise HTTPException( + status_code=429, + detail={ + "error": "Rate limit exceeded", + "message": f"Custom rate limit exceeded: {current_count}/{requests_per_minute} requests per minute", + "retryAfter": 60 - current_time.second, + "userType": user_type, + "endpoint": request.url.path + }, + headers={"Retry-After": str(60 - current_time.second)} + ) + + # Increment counter + pipe = rate_limiter.redis.pipeline() + pipe.incr(custom_key) + pipe.expire(custom_key, 120) # 2 minutes TTL + await pipe.execute() + + logger.debug(f"āœ… Custom rate limit check passed for {user_type} {user_id}: {current_count + 1}/{requests_per_minute}") + + except HTTPException: + raise + except Exception as e: + logger.error(f"āŒ Custom rate limiting error: {e}") + # Fail open + +# ============================ +# Alternative: FastAPI Dependency-Based Rate Limiting +# ============================ + +def create_rate_limit_dependency( + guest_per_minute: int = 10, + user_per_minute: int = 60, + admin_per_minute: int = 120 +): + """ + Create a FastAPI dependency for rate limiting + + Usage: + rate_limit_5_30 = create_rate_limit_dependency(guest_per_minute=5, user_per_minute=30) + + @api_router.post("/my-endpoint") + async def my_endpoint( + rate_check = Depends(rate_limit_5_30), + current_user = Depends(get_current_user_or_guest), + database: RedisDatabase = Depends(get_database) + ): + return {"message": "Rate limited endpoint"} + """ + async def rate_limit_dependency( + request: Request, + current_user = Depends(get_current_user_or_guest), + rate_limiter: RateLimiter = Depends(get_rate_limiter) + ): + await apply_custom_rate_limiting( + request, current_user, rate_limiter, + guest_per_minute, user_per_minute, admin_per_minute + ) + return True + + return rate_limit_dependency + +# ============================ +# Rate Limiting Utilities +# ============================ + +class EndpointRateLimiter: + """Utility class for endpoint-specific rate limiting""" + + def __init__(self, rate_limiter: RateLimiter): + self.rate_limiter = rate_limiter + self.custom_limits = {} + + def set_endpoint_limits(self, endpoint: str, limits: dict): + """Set custom limits for an endpoint""" + self.custom_limits[endpoint] = limits + + async def check_endpoint_limit(self, request: Request, current_user) -> bool: + """Check if request exceeds endpoint-specific limits""" + endpoint = request.url.path + + if endpoint not in self.custom_limits: + return True # No custom limits set + + limits = self.custom_limits[endpoint] + user_type = current_user.user_type.value if hasattr(current_user.user_type, 'value') else str(current_user.user_type) + + if getattr(current_user, 'is_admin', False): + user_type = "admin" + + limit = limits.get(user_type, limits.get("default", 60)) + + current_time = datetime.now(UTC) + key = f"endpoint_limit:{endpoint}:{user_type}:{current_user.id}:minute:{current_time.strftime('%Y%m%d%H%M')}" + + current_count = int(await self.rate_limiter.redis.get(key) or 0) + + if current_count >= limit: + raise HTTPException( + status_code=429, + detail=f"Endpoint rate limit exceeded: {current_count}/{limit} for {endpoint}" + ) + + # Increment counter + await self.rate_limiter.redis.incr(key) + await self.rate_limiter.redis.expire(key, 120) + + return True + +# Global endpoint rate limiter instance +endpoint_rate_limiter = None + +def get_endpoint_rate_limiter(rate_limiter: RateLimiter = Depends(get_rate_limiter)) -> EndpointRateLimiter: + """Get endpoint rate limiter instance""" + global endpoint_rate_limiter + if endpoint_rate_limiter is None: + endpoint_rate_limiter = EndpointRateLimiter(rate_limiter) + + # Configure endpoint-specific limits + endpoint_rate_limiter.set_endpoint_limits("/api/1.0/chat/sessions/*/messages/stream", { + "guest": 5, "candidate": 30, "employer": 30, "admin": 100 + }) + endpoint_rate_limiter.set_endpoint_limits("/api/1.0/candidates/documents/upload", { + "guest": 2, "candidate": 10, "employer": 10, "admin": 50 + }) + endpoint_rate_limiter.set_endpoint_limits("/api/1.0/jobs", { + "guest": 1, "candidate": 5, "employer": 20, "admin": 50 + }) + + return endpoint_rate_limiter + diff --git a/src/backend/utils/responses.py b/src/backend/utils/responses.py new file mode 100644 index 0000000..297e08d --- /dev/null +++ b/src/backend/utils/responses.py @@ -0,0 +1,39 @@ +""" +Response utility functions for consistent API responses +""" +from typing import Any, Optional, Dict, List + +def create_success_response(data: Any, meta: Optional[Dict] = None) -> Dict: + return { + "success": True, + "data": data, + "meta": meta + } + +def create_error_response(code: str, message: str, details: Any = None) -> Dict: + return { + "success": False, + "error": { + "code": code, + "message": message, + "details": details + } + } + +def create_paginated_response( + data: List[Any], + page: int, + limit: int, + total: int +) -> Dict: + total_pages = (total + limit - 1) // limit + has_more = page < total_pages + + return { + "data": data, + "total": total, + "page": page, + "limit": limit, + "totalPages": total_pages, + "hasMore": has_more + } \ No newline at end of file diff --git a/src/tests/api/README.md b/src/tests/api/README.md new file mode 100644 index 0000000..d5426ef --- /dev/null +++ b/src/tests/api/README.md @@ -0,0 +1,247 @@ +# Backstory API Test Suite + +A comprehensive Python CLI utility for testing the Backstory API. This tool handles authentication with MFA support, token management with local persistence, and provides extensive testing coverage for all API endpoints. + +## Features + +- **MFA Authentication**: Full support for Multi-Factor Authentication flow +- **Token Persistence**: Stores bearer tokens locally in JSON format for reuse across sessions +- **Token Management**: Automatic token refresh and expiration handling +- **Comprehensive Coverage**: Tests all API endpoints including candidates, jobs, health checks, and metrics +- **Flexible Testing**: Run all tests or focus on specific endpoint groups +- **Detailed Reporting**: Get comprehensive test results with timing and error information +- **Multiple Output Formats**: Choose between human-readable text or JSON output +- **Robust Error Handling**: Includes retry logic and proper error reporting + +## Installation + +1. Clone or download the test suite files +2. Install dependencies: + +```bash +pip install -r requirements.txt +``` + +## Authentication Flows + +### First-time Authentication (with MFA) + +```bash +# Login will prompt for MFA code if required +python test_suite.py --login your-email@example.com --password your-password + +# Or provide MFA code directly +python test_suite.py --login your-email@example.com --password your-password --mfa-code 123456 +``` + +### Using Stored Tokens + +```bash +# Use previously stored tokens (no login required) +python test_suite.py --use-stored-tokens + +# Or just run normally - stored tokens will be used automatically if valid +python test_suite.py --login your-email@example.com --password your-password +``` + +### Token Management + +```bash +# Clear stored tokens +python test_suite.py --clear-tokens + +# Specify custom token file location +python test_suite.py --login user@example.com --password pass --token-file /path/to/tokens.json +``` + +## Usage + +### Command Line Options + +```bash +python test_suite.py [OPTIONS] + +Authentication Arguments: + --login EMAIL Login email for authentication + --password PASSWORD Login password for authentication + --mfa-code CODE MFA code from email (if required) + --use-stored-tokens Use previously stored tokens without login + --clear-tokens Clear stored tokens and exit + +Configuration Arguments: + --base-url URL Base URL for the API (default: https://backstory-beta.ketrenos.com) + --endpoint GROUP Test specific endpoint group: all, auth, candidates, jobs, health (default: all) + --token-file PATH Token storage file (default: .backstory_tokens.json) + --verbose, -v Enable verbose logging + --output FORMAT Output format: text, json (default: text) + --help, -h Show help message +``` + +### Examples + +**First-time login with MFA:** +```bash +python test_suite.py --login user@example.com --password mypassword +# Will prompt for MFA code if required +``` + +**Login with MFA code provided:** +```bash +python test_suite.py --login user@example.com --password mypassword --mfa-code 123456 +``` + +**Use stored tokens (no login required):** +```bash +python test_suite.py --use-stored-tokens +``` + +**Run all tests with verbose output:** +```bash +python test_suite.py --login user@example.com --password mypassword --verbose +``` + +**Test only candidate endpoints:** +```bash +python test_suite.py --use-stored-tokens --endpoint candidates +``` + +**Test against a different environment:** +```bash +python test_suite.py --login user@example.com --password mypassword --base-url https://localhost:8000 +``` + +**Get JSON output for CI/CD integration:** +```bash +python test_suite.py --use-stored-tokens --output json +``` + +**Clear stored tokens:** +```bash +python test_suite.py --clear-tokens +``` + +## Authentication Flow + +1. **Initial Login**: Provide email and password +2. **Device ID Capture**: The system captures the `device_id` from the login response +3. **MFA Verification**: If MFA is required, enter the code sent to your email (device_id is automatically included) +4. **Token Storage**: Bearer and refresh tokens are automatically saved locally +5. **Subsequent Runs**: Stored tokens are used automatically +6. **Token Refresh**: Expired tokens are automatically refreshed using the refresh token +7. **Token Expiry**: If refresh fails, you'll be prompted to login again + +## Token Storage + +- Tokens are stored in `.backstory_tokens.json` by default +- The file includes bearer token, refresh token, expiration time, and base URL +- Tokens are automatically loaded when the application starts +- Different base URLs use separate token storage +- Tokens are automatically refreshed when expired + +## API Endpoints Tested + +### Authentication +- `POST /api/1.0/auth/login` - User login (captures device_id for MFA flow) +- `POST /api/1.0/auth/mfa/verify` - MFA verification (includes device_id) +- `POST /api/1.0/auth/refresh` - Token refresh + +### Candidates +- `GET /api/1.0/candidates` - List candidates (with pagination) +- `POST /api/1.0/candidates` - Create candidate +- `GET /api/1.0/candidates/{id}` - Get specific candidate +- `PATCH /api/1.0/candidates/{id}` - Update candidate (requires auth) +- `GET /api/1.0/candidates/search` - Search candidates + +### Jobs +- `GET /api/1.0/jobs` - List jobs (with pagination) +- `POST /api/1.0/jobs` - Create job (requires auth) +- `GET /api/1.0/jobs/{id}` - Get specific job +- `GET /api/1.0/jobs/search` - Search jobs + +### System +- `GET /api/1.0/health` - Health check +- `GET /api/1.0/` - API information +- `GET /api/1.0/redis/stats` - Redis statistics +- `GET /api/1.0/metrics` - Prometheus metrics + +## Test Flow + +1. **Authentication**: + - Loads stored tokens if available and valid + - If no valid tokens, performs login with optional MFA + - Saves new tokens for future use +2. **System Tests**: Tests health, info, and metrics endpoints +3. **Candidate Tests**: Tests candidate CRUD operations and search +4. **Job Tests**: Tests job CRUD operations and search +5. **Dependent Tests**: If entities are created successfully, tests retrieval and updates + +## Output + +### Text Output +The default output provides a comprehensive summary including: +- Authentication status and method used +- Total tests run and success rate +- List of any failed tests with error details +- Detailed results for all tests with response times + +### JSON Output +Perfect for CI/CD integration, includes: +- Summary statistics +- Detailed results for each test +- Structured error information + +## Error Handling + +The test suite includes robust error handling: +- **Authentication Issues**: Clear MFA prompts and token management +- **Network Issues**: Automatic retries with exponential backoff +- **Token Expiry**: Automatic refresh with fallback to re-authentication +- **API Errors**: Detailed error reporting with status codes and messages +- **Timeout Handling**: Configurable timeouts for all requests + +## Exit Codes + +- `0`: All tests passed +- `1`: One or more tests failed or authentication failed + +## Dependencies + +- `requests`: HTTP client library +- `urllib3`: HTTP library with retry capabilities + +## Security Notes + +- Token files are stored locally and contain sensitive authentication data +- Ensure appropriate file permissions on token storage files +- Tokens are automatically refreshed but may require re-authentication if refresh tokens expire +- Consider using environment-specific token files for different deployments +- Device IDs are automatically handled during MFA flows and cleared after authentication + +## Troubleshooting + +### MFA Issues +- If MFA verification fails, ensure you're using the latest code from your email +- Check verbose output (`--verbose`) to see device_id information +- MFA codes typically expire quickly (usually 5-10 minutes) + +### Token Issues +- Use `--clear-tokens` to reset stored authentication +- Check token file permissions if loading fails +- Different base URLs maintain separate token storage + +### Network Issues +- The tool includes automatic retries for network failures +- Increase verbosity with `--verbose` to see detailed request information +- Check firewall/proxy settings if requests consistently fail + +## Contributing + +To add new tests: + +1. Add test methods to the `BackstoryTestSuite` class +2. Call your test methods from `run_all_tests()` or the appropriate endpoint group method +3. Follow the existing pattern of creating `TestResult` objects + +## License + +This test suite is provided as-is for testing the Backstory API. \ No newline at end of file diff --git a/src/tests/api/test_suite.py b/src/tests/api/test_suite.py new file mode 100644 index 0000000..413c8f3 --- /dev/null +++ b/src/tests/api/test_suite.py @@ -0,0 +1,733 @@ +#!/usr/bin/env python3 +""" +Backstory API Test Suite CLI Utility + +A comprehensive test suite for the Backstory API that handles authentication, +token management, and testing of all API endpoints. + +Usage: + python test_suite.py --login user@example.com --password mypassword + python test_suite.py --login user@example.com --password mypassword --verbose + python test_suite.py --login user@example.com --password mypassword --endpoint candidates +""" + +import argparse +import json +import logging +import os +import sys +import time +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import Dict, List, Optional, Any +from urllib.parse import urljoin + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + + +@dataclass +class TestResult: + """Represents the result of a single test.""" + endpoint: str + method: str + status_code: int + success: bool + response_time: float + error_message: Optional[str] = None + response_data: Optional[Dict] = None + + +class BackstoryAPIClient: + """Client for interacting with the Backstory API.""" + + def __init__(self, base_url: str = "https://backstory-beta.ketrenos.com", token_file: str = ".backstory_tokens.json"): + self.base_url = base_url + self.token_file = Path(token_file) + self.bearer_token: Optional[str] = None + self.refresh_token: Optional[str] = None + self.token_expires_at: Optional[datetime] = None + self.device_id: Optional[str] = None # Store device_id for MFA flow + self.user: Optional[Dict[str, Any]] = None # Store user ID if needed + + # Configure session with retries + self.session = requests.Session() + retry_strategy = Retry( + total=3, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["HEAD", "GET", "OPTIONS"], + backoff_factor=1 + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + self.session.mount("http://", adapter) + self.session.mount("https://", adapter) + + # Try to load existing tokens + self.load_tokens() + + def save_tokens(self): + """Save tokens to local JSON file.""" + token_data = { + "bearer_token": self.bearer_token, + "refresh_token": self.refresh_token, + "expires_at": self.token_expires_at.isoformat() if self.token_expires_at else None, + "base_url": self.base_url, + "user": self.user, + } + + logging.debug(f"Saving tokens to {self.token_file}") + try: + with open(self.token_file, 'w') as f: + json.dump(token_data, f, indent=2) + logging.debug(f"Tokens saved to {self.token_file}") + except Exception as e: + logging.warning(f"Failed to save tokens: {e}") + + def load_tokens(self): + """Load tokens from local JSON file.""" + if not self.token_file.exists(): + return False + + try: + with open(self.token_file, 'r') as f: + token_data = json.load(f) + + # Only load tokens if they're for the same base URL + if token_data.get("base_url") != self.base_url: + logging.debug("Token file exists but for different base URL") + return False + + self.user = token_data.get("user") + self.bearer_token = token_data.get("bearer_token") + self.refresh_token = token_data.get("refresh_token") + + if token_data.get("expires_at"): + self.token_expires_at = datetime.fromisoformat(token_data["expires_at"]) + + # Check if token is expired + if self.token_expires_at and datetime.now() >= self.token_expires_at: + logging.debug("Stored token is expired") + return self.refresh_access_token() + + if self.bearer_token: + self.session.headers.update({ + "Authorization": f"Bearer {self.bearer_token}" + }) + logging.debug("Loaded tokens from file") + return True + + except Exception as e: + logging.warning(f"Failed to load tokens: {e}") + + return False + + def clear_tokens(self): + """Clear tokens from memory and file.""" + self.bearer_token = None + self.refresh_token = None + self.token_expires_at = None + self.device_id = None # Clear device_id as well + + if "Authorization" in self.session.headers: + del self.session.headers["Authorization"] + + if self.token_file.exists(): + try: + self.token_file.unlink() + logging.debug("Token file deleted") + except Exception as e: + logging.warning(f"Failed to delete token file: {e}") + + def is_authenticated(self) -> bool: + """Check if we have valid authentication.""" + if not self.bearer_token: + return False + + if self.token_expires_at and datetime.now() >= self.token_expires_at: + return self.refresh_access_token() + + return True + + def login(self, email: str, password: str) -> Dict[str, Any]: + """ + Authenticate with the API. Returns result indicating if MFA is required. + + Returns: + Dict with 'success', 'mfa_required', 'message', and optionally 'device_id' keys + """ + url = urljoin(self.base_url, "/api/1.0/auth/login") + payload = {"login": email, "password": password} + + try: + response = self.session.post(url, json=payload, timeout=30) + response.raise_for_status() + + result = response.json() + data = result.get("data", {}) + mfaData = data.get("mfaData", {}) + + # Store device_id if present (needed for MFA flow) + self.device_id = mfaData.get("deviceId") + + # Check if this is a complete login or requires MFA + if "accessToken" in data: + # Complete login - store tokens + self._store_auth_tokens(data) + return {"success": True, "mfa_required": False, "message": "Login successful"} + + elif "mfaRequired" in data or "requiresMfa" in data or response.status_code == 202: + # MFA required - device_id should be available + return { + "success": True, + "mfa_required": True, + "message": "MFA code required", + "device_id": self.device_id + } + + else: + return {"success": False, "mfa_required": False, "message": "Unexpected login response"} + + except requests.exceptions.RequestException as e: + logging.error(f"Login failed: {e}") + return {"success": False, "mfa_required": False, "message": str(e)} + + def verify_mfa(self, email: str, mfa_code: str) -> bool: + """ + Verify MFA code and complete authentication. + + Args: + email: User email + mfa_code: MFA code from email + + Returns: + True if MFA verification successful, False otherwise + """ + # Try different possible MFA endpoints + possible_endpoints = [ + "/api/1.0/auth/mfa/verify", + "/api/1.0/auth/verify-mfa", + "/api/1.0/auth/mfa", + "/api/1.0/auth/verify" + ] + + # Base payload with multiple field name variations + payload = { + "email": email, + "code": mfa_code, + "mfa_code": mfa_code, # Some APIs use this field name + "verification_code": mfa_code # Some APIs use this field name + } + + # Include device_id if available (required for proper MFA flow) + if self.device_id: + payload["deviceId"] = self.device_id + logging.debug(f"Including device_id in MFA verification: {self.device_id}") + else: + logging.warning("No device_id available for MFA verification - this may cause issues") + + for endpoint in possible_endpoints: + url = urljoin(self.base_url, endpoint) + + try: + response = self.session.post(url, json=payload, timeout=30) + + if response.status_code == 200: + result = response.json() + data = result.get("data", {}) + user = data.get("user", {}) + logging.info(f"Login response: {data}") + self.user = user + + if "accessToken" in data: + self._store_auth_tokens(data) + # Clear device_id after successful MFA + self.device_id = None + return True + elif response.status_code == 404: + # Endpoint doesn't exist, try next one + continue + else: + logging.debug(f"MFA verification failed at {endpoint}: {response.status_code} - {response.text}") + + except requests.exceptions.RequestException as e: + logging.debug(f"MFA request failed for {endpoint}: {e}") + continue + + logging.error("MFA verification failed - no valid endpoint found") + # Clear device_id on failure + self.device_id = None + return False + + def _store_auth_tokens(self, data: Dict[str, Any]): + """Store authentication tokens from API response.""" + self.bearer_token = data.get("accessToken") + self.refresh_token = data.get("refreshToken") + + # Calculate expiration time (default to 1 hour if not provided) + expires_in = data.get("expiresIn", 3600) # seconds + self.token_expires_at = datetime.now() + timedelta(seconds=expires_in) + + if self.bearer_token: + self.session.headers.update({ + "Authorization": f"Bearer {self.bearer_token}" + }) + self.save_tokens() + + def refresh_access_token(self) -> bool: + """Refresh the access token using the refresh token.""" + if not self.refresh_token: + return False + + url = urljoin(self.base_url, "/api/1.0/auth/refresh") + + try: + response = self.session.post(url, json=self.refresh_token, timeout=30) + response.raise_for_status() + + result = response.json() + data = result.get("data", {}) + new_token = data.get("accessToken") + + if new_token: + self._store_auth_tokens(data) + return True + return False + + except requests.exceptions.RequestException as e: + logging.error(f"Token refresh failed: {e}") + self.clear_tokens() # Clear invalid tokens + return False + + def make_request(self, method: str, endpoint: str, **kwargs) -> TestResult: + """Make an API request and return a TestResult.""" + url = urljoin(self.base_url, endpoint) + start_time = time.time() + + try: + response = self.session.request(method, url, timeout=30, **kwargs) + response_time = time.time() - start_time + + success = 200 <= response.status_code < 300 + error_message = None if success else f"HTTP {response.status_code}: {response.text}" + + try: + response_data = response.json() if response.content else None + except json.JSONDecodeError: + response_data = {"raw_response": response.text} + + return TestResult( + endpoint=endpoint, + method=method, + status_code=response.status_code, + success=success, + response_time=response_time, + error_message=error_message, + response_data=response_data + ) + + except requests.exceptions.RequestException as e: + response_time = time.time() - start_time + return TestResult( + endpoint=endpoint, + method=method, + status_code=0, + success=False, + response_time=response_time, + error_message=str(e) + ) + + +class BackstoryTestSuite: + """Comprehensive test suite for the Backstory API.""" + + def __init__(self, client: BackstoryAPIClient): + self.client = client + self.test_results: List[TestResult] = [] + self.test_data = { + "candidate_id": None, + "job_id": None + } + + def run_all_tests(self) -> List[TestResult]: + """Run all tests in the correct order.""" + self.test_results.clear() + + # Basic endpoints (no auth required) + self._test_health_check() + self._test_api_info() + self._test_redis_stats() + self._test_metrics() + + # Candidates endpoints + self._test_get_candidates() + self._test_search_candidates() + self._test_create_candidate() + + if self.test_data["candidate_id"]: + self._test_get_candidate() + self._test_update_candidate() + + # Jobs endpoints + self._test_get_jobs() + self._test_search_jobs() + self._test_create_job() + + if self.test_data["job_id"]: + self._test_get_job() + + return self.test_results + + def run_endpoint_tests(self, endpoint_group: str) -> List[TestResult]: + """Run tests for a specific endpoint group.""" + self.test_results.clear() + + if endpoint_group == "auth": + # Auth tests are handled during login + pass + elif endpoint_group == "candidates": + self._test_get_candidates() + self._test_search_candidates() + self._test_create_candidate() + if self.test_data["candidate_id"]: + self._test_get_candidate() + self._test_update_candidate() + elif endpoint_group == "jobs": + self._test_get_jobs() + self._test_search_jobs() + self._test_create_job() + if self.test_data["job_id"]: + self._test_get_job() + elif endpoint_group == "health": + self._test_health_check() + self._test_api_info() + self._test_redis_stats() + self._test_metrics() + + return self.test_results + + def _test_health_check(self): + """Test the health check endpoint.""" + result = self.client.make_request("GET", "/health") + self.test_results.append(result) + logging.info(f"Health check: {result.status_code} ({result.response_time:.3f}s)") + + def _test_api_info(self): + """Test the API info endpoint.""" + result = self.client.make_request("GET", "/api/1.0/") + self.test_results.append(result) + logging.info(f"API info: {result.status_code} ({result.response_time:.3f}s)") + + def _test_redis_stats(self): + """Test the Redis stats endpoint.""" + result = self.client.make_request("GET", "/api/1.0/redis/stats") + self.test_results.append(result) + logging.info(f"Redis stats: {result.status_code} ({result.response_time:.3f}s)") + + def _test_metrics(self): + """Test the metrics endpoint.""" + result = self.client.make_request("GET", "/api/1.0/metrics") + self.test_results.append(result) + logging.info(f"Metrics: {result.status_code} ({result.response_time:.3f}s)") + + def _test_get_candidates(self): + """Test getting candidates with pagination.""" + # Test basic get + result = self.client.make_request("GET", "/api/1.0/candidates") + self.test_results.append(result) + logging.info(f"Get candidates: {result.status_code} ({result.response_time:.3f}s)") + + # Test with pagination parameters + params = {"page": 1, "limit": 10, "sortOrder": "asc"} + result = self.client.make_request("GET", "/api/1.0/candidates", params=params) + self.test_results.append(result) + logging.info(f"Get candidates (paginated): {result.status_code} ({result.response_time:.3f}s)") + + def _test_search_candidates(self): + """Test searching candidates.""" + params = {"query": "test", "page": 1, "limit": 5} + result = self.client.make_request("GET", "/api/1.0/candidates/search", params=params) + self.test_results.append(result) + logging.info(f"Search candidates: {result.status_code} ({result.response_time:.3f}s)") + + def _test_create_candidate(self): + """Test creating a candidate.""" + candidate_data = { + "full_name": "Test Candidate", + "first_name": "Test", + "last_name": "Candidate", + "username": "glassmonkey", + "password": "se!curepassWord123", + "email": "james_backstorytest@ketrenos.com", + "skills": ["Python", "API Testing"], + "experience": "5 years" + } + + result = self.client.make_request("POST", "/api/1.0/candidates", json=candidate_data) + self.test_results.append(result) + + # Store candidate ID for later tests + if result.success and result.response_data: + self.test_data["candidate_id"] = result.response_data.get("id") + + logging.info(f"Create candidate: {result.status_code} ({result.response_time:.3f}s)") + + def _test_get_candidate(self): + """Test getting a specific candidate.""" + if not self.test_data["candidate_id"]: + return + + endpoint = f"/api/1.0/candidates/{self.test_data['candidate_id']}" + result = self.client.make_request("GET", endpoint) + self.test_results.append(result) + logging.info(f"Get candidate: {result.status_code} ({result.response_time:.3f}s)") + + def _test_update_candidate(self): + """Test updating a candidate.""" + if not self.test_data["candidate_id"]: + return + + update_data = {"skills": ["Python", "API Testing", "Quality Assurance"]} + endpoint = f"/api/1.0/candidates/{self.test_data['candidate_id']}" + + result = self.client.make_request("PATCH", endpoint, json=update_data) + self.test_results.append(result) + logging.info(f"Update candidate: {result.status_code} ({result.response_time:.3f}s)") + + def _test_get_jobs(self): + """Test getting jobs with pagination.""" + # Test basic get + result = self.client.make_request("GET", "/api/1.0/jobs") + self.test_results.append(result) + logging.info(f"Get jobs: {result.status_code} ({result.response_time:.3f}s)") + + # Test with pagination parameters + params = {"page": 1, "limit": 10, "sortOrder": "desc"} + result = self.client.make_request("GET", "/api/1.0/jobs", params=params) + self.test_results.append(result) + logging.info(f"Get jobs (paginated): {result.status_code} ({result.response_time:.3f}s)") + + def _test_search_jobs(self): + """Test searching jobs.""" + params = {"query": "developer", "page": 1, "limit": 5} + result = self.client.make_request("GET", "/api/1.0/jobs/search", params=params) + self.test_results.append(result) + logging.info(f"Search jobs: {result.status_code} ({result.response_time:.3f}s)") + + def _test_create_job(self): + """Test creating a job.""" + job_data = { + "title": "Senior Python Developer", + "owner_id": self.client.user['id'] if self.client.user else None, + "owner_type": self.client.user['userType'] if self.client.user else None, + "description": "Looking for an experienced Python developer", + "requirements": { + "technical_skills": { "required": ["Python", "FastAPI", "PostgreSQL"], "preferred": ["Docker", "Kubernetes"] }, + "experience_requirements": { "required": [], "preferred": [] }, + "soft_skills": ["Communication", "Teamwork", "Problem Solving"] + },#["Python", "FastAPI", "PostgreSQL"], + "summary": "Senior Python Developer with 5+ years experience", + "company": "Tech Innovations Inc.", + "location": "Remote", + "salary_range": "80000-120000" + } + + result = self.client.make_request("POST", "/api/1.0/jobs", json=job_data) + self.test_results.append(result) + + # Store job ID for later tests + if result.success and result.response_data: + self.test_data["job_id"] = result.response_data.get("id") + + logging.info(f"Create job: {result.status_code} ({result.response_time:.3f}s)") + + def _test_get_job(self): + """Test getting a specific job.""" + if not self.test_data["job_id"]: + return + + endpoint = f"/api/1.0/jobs/{self.test_data['job_id']}" + result = self.client.make_request("GET", endpoint) + self.test_results.append(result) + logging.info(f"Get job: {result.status_code} ({result.response_time:.3f}s)") + + +def print_test_summary(results: List[TestResult]): + """Print a summary of test results.""" + total_tests = len(results) + successful_tests = sum(1 for r in results if r.success) + failed_tests = total_tests - successful_tests + + print("\n" + "="*80) + print("TEST SUMMARY") + print("="*80) + print(f"Total Tests: {total_tests}") + print(f"Successful: {successful_tests}") + print(f"Failed: {failed_tests}") + print(f"Success Rate: {(successful_tests/total_tests*100):.1f}%" if total_tests > 0 else "No tests run") + + if failed_tests > 0: + print("\nFAILED TESTS:") + print("-" * 40) + for result in results: + if not result.success: + print(f"āŒ {result.method} {result.endpoint}") + print(f" Status: {result.status_code}") + print(f" Error: {result.error_message}") + print() + + print("\nDETAILED RESULTS:") + print("-" * 40) + for result in results: + status_icon = "āœ…" if result.success else "āŒ" + print(f"{status_icon} {result.method} {result.endpoint} - " + f"{result.status_code} ({result.response_time:.3f}s)") + + +def main(): + """Main CLI entry point.""" + parser = argparse.ArgumentParser( + description="Backstory API Test Suite", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s --login user@example.com --password mypassword + %(prog)s --login user@example.com --password mypassword --mfa-code 123456 + %(prog)s --login user@example.com --password mypassword --verbose + %(prog)s --login user@example.com --password mypassword --endpoint candidates + %(prog)s --login user@example.com --password mypassword --base-url https://localhost:8000 + %(prog)s --use-stored-tokens # Use previously stored tokens + %(prog)s --clear-tokens # Clear stored tokens + """ + ) + + parser.add_argument("--login", help="Login email") + parser.add_argument("--password", help="Login password") + parser.add_argument("--mfa-code", help="MFA code from email (if required)") + parser.add_argument("--base-url", default="https://backstory-beta.ketrenos.com", + help="Base URL for the API (default: %(default)s)") + parser.add_argument("--endpoint", choices=["all", "auth", "candidates", "jobs", "health"], + default="all", help="Test specific endpoint group (default: %(default)s)") + parser.add_argument("--verbose", "-v", action="store_true", + help="Enable verbose logging") + parser.add_argument("--output", choices=["text", "json"], default="text", + help="Output format (default: %(default)s)") + parser.add_argument("--token-file", default=".backstory_tokens.json", + help="Token storage file (default: %(default)s)") + parser.add_argument("--use-stored-tokens", action="store_true", default=True, + help="Use previously stored tokens without login") + parser.add_argument("--clear-tokens", action="store_true", + help="Clear stored tokens and exit") + + args = parser.parse_args() + + # Configure logging + log_level = logging.DEBUG if args.verbose else logging.INFO + logging.basicConfig( + level=log_level, + format="%(asctime)s - %(levelname)s - %(message)s", + datefmt="%H:%M:%S" + ) + + # Initialize client + client = BackstoryAPIClient(args.base_url, args.token_file) + + # Handle token clearing + if args.clear_tokens: + client.clear_tokens() + print("āœ… Stored tokens cleared!") + sys.exit(0) + + # Try to use stored tokens first + if args.use_stored_tokens or client.is_authenticated(): + if client.is_authenticated(): + print("āœ… Using stored authentication tokens!") + else: + print("āŒ No valid stored tokens found!") + if not args.login: + print("Please provide --login and --password to authenticate.") + sys.exit(1) + + # Perform authentication if needed + if not client.is_authenticated(): + if not args.login or not args.password: + print("āŒ Login credentials required!") + print("Please provide --login and --password") + sys.exit(1) + + print(f"Authenticating with {args.base_url}...") + login_result = client.login(args.login, args.password) + + if not login_result["success"]: + print(f"āŒ Authentication failed: {login_result['message']}") + sys.exit(1) + + if login_result["mfa_required"]: + print(f"šŸ“§ MFA required! Check your email for the verification code.") + + if args.verbose and login_result.get("device_id"): + print(f"šŸ”§ Device ID: {login_result['device_id']}") + + # Get MFA code from command line or prompt user + mfa_code = args.mfa_code + if not mfa_code: + try: + mfa_code = input("Enter MFA code: ").strip() + except KeyboardInterrupt: + print("\nāŒ Authentication cancelled!") + sys.exit(1) + + if not mfa_code: + print("āŒ MFA code required!") + sys.exit(1) + + print("Verifying MFA code...") + if not client.verify_mfa(args.login, mfa_code): + print("āŒ MFA verification failed!") + sys.exit(1) + + print("āœ… Authentication successful!") + + # Initialize test suite and run tests + test_suite = BackstoryTestSuite(client) + + print(f"\nRunning tests for: {args.endpoint}") + print("="*50) + + if args.endpoint == "all": + results = test_suite.run_all_tests() + else: + results = test_suite.run_endpoint_tests(args.endpoint) + + # Output results + if args.output == "json": + output = { + "summary": { + "total": len(results), + "successful": sum(1 for r in results if r.success), + "failed": sum(1 for r in results if not r.success) + }, + "results": [ + { + "endpoint": r.endpoint, + "method": r.method, + "status_code": r.status_code, + "success": r.success, + "response_time": r.response_time, + "error_message": r.error_message + } + for r in results + ] + } + print(json.dumps(output, indent=2)) + else: + print_test_summary(results) + + # Exit with error code if any tests failed + failed_count = sum(1 for r in results if not r.success) + sys.exit(1 if failed_count > 0 else 0) + + +if __name__ == "__main__": + main() \ No newline at end of file