rairo commited on
Commit
9337b76
·
verified ·
1 Parent(s): d586fdf

Create main.py

Browse files
Files changed (1) hide show
  1. main.py +613 -0
main.py ADDED
@@ -0,0 +1,613 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import io
3
+ import uuid
4
+ import re
5
+ import time
6
+ import json
7
+ import traceback
8
+ import wave
9
+ from datetime import datetime, timedelta
10
+
11
+ from flask import Flask, request, jsonify
12
+ from flask_cors import CORS
13
+ import firebase_admin
14
+ from firebase_admin import credentials, db, storage, auth
15
+ from PIL import Image
16
+ import requests
17
+
18
+ # Import and configure Google GenAI, matching the Streamlit app
19
+ from google import genai
20
+ from google.genai import types
21
+
22
+ # -----------------------------------------------------------------------------
23
+ # 1. CONFIGURATION & INITIALIZATION
24
+ # -----------------------------------------------------------------------------
25
+
26
+ # Initialize Flask app and CORS
27
+ app = Flask(__name__)
28
+ CORS(app)
29
+
30
+ # --- Firebase Initialization ---
31
+ try:
32
+ credentials_json_string = os.environ.get("FIREBASE")
33
+ if not credentials_json_string:
34
+ raise ValueError("The FIREBASE environment variable is not set.")
35
+
36
+ credentials_json = json.loads(credentials_json_string)
37
+ firebase_db_url = os.environ.get("Firebase_DB")
38
+ firebase_storage_bucket = os.environ.get("Firebase_Storage")
39
+
40
+ if not firebase_db_url or not firebase_storage_bucket:
41
+ raise ValueError("Firebase_DB and Firebase_Storage environment variables must be set.")
42
+
43
+ cred = credentials.Certificate(credentials_json)
44
+ firebase_admin.initialize_app(cred, {
45
+ 'databaseURL': firebase_db_url,
46
+ 'storageBucket': firebase_storage_bucket
47
+ })
48
+ print("Firebase Admin SDK initialized successfully.")
49
+ except Exception as e:
50
+ print(f"FATAL: Error initializing Firebase: {e}")
51
+ exit(1)
52
+
53
+ # Initialize Firebase services
54
+ bucket = storage.bucket()
55
+ db_ref = db.reference()
56
+
57
+
58
+ # --- Google GenAI Client Initialization (as per Streamlit app) ---
59
+ try:
60
+ api_key = os.environ.get("Gemini")
61
+ if not api_key:
62
+ raise ValueError("The 'Gemini' environment variable for the API key is not set.")
63
+
64
+ client = genai.Client(api_key=api_key)
65
+ print("Google GenAI Client initialized successfully.")
66
+ except Exception as e:
67
+ print(f"FATAL: Error initializing GenAI Client: {e}")
68
+ exit(1)
69
+
70
+ # --- Model Constants (as per Streamlit app) ---
71
+ CATEGORY_MODEL = "gemini-2.0-flash-exp"
72
+ GENERATION_MODEL = "gemini-2.0-flash-exp-image-generation"
73
+ TTS_MODEL = "gemini-2.5-flash-preview-tts"
74
+
75
+
76
+ # -----------------------------------------------------------------------------
77
+ # 2. HELPER FUNCTIONS (Adapted directly from Streamlit App & Template)
78
+ # -----------------------------------------------------------------------------
79
+
80
+ def verify_token(auth_header):
81
+ """Verifies the Firebase ID token from the Authorization header."""
82
+ if not auth_header or not auth_header.startswith('Bearer '):
83
+ return None
84
+ token = auth_header.split('Bearer ')[1]
85
+ try:
86
+ decoded_token = auth.verify_id_token(token)
87
+ return decoded_token['uid']
88
+ except Exception as e:
89
+ print(f"Token verification failed: {e}")
90
+ return None
91
+
92
+ def verify_admin(auth_header):
93
+ """Verifies if the user is an admin."""
94
+ uid = verify_token(auth_header)
95
+ if not uid:
96
+ raise PermissionError('Invalid or missing user token')
97
+
98
+ user_ref = db_ref.child(f'users/{uid}')
99
+ user_data = user_ref.get()
100
+ if not user_data or not user_data.get('is_admin', False):
101
+ raise PermissionError('Admin access required')
102
+ return uid
103
+
104
+ def upload_to_storage(data_bytes, destination_blob_name, content_type):
105
+ """Uploads a bytes object to Firebase Storage and returns its public URL."""
106
+ blob = bucket.blob(destination_blob_name)
107
+ blob.upload_from_string(data_bytes, content_type=content_type)
108
+ blob.make_public()
109
+ return blob.public_url
110
+
111
+ def parse_numbered_steps(text):
112
+ """Helper to parse numbered steps out of Gemini text."""
113
+ text = "\n" + text
114
+ steps_found = re.findall(r"\n\s*(\d+)\.\s*(.*)", text, re.MULTILINE)
115
+ return [{"stepNumber": int(num), "text": desc.strip()} for num, desc in steps_found]
116
+
117
+ def _convert_pcm_to_wav(pcm_data, sample_rate=24000, channels=1, sample_width=2):
118
+ """Wraps raw PCM audio data in a WAV container in memory."""
119
+ audio_buffer = io.BytesIO()
120
+ with wave.open(audio_buffer, 'wb') as wf:
121
+ wf.setnchannels(channels)
122
+ wf.setsampwidth(sample_width)
123
+ wf.setframerate(sample_rate)
124
+ wf.writeframes(pcm_data)
125
+ audio_buffer.seek(0)
126
+ return audio_buffer.getvalue()
127
+
128
+ def generate_tts_audio_and_upload(text_to_speak, uid, project_id, step_num):
129
+ """Generates audio using the exact method from the Streamlit app and uploads it."""
130
+ try:
131
+ response = client.models.generate_content(
132
+ model=TTS_MODEL,
133
+ contents=f"Say clearly: {text_to_speak}",
134
+ config=types.GenerateContentConfig(
135
+ response_modalities=["AUDIO"],
136
+ speech_config=types.SpeechConfig(
137
+ voice_config=types.VoiceConfig(
138
+ prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name='Kore')
139
+ )
140
+ ),
141
+ )
142
+ )
143
+ audio_part = response.candidates[0].content.parts[0]
144
+ audio_data = audio_part.inline_data.data
145
+ mime_type = audio_part.inline_data.mime_type
146
+
147
+ final_audio_bytes = _convert_pcm_to_wav(audio_data) if 'pcm' in mime_type else audio_data
148
+
149
+ audio_path = f"users/{uid}/projects/{project_id}/narrations/step_{step_num}.wav"
150
+ return upload_to_storage(final_audio_bytes, audio_path, 'audio/wav')
151
+ except Exception as e:
152
+ print(f"Error during TTS generation for step {step_num}: {e}")
153
+ return None
154
+
155
+ def send_text_request(model_name, prompt, image):
156
+ """Helper to send requests that expect only a text response."""
157
+ try:
158
+ chat = client.chats.create(model=model_name)
159
+ response = chat.send_message([prompt, image])
160
+ response_text = "".join(part.text for part in response.candidates[0].content.parts if hasattr(part, 'text'))
161
+ return response_text.strip()
162
+ except Exception as e:
163
+ print(f"Error with model {model_name}: {e}")
164
+ return None
165
+
166
+ # -----------------------------------------------------------------------------
167
+ # 3. AUTHENTICATION & USER MANAGEMENT
168
+ # -----------------------------------------------------------------------------
169
+
170
+ @app.route('/api/auth/signup', methods=['POST'])
171
+ def signup():
172
+ try:
173
+ data = request.get_json()
174
+ email, password = data.get('email'), data.get('password')
175
+ if not email or not password: return jsonify({'error': 'Email and password are required'}), 400
176
+
177
+ user = auth.create_user(email=email, password=password)
178
+ user_ref = db_ref.child(f'users/{user.uid}')
179
+ user_data = {'email': email, 'credits': 15, 'is_admin': False, 'createdAt': datetime.utcnow().isoformat()}
180
+ user_ref.set(user_data)
181
+ return jsonify({'success': True, 'uid': user.uid, **user_data}), 201
182
+ except Exception as e:
183
+ return jsonify({'error': str(e)}), 400
184
+
185
+ @app.route('/api/user/profile', methods=['GET'])
186
+ def get_user_profile():
187
+ uid = verify_token(request.headers.get('Authorization'))
188
+ if not uid: return jsonify({'error': 'Invalid or expired token'}), 401
189
+
190
+ user_data = db_ref.child(f'users/{uid}').get()
191
+ if not user_data: return jsonify({'error': 'User not found'}), 404
192
+
193
+ return jsonify({'uid': uid, **user_data})
194
+
195
+ # -----------------------------------------------------------------------------
196
+ # 4. FEEDBACK AND CREDIT REQUESTS (USER-FACING)
197
+ # -----------------------------------------------------------------------------
198
+
199
+ @app.route('/api/feedback', methods=['POST'])
200
+ def submit_feedback():
201
+ uid = verify_token(request.headers.get('Authorization'))
202
+ if not uid: return jsonify({'error': 'Invalid or expired token'}), 401
203
+
204
+ try:
205
+ data = request.get_json()
206
+ if not data or not data.get('message'): return jsonify({'error': 'Message is required'}), 400
207
+
208
+ user_email = (db_ref.child(f'users/{uid}').get() or {}).get('email', 'unknown')
209
+
210
+ feedback_ref = db_ref.child('feedback').push()
211
+ feedback_record = {
212
+ "feedbackId": feedback_ref.key,
213
+ "userId": uid,
214
+ "userEmail": user_email,
215
+ "type": data.get('type', 'general'),
216
+ "message": data.get('message'),
217
+ "createdAt": datetime.utcnow().isoformat(),
218
+ "status": "open"
219
+ }
220
+ feedback_ref.set(feedback_record)
221
+ return jsonify({"success": True, "feedbackId": feedback_ref.key}), 201
222
+ except Exception as e:
223
+ return jsonify({'error': str(e)}), 500
224
+
225
+ @app.route('/api/user/request-credits', methods=['POST'])
226
+ def request_credits():
227
+ uid = verify_token(request.headers.get('Authorization'))
228
+ if not uid: return jsonify({'error': 'Invalid or expired token'}), 401
229
+
230
+ try:
231
+ data = request.get_json()
232
+ if not data or 'requested_credits' not in data: return jsonify({'error': 'requested_credits is required'}), 400
233
+
234
+ request_ref = db_ref.child('credit_requests').push()
235
+ request_ref.set({
236
+ 'requestId': request_ref.key,
237
+ 'userId': uid,
238
+ 'requested_credits': data['requested_credits'],
239
+ 'status': 'pending',
240
+ 'requestedAt': datetime.utcnow().isoformat()
241
+ })
242
+ return jsonify({'success': True, 'requestId': request_ref.key})
243
+ except Exception as e:
244
+ return jsonify({'error': str(e)}), 500
245
+
246
+ # -----------------------------------------------------------------------------
247
+ # 5. ADMIN ENDPOINTS
248
+ # -----------------------------------------------------------------------------
249
+
250
+ @app.route('/api/admin/profile', methods=['GET'])
251
+ def get_admin_profile():
252
+ try:
253
+ admin_uid = verify_admin(request.headers.get('Authorization'))
254
+
255
+ # Fetch all necessary data from Firebase in one go
256
+ all_users = db_ref.child('users').get() or {}
257
+ all_projects = db_ref.child('projects').get() or {}
258
+ all_feedback = db_ref.child('feedback').get() or {}
259
+ all_credit_requests = db_ref.child('credit_requests').get() or {}
260
+
261
+ # --- User Statistics Calculation ---
262
+ total_users = len(all_users)
263
+ admin_count = 0
264
+ total_credits_in_system = 0
265
+ new_users_last_7_days = 0
266
+ seven_days_ago = datetime.utcnow() - timedelta(days=7)
267
+
268
+ for user_data in all_users.values():
269
+ if user_data.get('is_admin', False):
270
+ admin_count += 1
271
+ total_credits_in_system += user_data.get('credits', 0)
272
+
273
+ # Check for new users
274
+ try:
275
+ created_at_str = user_data.get('createdAt')
276
+ if created_at_str:
277
+ # Accommodate different possible ISO formats
278
+ user_created_at = datetime.fromisoformat(created_at_str.replace('Z', '+00:00'))
279
+ if user_created_at.replace(tzinfo=None) > seven_days_ago:
280
+ new_users_last_7_days += 1
281
+ except (ValueError, TypeError):
282
+ # Ignore if date format is invalid or missing
283
+ pass
284
+
285
+ # --- Project Statistics Calculation ---
286
+ total_projects = len(all_projects)
287
+ projects_by_status = {
288
+ "awaiting_approval": 0,
289
+ "awaiting_selection": 0,
290
+ "ready": 0,
291
+ "unknown": 0
292
+ }
293
+ projects_by_category = {}
294
+
295
+ for project_data in all_projects.values():
296
+ # Tally by status
297
+ status = project_data.get('status', 'unknown')
298
+ projects_by_status[status] = projects_by_status.get(status, 0) + 1
299
+
300
+ # Tally by category
301
+ category = project_data.get('category', 'N/A')
302
+ projects_by_category[category] = projects_by_category.get(category, 0) + 1
303
+
304
+ # --- System Health Calculation ---
305
+ open_feedback_count = sum(1 for fb in all_feedback.values() if fb.get('status') == 'open')
306
+ pending_requests_count = sum(1 for req in all_credit_requests.values() if req.get('status') == 'pending')
307
+
308
+ # Assemble the final response object
309
+ admin_personal_data = all_users.get(admin_uid, {})
310
+
311
+ response_data = {
312
+ 'uid': admin_uid,
313
+ 'email': admin_personal_data.get('email'),
314
+ 'credits': admin_personal_data.get('credits'),
315
+ 'is_admin': True,
316
+ 'dashboardStats': {
317
+ 'users': {
318
+ 'total': total_users,
319
+ 'admins': admin_count,
320
+ 'regular': total_users - admin_count,
321
+ 'newLast7Days': new_users_last_7_days,
322
+ 'totalCreditsInSystem': total_credits_in_system
323
+ },
324
+ 'projects': {
325
+ 'total': total_projects,
326
+ 'byStatus': projects_by_status,
327
+ 'byCategory': projects_by_category
328
+ },
329
+ 'system': {
330
+ 'openFeedback': open_feedback_count,
331
+ 'pendingCreditRequests': pending_requests_count
332
+ }
333
+ }
334
+ }
335
+
336
+ return jsonify(response_data), 200
337
+
338
+ except PermissionError as e:
339
+ return jsonify({'error': str(e)}), 403 # Use 403 Forbidden for permission issues
340
+ except Exception as e:
341
+ print(traceback.format_exc())
342
+ return jsonify({'error': f"An internal error occurred: {e}"}), 500
343
+
344
+ @app.route('/api/admin/credit_requests', methods=['GET'])
345
+ def list_credit_requests():
346
+ try:
347
+ verify_admin(request.headers.get('Authorization'))
348
+ requests_data = db_ref.child('credit_requests').get() or {}
349
+ return jsonify(list(requests_data.values()))
350
+ except Exception as e:
351
+ return jsonify({'error': str(e)}), 500
352
+
353
+ @app.route('/api/admin/credit_requests/<string:request_id>', methods=['PUT'])
354
+ def process_credit_request(request_id):
355
+ try:
356
+ admin_uid = verify_admin(request.headers.get('Authorization'))
357
+ req_ref = db_ref.child(f'credit_requests/{request_id}')
358
+ req_data = req_ref.get()
359
+ if not req_data: return jsonify({'error': 'Credit request not found'}), 404
360
+
361
+ decision = request.json.get('decision')
362
+ if decision not in ['approved', 'declined']: return jsonify({'error': 'Decision must be "approved" or "declined"'}), 400
363
+
364
+ if decision == 'approved':
365
+ user_ref = db_ref.child(f'users/{req_data["userId"]}')
366
+ user_data = user_ref.get()
367
+ if user_data:
368
+ new_total = user_data.get('credits', 0) + int(req_data.get('requested_credits', 0))
369
+ user_ref.update({'credits': new_total})
370
+
371
+ req_ref.update({'status': decision, 'processedBy': admin_uid, 'processedAt': datetime.utcnow().isoformat()})
372
+ return jsonify({'success': True, 'message': f'Request {decision}.'})
373
+ except Exception as e:
374
+ return jsonify({'error': str(e)}), 500
375
+
376
+ @app.route('/api/admin/feedback', methods=['GET'])
377
+ def admin_view_feedback():
378
+ try:
379
+ verify_admin(request.headers.get('Authorization'))
380
+ feedback_data = db_ref.child('feedback').get() or {}
381
+ return jsonify(list(feedback_data.values()))
382
+ except Exception as e:
383
+ return jsonify({'error': str(e)}), 500
384
+
385
+ @app.route('/api/admin/users', methods=['GET'])
386
+ def admin_list_users():
387
+ try:
388
+ verify_admin(request.headers.get('Authorization'))
389
+ all_users = db_ref.child('users').get() or {}
390
+ user_list = [{'uid': uid, **data} for uid, data in all_users.items()]
391
+ return jsonify(user_list)
392
+ except Exception as e:
393
+ return jsonify({'error': str(e)}), 500
394
+
395
+ @app.route('/api/admin/users/<string:uid>/credits', methods=['PUT'])
396
+ def admin_update_credits(uid):
397
+ try:
398
+ verify_admin(request.headers.get('Authorization'))
399
+ add_credits = request.json.get('add_credits')
400
+ if add_credits is None: return jsonify({'error': 'add_credits is required'}), 400
401
+
402
+ user_ref = db_ref.child(f'users/{uid}')
403
+ user_data = user_ref.get()
404
+ if not user_data: return jsonify({'error': 'User not found'}), 404
405
+
406
+ new_total = user_data.get('credits', 0) + float(add_credits)
407
+ user_ref.update({'credits': new_total})
408
+ return jsonify({'success': True, 'new_total_credits': new_total})
409
+ except Exception as e:
410
+ return jsonify({'error': str(e)}), 500
411
+
412
+ # -----------------------------------------------------------------------------
413
+ # 6. DIY PROJECT ENDPOINTS (Core Logic)
414
+ # -----------------------------------------------------------------------------
415
+ # (The project endpoints from the previous answer go here, unchanged)
416
+ @app.route('/api/projects', methods=['POST'])
417
+ def create_project():
418
+ uid = verify_token(request.headers.get('Authorization'))
419
+ if not uid: return jsonify({'error': 'Unauthorized'}), 401
420
+
421
+ user_ref = db_ref.child(f'users/{uid}')
422
+ user_data = user_ref.get()
423
+ if not user_data or user_data.get('credits', 0) < 1:
424
+ return jsonify({'error': 'Insufficient credits'}), 402
425
+
426
+ if 'image' not in request.files:
427
+ return jsonify({'error': 'Image file is required'}), 400
428
+
429
+ image_file = request.files['image']
430
+ context_text = request.form.get('contextText', '')
431
+ image_bytes = image_file.read()
432
+ pil_image = Image.open(io.BytesIO(image_bytes))
433
+
434
+ try:
435
+ category_prompt = (
436
+ "You are an expert DIY assistant. Analyze the user's image and context. "
437
+ f"Context: '{context_text}'. "
438
+ "Categorize the project into ONE of the following: "
439
+ "Home Appliance Repair, Automotive Maintenance, Gardening & Urban Farming, "
440
+ "Upcycling & Sustainable Crafts, or DIY Project Creation. "
441
+ "Reply with ONLY the category name."
442
+ )
443
+ category = send_text_request(CATEGORY_MODEL, category_prompt, pil_image)
444
+ if not category: return jsonify({'error': 'Failed to get project category from AI.'}), 500
445
+
446
+ plan_prompt = f"""
447
+ You are an expert DIY assistant in the category: {category}.
448
+ User Context: "{context_text if context_text else 'No context provided.'}"
449
+ Based on the image and context, perform the following:
450
+ 1. **Title:** Create a short, clear title for this project.
451
+ 2. **Description:** Write a brief, one-paragraph description of the goal.
452
+ 3. **Initial Plan:**
453
+ - If 'Upcycling & Sustainable Crafts' AND no specific project is mentioned, propose three distinct project options as a numbered list under "UPCYCLING OPTIONS:".
454
+ - For all other cases, briefly outline the main stages of the proposed solution.
455
+ Structure your response EXACTLY like this:
456
+ TITLE: [Your title]
457
+ DESCRIPTION: [Your description]
458
+ INITIAL PLAN:
459
+ [Your plan or 3 options]
460
+ """
461
+ plan_response = send_text_request(GENERATION_MODEL, plan_prompt, pil_image)
462
+ if not plan_response: return jsonify({'error': 'Failed to generate project plan from AI.'}), 500
463
+
464
+ title = re.search(r"TITLE:\s*(.*)", plan_response).group(1).strip()
465
+ description = re.search(r"DESCRIPTION:\s*(.*)", plan_response, re.DOTALL).group(1).strip()
466
+ initial_plan_text = re.search(r"INITIAL PLAN:\s*(.*)", plan_response, re.DOTALL).group(1).strip()
467
+
468
+ upcycling_options = re.findall(r"^\s*\d+\.\s*(.*)", initial_plan_text, re.MULTILINE) if "UPCYCLING OPTIONS:" in initial_plan_text else []
469
+ initial_plan = initial_plan_text if not upcycling_options else ""
470
+ status = "awaiting_selection" if upcycling_options else "awaiting_approval"
471
+
472
+ project_id = str(uuid.uuid4())
473
+ image_path = f"users/{uid}/projects/{project_id}/initial_image.png"
474
+ image_url = upload_to_storage(image_bytes, image_path, content_type=image_file.content_type)
475
+
476
+ project_data = {
477
+ "uid": uid, "projectId": project_id, "status": status, "createdAt": datetime.utcnow().isoformat(),
478
+ "userImageURL": image_url, "contextText": context_text, "projectTitle": title,
479
+ "projectDescription": description, "category": category, "initialPlan": initial_plan,
480
+ "upcyclingOptions": upcycling_options, "toolsList": [], "steps": []
481
+ }
482
+ db_ref.child(f'projects/{project_id}').set(project_data)
483
+
484
+ user_ref.update({'credits': user_data.get('credits', 1) - 1})
485
+ return jsonify(project_data), 201
486
+
487
+ except Exception as e:
488
+ print(traceback.format_exc())
489
+ return jsonify({'error': f"An error occurred: {e}"}), 500
490
+
491
+ @app.route('/api/projects/<string:project_id>/approve', methods=['PUT'])
492
+ def approve_project_plan(project_id):
493
+ uid = verify_token(request.headers.get('Authorization'))
494
+ if not uid: return jsonify({'error': 'Unauthorized'}), 401
495
+
496
+ project_ref = db_ref.child(f'projects/{project_id}')
497
+ project_data = project_ref.get()
498
+ if not project_data or project_data.get('uid') != uid:
499
+ return jsonify({'error': 'Project not found or access denied'}), 404
500
+
501
+ selected_option = request.json.get('selectedOption')
502
+ response = requests.get(project_data['userImageURL'])
503
+ pil_image = Image.open(io.BytesIO(response.content))
504
+
505
+ context = f"The user chose the upcycling project: '{selected_option}'." if selected_option else f"The user has approved the plan for '{project_data['projectTitle']}'."
506
+
507
+ detailed_prompt = f"""
508
+ You are a DIY expert. The user wants to proceed with the project titled "{project_data['projectTitle']}".
509
+ {context}
510
+ Provide a detailed guide. For each step, you MUST provide a simple, clear illustrative image.
511
+ Format your response EXACTLY like this:
512
+
513
+ TOOLS AND MATERIALS:
514
+ - Tool A
515
+ - Material B
516
+
517
+ STEPS(Maximum 7 steps):
518
+ 1. First step instructions.
519
+ 2. Second step instructions...
520
+ """
521
+ try:
522
+ chat = client.chats.create(model=GENERATION_MODEL, config=types.GenerateContentConfig(response_modalities=["Text", "Image"]))
523
+ full_resp = chat.send_message([detailed_prompt, pil_image])
524
+
525
+ combined_text = "".join(part.text + "\n" for part in full_resp.parts if hasattr(part, 'text') and part.text)
526
+ inline_images = [Image.open(BytesIO(part.inline_data.data)) for part in full_resp.parts if hasattr(part, 'inline_data')]
527
+
528
+ tools_section = re.search(r"TOOLS AND MATERIALS:\s*(.*?)\s*STEPS:", combined_text, re.DOTALL).group(1).strip()
529
+ steps_section = re.search(r"STEPS:\s*(.*)", combined_text, re.DOTALL).group(1).strip()
530
+
531
+ tools_list = [line.strip("- ").strip() for line in tools_section.split('\n') if line.strip()]
532
+ parsed_steps = parse_numbered_steps(steps_section)
533
+
534
+ if len(parsed_steps) != len(inline_images): return jsonify({'error': 'AI response mismatch: Steps and images do not match.'}), 500
535
+
536
+ final_steps = []
537
+ for i, step_info in enumerate(parsed_steps):
538
+ img_byte_arr = io.BytesIO()
539
+ inline_images[i].save(img_byte_arr, format='PNG')
540
+ img_path = f"users/{uid}/projects/{project_id}/steps/step_{i+1}_image.png"
541
+ img_url = upload_to_storage(img_byte_arr.getvalue(), img_path, 'image/png')
542
+
543
+ narration_url = generate_tts_audio_and_upload(step_info['text'], uid, project_id, i + 1)
544
+ step_info.update({"imageUrl": img_url, "narrationUrl": narration_url, "isDone": False, "notes": ""})
545
+ final_steps.append(step_info)
546
+
547
+ update_data = {"status": "ready", "toolsList": tools_list, "steps": final_steps, "selectedOption": selected_option or ""}
548
+ project_ref.update(update_data)
549
+
550
+ return jsonify({"success": True, **update_data})
551
+
552
+ except Exception as e:
553
+ print(traceback.format_exc())
554
+ return jsonify({'error': f"Failed to generate detailed guide: {e}"}), 500
555
+
556
+ @app.route('/api/projects', methods=['GET'])
557
+ def list_projects():
558
+ uid = verify_token(request.headers.get('Authorization'))
559
+ if not uid: return jsonify({'error': 'Unauthorized'}), 401
560
+ projects = (db_ref.child('projects').order_by_child('uid').equal_to(uid).get() or {}).values()
561
+ return jsonify(list(projects))
562
+
563
+ @app.route('/api/projects/<string:project_id>', methods=['GET'])
564
+ def get_project(project_id):
565
+ uid = verify_token(request.headers.get('Authorization'))
566
+ if not uid: return jsonify({'error': 'Unauthorized'}), 401
567
+ project_data = db_ref.child(f'projects/{project_id}').get()
568
+ if not project_data or project_data.get('uid') != uid:
569
+ return jsonify({'error': 'Project not found or access denied'}), 404
570
+ return jsonify(project_data)
571
+
572
+ @app.route('/api/projects/<string:project_id>/step/<int:step_number>', methods=['PUT'])
573
+ def update_step(project_id, step_number):
574
+ uid = verify_token(request.headers.get('Authorization'))
575
+ if not uid: return jsonify({'error': 'Unauthorized'}), 401
576
+ data = request.get_json()
577
+ if data is None: return jsonify({'error': 'JSON body is required'}), 400
578
+
579
+ project_data = db_ref.child(f'projects/{project_id}').get()
580
+ if not project_data or project_data.get('uid') != uid:
581
+ return jsonify({'error': 'Project not found or access denied'}), 404
582
+
583
+ steps = project_data.get('steps', [])
584
+ step_index = next((i for i, s in enumerate(steps) if s.get('stepNumber') == step_number), -1)
585
+ if step_index == -1: return jsonify({'error': f'Step number {step_number} not found'}), 404
586
+
587
+ step_path = f'projects/{project_id}/steps/{step_index}'
588
+ if 'isDone' in data: db_ref.child(f'{step_path}/isDone').set(bool(data['isDone']))
589
+ if 'notes' in data: db_ref.child(f'{step_path}/notes').set(str(data['notes']))
590
+
591
+ return jsonify({"success": True, "updatedStep": db_ref.child(step_path).get()})
592
+
593
+ @app.route('/api/projects/<string:project_id>', methods=['DELETE'])
594
+ def delete_project(project_id):
595
+ uid = verify_token(request.headers.get('Authorization'))
596
+ if not uid: return jsonify({'error': 'Unauthorized'}), 401
597
+
598
+ project_ref = db_ref.child(f'projects/{project_id}')
599
+ project_data = project_ref.get()
600
+ if not project_data or project_data.get('uid') != uid:
601
+ return jsonify({'error': 'Project not found or access denied'}), 404
602
+
603
+ project_ref.delete()
604
+ for blob in bucket.list_blobs(prefix=f"users/{uid}/projects/{project_id}/"):
605
+ blob.delete()
606
+ return jsonify({"success": True, "message": f"Project {project_id} deleted."})
607
+
608
+
609
+ # -----------------------------------------------------------------------------
610
+ # 7. MAIN EXECUTION
611
+ # -----------------------------------------------------------------------------
612
+ if __name__ == '__main__':
613
+ app.run(debug=True, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)))