From b5b3a1f5dc7464cdeb41883093c3d0e78b34f037 Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Wed, 28 May 2025 19:09:02 -0700 Subject: [PATCH] Working on model conversion --- frontend/src/BackstoryApp.tsx | 66 +-- frontend/src/PhoneInput.css | 57 -- frontend/src/TestApp.tsx | 540 ------------------ frontend/src/components/Conversation.tsx | 6 +- .../src/components/layout/BackstoryLayout.tsx | 17 +- .../src/components/layout/BackstoryRoutes.tsx | 2 +- frontend/src/components/layout/Header.tsx | 24 +- frontend/src/hooks/useUser.tsx | 114 +++- frontend/src/pages/ChatPage.tsx | 9 +- frontend/src/pages/ControlsPage.tsx | 15 +- frontend/src/pages/FindCandidatePage.tsx | 37 +- frontend/src/pages/GenerateCandidate.tsx | 58 +- frontend/src/routes/CandidateRoute.tsx | 8 +- frontend/src/types/api-client.ts | 25 +- frontend/src/types/types.ts | 452 +++++++-------- src/backend/focused_test.py | 5 +- src/backend/generate_types.py | 197 ++++++- src/backend/main.py | 287 +++++++++- src/backend/models.py | 25 +- src/focused_test.py | 207 ------- update-types.sh | 2 + 21 files changed, 925 insertions(+), 1228 deletions(-) delete mode 100644 frontend/src/PhoneInput.css delete mode 100644 frontend/src/TestApp.tsx delete mode 100644 src/focused_test.py create mode 100755 update-types.sh diff --git a/frontend/src/BackstoryApp.tsx b/frontend/src/BackstoryApp.tsx index ec7e61c..4152b47 100644 --- a/frontend/src/BackstoryApp.tsx +++ b/frontend/src/BackstoryApp.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState, useRef, useCallback } from 'react'; import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { ThemeProvider } from '@mui/material/styles'; - +import { Box } from '@mui/material'; import { backstoryTheme } from './BackstoryTheme'; import { SeverityType } from 'components/Snack'; @@ -17,13 +17,7 @@ import '@fontsource/roboto/400.css'; import '@fontsource/roboto/500.css'; import '@fontsource/roboto/700.css'; -import { debugConversion } from 'types/conversion'; -import { User, Guest, Candidate } from 'types/types'; - const BackstoryApp = () => { - const [user, setUser] = useState(null); - const [guest, setGuest] = useState(null); - const [candidate, setCandidate] = useState(null); const navigate = useNavigate(); const location = useLocation(); const snackRef = useRef(null); @@ -38,51 +32,6 @@ const BackstoryApp = () => { }; const [page, setPage] = useState(""); - const createGuestSession = () => { - console.log("TODO: Convert this to query the server for the session instead of generating it."); - const sessionId = `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - const guest: Guest = { - sessionId, - createdAt: new Date(), - lastActivity: new Date(), - ipAddress: 'unknown', - userAgent: navigator.userAgent - }; - setGuest(guest); - debugConversion(guest, 'Guest Session'); - }; - - const checkExistingAuth = () => { - const token = localStorage.getItem('accessToken'); - const userData = localStorage.getItem('userData'); - if (token && userData) { - try { - const user = JSON.parse(userData); - // Convert dates back to Date objects if they're stored as strings - if (user.createdAt && typeof user.createdAt === 'string') { - user.createdAt = new Date(user.createdAt); - } - if (user.updatedAt && typeof user.updatedAt === 'string') { - user.updatedAt = new Date(user.updatedAt); - } - if (user.lastLogin && typeof user.lastLogin === 'string') { - user.lastLogin = new Date(user.lastLogin); - } - setUser(user); - } catch (e) { - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem('userData'); - } - } - }; - - // Create guest session on component mount - useEffect(() => { - createGuestSession(); - checkExistingAuth(); - }, []); - useEffect(() => { const currentRoute = location.pathname.split("/")[1] ? `/${location.pathname.split("/")[1]}` : "/"; setPage(currentRoute); @@ -91,27 +40,20 @@ const BackstoryApp = () => { // Render appropriate routes based on user type return ( - + - } /> + } /> {/* Static/shared routes */} + } /> ); - }; export { diff --git a/frontend/src/PhoneInput.css b/frontend/src/PhoneInput.css deleted file mode 100644 index adb4cce..0000000 --- a/frontend/src/PhoneInput.css +++ /dev/null @@ -1,57 +0,0 @@ - -.PhoneInput:disabled { - opacity: 0.38; -} - -/* .PhoneInput:not(:active):not(:focus):not(:hover) { -} */ - -.PhoneInput::placeholder { - color: rgba(46, 46, 46, 0.38); -} - -.PhoneInput:focus, -.PhoneInput:active { - outline: 2px solid black; -} - -.PhoneInput:hover:not(:active):not(:focus) { - outline: 1px solid black; -} - -.PhoneInputInput { - font: inherit; - letter-spacing: inherit; - color: currentColor; - padding: 4px 0 5px; - border: 0; - box-sizing: content-box; - background: none; - height: 1.4375em; - margin: 0; - -webkit-tap-highlight-color: transparent; - display: block; - min-width: 0; - width: 100%; - -webkit-animation-name: mui-auto-fill-cancel; - animation-name: mui-auto-fill-cancel; - -webkit-animation-duration: 10ms; - animation-duration: 10ms; - padding: 16.5px 14px; - } - -.PhoneInputCountry { - min-width: 64px; - justify-content: center; -} - -.PhoneInputCountry:focus, -.PhoneInputCountry:active { - outline: 2px solid black; -} - -.PhoneInput { - display: flex; - outline: 1px solid rgba(46, 46, 46, 0.38); - border: none; -} \ No newline at end of file diff --git a/frontend/src/TestApp.tsx b/frontend/src/TestApp.tsx deleted file mode 100644 index ed15255..0000000 --- a/frontend/src/TestApp.tsx +++ /dev/null @@ -1,540 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - Box, - Container, - Paper, - TextField, - Button, - Typography, - Grid, - Alert, - CircularProgress, - Tabs, - Tab, - AppBar, - Toolbar, - Card, - CardContent, - Divider, - Avatar -} from '@mui/material'; -import { Person, PersonAdd, AccountCircle, ExitToApp } from '@mui/icons-material'; -import 'react-phone-number-input/style.css'; -import PhoneInput from 'react-phone-number-input'; -import { E164Number } from 'libphonenumber-js/core'; -import './PhoneInput.css'; - -import { ApiClient } from 'types/api-client'; - -// Import conversion utilities -import { - formatApiRequest, - parseApiResponse, - handleApiResponse, - extractApiData, - isSuccessResponse, - debugConversion, - type ApiResponse -} from './types/conversion'; - -import { - AuthResponse, User, Guest, Candidate -} from './types/types' - -interface LoginRequest { - login: string; - password: string; -} - -interface RegisterRequest { - username: string; - email: string; - firstName: string; - lastName: string; - password: string; - phone?: string; -} - -const BackstoryTestApp: React.FC = () => { - const apiClient = new ApiClient(); - const [currentUser, setCurrentUser] = useState(null); - const [guestSession, setGuestSession] = useState(null); - const [tabValue, setTabValue] = useState(0); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); - const [phone, setPhone] = useState(null); - const name = (currentUser?.userType === 'candidate' ? (currentUser as Candidate).username : currentUser?.email) || ''; - - // Login form state - const [loginForm, setLoginForm] = useState({ - login: '', - password: '' - }); - - // Register form state - const [registerForm, setRegisterForm] = useState({ - username: '', - email: '', - firstName: '', - lastName: '', - password: '', - phone: '' - }); - - // Create guest session on component mount - useEffect(() => { - createGuestSession(); - checkExistingAuth(); - }, []); - - useEffect(() => { - if (phone !== registerForm.phone && phone) { - console.log({ phone }); - setRegisterForm({ ...registerForm, phone }); - } - }, [phone, registerForm]); - - const createGuestSession = () => { - const sessionId = `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - const guest: Guest = { - sessionId, - createdAt: new Date(), - lastActivity: new Date(), - ipAddress: 'unknown', - userAgent: navigator.userAgent - }; - setGuestSession(guest); - debugConversion(guest, 'Guest Session'); - }; - - const checkExistingAuth = () => { - const token = localStorage.getItem('accessToken'); - const userData = localStorage.getItem('userData'); - if (token && userData) { - try { - const user = JSON.parse(userData); - // Convert dates back to Date objects if they're stored as strings - if (user.createdAt && typeof user.createdAt === 'string') { - user.createdAt = new Date(user.createdAt); - } - if (user.updatedAt && typeof user.updatedAt === 'string') { - user.updatedAt = new Date(user.updatedAt); - } - if (user.lastLogin && typeof user.lastLogin === 'string') { - user.lastLogin = new Date(user.lastLogin); - } - setCurrentUser(user); - } catch (e) { - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem('userData'); - } - } - }; - - const handleLogin = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - setError(null); - setSuccess(null); - - try { - const authResponse = await apiClient.login(loginForm.login, loginForm.password) - - debugConversion(authResponse, 'Login Response'); - - // Store tokens in localStorage - localStorage.setItem('accessToken', authResponse.accessToken); - localStorage.setItem('refreshToken', authResponse.refreshToken); - localStorage.setItem('userData', JSON.stringify(authResponse.user)); - - setCurrentUser(authResponse.user); - setSuccess('Login successful!'); - - // Clear form - setLoginForm({ login: '', password: '' }); - - } catch (err) { - console.error('Login error:', err); - setError(err instanceof Error ? err.message : 'Login failed'); - } finally { - setLoading(false); - } - }; - - const handleRegister = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - setError(null); - setSuccess(null); - - try { - const candidate: Candidate = { - username: registerForm.username, - email: registerForm.email, - firstName: registerForm.firstName, - lastName: registerForm.lastName, - fullName: `${registerForm.firstName} ${registerForm.lastName}`, - phone: registerForm.phone || undefined, - userType: 'candidate', - status: 'active', - createdAt: new Date(), - updatedAt: new Date(), - skills: [], - experience: [], - education: [], - preferredJobTypes: [], - languages: [], - certifications: [], - location: { - city: '', - country: '', - remote: true - } - }; - - const result = await apiClient.createCandidate(candidate); - - debugConversion(result, 'Registration Response'); - - setSuccess('Registration successful! You can now login.'); - - // Clear form and switch to login tab - setRegisterForm({ - username: '', - email: '', - firstName: '', - lastName: '', - password: '', - phone: '' - }); - setTabValue(0); - - } catch (err) { - console.error('Registration error:', err); - setError(err instanceof Error ? err.message : 'Registration failed'); - } finally { - setLoading(false); - } - }; - - const handleLogout = () => { - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem('userData'); - setCurrentUser(null); - setSuccess('Logged out successfully'); - createGuestSession(); - }; - - const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { - setTabValue(newValue); - setError(null); - setSuccess(null); - }; - - // API helper function for authenticated requests - const makeAuthenticatedRequest = async (url: string, options: RequestInit = {}) => { - const token = localStorage.getItem('accessToken'); - - const headers = { - 'Content-Type': 'application/json', - ...(token && { 'Authorization': `Bearer ${token}` }), - ...options.headers, - }; - - const response = await fetch(url, { - ...options, - headers, - }); - - return handleApiResponse(response); - }; - - // If user is logged in, show their profile - if (currentUser) { - return ( - - - - - - Welcome, {name} - - - - - - - - - - - - - - User Profile - - - - - - - - - Username: {name} - - - - - Email: {currentUser.email} - - - - - Status: {currentUser.status} - - - - - Phone: {currentUser.phone || 'Not provided'} - - - - - Last Login: { - currentUser.lastLogin - ? currentUser.lastLogin.toLocaleString() - : 'N/A' - } - - - - - Member Since: {currentUser.createdAt.toLocaleDateString()} - - - - - - - - ); - } - - const validateInput = (value: string) => { - if (!value) return 'This field is required'; - - // Username: alphanumeric, 3-20 characters, no @ - const usernameRegex = /^[a-zA-Z0-9]{3,20}$/; - // Email: basic email format - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - - if (usernameRegex.test(value)) return ''; - if (emailRegex.test(value)) return ''; - return 'Enter a valid username (3-20 alphanumeric characters) or email'; - }; - - const handleLoginChange = (event: React.ChangeEvent) => { - const { value } = event.target; - setLoginForm({ ...loginForm, login: value }); - setError(validateInput(value)); - }; - - return ( - - - - Backstory Platform - - - {guestSession && ( - - - - Guest Session Active - - - Session ID: {guestSession.sessionId} - - - Created: {guestSession.createdAt.toLocaleString()} - - - - )} - - - - } label="Login" /> - } label="Register" /> - - - - {error && ( - - {error} - - )} - - {success && ( - - {success} - - )} - - {tabValue === 0 && ( - - - Sign In - - - - - setLoginForm({ ...loginForm, password: e.target.value })} - margin="normal" - required - disabled={loading} - variant="outlined" - autoComplete='current-password' - /> - - - - )} - - {tabValue === 1 && ( - - - Create Account - - - - - setRegisterForm({ ...registerForm, firstName: e.target.value })} - required - disabled={loading} - variant="outlined" - /> - - - - setRegisterForm({ ...registerForm, lastName: e.target.value })} - required - disabled={loading} - variant="outlined" - /> - - - - setRegisterForm({ ...registerForm, username: e.target.value })} - margin="normal" - required - disabled={loading} - variant="outlined" - /> - - setRegisterForm({ ...registerForm, email: e.target.value })} - margin="normal" - required - disabled={loading} - variant="outlined" - /> - - setPhone(v as E164Number)} /> - {/* setRegisterForm({ ...registerForm, phone: e.target.value })} - margin="normal" - disabled={loading} - variant="outlined" - /> */} - - setRegisterForm({ ...registerForm, password: e.target.value })} - margin="normal" - required - disabled={loading} - variant="outlined" - /> - - - - )} - - - ); -}; - -export { BackstoryTestApp }; \ No newline at end of file diff --git a/frontend/src/components/Conversation.tsx b/frontend/src/components/Conversation.tsx index 0a2f604..b89a8b9 100644 --- a/frontend/src/components/Conversation.tsx +++ b/frontend/src/components/Conversation.tsx @@ -69,8 +69,7 @@ const Conversation = forwardRef((props: C sx, type, } = props; - const apiClient = new ApiClient(); - const { candidate } = useUser() + const { candidate, apiClient } = useUser() const [processing, setProcessing] = useState(false); const [countdown, setCountdown] = useState(0); const [conversation, setConversation] = useState([]); @@ -127,9 +126,8 @@ const Conversation = forwardRef((props: C try { const aiParameters: AIParameters = { name: '', - model: 'custom', + model: 'qwen2.5', temperature: 0.7, - maxTokens: -1, topP: 1, frequencyPenalty: 0, presencePenalty: 0, diff --git a/frontend/src/components/layout/BackstoryLayout.tsx b/frontend/src/components/layout/BackstoryLayout.tsx index f0d5567..10f4b0b 100644 --- a/frontend/src/components/layout/BackstoryLayout.tsx +++ b/frontend/src/components/layout/BackstoryLayout.tsx @@ -38,17 +38,17 @@ const DefaultNavItems: NavigationLinkType[] = [ const CandidateNavItems : NavigationLinkType[]= [ { name: 'Chat', path: '/chat', icon: }, - { name: 'Job Analysis', path: '/job-analysis', icon: }, + // { name: 'Job Analysis', path: '/job-analysis', icon: }, { name: 'Resume Builder', path: '/resume-builder', icon: }, - { name: 'Knowledge Explorer', path: '/knowledge-explorer', icon: }, + // { name: 'Knowledge Explorer', path: '/knowledge-explorer', icon: }, { name: 'Find a Candidate', path: '/find-a-candidate', icon: }, // { name: 'Dashboard', icon: , path: '/dashboard' }, // { name: 'Profile', icon: , path: '/profile' }, // { name: 'Backstory', icon: , path: '/backstory' }, - { name: 'Resumes', icon: , path: '/resumes' }, + // { name: 'Resumes', icon: , path: '/resumes' }, // { name: 'Q&A Setup', icon: , path: '/qa-setup' }, - { name: 'Analytics', icon: , path: '/analytics' }, - { name: 'Settings', icon: , path: '/settings' }, + // { name: 'Analytics', icon: , path: '/analytics' }, + // { name: 'Settings', icon: , path: '/settings' }, ]; const EmployerNavItems: NavigationLinkType[] = [ @@ -121,13 +121,16 @@ const BackstoryPageContainer = (props : BackstoryPageContainerProps) => { ); } -const BackstoryLayout: React.FC<{ +interface BackstoryLayoutProps { setSnack: SetSnackType; page: string; chatRef: React.Ref; snackRef: React.Ref; submitQuery: any; -}> = ({ setSnack, page, chatRef, snackRef, submitQuery }) => { +}; + +const BackstoryLayout: React.FC = (props: BackstoryLayoutProps) => { + const { setSnack, page, chatRef, snackRef, submitQuery } = props; const navigate = useNavigate(); const location = useLocation(); const { user, guest, candidate } = useUser(); diff --git a/frontend/src/components/layout/BackstoryRoutes.tsx b/frontend/src/components/layout/BackstoryRoutes.tsx index 5979285..0a43793 100644 --- a/frontend/src/components/layout/BackstoryRoutes.tsx +++ b/frontend/src/components/layout/BackstoryRoutes.tsx @@ -17,6 +17,7 @@ import { CandidateListingPage } from 'pages/FindCandidatePage'; import { JobAnalysisPage } from 'pages/JobAnalysisPage'; import { GenerateCandidate } from "pages/GenerateCandidate"; import { ControlsPage } from 'pages/ControlsPage'; +import { LoginPage } from "pages/LoginPage"; const ProfilePage = () => (Profile); const BackstoryPage = () => (Backstory); @@ -27,7 +28,6 @@ const SavedPage = () => (Saved (Jobs); const CompanyPage = () => (Company); const LogoutPage = () => (Logout page...); -const LoginPage = () => (Login page...); // const DashboardPage = () => (Dashboard); // const AnalyticsPage = () => (Analytics); // const SettingsPage = () => (Settings); diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index bf29256..a9c8520 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -87,7 +87,6 @@ const MobileDrawer = styled(Drawer)(({ theme }) => ({ interface HeaderProps { transparent?: boolean; - onLogout?: () => void; className?: string; navigate: NavigateFunction; navigationLinks: NavigationLinkType[]; @@ -98,7 +97,7 @@ interface HeaderProps { } const Header: React.FC = (props: HeaderProps) => { - const { user } = useUser(); + const { user, setUser } = useUser(); const candidate: Candidate | null = (user && user.userType === "candidate") ? user as Candidate : null; const employer: Employer | null = (user && user.userType === "employer") ? user as Employer : null; const { @@ -108,7 +107,6 @@ const Header: React.FC = (props: HeaderProps) => { navigationLinks, showLogin, sessionId, - onLogout, setSnack, } = props; const theme = useTheme(); @@ -177,9 +175,7 @@ const Header: React.FC = (props: HeaderProps) => { const handleLogout = () => { handleUserMenuClose(); - if (onLogout) { - onLogout(); - } + setUser(null); }; const handleDrawerToggle = () => { @@ -245,14 +241,6 @@ const Header: React.FC = (props: HeaderProps) => { > Login - )} @@ -279,14 +267,6 @@ const Header: React.FC = (props: HeaderProps) => { > Login - ); } diff --git a/frontend/src/hooks/useUser.tsx b/frontend/src/hooks/useUser.tsx index 62e1eca..3931589 100644 --- a/frontend/src/hooks/useUser.tsx +++ b/frontend/src/hooks/useUser.tsx @@ -1,11 +1,16 @@ import React, { createContext, useContext, useEffect, useState } from "react"; import { SetSnackType } from '../components/Snack'; import { User, Guest, Candidate } from 'types/types'; +import { ApiClient } from "types/api-client"; +import { debugConversion } from "types/conversion"; type UserContextType = { + apiClient: ApiClient; user: User | null; guest: Guest; candidate: Candidate | null; + setUser: (user: User | null) => void; + setCandidate: (candidate: Candidate | null) => void; }; const UserContext = createContext(undefined); @@ -18,19 +23,118 @@ const useUser = () => { interface UserProviderProps { children: React.ReactNode; - candidate: Candidate | null; - user: User | null; - guest: Guest | null; setSnack: SetSnackType; }; + const UserProvider: React.FC = (props: UserProviderProps) => { - const { guest, user, children, candidate, setSnack } = props; + const { children, setSnack } = props; + const [apiClient, setApiClient] = useState(new ApiClient()); + const [candidate, setCandidate] = useState(null); + const [guest, setGuest] = useState(null); + const [user, setUser] = useState(null); + const [activeUser, setActiveUser] = useState(null); + + useEffect(() => { + console.log("Candidate =>", candidate); + }, [candidate]); + + useEffect(() => { + console.log("Guest =>", guest); + }, [guest]); + + useEffect(() => { + console.log("User => ", user); + }, [user]); + + /* Handle logout if any consumers of UserProvider setUser to NULL */ + useEffect(() => { + /* If there is an active user and it is the same as the + * new user, do nothing */ + if (activeUser && activeUser.email === user?.email) { + return; + } + + const logout = async () => { + if (!user) { + return; + } + console.log(`Logging out ${user.email}`); + try { + const results = await apiClient.logout(); + if (results.error) { + console.error(results.error); + setSnack(results.error.message, "error") + } + } catch (e) { + console.error(e); + setSnack(`Unable to logout: ${e}`, "error") + } + + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('userData'); + createGuestSession(); + setUser(null); + }; + + setActiveUser(user); + if (!user) { + logout(); + } + }, [user, apiClient, activeUser]); + + const createGuestSession = () => { + console.log("TODO: Convert this to query the server for the session instead of generating it."); + const sessionId = `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const guest: Guest = { + sessionId, + createdAt: new Date(), + lastActivity: new Date(), + ipAddress: 'unknown', + userAgent: navigator.userAgent + }; + setGuest(guest); + debugConversion(guest, 'Guest Session'); + }; + + const checkExistingAuth = () => { + const token = localStorage.getItem('accessToken'); + const userData = localStorage.getItem('userData'); + if (token && userData) { + try { + const user = JSON.parse(userData); + // Convert dates back to Date objects if they're stored as strings + if (user.createdAt && typeof user.createdAt === 'string') { + user.createdAt = new Date(user.createdAt); + } + if (user.updatedAt && typeof user.updatedAt === 'string') { + user.updatedAt = new Date(user.updatedAt); + } + if (user.lastLogin && typeof user.lastLogin === 'string') { + user.lastLogin = new Date(user.lastLogin); + } + setApiClient(new ApiClient(token)); + setUser(user); + } catch (e) { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('userData'); + } + } + }; + + // Create guest session on component mount + useEffect(() => { + createGuestSession(); + checkExistingAuth(); + }, []); if (guest === null) { return <>; } + return ( - + {children} ); diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index 53f89d5..27645f7 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -9,16 +9,15 @@ import { Conversation, ConversationHandle } from '../components/Conversation'; import { ChatQuery } from '../components/ChatQuery'; import { CandidateInfo } from 'components/CandidateInfo'; import { useUser } from "../hooks/useUser"; -import { Candidate } from "../types/types"; const ChatPage = forwardRef((props: BackstoryPageProps, ref) => { const { setSnack, submitQuery } = props; + const { candidate } = useUser(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); const [questions, setQuestions] = useState([]); - const { user } = useUser(); - const candidate: Candidate | null = (user && user.userType === 'candidate') ? user as Candidate : null; + console.log("ChatPage candidate =>", candidate); useEffect(() => { if (!candidate) { return; @@ -38,8 +37,8 @@ const ChatPage = forwardRef((props: Back }, [candidate, isMobile, submitQuery]); if (!candidate) { - return (<>); - } + return (<>); + } return ( diff --git a/frontend/src/pages/ControlsPage.tsx b/frontend/src/pages/ControlsPage.tsx index d312e2d..ba64464 100644 --- a/frontend/src/pages/ControlsPage.tsx +++ b/frontend/src/pages/ControlsPage.tsx @@ -100,7 +100,7 @@ const ControlsPage = (props: BackstoryPageProps) => { } const sendSystemPrompt = async (prompt: string) => { try { - const response = await fetch(connectionBase + `/api/tunables`, { + const response = await fetch(connectionBase + `/api/1.0/tunables`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -126,7 +126,7 @@ const ControlsPage = (props: BackstoryPageProps) => { const reset = async (types: ("rags" | "tools" | "history" | "system_prompt")[], message: string = "Update successful.") => { try { - const response = await fetch(connectionBase + `/api/reset/`, { + const response = await fetch(connectionBase + `/api/1.0/reset/`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -178,7 +178,7 @@ const ControlsPage = (props: BackstoryPageProps) => { } const fetchSystemInfo = async () => { try { - const response = await fetch(connectionBase + `/api/system-info`, { + const response = await fetch(connectionBase + `/api/1.0/system-info`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -210,13 +210,16 @@ const ControlsPage = (props: BackstoryPageProps) => { }, [systemInfo, setSystemInfo, setSnack]) useEffect(() => { + if (!systemPrompt) { + return; + } setEditSystemPrompt(systemPrompt.trim()); }, [systemPrompt, setEditSystemPrompt]); const toggleRag = async (tool: Tool) => { tool.enabled = !tool.enabled try { - const response = await fetch(connectionBase + `/api/tunables`, { + const response = await fetch(connectionBase + `/api/1.0/tunables`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -238,7 +241,7 @@ const ControlsPage = (props: BackstoryPageProps) => { const toggleTool = async (tool: Tool) => { tool.enabled = !tool.enabled try { - const response = await fetch(connectionBase + `/api/tunables`, { + const response = await fetch(connectionBase + `/api/1.0/tunables`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -265,7 +268,7 @@ const ControlsPage = (props: BackstoryPageProps) => { const fetchTunables = async () => { try { // Make the fetch request with proper headers - const response = await fetch(connectionBase + `/api/tunables`, { + const response = await fetch(connectionBase + `/api/1.0/tunables`, { method: 'GET', headers: { 'Content-Type': 'application/json', diff --git a/frontend/src/pages/FindCandidatePage.tsx b/frontend/src/pages/FindCandidatePage.tsx index 1331dd2..ff2092e 100644 --- a/frontend/src/pages/FindCandidatePage.tsx +++ b/frontend/src/pages/FindCandidatePage.tsx @@ -5,11 +5,11 @@ import Box from '@mui/material/Box'; import { BackstoryPageProps } from '../components/BackstoryTab'; import { CandidateInfo } from 'components/CandidateInfo'; -import { connectionBase } from '../utils/Global'; import { Candidate } from "../types/types"; -import { ApiClient } from 'types/api-client'; +import { useUser } from 'hooks/useUser'; + const CandidateListingPage = (props: BackstoryPageProps) => { - const apiClient = new ApiClient(); + const { apiClient, setCandidate } = useUser(); const navigate = useNavigate(); const { setSnack } = props; const [candidates, setCandidates] = useState(null); @@ -44,27 +44,24 @@ const CandidateListingPage = (props: BackstoryPageProps) => { return ( - - Not seeing a candidate you like? - - - + + Not seeing a candidate you like? + + + {candidates?.map((u, i) => ) : void => { - navigate(`/u/${u.username}`) - }} - sx={{ cursor: "pointer" }} - > + onClick={() => { setCandidate(u); navigate("/chat"); }} + sx={{ cursor: "pointer" }}> - )} - + )} + ); }; diff --git a/frontend/src/pages/GenerateCandidate.tsx b/frontend/src/pages/GenerateCandidate.tsx index 447f7c6..5518042 100644 --- a/frontend/src/pages/GenerateCandidate.tsx +++ b/frontend/src/pages/GenerateCandidate.tsx @@ -10,9 +10,7 @@ import SendIcon from '@mui/icons-material/Send'; import PropagateLoader from 'react-spinners/PropagateLoader'; import { jsonrepair } from 'jsonrepair'; - import { CandidateInfo } from '../components/CandidateInfo'; -import { Query } from '../types/types' import { Quote } from 'components/Quote'; import { Candidate } from '../types/types'; import { BackstoryElementProps } from 'components/BackstoryTab'; @@ -21,6 +19,8 @@ import { StyledMarkdown } from 'components/StyledMarkdown'; import { Scrollable } from '../components/Scrollable'; import { Pulse } from 'components/Pulse'; import { StreamingResponse } from 'types/api-client'; +import { ChatContext, ChatSession, AIParameters, Query } from 'types/types'; +import { useUser } from 'hooks/useUser'; const emptyUser: Candidate = { description: "[blank]", @@ -46,6 +46,7 @@ const emptyUser: Candidate = { }; const GenerateCandidate = (props: BackstoryElementProps) => { + const { apiClient } = useUser(); const { setSnack, submitQuery } = props; const [streaming, setStreaming] = useState(''); const [processing, setProcessing] = useState(false); @@ -57,16 +58,45 @@ const GenerateCandidate = (props: BackstoryElementProps) => { const [timestamp, setTimestamp] = useState(0); const [state, setState] = useState(0); // Replaced stateRef const [shouldGenerateProfile, setShouldGenerateProfile] = useState(false); + const [chatSession, setChatSession] = useState(null); // Only keep refs that are truly necessary const controllerRef = useRef(null); const backstoryTextRef = useRef(null); - const generatePersona = useCallback((query: Query) => { - if (controllerRef.current) { + /* Create the chat session */ + useEffect(() => { + if (chatSession) { return; } - setPrompt(query.prompt); + + const createChatSession = async () => { + try { + const aiParameters: AIParameters = { model: 'qwen2.5' }; + + const chatContext: ChatContext = { + type: "generate_persona", + aiParameters + }; + const response: ChatSession = await apiClient.createChatSession(chatContext); + setChatSession(response); + setSnack(`Chat session created for generate_persona: ${response.id}`); + } catch (e) { + console.error(e); + setSnack("Unable to create chat session.", "error"); + } + }; + + createChatSession(); + }, [chatSession, setChatSession]); + + const generatePersona = useCallback((query: Query) => { + if (!chatSession || !chatSession.id) { + return; + } + const sessionId: string = chatSession.id; + + setPrompt(query.prompt || ''); setState(0); setStatus("Generating persona..."); setUser(emptyUser); @@ -76,6 +106,24 @@ const GenerateCandidate = (props: BackstoryElementProps) => { setCanGenImage(false); setShouldGenerateProfile(false); // Reset the flag + const streamResponse = apiClient.sendMessageStream(sessionId, query, { + onPartialMessage: (content, messageId) => { + console.log('Partial content:', content); + // Update UI with partial content + }, + onStatusChange: (status) => { + console.log('Status changed:', status); + // Update UI status indicator + }, + onComplete: (finalMessage) => { + console.log('Final message:', finalMessage.content); + // Handle completed message + }, + onError: (error) => { + console.error('Streaming error:', error); + // Handle error + } + }); // controllerRef.current = streamQueryResponse({ // query, // type: "persona", diff --git a/frontend/src/routes/CandidateRoute.tsx b/frontend/src/routes/CandidateRoute.tsx index 30a5272..d0f7062 100644 --- a/frontend/src/routes/CandidateRoute.tsx +++ b/frontend/src/routes/CandidateRoute.tsx @@ -1,12 +1,11 @@ import React, { useEffect, useState } from "react"; import { useParams, useNavigate } from "react-router-dom"; -import { useUser } from "../hooks/useUser"; import { Box } from "@mui/material"; import { SetSnackType } from '../components/Snack'; import { LoadingComponent } from "../components/LoadingComponent"; import { User, Guest, Candidate } from 'types/types'; -import { ApiClient } from "types/api-client"; +import { useUser } from "hooks/useUser"; interface CandidateRouteProps { guest?: Guest | null; @@ -15,7 +14,7 @@ interface CandidateRouteProps { }; const CandidateRoute: React.FC = (props: CandidateRouteProps) => { - const apiClient = new ApiClient(); + const { apiClient } = useUser(); const { setSnack } = props; const { username } = useParams<{ username: string }>(); const [candidate, setCandidate] = useState(null); @@ -32,11 +31,12 @@ const CandidateRoute: React.FC = (props: CandidateRouteProp navigate('/chat'); } catch { setSnack(`Unable to obtain information for ${username}.`, "error"); + navigate('/'); } } getCandidate(username); - }, [candidate, username, setCandidate]); + }, [candidate, username, setCandidate, navigate, setSnack]); if (candidate === null) { return ( diff --git a/frontend/src/types/api-client.ts b/frontend/src/types/api-client.ts index bd39da1..d5667c3 100644 --- a/frontend/src/types/api-client.ts +++ b/frontend/src/types/api-client.ts @@ -9,14 +9,14 @@ import * as Types from './types'; import { formatApiRequest, - parseApiResponse, - parsePaginatedResponse, + // parseApiResponse, + // parsePaginatedResponse, handleApiResponse, handlePaginatedApiResponse, createPaginatedRequest, toUrlParams, - extractApiData, - ApiResponse, + // extractApiData, + // ApiResponse, PaginatedResponse, PaginatedRequest } from './conversion'; @@ -76,16 +76,25 @@ class ApiClient { // Authentication Methods // ============================ - async login(email: string, password: string): Promise { + async login(login: string, password: string): Promise { const response = await fetch(`${this.baseUrl}/auth/login`, { method: 'POST', headers: this.defaultHeaders, - body: JSON.stringify(formatApiRequest({ email, password })) + body: JSON.stringify(formatApiRequest({ login, password })) }); return handleApiResponse(response); } + async logout(): Promise { + const response = await fetch(`${this.baseUrl}/auth/logout`, { + method: 'POST', + headers: this.defaultHeaders, + }); + + return handleApiResponse(response); + } + async refreshToken(refreshToken: string): Promise { const response = await fetch(`${this.baseUrl}/auth/refresh`, { method: 'POST', @@ -110,8 +119,8 @@ class ApiClient { return handleApiResponse(response); } - async getCandidate(id: string): Promise { - const response = await fetch(`${this.baseUrl}/candidates/${id}`, { + async getCandidate(username: string): Promise { + const response = await fetch(`${this.baseUrl}/candidates/${username}`, { headers: this.defaultHeaders }); diff --git a/frontend/src/types/types.ts b/frontend/src/types/types.ts index 4757638..da6fa62 100644 --- a/frontend/src/types/types.ts +++ b/frontend/src/types/types.ts @@ -1,19 +1,19 @@ // Generated TypeScript types from Pydantic models // Source: src/backend/models.py -// Generated on: 2025-05-28T21:47:08.590102 +// Generated on: 2025-05-29T02:05:50.622601 // DO NOT EDIT MANUALLY - This file is auto-generated // ============================ // Enums // ============================ -export type AIModelType = "gpt-4" | "gpt-3.5-turbo" | "claude-3" | "claude-3-opus" | "custom"; +export type AIModelType = "qwen2.5" | "flux-schnell"; export type ActivityType = "login" | "search" | "view_job" | "apply_job" | "message" | "update_profile" | "chat"; export type ApplicationStatus = "applied" | "reviewing" | "interview" | "offer" | "rejected" | "accepted" | "withdrawn"; -export type ChatContextType = "job_search" | "candidate_screening" | "interview_prep" | "resume_review" | "general"; +export type ChatContextType = "job_search" | "candidate_screening" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile"; export type ChatSenderType = "user" | "ai" | "system"; @@ -66,138 +66,138 @@ export type VectorStoreType = "pinecone" | "qdrant" | "faiss" | "milvus" | "weav export interface AIParameters { id?: string; userId?: string; - name: string; + name?: string; description?: string; - model: "gpt-4" | "gpt-3.5-turbo" | "claude-3" | "claude-3-opus" | "custom"; - temperature: number; - maxTokens: number; - topP: number; - frequencyPenalty: number; - presencePenalty: number; + model?: "qwen2.5" | "flux-schnell"; + temperature?: number; + maxTokens?: number; + topP?: number; + frequencyPenalty?: number; + presencePenalty?: number; systemPrompt?: string; - isDefault: boolean; - createdAt: Date; - updatedAt: Date; + isDefault?: boolean; + createdAt?: Date; + updatedAt?: Date; customModelConfig?: Record; } export interface AccessibilitySettings { - fontSize: "small" | "medium" | "large"; - highContrast: boolean; - reduceMotion: boolean; - screenReader: boolean; + fontSize?: "small" | "medium" | "large"; + highContrast?: boolean; + reduceMotion?: boolean; + screenReader?: boolean; colorBlindMode?: "protanopia" | "deuteranopia" | "tritanopia" | "none"; } export interface Analytics { id?: string; - entityType: "job" | "candidate" | "chat" | "system" | "employer"; - entityId: string; - metricType: string; - value: number; - timestamp: Date; + entityType?: "job" | "candidate" | "chat" | "system" | "employer"; + entityId?: string; + metricType?: string; + value?: number; + timestamp?: Date; dimensions?: Record; segment?: string; } export interface ApiResponse { - success: boolean; + success?: boolean; data?: any; error?: ErrorDetail; meta?: Record; } export interface ApplicationDecision { - status: "accepted" | "rejected"; + status?: "accepted" | "rejected"; reason?: string; - date: Date; - by: string; + date?: Date; + by?: string; } export interface Attachment { id?: string; - fileName: string; - fileType: string; - fileSize: number; - fileUrl: string; - uploadedAt: Date; - isProcessed: boolean; + fileName?: string; + fileType?: string; + fileSize?: number; + fileUrl?: string; + uploadedAt?: Date; + isProcessed?: boolean; processingResult?: any; thumbnailUrl?: string; } export interface AuthResponse { - accessToken: string; - refreshToken: string; - user: any; - expiresAt: number; + accessToken?: string; + refreshToken?: string; + user?: any; + expiresAt?: number; } export interface Authentication { - userId: string; - passwordHash: string; - salt: string; - refreshTokens: Array; + userId?: string; + passwordHash?: string; + salt?: string; + refreshTokens?: Array; resetPasswordToken?: string; resetPasswordExpiry?: Date; - lastPasswordChange: Date; - mfaEnabled: boolean; + lastPasswordChange?: Date; + mfaEnabled?: boolean; mfaMethod?: "app" | "sms" | "email"; mfaSecret?: string; - loginAttempts: number; + loginAttempts?: number; lockedUntil?: Date; } export interface BaseUser { id?: string; - email: string; + email?: string; phone?: string; - createdAt: Date; - updatedAt: Date; + createdAt?: Date; + updatedAt?: Date; lastLogin?: Date; profileImage?: string; - status: "active" | "inactive" | "pending" | "banned"; + status?: "active" | "inactive" | "pending" | "banned"; } export interface BaseUserWithType { id?: string; - email: string; + email?: string; phone?: string; - createdAt: Date; - updatedAt: Date; + createdAt?: Date; + updatedAt?: Date; lastLogin?: Date; profileImage?: string; - status: "active" | "inactive" | "pending" | "banned"; - userType: "candidate" | "employer" | "guest"; + status?: "active" | "inactive" | "pending" | "banned"; + userType?: "candidate" | "employer" | "guest"; } export interface Candidate { id?: string; - email: string; + email?: string; phone?: string; - createdAt: Date; - updatedAt: Date; + createdAt?: Date; + updatedAt?: Date; lastLogin?: Date; profileImage?: string; - status: "active" | "inactive" | "pending" | "banned"; + status?: "active" | "inactive" | "pending" | "banned"; userType?: "candidate"; - username: string; - firstName: string; - lastName: string; - fullName: string; + username?: string; + firstName?: string; + lastName?: string; + fullName?: string; description?: string; resume?: string; - skills: Array; - experience: Array; + skills?: Array; + experience?: Array; questions?: Array; - education: Array; - preferredJobTypes: Array<"full-time" | "part-time" | "contract" | "internship" | "freelance">; + education?: Array; + preferredJobTypes?: Array<"full-time" | "part-time" | "contract" | "internship" | "freelance">; desiredSalary?: DesiredSalary; - location: Location; + location?: Location; availabilityDate?: Date; summary?: string; - languages: Array; - certifications: Array; + languages?: Array; + certifications?: Array; jobApplications?: Array; hasProfile?: boolean; age?: number; @@ -206,24 +206,24 @@ export interface Candidate { } export interface CandidateContact { - email: string; + email?: string; phone?: string; } export interface CandidateListResponse { - success: boolean; + success?: boolean; data?: Array; error?: ErrorDetail; meta?: Record; } export interface CandidateQuestion { - question: string; + question?: string; tunables?: Tunables; } export interface CandidateResponse { - success: boolean; + success?: boolean; data?: Candidate; error?: ErrorDetail; meta?: Record; @@ -231,30 +231,30 @@ export interface CandidateResponse { export interface Certification { id?: string; - name: string; - issuingOrganization: string; - issueDate: Date; + name?: string; + issuingOrganization?: string; + issueDate?: Date; expirationDate?: Date; credentialId?: string; credentialUrl?: string; } export interface ChatContext { - type: "job_search" | "candidate_screening" | "interview_prep" | "resume_review" | "general"; + type?: "job_search" | "candidate_screening" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile"; relatedEntityId?: string; relatedEntityType?: "job" | "candidate" | "employer"; - aiParameters: AIParameters; + aiParameters?: AIParameters; additionalContext?: Record; } export interface ChatMessage { id?: string; - sessionId: string; - status: "partial" | "done" | "streaming" | "thinking" | "error"; - sender: "user" | "ai" | "system"; + sessionId?: string; + status?: "partial" | "done" | "streaming" | "thinking" | "error"; + sender?: "user" | "ai" | "system"; senderId?: string; - content: string; - timestamp: Date; + content?: string; + timestamp?: Date; attachments?: Array; reactions?: Array; isEdited?: boolean; @@ -266,54 +266,54 @@ export interface ChatSession { id?: string; userId?: string; guestId?: string; - createdAt: Date; - lastActivity: Date; + createdAt?: Date; + lastActivity?: Date; title?: string; - context: ChatContext; + context?: ChatContext; messages?: Array; isArchived?: boolean; systemPrompt?: string; } export interface CustomQuestion { - question: string; - answer: string; + question?: string; + answer?: string; } export interface DataSourceConfiguration { id?: string; - ragConfigId: string; - name: string; - sourceType: "document" | "website" | "api" | "database" | "internal"; - connectionDetails: Record; - processingPipeline: Array; + ragConfigId?: string; + name?: string; + sourceType?: "document" | "website" | "api" | "database" | "internal"; + connectionDetails?: Record; + processingPipeline?: Array; refreshSchedule?: string; lastRefreshed?: Date; - status: "active" | "pending" | "error" | "processing"; + status?: "active" | "pending" | "error" | "processing"; errorDetails?: string; metadata?: Record; } export interface DesiredSalary { - amount: number; - currency: string; - period: "hour" | "day" | "month" | "year"; + amount?: number; + currency?: string; + period?: "hour" | "day" | "month" | "year"; } export interface EditHistory { - content: string; - editedAt: Date; - editedBy: string; + content?: string; + editedAt?: Date; + editedBy?: string; } export interface Education { id?: string; - institution: string; - degree: string; - fieldOfStudy: string; - startDate: Date; + institution?: string; + degree?: string; + fieldOfStudy?: string; + startDate?: Date; endDate?: Date; - isCurrent: boolean; + isCurrent?: boolean; gpa?: number; achievements?: Array; location?: Location; @@ -321,45 +321,45 @@ export interface Education { export interface Employer { id?: string; - email: string; + email?: string; phone?: string; - createdAt: Date; - updatedAt: Date; + createdAt?: Date; + updatedAt?: Date; lastLogin?: Date; profileImage?: string; - status: "active" | "inactive" | "pending" | "banned"; + status?: "active" | "inactive" | "pending" | "banned"; userType?: "employer"; - companyName: string; - industry: string; + companyName?: string; + industry?: string; description?: string; - companySize: string; - companyDescription: string; + companySize?: string; + companyDescription?: string; websiteUrl?: string; jobs?: Array; - location: Location; + location?: Location; companyLogo?: string; socialLinks?: Array; poc?: PointOfContact; } export interface EmployerResponse { - success: boolean; + success?: boolean; data?: Employer; error?: ErrorDetail; meta?: Record; } export interface ErrorDetail { - code: string; - message: string; + code?: string; + message?: string; details?: any; } export interface Guest { id?: string; - sessionId: string; - createdAt: Date; - lastActivity: Date; + sessionId?: string; + createdAt?: Date; + lastActivity?: Date; convertedToUserId?: string; ipAddress?: string; userAgent?: string; @@ -367,49 +367,49 @@ export interface Guest { export interface InterviewFeedback { id?: string; - interviewId: string; - reviewerId: string; - technicalScore: number; - culturalScore: number; - overallScore: number; - strengths: Array; - weaknesses: Array; - recommendation: "strong_hire" | "hire" | "no_hire" | "strong_no_hire"; - comments: string; - createdAt: Date; - updatedAt: Date; - isVisible: boolean; + interviewId?: string; + reviewerId?: string; + technicalScore?: number; + culturalScore?: number; + overallScore?: number; + strengths?: Array; + weaknesses?: Array; + recommendation?: "strong_hire" | "hire" | "no_hire" | "strong_no_hire"; + comments?: string; + createdAt?: Date; + updatedAt?: Date; + isVisible?: boolean; skillAssessments?: Array; } export interface InterviewSchedule { id?: string; - applicationId: string; - scheduledDate: Date; - endDate: Date; - interviewType: "phone" | "video" | "onsite" | "technical" | "behavioral"; - interviewers: Array; + applicationId?: string; + scheduledDate?: Date; + endDate?: Date; + interviewType?: "phone" | "video" | "onsite" | "technical" | "behavioral"; + interviewers?: Array; location?: string | Location; notes?: string; feedback?: InterviewFeedback; - status: "scheduled" | "completed" | "cancelled" | "rescheduled"; + status?: "scheduled" | "completed" | "cancelled" | "rescheduled"; meetingLink?: string; } export interface Job { id?: string; - title: string; - description: string; - responsibilities: Array; - requirements: Array; + title?: string; + description?: string; + responsibilities?: Array; + requirements?: Array; preferredSkills?: Array; - employerId: string; - location: Location; + employerId?: string; + location?: Location; salaryRange?: SalaryRange; - employmentType: "full-time" | "part-time" | "contract" | "internship" | "freelance"; - datePosted: Date; + employmentType?: "full-time" | "part-time" | "contract" | "internship" | "freelance"; + datePosted?: Date; applicationDeadline?: Date; - isActive: boolean; + isActive?: boolean; applicants?: Array; department?: string; reportsTo?: string; @@ -422,12 +422,12 @@ export interface Job { export interface JobApplication { id?: string; - jobId: string; - candidateId: string; - status: "applied" | "reviewing" | "interview" | "offer" | "rejected" | "accepted" | "withdrawn"; - appliedDate: Date; - updatedDate: Date; - resumeVersion: string; + jobId?: string; + candidateId?: string; + status?: "applied" | "reviewing" | "interview" | "offer" | "rejected" | "accepted" | "withdrawn"; + appliedDate?: Date; + updatedDate?: Date; + resumeVersion?: string; coverLetter?: string; notes?: string; interviewSchedules?: Array; @@ -437,28 +437,28 @@ export interface JobApplication { } export interface JobListResponse { - success: boolean; + success?: boolean; data?: Array; error?: ErrorDetail; meta?: Record; } export interface JobResponse { - success: boolean; + success?: boolean; data?: Job; error?: ErrorDetail; meta?: Record; } export interface Language { - language: string; - proficiency: "basic" | "conversational" | "fluent" | "native"; + language?: string; + proficiency?: "basic" | "conversational" | "fluent" | "native"; } export interface Location { - city: string; + city?: string; state?: string; - country: string; + country?: string; postalCode?: string; latitude?: number; longitude?: number; @@ -468,15 +468,15 @@ export interface Location { } export interface MessageReaction { - userId: string; - reaction: string; - timestamp: Date; + userId?: string; + reaction?: string; + timestamp?: Date; } export interface NotificationPreference { - type: "email" | "push" | "in_app"; - events: Array; - isEnabled: boolean; + type?: "email" | "push" | "in_app"; + events?: Array; + isEnabled?: boolean; } export interface PaginatedRequest { @@ -488,80 +488,80 @@ export interface PaginatedRequest { } export interface PaginatedResponse { - data: Array; - total: number; - page: number; - limit: number; - totalPages: number; - hasMore: boolean; + data?: Array; + total?: number; + page?: number; + limit?: number; + totalPages?: number; + hasMore?: boolean; } export interface PointOfContact { - name: string; - position: string; - email: string; + name?: string; + position?: string; + email?: string; phone?: string; } export interface ProcessingStep { id?: string; - type: "extract" | "transform" | "chunk" | "embed" | "filter" | "summarize"; - parameters: Record; - order: number; + type?: "extract" | "transform" | "chunk" | "embed" | "filter" | "summarize"; + parameters?: Record; + order?: number; dependsOn?: Array; } export interface Query { - prompt: string; + prompt?: string; tunables?: Tunables; agentOptions?: Record; } export interface RAGConfiguration { id?: string; - userId: string; - name: string; + userId?: string; + name?: string; description?: string; - dataSourceConfigurations: Array; - embeddingModel: string; - vectorStoreType: "pinecone" | "qdrant" | "faiss" | "milvus" | "weaviate"; - retrievalParameters: RetrievalParameters; - createdAt: Date; - updatedAt: Date; - isDefault: boolean; - version: number; - isActive: boolean; + dataSourceConfigurations?: Array; + embeddingModel?: string; + vectorStoreType?: "pinecone" | "qdrant" | "faiss" | "milvus" | "weaviate"; + retrievalParameters?: RetrievalParameters; + createdAt?: Date; + updatedAt?: Date; + isDefault?: boolean; + version?: number; + isActive?: boolean; } export interface RefreshToken { - token: string; - expiresAt: Date; - device: string; - ipAddress: string; - isRevoked: boolean; + token?: string; + expiresAt?: Date; + device?: string; + ipAddress?: string; + isRevoked?: boolean; revokedReason?: string; } export interface RetrievalParameters { - searchType: "similarity" | "mmr" | "hybrid" | "keyword"; - topK: number; + searchType?: "similarity" | "mmr" | "hybrid" | "keyword"; + topK?: number; similarityThreshold?: number; rerankerModel?: string; - useKeywordBoost: boolean; + useKeywordBoost?: boolean; filterOptions?: Record; - contextWindow: number; + contextWindow?: number; } export interface SalaryRange { - min: number; - max: number; - currency: string; - period: "hour" | "day" | "month" | "year"; - isVisible: boolean; + min?: number; + max?: number; + currency?: string; + period?: "hour" | "day" | "month" | "year"; + isVisible?: boolean; } export interface SearchQuery { - query: string; + query?: string; filters?: Record; page?: number; limit?: number; @@ -571,21 +571,21 @@ export interface SearchQuery { export interface Skill { id?: string; - name: string; - category: string; - level: "beginner" | "intermediate" | "advanced" | "expert"; + name?: string; + category?: string; + level?: "beginner" | "intermediate" | "advanced" | "expert"; yearsOfExperience?: number; } export interface SkillAssessment { - skillName: string; - score: number; + skillName?: string; + score?: number; comments?: string; } export interface SocialLink { - platform: "linkedin" | "twitter" | "github" | "dribbble" | "behance" | "website" | "other"; - url: string; + platform?: "linkedin" | "twitter" | "github" | "dribbble" | "behance" | "website" | "other"; + url?: string; } export interface Tunables { @@ -598,35 +598,35 @@ export interface UserActivity { id?: string; userId?: string; guestId?: string; - activityType: "login" | "search" | "view_job" | "apply_job" | "message" | "update_profile" | "chat"; - timestamp: Date; - metadata: Record; + activityType?: "login" | "search" | "view_job" | "apply_job" | "message" | "update_profile" | "chat"; + timestamp?: Date; + metadata?: Record; ipAddress?: string; userAgent?: string; sessionId?: string; } export interface UserPreference { - userId: string; - theme: "light" | "dark" | "system"; - notifications: Array; - accessibility: AccessibilitySettings; + userId?: string; + theme?: "light" | "dark" | "system"; + notifications?: Array; + accessibility?: AccessibilitySettings; dashboardLayout?: Record; - language: string; - timezone: string; - emailFrequency: "immediate" | "daily" | "weekly" | "never"; + language?: string; + timezone?: string; + emailFrequency?: "immediate" | "daily" | "weekly" | "never"; } export interface WorkExperience { id?: string; - companyName: string; - position: string; - startDate: Date; + companyName?: string; + position?: string; + startDate?: Date; endDate?: Date; - isCurrent: boolean; - description: string; - skills: Array; - location: Location; + isCurrent?: boolean; + description?: string; + skills?: Array; + location?: Location; achievements?: Array; } diff --git a/src/backend/focused_test.py b/src/backend/focused_test.py index f150dee..973ef93 100644 --- a/src/backend/focused_test.py +++ b/src/backend/focused_test.py @@ -11,6 +11,7 @@ from models import ( Candidate, Employer, Location, Skill, AIParameters, AIModelType ) + def test_model_creation(): """Test that we can create models successfully""" print("๐Ÿงช Testing model creation...") @@ -122,7 +123,7 @@ def test_validation_constraints(): # Test AI Parameters with constraints valid_params = AIParameters( name="Test Config", - model=AIModelType.GPT_4, + model=AIModelType.QWEN2_5, temperature=0.7, # Valid: 0-1 maxTokens=2000, # Valid: > 0 topP=0.95, # Valid: 0-1 @@ -138,7 +139,7 @@ def test_validation_constraints(): try: invalid_params = AIParameters( name="Invalid Config", - model=AIModelType.GPT_4, + model=AIModelType.QWEN2_5, temperature=1.5, # Invalid: > 1 maxTokens=2000, topP=0.95, diff --git a/src/backend/generate_types.py b/src/backend/generate_types.py index a2f1025..b1a7d85 100644 --- a/src/backend/generate_types.py +++ b/src/backend/generate_types.py @@ -13,6 +13,7 @@ from datetime import datetime from enum import Enum from pathlib import Path import stat + def run_command(command: str, description: str, cwd: str | None = None) -> bool: """Run a command and return success status""" try: @@ -68,9 +69,34 @@ except ImportError as e: print("Make sure pydantic is installed: pip install pydantic") sys.exit(1) -def python_type_to_typescript(python_type: Any) -> str: +def unwrap_annotated_type(python_type: Any) -> Any: + """Unwrap Annotated types to get the actual type""" + # Handle typing_extensions.Annotated and typing.Annotated + origin = get_origin(python_type) + args = get_args(python_type) + + # Check for Annotated types - more robust detection + if origin is not None and args: + origin_str = str(origin) + if 'Annotated' in origin_str or (hasattr(origin, '__name__') and origin.__name__ == 'Annotated'): + # Return the first argument (the actual type) + return unwrap_annotated_type(args[0]) # Recursive unwrap in case of nested annotations + + return python_type + +def python_type_to_typescript(python_type: Any, debug: bool = False) -> str: """Convert a Python type to TypeScript type string""" + if debug: + print(f" ๐Ÿ” Converting type: {python_type} (type: {type(python_type)})") + + # First unwrap any Annotated types + original_type = python_type + python_type = unwrap_annotated_type(python_type) + + if debug and original_type != python_type: + print(f" ๐Ÿ”„ Unwrapped: {original_type} -> {python_type}") + # Handle None/null if python_type is type(None): return "null" @@ -79,6 +105,8 @@ def python_type_to_typescript(python_type: Any) -> str: if python_type == str: return "string" elif python_type == int or python_type == float: + if debug: + print(f" โœ… Converting {python_type} to number") return "number" elif python_type == bool: return "boolean" @@ -91,30 +119,33 @@ def python_type_to_typescript(python_type: Any) -> str: origin = get_origin(python_type) args = get_args(python_type) + if debug and origin: + print(f" ๐Ÿ” Generic type - origin: {origin}, args: {args}") + if origin is Union: # Handle Optional (Union[T, None]) if len(args) == 2 and type(None) in args: non_none_type = next(arg for arg in args if arg is not type(None)) - return python_type_to_typescript(non_none_type) + return python_type_to_typescript(non_none_type, debug) # Handle other unions - union_types = [python_type_to_typescript(arg) for arg in args if arg is not type(None)] + union_types = [python_type_to_typescript(arg, debug) for arg in args if arg is not type(None)] return " | ".join(union_types) elif origin is list or origin is List: if args: - item_type = python_type_to_typescript(args[0]) + item_type = python_type_to_typescript(args[0], debug) return f"Array<{item_type}>" return "Array" elif origin is dict or origin is Dict: if len(args) == 2: - key_type = python_type_to_typescript(args[0]) - value_type = python_type_to_typescript(args[1]) + key_type = python_type_to_typescript(args[0], debug) + value_type = python_type_to_typescript(args[1], debug) return f"Record<{key_type}, {value_type}>" return "Record" - # Handle Literal types - UPDATED SECTION + # Handle Literal types if hasattr(python_type, '__origin__') and str(python_type.__origin__).endswith('Literal'): if args: literal_values = [] @@ -155,6 +186,8 @@ def python_type_to_typescript(python_type: Any) -> str: return "string" # Default fallback + if debug: + print(f" โš ๏ธ Falling back to 'any' for type: {python_type}") return "any" def snake_to_camel(snake_str: str) -> str: @@ -162,32 +195,104 @@ def snake_to_camel(snake_str: str) -> str: components = snake_str.split('_') return components[0] + ''.join(x.title() for x in components[1:]) -def process_pydantic_model(model_class) -> Dict[str, Any]: +def is_field_optional(field_info: Any, field_type: Any) -> bool: + """Determine if a field should be optional in TypeScript""" + + # First, check if the type itself is Optional (Union with None) + origin = get_origin(field_type) + args = get_args(field_type) + is_union_with_none = origin is Union and type(None) in args + + # If the type is Optional[T], it's always optional regardless of Field settings + if is_union_with_none: + return True + + # For non-Optional types, check Field settings and defaults + + # Check for default factory (makes field optional) + if hasattr(field_info, 'default_factory') and field_info.default_factory is not None: + return True + + # Check the default value + if hasattr(field_info, 'default'): + default_val = field_info.default + + # Field(...) or Ellipsis means REQUIRED (not optional) + if default_val is ...: + return False + + # Any other default value (including None) makes it optional + # This covers: Field(None), Field("some_value"), = "some_value", = None, etc. + else: + return True + + # If no default is set at all, check if field is explicitly marked as not required + # This is for edge cases in Pydantic v2 + if hasattr(field_info, 'is_required'): + try: + return not field_info.is_required() + except: + pass + elif hasattr(field_info, 'required'): + return not field_info.required + + # Default: if type is not Optional and no explicit default, it's required (not optional) + return False + +def process_pydantic_model(model_class, debug: bool = False) -> Dict[str, Any]: """Process a Pydantic model and return TypeScript interface definition""" interface_name = model_class.__name__ properties = [] + if debug: + print(f" ๐Ÿ” Processing model: {interface_name}") + # Get fields from the model if hasattr(model_class, 'model_fields'): # Pydantic v2 fields = model_class.model_fields for field_name, field_info in fields.items(): - ts_name = snake_to_camel(field_name) + if debug: + print(f" ๐Ÿ“ Field: {field_name}") + print(f" Field info: {field_info}") + print(f" Default: {getattr(field_info, 'default', 'NO_DEFAULT')}") - # Check for alias + # Use alias if available, otherwise convert snake_case to camelCase if hasattr(field_info, 'alias') and field_info.alias: ts_name = field_info.alias + else: + ts_name = snake_to_camel(field_name) # Get type annotation field_type = getattr(field_info, 'annotation', str) - ts_type = python_type_to_typescript(field_type) + if debug: + print(f" Raw type: {field_type}") + + ts_type = python_type_to_typescript(field_type, debug) # Check if optional - is_optional = False - if hasattr(field_info, 'is_required'): - is_optional = not field_info.is_required() - elif hasattr(field_info, 'default'): - is_optional = field_info.default is not None + is_optional = is_field_optional(field_info, field_type) + + if debug: + print(f" TS name: {ts_name}") + print(f" TS type: {ts_type}") + print(f" Optional: {is_optional}") + + # Debug the optional logic + origin = get_origin(field_type) + args = get_args(field_type) + is_union_with_none = origin is Union and type(None) in args + has_default = hasattr(field_info, 'default') + has_default_factory = hasattr(field_info, 'default_factory') and field_info.default_factory is not None + + print(f" โ””โ”€ Type is Optional: {is_union_with_none}") + if has_default: + default_val = field_info.default + print(f" โ””โ”€ Has default: {default_val} (is ...? {default_val is ...})") + else: + print(f" โ””โ”€ No default attribute") + print(f" โ””โ”€ Has default factory: {has_default_factory}") + print() properties.append({ 'name': ts_name, @@ -199,17 +304,45 @@ def process_pydantic_model(model_class) -> Dict[str, Any]: # Pydantic v1 fields = model_class.__fields__ for field_name, field_info in fields.items(): - ts_name = snake_to_camel(field_name) + if debug: + print(f" ๐Ÿ“ Field: {field_name} (Pydantic v1)") + print(f" Field info: {field_info}") + # Use alias if available, otherwise convert snake_case to camelCase if hasattr(field_info, 'alias') and field_info.alias: ts_name = field_info.alias + else: + ts_name = snake_to_camel(field_name) field_type = getattr(field_info, 'annotation', getattr(field_info, 'type_', str)) - ts_type = python_type_to_typescript(field_type) + if debug: + print(f" Raw type: {field_type}") - is_optional = not getattr(field_info, 'required', True) - if hasattr(field_info, 'default') and field_info.default is not None: - is_optional = True + ts_type = python_type_to_typescript(field_type, debug) + + # For Pydantic v1, check required and default + is_optional = is_field_optional(field_info, field_type) + + if debug: + print(f" TS name: {ts_name}") + print(f" TS type: {ts_type}") + print(f" Optional: {is_optional}") + + # Debug the optional logic + origin = get_origin(field_type) + args = get_args(field_type) + is_union_with_none = origin is Union and type(None) in args + has_default = hasattr(field_info, 'default') + has_default_factory = hasattr(field_info, 'default_factory') and field_info.default_factory is not None + + print(f" โ””โ”€ Type is Optional: {is_union_with_none}") + if has_default: + default_val = field_info.default + print(f" โ””โ”€ Has default: {default_val} (is ...? {default_val is ...})") + else: + print(f" โ””โ”€ No default attribute") + print(f" โ””โ”€ Has default factory: {has_default_factory}") + print() properties.append({ 'name': ts_name, @@ -233,7 +366,7 @@ def process_enum(enum_class) -> Dict[str, Any]: 'values': " | ".join(values) } -def generate_typescript_interfaces(source_file: str): +def generate_typescript_interfaces(source_file: str, debug: bool = False): """Generate TypeScript interfaces from models""" print(f"๐Ÿ“– Scanning {source_file} for Pydantic models and enums...") @@ -270,7 +403,7 @@ def generate_typescript_interfaces(source_file: str): issubclass(obj, BaseModel) and obj != BaseModel): - interface = process_pydantic_model(obj) + interface = process_pydantic_model(obj, debug) interfaces.append(interface) print(f" โœ… Found Pydantic model: {name}") @@ -284,6 +417,9 @@ def generate_typescript_interfaces(source_file: str): except Exception as e: print(f" โš ๏ธ Warning: Error processing {name}: {e}") + if debug: + import traceback + traceback.print_exc() continue print(f"\n๐Ÿ“Š Found {len(interfaces)} interfaces and {len(enums)} enums") @@ -362,7 +498,8 @@ Examples: python generate_types.py --source models.py --output types.ts # Specify files python generate_types.py --skip-test # Skip model validation python generate_types.py --skip-compile # Skip TS compilation - python generate_types.py --source models.py --output types.ts --skip-test --skip-compile + python generate_types.py --debug # Enable debug output + python generate_types.py --source models.py --output types.ts --skip-test --skip-compile --debug """ ) @@ -390,6 +527,12 @@ Examples: help='Skip TypeScript compilation check after generation' ) + parser.add_argument( + '--debug', + action='store_true', + help='Enable debug output to troubleshoot type conversion issues' + ) + parser.add_argument( '--version', '-v', action='version', @@ -422,7 +565,11 @@ Examples: # Step 3: Generate TypeScript content print("๐Ÿ”„ Generating TypeScript types...") - ts_content = generate_typescript_interfaces(args.source) + if args.debug: + print("๐Ÿ› Debug mode enabled - detailed output follows:") + print() + + ts_content = generate_typescript_interfaces(args.source, args.debug) if ts_content is None: print("โŒ Failed to generate TypeScript content") diff --git a/src/backend/main.py b/src/backend/main.py index 0ff3a21..b24edde 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -140,18 +140,43 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt -def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)): +async def verify_token_with_blacklist(credentials: HTTPAuthorizationCredentials = Depends(security)): + """Verify token and check if it's blacklisted""" try: + # First decode the token payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]) user_id: str = payload.get("sub") if user_id is None: raise HTTPException(status_code=401, detail="Invalid authentication credentials") + + # Check if token is blacklisted + redis_client = redis_manager.get_client() + blacklist_key = f"blacklisted_token:{credentials.credentials}" + + is_blacklisted = await redis_client.exists(blacklist_key) + if is_blacklisted: + logger.warning(f"๐Ÿšซ Attempt to use blacklisted token for user {user_id}") + raise HTTPException(status_code=401, detail="Token has been revoked") + + # Optional: Check if all user tokens are revoked (for "logout from all devices") + # user_revoked_key = f"user_tokens_revoked:{user_id}" + # user_tokens_revoked_at = await redis_client.get(user_revoked_key) + # if user_tokens_revoked_at: + # revoked_timestamp = datetime.fromisoformat(user_tokens_revoked_at.decode()) + # token_issued_at = datetime.fromtimestamp(payload.get("iat", 0), UTC) + # if token_issued_at < revoked_timestamp: + # raise HTTPException(status_code=401, detail="All user tokens have been revoked") + return user_id + except jwt.PyJWTError: raise HTTPException(status_code=401, detail="Invalid authentication credentials") + except Exception as e: + logger.error(f"Token verification error: {e}") + raise HTTPException(status_code=401, detail="Token verification failed") async def get_current_user( - user_id: str = Depends(verify_token), + user_id: str = Depends(verify_token_with_blacklist), database: RedisDatabase = Depends(lambda: db_manager.get_database()) ): """Get current user from database""" @@ -321,12 +346,146 @@ async def login( return create_success_response(auth_response.model_dump(by_alias=True)) except Exception as e: - logger.error(f"Login error: {e}") + logger.error(f"โš ๏ธ Login error: {e}") return JSONResponse( status_code=500, content=create_error_response("LOGIN_ERROR", str(e)) ) +@api_router.post("/auth/logout") +async def logout( + refreshToken: str = Body(..., alias="refreshToken"), + accessToken: Optional[str] = Body(None, alias="accessToken"), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Logout endpoint - revokes both access and refresh tokens""" + try: + # Verify refresh token + try: + refresh_payload = jwt.decode(refreshToken, SECRET_KEY, algorithms=[ALGORITHM]) + user_id = refresh_payload.get("sub") + token_type = refresh_payload.get("type") + refresh_exp = refresh_payload.get("exp") + + if not user_id or token_type != "refresh": + return JSONResponse( + status_code=401, + content=create_error_response("INVALID_TOKEN", "Invalid refresh token") + ) + except jwt.PyJWTError as e: + logger.warning(f"Invalid refresh token during logout: {e}") + return JSONResponse( + status_code=401, + content=create_error_response("INVALID_TOKEN", "Invalid refresh token") + ) + + # Verify that the refresh token belongs to the current user + if user_id != current_user.id: + return JSONResponse( + status_code=403, + content=create_error_response("FORBIDDEN", "Token does not belong to current user") + ) + + # Get Redis client + redis_client = redis_manager.get_client() + + # Revoke refresh token (blacklist it until its natural expiration) + refresh_ttl = max(0, refresh_exp - int(datetime.now(UTC).timestamp())) + if refresh_ttl > 0: + await redis_client.setex( + f"blacklisted_token:{refreshToken}", + refresh_ttl, + json.dumps({ + "user_id": user_id, + "token_type": "refresh", + "revoked_at": datetime.now(UTC).isoformat(), + "reason": "user_logout" + }) + ) + logger.info(f"๐Ÿ”’ Blacklisted refresh token for user {user_id}") + + # If access token is provided, revoke it too + if accessToken: + try: + access_payload = jwt.decode(accessToken, SECRET_KEY, algorithms=[ALGORITHM]) + access_user_id = access_payload.get("sub") + access_exp = access_payload.get("exp") + + # Verify access token belongs to same user + if access_user_id == user_id: + access_ttl = max(0, access_exp - int(datetime.now(UTC).timestamp())) + if access_ttl > 0: + await redis_client.setex( + f"blacklisted_token:{accessToken}", + access_ttl, + json.dumps({ + "user_id": user_id, + "token_type": "access", + "revoked_at": datetime.now(UTC).isoformat(), + "reason": "user_logout" + }) + ) + logger.info(f"๐Ÿ”’ Blacklisted access token for user {user_id}") + else: + logger.warning(f"Access token user mismatch during logout: {access_user_id} != {user_id}") + except jwt.PyJWTError as e: + logger.warning(f"Invalid access token during logout (non-critical): {e}") + # Don't fail logout if access token is invalid + + # Optional: Revoke all tokens for this user (for "logout from all devices") + # Uncomment the following lines if you want to implement this feature: + # + # await redis_client.setex( + # f"user_tokens_revoked:{user_id}", + # timedelta(days=30).total_seconds(), # Max refresh token lifetime + # datetime.now(UTC).isoformat() + # ) + + logger.info(f"๐Ÿ”‘ User {user_id} logged out successfully") + return create_success_response({ + "message": "Logged out successfully", + "tokensRevoked": { + "refreshToken": True, + "accessToken": bool(accessToken) + } + }) + + except Exception as e: + logger.error(f"โš ๏ธ Logout error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("LOGOUT_ERROR", str(e)) + ) + +@api_router.post("/auth/logout-all") +async def logout_all_devices( + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Logout from all devices by revoking all tokens for the user""" + try: + redis_client = redis_manager.get_client() + + # Set a timestamp that invalidates all tokens issued before this moment + await redis_client.setex( + f"user_tokens_revoked:{current_user.id}", + int(timedelta(days=30).total_seconds()), # Max refresh token lifetime + datetime.now(UTC).isoformat() + ) + + logger.info(f"๐Ÿ”’ All tokens revoked for user {current_user.id}") + return create_success_response({ + "message": "Logged out from all devices successfully" + }) + + except Exception as e: + logger.error(f"โš ๏ธ Logout all devices error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("LOGOUT_ALL_ERROR", str(e)) + ) + @api_router.post("/auth/refresh") async def refresh_token_endpoint( refreshToken: str = Body(..., alias="refreshToken"), @@ -427,21 +586,33 @@ async def create_candidate( content=create_error_response("CREATION_FAILED", str(e)) ) -@api_router.get("/candidates/{candidate_id}") +@api_router.get("/candidates/{username}") async def get_candidate( - candidate_id: str = Path(...), + username: str = Path(...), database: RedisDatabase = Depends(get_database) ): - """Get a candidate by ID""" + """Get a candidate by username""" try: - candidate_data = await database.get_candidate(candidate_id) - if not candidate_data: + 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(candidate_data) + candidate = Candidate.model_validate(candidates_list[0]) return create_success_response(candidate.model_dump(by_alias=True)) except Exception as e: @@ -558,6 +729,7 @@ async def search_candidates( if (query_lower in c.first_name.lower() or query_lower in c.last_name.lower() or query_lower in c.email.lower() or + query_lower in c.username.lower() or any(query_lower in skill.name.lower() for skill in c.skills)) ] @@ -727,6 +899,98 @@ async def search_jobs( content=create_error_response("SEARCH_FAILED", str(e)) ) +# ============================ +# Chat Endpoints +# ============================ +@api_router.post("/chat/sessions") +async def create_chat_session( + session_data: Dict[str, Any] = Body(...), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Create a new chat session""" + try: + # Add required fields + session_data["id"] = str(uuid.uuid4()) + session_data["createdAt"] = datetime.now(UTC).isoformat() + session_data["updatedAt"] = datetime.now(UTC).isoformat() + + # Create chat session + chat_session = ChatSession.model_validate(session_data) + await database.set_chat_session(chat_session.id, chat_session.model_dump()) + + return create_success_response(chat_session.model_dump(by_alias=True)) + + except Exception as e: + logger.error(f"Chat session creation error: {e}") + return JSONResponse( + status_code=400, + content=create_error_response("CREATION_FAILED", str(e)) + ) + +@api_router.get("/chat/sessions/{session_id}") +async def get_chat_session( + session_id: str = Path(...), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Get a chat session by ID""" + try: + chat_session_data = await database.get_chat_session(session_id) + if not chat_session_data: + return JSONResponse( + status_code=404, + content=create_error_response("NOT_FOUND", "Chat session not found") + ) + + chat_session = ChatSession.model_validate(chat_session_data) + return create_success_response(chat_session.model_dump(by_alias=True)) + + except Exception as e: + logger.error(f"Get chat session error: {e}") + return JSONResponse( + status_code=500, + content=create_error_response("FETCH_ERROR", str(e)) + ) + +@api_router.get("/chat/sessions") +async def get_chat_sessions( + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=100), + sortBy: Optional[str] = Query(None, alias="sortBy"), + sortOrder: str = Query("desc", pattern="^(asc|desc)$", alias="sortOrder"), + filters: Optional[str] = Query(None), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Get paginated list of chat sessions""" + try: + filter_dict = None + if filters: + filter_dict = json.loads(filters) + + # Get all chat sessions from Redis + all_sessions_data = await database.get_all_chat_sessions() + sessions_list = [ChatSession.model_validate(data) for data in all_sessions_data.values()] + + paginated_sessions, total = filter_and_paginate( + sessions_list, page, limit, sortBy, sortOrder, filter_dict + ) + + paginated_response = create_paginated_response( + [s.model_dump(by_alias=True) for s in paginated_sessions], + page, limit, total + ) + + return create_success_response(paginated_response) + + except Exception as e: + logger.error(f"Get chat sessions error: {e}") + return JSONResponse( + status_code=400, + content=create_error_response("FETCH_FAILED", str(e)) + ) + # ============================ # Health Check and Info Endpoints # ============================ @@ -790,6 +1054,11 @@ async def redis_stats(redis_client: redis.Redis = Depends(get_redis)): except Exception as e: raise HTTPException(status_code=503, detail=f"Redis stats unavailable: {e}") +@api_router.get("/system-info") +async def get_system_info(request: Request): + from system_info import system_info # Import system_info function from system_info module + return JSONResponse(system_info()) + @api_router.get("/") async def api_info(): """API information endpoint""" diff --git a/src/backend/models.py b/src/backend/models.py index f40d84a..dccd711 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -80,13 +80,12 @@ class ChatContextType(str, Enum): INTERVIEW_PREP = "interview_prep" RESUME_REVIEW = "resume_review" GENERAL = "general" + GENERATE_PERSONA = "generate_persona" + GENERATE_PROFILE = "generate_profile" class AIModelType(str, Enum): - GPT_4 = "gpt-4" - GPT_35_TURBO = "gpt-3.5-turbo" - CLAUDE_3 = "claude-3" - CLAUDE_3_OPUS = "claude-3-opus" - CUSTOM = "custom" + QWEN2_5 = "qwen2.5" + FLUX_SCHNELL = "flux-schnell" class MFAMethod(str, Enum): APP = "app" @@ -526,15 +525,15 @@ class AIParameters(BaseModel): name: str description: Optional[str] = None model: AIModelType - temperature: Annotated[float, Field(ge=0, le=1)] - max_tokens: Annotated[int, Field(gt=0)] = Field(..., alias="maxTokens") - top_p: Annotated[float, Field(ge=0, le=1)] = Field(..., alias="topP") - frequency_penalty: Annotated[float, Field(ge=-2, le=2)] = Field(..., alias="frequencyPenalty") - presence_penalty: Annotated[float, Field(ge=-2, le=2)] = Field(..., alias="presencePenalty") + temperature: Optional[Annotated[float, Field(ge=0, le=1)]] = 0.7 + max_tokens: Optional[Annotated[int, Field(gt=0)]] = Field(..., alias="maxTokens") + top_p: Optional[Annotated[float, Field(ge=0, le=1)]] = Field(..., alias="topP") + frequency_penalty: Optional[Annotated[float, Field(ge=-2, le=2)]] = Field(..., alias="frequencyPenalty") + presence_penalty: Optional[Annotated[float, Field(ge=-2, le=2)]] = Field(..., alias="presencePenalty") system_prompt: Optional[str] = Field(None, alias="systemPrompt") - is_default: bool = Field(..., alias="isDefault") - created_at: datetime = Field(..., alias="createdAt") - updated_at: datetime = Field(..., alias="updatedAt") + is_default: Optional[bool] = Field(..., alias="isDefault") + created_at: Optional[datetime] = Field(..., alias="createdAt") + updated_at: Optional[datetime] = Field(..., alias="updatedAt") custom_model_config: Optional[Dict[str, Any]] = Field(None, alias="customModelConfig") class Config: populate_by_name = True # Allow both field names and aliases diff --git a/src/focused_test.py b/src/focused_test.py deleted file mode 100644 index f150dee..0000000 --- a/src/focused_test.py +++ /dev/null @@ -1,207 +0,0 @@ -#!/usr/bin/env python -""" -Focused test script that tests the most important functionality -without getting caught up in serialization format complexities -""" - -import sys -from datetime import datetime -from models import ( - UserStatus, UserType, SkillLevel, EmploymentType, - Candidate, Employer, Location, Skill, AIParameters, AIModelType -) - -def test_model_creation(): - """Test that we can create models successfully""" - print("๐Ÿงช Testing model creation...") - - # Create supporting objects - location = Location(city="Austin", country="USA") - skill = Skill(name="Python", category="Programming", level=SkillLevel.ADVANCED) - - # Create candidate - candidate = Candidate( - email="test@example.com", - username="test_candidate", - createdAt=datetime.now(), - updatedAt=datetime.now(), - status=UserStatus.ACTIVE, - firstName="John", - lastName="Doe", - fullName="John Doe", - skills=[skill], - experience=[], - education=[], - preferredJobTypes=[EmploymentType.FULL_TIME], - location=location, - languages=[], - certifications=[] - ) - - # Create employer - employer = Employer( - email="hr@company.com", - username="test_employer", - createdAt=datetime.now(), - updatedAt=datetime.now(), - status=UserStatus.ACTIVE, - companyName="Test Company", - industry="Technology", - companySize="50-200", - companyDescription="A test company", - location=location - ) - - print(f"โœ… Candidate: {candidate.first_name} {candidate.last_name}") - print(f"โœ… Employer: {employer.company_name}") - print(f"โœ… User types: {candidate.user_type}, {employer.user_type}") - - return candidate, employer - -def test_json_api_format(): - """Test JSON serialization in API format (the most important use case)""" - print("\n๐Ÿ“ก Testing JSON API format...") - - candidate, employer = test_model_creation() - - # Serialize to JSON (API format) - candidate_json = candidate.model_dump_json(by_alias=True) - employer_json = employer.model_dump_json(by_alias=True) - - print(f"โœ… Candidate JSON: {len(candidate_json)} chars") - print(f"โœ… Employer JSON: {len(employer_json)} chars") - - # Deserialize from JSON - candidate_back = Candidate.model_validate_json(candidate_json) - employer_back = Employer.model_validate_json(employer_json) - - # Verify data integrity - assert candidate_back.email == candidate.email - assert candidate_back.first_name == candidate.first_name - assert employer_back.company_name == employer.company_name - - print(f"โœ… JSON round-trip successful") - print(f"โœ… Data integrity verified") - - return True - -def test_api_dict_format(): - """Test dictionary format with aliases (for API requests/responses)""" - print("\n๐Ÿ“Š Testing API dictionary format...") - - candidate, employer = test_model_creation() - - # Create API format dictionaries - candidate_dict = candidate.model_dump(by_alias=True) - employer_dict = employer.model_dump(by_alias=True) - - # Verify camelCase aliases are used - assert "firstName" in candidate_dict - assert "lastName" in candidate_dict - assert "createdAt" in candidate_dict - assert "companyName" in employer_dict - - print(f"โœ… API format dictionaries created") - print(f"โœ… CamelCase aliases verified") - - # Test deserializing from API format - candidate_back = Candidate.model_validate(candidate_dict) - employer_back = Employer.model_validate(employer_dict) - - assert candidate_back.email == candidate.email - assert employer_back.company_name == employer.company_name - - print(f"โœ… API format round-trip successful") - - return True - -def test_validation_constraints(): - """Test that validation constraints work""" - print("\n๐Ÿ”’ Testing validation constraints...") - - # Test AI Parameters with constraints - valid_params = AIParameters( - name="Test Config", - model=AIModelType.GPT_4, - temperature=0.7, # Valid: 0-1 - maxTokens=2000, # Valid: > 0 - topP=0.95, # Valid: 0-1 - frequencyPenalty=0.0, # Valid: -2 to 2 - presencePenalty=0.0, # Valid: -2 to 2 - isDefault=True, - createdAt=datetime.now(), - updatedAt=datetime.now() - ) - print(f"โœ… Valid AI parameters created") - - # Test constraint violation - try: - invalid_params = AIParameters( - name="Invalid Config", - model=AIModelType.GPT_4, - temperature=1.5, # Invalid: > 1 - maxTokens=2000, - topP=0.95, - frequencyPenalty=0.0, - presencePenalty=0.0, - isDefault=True, - createdAt=datetime.now(), - updatedAt=datetime.now() - ) - print("โŒ Should have rejected invalid temperature") - return False - except Exception: - print(f"โœ… Constraint validation working") - - return True - -def test_enum_values(): - """Test that enum values work correctly""" - print("\n๐Ÿ“‹ Testing enum values...") - - # Test that enum values are properly handled - candidate, employer = test_model_creation() - - # Check enum values in serialization - candidate_dict = candidate.model_dump(by_alias=True) - - assert candidate_dict["status"] == "active" - assert candidate_dict["userType"] == "candidate" - assert employer.user_type == UserType.EMPLOYER - - print(f"โœ… Enum values correctly serialized") - print(f"โœ… User types: candidate={candidate.user_type}, employer={employer.user_type}") - - return True - -def main(): - """Run all focused tests""" - print("๐ŸŽฏ Focused Pydantic Model Tests") - print("=" * 40) - - try: - test_model_creation() - test_json_api_format() - test_api_dict_format() - test_validation_constraints() - test_enum_values() - - print(f"\n๐ŸŽ‰ All focused tests passed!") - print("=" * 40) - print("โœ… Models work correctly") - print("โœ… JSON API format works") - print("โœ… Validation constraints work") - print("โœ… Enum values work") - print("โœ… Ready for type generation!") - - return True - - except Exception as e: - print(f"\nโŒ Test failed: {type(e).__name__}: {e}") - import traceback - traceback.print_exc() - return False - -if __name__ == "__main__": - success = main() - sys.exit(0 if success else 1) \ No newline at end of file diff --git a/update-types.sh b/update-types.sh new file mode 100755 index 0000000..4e04899 --- /dev/null +++ b/update-types.sh @@ -0,0 +1,2 @@ +#!/bin/bash +docker compose exec backstory shell "python src/backend/generate_types.py --source src/backend/models.py --output frontend/src/types/types.ts ${*}"