This commit is contained in:
James Ketr 2025-06-02 17:26:07 -07:00
parent a65f48034c
commit 699acf9313
9 changed files with 250 additions and 123 deletions

View File

@ -57,7 +57,7 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
maxWidth: "80px"
}}>
<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`}
sx={{
alignSelf: "flex-start",

View File

@ -406,7 +406,7 @@ const VectorVisualizer: React.FC<VectorVisualizerProps> = (props: VectorVisualiz
};
};
if (!plotData) return (
if (!result) return (
<Box sx={{ display: 'flex', flexGrow: 1, justifyContent: 'center', alignItems: 'center' }}>
<div>Loading visualization...</div>
</Box>

View File

@ -45,7 +45,7 @@ const emptyUser: CandidateAI = {
languages: [],
certifications: [],
isAdmin: false,
hasProfile: false,
profileImage: undefined,
ragContentSize: 0
};
@ -381,7 +381,7 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
}}>
<Box sx={{ display: "flex", position: "relative", width: "min-content", height: "min-content" }}>
<Avatar
src={user?.hasProfile ? `/api/u/${user.username}/profile` : ''}
src={user?.profileImage ? `/api/1.0/candidates/profile/${user.username}` : ''}
alt={`${user?.fullName}'s profile`}
sx={{
width: 80,
@ -392,7 +392,7 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
{processing && <Pulse sx={{ position: "relative", left: "-80px", top: "0px", mr: "-80px" }} timestamp={timestamp} />}
</Box>
<Tooltip title={`${user?.hasProfile ? 'Re-' : ''}Generate Picture`}>
<Tooltip title={`${user?.profileImage ? 'Re-' : ''}Generate Picture`}>
<span style={{ display: "flex", flexGrow: 1 }}>
<Button
sx={{ m: 1, gap: 1, justifySelf: "flex-start", alignSelf: "center", flexGrow: 0, maxHeight: "min-content" }}
@ -401,7 +401,7 @@ const GenerateCandidate = (props: BackstoryElementProps) => {
processing || !canGenImage
}
onClick={() => { setShouldGenerateProfile(true); }}>
{user?.hasProfile ? 'Re-' : ''}Generate Picture<SendIcon />
{user?.profileImage ? 'Re-' : ''}Generate Picture<SendIcon />
</Button>
</span>
</Tooltip>

View File

@ -204,15 +204,12 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
};
// Handle profile image upload
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setProfileImage(event.target.result.toString());
}
};
reader.readAsDataURL(e.target.files[0]);
if (await apiClient.uploadCandidateProfile(e.target.files[0])) {
candidate.profileImage = e.target.files[0].name;
updateUserData(candidate);
}
}
};
@ -301,7 +298,7 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
<Box sx={{ textAlign: 'center', mb: { xs: 1, sm: 2 } }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Avatar
src={profileImage || candidate.profileImage || ''}
src={profileImage ? `/api/1.0/candidates/profile/${candidate.username}` : ''}
sx={{
width: { xs: 80, sm: 120 },
height: { xs: 80, sm: 120 },
@ -309,7 +306,7 @@ const CandidateProfilePage: React.FC<BackstoryPageProps> = (props: BackstoryPage
border: `2px solid ${theme.palette.primary.main}`
}}
>
{!profileImage && !candidate.profileImage && <AccountCircle sx={{ fontSize: { xs: 50, sm: 80 } }} />}
{!profileImage && <AccountCircle sx={{ fontSize: { xs: 50, sm: 80 } }} />}
</Avatar>
{editMode.basic && (
<>

View File

@ -528,6 +528,25 @@ class ApiClient {
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>> {
const paginatedRequest = createPaginatedRequest(request);
const params = toUrlParams(formatApiRequest(paginatedRequest));
@ -780,7 +799,6 @@ class ApiClient {
*/
async uploadCandidateDocument(file: File, includeInRag: boolean = true): Promise<Types.Document> {
const formData = new FormData()
console.log(file);
formData.append('file', file);
formData.append('filename', file.name);
formData.append('include_in_rag', includeInRag.toString());

View File

@ -1,6 +1,6 @@
// Generated TypeScript types from Pydantic models
// Source: src/backend/models.py
// Generated on: 2025-06-02T23:04:30.814624
// Generated on: 2025-06-02T23:24:36.213957
// DO NOT EDIT MANUALLY - This file is auto-generated
// ============================
@ -196,7 +196,6 @@ export interface Candidate {
languages?: Array<Language>;
certifications?: Array<Certification>;
jobApplications?: Array<JobApplication>;
hasProfile: boolean;
rags?: Array<RagEntry>;
ragContentSize: number;
}
@ -230,7 +229,6 @@ export interface CandidateAI {
languages?: Array<Language>;
certifications?: Array<Certification>;
jobApplications?: Array<any>;
hasProfile: boolean;
rags?: Array<RagEntry>;
ragContentSize: number;
isAI: boolean;

View File

@ -107,82 +107,6 @@ class CandidateEntity(Candidate):
raise ValueError("initialize() has not been called.")
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):
if self.CandidateEntity__initialized:
# 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)
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(rag_content_dir, exist_ok=True)

View File

@ -406,14 +406,6 @@ def get_candidate_files_dir(username: str) -> pathlib.Path:
files_dir.mkdir(parents=True, exist_ok=True)
return files_dir
def get_document_file_path(username: str, document_id: str, filename: str) -> pathlib.Path:
"""Get the full file path for a document"""
files_dir = get_candidate_files_dir(username)
# Use document ID + original extension to avoid filename conflicts
file_extension = pathlib.Path(filename).suffix
safe_filename = f"{document_id}{file_extension}"
return files_dir / safe_filename
def get_document_type_from_filename(filename: str) -> DocumentType:
"""Determine document type from filename extension"""
extension = pathlib.Path(filename).suffix.lower()
@ -1501,7 +1493,23 @@ async def upload_candidate_document(
)
logger.info(f"📁 Received file upload: filename='{file.filename}', content_type='{file.content_type}', size estimate='{file.size if hasattr(file, 'size') else 'unknown'}'")
# Ensure the file does not already exist either in 'files' or in 'rag-content'
file_path = os.path.join(defines.user_dir, candidate.username, "rag-content", file.filename)
if os.path.exists(file_path):
logger.warning(f"⚠️ File already exists: {file_path}")
return JSONResponse(
status_code=400,
content=create_error_response("FILE_EXISTS", "File with this name already exists")
)
file_path = os.path.join(defines.user_dir, candidate.username, "files", file.filename)
if os.path.exists(file_path):
logger.warning(f"⚠️ File already exists: {file_path}")
return JSONResponse(
status_code=400,
content=create_error_response("FILE_EXISTS", "File with this name already exists")
)
# Validate file type
allowed_types = ['.txt', '.md', '.docx', '.pdf', '.png', '.jpg', '.jpeg', '.gif']
file_extension = pathlib.Path(file.filename).suffix.lower() if file.filename else ""
@ -1542,7 +1550,7 @@ async def upload_candidate_document(
)
# Save file to disk
file_path = os.path.join(defines.user_dir, candidate.username, "files", file.filename)
file_path = os.path.join(defines.user_dir, candidate.username, "rag-content" if include_in_rag else "files", file.filename)
try:
with open(file_path, "wb") as f:
@ -1589,6 +1597,140 @@ async def upload_candidate_document(
content=create_error_response("UPLOAD_ERROR", "Failed to upload document")
)
@api_router.post("/candidates/profile/upload")
async def upload_candidate_profile(
file: UploadFile = File(...),
current_user = Depends(get_current_user),
database: RedisDatabase = Depends(get_database)
):
"""Upload a document for the current candidate"""
try:
# Verify user is a candidate
if current_user.user_type != "candidate":
logger.warning(f"⚠️ Unauthorized upload attempt by user type: {current_user.user_type}")
return JSONResponse(
status_code=403,
content=create_error_response("FORBIDDEN", "Only candidates can upload their profile")
)
candidate: Candidate = current_user
# Validate file type
allowed_types = ['.png', '.jpg', '.jpeg', '.gif']
file_extension = pathlib.Path(file.filename).suffix.lower() if file.filename else ""
if file_extension not in allowed_types:
logger.warning(f"⚠️ Invalid file type: {file_extension} for file {file.filename}")
return JSONResponse(
status_code=400,
content=create_error_response(
"INVALID_FILE_TYPE",
f"File type {file_extension} not supported. Allowed types: {', '.join(allowed_types)}"
)
)
# Check file size (limit to 2MB)
max_size = 2 * 1024 * 1024 # 2MB
file_content = await file.read()
if len(file_content) > max_size:
logger.info(f"⚠️ File too large: {file.filename} ({len(file_content)} bytes)")
return JSONResponse(
status_code=400,
content=create_error_response("FILE_TOO_LARGE", "File size exceeds 10MB limit")
)
# Save file to disk as "profile.<extension>"
_, extension = os.path.splitext(file.filename)
file_path = os.path.join(defines.user_dir, candidate.username, f"profile{extension}")
try:
with open(file_path, "wb") as f:
f.write(file_content)
logger.info(f"📁 File saved to disk: {file_path}")
except Exception as e:
logger.error(f"❌ Failed to save file to disk: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("FILE_SAVE_ERROR", "Failed to save file to disk")
)
updates = {
"updated_at": datetime.now(UTC).isoformat(),
"profile_image": f"profile{extension}"
}
candidate_dict = candidate.model_dump()
candidate_dict.update(updates)
updated_candidate = Candidate.model_validate(candidate_dict)
await database.set_candidate(candidate.id, updated_candidate.model_dump())
logger.info(f"📄 Profile image uploaded: {updated_candidate.profile_image} for candidate {candidate.id}")
return create_success_response(True)
except Exception as e:
logger.error(traceback.format_exc())
logger.error(f"❌ Document upload error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("UPLOAD_ERROR", "Failed to upload document")
)
@api_router.get("/candidates/profile/{username}")
async def get_candidate_profile_image(
username: str = Path(..., description="Username of the candidate"),
# current_user = Depends(get_current_user),
database: RedisDatabase = Depends(get_database)
):
"""Get profile image of a candidate by username"""
logger.info(f"🔍 Fetching profile image for candidate: {username}")
try:
all_candidates_data = await database.get_all_candidates()
candidates_list = [Candidate.model_validate(data) for data in all_candidates_data.values()]
# Normalize username to lowercase for case-insensitive search
query_lower = username.lower()
# Filter by search query
candidates_list = [
c for c in candidates_list
if (query_lower == c.email.lower() or
query_lower == c.username.lower())
]
if not len(candidates_list):
return JSONResponse(
status_code=404,
content=create_error_response("NOT_FOUND", "Candidate not found")
)
candidate = Candidate.model_validate(candidates_list[0])
if not candidate.profile_image:
logger.warning(f"⚠️ Candidate {candidate.username} has no profile image set")
return JSONResponse(
status_code=404,
content=create_error_response("NOT_FOUND", "Profile image not found")
)
file_path = os.path.join(defines.user_dir, candidate.username, candidate.profile_image)
file_path = pathlib.Path(file_path)
if not file_path.exists():
logger.error(f"❌ Profile image file not found on disk: {file_path}")
return JSONResponse(
status_code=404,
content=create_error_response("FILE_NOT_FOUND", "Profile image file not found on disk")
)
return FileResponse(
file_path,
media_type=f"image/{file_path.suffix[1:]}", # Get extension without dot
filename=candidate.profile_image
)
except Exception as e:
logger.error(traceback.format_exc())
logger.error(f"❌ Get candidate profile image failed: {str(e)}")
return JSONResponse(
status_code=500,
content=create_error_response("FETCH_ERROR", "Failed to retrieve profile image")
)
@api_router.get("/candidates/documents")
async def get_candidate_documents(
current_user = Depends(get_current_user),
@ -1662,7 +1804,7 @@ async def get_document_content(
content=create_error_response("FORBIDDEN", "Cannot access another candidate's document")
)
file_path = os.path.join(defines.user_dir, candidate.username, "files", document.originalName)
file_path = os.path.join(defines.user_dir, candidate.username, "rag-content" if document.include_in_RAG else "files", document.originalName)
file_path = pathlib.Path(file_path)
if not document.type in [DocumentType.TXT, DocumentType.MARKDOWN]:
file_path = file_path.with_suffix('.md')
@ -1737,6 +1879,39 @@ async def update_document(
content=create_error_response("FORBIDDEN", "Cannot update another candidate's document")
)
if document.include_in_RAG != updates.include_in_RAG:
# If RAG status is changing, we need to handle file movement
rag_dir = os.path.join(defines.user_dir, candidate.username, "rag-content")
file_dir = os.path.join(defines.user_dir, candidate.username, "files")
os.makedirs(rag_dir, exist_ok=True)
os.makedirs(file_dir, exist_ok=True)
rag_path = os.path.join(rag_dir, document.originalName)
file_path = os.path.join(file_dir, document.originalName)
if updates.include_in_RAG:
src = pathlib.Path(file_path)
dst = pathlib.Path(rag_path)
# Move to RAG directory
src.rename(dst)
logger.info(f"📁 Moved file to RAG directory")
if document.type != DocumentType.MARKDOWN and document.type != DocumentType.TXT:
src = pathlib.Path(file_path)
src_as_md = src.with_suffix(".md")
if src_as_md.exists():
dst = pathlib.Path(rag_path).with_suffix(".md")
src_as_md.rename(dst)
else:
src = pathlib.Path(rag_path)
dst = pathlib.Path(file_path)
# Move to regular files directory
src.rename(dst)
logger.info(f"📁 Moved file to regular files directory")
if document.type != DocumentType.MARKDOWN and document.type != DocumentType.TXT:
src_as_md = src.with_suffix(".md")
if src_as_md.exists():
dst = pathlib.Path(file_path).with_suffix(".md")
src_as_md.rename(dst)
# Apply updates
update_dict = {}
if updates.filename is not None:
@ -1812,7 +1987,8 @@ async def delete_document(
)
# Delete file from disk
file_path = get_document_file_path(candidate.username, document_id, document.originalName)
file_path = os.path.join(defines.user_dir, candidate.username, "rag-content" if document.include_in_RAG else "files", document.originalName)
file_path = pathlib.Path(file_path)
try:
if file_path.exists():
@ -1820,6 +1996,14 @@ async def delete_document(
logger.info(f"🗑️ File deleted from disk: {file_path}")
else:
logger.warning(f"⚠️ File not found on disk during deletion: {file_path}")
# Delete side-car file if it exists
if document.type != DocumentType.MARKDOWN and document.type != DocumentType.TXT:
p = pathlib.Path(file_path)
p_as_md = p.with_suffix(".md")
if p_as_md.exists():
p_as_md.unlink()
except Exception as e:
logger.error(f"❌ Failed to delete file from disk: {e}")
# Continue with metadata deletion even if file deletion fails
@ -1940,21 +2124,29 @@ async def post_candidate_vectors(
async with entities.get_candidate_entity(candidate=candidate) as candidate_entity:
collection = candidate_entity.umap_collection
if not collection:
logger.error(f"❌ Candidate collection not found")
return JSONResponse(
status_code=404,
content=create_error_response("NOT_FOUND", "Candidate collection not found")
)
results = {
"ids": [],
"metadatas": [],
"documents": [],
"embeddings": [],
"size": 0
}
return create_success_response(results)
if dimensions == 2:
umap_embedding = candidate_entity.file_watcher.umap_embedding_2d
else:
umap_embedding = candidate_entity.file_watcher.umap_embedding_3d
if len(umap_embedding) == 0:
return JSONResponse(
status_code=404,
content=create_error_response("NOT_FOUND", "Candidate collection embedding not found")
)
results = {
"ids": [],
"metadatas": [],
"documents": [],
"embeddings": [],
"size": 0
}
return create_success_response(results)
result = {
"ids": collection.get("ids", []),
"metadatas": collection.get("metadatas", []),
@ -2646,7 +2838,7 @@ async def post_chat_session_message_stream(
chat_session_data["lastActivity"] = datetime.now(UTC).isoformat()
await database.set_chat_session(user_message.session_id, chat_session_data)
return stream_agent_response(
return await stream_agent_response(
chat_agent=chat_agent,
user_message=user_message,
candidate=candidate,
@ -2656,10 +2848,10 @@ async def post_chat_session_message_stream(
except Exception as e:
logger.error(traceback.format_exc())
logger.error(f"❌ Chat message streaming error: {e}")
logger.error(f"❌ Chat message streaming error")
return JSONResponse(
status_code=500,
content=create_error_response("STREAMING_ERROR", str(e))
content=create_error_response("STREAMING_ERROR", "")
)
@api_router.get("/chat/sessions/{session_id}/messages")
@ -3167,6 +3359,7 @@ async def log_requests(request: Request, call_next):
logger.warning(f"⚠️ Response {request.method} {response.status_code}: Path: {request.url.path}")
return response
except Exception as e:
logger.error(traceback.format_exc())
logger.error(f"❌ Error processing request: {str(e)}, Path: {request.url.path}, Method: {request.method}")
return JSONResponse(status_code=400, content={"detail": "Invalid HTTP request"})

View File

@ -537,7 +537,6 @@ class Candidate(BaseUser):
languages: Optional[List[Language]] = None
certifications: Optional[List[Certification]] = None
job_applications: Optional[List["JobApplication"]] = Field(None, alias="jobApplications")
has_profile: bool = Field(default=False, alias="hasProfile")
rags: List[RagEntry] = Field(default_factory=list)
rag_content_size : int = 0