File upload working
This commit is contained in:
parent
149bbdf73b
commit
a65f48034c
@ -7,7 +7,7 @@ import {
|
|||||||
useTheme,
|
useTheme,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useMediaQuery } 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 { CopyBubble } from "components/CopyBubble";
|
||||||
import { rest } from 'lodash';
|
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) => {
|
const fetchRAGMeta = async (node: Node) => {
|
||||||
try {
|
try {
|
||||||
const result = await apiClient.getCandidateContent(node.id);
|
const result = await apiClient.getCandidateRAGContent(node.id);
|
||||||
const update: Node = {
|
const update: Node = {
|
||||||
...node,
|
...node,
|
||||||
fullContent: result.content
|
fullContent: result.content
|
||||||
|
@ -12,18 +12,18 @@ import { jsonrepair } from 'jsonrepair';
|
|||||||
|
|
||||||
import { CandidateInfo } from '../components/CandidateInfo';
|
import { CandidateInfo } from '../components/CandidateInfo';
|
||||||
import { Quote } from 'components/Quote';
|
import { Quote } from 'components/Quote';
|
||||||
import { Candidate } from '../types/types';
|
|
||||||
import { BackstoryElementProps } from 'components/BackstoryTab';
|
import { BackstoryElementProps } from 'components/BackstoryTab';
|
||||||
import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField';
|
import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryTextField';
|
||||||
import { StyledMarkdown } from 'components/StyledMarkdown';
|
import { StyledMarkdown } from 'components/StyledMarkdown';
|
||||||
import { Scrollable } from '../components/Scrollable';
|
import { Scrollable } from '../components/Scrollable';
|
||||||
import { Pulse } from 'components/Pulse';
|
import { Pulse } from 'components/Pulse';
|
||||||
import { StreamingResponse } from 'services/api-client';
|
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';
|
import { useAuth } from 'hooks/AuthContext';
|
||||||
|
|
||||||
const emptyUser: Candidate = {
|
const emptyUser: CandidateAI = {
|
||||||
userType: "candidate",
|
userType: "candidate",
|
||||||
|
isAI: true,
|
||||||
description: "[blank]",
|
description: "[blank]",
|
||||||
username: "[blank]",
|
username: "[blank]",
|
||||||
firstName: "[blank]",
|
firstName: "[blank]",
|
||||||
@ -54,7 +54,7 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
|
|||||||
const { setSnack, submitQuery } = props;
|
const { setSnack, submitQuery } = props;
|
||||||
const [streaming, setStreaming] = useState<string>('');
|
const [streaming, setStreaming] = useState<string>('');
|
||||||
const [processing, setProcessing] = useState<boolean>(false);
|
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 [prompt, setPrompt] = useState<string>('');
|
||||||
const [resume, setResume] = useState<string>('');
|
const [resume, setResume] = useState<string>('');
|
||||||
const [canGenImage, setCanGenImage] = useState<boolean>(false);
|
const [canGenImage, setCanGenImage] = useState<boolean>(false);
|
||||||
|
@ -60,6 +60,7 @@ import * as Types from 'types/types';
|
|||||||
import { ComingSoon } from 'components/ui/ComingSoon';
|
import { ComingSoon } from 'components/ui/ComingSoon';
|
||||||
import { VectorVisualizer } from 'components/VectorVisualizer';
|
import { VectorVisualizer } from 'components/VectorVisualizer';
|
||||||
import { BackstoryPageProps } from 'components/BackstoryTab';
|
import { BackstoryPageProps } from 'components/BackstoryTab';
|
||||||
|
import { DocumentManager } from 'components/DocumentManager';
|
||||||
|
|
||||||
// Styled components
|
// Styled components
|
||||||
const VisuallyHiddenInput = styled('input')({
|
const VisuallyHiddenInput = styled('input')({
|
||||||
@ -105,11 +106,11 @@ function TabPanel(props: TabPanelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
|
const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
|
||||||
const { setSnack, submitQuery } = props;
|
const { setSnack, submitQuery } = props;
|
||||||
const backstoryProps = { setSnack, submitQuery };
|
const backstoryProps = { setSnack, submitQuery };
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
const { user, /*updateUser,*/ apiClient } = useAuth();
|
const { user, updateUserData, apiClient } = useAuth();
|
||||||
|
|
||||||
// Check if user is a candidate
|
// Check if user is a candidate
|
||||||
const candidate = user?.userType === 'candidate' ? user as Types.Candidate : null;
|
const candidate = user?.userType === 'candidate' ? user as Types.Candidate : null;
|
||||||
@ -131,7 +132,6 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
|
|||||||
// Form data state
|
// Form data state
|
||||||
const [formData, setFormData] = useState<Partial<Types.Candidate>>({});
|
const [formData, setFormData] = useState<Partial<Types.Candidate>>({});
|
||||||
const [profileImage, setProfileImage] = useState<string | null>(null);
|
const [profileImage, setProfileImage] = useState<string | null>(null);
|
||||||
const [resumeFile, setResumeFile] = useState<File | null>(null);
|
|
||||||
|
|
||||||
// Dialog states
|
// Dialog states
|
||||||
const [skillDialog, setSkillDialog] = useState(false);
|
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
|
// Toggle edit mode for a section
|
||||||
const toggleEditMode = (section: string) => {
|
const toggleEditMode = (section: string) => {
|
||||||
setEditMode({
|
setEditMode({
|
||||||
@ -242,7 +230,7 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
|
|||||||
try {
|
try {
|
||||||
if (candidate.id) {
|
if (candidate.id) {
|
||||||
const updatedCandidate = await apiClient.updateCandidate(candidate.id, formData);
|
const updatedCandidate = await apiClient.updateCandidate(candidate.id, formData);
|
||||||
// updateUser(updatedCandidate);
|
updateUserData(updatedCandidate);
|
||||||
setSnackbar({
|
setSnackbar({
|
||||||
open: true,
|
open: true,
|
||||||
message: 'Profile updated successfully!',
|
message: 'Profile updated successfully!',
|
||||||
@ -309,8 +297,8 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
|
|||||||
|
|
||||||
// Basic Information Tab
|
// Basic Information Tab
|
||||||
const renderBasicInfo = () => (
|
const renderBasicInfo = () => (
|
||||||
<Grid container spacing={{ xs: 1, sm: 3 }} sx={{ maxWidth: '100%' }}>
|
<Box sx={{ display: "flex", flexDirection: "column", "& .entry": { flexDirection: "column", fontSize: "0.9rem", display: "flex", mt: 1 }, "& .title": { display: "flex", fontWeight: "bold" } }}>
|
||||||
<Grid size={{ xs: 12 }} sx={{ textAlign: 'center', mb: { xs: 1, sm: 2 } }}>
|
<Box sx={{ textAlign: 'center', mb: { xs: 1, sm: 2 } }}>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={profileImage || candidate.profileImage || ''}
|
src={profileImage || candidate.profileImage || ''}
|
||||||
@ -344,9 +332,9 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Box>
|
||||||
|
|
||||||
<Grid size={{ xs: 12, sm: 6 }}>
|
<Box className="entry">
|
||||||
{editMode.basic ? (
|
{editMode.basic ? (
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
@ -355,14 +343,13 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
|
|||||||
onChange={(e) => handleInputChange('firstName', e.target.value)}
|
onChange={(e) => handleInputChange('firstName', e.target.value)}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (<>
|
||||||
<Typography variant="body1">
|
<Box className="title">First Name</Box>
|
||||||
<strong>First Name:</strong> {candidate.firstName}
|
<Box className="value">{candidate.firstName}</Box>
|
||||||
</Typography>
|
</>)}
|
||||||
)}
|
</Box>
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid size={{ xs: 12, sm: 6 }}>
|
<Box className="entry">
|
||||||
{editMode.basic ? (
|
{editMode.basic ? (
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
@ -371,15 +358,14 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
|
|||||||
onChange={(e) => handleInputChange('lastName', e.target.value)}
|
onChange={(e) => handleInputChange('lastName', e.target.value)}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (<>
|
||||||
<Typography variant="body1">
|
<Box className="title">Last Name</Box>
|
||||||
<strong>Last Name:</strong> {candidate.lastName}
|
<Box className="value">{candidate.lastName}</Box>
|
||||||
</Typography>
|
</>)}
|
||||||
)}
|
</Box>
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid size={{ xs: 12, sm: 6 }}>
|
<Box className="entry">
|
||||||
{editMode.basic ? (
|
{(false && editMode.basic) ? (
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Email"
|
label="Email"
|
||||||
@ -388,15 +374,15 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
|
|||||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (<>
|
||||||
<Typography variant="body1">
|
<Box className="title"><Email sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||||
<Email sx={{ mr: 1, verticalAlign: 'middle' }} />
|
Email</Box>
|
||||||
<strong>Email:</strong> {candidate.email}
|
<Box className="value">{candidate.email}</Box>
|
||||||
</Typography>
|
</>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Box>
|
||||||
|
|
||||||
<Grid size={{ xs: 12, sm: 6 }}>
|
<Box className="entry">
|
||||||
{editMode.basic ? (
|
{editMode.basic ? (
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
@ -405,35 +391,33 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
|
|||||||
onChange={(e) => handleInputChange('phone', e.target.value)}
|
onChange={(e) => handleInputChange('phone', e.target.value)}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (<>
|
||||||
<Typography variant="body1">
|
<Box className="title"><Phone sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||||
<Phone sx={{ mr: 1, verticalAlign: 'middle' }} />
|
Phone</Box>
|
||||||
<strong>Phone:</strong> {candidate.phone || 'Not provided'}
|
<Box className="value">{candidate.phone || 'Not provided'}</Box>
|
||||||
</Typography>
|
</>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Box>
|
||||||
|
|
||||||
<Grid size={{ xs: 12 }}>
|
<Box className="entry">
|
||||||
{editMode.basic ? (
|
{editMode.basic ? (
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
rows={3}
|
rows={3}
|
||||||
label="Professional Summary"
|
label="Professional Summary"
|
||||||
value={formData.summary || ''}
|
value={formData.description || ''}
|
||||||
onChange={(e) => handleInputChange('summary', e.target.value)}
|
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (<>
|
||||||
<Typography variant="body1">
|
<Box className="title">Professional Summary</Box>
|
||||||
<strong>Professional Summary:</strong><br />
|
<Box className="value">{candidate.description || 'No summary provided'}</Box>
|
||||||
{candidate.summary || 'No summary provided'}
|
</>)}
|
||||||
</Typography>
|
</Box>
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* <Grid size={{ xs: 12 }}>
|
<Box className="entry">
|
||||||
{editMode.basic ? (
|
{false && editMode.basic ? (
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Location"
|
label="Location"
|
||||||
@ -445,15 +429,15 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
placeholder="City, State, Country"
|
placeholder="City, State, Country"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (<><Box className="title">
|
||||||
<Typography variant="body1">
|
|
||||||
<LocationOn sx={{ mr: 1, verticalAlign: 'middle' }} />
|
<LocationOn sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||||
<strong>Location:</strong> {candidate.location?.city || 'Not specified'}, {candidate.location?.country || ''}
|
Location</Box>
|
||||||
</Typography>
|
<Box className="value">{candidate.location?.city || 'Not specified'} {candidate.location?.country || ''}</Box>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Grid> */}
|
</Box>
|
||||||
|
|
||||||
<Grid size={{ xs: 12 }}>
|
<Box className="entry">
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: { xs: 'column', sm: 'row' },
|
flexDirection: { xs: 'column', sm: 'row' },
|
||||||
@ -488,12 +472,12 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
|
|||||||
startIcon={<Edit />}
|
startIcon={<Edit />}
|
||||||
fullWidth={isMobile}
|
fullWidth={isMobile}
|
||||||
>
|
>
|
||||||
Edit Basic Info
|
Edit Info
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Box>
|
||||||
</Grid>
|
</Box >
|
||||||
);
|
);
|
||||||
|
|
||||||
// Skills Tab
|
// Skills Tab
|
||||||
@ -682,62 +666,7 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
|
|||||||
|
|
||||||
// Resume Tab
|
// Resume Tab
|
||||||
const renderResume = () => (
|
const renderResume = () => (
|
||||||
<Box>
|
<DocumentManager {...backstoryProps} />
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -789,7 +718,7 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
|
|||||||
iconPosition={isMobile ? "top" : "start"}
|
iconPosition={isMobile ? "top" : "start"}
|
||||||
/>
|
/>
|
||||||
<Tab
|
<Tab
|
||||||
label="Resume"
|
label="Docs"
|
||||||
icon={<CloudUpload sx={{ fontSize: { xs: 18, sm: 24 } }} />}
|
icon={<CloudUpload sx={{ fontSize: { xs: 18, sm: 24 } }} />}
|
||||||
iconPosition={isMobile ? "top" : "start"}
|
iconPosition={isMobile ? "top" : "start"}
|
||||||
/>
|
/>
|
||||||
|
@ -761,13 +761,13 @@ class ApiClient {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCandidateContent(
|
async getCandidateRAGContent(
|
||||||
doc_id: string,
|
documentId: string,
|
||||||
): Promise<Types.RagContentResponse> {
|
): Promise<Types.RagContentResponse> {
|
||||||
const response = await fetch(`${this.baseUrl}/candidates/rag-content`, {
|
const response = await fetch(`${this.baseUrl}/candidates/rag-content`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: this.defaultHeaders,
|
headers: this.defaultHeaders,
|
||||||
body: JSON.stringify(doc_id)
|
body: JSON.stringify({ id: documentId })
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await handleApiResponse<Types.RagContentResponse>(response);
|
const result = await handleApiResponse<Types.RagContentResponse>(response);
|
||||||
@ -775,6 +775,79 @@ class ApiClient {
|
|||||||
return result;
|
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
|
* Create a chat session about a specific candidate
|
||||||
*/
|
*/
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
// Generated TypeScript types from Pydantic models
|
// Generated TypeScript types from Pydantic models
|
||||||
// Source: src/backend/models.py
|
// 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
|
// 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 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 EmploymentType = "full-time" | "part-time" | "contract" | "internship" | "freelance";
|
||||||
|
|
||||||
export type FontSize = "small" | "medium" | "large";
|
export type FontSize = "small" | "medium" | "large";
|
||||||
@ -197,6 +199,41 @@ export interface Candidate {
|
|||||||
hasProfile: boolean;
|
hasProfile: boolean;
|
||||||
rags?: Array<RagEntry>;
|
rags?: Array<RagEntry>;
|
||||||
ragContentSize: number;
|
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;
|
age?: number;
|
||||||
gender?: "female" | "male";
|
gender?: "female" | "male";
|
||||||
ethnicity?: string;
|
ethnicity?: string;
|
||||||
@ -398,6 +435,36 @@ export interface DesiredSalary {
|
|||||||
period: "hour" | "day" | "month" | "year";
|
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 {
|
export interface EditHistory {
|
||||||
content: string;
|
content: string;
|
||||||
editedAt: Date;
|
editedAt: Date;
|
||||||
@ -909,6 +976,25 @@ export function convertCandidateFromApi(data: any): Candidate {
|
|||||||
availabilityDate: data.availabilityDate ? new Date(data.availabilityDate) : undefined,
|
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
|
* Convert Certification from API response, parsing date fields
|
||||||
* Date fields: issueDate, expirationDate
|
* Date fields: issueDate, expirationDate
|
||||||
@ -1004,6 +1090,19 @@ export function convertDataSourceConfigurationFromApi(data: any): DataSourceConf
|
|||||||
lastRefreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : undefined,
|
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
|
* Convert EditHistory from API response, parsing date fields
|
||||||
* Date fields: editedAt
|
* Date fields: editedAt
|
||||||
@ -1218,6 +1317,8 @@ export function convertFromApi<T>(data: any, modelType: string): T {
|
|||||||
return convertBaseUserWithTypeFromApi(data) as T;
|
return convertBaseUserWithTypeFromApi(data) as T;
|
||||||
case 'Candidate':
|
case 'Candidate':
|
||||||
return convertCandidateFromApi(data) as T;
|
return convertCandidateFromApi(data) as T;
|
||||||
|
case 'CandidateAI':
|
||||||
|
return convertCandidateAIFromApi(data) as T;
|
||||||
case 'Certification':
|
case 'Certification':
|
||||||
return convertCertificationFromApi(data) as T;
|
return convertCertificationFromApi(data) as T;
|
||||||
case 'ChatMessage':
|
case 'ChatMessage':
|
||||||
@ -1232,6 +1333,8 @@ export function convertFromApi<T>(data: any, modelType: string): T {
|
|||||||
return convertChatSessionFromApi(data) as T;
|
return convertChatSessionFromApi(data) as T;
|
||||||
case 'DataSourceConfiguration':
|
case 'DataSourceConfiguration':
|
||||||
return convertDataSourceConfigurationFromApi(data) as T;
|
return convertDataSourceConfigurationFromApi(data) as T;
|
||||||
|
case 'Document':
|
||||||
|
return convertDocumentFromApi(data) as T;
|
||||||
case 'EditHistory':
|
case 'EditHistory':
|
||||||
return convertEditHistoryFromApi(data) as T;
|
return convertEditHistoryFromApi(data) as T;
|
||||||
case 'Education':
|
case 'Education':
|
||||||
|
@ -181,6 +181,7 @@ class RedisDatabase:
|
|||||||
'chat_messages': 'chat_messages:', # This will store lists
|
'chat_messages': 'chat_messages:', # This will store lists
|
||||||
'ai_parameters': 'ai_parameters:',
|
'ai_parameters': 'ai_parameters:',
|
||||||
'users': 'user:',
|
'users': 'user:',
|
||||||
|
'candidate_documents': 'candidate_documents:',
|
||||||
}
|
}
|
||||||
|
|
||||||
def _serialize(self, data: Any) -> str:
|
def _serialize(self, data: Any) -> str:
|
||||||
@ -199,6 +200,103 @@ class RedisDatabase:
|
|||||||
logger.error(f"Failed to deserialize data: {data}")
|
logger.error(f"Failed to deserialize data: {data}")
|
||||||
return None
|
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
|
# Viewer operations
|
||||||
async def get_viewer(self, viewer_id: str) -> Optional[Dict]:
|
async def get_viewer(self, viewer_id: str) -> Optional[Dict]:
|
||||||
"""Get viewer by ID"""
|
"""Get viewer by ID"""
|
||||||
|
@ -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.middleware.cors import CORSMiddleware # type: ignore
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials # type: ignore
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials # type: ignore
|
||||||
from fastapi.exceptions import RequestValidationError # 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 fastapi.staticfiles import StaticFiles # type: ignore
|
||||||
from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY # 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
|
import uvicorn # type: ignore
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from datetime import datetime, timedelta, UTC
|
from datetime import datetime, timedelta, UTC
|
||||||
@ -69,6 +76,9 @@ from models import (
|
|||||||
ChatSession, ChatMessage, ChatContext, ChatQuery, ChatStatusType, ChatMessageBase, ChatMessageUser, ChatSenderType, ChatMessageType, ChatContextType,
|
ChatSession, ChatMessage, ChatContext, ChatQuery, ChatStatusType, ChatMessageBase, ChatMessageUser, ChatSenderType, ChatMessageType, ChatContextType,
|
||||||
ChatMessageRagSearch,
|
ChatMessageRagSearch,
|
||||||
|
|
||||||
|
# Document models
|
||||||
|
Document, DocumentType, DocumentListResponse, DocumentUpdateRequest, DocumentContentResponse,
|
||||||
|
|
||||||
# Supporting models
|
# Supporting models
|
||||||
Location, MFARequest, MFAData, MFARequestResponse, MFAVerifyRequest, RagContentResponse, ResendVerificationRequest, Skill, WorkExperience, Education,
|
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
|
# API Router Setup
|
||||||
# ============================
|
# ============================
|
||||||
@ -1429,45 +1474,420 @@ async def verify_mfa(
|
|||||||
content=create_error_response("MFA_VERIFICATION_FAILED", "Failed to verify MFA")
|
content=create_error_response("MFA_VERIFICATION_FAILED", "Failed to verify MFA")
|
||||||
)
|
)
|
||||||
|
|
||||||
@api_router.get("/candidates/{username}")
|
@api_router.post("/candidates/documents/upload")
|
||||||
async def get_candidate(
|
async def upload_candidate_document(
|
||||||
username: str = Path(...),
|
file: UploadFile = File(...),
|
||||||
|
include_in_rag: bool = Form(True),
|
||||||
|
current_user = Depends(get_current_user),
|
||||||
database: RedisDatabase = Depends(get_database)
|
database: RedisDatabase = Depends(get_database)
|
||||||
):
|
):
|
||||||
"""Get a candidate by username"""
|
"""Upload a document for the current candidate"""
|
||||||
try:
|
try:
|
||||||
all_candidates_data = await database.get_all_candidates()
|
# Verify user is a candidate
|
||||||
candidates_list = [Candidate.model_validate(data) for data in all_candidates_data.values()]
|
if current_user.user_type != "candidate":
|
||||||
|
logger.warning(f"⚠️ Unauthorized upload attempt by user type: {current_user.user_type}")
|
||||||
# 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(
|
return JSONResponse(
|
||||||
status_code=404,
|
status_code=403,
|
||||||
content=create_error_response("NOT_FOUND", "Candidate not found")
|
content=create_error_response("FORBIDDEN", "Only candidates can upload documents")
|
||||||
)
|
)
|
||||||
|
|
||||||
candidate = Candidate.model_validate(candidates_list[0])
|
candidate: Candidate = current_user
|
||||||
return create_success_response(candidate.model_dump(by_alias=True, exclude_unset=True))
|
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:
|
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(
|
return JSONResponse(
|
||||||
status_code=500,
|
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")
|
@api_router.post("/candidates/rag-content")
|
||||||
async def post_candidate_vector_content(
|
async def post_candidate_vector_content(
|
||||||
doc_id: str = Body(...),
|
rag_document: RAGDocumentRequest = Body(...),
|
||||||
current_user = Depends(get_current_user)
|
current_user = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
@ -1486,17 +1906,17 @@ async def post_candidate_vector_content(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not collection.get("metadatas", None):
|
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", [])):
|
for index, id in enumerate(collection.get("ids", [])):
|
||||||
if id == doc_id:
|
if id == rag_document.id:
|
||||||
metadata = collection.get("metadatas", [])[index].copy()
|
metadata = collection.get("metadatas", [])[index].copy()
|
||||||
content = candidate_entity.file_watcher.prepare_metadata(metadata)
|
content = candidate_entity.file_watcher.prepare_metadata(metadata)
|
||||||
rag_response = RagContentResponse(id=id, content=content, 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}")
|
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 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:
|
except Exception as e:
|
||||||
logger.error(f"❌ Post candidate content error: {e}")
|
logger.error(f"❌ Post candidate content error: {e}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@ -1977,6 +2397,42 @@ async def post_candidate_rag_search(
|
|||||||
content=create_error_response("SUMMARY_ERROR", str(e))
|
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")
|
@api_router.get("/candidates/{username}/chat-summary")
|
||||||
async def get_candidate_chat_summary(
|
async def get_candidate_chat_summary(
|
||||||
username: str = Path(...),
|
username: str = Path(...),
|
||||||
|
@ -216,7 +216,6 @@ class LoginRequest(BaseModel):
|
|||||||
# MFA Models
|
# MFA Models
|
||||||
# ============================
|
# ============================
|
||||||
|
|
||||||
|
|
||||||
class EmailVerificationRequest(BaseModel):
|
class EmailVerificationRequest(BaseModel):
|
||||||
token: str
|
token: str
|
||||||
|
|
||||||
@ -480,6 +479,48 @@ class RagContentResponse(BaseModel):
|
|||||||
content: str
|
content: str
|
||||||
metadata: RagContentMetadata
|
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):
|
class Candidate(BaseUser):
|
||||||
user_type: Literal[UserType.CANDIDATE] = Field(UserType.CANDIDATE, alias="userType")
|
user_type: Literal[UserType.CANDIDATE] = Field(UserType.CANDIDATE, alias="userType")
|
||||||
username: str
|
username: str
|
||||||
@ -499,7 +540,10 @@ class Candidate(BaseUser):
|
|||||||
has_profile: bool = Field(default=False, alias="hasProfile")
|
has_profile: bool = Field(default=False, alias="hasProfile")
|
||||||
rags: List[RagEntry] = Field(default_factory=list)
|
rags: List[RagEntry] = Field(default_factory=list)
|
||||||
rag_content_size : int = 0
|
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
|
age: Optional[int] = None
|
||||||
gender: Optional[UserGender] = None
|
gender: Optional[UserGender] = None
|
||||||
ethnicity: Optional[str] = None
|
ethnicity: Optional[str] = None
|
||||||
|
Loading…
x
Reference in New Issue
Block a user