Compare commits
3 Commits
bb84709f44
...
699acf9313
Author | SHA1 | Date | |
---|---|---|---|
699acf9313 | |||
a65f48034c | |||
149bbdf73b |
@ -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';
|
||||||
|
|
||||||
@ -57,7 +57,7 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
|
|||||||
maxWidth: "80px"
|
maxWidth: "80px"
|
||||||
}}>
|
}}>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={candidate.hasProfile ? `/api/u/${candidate.username}/profile?timestamp=${Date.now()}` : ''}
|
src={candidate.profileImage ? `/api/1.0/candidates/profile/${candidate.username}?timestamp=${Date.now()}` : ''}
|
||||||
alt={`${candidate.fullName}'s profile`}
|
alt={`${candidate.fullName}'s profile`}
|
||||||
sx={{
|
sx={{
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
|
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 };
|
@ -21,6 +21,15 @@ import { connectionBase } from '../utils/Global';
|
|||||||
|
|
||||||
import './VectorVisualizer.css';
|
import './VectorVisualizer.css';
|
||||||
import { BackstoryPageProps } from './BackstoryTab';
|
import { BackstoryPageProps } from './BackstoryTab';
|
||||||
|
import { useAuth } from 'hooks/AuthContext';
|
||||||
|
import * as Types from 'types/types';
|
||||||
|
import { useSelectedCandidate } from 'hooks/GlobalContext';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Message } from './Message';
|
||||||
|
const defaultMessage: Types.ChatMessageBase = {
|
||||||
|
type: "preparing", status: "done", sender: "system", sessionId: "", timestamp: new Date(), content: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
interface VectorVisualizerProps extends BackstoryPageProps {
|
interface VectorVisualizerProps extends BackstoryPageProps {
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
@ -29,23 +38,11 @@ interface VectorVisualizerProps extends BackstoryPageProps {
|
|||||||
|
|
||||||
interface Metadata {
|
interface Metadata {
|
||||||
id: string;
|
id: string;
|
||||||
doc_type: string;
|
docType: string;
|
||||||
content: string;
|
content: string;
|
||||||
distance?: number;
|
distance?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type QuerySet = {
|
|
||||||
ids: string[],
|
|
||||||
documents: string[],
|
|
||||||
metadatas: Metadata[],
|
|
||||||
embeddings: (number[])[],
|
|
||||||
distances?: (number | undefined)[],
|
|
||||||
dimensions?: number;
|
|
||||||
query?: string;
|
|
||||||
umap_embedding_2d?: number[];
|
|
||||||
umap_embedding_3d?: number[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyQuerySet = {
|
const emptyQuerySet = {
|
||||||
ids: [],
|
ids: [],
|
||||||
documents: [],
|
documents: [],
|
||||||
@ -173,25 +170,27 @@ const DEFAULT_UNFOCUS_SIZE = 2.;
|
|||||||
type Node = {
|
type Node = {
|
||||||
id: string,
|
id: string,
|
||||||
content: string, // Portion of content that was used for embedding
|
content: string, // Portion of content that was used for embedding
|
||||||
full_content: string | undefined, // Portion of content plus/minus buffer
|
fullContent: string | undefined, // Portion of content plus/minus buffer
|
||||||
emoji: string,
|
emoji: string,
|
||||||
doc_type: string,
|
docType: string,
|
||||||
source_file: string,
|
source_file: string,
|
||||||
distance: number | undefined,
|
distance: number | undefined,
|
||||||
path: string,
|
path: string,
|
||||||
chunk_begin: number,
|
chunkBegin: number,
|
||||||
line_begin: number,
|
lineBegin: number,
|
||||||
chunk_end: number,
|
chunkEnd: number,
|
||||||
line_end: number,
|
lineEnd: number,
|
||||||
sx: SxProps,
|
sx: SxProps,
|
||||||
};
|
};
|
||||||
|
|
||||||
const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualizerProps) => {
|
const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualizerProps) => {
|
||||||
const { setSnack, rag, inline, sx } = props;
|
const { user, apiClient } = useAuth();
|
||||||
|
const { setSnack, submitQuery, rag, inline, sx } = props;
|
||||||
|
const backstoryProps = { setSnack, submitQuery };
|
||||||
const [plotData, setPlotData] = useState<PlotData | null>(null);
|
const [plotData, setPlotData] = useState<PlotData | null>(null);
|
||||||
const [newQuery, setNewQuery] = useState<string>('');
|
const [newQuery, setNewQuery] = useState<string>('');
|
||||||
const [querySet, setQuerySet] = useState<QuerySet>(rag || emptyQuerySet);
|
const [querySet, setQuerySet] = useState<Types.ChromaDBGetResponse>(rag || emptyQuerySet);
|
||||||
const [result, setResult] = useState<QuerySet | undefined>(undefined);
|
const [result, setResult] = useState<Types.ChromaDBGetResponse | null>(null);
|
||||||
const [view2D, setView2D] = useState<boolean>(true);
|
const [view2D, setView2D] = useState<boolean>(true);
|
||||||
const plotlyRef = useRef(null);
|
const plotlyRef = useRef(null);
|
||||||
const boxRef = useRef<HTMLElement>(null);
|
const boxRef = useRef<HTMLElement>(null);
|
||||||
@ -199,6 +198,9 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
const [plotDimensions, setPlotDimensions] = useState({ width: 0, height: 0 });
|
const [plotDimensions, setPlotDimensions] = useState({ width: 0, height: 0 });
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const candidate: Types.Candidate | null = user?.userType === 'candidate' ? user : null;
|
||||||
|
|
||||||
/* Force resize of Plotly as it tends to not be the correct size if it is initially rendered
|
/* Force resize of Plotly as it tends to not be the correct size if it is initially rendered
|
||||||
* off screen (eg., the VectorVisualizer is not on the tab the app loads to) */
|
* off screen (eg., the VectorVisualizer is not on the tab the app loads to) */
|
||||||
@ -225,21 +227,16 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
|
|||||||
|
|
||||||
// Get the collection to visualize
|
// Get the collection to visualize
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((result !== undefined && result.dimensions !== (view2D ? 3 : 2))) {
|
if (result) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fetchCollection = async () => {
|
const fetchCollection = async () => {
|
||||||
|
if (!candidate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch(connectionBase + `/api/umap/`, {
|
const result = await apiClient.getCandidateVectors(view2D ? 2 : 3);
|
||||||
method: 'PUT',
|
setResult(result);
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ dimensions: view2D ? 2 : 3 }),
|
|
||||||
});
|
|
||||||
const data: QuerySet = await response.json();
|
|
||||||
data.dimensions = view2D ? 2 : 3;
|
|
||||||
setResult(data);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error obtaining collection information:', error);
|
console.error('Error obtaining collection information:', error);
|
||||||
setSnack("Unable to obtain collection information.", "error");
|
setSnack("Unable to obtain collection information.", "error");
|
||||||
@ -253,7 +250,8 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
|
|||||||
if (!result || !result.embeddings) return;
|
if (!result || !result.embeddings) return;
|
||||||
if (result.embeddings.length === 0) return;
|
if (result.embeddings.length === 0) return;
|
||||||
|
|
||||||
const full: QuerySet = {
|
const full: Types.ChromaDBGetResponse = {
|
||||||
|
...result,
|
||||||
ids: [...result.ids || []],
|
ids: [...result.ids || []],
|
||||||
documents: [...result.documents || []],
|
documents: [...result.documents || []],
|
||||||
embeddings: [...result.embeddings],
|
embeddings: [...result.embeddings],
|
||||||
@ -270,18 +268,27 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let query: QuerySet = {
|
let query: Types.ChromaDBGetResponse = {
|
||||||
ids: [],
|
ids: [],
|
||||||
documents: [],
|
documents: [],
|
||||||
embeddings: [],
|
embeddings: [],
|
||||||
metadatas: [],
|
metadatas: [],
|
||||||
distances: [],
|
distances: [],
|
||||||
|
query: '',
|
||||||
|
size: 0,
|
||||||
|
dimensions: 2,
|
||||||
|
name: ''
|
||||||
};
|
};
|
||||||
let filtered: QuerySet = {
|
let filtered: Types.ChromaDBGetResponse = {
|
||||||
ids: [],
|
ids: [],
|
||||||
documents: [],
|
documents: [],
|
||||||
embeddings: [],
|
embeddings: [],
|
||||||
metadatas: [],
|
metadatas: [],
|
||||||
|
distances: [],
|
||||||
|
query: '',
|
||||||
|
size: 0,
|
||||||
|
dimensions: 2,
|
||||||
|
name: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Loop through all items and divide into two groups:
|
/* Loop through all items and divide into two groups:
|
||||||
@ -310,30 +317,30 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (view2D && querySet.umap_embedding_2d && querySet.umap_embedding_2d.length) {
|
if (view2D && querySet.umapEmbedding2D && querySet.umapEmbedding2D.length) {
|
||||||
query.ids.unshift('query');
|
query.ids.unshift('query');
|
||||||
query.metadatas.unshift({ id: 'query', doc_type: 'query', content: querySet.query || '', distance: 0 });
|
query.metadatas.unshift({ id: 'query', docType: 'query', content: querySet.query || '', distance: 0 });
|
||||||
query.embeddings.unshift(querySet.umap_embedding_2d);
|
query.embeddings.unshift(querySet.umapEmbedding2D);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!view2D && querySet.umap_embedding_3d && querySet.umap_embedding_3d.length) {
|
if (!view2D && querySet.umapEmbedding3D && querySet.umapEmbedding3D.length) {
|
||||||
query.ids.unshift('query');
|
query.ids.unshift('query');
|
||||||
query.metadatas.unshift({ id: 'query', doc_type: 'query', content: querySet.query || '', distance: 0 });
|
query.metadatas.unshift({ id: 'query', docType: 'query', content: querySet.query || '', distance: 0 });
|
||||||
query.embeddings.unshift(querySet.umap_embedding_3d);
|
query.embeddings.unshift(querySet.umapEmbedding3D);
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered_doc_types = filtered.metadatas.map(m => m.doc_type || 'unknown')
|
const filtered_docTypes = filtered.metadatas.map(m => m.docType || 'unknown')
|
||||||
const query_doc_types = query.metadatas.map(m => m.doc_type || 'unknown')
|
const query_docTypes = query.metadatas.map(m => m.docType || 'unknown')
|
||||||
|
|
||||||
const has_query = query.metadatas.length > 0;
|
const has_query = query.metadatas.length > 0;
|
||||||
const filtered_sizes = filtered.metadatas.map(m => has_query ? DEFAULT_UNFOCUS_SIZE : DEFAULT_SIZE);
|
const filtered_sizes = filtered.metadatas.map(m => has_query ? DEFAULT_UNFOCUS_SIZE : DEFAULT_SIZE);
|
||||||
const filtered_colors = filtered_doc_types.map(type => colorMap[type] || '#ff8080');
|
const filtered_colors = filtered_docTypes.map(type => colorMap[type] || '#4d4d4d');
|
||||||
const filtered_x = normalizeDimension(filtered.embeddings.map((v: number[]) => v[0]));
|
const filtered_x = normalizeDimension(filtered.embeddings.map((v: number[]) => v[0]));
|
||||||
const filtered_y = normalizeDimension(filtered.embeddings.map((v: number[]) => v[1]));
|
const filtered_y = normalizeDimension(filtered.embeddings.map((v: number[]) => v[1]));
|
||||||
const filtered_z = is3D ? normalizeDimension(filtered.embeddings.map((v: number[]) => v[2])) : undefined;
|
const filtered_z = is3D ? normalizeDimension(filtered.embeddings.map((v: number[]) => v[2])) : undefined;
|
||||||
|
|
||||||
const query_sizes = query.metadatas.map(m => DEFAULT_SIZE + 2. * DEFAULT_SIZE * Math.pow((1. - (m.distance || 1.)), 3));
|
const query_sizes = query.metadatas.map(m => DEFAULT_SIZE + 2. * DEFAULT_SIZE * Math.pow((1. - (m.distance || 1.)), 3));
|
||||||
const query_colors = query_doc_types.map(type => colorMap[type] || '#ff8080');
|
const query_colors = query_docTypes.map(type => colorMap[type] || '#4d4d4d');
|
||||||
const query_x = normalizeDimension(query.embeddings.map((v: number[]) => v[0]));
|
const query_x = normalizeDimension(query.embeddings.map((v: number[]) => v[0]));
|
||||||
const query_y = normalizeDimension(query.embeddings.map((v: number[]) => v[1]));
|
const query_y = normalizeDimension(query.embeddings.map((v: number[]) => v[1]));
|
||||||
const query_z = is3D ? normalizeDimension(query.embeddings.map((v: number[]) => v[2])) : undefined;
|
const query_z = is3D ? normalizeDimension(query.embeddings.map((v: number[]) => v[2])) : undefined;
|
||||||
@ -388,43 +395,35 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
|
|||||||
const sendQuery = async (query: string) => {
|
const sendQuery = async (query: string) => {
|
||||||
if (!query.trim()) return;
|
if (!query.trim()) return;
|
||||||
setNewQuery('');
|
setNewQuery('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${connectionBase}/api/similarity/`, {
|
const result = await apiClient.getCandidateSimilarContent(query);
|
||||||
method: 'PUT',
|
console.log(result);
|
||||||
headers: {
|
setQuerySet(result);
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
query: query,
|
|
||||||
dimensions: view2D ? 2 : 3,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
setQuerySet(data);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error obtaining query similarity information:', error);
|
const msg = `Error obtaining similar content to ${query}.`
|
||||||
setSnack("Unable to obtain query similarity information.", "error");
|
setSnack(msg, "error");
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!plotData) return (
|
if (!result) return (
|
||||||
<Box sx={{ display: 'flex', flexGrow: 1, justifyContent: 'center', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', flexGrow: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||||
<div>Loading visualization...</div>
|
<div>Loading visualization...</div>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!candidate) return (
|
||||||
|
<Box sx={{ display: 'flex', flexGrow: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||||
|
<div>No candidate selected. Please <Button onClick={() => navigate('/find-a-candidate')}>select a candidate</Button> first.</div>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
const fetchRAGMeta = async (node: Node) => {
|
const fetchRAGMeta = async (node: Node) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(connectionBase + `/api/umap/entry/${node.id}`, {
|
const result = await apiClient.getCandidateRAGContent(node.id);
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const update: Node = {
|
const update: Node = {
|
||||||
...node,
|
...node,
|
||||||
full_content: await response.json()
|
fullContent: result.content
|
||||||
}
|
}
|
||||||
setNode(update);
|
setNode(update);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -436,14 +435,15 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
|
|||||||
|
|
||||||
const onNodeSelected = (metadata: any) => {
|
const onNodeSelected = (metadata: any) => {
|
||||||
let node: Node;
|
let node: Node;
|
||||||
if (metadata.doc_type === 'query') {
|
console.log(metadata);
|
||||||
|
if (metadata.docType === 'query') {
|
||||||
node = {
|
node = {
|
||||||
...metadata,
|
...metadata,
|
||||||
content: `Similarity results for the query **${querySet.query || ''}**
|
content: `Similarity results for the query **${querySet.query || ''}**
|
||||||
|
|
||||||
The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '2' : '3'}-dimensional space. Larger dots represent relative similarity in N-dimensional space.
|
The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '2' : '3'}-dimensional space. Larger dots represent relative similarity in N-dimensional space.
|
||||||
`,
|
`,
|
||||||
emoji: emojiMap[metadata.doc_type],
|
emoji: emojiMap[metadata.docType],
|
||||||
sx: {
|
sx: {
|
||||||
m: 0.5,
|
m: 0.5,
|
||||||
p: 2,
|
p: 2,
|
||||||
@ -453,7 +453,7 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
flexGrow: 0,
|
flexGrow: 0,
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
backgroundColor: colorMap[metadata.doc_type] || '#ff8080',
|
backgroundColor: colorMap[metadata.docType] || '#ff8080',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setNode(node);
|
setNode(node);
|
||||||
@ -463,7 +463,7 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
|
|||||||
node = {
|
node = {
|
||||||
content: `Loading...`,
|
content: `Loading...`,
|
||||||
...metadata,
|
...metadata,
|
||||||
emoji: emojiMap[metadata.doc_type] || '❓',
|
emoji: emojiMap[metadata.docType] || '❓',
|
||||||
}
|
}
|
||||||
|
|
||||||
setNode(node);
|
setNode(node);
|
||||||
@ -499,7 +499,7 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
|
|||||||
flexBasis: 0,
|
flexBasis: 0,
|
||||||
flexGrow: 0
|
flexGrow: 0
|
||||||
}}
|
}}
|
||||||
control={<Switch checked={!view2D} />} onChange={() => setView2D(!view2D)} label="3D" />
|
control={<Switch checked={!view2D} />} onChange={() => { setView2D(!view2D); setResult(null); }} label="3D" />
|
||||||
<Plot
|
<Plot
|
||||||
ref={plotlyRef}
|
ref={plotlyRef}
|
||||||
onClick={(event: any) => { onNodeSelected(event.points[0].customdata); }}
|
onClick={(event: any) => { onNodeSelected(event.points[0].customdata); }}
|
||||||
@ -528,7 +528,7 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
|
|||||||
<TableBody sx={{ '& td': { verticalAlign: "top", fontSize: "0.75rem", }, '& td:first-of-type': { whiteSpace: "nowrap", width: "1rem" } }}>
|
<TableBody sx={{ '& td': { verticalAlign: "top", fontSize: "0.75rem", }, '& td:first-of-type': { whiteSpace: "nowrap", width: "1rem" } }}>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Type</TableCell>
|
<TableCell>Type</TableCell>
|
||||||
<TableCell>{node.emoji} {node.doc_type}</TableCell>
|
<TableCell>{node.emoji} {node.docType}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{node.source_file !== undefined && <TableRow>
|
{node.source_file !== undefined && <TableRow>
|
||||||
<TableCell>File</TableCell>
|
<TableCell>File</TableCell>
|
||||||
@ -560,7 +560,7 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
|
|||||||
Click a point in the scatter-graph to see information about that node.
|
Click a point in the scatter-graph to see information about that node.
|
||||||
</Paper>
|
</Paper>
|
||||||
}
|
}
|
||||||
{node !== null && node.full_content &&
|
{node !== null && node.fullContent &&
|
||||||
<Scrollable
|
<Scrollable
|
||||||
autoscroll={false}
|
autoscroll={false}
|
||||||
sx={{
|
sx={{
|
||||||
@ -575,16 +575,16 @@ The scatter graph shows the query in N-dimensional space, mapped to ${view2D ? '
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
node.full_content.split('\n').map((line, index) => {
|
node.fullContent.split('\n').map((line, index) => {
|
||||||
index += 1 + node.chunk_begin;
|
index += 1 + node.chunkBegin;
|
||||||
const bgColor = (index > node.line_begin && index <= node.line_end) ? '#f0f0f0' : 'auto';
|
const bgColor = (index > node.lineBegin && index <= node.lineEnd) ? '#f0f0f0' : 'auto';
|
||||||
return <Box key={index} sx={{ display: "flex", flexDirection: "row", borderBottom: '1px solid #d0d0d0', ':first-of-type': { borderTop: '1px solid #d0d0d0' }, backgroundColor: bgColor }}>
|
return <Box key={index} sx={{ display: "flex", flexDirection: "row", borderBottom: '1px solid #d0d0d0', ':first-of-type': { borderTop: '1px solid #d0d0d0' }, backgroundColor: bgColor }}>
|
||||||
<Box sx={{ fontFamily: 'courier', fontSize: "0.8rem", minWidth: "2rem", pt: "0.1rem", align: "left", verticalAlign: "top" }}>{index}</Box>
|
<Box sx={{ fontFamily: 'courier', fontSize: "0.8rem", minWidth: "2rem", pt: "0.1rem", align: "left", verticalAlign: "top" }}>{index}</Box>
|
||||||
<pre style={{ margin: 0, padding: 0, border: "none", minHeight: "1rem", overflow: "hidden" }} >{line || " "}</pre>
|
<pre style={{ margin: 0, padding: 0, border: "none", minHeight: "1rem", overflow: "hidden" }} >{line || " "}</pre>
|
||||||
</Box>;
|
</Box>;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
{!node.line_begin && <pre style={{ margin: 0, padding: 0, border: "none" }}>{node.content}</pre>}
|
{!node.lineBegin && <pre style={{ margin: 0, padding: 0, border: "none" }}>{node.content}</pre>}
|
||||||
</Scrollable>
|
</Scrollable>
|
||||||
}
|
}
|
||||||
</Box>
|
</Box>
|
||||||
|
@ -20,8 +20,8 @@ import { ControlsPage } from 'pages/ControlsPage';
|
|||||||
import { LoginPage } from "pages/LoginPage";
|
import { LoginPage } from "pages/LoginPage";
|
||||||
import { CandidateDashboardPage } from "pages/CandidateDashboardPage"
|
import { CandidateDashboardPage } from "pages/CandidateDashboardPage"
|
||||||
import { EmailVerificationPage } from "components/EmailVerificationComponents";
|
import { EmailVerificationPage } from "components/EmailVerificationComponents";
|
||||||
|
import { CandidateProfilePage } from "pages/candidate/Profile";
|
||||||
|
|
||||||
const ProfilePage = () => (<BetaPage><Typography variant="h4">Profile</Typography></BetaPage>);
|
|
||||||
const BackstoryPage = () => (<BetaPage><Typography variant="h4">Backstory</Typography></BetaPage>);
|
const BackstoryPage = () => (<BetaPage><Typography variant="h4">Backstory</Typography></BetaPage>);
|
||||||
const ResumesPage = () => (<BetaPage><Typography variant="h4">Resumes</Typography></BetaPage>);
|
const ResumesPage = () => (<BetaPage><Typography variant="h4">Resumes</Typography></BetaPage>);
|
||||||
const QASetupPage = () => (<BetaPage><Typography variant="h4">Q&A Setup</Typography></BetaPage>);
|
const QASetupPage = () => (<BetaPage><Typography variant="h4">Q&A Setup</Typography></BetaPage>);
|
||||||
@ -69,7 +69,7 @@ const getBackstoryDynamicRoutes = (props: BackstoryDynamicRoutesProps): ReactNod
|
|||||||
if (user.userType === 'candidate') {
|
if (user.userType === 'candidate') {
|
||||||
routes.splice(-1, 0, ...[
|
routes.splice(-1, 0, ...[
|
||||||
<Route key={`${index++}`} path="/candidate/dashboard" element={<BetaPage><CandidateDashboardPage {...backstoryProps} /></BetaPage>} />,
|
<Route key={`${index++}`} path="/candidate/dashboard" element={<BetaPage><CandidateDashboardPage {...backstoryProps} /></BetaPage>} />,
|
||||||
<Route key={`${index++}`} path="/candidate/profile" element={<ProfilePage />} />,
|
<Route key={`${index++}`} path="/candidate/profile" element={<CandidateProfilePage {...backstoryProps} />} />,
|
||||||
<Route key={`${index++}`} path="/candidate/backstory" element={<BackstoryPage />} />,
|
<Route key={`${index++}`} path="/candidate/backstory" element={<BackstoryPage />} />,
|
||||||
<Route key={`${index++}`} path="/candidate/resumes" element={<ResumesPage />} />,
|
<Route key={`${index++}`} path="/candidate/resumes" element={<ResumesPage />} />,
|
||||||
<Route key={`${index++}`} path="/candidate/qa-setup" element={<QASetupPage />} />,
|
<Route key={`${index++}`} path="/candidate/qa-setup" element={<QASetupPage />} />,
|
||||||
|
@ -23,13 +23,6 @@ interface LoginRequest {
|
|||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MFAVerificationRequest {
|
|
||||||
email: string;
|
|
||||||
code: string;
|
|
||||||
deviceId: string;
|
|
||||||
rememberDevice?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EmailVerificationRequest {
|
interface EmailVerificationRequest {
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
@ -418,7 +411,7 @@ function useAuthenticationLogic() {
|
|||||||
}, [apiClient]);
|
}, [apiClient]);
|
||||||
|
|
||||||
// MFA verification
|
// MFA verification
|
||||||
const verifyMFA = useCallback(async (mfaData: MFAVerificationRequest): Promise<boolean> => {
|
const verifyMFA = useCallback(async (mfaData: Types.MFAVerifyRequest): Promise<boolean> => {
|
||||||
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -742,7 +735,7 @@ function ProtectedRoute({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
AuthState, LoginRequest, MFAVerificationRequest, EmailVerificationRequest, ResendVerificationRequest, PasswordResetRequest
|
AuthState, LoginRequest, EmailVerificationRequest, ResendVerificationRequest, PasswordResetRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { CreateCandidateRequest, CreateEmployerRequest } from '../services/api-client';
|
export type { CreateCandidateRequest, CreateEmployerRequest } from '../services/api-client';
|
||||||
|
@ -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]",
|
||||||
@ -43,7 +43,10 @@ const emptyUser: Candidate = {
|
|||||||
education: [],
|
education: [],
|
||||||
preferredJobTypes: [],
|
preferredJobTypes: [],
|
||||||
languages: [],
|
languages: [],
|
||||||
certifications: []
|
certifications: [],
|
||||||
|
isAdmin: false,
|
||||||
|
profileImage: undefined,
|
||||||
|
ragContentSize: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
const GenerateCandidate = (props: BackstoryElementProps) => {
|
const GenerateCandidate = (props: BackstoryElementProps) => {
|
||||||
@ -51,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);
|
||||||
@ -378,7 +381,7 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
|
|||||||
}}>
|
}}>
|
||||||
<Box sx={{ display: "flex", position: "relative", width: "min-content", height: "min-content" }}>
|
<Box sx={{ display: "flex", position: "relative", width: "min-content", height: "min-content" }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={user?.hasProfile ? `/api/u/${user.username}/profile` : ''}
|
src={user?.profileImage ? `/api/1.0/candidates/profile/${user.username}` : ''}
|
||||||
alt={`${user?.fullName}'s profile`}
|
alt={`${user?.fullName}'s profile`}
|
||||||
sx={{
|
sx={{
|
||||||
width: 80,
|
width: 80,
|
||||||
@ -389,7 +392,7 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
|
|||||||
{processing && <Pulse sx={{ position: "relative", left: "-80px", top: "0px", mr: "-80px" }} timestamp={timestamp} />}
|
{processing && <Pulse sx={{ position: "relative", left: "-80px", top: "0px", mr: "-80px" }} timestamp={timestamp} />}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Tooltip title={`${user?.hasProfile ? 'Re-' : ''}Generate Picture`}>
|
<Tooltip title={`${user?.profileImage ? 'Re-' : ''}Generate Picture`}>
|
||||||
<span style={{ display: "flex", flexGrow: 1 }}>
|
<span style={{ display: "flex", flexGrow: 1 }}>
|
||||||
<Button
|
<Button
|
||||||
sx={{ m: 1, gap: 1, justifySelf: "flex-start", alignSelf: "center", flexGrow: 0, maxHeight: "min-content" }}
|
sx={{ m: 1, gap: 1, justifySelf: "flex-start", alignSelf: "center", flexGrow: 0, maxHeight: "min-content" }}
|
||||||
@ -398,7 +401,7 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
|
|||||||
processing || !canGenImage
|
processing || !canGenImage
|
||||||
}
|
}
|
||||||
onClick={() => { setShouldGenerateProfile(true); }}>
|
onClick={() => { setShouldGenerateProfile(true); }}>
|
||||||
{user?.hasProfile ? 'Re-' : ''}Generate Picture<SendIcon />
|
{user?.profileImage ? 'Re-' : ''}Generate Picture<SendIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
@ -28,8 +28,10 @@ import { BackstoryPageProps } from 'components/BackstoryTab';
|
|||||||
|
|
||||||
import { LoginForm } from "components/EmailVerificationComponents";
|
import { LoginForm } from "components/EmailVerificationComponents";
|
||||||
import { CandidateRegistrationForm } from "components/RegistrationForms";
|
import { CandidateRegistrationForm } from "components/RegistrationForms";
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
|
const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { setSnack } = props;
|
const { setSnack } = props;
|
||||||
const [tabValue, setTabValue] = useState(0);
|
const [tabValue, setTabValue] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -62,68 +64,10 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
|
|||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// If user is logged in, show their profile
|
// If user is logged in, navigate to the profile page
|
||||||
if (user) {
|
if (user) {
|
||||||
return (
|
navigate('/candidate/profile');
|
||||||
<Container maxWidth="md" sx={{ mt: 4 }}>
|
return (<></>);
|
||||||
<Card elevation={3}>
|
|
||||||
<CardContent>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
|
||||||
<Avatar sx={{ mr: 2, bgcolor: 'primary.main' }}>
|
|
||||||
<AccountCircle />
|
|
||||||
</Avatar>
|
|
||||||
<Typography variant="h4" component="h1">
|
|
||||||
User Profile
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider sx={{ mb: 3 }} />
|
|
||||||
|
|
||||||
<Grid container spacing={3}>
|
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
|
||||||
<Typography variant="body1" sx={{ mb: 1 }}>
|
|
||||||
<strong>Username:</strong> {name}
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
|
||||||
<Typography variant="body1" sx={{ mb: 1 }}>
|
|
||||||
<strong>Email:</strong> {user.email}
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
|
||||||
<Typography variant="body1" sx={{ mb: 1 }}>
|
|
||||||
{/* <strong>Status:</strong> {user.status} */}
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
|
||||||
<Typography variant="body1" sx={{ mb: 1 }}>
|
|
||||||
<strong>Phone:</strong> {user.phone || 'Not provided'}
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
|
||||||
<Typography variant="body1" sx={{ mb: 1 }}>
|
|
||||||
<strong>Account type:</strong> {user.userType}
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
|
||||||
<Typography variant="body1" sx={{ mb: 1 }}>
|
|
||||||
<strong>Last Login:</strong> {
|
|
||||||
user.lastLogin
|
|
||||||
? user.lastLogin.toLocaleString()
|
|
||||||
: 'N/A'
|
|
||||||
}
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
|
||||||
<Typography variant="body1" sx={{ mb: 1 }}>
|
|
||||||
<strong>Member Since:</strong> {user.createdAt.toLocaleDateString()}
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
982
frontend/src/pages/candidate/Profile.tsx
Normal file
982
frontend/src/pages/candidate/Profile.tsx
Normal file
@ -0,0 +1,982 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Grid,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
Avatar,
|
||||||
|
IconButton,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
useMediaQuery,
|
||||||
|
CircularProgress,
|
||||||
|
Snackbar,
|
||||||
|
Alert,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardActions,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
ListItemSecondaryAction,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Switch,
|
||||||
|
FormControlLabel
|
||||||
|
} from '@mui/material';
|
||||||
|
import { styled } from '@mui/material/styles';
|
||||||
|
import {
|
||||||
|
CloudUpload,
|
||||||
|
PhotoCamera,
|
||||||
|
Edit,
|
||||||
|
Save,
|
||||||
|
Cancel,
|
||||||
|
Add,
|
||||||
|
Delete,
|
||||||
|
Work,
|
||||||
|
School,
|
||||||
|
Language,
|
||||||
|
EmojiEvents,
|
||||||
|
LocationOn,
|
||||||
|
Phone,
|
||||||
|
Email,
|
||||||
|
AccountCircle,
|
||||||
|
BubbleChart
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
import { useAuth } from "hooks/AuthContext";
|
||||||
|
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')({
|
||||||
|
clip: 'rect(0 0 0 0)',
|
||||||
|
clipPath: 'inset(50%)',
|
||||||
|
height: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
width: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface TabPanelProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
index: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabPanel(props: TabPanelProps) {
|
||||||
|
const { children, value, index, ...other } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={value !== index}
|
||||||
|
id={`profile-tabpanel-${index}`}
|
||||||
|
aria-labelledby={`profile-tab-${index}`}
|
||||||
|
{...other}
|
||||||
|
>
|
||||||
|
{value === index && (
|
||||||
|
<Box sx={{
|
||||||
|
p: { xs: 1, sm: 3 },
|
||||||
|
maxWidth: '100%',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
|
||||||
|
const { setSnack, submitQuery } = props;
|
||||||
|
const backstoryProps = { setSnack, submitQuery };
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
const { user, updateUserData, apiClient } = useAuth();
|
||||||
|
|
||||||
|
// Check if user is a candidate
|
||||||
|
const candidate = user?.userType === 'candidate' ? user as Types.Candidate : null;
|
||||||
|
|
||||||
|
// State management
|
||||||
|
const [tabValue, setTabValue] = useState(0);
|
||||||
|
const [editMode, setEditMode] = useState<{ [key: string]: boolean }>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [snackbar, setSnackbar] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
message: string;
|
||||||
|
severity: "success" | "error" | "info" | "warning";
|
||||||
|
}>({
|
||||||
|
open: false,
|
||||||
|
message: '',
|
||||||
|
severity: 'success'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form data state
|
||||||
|
const [formData, setFormData] = useState<Partial<Types.Candidate>>({});
|
||||||
|
const [profileImage, setProfileImage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Dialog states
|
||||||
|
const [skillDialog, setSkillDialog] = useState(false);
|
||||||
|
const [experienceDialog, setExperienceDialog] = useState(false);
|
||||||
|
const [educationDialog, setEducationDialog] = useState(false);
|
||||||
|
const [languageDialog, setLanguageDialog] = useState(false);
|
||||||
|
const [certificationDialog, setCertificationDialog] = useState(false);
|
||||||
|
|
||||||
|
// New item states
|
||||||
|
const [newSkill, setNewSkill] = useState<Partial<Types.Skill>>({
|
||||||
|
name: '',
|
||||||
|
category: '',
|
||||||
|
level: 'beginner',
|
||||||
|
yearsOfExperience: 0
|
||||||
|
});
|
||||||
|
const [newExperience, setNewExperience] = useState<Partial<Types.WorkExperience>>({
|
||||||
|
companyName: '',
|
||||||
|
position: '',
|
||||||
|
startDate: new Date(),
|
||||||
|
isCurrent: false,
|
||||||
|
description: '',
|
||||||
|
skills: [],
|
||||||
|
location: { city: '', country: '' }
|
||||||
|
});
|
||||||
|
const [newEducation, setNewEducation] = useState<Partial<Types.Education>>({
|
||||||
|
institution: '',
|
||||||
|
degree: '',
|
||||||
|
fieldOfStudy: '',
|
||||||
|
startDate: new Date(),
|
||||||
|
isCurrent: false
|
||||||
|
});
|
||||||
|
const [newLanguage, setNewLanguage] = useState<Partial<Types.Language>>({
|
||||||
|
language: '',
|
||||||
|
proficiency: 'basic'
|
||||||
|
});
|
||||||
|
const [newCertification, setNewCertification] = useState<Partial<Types.Certification>>({
|
||||||
|
name: '',
|
||||||
|
issuingOrganization: '',
|
||||||
|
issueDate: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (candidate) {
|
||||||
|
setFormData(candidate);
|
||||||
|
setProfileImage(candidate.profileImage || null);
|
||||||
|
}
|
||||||
|
}, [candidate]);
|
||||||
|
|
||||||
|
if (!candidate) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="md" sx={{ mt: 4 }}>
|
||||||
|
<Alert severity="error">
|
||||||
|
Access denied. This page is only available for candidates.
|
||||||
|
</Alert>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tab change
|
||||||
|
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||||
|
setTabValue(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle form input changes
|
||||||
|
const handleInputChange = (field: string, value: any) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[field]: value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle profile image upload
|
||||||
|
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
if (await apiClient.uploadCandidateProfile(e.target.files[0])) {
|
||||||
|
candidate.profileImage = e.target.files[0].name;
|
||||||
|
updateUserData(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle edit mode for a section
|
||||||
|
const toggleEditMode = (section: string) => {
|
||||||
|
setEditMode({
|
||||||
|
...editMode,
|
||||||
|
[section]: !editMode[section]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save changes
|
||||||
|
const handleSave = async (section: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (candidate.id) {
|
||||||
|
const updatedCandidate = await apiClient.updateCandidate(candidate.id, formData);
|
||||||
|
updateUserData(updatedCandidate);
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: 'Profile updated successfully!',
|
||||||
|
severity: 'success'
|
||||||
|
});
|
||||||
|
toggleEditMode(section);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: 'Failed to update profile. Please try again.',
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cancel edit
|
||||||
|
const handleCancel = (section: string) => {
|
||||||
|
setFormData(candidate);
|
||||||
|
toggleEditMode(section);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add new skill
|
||||||
|
const handleAddSkill = () => {
|
||||||
|
if (newSkill.name && newSkill.category) {
|
||||||
|
const updatedSkills = [...(formData.skills || []), newSkill as Types.Skill];
|
||||||
|
setFormData({ ...formData, skills: updatedSkills });
|
||||||
|
setNewSkill({ name: '', category: '', level: 'beginner', yearsOfExperience: 0 });
|
||||||
|
setSkillDialog(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove skill
|
||||||
|
const handleRemoveSkill = (index: number) => {
|
||||||
|
const updatedSkills = (formData.skills || []).filter((_, i) => i !== index);
|
||||||
|
setFormData({ ...formData, skills: updatedSkills });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add new work experience
|
||||||
|
const handleAddExperience = () => {
|
||||||
|
if (newExperience.companyName && newExperience.position) {
|
||||||
|
const updatedExperience = [...(formData.experience || []), newExperience as Types.WorkExperience];
|
||||||
|
setFormData({ ...formData, experience: updatedExperience });
|
||||||
|
setNewExperience({
|
||||||
|
companyName: '',
|
||||||
|
position: '',
|
||||||
|
startDate: new Date(),
|
||||||
|
isCurrent: false,
|
||||||
|
description: '',
|
||||||
|
skills: [],
|
||||||
|
location: { city: '', country: '' }
|
||||||
|
});
|
||||||
|
setExperienceDialog(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove work experience
|
||||||
|
const handleRemoveExperience = (index: number) => {
|
||||||
|
const updatedExperience = (formData.experience || []).filter((_, i) => i !== index);
|
||||||
|
setFormData({ ...formData, experience: updatedExperience });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Basic Information Tab
|
||||||
|
const renderBasicInfo = () => (
|
||||||
|
<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 ? `/api/1.0/candidates/profile/${candidate.username}` : ''}
|
||||||
|
sx={{
|
||||||
|
width: { xs: 80, sm: 120 },
|
||||||
|
height: { xs: 80, sm: 120 },
|
||||||
|
mb: { xs: 1, sm: 2 },
|
||||||
|
border: `2px solid ${theme.palette.primary.main}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!profileImage && <AccountCircle sx={{ fontSize: { xs: 50, sm: 80 } }} />}
|
||||||
|
</Avatar>
|
||||||
|
{editMode.basic && (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
color="primary"
|
||||||
|
aria-label="upload picture"
|
||||||
|
component="label"
|
||||||
|
size={isMobile ? "small" : "medium"}
|
||||||
|
>
|
||||||
|
<PhotoCamera />
|
||||||
|
<VisuallyHiddenInput
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="caption" color="textSecondary" sx={{ textAlign: 'center', fontSize: { xs: '0.7rem', sm: '0.75rem' } }}>
|
||||||
|
Update profile photo
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box className="entry">
|
||||||
|
{editMode.basic ? (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="First Name"
|
||||||
|
value={formData.firstName || ''}
|
||||||
|
onChange={(e) => handleInputChange('firstName', e.target.value)}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
) : (<>
|
||||||
|
<Box className="title">First Name</Box>
|
||||||
|
<Box className="value">{candidate.firstName}</Box>
|
||||||
|
</>)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box className="entry">
|
||||||
|
{editMode.basic ? (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Last Name"
|
||||||
|
value={formData.lastName || ''}
|
||||||
|
onChange={(e) => handleInputChange('lastName', e.target.value)}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
) : (<>
|
||||||
|
<Box className="title">Last Name</Box>
|
||||||
|
<Box className="value">{candidate.lastName}</Box>
|
||||||
|
</>)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box className="entry">
|
||||||
|
{(false && editMode.basic) ? (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={formData.email || ''}
|
||||||
|
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
) : (<>
|
||||||
|
<Box className="title"><Email sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||||
|
Email</Box>
|
||||||
|
<Box className="value">{candidate.email}</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box className="entry">
|
||||||
|
{editMode.basic ? (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Phone"
|
||||||
|
value={formData.phone || ''}
|
||||||
|
onChange={(e) => handleInputChange('phone', e.target.value)}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
) : (<>
|
||||||
|
<Box className="title"><Phone sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||||
|
Phone</Box>
|
||||||
|
<Box className="value">{candidate.phone || 'Not provided'}</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box className="entry">
|
||||||
|
{editMode.basic ? (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
label="Professional Summary"
|
||||||
|
value={formData.description || ''}
|
||||||
|
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
) : (<>
|
||||||
|
<Box className="title">Professional Summary</Box>
|
||||||
|
<Box className="value">{candidate.description || 'No summary provided'}</Box>
|
||||||
|
</>)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box className="entry">
|
||||||
|
{false && editMode.basic ? (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Location"
|
||||||
|
value={formData.location?.city || ''}
|
||||||
|
onChange={(e) => handleInputChange('location', {
|
||||||
|
...formData.location,
|
||||||
|
city: e.target.value
|
||||||
|
})}
|
||||||
|
variant="outlined"
|
||||||
|
placeholder="City, State, Country"
|
||||||
|
/>
|
||||||
|
) : (<><Box className="title">
|
||||||
|
<LocationOn sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||||
|
Location</Box>
|
||||||
|
<Box className="value">{candidate.location?.city || 'Not specified'} {candidate.location?.country || ''}</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box className="entry">
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: { xs: 'column', sm: 'row' },
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: 2,
|
||||||
|
mt: { xs: 2, sm: 0 }
|
||||||
|
}}>
|
||||||
|
{editMode.basic ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => handleCancel('basic')}
|
||||||
|
startIcon={<Cancel />}
|
||||||
|
fullWidth={isMobile}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => handleSave('basic')}
|
||||||
|
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <Save />}
|
||||||
|
disabled={loading}
|
||||||
|
fullWidth={isMobile}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => toggleEditMode('basic')}
|
||||||
|
startIcon={<Edit />}
|
||||||
|
fullWidth={isMobile}
|
||||||
|
>
|
||||||
|
Edit Info
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box >
|
||||||
|
);
|
||||||
|
|
||||||
|
// Skills Tab
|
||||||
|
const renderSkills = () => (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: { xs: 'column', sm: 'row' },
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: { xs: 'stretch', sm: 'center' },
|
||||||
|
mb: { xs: 2, sm: 3 },
|
||||||
|
gap: { xs: 1, sm: 0 }
|
||||||
|
}}>
|
||||||
|
<Typography variant={isMobile ? "subtitle1" : "h6"}>Skills & Expertise</Typography>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<Add />}
|
||||||
|
onClick={() => setSkillDialog(true)}
|
||||||
|
fullWidth={isMobile}
|
||||||
|
size={isMobile ? "small" : "medium"}
|
||||||
|
>
|
||||||
|
Add Skill
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Grid container spacing={{ xs: 1, sm: 2 }} sx={{ maxWidth: '100%' }}>
|
||||||
|
{(formData.skills || []).map((skill, index) => (
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={index}>
|
||||||
|
<Card variant="outlined" sx={{ height: '100%' }}>
|
||||||
|
<CardContent sx={{ p: { xs: 1.5, sm: 3 } }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography variant={isMobile ? "subtitle2" : "h6"} component="div" sx={{
|
||||||
|
fontSize: { xs: '0.9rem', sm: '1.25rem' },
|
||||||
|
wordBreak: 'break-word'
|
||||||
|
}}>
|
||||||
|
{skill.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
fontSize: { xs: '0.75rem', sm: '0.875rem' }
|
||||||
|
}}>
|
||||||
|
{skill.category}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={skill.level}
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
mt: 1,
|
||||||
|
fontSize: { xs: '0.65rem', sm: '0.75rem' },
|
||||||
|
height: { xs: 20, sm: 24 }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{skill.yearsOfExperience && (
|
||||||
|
<Typography variant="caption" display="block" sx={{ fontSize: { xs: '0.65rem', sm: '0.75rem' } }}>
|
||||||
|
{skill.yearsOfExperience} years experience
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleRemoveSkill(index)}
|
||||||
|
color="error"
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
>
|
||||||
|
<Delete sx={{ fontSize: { xs: 16, sm: 20 } }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{(!formData.skills || formData.skills.length === 0) && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
No skills added yet. Click "Add Skill" to get started.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Experience Tab
|
||||||
|
const renderExperience = () => (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: { xs: 'column', sm: 'row' },
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: { xs: 'stretch', sm: 'center' },
|
||||||
|
mb: { xs: 2, sm: 3 },
|
||||||
|
gap: { xs: 1, sm: 0 }
|
||||||
|
}}>
|
||||||
|
<Typography variant={isMobile ? "subtitle1" : "h6"}>Work Experience</Typography>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<Add />}
|
||||||
|
onClick={() => setExperienceDialog(true)}
|
||||||
|
fullWidth={isMobile}
|
||||||
|
size={isMobile ? "small" : "medium"}
|
||||||
|
>
|
||||||
|
Add Experience
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{(formData.experience || []).map((exp, index) => (
|
||||||
|
<Card key={index} sx={{ mb: { xs: 1.5, sm: 2 }, overflow: 'hidden' }}>
|
||||||
|
<CardContent sx={{ p: { xs: 1.5, sm: 3 } }}>
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: { xs: 'column', sm: 'row' },
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: { xs: 1, sm: 0 }
|
||||||
|
}}>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography variant={isMobile ? "subtitle1" : "h6"} component="div" sx={{
|
||||||
|
fontSize: { xs: '1rem', sm: '1.25rem' },
|
||||||
|
wordBreak: 'break-word'
|
||||||
|
}}>
|
||||||
|
{exp.position}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle1" color="primary" sx={{
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
fontSize: { xs: '0.9rem', sm: '1rem' }
|
||||||
|
}}>
|
||||||
|
{exp.companyName}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.8rem', sm: '0.875rem' } }}>
|
||||||
|
{exp.startDate?.toLocaleDateString()} - {exp.isCurrent ? 'Present' : exp.endDate?.toLocaleDateString()}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{
|
||||||
|
mt: 1,
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
fontSize: { xs: '0.8rem', sm: '0.875rem' }
|
||||||
|
}}>
|
||||||
|
{exp.description}
|
||||||
|
</Typography>
|
||||||
|
{exp.skills && exp.skills.length > 0 && (
|
||||||
|
<Box sx={{ mt: { xs: 1, sm: 2 } }}>
|
||||||
|
{exp.skills.map((skill, skillIndex) => (
|
||||||
|
<Chip
|
||||||
|
key={skillIndex}
|
||||||
|
label={skill}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
mr: 0.5,
|
||||||
|
mb: 0.5,
|
||||||
|
fontSize: { xs: '0.65rem', sm: '0.75rem' },
|
||||||
|
height: { xs: 20, sm: 24 }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => handleRemoveExperience(index)}
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
alignSelf: { xs: 'flex-end', sm: 'flex-start' },
|
||||||
|
ml: { sm: 1 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Delete sx={{ fontSize: { xs: 16, sm: 20 } }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{(!formData.experience || formData.experience.length === 0) && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{
|
||||||
|
textAlign: 'center',
|
||||||
|
py: { xs: 2, sm: 4 },
|
||||||
|
fontSize: { xs: '0.8rem', sm: '0.875rem' }
|
||||||
|
}}>
|
||||||
|
No work experience added yet. Click "Add Experience" to get started.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resume Tab
|
||||||
|
const renderResume = () => (
|
||||||
|
<DocumentManager {...backstoryProps} />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{
|
||||||
|
mt: { xs: 1, sm: 4 },
|
||||||
|
mb: { xs: 1, sm: 4 },
|
||||||
|
px: { xs: 0.5, sm: 3 }
|
||||||
|
}}>
|
||||||
|
<Paper elevation={3} sx={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
mx: { xs: 0, sm: 0 }
|
||||||
|
}}>
|
||||||
|
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<Tabs
|
||||||
|
value={tabValue}
|
||||||
|
onChange={handleTabChange}
|
||||||
|
variant="scrollable"
|
||||||
|
scrollButtons="auto"
|
||||||
|
allowScrollButtonsMobile
|
||||||
|
sx={{
|
||||||
|
'& .MuiTabs-flexContainer': {
|
||||||
|
justifyContent: isMobile ? 'flex-start' : 'center'
|
||||||
|
},
|
||||||
|
'& .MuiTab-root': {
|
||||||
|
fontSize: { xs: '0.75rem', sm: '0.875rem' },
|
||||||
|
minWidth: { xs: 60, sm: 120 },
|
||||||
|
padding: { xs: '6px 8px', sm: '12px 16px' }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
label={isMobile ? "Info" : "Basic Info"}
|
||||||
|
icon={<AccountCircle sx={{ fontSize: { xs: 18, sm: 24 } }} />}
|
||||||
|
iconPosition={isMobile ? "top" : "start"}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
label="Skills"
|
||||||
|
icon={<EmojiEvents sx={{ fontSize: { xs: 18, sm: 24 } }} />}
|
||||||
|
iconPosition={isMobile ? "top" : "start"}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
label={isMobile ? "Work" : "Experience"}
|
||||||
|
icon={<Work sx={{ fontSize: { xs: 18, sm: 24 } }} />}
|
||||||
|
iconPosition={isMobile ? "top" : "start"}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
label={isMobile ? "Edu" : "Education"}
|
||||||
|
icon={<School sx={{ fontSize: { xs: 18, sm: 24 } }} />}
|
||||||
|
iconPosition={isMobile ? "top" : "start"}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
label="Docs"
|
||||||
|
icon={<CloudUpload sx={{ fontSize: { xs: 18, sm: 24 } }} />}
|
||||||
|
iconPosition={isMobile ? "top" : "start"}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
label="RAG"
|
||||||
|
icon={<BubbleChart sx={{ fontSize: { xs: 18, sm: 24 } }} />}
|
||||||
|
iconPosition={isMobile ? "top" : "start"}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TabPanel value={tabValue} index={0}>
|
||||||
|
{renderBasicInfo()}
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value={tabValue} index={1}>
|
||||||
|
<ComingSoon>{renderSkills()}</ComingSoon>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value={tabValue} index={2}>
|
||||||
|
<ComingSoon>{renderExperience()}</ComingSoon>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value={tabValue} index={3}>
|
||||||
|
<ComingSoon>
|
||||||
|
<Typography variant="h6">Education (Coming Soon)</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Education management will be available in a future update.
|
||||||
|
</Typography>
|
||||||
|
</ComingSoon>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value={tabValue} index={4}>
|
||||||
|
{renderResume()}
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value={tabValue} index={5}>
|
||||||
|
<VectorVisualizer {...backstoryProps} />
|
||||||
|
</TabPanel>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Add Skill Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={skillDialog}
|
||||||
|
onClose={() => setSkillDialog(false)}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
fullScreen={isMobile}
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
...(isMobile && {
|
||||||
|
margin: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
maxHeight: '100%'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ pb: { xs: 1, sm: 2 } }}>Add New Skill</DialogTitle>
|
||||||
|
<DialogContent
|
||||||
|
sx={{
|
||||||
|
overflow: 'auto',
|
||||||
|
pt: { xs: 1, sm: 2 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Grid container spacing={{ xs: 1.5, sm: 2 }} sx={{ mt: 0.5, maxWidth: '100%' }}>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Skill Name"
|
||||||
|
value={newSkill.name || ''}
|
||||||
|
onChange={(e) => setNewSkill({ ...newSkill, name: e.target.value })}
|
||||||
|
size={isMobile ? "small" : "medium"}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Category"
|
||||||
|
value={newSkill.category || ''}
|
||||||
|
onChange={(e) => setNewSkill({ ...newSkill, category: e.target.value })}
|
||||||
|
placeholder="e.g., Programming, Design, Marketing"
|
||||||
|
size={isMobile ? "small" : "medium"}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<FormControl fullWidth size={isMobile ? "small" : "medium"}>
|
||||||
|
<InputLabel>Proficiency Level</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={newSkill.level || 'beginner'}
|
||||||
|
onChange={(e) => setNewSkill({ ...newSkill, level: e.target.value as Types.SkillLevel })}
|
||||||
|
label="Proficiency Level"
|
||||||
|
>
|
||||||
|
<MenuItem value="beginner">Beginner</MenuItem>
|
||||||
|
<MenuItem value="intermediate">Intermediate</MenuItem>
|
||||||
|
<MenuItem value="advanced">Advanced</MenuItem>
|
||||||
|
<MenuItem value="expert">Expert</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
type="number"
|
||||||
|
label="Years of Experience"
|
||||||
|
value={newSkill.yearsOfExperience || 0}
|
||||||
|
onChange={(e) => setNewSkill({ ...newSkill, yearsOfExperience: parseInt(e.target.value) || 0 })}
|
||||||
|
size={isMobile ? "small" : "medium"}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{
|
||||||
|
p: { xs: 1.5, sm: 3 },
|
||||||
|
flexDirection: { xs: 'column', sm: 'row' },
|
||||||
|
gap: { xs: 1, sm: 0 }
|
||||||
|
}}>
|
||||||
|
<Button
|
||||||
|
onClick={() => setSkillDialog(false)}
|
||||||
|
fullWidth={isMobile}
|
||||||
|
size={isMobile ? "small" : "medium"}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddSkill}
|
||||||
|
variant="contained"
|
||||||
|
fullWidth={isMobile}
|
||||||
|
size={isMobile ? "small" : "medium"}
|
||||||
|
>
|
||||||
|
Add Skill
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Add Experience Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={experienceDialog}
|
||||||
|
onClose={() => setExperienceDialog(false)}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
fullScreen={isMobile}
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
...(isMobile && {
|
||||||
|
margin: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
maxHeight: '100%'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ pb: { xs: 1, sm: 2 } }}>Add Work Experience</DialogTitle>
|
||||||
|
<DialogContent
|
||||||
|
sx={{
|
||||||
|
overflow: 'auto',
|
||||||
|
pt: { xs: 1, sm: 2 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Grid container spacing={{ xs: 1.5, sm: 2 }} sx={{ mt: 0.5, maxWidth: '100%' }}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Company Name"
|
||||||
|
value={newExperience.companyName || ''}
|
||||||
|
onChange={(e) => setNewExperience({ ...newExperience, companyName: e.target.value })}
|
||||||
|
size={isMobile ? "small" : "medium"}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Position/Title"
|
||||||
|
value={newExperience.position || ''}
|
||||||
|
onChange={(e) => setNewExperience({ ...newExperience, position: e.target.value })}
|
||||||
|
size={isMobile ? "small" : "medium"}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
type="date"
|
||||||
|
label="Start Date"
|
||||||
|
value={newExperience.startDate?.toISOString().split('T')[0] || ''}
|
||||||
|
onChange={(e) => setNewExperience({ ...newExperience, startDate: new Date(e.target.value) })}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
size={isMobile ? "small" : "medium"}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={newExperience.isCurrent || false}
|
||||||
|
onChange={(e) => setNewExperience({ ...newExperience, isCurrent: e.target.checked })}
|
||||||
|
size={isMobile ? "small" : "medium"}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Currently working here"
|
||||||
|
sx={{
|
||||||
|
'& .MuiFormControlLabel-label': {
|
||||||
|
fontSize: { xs: '0.875rem', sm: '1rem' }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={isMobile ? 3 : 4}
|
||||||
|
label="Job Description"
|
||||||
|
value={newExperience.description || ''}
|
||||||
|
onChange={(e) => setNewExperience({ ...newExperience, description: e.target.value })}
|
||||||
|
placeholder="Describe your responsibilities and achievements..."
|
||||||
|
size={isMobile ? "small" : "medium"}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{
|
||||||
|
p: { xs: 1.5, sm: 3 },
|
||||||
|
flexDirection: { xs: 'column', sm: 'row' },
|
||||||
|
gap: { xs: 1, sm: 0 }
|
||||||
|
}}>
|
||||||
|
<Button
|
||||||
|
onClick={() => setExperienceDialog(false)}
|
||||||
|
fullWidth={isMobile}
|
||||||
|
size={isMobile ? "small" : "medium"}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddExperience}
|
||||||
|
variant="contained"
|
||||||
|
fullWidth={isMobile}
|
||||||
|
size={isMobile ? "small" : "medium"}
|
||||||
|
>
|
||||||
|
Add Experience
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Snackbar for notifications */}
|
||||||
|
<Snackbar
|
||||||
|
open={snackbar.open}
|
||||||
|
autoHideDuration={6000}
|
||||||
|
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||||
|
severity={snackbar.severity}
|
||||||
|
sx={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{snackbar.message}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { CandidateProfilePage };
|
@ -33,6 +33,7 @@ import {
|
|||||||
convertFromApi,
|
convertFromApi,
|
||||||
convertArrayFromApi
|
convertArrayFromApi
|
||||||
} from 'types/types';
|
} from 'types/types';
|
||||||
|
import internal from 'stream';
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Streaming Types and Interfaces
|
// Streaming Types and Interfaces
|
||||||
@ -290,14 +291,7 @@ class ApiClient {
|
|||||||
body: JSON.stringify(formatApiRequest(auth))
|
body: JSON.stringify(formatApiRequest(auth))
|
||||||
});
|
});
|
||||||
|
|
||||||
// This could return either a full auth response or MFA request
|
return handleApiResponse<Types.AuthResponse | Types.MFARequestResponse>(response);
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.error?.message || 'Login failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -524,15 +518,35 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateCandidate(id: string, updates: Partial<Types.Candidate>): Promise<Types.Candidate> {
|
async updateCandidate(id: string, updates: Partial<Types.Candidate>): Promise<Types.Candidate> {
|
||||||
|
const request = formatApiRequest(updates);
|
||||||
const response = await fetch(`${this.baseUrl}/candidates/${id}`, {
|
const response = await fetch(`${this.baseUrl}/candidates/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: this.defaultHeaders,
|
headers: this.defaultHeaders,
|
||||||
body: JSON.stringify(formatApiRequest(updates))
|
body: JSON.stringify(request)
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.handleApiResponseWithConversion<Types.Candidate>(response, 'Candidate');
|
return this.handleApiResponseWithConversion<Types.Candidate>(response, 'Candidate');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async uploadCandidateProfile(file: File): Promise<boolean> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('filename', file.name);
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}/candidates/profile/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<boolean>(response);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async getCandidates(request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.Candidate>> {
|
async getCandidates(request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.Candidate>> {
|
||||||
const paginatedRequest = createPaginatedRequest(request);
|
const paginatedRequest = createPaginatedRequest(request);
|
||||||
const params = toUrlParams(formatApiRequest(paginatedRequest));
|
const params = toUrlParams(formatApiRequest(paginatedRequest));
|
||||||
@ -739,6 +753,119 @@ class ApiClient {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCandidateSimilarContent(query: string
|
||||||
|
): Promise<Types.ChromaDBGetResponse> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/candidates/rag-search`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.defaultHeaders,
|
||||||
|
body: JSON.stringify(query)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await handleApiResponse<Types.ChromaDBGetResponse>(response);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCandidateVectors(
|
||||||
|
dimensions: number,
|
||||||
|
): Promise<Types.ChromaDBGetResponse> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/candidates/rag-vectors`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.defaultHeaders,
|
||||||
|
body: JSON.stringify(dimensions)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await handleApiResponse<Types.ChromaDBGetResponse>(response);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCandidateRAGContent(
|
||||||
|
documentId: string,
|
||||||
|
): Promise<Types.RagContentResponse> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/candidates/rag-content`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.defaultHeaders,
|
||||||
|
body: JSON.stringify({ id: documentId })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await handleApiResponse<Types.RagContentResponse>(response);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/****
|
||||||
|
* Document CRUD API
|
||||||
|
*/
|
||||||
|
async uploadCandidateDocument(file: File, includeInRag: boolean = true): Promise<Types.Document> {
|
||||||
|
const formData = new FormData()
|
||||||
|
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
|
||||||
*/
|
*/
|
||||||
@ -809,7 +936,7 @@ class ApiClient {
|
|||||||
* Send message with streaming response support and date conversion
|
* Send message with streaming response support and date conversion
|
||||||
*/
|
*/
|
||||||
sendMessageStream(
|
sendMessageStream(
|
||||||
chatMessage: Types.ChatMessageUser,
|
chatMessage: Types.ChatMessageBase,
|
||||||
options: StreamingOptions = {}
|
options: StreamingOptions = {}
|
||||||
): StreamingResponse {
|
): StreamingResponse {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
@ -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-01T20:40:46.797024
|
// Generated on: 2025-06-02T23:24:36.213957
|
||||||
// DO NOT EDIT MANUALLY - This file is auto-generated
|
// DO NOT EDIT MANUALLY - This file is auto-generated
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
@ -13,9 +13,9 @@ export type ActivityType = "login" | "search" | "view_job" | "apply_job" | "mess
|
|||||||
|
|
||||||
export type ApplicationStatus = "applied" | "reviewing" | "interview" | "offer" | "rejected" | "accepted" | "withdrawn";
|
export type ApplicationStatus = "applied" | "reviewing" | "interview" | "offer" | "rejected" | "accepted" | "withdrawn";
|
||||||
|
|
||||||
export type ChatContextType = "job_search" | "candidate_chat" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile";
|
export type ChatContextType = "job_search" | "candidate_chat" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile" | "rag_search";
|
||||||
|
|
||||||
export type ChatMessageType = "error" | "generating" | "info" | "preparing" | "processing" | "response" | "searching" | "system" | "thinking" | "tooling" | "user";
|
export type ChatMessageType = "error" | "generating" | "info" | "preparing" | "processing" | "response" | "searching" | "rag_result" | "system" | "thinking" | "tooling" | "user";
|
||||||
|
|
||||||
export type ChatSenderType = "user" | "assistant" | "system";
|
export type ChatSenderType = "user" | "assistant" | "system";
|
||||||
|
|
||||||
@ -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";
|
||||||
@ -145,7 +147,7 @@ export interface BaseUser {
|
|||||||
lastLogin?: Date;
|
lastLogin?: Date;
|
||||||
profileImage?: string;
|
profileImage?: string;
|
||||||
status: "active" | "inactive" | "pending" | "banned";
|
status: "active" | "inactive" | "pending" | "banned";
|
||||||
isAdmin?: boolean;
|
isAdmin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BaseUserWithType {
|
export interface BaseUserWithType {
|
||||||
@ -161,7 +163,7 @@ export interface BaseUserWithType {
|
|||||||
lastLogin?: Date;
|
lastLogin?: Date;
|
||||||
profileImage?: string;
|
profileImage?: string;
|
||||||
status: "active" | "inactive" | "pending" | "banned";
|
status: "active" | "inactive" | "pending" | "banned";
|
||||||
isAdmin?: boolean;
|
isAdmin: boolean;
|
||||||
userType: "candidate" | "employer" | "guest";
|
userType: "candidate" | "employer" | "guest";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,7 +180,7 @@ export interface Candidate {
|
|||||||
lastLogin?: Date;
|
lastLogin?: Date;
|
||||||
profileImage?: string;
|
profileImage?: string;
|
||||||
status: "active" | "inactive" | "pending" | "banned";
|
status: "active" | "inactive" | "pending" | "banned";
|
||||||
isAdmin?: boolean;
|
isAdmin: boolean;
|
||||||
userType: "candidate";
|
userType: "candidate";
|
||||||
username: string;
|
username: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@ -194,9 +196,42 @@ export interface Candidate {
|
|||||||
languages?: Array<Language>;
|
languages?: Array<Language>;
|
||||||
certifications?: Array<Certification>;
|
certifications?: Array<Certification>;
|
||||||
jobApplications?: Array<JobApplication>;
|
jobApplications?: Array<JobApplication>;
|
||||||
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>;
|
||||||
|
rags?: Array<RagEntry>;
|
||||||
|
ragContentSize: number;
|
||||||
|
isAI: boolean;
|
||||||
age?: number;
|
age?: number;
|
||||||
gender?: "female" | "male";
|
gender?: "female" | "male";
|
||||||
ethnicity?: string;
|
ethnicity?: string;
|
||||||
@ -237,7 +272,7 @@ export interface Certification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatContext {
|
export interface ChatContext {
|
||||||
type: "job_search" | "candidate_chat" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile";
|
type: "job_search" | "candidate_chat" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile" | "rag_search";
|
||||||
relatedEntityId?: string;
|
relatedEntityId?: string;
|
||||||
relatedEntityType?: "job" | "candidate" | "employer";
|
relatedEntityType?: "job" | "candidate" | "employer";
|
||||||
additionalContext?: Record<string, any>;
|
additionalContext?: Record<string, any>;
|
||||||
@ -248,11 +283,11 @@ export interface ChatMessage {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
senderId?: string;
|
senderId?: string;
|
||||||
status: "initializing" | "streaming" | "done" | "error";
|
status: "initializing" | "streaming" | "done" | "error";
|
||||||
type: "error" | "generating" | "info" | "preparing" | "processing" | "response" | "searching" | "system" | "thinking" | "tooling" | "user";
|
type: "error" | "generating" | "info" | "preparing" | "processing" | "response" | "searching" | "rag_result" | "system" | "thinking" | "tooling" | "user";
|
||||||
sender: "user" | "assistant" | "system";
|
sender: "user" | "assistant" | "system";
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
tunables?: Tunables;
|
tunables?: Tunables;
|
||||||
content?: string;
|
content: string;
|
||||||
metadata?: ChatMessageMetaData;
|
metadata?: ChatMessageMetaData;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -261,32 +296,45 @@ export interface ChatMessageBase {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
senderId?: string;
|
senderId?: string;
|
||||||
status: "initializing" | "streaming" | "done" | "error";
|
status: "initializing" | "streaming" | "done" | "error";
|
||||||
type: "error" | "generating" | "info" | "preparing" | "processing" | "response" | "searching" | "system" | "thinking" | "tooling" | "user";
|
type: "error" | "generating" | "info" | "preparing" | "processing" | "response" | "searching" | "rag_result" | "system" | "thinking" | "tooling" | "user";
|
||||||
sender: "user" | "assistant" | "system";
|
sender: "user" | "assistant" | "system";
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
tunables?: Tunables;
|
tunables?: Tunables;
|
||||||
content?: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessageMetaData {
|
export interface ChatMessageMetaData {
|
||||||
model: "qwen2.5";
|
model: "qwen2.5";
|
||||||
temperature?: number;
|
temperature: number;
|
||||||
maxTokens?: number;
|
maxTokens: number;
|
||||||
topP?: number;
|
topP: number;
|
||||||
frequencyPenalty?: number;
|
frequencyPenalty?: number;
|
||||||
presencePenalty?: number;
|
presencePenalty?: number;
|
||||||
stopSequences?: Array<string>;
|
stopSequences?: Array<string>;
|
||||||
ragResults?: Array<ChromaDBGetResponse>;
|
ragResults?: Array<ChromaDBGetResponse>;
|
||||||
llmHistory?: Array<LLMMessage>;
|
llmHistory?: Array<LLMMessage>;
|
||||||
evalCount?: number;
|
evalCount: number;
|
||||||
evalDuration?: number;
|
evalDuration: number;
|
||||||
promptEvalCount?: number;
|
promptEvalCount: number;
|
||||||
promptEvalDuration?: number;
|
promptEvalDuration: number;
|
||||||
options?: ChatOptions;
|
options?: ChatOptions;
|
||||||
tools?: Record<string, any>;
|
tools?: Record<string, any>;
|
||||||
timers?: Record<string, number>;
|
timers?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatMessageRagSearch {
|
||||||
|
id?: string;
|
||||||
|
sessionId: string;
|
||||||
|
senderId?: string;
|
||||||
|
status: "done";
|
||||||
|
type: "rag_result";
|
||||||
|
sender: "user";
|
||||||
|
timestamp: Date;
|
||||||
|
tunables?: Tunables;
|
||||||
|
content: string;
|
||||||
|
dimensions: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatMessageUser {
|
export interface ChatMessageUser {
|
||||||
id?: string;
|
id?: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@ -296,7 +344,7 @@ export interface ChatMessageUser {
|
|||||||
sender: "user";
|
sender: "user";
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
tunables?: Tunables;
|
tunables?: Tunables;
|
||||||
content?: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatOptions {
|
export interface ChatOptions {
|
||||||
@ -320,23 +368,46 @@ export interface ChatSession {
|
|||||||
title?: string;
|
title?: string;
|
||||||
context: ChatContext;
|
context: ChatContext;
|
||||||
messages?: Array<ChatMessage>;
|
messages?: Array<ChatMessage>;
|
||||||
isArchived?: boolean;
|
isArchived: boolean;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChromaDBGetResponse {
|
export interface ChromaDBGetResponse {
|
||||||
ids?: Array<string>;
|
ids: Array<string>;
|
||||||
embeddings?: Array<Array<number>>;
|
embeddings: Array<Array<number>>;
|
||||||
documents?: Array<string>;
|
documents: Array<string>;
|
||||||
metadatas?: Array<Record<string, any>>;
|
metadatas: Array<Record<string, any>>;
|
||||||
name?: string;
|
distances: Array<number>;
|
||||||
size?: number;
|
name: string;
|
||||||
query?: string;
|
size: number;
|
||||||
|
dimensions: number;
|
||||||
|
query: string;
|
||||||
queryEmbedding?: Array<number>;
|
queryEmbedding?: Array<number>;
|
||||||
umapEmbedding2D?: Array<number>;
|
umapEmbedding2D?: Array<number>;
|
||||||
umapEmbedding3D?: Array<number>;
|
umapEmbedding3D?: Array<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateCandidateRequest {
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEmployerRequest {
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
companyName: string;
|
||||||
|
industry: string;
|
||||||
|
companySize: string;
|
||||||
|
companyDescription: string;
|
||||||
|
websiteUrl?: string;
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CustomQuestion {
|
export interface CustomQuestion {
|
||||||
question: string;
|
question: string;
|
||||||
answer: string;
|
answer: string;
|
||||||
@ -362,6 +433,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;
|
||||||
@ -398,7 +499,7 @@ export interface Employer {
|
|||||||
lastLogin?: Date;
|
lastLogin?: Date;
|
||||||
profileImage?: string;
|
profileImage?: string;
|
||||||
status: "active" | "inactive" | "pending" | "banned";
|
status: "active" | "inactive" | "pending" | "banned";
|
||||||
isAdmin?: boolean;
|
isAdmin: boolean;
|
||||||
userType: "employer";
|
userType: "employer";
|
||||||
companyName: string;
|
companyName: string;
|
||||||
industry: string;
|
industry: string;
|
||||||
@ -486,8 +587,8 @@ export interface Job {
|
|||||||
benefits?: Array<string>;
|
benefits?: Array<string>;
|
||||||
visaSponsorship?: boolean;
|
visaSponsorship?: boolean;
|
||||||
featuredUntil?: Date;
|
featuredUntil?: Date;
|
||||||
views?: number;
|
views: number;
|
||||||
applicationCount?: number;
|
applicationCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JobApplication {
|
export interface JobApplication {
|
||||||
@ -521,8 +622,8 @@ export interface JobResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LLMMessage {
|
export interface LLMMessage {
|
||||||
role?: string;
|
role: string;
|
||||||
content?: string;
|
content: string;
|
||||||
toolCalls?: Array<Record<string, any>>;
|
toolCalls?: Array<Record<string, any>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -572,7 +673,7 @@ export interface MFAVerifyRequest {
|
|||||||
email: string;
|
email: string;
|
||||||
code: string;
|
code: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
rememberDevice?: boolean;
|
rememberDevice: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageReaction {
|
export interface MessageReaction {
|
||||||
@ -588,8 +689,8 @@ export interface NotificationPreference {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedRequest {
|
export interface PaginatedRequest {
|
||||||
page?: number;
|
page: number;
|
||||||
limit?: number;
|
limit: number;
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: "asc" | "desc";
|
sortOrder?: "asc" | "desc";
|
||||||
filters?: Record<string, any>;
|
filters?: Record<string, any>;
|
||||||
@ -634,10 +735,26 @@ export interface RAGConfiguration {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RagContentMetadata {
|
||||||
|
sourceFile: string;
|
||||||
|
lineBegin: number;
|
||||||
|
lineEnd: number;
|
||||||
|
lines: number;
|
||||||
|
chunkBegin?: number;
|
||||||
|
chunkEnd?: number;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RagContentResponse {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
metadata: RagContentMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RagEntry {
|
export interface RagEntry {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description: string;
|
||||||
enabled?: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RefreshToken {
|
export interface RefreshToken {
|
||||||
@ -674,8 +791,8 @@ export interface SalaryRange {
|
|||||||
export interface SearchQuery {
|
export interface SearchQuery {
|
||||||
query: string;
|
query: string;
|
||||||
filters?: Record<string, any>;
|
filters?: Record<string, any>;
|
||||||
page?: number;
|
page: number;
|
||||||
limit?: number;
|
limit: number;
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: "asc" | "desc";
|
sortOrder?: "asc" | "desc";
|
||||||
}
|
}
|
||||||
@ -700,9 +817,9 @@ export interface SocialLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Tunables {
|
export interface Tunables {
|
||||||
enableRAG?: boolean;
|
enableRAG: boolean;
|
||||||
enableTools?: boolean;
|
enableTools: boolean;
|
||||||
enableContext?: boolean;
|
enableContext: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserActivity {
|
export interface UserActivity {
|
||||||
@ -857,6 +974,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
|
||||||
@ -898,6 +1034,19 @@ export function convertChatMessageBaseFromApi(data: any): ChatMessageBase {
|
|||||||
timestamp: new Date(data.timestamp),
|
timestamp: new Date(data.timestamp),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Convert ChatMessageRagSearch from API response, parsing date fields
|
||||||
|
* Date fields: timestamp
|
||||||
|
*/
|
||||||
|
export function convertChatMessageRagSearchFromApi(data: any): ChatMessageRagSearch {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert timestamp from ISO string to Date
|
||||||
|
timestamp: new Date(data.timestamp),
|
||||||
|
};
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Convert ChatMessageUser from API response, parsing date fields
|
* Convert ChatMessageUser from API response, parsing date fields
|
||||||
* Date fields: timestamp
|
* Date fields: timestamp
|
||||||
@ -939,6 +1088,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
|
||||||
@ -1153,18 +1315,24 @@ 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':
|
||||||
return convertChatMessageFromApi(data) as T;
|
return convertChatMessageFromApi(data) as T;
|
||||||
case 'ChatMessageBase':
|
case 'ChatMessageBase':
|
||||||
return convertChatMessageBaseFromApi(data) as T;
|
return convertChatMessageBaseFromApi(data) as T;
|
||||||
|
case 'ChatMessageRagSearch':
|
||||||
|
return convertChatMessageRagSearchFromApi(data) as T;
|
||||||
case 'ChatMessageUser':
|
case 'ChatMessageUser':
|
||||||
return convertChatMessageUserFromApi(data) as T;
|
return convertChatMessageUserFromApi(data) as T;
|
||||||
case 'ChatSession':
|
case 'ChatSession':
|
||||||
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':
|
||||||
|
@ -60,7 +60,7 @@ class Agent(BaseModel, ABC):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
# Agent properties
|
# Agent properties
|
||||||
system_prompt: str # Mandatory
|
system_prompt: str = ""
|
||||||
context_tokens: int = 0
|
context_tokens: int = 0
|
||||||
|
|
||||||
# context_size is shared across all subclasses
|
# context_size is shared across all subclasses
|
||||||
|
98
src/backend/agents/rag_search.py
Normal file
98
src/backend/agents/rag_search.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from typing import Literal, AsyncGenerator, ClassVar, Optional, Any, List
|
||||||
|
from datetime import datetime, UTC
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from .base import Agent, agent_registry
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
from .registry import agent_registry
|
||||||
|
from models import ( ChatMessage, ChatStatusType, ChatMessage, ChatOptions, ChatMessageType, ChatSenderType, ChatStatusType, ChatMessageMetaData, Candidate )
|
||||||
|
from rag import ( ChromaDBGetResponse )
|
||||||
|
|
||||||
|
class Chat(Agent):
|
||||||
|
"""
|
||||||
|
Chat Agent
|
||||||
|
"""
|
||||||
|
|
||||||
|
agent_type: Literal["rag_search"] = "rag_search" # type: ignore
|
||||||
|
_agent_type: ClassVar[str] = agent_type # Add this for registration
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self, llm: Any, model: str, user_message: ChatMessage, user: Candidate, temperature=0.7
|
||||||
|
) -> AsyncGenerator[ChatMessage, None]:
|
||||||
|
"""
|
||||||
|
Generate a response based on the user message and the provided LLM.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
llm: The language model to use for generation.
|
||||||
|
model: The specific model to use.
|
||||||
|
user_message: The message from the user.
|
||||||
|
user: Optional user information.
|
||||||
|
temperature: The temperature setting for generation.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
ChatMessage: The generated response.
|
||||||
|
"""
|
||||||
|
logger.info(f"{self.agent_type} - {inspect.stack()[0].function}")
|
||||||
|
|
||||||
|
if user.id != user_message.sender_id:
|
||||||
|
logger.error(f"User {user.username} id does not match message {user_message.sender_id}")
|
||||||
|
raise ValueError("User does not match message sender")
|
||||||
|
|
||||||
|
chat_message = ChatMessage(
|
||||||
|
session_id=user_message.session_id,
|
||||||
|
tunables=user_message.tunables,
|
||||||
|
status=ChatStatusType.INITIALIZING,
|
||||||
|
type=ChatMessageType.PREPARING,
|
||||||
|
sender=ChatSenderType.ASSISTANT,
|
||||||
|
content="",
|
||||||
|
timestamp=datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
chat_message.metadata = ChatMessageMetaData()
|
||||||
|
chat_message.metadata.options = ChatOptions(
|
||||||
|
seed=8911,
|
||||||
|
num_ctx=self.context_size,
|
||||||
|
temperature=temperature, # Higher temperature to encourage tool usage
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a dict for storing various timing stats
|
||||||
|
chat_message.metadata.timers = {}
|
||||||
|
|
||||||
|
self.metrics.generate_count.labels(agent=self.agent_type).inc()
|
||||||
|
with self.metrics.generate_duration.labels(agent=self.agent_type).time():
|
||||||
|
|
||||||
|
rag_message : Optional[ChatMessage] = None
|
||||||
|
async for rag_message in self.generate_rag_results(chat_message=user_message):
|
||||||
|
if rag_message.status == ChatStatusType.ERROR:
|
||||||
|
chat_message.status = rag_message.status
|
||||||
|
chat_message.content = rag_message.content
|
||||||
|
yield chat_message
|
||||||
|
return
|
||||||
|
yield rag_message
|
||||||
|
|
||||||
|
if rag_message:
|
||||||
|
chat_message.content = ""
|
||||||
|
rag_results: List[ChromaDBGetResponse] = rag_message.metadata.rag_results
|
||||||
|
chat_message.metadata.rag_results = rag_results
|
||||||
|
for chroma_results in rag_results:
|
||||||
|
for index, metadata in enumerate(chroma_results.metadatas):
|
||||||
|
content = "\n".join([
|
||||||
|
line.strip()
|
||||||
|
for line in chroma_results.documents[index].split("\n")
|
||||||
|
if line
|
||||||
|
]).strip()
|
||||||
|
chat_message.content += f"""
|
||||||
|
Source: {metadata.get("doc_type", "unknown")}: {metadata.get("path", "")}
|
||||||
|
Document reference: {chroma_results.ids[index]}
|
||||||
|
Content: { content }
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
chat_message.status = ChatStatusType.DONE
|
||||||
|
chat_message.type = ChatMessageType.RAG_RESULT
|
||||||
|
yield chat_message
|
||||||
|
|
||||||
|
# Register the base agent
|
||||||
|
agent_registry.register(Chat._agent_type, Chat)
|
@ -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"""
|
||||||
|
@ -107,82 +107,6 @@ class CandidateEntity(Candidate):
|
|||||||
raise ValueError("initialize() has not been called.")
|
raise ValueError("initialize() has not been called.")
|
||||||
return self.CandidateEntity__observer
|
return self.CandidateEntity__observer
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def sanitize(cls, user: Dict[str, Any]):
|
|
||||||
sanitized : Dict[str, Any] = {}
|
|
||||||
sanitized["username"] = user.get("username", "default")
|
|
||||||
sanitized["first_name"] = user.get("first_name", sanitized["username"])
|
|
||||||
sanitized["last_name"] = user.get("last_name", "")
|
|
||||||
sanitized["title"] = user.get("title", "")
|
|
||||||
sanitized["phone"] = user.get("phone", "")
|
|
||||||
sanitized["location"] = user.get("location", "")
|
|
||||||
sanitized["email"] = user.get("email", "")
|
|
||||||
sanitized["full_name"] = user.get("full_name", f"{sanitized["first_name"]} {sanitized["last_name"]}")
|
|
||||||
sanitized["description"] = user.get("description", "")
|
|
||||||
profile_image = os.path.join(defines.user_dir, sanitized["username"], "profile.png")
|
|
||||||
sanitized["has_profile"] = os.path.exists(profile_image)
|
|
||||||
contact_info = user.get("contact_info", {})
|
|
||||||
sanitized["contact_info"] = {}
|
|
||||||
for key in contact_info:
|
|
||||||
if not isinstance(contact_info[key], (str, int, float, complex)):
|
|
||||||
continue
|
|
||||||
sanitized["contact_info"][key] = contact_info[key]
|
|
||||||
questions = user.get("questions", [ f"Tell me about {sanitized['first_name']}.", f"What are {sanitized['first_name']}'s professional strengths?"])
|
|
||||||
sanitized["user_questions"] = []
|
|
||||||
for question in questions:
|
|
||||||
if type(question) == str:
|
|
||||||
sanitized["user_questions"].append({"question": question})
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
tmp = CandidateQuestion.model_validate(question)
|
|
||||||
sanitized["user_questions"].append({"question": tmp.question})
|
|
||||||
except Exception as e:
|
|
||||||
continue
|
|
||||||
return sanitized
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_users(cls):
|
|
||||||
# Initialize an empty list to store parsed JSON data
|
|
||||||
user_data = []
|
|
||||||
|
|
||||||
# Define the users directory path
|
|
||||||
users_dir = os.path.join(defines.user_dir)
|
|
||||||
|
|
||||||
# Check if the users directory exists
|
|
||||||
if not os.path.exists(users_dir):
|
|
||||||
return user_data
|
|
||||||
|
|
||||||
# Iterate through all items in the users directory
|
|
||||||
for item in os.listdir(users_dir):
|
|
||||||
# Construct the full path to the item
|
|
||||||
item_path = os.path.join(users_dir, item)
|
|
||||||
|
|
||||||
# Check if the item is a directory
|
|
||||||
if os.path.isdir(item_path):
|
|
||||||
# Construct the path to info.json
|
|
||||||
info_path = os.path.join(item_path, "info.json")
|
|
||||||
|
|
||||||
# Check if info.json exists
|
|
||||||
if os.path.exists(info_path):
|
|
||||||
try:
|
|
||||||
# Read and parse the JSON file
|
|
||||||
with open(info_path, 'r') as file:
|
|
||||||
data = json.load(file)
|
|
||||||
data["username"] = item
|
|
||||||
profile_image = os.path.join(defines.user_dir, item, "profile.png")
|
|
||||||
data["has_profile"] = os.path.exists(profile_image)
|
|
||||||
user_data.append(data)
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
# Skip files that aren't valid JSON
|
|
||||||
logger.info(f"Invalid JSON for {info_path}: {str(e)}")
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
# Skip files that can't be read
|
|
||||||
logger.info(f"Exception processing {info_path}: {str(e)}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
return user_data
|
|
||||||
|
|
||||||
async def initialize(self, prometheus_collector: CollectorRegistry):
|
async def initialize(self, prometheus_collector: CollectorRegistry):
|
||||||
if self.CandidateEntity__initialized:
|
if self.CandidateEntity__initialized:
|
||||||
# Initialization can only be attempted once; if there are multiple attempts, it means
|
# Initialization can only be attempted once; if there are multiple attempts, it means
|
||||||
@ -200,8 +124,6 @@ class CandidateEntity(Candidate):
|
|||||||
vector_db_dir=os.path.join(user_dir, defines.persist_directory)
|
vector_db_dir=os.path.join(user_dir, defines.persist_directory)
|
||||||
rag_content_dir=os.path.join(user_dir, defines.rag_content_dir)
|
rag_content_dir=os.path.join(user_dir, defines.rag_content_dir)
|
||||||
|
|
||||||
logger.info(f"CandidateEntity(username={self.username}, user_dir={user_dir} persist_directory={vector_db_dir}, rag_content_dir={rag_content_dir}")
|
|
||||||
|
|
||||||
os.makedirs(vector_db_dir, exist_ok=True)
|
os.makedirs(vector_db_dir, exist_ok=True)
|
||||||
os.makedirs(rag_content_dir, exist_ok=True)
|
os.makedirs(rag_content_dir, exist_ok=True)
|
||||||
|
|
||||||
|
@ -382,10 +382,11 @@ def is_field_optional(field_info: Any, field_type: Any, debug: bool = False) ->
|
|||||||
print(f" └─ RESULT: Required (has specific enum default: {default_val.value})")
|
print(f" └─ RESULT: Required (has specific enum default: {default_val.value})")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Any other actual default value makes it optional
|
# FIXED: Fields with actual default values (like [], "", 0) should be REQUIRED
|
||||||
|
# because they will always have a value (either provided or the default)
|
||||||
if debug:
|
if debug:
|
||||||
print(f" └─ RESULT: Optional (has actual default value)")
|
print(f" └─ RESULT: Required (has actual default value - field will always have a value)")
|
||||||
return True
|
return False # Changed from True to False
|
||||||
else:
|
else:
|
||||||
if debug:
|
if debug:
|
||||||
print(f" └─ No default attribute found")
|
print(f" └─ No default attribute found")
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -81,6 +81,7 @@ class ChatMessageType(str, Enum):
|
|||||||
PROCESSING = "processing"
|
PROCESSING = "processing"
|
||||||
RESPONSE = "response"
|
RESPONSE = "response"
|
||||||
SEARCHING = "searching"
|
SEARCHING = "searching"
|
||||||
|
RAG_RESULT = "rag_result"
|
||||||
SYSTEM = "system"
|
SYSTEM = "system"
|
||||||
THINKING = "thinking"
|
THINKING = "thinking"
|
||||||
TOOLING = "tooling"
|
TOOLING = "tooling"
|
||||||
@ -100,6 +101,7 @@ class ChatContextType(str, Enum):
|
|||||||
GENERAL = "general"
|
GENERAL = "general"
|
||||||
GENERATE_PERSONA = "generate_persona"
|
GENERATE_PERSONA = "generate_persona"
|
||||||
GENERATE_PROFILE = "generate_profile"
|
GENERATE_PROFILE = "generate_profile"
|
||||||
|
RAG_SEARCH = "rag_search"
|
||||||
|
|
||||||
class AIModelType(str, Enum):
|
class AIModelType(str, Enum):
|
||||||
QWEN2_5 = "qwen2.5"
|
QWEN2_5 = "qwen2.5"
|
||||||
@ -214,7 +216,6 @@ class LoginRequest(BaseModel):
|
|||||||
# MFA Models
|
# MFA Models
|
||||||
# ============================
|
# ============================
|
||||||
|
|
||||||
|
|
||||||
class EmailVerificationRequest(BaseModel):
|
class EmailVerificationRequest(BaseModel):
|
||||||
token: str
|
token: str
|
||||||
|
|
||||||
@ -461,6 +462,65 @@ class RagEntry(BaseModel):
|
|||||||
description: str = ""
|
description: str = ""
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
|
||||||
|
class RagContentMetadata(BaseModel):
|
||||||
|
source_file: str = Field(..., alias="sourceFile")
|
||||||
|
line_begin: int = Field(..., alias="lineBegin")
|
||||||
|
line_end: int = Field(..., alias="lineEnd")
|
||||||
|
lines: int
|
||||||
|
chunk_begin: Optional[int] = Field(None, alias="chunkBegin")
|
||||||
|
chunk_end: Optional[int] = Field(None, alias="chunkEnd")
|
||||||
|
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
model_config = {
|
||||||
|
"populate_by_name": True, # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
|
class RagContentResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
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):
|
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
|
||||||
@ -477,10 +537,12 @@ class Candidate(BaseUser):
|
|||||||
languages: Optional[List[Language]] = None
|
languages: Optional[List[Language]] = None
|
||||||
certifications: Optional[List[Certification]] = None
|
certifications: Optional[List[Certification]] = None
|
||||||
job_applications: Optional[List["JobApplication"]] = Field(None, alias="jobApplications")
|
job_applications: Optional[List["JobApplication"]] = Field(None, alias="jobApplications")
|
||||||
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
|
||||||
@ -618,12 +680,14 @@ class JobApplication(BaseModel):
|
|||||||
class ChromaDBGetResponse(BaseModel):
|
class ChromaDBGetResponse(BaseModel):
|
||||||
# Chroma fields
|
# Chroma fields
|
||||||
ids: List[str] = []
|
ids: List[str] = []
|
||||||
embeddings: List[List[float]] = Field(default=[])
|
embeddings: List[List[float]] = []
|
||||||
documents: List[str] = []
|
documents: List[str] = []
|
||||||
metadatas: List[Dict[str, Any]] = []
|
metadatas: List[Dict[str, Any]] = []
|
||||||
|
distances: List[float] = []
|
||||||
# Additional fields
|
# Additional fields
|
||||||
name: str = ""
|
name: str = ""
|
||||||
size: int = 0
|
size: int = 0
|
||||||
|
dimensions: int = 2 | 3
|
||||||
query: str = ""
|
query: str = ""
|
||||||
query_embedding: Optional[List[float]] = Field(default=None, alias="queryEmbedding")
|
query_embedding: Optional[List[float]] = Field(default=None, alias="queryEmbedding")
|
||||||
umap_embedding_2d: Optional[List[float]] = Field(default=None, alias="umapEmbedding2D")
|
umap_embedding_2d: Optional[List[float]] = Field(default=None, alias="umapEmbedding2D")
|
||||||
@ -663,6 +727,12 @@ class ChatMessageBase(BaseModel):
|
|||||||
"populate_by_name": True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ChatMessageRagSearch(ChatMessageBase):
|
||||||
|
status: ChatStatusType = ChatStatusType.DONE
|
||||||
|
type: ChatMessageType = ChatMessageType.RAG_RESULT
|
||||||
|
sender: ChatSenderType = ChatSenderType.USER
|
||||||
|
dimensions: int = 2 | 3
|
||||||
|
|
||||||
class ChatMessageMetaData(BaseModel):
|
class ChatMessageMetaData(BaseModel):
|
||||||
model: AIModelType = AIModelType.QWEN2_5
|
model: AIModelType = AIModelType.QWEN2_5
|
||||||
temperature: float = 0.7
|
temperature: float = 0.7
|
||||||
|
Loading…
x
Reference in New Issue
Block a user