File upload working
This commit is contained in:
parent
149bbdf73b
commit
a65f48034c
@ -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';
|
||||
|
||||
|
451
frontend/src/components/DocumentManager.tsx
Normal file
451
frontend/src/components/DocumentManager.tsx
Normal 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 };
|
@ -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
|
||||
|
@ -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 &&
|
||||
|
@ -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"}
|
||||
/>
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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':
|
||||
|
@ -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]:
|
||||
|
@ -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(...),
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user