Compare commits

...

3 Commits

Author SHA1 Message Date
699acf9313 Works 2025-06-02 17:26:07 -07:00
a65f48034c File upload working 2025-06-02 16:06:25 -07:00
149bbdf73b RAG working in candidate page 2025-06-02 13:03:04 -07:00
17 changed files with 3039 additions and 372 deletions

View File

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

View File

@ -0,0 +1,451 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
Grid,
useMediaQuery,
Typography,
Card,
CardContent,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
Switch,
FormControlLabel,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Chip,
Divider,
Paper,
} from '@mui/material';
import { styled } from '@mui/material/styles';
import {
CloudUpload,
Edit,
Delete,
Visibility,
Close,
} from '@mui/icons-material';
import { useTheme } from '@mui/material/styles';
import { useAuth } from "hooks/AuthContext";
import * as Types from 'types/types';
import { BackstoryElementProps } from './BackstoryTab';
const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
height: 1,
overflow: 'hidden',
position: 'absolute',
bottom: 0,
left: 0,
whiteSpace: 'nowrap',
width: 1,
});
const DocumentManager = (props: BackstoryElementProps) => {
const { setSnack, submitQuery } = props;
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const { user, apiClient } = useAuth();
const [documents, setDocuments] = useState<Types.Document[]>([]);
const [selectedDocument, setSelectedDocument] = useState<Types.Document | null>(null);
const [documentContent, setDocumentContent] = useState<string>('');
const [isViewingContent, setIsViewingContent] = useState(false);
const [editingDocument, setEditingDocument] = useState<Types.Document | null>(null);
const [editingName, setEditingName] = useState('');
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
// Check if user is a candidate
const candidate = user?.userType === 'candidate' ? user as Types.Candidate : null;
// Load documents on component mount
useEffect(() => {
if (candidate) {
loadDocuments();
}
}, [candidate]);
const loadDocuments = async () => {
try {
const results = await apiClient.getCandidateDocuments();
setDocuments(results.documents);
} catch (error) {
console.error(error);
setSnack('Failed to load documents', 'error');
}
};
// Handle document upload
const handleDocumentUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
let docType : Types.DocumentType | null = null;
switch (fileExtension.substring(1)) {
case "pdf":
docType = "pdf";
break;
case "docx":
docType = "docx";
break;
case "md":
docType = "markdown";
break;
case "txt":
docType = "txt";
break;
}
if (!docType) {
setSnack('Invalid file type. Please upload .txt, .md, .docx, or .pdf files only.', 'error');
return;
}
try {
// Upload file (replace with actual API call)
const newDocument = await apiClient.uploadCandidateDocument(file);
setDocuments(prev => [...prev, newDocument]);
setSnack(`Document uploaded: ${file.name}`, 'success');
// Reset file input
e.target.value = '';
} catch (error) {
setSnack('Failed to upload document', 'error');
}
}
};
// Handle document deletion
const handleDeleteDocument = async (document: Types.Document) => {
try {
// Call API to delete document
await apiClient.deleteCandidateDocument(document);
setDocuments(prev => prev.filter(doc => doc.id !== document.id));
setSnack('Document deleted successfully', 'success');
// Close content view if this document was being viewed
if (selectedDocument?.id === document.id) {
setIsViewingContent(false);
setSelectedDocument(null);
setDocumentContent('');
}
} catch (error) {
setSnack('Failed to delete document', 'error');
}
};
// Handle RAG flag toggle
const handleRAGToggle = async (document: Types.Document, includeInRAG: boolean) => {
try {
document.includeInRAG = includeInRAG;
// Call API to update RAG flag
await apiClient.updateCandidateDocument(document);
setDocuments(prev =>
prev.map(doc =>
doc.id === document.id
? { ...doc, includeInRAG }
: doc
)
);
setSnack(`Document ${includeInRAG ? 'included in' : 'excluded from'} RAG`, 'success');
} catch (error) {
setSnack('Failed to update RAG setting', 'error');
}
};
// Handle document rename
const handleRenameDocument = async (document: Types.Document, newName: string) => {
if (!newName.trim()) {
setSnack('Document name cannot be empty', 'error');
return;
}
try {
// Call API to rename document
document.filename = newName
await apiClient.updateCandidateDocument(document);
setDocuments(prev =>
prev.map(doc =>
doc.id === document.id
? { ...doc, filename: newName.trim() }
: doc
)
);
setSnack('Document renamed successfully', 'success');
setIsRenameDialogOpen(false);
setEditingDocument(null);
setEditingName('');
} catch (error) {
setSnack('Failed to rename document', 'error');
}
};
// Handle document content viewing
const handleViewDocument = async (document: Types.Document) => {
try {
setSelectedDocument(document);
setIsViewingContent(true);
// Call API to get document content
const result = await apiClient.getCandidateDocumentText(document);
setDocumentContent(result.content);
} catch (error) {
setSnack('Failed to load document content', 'error');
setIsViewingContent(false);
}
};
// Start rename process
const startRename = (document: Types.Document, currentName: string) => {
setEditingDocument(document);
setEditingName(currentName);
setIsRenameDialogOpen(true);
};
// Format file size
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// Get file type color
const getFileTypeColor = (type: string): 'primary' | 'secondary' | 'success' | 'warning' => {
switch (type) {
case 'pdf': return 'primary';
case 'docx': return 'secondary';
case 'txt': return 'success';
case 'md': return 'warning';
default: return 'primary';
}
};
if (!candidate) {
return (<Box>You must be logged in as a candidate to view this content.</Box>);
}
return (
<>
<Grid container spacing={{ xs: 1, sm: 3 }} sx={{ maxWidth: '100%' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, width: "100%", verticalAlign: "center" }}>
<Typography variant={isMobile ? "subtitle2" : "h6"}>
Documents
</Typography>
<Button
component="label"
variant="contained"
startIcon={<CloudUpload />}
size={isMobile ? "small" : "medium"}>
Upload Document
<VisuallyHiddenInput
type="file"
accept=".txt,.md,.docx,.pdf"
onChange={handleDocumentUpload}
/>
</Button>
</Box>
<Grid size={{ xs: 12 }}>
<Card variant="outlined">
<CardContent sx={{ p: { xs: 1.5, sm: 3 } }}>
{documents.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{
fontSize: { xs: '0.8rem', sm: '0.875rem' },
textAlign: 'center',
py: 3
}}>
No additional documents uploaded
</Typography>
) : (
<List sx={{ width: '100%' }}>
{documents.map((doc, index) => (
<React.Fragment key={doc.id}>
{index > 0 && <Divider />}
<ListItem sx={{ px: 0 }}>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="body1" sx={{
wordBreak: 'break-word',
fontSize: { xs: '0.9rem', sm: '1rem' }
}}>
{doc.filename}
</Typography>
<Chip
label={doc.type.toUpperCase()}
size="small"
color={getFileTypeColor(doc.type)}
/>
{doc.includeInRAG && (
<Chip
label="RAG"
size="small"
color="success"
variant="outlined"
/>
)}
</Box>
}
secondary={
<Box sx={{ mt: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{formatFileSize(doc.size)} {doc?.uploadDate?.toLocaleDateString()}
</Typography>
<Box sx={{ mt: 1 }}>
<FormControlLabel
control={
<Switch
checked={doc.includeInRAG}
onChange={(e) => handleRAGToggle(doc, e.target.checked)}
size="small"
/>
}
label={
<Typography variant="caption">
Include in RAG
</Typography>
}
/>
</Box>
</Box>
}
/>
<ListItemSecondaryAction>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton
edge="end"
size="small"
onClick={() => handleViewDocument(doc)}
title="View content"
>
<Visibility />
</IconButton>
<IconButton
edge="end"
size="small"
onClick={() => startRename(doc, doc.filename)}
title="Rename"
>
<Edit />
</IconButton>
<IconButton
edge="end"
size="small"
onClick={() => handleDeleteDocument(doc)}
title="Delete"
color="error"
>
<Delete />
</IconButton>
</Box>
</ListItemSecondaryAction>
</ListItem>
</React.Fragment>
))}
</List>
)}
</CardContent>
</Card>
</Grid>
{/* Document Content Viewer */}
{isViewingContent && (
<Grid size={{ xs: 12 }}>
<Card variant="outlined">
<CardContent sx={{ p: { xs: 1.5, sm: 3 } }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant={isMobile ? "subtitle2" : "h6"}>
Document Content
</Typography>
<IconButton
size="small"
onClick={() => {
setIsViewingContent(false);
setSelectedDocument(null);
setDocumentContent('');
}}
>
<Close />
</IconButton>
</Box>
<Paper
variant="outlined"
sx={{
p: 2,
maxHeight: 400,
overflow: 'auto',
backgroundColor: 'grey.50'
}}
>
<pre style={{
margin: 0,
fontFamily: 'monospace',
fontSize: isMobile ? '0.75rem' : '0.875rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}>
{documentContent || 'Loading content...'}
</pre>
</Paper>
</CardContent>
</Card>
</Grid>
)}
{/* Rename Dialog */}
<Dialog
open={isRenameDialogOpen}
onClose={() => setIsRenameDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Rename Document</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Document Name"
fullWidth
variant="outlined"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter' && editingDocument) {
handleRenameDocument(editingDocument, editingName);
}
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsRenameDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={() => editingDocument && handleRenameDocument(editingDocument, editingName)}
variant="contained"
disabled={!editingName.trim()}
>
Rename
</Button>
</DialogActions>
</Dialog>
</Grid>
</>
);
};
export { DocumentManager };

View File

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

View File

@ -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 />} />,

View File

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

View File

@ -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);
@ -344,7 +347,7 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
maxWidth: { xs: '100%', md: '700px', lg: '1024px' }, maxWidth: { xs: '100%', md: '700px', lg: '1024px' },
}}> }}>
{user && <CandidateInfo {user && <CandidateInfo
candidate={user} candidate={user}
sx={{flexShrink: 1}}/> sx={{flexShrink: 1}}/>
} }
{ prompt && { prompt &&
@ -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>

View File

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

View 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 };

View File

@ -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();

View File

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

View File

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

View 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)

View File

@ -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:
@ -198,6 +199,103 @@ class RedisDatabase:
except json.JSONDecodeError: except json.JSONDecodeError:
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]:

View File

@ -63,7 +63,7 @@ class CandidateEntity(Candidate):
# Check if file exists # Check if file exists
return user_info_path.is_file() return user_info_path.is_file()
def get_or_create_agent(self, agent_type: ChatContextType, **kwargs) -> agents.Agent: def get_or_create_agent(self, agent_type: ChatContextType, **kwargs) -> agents.Agent:
""" """
Get or create an agent of the specified type for this candidate. Get or create an agent of the specified type for this candidate.
@ -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)

View File

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

View File

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