File upload working

This commit is contained in:
James Ketr 2025-06-02 16:06:25 -07:00
parent 149bbdf73b
commit a65f48034c
10 changed files with 1324 additions and 170 deletions

View File

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

View File

@ -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<Types.Document[]>([]);
const [selectedDocument, setSelectedDocument] = useState<Types.Document | null>(null);
const [documentContent, setDocumentContent] = useState<string>('');
const [isViewingContent, setIsViewingContent] = useState(false);
const [editingDocument, setEditingDocument] = useState<Types.Document | null>(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<HTMLInputElement>) => {
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 (<Box>You must be logged in as a candidate to view this content.</Box>);
}
return (
<>
<Grid container spacing={{ xs: 1, sm: 3 }} sx={{ maxWidth: '100%' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, width: "100%", verticalAlign: "center" }}>
<Typography variant={isMobile ? "subtitle2" : "h6"}>
Documents
</Typography>
<Button
component="label"
variant="contained"
startIcon={<CloudUpload />}
size={isMobile ? "small" : "medium"}>
Upload Document
<VisuallyHiddenInput
type="file"
accept=".txt,.md,.docx,.pdf"
onChange={handleDocumentUpload}
/>
</Button>
</Box>
<Grid size={{ xs: 12 }}>
<Card variant="outlined">
<CardContent sx={{ p: { xs: 1.5, sm: 3 } }}>
{documents.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{
fontSize: { xs: '0.8rem', sm: '0.875rem' },
textAlign: 'center',
py: 3
}}>
No additional documents uploaded
</Typography>
) : (
<List sx={{ width: '100%' }}>
{documents.map((doc, index) => (
<React.Fragment key={doc.id}>
{index > 0 && <Divider />}
<ListItem sx={{ px: 0 }}>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="body1" sx={{
wordBreak: 'break-word',
fontSize: { xs: '0.9rem', sm: '1rem' }
}}>
{doc.filename}
</Typography>
<Chip
label={doc.type.toUpperCase()}
size="small"
color={getFileTypeColor(doc.type)}
/>
{doc.includeInRAG && (
<Chip
label="RAG"
size="small"
color="success"
variant="outlined"
/>
)}
</Box>
}
secondary={
<Box sx={{ mt: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{formatFileSize(doc.size)} {doc?.uploadDate?.toLocaleDateString()}
</Typography>
<Box sx={{ mt: 1 }}>
<FormControlLabel
control={
<Switch
checked={doc.includeInRAG}
onChange={(e) => handleRAGToggle(doc, e.target.checked)}
size="small"
/>
}
label={
<Typography variant="caption">
Include in RAG
</Typography>
}
/>
</Box>
</Box>
}
/>
<ListItemSecondaryAction>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton
edge="end"
size="small"
onClick={() => handleViewDocument(doc)}
title="View content"
>
<Visibility />
</IconButton>
<IconButton
edge="end"
size="small"
onClick={() => startRename(doc, doc.filename)}
title="Rename"
>
<Edit />
</IconButton>
<IconButton
edge="end"
size="small"
onClick={() => handleDeleteDocument(doc)}
title="Delete"
color="error"
>
<Delete />
</IconButton>
</Box>
</ListItemSecondaryAction>
</ListItem>
</React.Fragment>
))}
</List>
)}
</CardContent>
</Card>
</Grid>
{/* Document Content Viewer */}
{isViewingContent && (
<Grid size={{ xs: 12 }}>
<Card variant="outlined">
<CardContent sx={{ p: { xs: 1.5, sm: 3 } }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant={isMobile ? "subtitle2" : "h6"}>
Document Content
</Typography>
<IconButton
size="small"
onClick={() => {
setIsViewingContent(false);
setSelectedDocument(null);
setDocumentContent('');
}}
>
<Close />
</IconButton>
</Box>
<Paper
variant="outlined"
sx={{
p: 2,
maxHeight: 400,
overflow: 'auto',
backgroundColor: 'grey.50'
}}
>
<pre style={{
margin: 0,
fontFamily: 'monospace',
fontSize: isMobile ? '0.75rem' : '0.875rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}>
{documentContent || 'Loading content...'}
</pre>
</Paper>
</CardContent>
</Card>
</Grid>
)}
{/* Rename Dialog */}
<Dialog
open={isRenameDialogOpen}
onClose={() => setIsRenameDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Rename Document</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Document Name"
fullWidth
variant="outlined"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter' && editingDocument) {
handleRenameDocument(editingDocument, editingName);
}
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsRenameDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={() => editingDocument && handleRenameDocument(editingDocument, editingName)}
variant="contained"
disabled={!editingName.trim()}
>
Rename
</Button>
</DialogActions>
</Dialog>
</Grid>
</>
);
};
export { DocumentManager };

View File

@ -420,7 +420,7 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (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

View File

@ -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<string>('');
const [processing, setProcessing] = useState<boolean>(false);
const [user, setUser] = useState<Candidate | null>(null);
const [user, setUser] = useState<CandidateAI | null>(null);
const [prompt, setPrompt] = useState<string>('');
const [resume, setResume] = useState<string>('');
const [canGenImage, setCanGenImage] = useState<boolean>(false);
@ -347,7 +347,7 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
maxWidth: { xs: '100%', md: '700px', lg: '1024px' },
}}>
{user && <CandidateInfo
candidate={user}
candidate={user}
sx={{flexShrink: 1}}/>
}
{ prompt &&

View File

@ -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<BackstoryPageProps> = (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<BackstoryPageProps> = (props: BackstoryPage
// Form data state
const [formData, setFormData] = useState<Partial<Types.Candidate>>({});
const [profileImage, setProfileImage] = useState<string | null>(null);
const [resumeFile, setResumeFile] = useState<File | null>(null);
// Dialog states
const [skillDialog, setSkillDialog] = useState(false);
@ -216,18 +216,6 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
}
};
// Handle resume upload
const handleResumeUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
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<BackstoryPageProps> = (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<BackstoryPageProps> = (props: BackstoryPage
// Basic Information Tab
const renderBasicInfo = () => (
<Grid container spacing={{ xs: 1, sm: 3 }} sx={{ maxWidth: '100%' }}>
<Grid size={{ xs: 12 }} sx={{ textAlign: 'center', mb: { xs: 1, sm: 2 } }}>
<Box sx={{ display: "flex", flexDirection: "column", "& .entry": { flexDirection: "column", fontSize: "0.9rem", display: "flex", mt: 1 }, "& .title": { display: "flex", fontWeight: "bold" } }}>
<Box sx={{ textAlign: 'center', mb: { xs: 1, sm: 2 } }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Avatar
src={profileImage || candidate.profileImage || ''}
@ -344,9 +332,9 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
</>
)}
</Box>
</Grid>
</Box>
<Grid size={{ xs: 12, sm: 6 }}>
<Box className="entry">
{editMode.basic ? (
<TextField
fullWidth
@ -355,14 +343,13 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
onChange={(e) => handleInputChange('firstName', e.target.value)}
variant="outlined"
/>
) : (
<Typography variant="body1">
<strong>First Name:</strong> {candidate.firstName}
</Typography>
)}
</Grid>
) : (<>
<Box className="title">First Name</Box>
<Box className="value">{candidate.firstName}</Box>
</>)}
</Box>
<Grid size={{ xs: 12, sm: 6 }}>
<Box className="entry">
{editMode.basic ? (
<TextField
fullWidth
@ -371,15 +358,14 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
onChange={(e) => handleInputChange('lastName', e.target.value)}
variant="outlined"
/>
) : (
<Typography variant="body1">
<strong>Last Name:</strong> {candidate.lastName}
</Typography>
)}
</Grid>
) : (<>
<Box className="title">Last Name</Box>
<Box className="value">{candidate.lastName}</Box>
</>)}
</Box>
<Grid size={{ xs: 12, sm: 6 }}>
{editMode.basic ? (
<Box className="entry">
{(false && editMode.basic) ? (
<TextField
fullWidth
label="Email"
@ -388,15 +374,15 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
onChange={(e) => handleInputChange('email', e.target.value)}
variant="outlined"
/>
) : (
<Typography variant="body1">
<Email sx={{ mr: 1, verticalAlign: 'middle' }} />
<strong>Email:</strong> {candidate.email}
</Typography>
) : (<>
<Box className="title"><Email sx={{ mr: 1, verticalAlign: 'middle' }} />
Email</Box>
<Box className="value">{candidate.email}</Box>
</>
)}
</Grid>
</Box>
<Grid size={{ xs: 12, sm: 6 }}>
<Box className="entry">
{editMode.basic ? (
<TextField
fullWidth
@ -405,35 +391,33 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
onChange={(e) => handleInputChange('phone', e.target.value)}
variant="outlined"
/>
) : (
<Typography variant="body1">
<Phone sx={{ mr: 1, verticalAlign: 'middle' }} />
<strong>Phone:</strong> {candidate.phone || 'Not provided'}
</Typography>
) : (<>
<Box className="title"><Phone sx={{ mr: 1, verticalAlign: 'middle' }} />
Phone</Box>
<Box className="value">{candidate.phone || 'Not provided'}</Box>
</>
)}
</Grid>
</Box>
<Grid size={{ xs: 12 }}>
<Box className="entry">
{editMode.basic ? (
<TextField
fullWidth
multiline
rows={3}
label="Professional Summary"
value={formData.summary || ''}
onChange={(e) => handleInputChange('summary', e.target.value)}
value={formData.description || ''}
onChange={(e) => handleInputChange('description', e.target.value)}
variant="outlined"
/>
) : (
<Typography variant="body1">
<strong>Professional Summary:</strong><br />
{candidate.summary || 'No summary provided'}
</Typography>
)}
</Grid>
) : (<>
<Box className="title">Professional Summary</Box>
<Box className="value">{candidate.description || 'No summary provided'}</Box>
</>)}
</Box>
{/* <Grid size={{ xs: 12 }}>
{editMode.basic ? (
<Box className="entry">
{false && editMode.basic ? (
<TextField
fullWidth
label="Location"
@ -445,15 +429,15 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
variant="outlined"
placeholder="City, State, Country"
/>
) : (
<Typography variant="body1">
) : (<><Box className="title">
<LocationOn sx={{ mr: 1, verticalAlign: 'middle' }} />
<strong>Location:</strong> {candidate.location?.city || 'Not specified'}, {candidate.location?.country || ''}
</Typography>
Location</Box>
<Box className="value">{candidate.location?.city || 'Not specified'} {candidate.location?.country || ''}</Box>
</>
)}
</Grid> */}
</Box>
<Grid size={{ xs: 12 }}>
<Box className="entry">
<Box sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
@ -488,12 +472,12 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
startIcon={<Edit />}
fullWidth={isMobile}
>
Edit Basic Info
Edit Info
</Button>
)}
</Box>
</Grid>
</Grid>
</Box>
</Box >
);
// Skills Tab
@ -682,62 +666,7 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
// Resume Tab
const renderResume = () => (
<Box>
<Typography variant={isMobile ? "subtitle1" : "h6"} gutterBottom>Resume & Documents</Typography>
<Grid container spacing={{ xs: 1, sm: 3 }} sx={{ maxWidth: '100%' }}>
<Grid size={{ xs: 12 }}>
<Card variant="outlined">
<CardContent sx={{ p: { xs: 1.5, sm: 3 } }}>
<Typography variant={isMobile ? "subtitle2" : "h6"} gutterBottom>
Current Resume
</Typography>
{candidate.resume ? (
<Typography variant="body2" color="text.secondary" sx={{
wordBreak: 'break-word',
fontSize: { xs: '0.8rem', sm: '0.875rem' }
}}>
Resume on file: {candidate.resume}
</Typography>
) : (
<Typography variant="body2" color="text.secondary" sx={{
fontSize: { xs: '0.8rem', sm: '0.875rem' }
}}>
No resume uploaded
</Typography>
)}
<Box sx={{ mt: { xs: 1.5, sm: 2 } }}>
<Button
component="label"
variant="outlined"
startIcon={<CloudUpload />}
fullWidth={isMobile}
size={isMobile ? "small" : "medium"}
>
Upload New Resume
<VisuallyHiddenInput
type="file"
accept=".pdf,.docx,.txt,.md"
onChange={handleResumeUpload}
/>
</Button>
{resumeFile && (
<Typography variant="body2" color="primary" sx={{
mt: 1,
wordBreak: 'break-word',
fontSize: { xs: '0.8rem', sm: '0.875rem' }
}}>
New file selected: {resumeFile.name}
</Typography>
)}
</Box>
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
<DocumentManager {...backstoryProps} />
);
return (
@ -789,7 +718,7 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
iconPosition={isMobile ? "top" : "start"}
/>
<Tab
label="Resume"
label="Docs"
icon={<CloudUpload sx={{ fontSize: { xs: 18, sm: 24 } }} />}
iconPosition={isMobile ? "top" : "start"}
/>

View File

@ -761,13 +761,13 @@ class ApiClient {
return result;
}
async getCandidateContent(
doc_id: string,
async getCandidateRAGContent(
documentId: string,
): Promise<Types.RagContentResponse> {
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<Types.RagContentResponse>(response);
@ -775,6 +775,79 @@ class ApiClient {
return result;
}
/****
* Document CRUD API
*/
async uploadCandidateDocument(file: File, includeInRag: boolean = true): Promise<Types.Document> {
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<Types.Document>(response);
return result;
}
async updateCandidateDocument(document: Types.Document) : Promise<Types.Document> {
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<Types.Document>(response);
return result;
};
async deleteCandidateDocument(document: Types.Document): Promise<boolean> {
const response = await fetch(`${this.baseUrl}/candidates/documents/${document.id}`, {
method: 'DELETE',
headers: this.defaultHeaders
});
const result = await handleApiResponse<boolean>(response);
return result;
}
async getCandidateDocuments(): Promise<Types.DocumentListResponse> {
const response = await fetch(`${this.baseUrl}/candidates/documents`, {
headers: this.defaultHeaders,
});
const result = await handleApiResponse<Types.DocumentListResponse>(response);
return result;
}
async getCandidateDocumentText(
document: Types.Document,
): Promise<Types.DocumentContentResponse> {
const response = await fetch(`${this.baseUrl}/candidates/documents/${document.id}/content`, {
headers: this.defaultHeaders,
});
const result = await handleApiResponse<Types.DocumentContentResponse>(response);
return result;
}
/**
* Create a chat session about a specific candidate
*/

View File

@ -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<RagEntry>;
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<Skill>;
experience?: Array<WorkExperience>;
questions?: Array<CandidateQuestion>;
education?: Array<Education>;
preferredJobTypes?: Array<"full-time" | "part-time" | "contract" | "internship" | "freelance">;
desiredSalary?: DesiredSalary;
availabilityDate?: Date;
summary?: string;
languages?: Array<Language>;
certifications?: Array<Certification>;
jobApplications?: Array<any>;
hasProfile: boolean;
rags?: Array<RagEntry>;
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<Document>;
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<T>(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<T>(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':

View File

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

View File

@ -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(...),

View File

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