diff --git a/frontend/src/components/CandidateInfo.tsx b/frontend/src/components/CandidateInfo.tsx index dd81379..4b0531b 100644 --- a/frontend/src/components/CandidateInfo.tsx +++ b/frontend/src/components/CandidateInfo.tsx @@ -7,7 +7,7 @@ import { useTheme, } from '@mui/material'; import { useMediaQuery } from '@mui/material'; -import { Candidate } from 'types/types'; +import { Candidate, CandidateAI } from 'types/types'; import { CopyBubble } from "components/CopyBubble"; import { rest } from 'lodash'; diff --git a/frontend/src/components/DocumentManager.tsx b/frontend/src/components/DocumentManager.tsx new file mode 100644 index 0000000..1a19cbb --- /dev/null +++ b/frontend/src/components/DocumentManager.tsx @@ -0,0 +1,451 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Button, + Grid, + useMediaQuery, + Typography, + Card, + CardContent, + List, + ListItem, + ListItemText, + ListItemSecondaryAction, + IconButton, + Switch, + FormControlLabel, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Chip, + Divider, + Paper, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { + CloudUpload, + Edit, + Delete, + Visibility, + Close, +} from '@mui/icons-material'; +import { useTheme } from '@mui/material/styles'; + +import { useAuth } from "hooks/AuthContext"; +import * as Types from 'types/types'; +import { BackstoryElementProps } from './BackstoryTab'; + +const VisuallyHiddenInput = styled('input')({ + clip: 'rect(0 0 0 0)', + clipPath: 'inset(50%)', + height: 1, + overflow: 'hidden', + position: 'absolute', + bottom: 0, + left: 0, + whiteSpace: 'nowrap', + width: 1, +}); + +const DocumentManager = (props: BackstoryElementProps) => { + const { setSnack, submitQuery } = props; + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const { user, apiClient } = useAuth(); + + const [documents, setDocuments] = useState([]); + const [selectedDocument, setSelectedDocument] = useState(null); + const [documentContent, setDocumentContent] = useState(''); + const [isViewingContent, setIsViewingContent] = useState(false); + const [editingDocument, setEditingDocument] = useState(null); + const [editingName, setEditingName] = useState(''); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + + // Check if user is a candidate + const candidate = user?.userType === 'candidate' ? user as Types.Candidate : null; + + // Load documents on component mount + useEffect(() => { + if (candidate) { + loadDocuments(); + } + }, [candidate]); + + const loadDocuments = async () => { + try { + const results = await apiClient.getCandidateDocuments(); + setDocuments(results.documents); + } catch (error) { + console.error(error); + setSnack('Failed to load documents', 'error'); + } + }; + + // Handle document upload + const handleDocumentUpload = async (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + const file = e.target.files[0]; + const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); + let docType : Types.DocumentType | null = null; + switch (fileExtension.substring(1)) { + case "pdf": + docType = "pdf"; + break; + case "docx": + docType = "docx"; + break; + case "md": + docType = "markdown"; + break; + case "txt": + docType = "txt"; + break; + } + + if (!docType) { + setSnack('Invalid file type. Please upload .txt, .md, .docx, or .pdf files only.', 'error'); + return; + } + + try { + // Upload file (replace with actual API call) + const newDocument = await apiClient.uploadCandidateDocument(file); + + setDocuments(prev => [...prev, newDocument]); + setSnack(`Document uploaded: ${file.name}`, 'success'); + + // Reset file input + e.target.value = ''; + } catch (error) { + setSnack('Failed to upload document', 'error'); + } + } + }; + + // Handle document deletion + const handleDeleteDocument = async (document: Types.Document) => { + try { + // Call API to delete document + await apiClient.deleteCandidateDocument(document); + + setDocuments(prev => prev.filter(doc => doc.id !== document.id)); + setSnack('Document deleted successfully', 'success'); + + // Close content view if this document was being viewed + if (selectedDocument?.id === document.id) { + setIsViewingContent(false); + setSelectedDocument(null); + setDocumentContent(''); + } + } catch (error) { + setSnack('Failed to delete document', 'error'); + } + }; + + // Handle RAG flag toggle + const handleRAGToggle = async (document: Types.Document, includeInRAG: boolean) => { + try { + document.includeInRAG = includeInRAG; + // Call API to update RAG flag + await apiClient.updateCandidateDocument(document); + + setDocuments(prev => + prev.map(doc => + doc.id === document.id + ? { ...doc, includeInRAG } + : doc + ) + ); + setSnack(`Document ${includeInRAG ? 'included in' : 'excluded from'} RAG`, 'success'); + } catch (error) { + setSnack('Failed to update RAG setting', 'error'); + } + }; + + // Handle document rename + const handleRenameDocument = async (document: Types.Document, newName: string) => { + if (!newName.trim()) { + setSnack('Document name cannot be empty', 'error'); + return; + } + + try { + // Call API to rename document + document.filename = newName + await apiClient.updateCandidateDocument(document); + + setDocuments(prev => + prev.map(doc => + doc.id === document.id + ? { ...doc, filename: newName.trim() } + : doc + ) + ); + setSnack('Document renamed successfully', 'success'); + setIsRenameDialogOpen(false); + setEditingDocument(null); + setEditingName(''); + } catch (error) { + setSnack('Failed to rename document', 'error'); + } + }; + + // Handle document content viewing + const handleViewDocument = async (document: Types.Document) => { + try { + setSelectedDocument(document); + setIsViewingContent(true); + + // Call API to get document content + const result = await apiClient.getCandidateDocumentText(document); + setDocumentContent(result.content); + } catch (error) { + setSnack('Failed to load document content', 'error'); + setIsViewingContent(false); + } + }; + + // Start rename process + const startRename = (document: Types.Document, currentName: string) => { + setEditingDocument(document); + setEditingName(currentName); + setIsRenameDialogOpen(true); + }; + + // Format file size + const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + // Get file type color + const getFileTypeColor = (type: string): 'primary' | 'secondary' | 'success' | 'warning' => { + switch (type) { + case 'pdf': return 'primary'; + case 'docx': return 'secondary'; + case 'txt': return 'success'; + case 'md': return 'warning'; + default: return 'primary'; + } + }; + + if (!candidate) { + return (You must be logged in as a candidate to view this content.); + } + + return ( + <> + + + + Documents + + + + + + + + {documents.length === 0 ? ( + + No additional documents uploaded + + ) : ( + + {documents.map((doc, index) => ( + + {index > 0 && } + + + + {doc.filename} + + + {doc.includeInRAG && ( + + )} + + } + secondary={ + + + {formatFileSize(doc.size)} • {doc?.uploadDate?.toLocaleDateString()} + + + handleRAGToggle(doc, e.target.checked)} + size="small" + /> + } + label={ + + Include in RAG + + } + /> + + + } + /> + + + handleViewDocument(doc)} + title="View content" + > + + + startRename(doc, doc.filename)} + title="Rename" + > + + + handleDeleteDocument(doc)} + title="Delete" + color="error" + > + + + + + + + ))} + + )} + + + + + {/* Document Content Viewer */} + {isViewingContent && ( + + + + + + Document Content + + { + setIsViewingContent(false); + setSelectedDocument(null); + setDocumentContent(''); + }} + > + + + + +
+                    {documentContent || 'Loading content...'}
+                  
+
+
+
+
+ )} + + {/* Rename Dialog */} + setIsRenameDialogOpen(false)} + maxWidth="sm" + fullWidth + > + Rename Document + + setEditingName(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter' && editingDocument) { + handleRenameDocument(editingDocument, editingName); + } + }} + /> + + + + + + +
+ + ); +}; + +export { DocumentManager }; \ No newline at end of file diff --git a/frontend/src/components/VectorVisualizer.tsx b/frontend/src/components/VectorVisualizer.tsx index 4585cb2..7fd0a17 100644 --- a/frontend/src/components/VectorVisualizer.tsx +++ b/frontend/src/components/VectorVisualizer.tsx @@ -420,7 +420,7 @@ const VectorVisualizer: React.FC = (props: VectorVisualiz const fetchRAGMeta = async (node: Node) => { try { - const result = await apiClient.getCandidateContent(node.id); + const result = await apiClient.getCandidateRAGContent(node.id); const update: Node = { ...node, fullContent: result.content diff --git a/frontend/src/pages/GenerateCandidate.tsx b/frontend/src/pages/GenerateCandidate.tsx index de0dd8b..b39c53b 100644 --- a/frontend/src/pages/GenerateCandidate.tsx +++ b/frontend/src/pages/GenerateCandidate.tsx @@ -12,18 +12,18 @@ import { jsonrepair } from 'jsonrepair'; import { CandidateInfo } from '../components/CandidateInfo'; import { Quote } from 'components/Quote'; -import { Candidate } from '../types/types'; import { BackstoryElementProps } from 'components/BackstoryTab'; import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField'; import { StyledMarkdown } from 'components/StyledMarkdown'; import { Scrollable } from '../components/Scrollable'; import { Pulse } from 'components/Pulse'; import { StreamingResponse } from 'services/api-client'; -import { ChatContext, ChatMessage, ChatMessageUser, ChatMessageBase, ChatSession, ChatQuery } from 'types/types'; +import { ChatContext, ChatMessage, ChatMessageUser, ChatMessageBase, ChatSession, ChatQuery, Candidate, CandidateAI } from 'types/types'; import { useAuth } from 'hooks/AuthContext'; -const emptyUser: Candidate = { +const emptyUser: CandidateAI = { userType: "candidate", + isAI: true, description: "[blank]", username: "[blank]", firstName: "[blank]", @@ -54,7 +54,7 @@ const GenerateCandidate = (props: BackstoryElementProps) => { const { setSnack, submitQuery } = props; const [streaming, setStreaming] = useState(''); const [processing, setProcessing] = useState(false); - const [user, setUser] = useState(null); + const [user, setUser] = useState(null); const [prompt, setPrompt] = useState(''); const [resume, setResume] = useState(''); const [canGenImage, setCanGenImage] = useState(false); @@ -347,7 +347,7 @@ const GenerateCandidate = (props: BackstoryElementProps) => { maxWidth: { xs: '100%', md: '700px', lg: '1024px' }, }}> {user && } { prompt && diff --git a/frontend/src/pages/candidate/Profile.tsx b/frontend/src/pages/candidate/Profile.tsx index 98c6ce1..d138e3e 100644 --- a/frontend/src/pages/candidate/Profile.tsx +++ b/frontend/src/pages/candidate/Profile.tsx @@ -60,6 +60,7 @@ import * as Types from 'types/types'; import { ComingSoon } from 'components/ui/ComingSoon'; import { VectorVisualizer } from 'components/VectorVisualizer'; import { BackstoryPageProps } from 'components/BackstoryTab'; +import { DocumentManager } from 'components/DocumentManager'; // Styled components const VisuallyHiddenInput = styled('input')({ @@ -105,11 +106,11 @@ function TabPanel(props: TabPanelProps) { } const CandidateProfilePage: React.FC = (props: BackstoryPageProps) => { - const { setSnack, submitQuery } = props; - const backstoryProps = { setSnack, submitQuery }; + const { setSnack, submitQuery } = props; + const backstoryProps = { setSnack, submitQuery }; const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); - const { user, /*updateUser,*/ apiClient } = useAuth(); + const { user, updateUserData, apiClient } = useAuth(); // Check if user is a candidate const candidate = user?.userType === 'candidate' ? user as Types.Candidate : null; @@ -131,7 +132,6 @@ const CandidateProfilePage: React.FC = (props: BackstoryPage // Form data state const [formData, setFormData] = useState>({}); const [profileImage, setProfileImage] = useState(null); - const [resumeFile, setResumeFile] = useState(null); // Dialog states const [skillDialog, setSkillDialog] = useState(false); @@ -216,18 +216,6 @@ const CandidateProfilePage: React.FC = (props: BackstoryPage } }; - // Handle resume upload - const handleResumeUpload = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files[0]) { - setResumeFile(e.target.files[0]); - setSnackbar({ - open: true, - message: `Resume uploaded: ${e.target.files[0].name}`, - severity: 'success' - }); - } - }; - // Toggle edit mode for a section const toggleEditMode = (section: string) => { setEditMode({ @@ -242,7 +230,7 @@ const CandidateProfilePage: React.FC = (props: BackstoryPage try { if (candidate.id) { const updatedCandidate = await apiClient.updateCandidate(candidate.id, formData); -// updateUser(updatedCandidate); + updateUserData(updatedCandidate); setSnackbar({ open: true, message: 'Profile updated successfully!', @@ -309,8 +297,8 @@ const CandidateProfilePage: React.FC = (props: BackstoryPage // Basic Information Tab const renderBasicInfo = () => ( - - + + = (props: BackstoryPage )} - + - + {editMode.basic ? ( = (props: BackstoryPage onChange={(e) => handleInputChange('firstName', e.target.value)} variant="outlined" /> - ) : ( - - First Name: {candidate.firstName} - - )} - + ) : (<> + First Name + {candidate.firstName} + )} + - + {editMode.basic ? ( = (props: BackstoryPage onChange={(e) => handleInputChange('lastName', e.target.value)} variant="outlined" /> - ) : ( - - Last Name: {candidate.lastName} - - )} - + ) : (<> + Last Name + {candidate.lastName} + )} + - - {editMode.basic ? ( + + {(false && editMode.basic) ? ( = (props: BackstoryPage onChange={(e) => handleInputChange('email', e.target.value)} variant="outlined" /> - ) : ( - - - Email: {candidate.email} - + ) : (<> + + Email + {candidate.email} + )} - + - + {editMode.basic ? ( = (props: BackstoryPage onChange={(e) => handleInputChange('phone', e.target.value)} variant="outlined" /> - ) : ( - - - Phone: {candidate.phone || 'Not provided'} - + ) : (<> + + Phone + {candidate.phone || 'Not provided'} + )} - + - + {editMode.basic ? ( handleInputChange('summary', e.target.value)} + value={formData.description || ''} + onChange={(e) => handleInputChange('description', e.target.value)} variant="outlined" /> - ) : ( - - Professional Summary:
- {candidate.summary || 'No summary provided'} -
- )} -
+ ) : (<> + Professional Summary + {candidate.description || 'No summary provided'} + )} + - {/* - {editMode.basic ? ( + + {false && editMode.basic ? ( = (props: BackstoryPage variant="outlined" placeholder="City, State, Country" /> - ) : ( - + ) : (<> - Location: {candidate.location?.city || 'Not specified'}, {candidate.location?.country || ''} - + Location + {candidate.location?.city || 'Not specified'} {candidate.location?.country || ''} + )} - */} + - + = (props: BackstoryPage startIcon={} fullWidth={isMobile} > - Edit Basic Info + Edit Info )} - -
+ + ); // Skills Tab @@ -682,62 +666,7 @@ const CandidateProfilePage: React.FC = (props: BackstoryPage // Resume Tab const renderResume = () => ( - - Resume & Documents - - - - - - - Current Resume - - {candidate.resume ? ( - - Resume on file: {candidate.resume} - - ) : ( - - No resume uploaded - - )} - - - - - {resumeFile && ( - - New file selected: {resumeFile.name} - - )} - - - - - - + ); return ( @@ -789,7 +718,7 @@ const CandidateProfilePage: React.FC = (props: BackstoryPage iconPosition={isMobile ? "top" : "start"} /> } iconPosition={isMobile ? "top" : "start"} /> diff --git a/frontend/src/services/api-client.ts b/frontend/src/services/api-client.ts index a49bc00..1fe2e39 100644 --- a/frontend/src/services/api-client.ts +++ b/frontend/src/services/api-client.ts @@ -761,13 +761,13 @@ class ApiClient { return result; } - async getCandidateContent( - doc_id: string, + async getCandidateRAGContent( + documentId: string, ): Promise { const response = await fetch(`${this.baseUrl}/candidates/rag-content`, { method: 'POST', headers: this.defaultHeaders, - body: JSON.stringify(doc_id) + body: JSON.stringify({ id: documentId }) }); const result = await handleApiResponse(response); @@ -775,6 +775,79 @@ class ApiClient { return result; } + /**** + * Document CRUD API + */ + async uploadCandidateDocument(file: File, includeInRag: boolean = true): Promise { + const formData = new FormData() + console.log(file); + formData.append('file', file); + formData.append('filename', file.name); + formData.append('include_in_rag', includeInRag.toString()); + + const response = await fetch(`${this.baseUrl}/candidates/documents/upload`, { + method: 'POST', + headers: { + // Don't set Content-Type - browser will set it automatically with boundary + 'Authorization': this.defaultHeaders['Authorization'] + }, + body: formData + }); + + const result = await handleApiResponse(response); + + return result; + } + + async updateCandidateDocument(document: Types.Document) : Promise { + const request : Types.DocumentUpdateRequest = { + filename: document.filename, + includeInRAG: document.includeInRAG + } + const response = await fetch(`${this.baseUrl}/candidates/documents/${document.id}`, { + method: 'PATCH', + headers: this.defaultHeaders, + body: JSON.stringify(formatApiRequest(request)) + }); + + const result = await handleApiResponse(response); + + return result; + }; + + async deleteCandidateDocument(document: Types.Document): Promise { + const response = await fetch(`${this.baseUrl}/candidates/documents/${document.id}`, { + method: 'DELETE', + headers: this.defaultHeaders + }); + + const result = await handleApiResponse(response); + + return result; + } + + async getCandidateDocuments(): Promise { + const response = await fetch(`${this.baseUrl}/candidates/documents`, { + headers: this.defaultHeaders, + }); + + const result = await handleApiResponse(response); + + return result; + } + + async getCandidateDocumentText( + document: Types.Document, + ): Promise { + const response = await fetch(`${this.baseUrl}/candidates/documents/${document.id}/content`, { + headers: this.defaultHeaders, + }); + + const result = await handleApiResponse(response); + + return result; + } + /** * Create a chat session about a specific candidate */ diff --git a/frontend/src/types/types.ts b/frontend/src/types/types.ts index 1078078..1fd9b1c 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-02T18:30:16.709256 +// Generated on: 2025-06-02T23:04:30.814624 // DO NOT EDIT MANUALLY - This file is auto-generated // ============================ @@ -25,6 +25,8 @@ export type ColorBlindMode = "protanopia" | "deuteranopia" | "tritanopia" | "non export type DataSourceType = "document" | "website" | "api" | "database" | "internal"; +export type DocumentType = "pdf" | "docx" | "txt" | "markdown" | "image"; + export type EmploymentType = "full-time" | "part-time" | "contract" | "internship" | "freelance"; export type FontSize = "small" | "medium" | "large"; @@ -197,6 +199,41 @@ export interface Candidate { hasProfile: boolean; rags?: Array; ragContentSize: number; +} + +export interface CandidateAI { + id?: string; + email: string; + firstName: string; + lastName: string; + fullName: string; + phone?: string; + location?: Location; + createdAt: Date; + updatedAt: Date; + lastLogin?: Date; + profileImage?: string; + status: "active" | "inactive" | "pending" | "banned"; + isAdmin: boolean; + userType: "candidate"; + username: string; + description?: string; + resume?: string; + skills?: Array; + experience?: Array; + questions?: Array; + education?: Array; + preferredJobTypes?: Array<"full-time" | "part-time" | "contract" | "internship" | "freelance">; + desiredSalary?: DesiredSalary; + availabilityDate?: Date; + summary?: string; + languages?: Array; + certifications?: Array; + jobApplications?: Array; + hasProfile: boolean; + rags?: Array; + ragContentSize: number; + isAI: boolean; age?: number; gender?: "female" | "male"; ethnicity?: string; @@ -398,6 +435,36 @@ export interface DesiredSalary { period: "hour" | "day" | "month" | "year"; } +export interface Document { + id?: string; + ownerId: string; + filename: string; + originalName: string; + type: "pdf" | "docx" | "txt" | "markdown" | "image"; + size: number; + uploadDate?: Date; + includeInRAG: boolean; + ragChunks?: number; +} + +export interface DocumentContentResponse { + documentId: string; + filename: string; + type: "pdf" | "docx" | "txt" | "markdown" | "image"; + content: string; + size: number; +} + +export interface DocumentListResponse { + documents: Array; + total: number; +} + +export interface DocumentUpdateRequest { + filename?: string; + includeInRAG?: boolean; +} + export interface EditHistory { content: string; editedAt: Date; @@ -909,6 +976,25 @@ export function convertCandidateFromApi(data: any): Candidate { availabilityDate: data.availabilityDate ? new Date(data.availabilityDate) : undefined, }; } +/** + * Convert CandidateAI from API response, parsing date fields + * Date fields: createdAt, updatedAt, lastLogin, availabilityDate + */ +export function convertCandidateAIFromApi(data: any): CandidateAI { + if (!data) return data; + + return { + ...data, + // Convert createdAt from ISO string to Date + createdAt: new Date(data.createdAt), + // Convert updatedAt from ISO string to Date + updatedAt: new Date(data.updatedAt), + // Convert lastLogin from ISO string to Date + lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined, + // Convert availabilityDate from ISO string to Date + availabilityDate: data.availabilityDate ? new Date(data.availabilityDate) : undefined, + }; +} /** * Convert Certification from API response, parsing date fields * Date fields: issueDate, expirationDate @@ -1004,6 +1090,19 @@ export function convertDataSourceConfigurationFromApi(data: any): DataSourceConf lastRefreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : undefined, }; } +/** + * Convert Document from API response, parsing date fields + * Date fields: uploadDate + */ +export function convertDocumentFromApi(data: any): Document { + if (!data) return data; + + return { + ...data, + // Convert uploadDate from ISO string to Date + uploadDate: data.uploadDate ? new Date(data.uploadDate) : undefined, + }; +} /** * Convert EditHistory from API response, parsing date fields * Date fields: editedAt @@ -1218,6 +1317,8 @@ export function convertFromApi(data: any, modelType: string): T { return convertBaseUserWithTypeFromApi(data) as T; case 'Candidate': return convertCandidateFromApi(data) as T; + case 'CandidateAI': + return convertCandidateAIFromApi(data) as T; case 'Certification': return convertCertificationFromApi(data) as T; case 'ChatMessage': @@ -1232,6 +1333,8 @@ export function convertFromApi(data: any, modelType: string): T { return convertChatSessionFromApi(data) as T; case 'DataSourceConfiguration': return convertDataSourceConfigurationFromApi(data) as T; + case 'Document': + return convertDocumentFromApi(data) as T; case 'EditHistory': return convertEditHistoryFromApi(data) as T; case 'Education': diff --git a/src/backend/database.py b/src/backend/database.py index b364ec1..570e3a0 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -181,6 +181,7 @@ class RedisDatabase: 'chat_messages': 'chat_messages:', # This will store lists 'ai_parameters': 'ai_parameters:', 'users': 'user:', + 'candidate_documents': 'candidate_documents:', } def _serialize(self, data: Any) -> str: @@ -198,6 +199,103 @@ class RedisDatabase: except json.JSONDecodeError: logger.error(f"Failed to deserialize data: {data}") return None + + # Document operations + async def get_document(self, document_id: str) -> Optional[Dict]: + """Get document metadata by ID""" + key = f"document:{document_id}" + data = await self.redis.get(key) + return self._deserialize(data) if data else None + + async def set_document(self, document_id: str, document_data: Dict): + """Set document metadata""" + key = f"document:{document_id}" + await self.redis.set(key, self._serialize(document_data)) + + async def delete_document(self, document_id: str): + """Delete document metadata""" + key = f"document:{document_id}" + await self.redis.delete(key) + + 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) + + if not document_ids: + return [] + + # Get all document metadata + pipe = self.redis.pipeline() + for doc_id in document_ids: + pipe.get(f"document:{doc_id}") + values = await pipe.execute() + + documents = [] + for doc_id, value in zip(document_ids, values): + if value: + doc_data = self._deserialize(value) + if doc_data: + documents.append(doc_data) + else: + # Clean up orphaned document ID + await self.redis.lrem(key, 0, doc_id) + logger.warning(f"Removed orphaned document ID {doc_id} for candidate {candidate_id}") + + return documents + + 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) + + 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) + + async def update_document(self, document_id: str, updates: Dict): + """Update document metadata""" + document_data = await self.get_document(document_id) + if document_data: + document_data.update(updates) + await self.set_document(document_id, document_data) + return document_data + return None + + async def get_documents_by_rag_status(self, candidate_id: str, include_in_rag: bool = True) -> List[Dict]: + """Get candidate documents filtered by RAG inclusion status""" + all_documents = await self.get_candidate_documents(candidate_id) + return [doc for doc in all_documents if doc.get("include_in_RAG", False) == include_in_rag] + + async def bulk_update_document_rag_status(self, candidate_id: str, document_ids: List[str], include_in_rag: bool): + """Bulk update RAG status for multiple documents""" + pipe = self.redis.pipeline() + + for doc_id in document_ids: + doc_data = await self.get_document(doc_id) + if doc_data and doc_data.get("candidate_id") == candidate_id: + doc_data["include_in_RAG"] = include_in_rag + doc_data["updatedAt"] = datetime.now(UTC).isoformat() + pipe.set(f"document:{doc_id}", self._serialize(doc_data)) + + await pipe.execute() + + 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) + + async def search_candidate_documents(self, candidate_id: str, query: str) -> List[Dict]: + """Search documents by filename for a candidate""" + all_documents = await self.get_candidate_documents(candidate_id) + query_lower = query.lower() + + return [ + doc for doc in all_documents + if (query_lower in doc.get("filename", "").lower() or + query_lower in doc.get("originalName", "").lower()) + ] # Viewer operations async def get_viewer(self, viewer_id: str) -> Optional[Dict]: diff --git a/src/backend/main.py b/src/backend/main.py index 9416e29..96e82b8 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -1,11 +1,18 @@ -from fastapi import FastAPI, HTTPException, Depends, Query, Path, Body, status, APIRouter, Request, BackgroundTasks # type: ignore +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# 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 +import os +import shutil +from enum import Enum +import uuid +import defines +import pathlib + import uvicorn # type: ignore from typing import List, Optional, Dict, Any from datetime import datetime, timedelta, UTC @@ -68,6 +75,9 @@ from models import ( # Chat models ChatSession, ChatMessage, ChatContext, ChatQuery, ChatStatusType, ChatMessageBase, ChatMessageUser, ChatSenderType, ChatMessageType, ChatContextType, ChatMessageRagSearch, + + # Document models + Document, DocumentType, DocumentListResponse, DocumentUpdateRequest, DocumentContentResponse, # Supporting models Location, MFARequest, MFAData, MFARequestResponse, MFAVerifyRequest, RagContentResponse, ResendVerificationRequest, Skill, WorkExperience, Education, @@ -388,6 +398,41 @@ async def stream_agent_response(chat_agent: agents.Agent, }, ) + +# 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_file_path(username: str, document_id: str, filename: str) -> pathlib.Path: + """Get the full file path for a document""" + files_dir = get_candidate_files_dir(username) + # Use document ID + original extension to avoid filename conflicts + file_extension = pathlib.Path(filename).suffix + safe_filename = f"{document_id}{file_extension}" + return files_dir / safe_filename + +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) + # ============================ # API Router Setup # ============================ @@ -1429,45 +1474,420 @@ async def verify_mfa( content=create_error_response("MFA_VERIFICATION_FAILED", "Failed to verify MFA") ) -@api_router.get("/candidates/{username}") -async def get_candidate( - username: str = Path(...), +@api_router.post("/candidates/documents/upload") +async def upload_candidate_document( + file: UploadFile = File(...), + include_in_rag: bool = Form(True), + current_user = Depends(get_current_user), database: RedisDatabase = Depends(get_database) ): - """Get a candidate by username""" + """Upload a document for the current candidate""" 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): + # 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=404, - content=create_error_response("NOT_FOUND", "Candidate not found") + status_code=403, + content=create_error_response("FORBIDDEN", "Only candidates can upload documents") ) - candidate = Candidate.model_validate(candidates_list[0]) - return create_success_response(candidate.model_dump(by_alias=True, exclude_unset=True)) + 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") + return JSONResponse( + status_code=400, + content=create_error_response("MISSING_FILENAME", "File must have a valid filename") + ) + + logger.info(f"📁 Received file upload: filename='{file.filename}', content_type='{file.content_type}', size estimate='{file.size if hasattr(file, 'size') else 'unknown'}'") + + # 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}") + 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 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 JSONResponse( + status_code=400, + content=create_error_response("FILE_TOO_LARGE", "File size exceeds 10MB limit") + ) + + # 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), + upload_date=datetime.now(UTC), + include_in_RAG=include_in_rag, + owner_id=candidate.id + ) + + # Save file to disk + file_path = os.path.join(defines.user_dir, candidate.username, "files", 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}") + return JSONResponse( + status_code=500, + content=create_error_response("FILE_SAVE_ERROR", "Failed to save file to disk") + ) + + 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 + ): + try: + from markitdown import MarkItDown # type: ignore + md = MarkItDown(enable_plugins=False) # Set to True to enable plugins + result = md.convert(file_path) + p_as_md.write_text(result.text_content) + except Exception as e: + logging.error(f"Error convering via markdownit: {e}") + + # 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}") + + return create_success_response(document_data.model_dump(by_alias=True, exclude_unset=True)) except Exception as e: - logger.error(f"❌ Get candidate error: {e}") + logger.error(traceback.format_exc()) + logger.error(f"❌ Document upload error: {e}") return JSONResponse( status_code=500, - content=create_error_response("FETCH_ERROR", str(e)) + content=create_error_response("UPLOAD_ERROR", "Failed to upload document") ) +@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, exclude_unset=True)) + + except Exception as e: + logger.error(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, "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.value, + content=content, + size=document.size + ) + return create_success_response(response.model_dump(by_alias=True, exclude_unset=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(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") + ) + + # Apply updates + update_dict = {} + if updates.filename is not None: + update_dict["filename"] = updates.filename.strip() + if updates.include_in_RAG is not None: + update_dict["include_in_RAG"] = updates.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, exclude_unset=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 = get_document_file_path(candidate.username, document_id, document.originalName) + + 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}") + 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(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, exclude_unset=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""" + doc_id: str + @api_router.post("/candidates/rag-content") async def post_candidate_vector_content( - doc_id: str = Body(...), + rag_document: RAGDocumentRequest = Body(...), current_user = Depends(get_current_user) ): try: @@ -1486,17 +1906,17 @@ async def post_candidate_vector_content( ) if not collection.get("metadatas", None): - return JSONResponse(f"Document id {doc_id} not found.", 404) + return JSONResponse(f"Document id {rag_document.id} not found.", 404) for index, id in enumerate(collection.get("ids", [])): - if id == doc_id: + if id == rag_document.id: metadata = collection.get("metadatas", [])[index].copy() content = candidate_entity.file_watcher.prepare_metadata(metadata) rag_response = RagContentResponse(id=id, content=content, metadata=metadata) logger.info(f"✅ Fetched RAG content for document id {id} for candidate {candidate.username}") return create_success_response(rag_response.model_dump(by_alias=True, exclude_unset=True)) - return JSONResponse(f"Document id {doc_id} not found.", 404) + return JSONResponse(f"Document id {rag_document.id} not found.", 404) except Exception as e: logger.error(f"❌ Post candidate content error: {e}") return JSONResponse( @@ -1977,6 +2397,42 @@ async def post_candidate_rag_search( content=create_error_response("SUMMARY_ERROR", str(e)) ) +@api_router.get("/candidates/{username}") +async def get_candidate( + username: str = Path(...), + database: RedisDatabase = Depends(get_database) +): + """Get 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]) + return create_success_response(candidate.model_dump(by_alias=True, exclude_unset=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(...), diff --git a/src/backend/models.py b/src/backend/models.py index 64fd8ef..5a28588 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -216,7 +216,6 @@ class LoginRequest(BaseModel): # MFA Models # ============================ - class EmailVerificationRequest(BaseModel): token: str @@ -480,6 +479,48 @@ class RagContentResponse(BaseModel): content: str metadata: RagContentMetadata +class DocumentType(str, Enum): + PDF = "pdf" + DOCX = "docx" + TXT = "txt" + MARKDOWN = "markdown" + IMAGE = "image" + +class Document(BaseModel): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + owner_id: str = Field(..., alias="ownerId") + filename: str + originalName: str + type: DocumentType + size: int + upload_date: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="uploadDate") + include_in_RAG: bool = Field(default=True, alias="includeInRAG") + rag_chunks: Optional[int] = Field(default=0, alias="ragChunks") + model_config = { + "populate_by_name": True # Allow both field names and aliases + } + +class DocumentContentResponse(BaseModel): + document_id: str = Field(..., alias="documentId") + filename: str + type: DocumentType + content: str + size: int + model_config = { + "populate_by_name": True # Allow both field names and aliases + } + +class DocumentListResponse(BaseModel): + documents: List[Document] + total: int + +class DocumentUpdateRequest(BaseModel): + filename: Optional[str] = None + include_in_RAG: Optional[bool] = Field(None, alias="includeInRAG") + model_config = { + "populate_by_name": True # Allow both field names and aliases + } + class Candidate(BaseUser): user_type: Literal[UserType.CANDIDATE] = Field(UserType.CANDIDATE, alias="userType") username: str @@ -499,7 +540,10 @@ class Candidate(BaseUser): has_profile: bool = Field(default=False, alias="hasProfile") rags: List[RagEntry] = Field(default_factory=list) rag_content_size : int = 0 - # Used for AI generated personas + +class CandidateAI(Candidate): + user_type: Literal[UserType.CANDIDATE] = Field(UserType.CANDIDATE, alias="userType") + is_AI: bool = Field(True, alias="isAI") age: Optional[int] = None gender: Optional[UserGender] = None ethnicity: Optional[str] = None