diff --git a/backend/college_transfer_ai/app.py b/backend/college_transfer_ai/app.py index ffe4b11..47c26fd 100644 --- a/backend/college_transfer_ai/app.py +++ b/backend/college_transfer_ai/app.py @@ -17,6 +17,11 @@ from google.auth.transport import requests as google_requests # --- End Google Auth Imports --- +# --- Google AI Imports --- +import google.generativeai as genai +from google.generativeai.types import HarmCategory, HarmBlockThreshold +# --- End Google AI Imports --- + print("--- Flask app.py loading ---") load_dotenv() @@ -24,8 +29,24 @@ openai_api_key = os.getenv("OPENAI_API_KEY") MONGO_URI = os.getenv("MONGO_URI") GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") +GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY") # --- End Config Vars --- +# --- Add this print statement --- +print(f"--- Attempting to configure Google AI with Key: '{GOOGLE_API_KEY}' ---") +# --- End print statement --- + +# Google AI Client Setup +if not GOOGLE_API_KEY: + print("Warning; GOOGLE_API_KEY not set. Google AI chat will fail.") +else: + try: + genai.configure(api_key=GOOGLE_API_KEY) + print("Google AI client configured successfully.") + except Exception as e: + print(f"CRITICAL: Failed to configure Google AI client: {e}") +# --- End Google AI Client Setup --- + # --- Client Setups --- if not openai_api_key: print("Warning: OPENAI_API_KEY not set.") openai_client = OpenAI(api_key=openai_api_key) if openai_api_key else None @@ -221,48 +242,101 @@ def serve_image(image_filename): # Chat Endpoint (remains the same, does not use Google Auth) @app.route('/chat', methods=['POST']) def chat_with_agreement(): - if not openai_client: return jsonify({"error": "OpenAI client not configured."}), 500 + if not GOOGLE_API_KEY: # Check if Google AI is configured + return jsonify({"error": "Google AI client not configured."}), 500 + try: data = request.get_json() if not data: return jsonify({"error": "Invalid JSON payload"}), 400 + new_user_message_text = data.get('new_message') - conversation_history = data.get('history', []) - image_filenames = data.get('image_filenames') + # Google's ChatSession manages history internally, but we need to format it initially + openai_history = data.get('history', []) + image_filenames = data.get('image_filenames') # Only sent on first turn + if not new_user_message_text: return jsonify({"error": "Missing 'new_message' text"}), 400 - new_openai_message_content = [{"type": "text", "text": new_user_message_text}] + # Inside the /chat endpoint + + # --- Prepare Input for Gemini --- + prompt_parts = [new_user_message_text] # Start with the text part + if image_filenames: - print(f"Processing {len(image_filenames)} images for the first turn.") + print(f"Processing {len(image_filenames)} images for Gemini.") image_count = 0 for filename in image_filenames: try: grid_out = fs.find_one({"filename": filename}) - if not grid_out: continue - base64_image = base64.b64encode(grid_out.read()).decode('utf-8') - new_openai_message_content.append({ - "type": "image_url", - "image_url": {"url": f"data:{getattr(grid_out, 'contentType', 'image/png')};base64,{base64_image}"} + if not grid_out: + print(f"Image {filename} not found in GridFS. Skipping.") + continue + + image_bytes = grid_out.read() + # --- Determine MIME type (ensure it's correct) --- + # Use getattr for safety, default to image/png if not found + mime_type = getattr(grid_out, 'contentType', None) + if not mime_type: + # Basic check based on filename extension if contentType is missing + if filename.lower().endswith(".jpg") or filename.lower().endswith(".jpeg"): + mime_type = "image/jpeg" + elif filename.lower().endswith(".png"): + mime_type = "image/png" + # Add more types if needed (webp, heic, heif) + else: + mime_type = "image/png" # Default fallback + print(f"Warning: contentType missing for {filename}, inferred as {mime_type}") + # --- End MIME type determination --- + + + # --- Correct way to add image part --- + # Append a dictionary, not genai.types.Part + prompt_parts.append({ + "mime_type": mime_type, + "data": image_bytes # Send raw bytes directly }) + # --- End Correction --- + image_count += 1 - except Exception as img_err: print(f"Error reading/encoding image {filename}: {img_err}. Skipping.") - print(f"Added {image_count} images.") - else: print("No image filenames provided.") + except Exception as img_err: + print(f"Error reading/processing image {filename} for Gemini: {img_err}. Skipping.") + print(f"Added {image_count} images to Gemini prompt.") + else: + print("No image filenames provided for Gemini.") - conversation_history.append({"role": "user", "content": new_openai_message_content}) + # --- Convert OpenAI history to Gemini format (remains the same) --- + gemini_history = [] + # ... (history conversion code) ... + + # --- Initialize Gemini Model and Chat (remains the same) --- + model = genai.GenerativeModel('gemini-1.5-flash-latest') + chat = model.start_chat(history=gemini_history) + + print(f"Sending request to Gemini with {len(prompt_parts)} parts...") + + # --- Send message to Gemini (remains the same) --- + safety_settings = { # Optional safety settings + # ... (safety settings) ... + } + response = chat.send_message(prompt_parts, safety_settings=safety_settings) + + assistant_reply = response.text + print("Received reply from Gemini.") - print(f"Sending request to OpenAI with {len(conversation_history)} messages...") - chat_completion = openai_client.chat.completions.create( - model="gpt-4o-mini", messages=conversation_history, max_tokens=1000 - ) - assistant_reply = chat_completion.choices[0].message.content - print(f"Received reply from OpenAI.") return jsonify({"reply": assistant_reply}) + # ... (exception handling remains the same) ... + except Exception as e: - print(f"Error in /chat endpoint: {e}") + print(f"Error in /chat endpoint (Gemini): {e}") traceback.print_exc() - return jsonify({"error": f"An unexpected error occurred: {str(e)}"}), 500 -# --- End Existing Endpoints --- + # Try to provide a more specific error if possible + error_message = f"An unexpected error occurred with the AI chat: {str(e)}" + # Check for specific Google API errors if needed + # if isinstance(e, google.api_core.exceptions.GoogleAPIError): + # error_message = f"Google API Error: {e}" + return jsonify({"error": error_message}), 500 + +# --- End Chat Endpoint --- # --- Course Map Endpoints --- diff --git a/backend/college_transfer_ai/college_transfer_API.py b/backend/college_transfer_ai/college_transfer_API.py index 770ff7c..fc71d86 100644 --- a/backend/college_transfer_ai/college_transfer_API.py +++ b/backend/college_transfer_ai/college_transfer_API.py @@ -216,5 +216,3 @@ def get_articulation_agreement(self, academic_year_id, sending_institution_id, r api = CollegeTransferAPI() - -# print(json.dumps(api.get_major_from_key("75/125/to/1/Major/d5469a2c-be7e-452b-b492-08dca4e7496b"), indent=4)) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 02d6ff8..367bb48 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ pytest PyMuPDF openai dotenv +google-generativeai diff --git a/src/App.jsx b/src/App.jsx index 7ca7d8d..2c05e64 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -100,25 +100,6 @@ function App() { navigate('/'); }; - // Optional: Effect to listen for storage changes in other tabs (advanced) - // useEffect(() => { - // const handleStorageChange = (event) => { - // if (event.key === USER_STORAGE_KEY) { - // if (!event.newValue) { // User logged out in another tab - // setUser(null); - // } else { // User logged in/updated in another tab - // try { - // setUser(JSON.parse(event.newValue)); - // } catch { - // setUser(null); - // } - // } - // } - // }; - // window.addEventListener('storage', handleStorageChange); - // return () => window.removeEventListener('storage', handleStorageChange); - // }, []); - return ( <> {/* Navigation/Header remains the same */} @@ -137,7 +118,6 @@ function App() { )} diff --git a/src/components/CourseMap.jsx b/src/components/CourseMap.jsx index 8f4966e..d5710b8 100644 --- a/src/components/CourseMap.jsx +++ b/src/components/CourseMap.jsx @@ -112,10 +112,13 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. // Try loading from cache first unless forcing refresh if (!forceRefresh) { const cachedList = loadFromCache(cacheKey); - if (cachedList) { + if (cachedList && Array.isArray(cachedList)) { // Added Array.isArray check for cache setMapList(cachedList); setIsMapListLoading(false); return cachedList; + } else if (cachedList) { + console.warn("Cached map list was not an array, removing:", cachedList); + removeFromCache(cacheKey); // Remove invalid cache } } @@ -123,10 +126,19 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. setIsMapListLoading(true); try { console.log("Fetching map list from API..."); - const list = await fetchData('course-maps', { + const responseData = await fetchData('course-maps', { // Renamed 'list' to 'responseData' headers: { 'Authorization': `Bearer ${user.idToken}` } }); - const validList = list || []; + console.log("Raw API response for map list:", responseData); // Log the raw response + + // --- Added Check --- + // Ensure responseData is an array before setting state + const validList = Array.isArray(responseData) ? responseData : []; + if (!Array.isArray(responseData)) { + console.warn("API response for map list was not an array:", responseData); + } + // --- End Check --- + setMapList(validList); saveToCache(cacheKey, validList); // Save fetched list to cache console.log("Map list fetched and cached:", validList); @@ -165,18 +177,24 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. 'Authorization': `Bearer ${user.idToken}`, 'Content-Type': 'application/json' // Good practice }, - // Send the default name in the body - body: JSON.stringify({ map_name: mapName }) + // --- Send map_name AND empty nodes/edges --- + body: JSON.stringify({ + map_name: mapName, + nodes: [], // Always send empty nodes array for new map + edges: [] // Always send empty edges array for new map + }) + // --- End Change --- }); + // --- Response handling remains the same --- if (newMapData && newMapData.map_id) { console.log("New map record created:", newMapData); // 1. Update Map List State & Cache const newMapEntry = { map_id: newMapData.map_id, - map_name: newMapData.map_name, - last_updated: newMapData.last_updated + map_name: newMapData.map_name || mapName, // Use returned name or prompted name + last_updated: newMapData.last_updated || new Date().toISOString() // Use returned date or now }; setMapList(prevList => { const newList = [newMapEntry, ...prevList]; @@ -190,7 +208,7 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. setNodes(defaultNodes); // Reset nodes/edges for the new map setEdges(defaultEdges); setCurrentMapId(newMapData.map_id); // Set the new ID - setCurrentMapName(newMapData.map_name); // Set the name + setCurrentMapName(newMapEntry.map_name); // Set the name from newMapEntry idCounter = 0; // Reset node counter // 3. Update Specific Map Cache (optional but good practice) @@ -199,8 +217,8 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. nodes: defaultNodes, edges: defaultEdges, map_id: newMapData.map_id, - map_name: newMapData.map_name, - last_updated: newMapData.last_updated + map_name: newMapEntry.map_name, + last_updated: newMapEntry.last_updated }); setSaveStatus("New map created."); @@ -209,6 +227,7 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. } else { throw new Error("Failed to create map record: Invalid response from server."); } + // --- End Response handling --- } catch (error) { console.error("Failed to create new map:", error); @@ -230,6 +249,7 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. setCurrentMapName('Untitled Map'); idCounter = 0; setIsLoading(false); + setSaveStatus(''); // Clear any previous status return; } @@ -406,24 +426,22 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. setSaveStatus(`Error saving map: ${error.message}`); } }, [userId, user?.idToken, nodes, edges, currentMapId, currentMapName, loadMapList, setMapList]); // Added setMapList dependency - - // --- Handle New Map --- // --- Handle Map Selection Change --- const handleMapSelectChange = (event) => { const selectedId = event.target.value; if (selectedId === "__NEW__") { - // Call handleNewMap which now includes the prompt - handleNewMap(); + console.log("Selected [Untitled Map], resetting view."); + loadSpecificMap(null); } else { // Load existing map (check cache first) loadSpecificMap(selectedId); } }; - // --- Handle Delete Map --- - const handleDeleteMap = useCallback(async () => { + // --- Handle Delete Map --- + const handleDeleteMap = useCallback(async () => { if (!currentMapId || !userId || !user?.idToken) { setSaveStatus("No map selected to delete or not logged in."); return; @@ -435,40 +453,65 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. const mapToDeleteId = currentMapId; // Capture ID before state changes const mapCacheKey = getCacheKey('courseMap', userId, mapToDeleteId); + const listCacheKey = getCacheKey('courseMapList', userId); // Cache key for the list - // --- Add Logging --- console.log(`[Delete Attempt] User ID: ${userId}, Map ID: ${mapToDeleteId}`); - // --- End Logging --- - setSaveStatus("Deleting..."); - console.log(`Attempting to delete map: ${currentMapId}`); try { - await fetchData(`course-map/${mapToDeleteId}`, { // Ensure mapToDeleteId is correct here + // --- Call Backend DELETE --- + await fetchData(`course-map/${mapToDeleteId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${user.idToken}` } }); + // --- Backend Delete Successful --- - console.log("Map deleted successfully."); + console.log("Map deleted successfully from backend."); setSaveStatus("Map deleted."); - removeFromCache(mapCacheKey); // <-- Used here - // Load the list again and load the next available map (or new) - loadMapList().then((list) => { - if (list && list.length > 0) { - loadSpecificMap(list[0].map_id); // Load most recent - } else { - handleNewMap(); // No maps left, create new + + // --- Frontend Cleanup --- + // 1. Remove from specific map cache + removeFromCache(mapCacheKey); + + // 2. Update mapList state and list cache + let nextMapIdToLoad = null; + setMapList(prevList => { + const newList = prevList.filter(map => map.map_id !== mapToDeleteId); + // Determine which map to load next (e.g., the first one in the updated list) + if (newList.length > 0) { + newList.sort((a, b) => new Date(b.last_updated) - new Date(a.last_updated)); // Re-sort just in case + nextMapIdToLoad = newList[0].map_id; } + saveToCache(listCacheKey, newList); // Update list cache + return newList; }); - setTimeout(() => setSaveStatus(''), 2000); + + // 3. Load the next map (or reset if list is empty) + if (nextMapIdToLoad) { + loadSpecificMap(nextMapIdToLoad); + } else { + // No maps left, reset to a new, unsaved state + setNodes(defaultNodes); + setEdges(defaultEdges); + setCurrentMapId(null); + setCurrentMapName('Untitled Map'); + idCounter = 0; + // Optionally, call handleNewMap() if you want to immediately prompt for a name + // handleNewMap(); + } + // --- End Frontend Cleanup --- + + setTimeout(() => setSaveStatus(''), 2000); } catch (error) { console.error(`[Delete Failed] User ID: ${userId}, Map ID: ${mapToDeleteId}`, error); setSaveStatus(`Error deleting map: ${error.message}`); + // Optionally, force refresh the list from API on error to ensure consistency + loadMapList(true); } - }, [userId, user?.idToken, currentMapId, currentMapName, loadMapList, loadSpecificMap, handleNewMap]); + }, [userId, user?.idToken, currentMapId, currentMapName, loadMapList, loadSpecificMap, setMapList, setNodes, setEdges]); // Added setMapList, setNodes, setEdges // --- Other Callbacks (onConnect, addNode, startEditing, handleEditChange, saveEdit, onPaneClick) remain the same --- @@ -531,7 +574,7 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc.