rairo Claude Fable 5 commited on
Commit
0fc7336
·
2 Parent(s): 4cade00f41c5bb

Merge branch async-generation-catalogue: async iterative generation + supplier catalogue

Browse files
Files changed (1) hide show
  1. main.py +567 -245
main.py CHANGED
@@ -6,6 +6,8 @@ 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, Response
@@ -87,9 +89,15 @@ except Exception as e:
87
  #
88
  # All changes are backwards-compatible – the client contract is unchanged.
89
  # ---------------------------------------------------------------------------
90
- CATEGORY_MODEL = "gemini-3.1-flash-lite" # was gemini-2.5-flash
91
- GENERATION_MODEL = "gemini-3.1-flash-image-preview" # Nano Banana 2 (was gemini-2.0-flash-exp-image-generation)
92
- SUMMARY_MODEL = "gemini-3.1-flash-lite" # was gemini-2.0-flash-lite
 
 
 
 
 
 
93
 
94
  # --- Stripe Initialization ---
95
  STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY")
@@ -168,6 +176,63 @@ def parse_numbered_steps(text):
168
  steps_found = re.findall(r"\n\s*(\d+)\.\s*(.*)", text, re.MULTILINE)
169
  return [{"stepNumber": int(num), "text": desc.strip()} for num, desc in steps_found]
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  def _convert_pcm_to_wav(pcm_data, sample_rate=24000, channels=1, sample_width=2):
172
  """Wraps raw PCM audio data in a WAV container in memory."""
173
  audio_buffer = io.BytesIO()
@@ -994,6 +1059,260 @@ def admin_send_email():
994
  # 7. DIY PROJECT ENDPOINTS (Core Logic)
995
  # -----------------------------------------------------------------------------
996
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
997
  @app.route('/api/projects', methods=['POST'])
998
  def create_project():
999
  uid = verify_token(request.headers.get('Authorization'))
@@ -1078,282 +1397,285 @@ def create_project():
1078
  return jsonify({'error': f"An error occurred: {e}"}), 500
1079
 
1080
 
1081
- @app.route('/api/projects/<string:project_id>/approve', methods=['PUT'])
1082
- def approve_project_plan(project_id):
1083
- start_time = time.time()
1084
- logger.info(f"[PROJECT APPROVAL] Starting approval process for project: {project_id}")
 
 
 
 
 
 
1085
 
1086
- uid = verify_token(request.headers.get('Authorization'))
1087
- if not uid:
1088
- return jsonify({'error': 'Unauthorized'}), 401
1089
 
1090
- user_ref = db_ref.child(f'users/{uid}')
1091
- user_data = user_ref.get()
1092
- if not user_data or user_data.get('credits', 0) < 5:
1093
- return jsonify({'error': 'Insufficient credits'}), 402
1094
 
1095
- project_ref = db_ref.child(f'projects/{project_id}')
1096
- project_data = project_ref.get()
1097
- if not project_data or project_data.get('uid') != uid:
1098
- return jsonify({'error': 'Project not found or access denied'}), 404
 
 
 
1099
 
1100
- selected_option = request.json.get('selectedOption')
1101
- logger.info(f"[PROJECT APPROVAL] Selected option: {selected_option}")
1102
 
1103
- # Image download and processing timing
1104
- image_download_start = time.time()
1105
- try:
1106
- response = requests.get(project_data['userImageURL'], timeout=30)
1107
- response.raise_for_status()
1108
- except requests.RequestException as e:
1109
- logger.error(f"[PROJECT APPROVAL] ERROR: Image download failed: {e}")
1110
- return jsonify({'error': 'Failed to download project image'}), 500
1111
 
1112
- image_download_time = time.time() - image_download_start
1113
- logger.info(f"[PROJECT APPROVAL] Image download completed in {image_download_time:.3f}s, size: {len(response.content)} bytes")
1114
 
1115
- image_processing_start = time.time()
1116
- try:
1117
- pil_image = Image.open(io.BytesIO(response.content)).convert('RGB')
1118
- except Exception as e:
1119
- logger.error(f"[PROJECT APPROVAL] ERROR: Image processing failed: {e}")
1120
- return jsonify({'error': 'Failed to process project image'}), 500
1121
-
1122
- image_processing_time = time.time() - image_processing_start
1123
- logger.info(f"[PROJECT APPROVAL] Image processing completed in {image_processing_time:.3f}s")
1124
-
1125
- # Context preparation timing
1126
- context_start = time.time()
1127
- context = (
1128
- f"The user chose the upcycling project: '{selected_option}'."
1129
- if selected_option
1130
- else f"The user has approved the plan for '{project_data['projectTitle']}'."
1131
- )
1132
 
1133
- detailed_prompt = f"""
1134
- You are a DIY expert. The user wants to proceed with the project titled "{project_data['projectTitle']}".
1135
- {context}
1136
- Provide a detailed guide. For each step, you MUST provide a simple, clear illustrative image.
1137
- Format your response EXACTLY like this:
1138
- TOOLS AND MATERIALS:
1139
- - Tool A
1140
- - Material B
1141
- STEPS(Maximum 5 steps):
1142
- 1. First step instructions.
1143
- 2. Second step instructions...
1144
  """
1145
- context_time = time.time() - context_start
1146
- logger.info(f"[PROJECT APPROVAL] Context preparation completed in {context_time:.3f}s")
1147
-
1148
  try:
1149
- # AI generation timing
1150
- ai_start = time.time()
1151
- logger.info(f"[PROJECT APPROVAL] Starting AI generation with model: {GENERATION_MODEL}")
1152
 
1153
- chat = client.chats.create(
1154
- model=GENERATION_MODEL,
1155
- config=types.GenerateContentConfig(response_modalities=["Text", "Image"])
 
 
 
 
 
1156
  )
1157
- full_resp = chat.send_message([detailed_prompt, pil_image])
1158
- ai_time = time.time() - ai_start
1159
- logger.info(f"[PROJECT APPROVAL] AI generation completed in {ai_time:.3f}s")
1160
-
1161
- # Response parsing timing
1162
- parsing_start = time.time()
1163
- gen_parts = full_resp.candidates[0].content.parts
1164
-
1165
- combined_text = ""
1166
- inline_images = []
1167
- for part in gen_parts:
1168
- if part.text is not None:
1169
- combined_text += part.text + "\n"
1170
- if part.inline_data is not None:
1171
- img = Image.open(io.BytesIO(part.inline_data.data)).convert('RGB')
1172
- inline_images.append(img)
1173
-
1174
- combined_text = combined_text.strip()
1175
- parsing_time = time.time() - parsing_start
1176
- logger.info(f"[PROJECT APPROVAL] Response parsing completed in {parsing_time:.3f}s, found {len(inline_images)} images")
1177
-
1178
- # Text extraction timing with robust error handling
1179
- extraction_start = time.time()
1180
-
1181
- logger.info(f"[PROJECT APPROVAL] AI Response structure check:")
1182
- logger.info(f"[PROJECT APPROVAL] Full response length: {len(combined_text)}")
1183
- logger.info(f"[PROJECT APPROVAL] Contains 'TOOLS AND MATERIALS': {'TOOLS AND MATERIALS' in combined_text.upper()}")
1184
- logger.info(f"[PROJECT APPROVAL] Contains 'STEPS': {'STEPS' in combined_text.upper()}")
1185
- logger.info(f"[PROJECT APPROVAL] Response preview: {combined_text[:300]}...")
1186
 
1187
  tools_match = re.search(r"TOOLS AND MATERIALS:\s*(.*?)\s*(?=STEPS\s*\(|STEPS\s*:|$)", combined_text, re.DOTALL | re.IGNORECASE)
1188
  steps_match = re.search(r"STEPS\s*\([^)]*\):\s*(.*)|STEPS\s*:\s*(.*)", combined_text, re.DOTALL | re.IGNORECASE)
 
 
 
1189
 
1190
- if not tools_match:
1191
- logger.error(f"[PROJECT APPROVAL] ERROR: Could not find TOOLS AND MATERIALS section in AI response")
1192
- logger.error(f"[PROJECT APPROVAL] AI Response full text: {combined_text}")
1193
- return jsonify({'error': 'AI response format error: Could not parse tools section'}), 500
1194
 
1195
- if not steps_match:
1196
- logger.error(f"[PROJECT APPROVAL] ERROR: Could not find STEPS section in AI response")
1197
- logger.error(f"[PROJECT APPROVAL] AI Response full text: {combined_text}")
1198
- return jsonify({'error': 'AI response format error: Could not parse steps section'}), 500
1199
 
1200
- tools_section = tools_match.group(1).strip()
1201
- steps_section = (steps_match.group(1) or steps_match.group(2)).strip() if steps_match else ""
 
 
1202
 
1203
- if not tools_section:
1204
- logger.error(f"[PROJECT APPROVAL] ERROR: Empty tools section found")
1205
- return jsonify({'error': 'AI response format error: Empty tools section'}), 500
1206
 
1207
- if not steps_section:
1208
- logger.error(f"[PROJECT APPROVAL] ERROR: Empty steps section found")
1209
- return jsonify({'error': 'AI response format error: Empty steps section'}), 500
1210
 
1211
- tools_list = [line.strip("- ").strip() for line in tools_section.split('\n') if line.strip() and not line.strip().startswith('-')]
1212
- dash_tools = [line.strip("- ").strip() for line in tools_section.split('\n') if line.strip().startswith('-')]
1213
- tools_list.extend(dash_tools)
 
 
1214
 
1215
- # Remove duplicates while preserving order
1216
- seen = set()
1217
- tools_list = [x for x in tools_list if not (x in seen or seen.add(x))]
1218
-
1219
- parsed_steps = parse_numbered_steps(steps_section)
1220
-
1221
- if not tools_list:
1222
- logger.error(f"[PROJECT APPROVAL] ERROR: No tools parsed from response")
1223
- logger.error(f"[PROJECT APPROVAL] Tools section was: {tools_section}")
1224
- return jsonify({'error': 'AI response format error: No tools found'}), 500
1225
-
1226
- if not parsed_steps:
1227
- logger.error(f"[PROJECT APPROVAL] ERROR: No steps parsed from response")
1228
- logger.error(f"[PROJECT APPROVAL] Steps section was: {steps_section}")
1229
- return jsonify({'error': 'AI response format error: No steps found'}), 500
1230
-
1231
- extraction_time = time.time() - extraction_start
1232
- logger.info(f"[PROJECT APPROVAL] Text extraction completed in {extraction_time:.3f}s, tools: {len(tools_list)}, steps: {len(parsed_steps)}")
1233
-
1234
- if len(parsed_steps) != len(inline_images):
1235
- logger.error(f"[PROJECT APPROVAL] ERROR: AI response mismatch - Steps: {len(parsed_steps)}, Images: {len(inline_images)}")
1236
- min_length = min(len(parsed_steps), len(inline_images))
1237
- if min_length > 0:
1238
- logger.info(f"[PROJECT APPROVAL] Attempting to proceed with {min_length} steps/images")
1239
- parsed_steps = parsed_steps[:min_length]
1240
- inline_images = inline_images[:min_length]
1241
- else:
1242
- return jsonify({'error': 'AI response mismatch: No valid steps and images found.'}), 500
1243
-
1244
- # ---------------------------------------------------------------
1245
- # Step processing – image upload and TTS run IN PARALLEL per step
1246
- # using ThreadPoolExecutor so Nano Banana 2's longer generation
1247
- # time doesn't stack on top of Deepgram latency.
1248
- # ---------------------------------------------------------------
1249
- import concurrent.futures
1250
-
1251
- step_processing_start = time.time()
1252
- final_steps = [None] * len(parsed_steps)
1253
- total_upload_time = 0
1254
- total_tts_time = 0
1255
-
1256
- def process_single_step(args):
1257
- """Runs image upload and TTS concurrently for one step, returns completed step_info."""
1258
- i, step_info, pil_img = args
1259
- result = dict(step_info) # shallow copy so we don't mutate shared state
1260
- img_url = ""
1261
- narration_url = ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1262
 
1263
- try:
1264
- # Encode JPEG in this thread before spawning sub-futures
1265
- img_byte_arr = io.BytesIO()
1266
- pil_img.save(img_byte_arr, format='JPEG', optimize=True, quality=70)
1267
- img_bytes_val = img_byte_arr.getvalue()
1268
- img_path = f"users/{uid}/projects/{project_id}/steps/step_{i+1}_image.jpg"
1269
 
1270
- # Fire image upload and TTS simultaneously
1271
- with concurrent.futures.ThreadPoolExecutor(max_workers=2) as inner_pool:
1272
- img_upload_start = time.time()
1273
- tts_start = time.time()
1274
 
1275
- img_future = inner_pool.submit(upload_to_storage, img_bytes_val, img_path, 'image/jpeg')
1276
- tts_future = inner_pool.submit(generate_tts_audio_and_upload, result['text'], uid, project_id, i + 1)
 
 
 
 
 
 
 
1277
 
1278
- img_url = img_future.result()
1279
- img_upload_time = time.time() - img_upload_start
1280
- logger.info(f"[PROJECT APPROVAL] Step {i+1} image upload completed in {img_upload_time:.3f}s")
1281
 
1282
- narration_url = tts_future.result()
1283
- tts_time = time.time() - tts_start
1284
- logger.info(f"[PROJECT APPROVAL] Step {i+1} TTS generation completed in {tts_time:.3f}s")
 
 
 
1285
 
1286
- except Exception as e:
1287
- logger.error(f"[PROJECT APPROVAL] ERROR processing step {i+1}: {e}")
 
1288
 
1289
- result.update({
1290
- "imageUrl": img_url,
1291
- "narrationUrl": narration_url,
1292
- "isDone": False,
1293
- "notes": ""
1294
- })
1295
- return i, result
1296
 
1297
- # Process all steps in parallel (up to 5 steps × 2 sub-tasks each)
1298
- step_args = [(i, parsed_steps[i], inline_images[i]) for i in range(len(parsed_steps))]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1299
 
1300
- with concurrent.futures.ThreadPoolExecutor(max_workers=len(parsed_steps)) as outer_pool:
1301
- for i, completed_step in outer_pool.map(process_single_step, step_args):
1302
- final_steps[i] = completed_step
1303
 
1304
- step_processing_time = time.time() - step_processing_start
1305
- logger.info(f"[PROJECT APPROVAL] All steps processing completed in {step_processing_time:.3f}s")
 
 
 
 
1306
 
1307
- # Database update timing
1308
- db_update_start = time.time()
1309
- update_data = {
1310
- "status": "ready",
1311
- "toolsList": tools_list,
1312
- "steps": final_steps,
1313
- "selectedOption": selected_option or ""
1314
- }
1315
- project_ref.update(update_data)
1316
- logger.info(f"[PROJECT APPROVAL] Updating data in db: {len(update_data)} fields")
1317
- db_update_time = time.time() - db_update_start
1318
- logger.info(f"[PROJECT APPROVAL] Database update completed in {db_update_time:.3f}s")
1319
-
1320
- # Final project fetch timing
1321
- final_fetch_start = time.time()
1322
- updated_project = project_ref.get()
1323
- updated_project["projectId"] = project_id
1324
- final_fetch_time = time.time() - final_fetch_start
1325
- logger.info(f"[PROJECT APPROVAL] Final project fetch completed in {final_fetch_time:.3f}s")
1326
-
1327
- # Credits deduction timing
1328
- credits_update_start = time.time()
1329
- user_ref.update({'credits': user_data.get('credits', 0) - 5})
1330
- credits_update_time = time.time() - credits_update_start
1331
- logger.info(f"[PROJECT APPROVAL] Credits update completed in {credits_update_time:.3f}s")
1332
-
1333
- # Total time calculation
1334
- total_time = time.time() - start_time
1335
- logger.info(f"[PROJECT APPROVAL] SUCCESS: Project approval completed in {total_time:.3f}s")
1336
- logger.info(f"[PROJECT APPROVAL] TIMING BREAKDOWN:")
1337
- logger.info(f"[PROJECT APPROVAL] - Image download: {image_download_time:.3f}s")
1338
- logger.info(f"[PROJECT APPROVAL] - Image processing: {image_processing_time:.3f}s")
1339
- logger.info(f"[PROJECT APPROVAL] - Context prep: {context_time:.3f}s")
1340
- logger.info(f"[PROJECT APPROVAL] - AI generation (Nano Banana 2): {ai_time:.3f}s")
1341
- logger.info(f"[PROJECT APPROVAL] - Response parsing: {parsing_time:.3f}s")
1342
- logger.info(f"[PROJECT APPROVAL] - Text extraction: {extraction_time:.3f}s")
1343
- logger.info(f"[PROJECT APPROVAL] - Step processing (threaded): {step_processing_time:.3f}s")
1344
- logger.info(f"[PROJECT APPROVAL] - DB update: {db_update_time:.3f}s")
1345
- logger.info(f"[PROJECT APPROVAL] - Final fetch: {final_fetch_time:.3f}s")
1346
- logger.info(f"[PROJECT APPROVAL] - Credits update: {credits_update_time:.3f}s")
1347
-
1348
- return jsonify(updated_project)
1349
 
1350
- except Exception as e:
1351
- total_time = time.time() - start_time
1352
- logger.error(f"[PROJECT APPROVAL] ERROR: Exception occurred after {total_time:.3f}s: {e}")
1353
- logger.error(f"[PROJECT APPROVAL] Error type: {type(e).__name__}")
1354
- logger.error(f"[PROJECT APPROVAL] Project ID: {project_id}, User ID: {uid}")
1355
- logger.error(f"[PROJECT APPROVAL] Full traceback: {traceback.format_exc()}")
1356
- return jsonify({'error': f'Internal server error: {str(e)}'}), 500
 
 
 
 
 
 
1357
 
1358
 
1359
  @app.route('/api/projects', methods=['GET'])
 
6
  import json
7
  import traceback
8
  import wave
9
+ import threading
10
+ import concurrent.futures
11
  from datetime import datetime, timedelta
12
 
13
  from flask import Flask, request, jsonify, Response
 
89
  #
90
  # All changes are backwards-compatible – the client contract is unchanged.
91
  # ---------------------------------------------------------------------------
92
+ CATEGORY_MODEL = os.environ.get("CATEGORY_MODEL", "gemini-3.1-flash-lite") # was gemini-2.5-flash
93
+ GENERATION_MODEL = os.environ.get("GENERATION_MODEL", "gemini-3.1-flash-image-preview") # Nano Banana 2 (was gemini-2.0-flash-exp-image-generation)
94
+ SUMMARY_MODEL = os.environ.get("SUMMARY_MODEL", "gemini-3.1-flash-lite") # was gemini-2.0-flash-lite
95
+
96
+ # Step-guide generation (async iterative pipeline)
97
+ PLAN_TEXT_MODEL = os.environ.get("PLAN_TEXT_MODEL", "gemini-3.1-flash-lite") # fast text-only planning call
98
+ FALLBACK_IMAGE_MODEL = os.environ.get("FALLBACK_IMAGE_MODEL", "gemini-2.5-flash-image") # nano banana original
99
+ IMAGE_CALL_TIMEOUT_S = float(os.environ.get("IMAGE_CALL_TIMEOUT_S", "28")) # per step-image call
100
+ GENERATION_STALE_S = float(os.environ.get("GENERATION_STALE_S", "240")) # progress heartbeat staleness
101
 
102
  # --- Stripe Initialization ---
103
  STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY")
 
176
  steps_found = re.findall(r"\n\s*(\d+)\.\s*(.*)", text, re.MULTILINE)
177
  return [{"stepNumber": int(num), "text": desc.strip()} for num, desc in steps_found]
178
 
179
+ def _update_progress(project_id, **fields):
180
+ """Writes generation progress fields under the project so the client can poll real state."""
181
+ fields['updatedAt'] = datetime.utcnow().isoformat()
182
+ db_ref.child(f'projects/{project_id}/generationProgress').update(fields)
183
+
184
+ _TOOL_STOPWORDS = {'a', 'an', 'the', 'of', 'for', 'and', 'or', 'with', 'to', 'in', 'on', 'set', 'pair', 'piece', 'pieces', 'small', 'large', 'medium', 'new', 'some', 'optional'}
185
+
186
+ SUPPORTED_CITIES = ['Harare', 'Bulawayo', 'Gweru', 'Mutare', 'Other']
187
+
188
+ def _normalize_tool_name(name):
189
+ """Lowercase, strip punctuation/stopwords, singularize trailing 's' for catalogue matching."""
190
+ words = re.sub(r"[^a-z0-9\s]", " ", (name or "").lower()).split()
191
+ normalized = []
192
+ for w in words:
193
+ if w in _TOOL_STOPWORDS:
194
+ continue
195
+ if len(w) > 3 and w.endswith('s') and not w.endswith('ss'):
196
+ w = w[:-1]
197
+ normalized.append(w)
198
+ return " ".join(normalized)
199
+
200
+ def match_tools_to_catalogue(tools_list):
201
+ """Matches free-text tool names against the supplier catalogue by keyword-token overlap.
202
+
203
+ Returns one best match per tool (score >= 1), cheap enough to run inline for ~1-2k items.
204
+ """
205
+ catalogue = db_ref.child('catalogue').get() or {}
206
+ matches = []
207
+ for tool in tools_list:
208
+ tool_tokens = set(_normalize_tool_name(tool).split())
209
+ if not tool_tokens:
210
+ continue
211
+ best_item, best_score = None, 0
212
+ for item_id, item in catalogue.items():
213
+ if not isinstance(item, dict) or not item.get('inStock', True):
214
+ continue
215
+ keywords = set(item.get('keywords', []))
216
+ score = len(tool_tokens & keywords)
217
+ normalized_name = item.get('normalizedName', '')
218
+ if normalized_name and set(normalized_name.split()) <= tool_tokens:
219
+ score += 2
220
+ if score > best_score:
221
+ best_item, best_score = (item_id, item), score
222
+ if best_item and best_score >= 1:
223
+ item_id, item = best_item
224
+ matches.append({
225
+ 'tool': tool,
226
+ 'itemId': item_id,
227
+ 'name': item.get('name', ''),
228
+ 'price': item.get('price', 0),
229
+ 'currency': item.get('currency', 'USD'),
230
+ 'supplier': item.get('supplier', 'Electrosales'),
231
+ 'city': item.get('city', 'Harare'),
232
+ 'imageUrl': item.get('imageUrl', ''),
233
+ })
234
+ return matches
235
+
236
  def _convert_pcm_to_wav(pcm_data, sample_rate=24000, channels=1, sample_width=2):
237
  """Wraps raw PCM audio data in a WAV container in memory."""
238
  audio_buffer = io.BytesIO()
 
1059
  # 7. DIY PROJECT ENDPOINTS (Core Logic)
1060
  # -----------------------------------------------------------------------------
1061
 
1062
+ # -----------------------------------------------------------------------------
1063
+ # SUPPLIER CATALOGUE (ADMIN) — grounds the tools & supplies list in real products
1064
+ # -----------------------------------------------------------------------------
1065
+
1066
+ def _build_catalogue_item(data, admin_uid):
1067
+ """Validates admin input and computes the matching fields; returns None when invalid."""
1068
+ name = (data.get('name') or '').strip()
1069
+ try:
1070
+ price = round(float(data.get('price')), 2)
1071
+ except (TypeError, ValueError):
1072
+ price = None
1073
+ if not name or price is None or price < 0:
1074
+ return None
1075
+
1076
+ normalized = _normalize_tool_name(name)
1077
+ tokens = set(normalized.split())
1078
+ for extra in data.get('keywords') or []:
1079
+ if isinstance(extra, str):
1080
+ tokens |= set(_normalize_tool_name(extra).split())
1081
+
1082
+ return {
1083
+ 'name': name,
1084
+ 'normalizedName': normalized,
1085
+ 'keywords': sorted(tokens),
1086
+ 'price': price,
1087
+ 'currency': (data.get('currency') or 'USD').upper(),
1088
+ 'supplier': (data.get('supplier') or 'Electrosales').strip(),
1089
+ 'city': data.get('city') if data.get('city') in SUPPORTED_CITIES else 'Harare',
1090
+ 'category': (data.get('category') or 'General').strip(),
1091
+ 'imageUrl': data.get('imageUrl') or '',
1092
+ 'inStock': bool(data.get('inStock', True)),
1093
+ 'createdAt': datetime.utcnow().isoformat(),
1094
+ 'createdBy': admin_uid,
1095
+ }
1096
+
1097
+
1098
+ @app.route('/api/admin/catalogue', methods=['GET'])
1099
+ def admin_list_catalogue():
1100
+ try:
1101
+ verify_admin(request.headers.get('Authorization'))
1102
+ except PermissionError as e:
1103
+ return jsonify({'error': str(e)}), 403
1104
+ items = db_ref.child('catalogue').get() or {}
1105
+ result = [{'itemId': key, **item} for key, item in items.items() if isinstance(item, dict)]
1106
+ result.sort(key=lambda x: x.get('createdAt', ''), reverse=True)
1107
+ return jsonify({'items': result})
1108
+
1109
+
1110
+ @app.route('/api/admin/catalogue', methods=['POST'])
1111
+ def admin_create_catalogue_item():
1112
+ try:
1113
+ admin_uid = verify_admin(request.headers.get('Authorization'))
1114
+ except PermissionError as e:
1115
+ return jsonify({'error': str(e)}), 403
1116
+ item = _build_catalogue_item(request.get_json(silent=True) or {}, admin_uid)
1117
+ if not item:
1118
+ return jsonify({'error': 'A valid name and non-negative price are required.'}), 400
1119
+ ref = db_ref.child('catalogue').push(item)
1120
+ return jsonify({'itemId': ref.key, **item}), 201
1121
+
1122
+
1123
+ @app.route('/api/admin/catalogue/bulk', methods=['POST'])
1124
+ def admin_bulk_create_catalogue():
1125
+ """Confirm-save for the extract flow: saves the reviewed items in one call."""
1126
+ try:
1127
+ admin_uid = verify_admin(request.headers.get('Authorization'))
1128
+ except PermissionError as e:
1129
+ return jsonify({'error': str(e)}), 403
1130
+ raw_items = (request.get_json(silent=True) or {}).get('items')
1131
+ if not isinstance(raw_items, list) or not raw_items:
1132
+ return jsonify({'error': 'items must be a non-empty array.'}), 400
1133
+
1134
+ created, skipped = [], 0
1135
+ for raw in raw_items:
1136
+ item = _build_catalogue_item(raw if isinstance(raw, dict) else {}, admin_uid)
1137
+ if not item:
1138
+ skipped += 1
1139
+ continue
1140
+ ref = db_ref.child('catalogue').push(item)
1141
+ created.append({'itemId': ref.key, **item})
1142
+ return jsonify({'created': created, 'createdCount': len(created), 'skippedCount': skipped}), 201
1143
+
1144
+
1145
+ @app.route('/api/admin/catalogue/extract', methods=['POST'])
1146
+ def admin_extract_catalogue():
1147
+ """AI-extracts products from an uploaded catalogue image or PDF. Nothing is saved —
1148
+ the parsed items go back to the admin for review, then /bulk persists them."""
1149
+ try:
1150
+ verify_admin(request.headers.get('Authorization'))
1151
+ except PermissionError as e:
1152
+ return jsonify({'error': str(e)}), 403
1153
+
1154
+ if 'file' not in request.files:
1155
+ return jsonify({'error': 'A catalogue file (image or PDF) is required.'}), 400
1156
+ upload = request.files['file']
1157
+ file_bytes = upload.read()
1158
+ if len(file_bytes) > 10 * 1024 * 1024:
1159
+ return jsonify({'error': 'File too large (max 10 MB).'}), 400
1160
+
1161
+ extract_prompt = (
1162
+ "Extract every product from this supplier catalogue / price list. "
1163
+ "Return a JSON array where each element is: "
1164
+ '{"name": string, "price": number (numeric only, no currency symbols; 0 if missing), '
1165
+ '"category": string (e.g. Electrical, Hand Tools, Fasteners, Paint, Plumbing, General), '
1166
+ '"inStock": boolean (true unless clearly marked out of stock)}. '
1167
+ "Skip headings, page numbers and non-product rows."
1168
+ )
1169
+
1170
+ is_pdf = upload.mimetype == 'application/pdf' or (upload.filename or '').lower().endswith('.pdf')
1171
+ try:
1172
+ if is_pdf:
1173
+ contents = [extract_prompt, types.Part.from_bytes(data=file_bytes, mime_type='application/pdf')]
1174
+ else:
1175
+ contents = [extract_prompt, Image.open(io.BytesIO(file_bytes))]
1176
+
1177
+ resp = client.models.generate_content(
1178
+ model=CATEGORY_MODEL,
1179
+ contents=contents,
1180
+ config=types.GenerateContentConfig(response_mime_type='application/json'),
1181
+ )
1182
+ raw_json = "".join(part.text or '' for part in resp.candidates[0].content.parts)
1183
+ parsed = json.loads(raw_json)
1184
+ except json.JSONDecodeError:
1185
+ logger.error(f"[CATALOGUE EXTRACT] Model returned non-JSON output")
1186
+ return jsonify({'error': 'Could not read products from this file. Try a clearer photo or a PDF.'}), 422
1187
+ except Exception as e:
1188
+ logger.error(f"[CATALOGUE EXTRACT] Extraction failed: {e}")
1189
+ return jsonify({'error': f'Extraction failed: {str(e)}'}), 500
1190
+
1191
+ items = []
1192
+ for entry in parsed if isinstance(parsed, list) else []:
1193
+ if not isinstance(entry, dict) or not (entry.get('name') or '').strip():
1194
+ continue
1195
+ try:
1196
+ price = round(float(entry.get('price', 0)), 2)
1197
+ except (TypeError, ValueError):
1198
+ price = 0
1199
+ items.append({
1200
+ 'name': entry['name'].strip(),
1201
+ 'price': max(price, 0),
1202
+ 'category': (entry.get('category') or 'General').strip(),
1203
+ 'inStock': bool(entry.get('inStock', True)),
1204
+ })
1205
+ return jsonify({'items': items})
1206
+
1207
+
1208
+ @app.route('/api/admin/catalogue/<string:item_id>', methods=['PUT'])
1209
+ def admin_update_catalogue_item(item_id):
1210
+ try:
1211
+ verify_admin(request.headers.get('Authorization'))
1212
+ except PermissionError as e:
1213
+ return jsonify({'error': str(e)}), 403
1214
+ item_ref = db_ref.child(f'catalogue/{item_id}')
1215
+ existing = item_ref.get()
1216
+ if not existing:
1217
+ return jsonify({'error': 'Catalogue item not found'}), 404
1218
+
1219
+ data = request.get_json(silent=True) or {}
1220
+ updates = {}
1221
+ if 'name' in data:
1222
+ name = (data.get('name') or '').strip()
1223
+ if not name:
1224
+ return jsonify({'error': 'name cannot be empty'}), 400
1225
+ normalized = _normalize_tool_name(name)
1226
+ updates.update({'name': name, 'normalizedName': normalized, 'keywords': sorted(set(normalized.split()))})
1227
+ if 'price' in data:
1228
+ try:
1229
+ price = round(float(data.get('price')), 2)
1230
+ except (TypeError, ValueError):
1231
+ return jsonify({'error': 'price must be a number'}), 400
1232
+ if price < 0:
1233
+ return jsonify({'error': 'price cannot be negative'}), 400
1234
+ updates['price'] = price
1235
+ for field in ('currency', 'supplier', 'category'):
1236
+ if field in data and isinstance(data[field], str) and data[field].strip():
1237
+ updates[field] = data[field].strip()
1238
+ if 'city' in data:
1239
+ if data['city'] not in SUPPORTED_CITIES:
1240
+ return jsonify({'error': f'city must be one of: {", ".join(SUPPORTED_CITIES)}'}), 400
1241
+ updates['city'] = data['city']
1242
+ if 'inStock' in data:
1243
+ updates['inStock'] = bool(data['inStock'])
1244
+ if not updates:
1245
+ return jsonify({'error': 'No valid fields to update.'}), 400
1246
+
1247
+ item_ref.update(updates)
1248
+ return jsonify({'itemId': item_id, **existing, **updates})
1249
+
1250
+
1251
+ @app.route('/api/admin/catalogue/<string:item_id>', methods=['DELETE'])
1252
+ def admin_delete_catalogue_item(item_id):
1253
+ try:
1254
+ verify_admin(request.headers.get('Authorization'))
1255
+ except PermissionError as e:
1256
+ return jsonify({'error': str(e)}), 403
1257
+ item_ref = db_ref.child(f'catalogue/{item_id}')
1258
+ existing = item_ref.get()
1259
+ if not existing:
1260
+ return jsonify({'error': 'Catalogue item not found'}), 404
1261
+ if existing.get('imageUrl'):
1262
+ try:
1263
+ bucket.blob(f'catalogue/{item_id}/image.jpg').delete()
1264
+ except Exception as e:
1265
+ logger.warning(f"[CATALOGUE] Could not delete image blob for {item_id}: {e}")
1266
+ item_ref.delete()
1267
+ return jsonify({'success': True})
1268
+
1269
+
1270
+ @app.route('/api/admin/catalogue/<string:item_id>/image', methods=['POST'])
1271
+ def admin_upload_catalogue_image(item_id):
1272
+ try:
1273
+ verify_admin(request.headers.get('Authorization'))
1274
+ except PermissionError as e:
1275
+ return jsonify({'error': str(e)}), 403
1276
+ item_ref = db_ref.child(f'catalogue/{item_id}')
1277
+ if not item_ref.get():
1278
+ return jsonify({'error': 'Catalogue item not found'}), 404
1279
+ if 'image' not in request.files:
1280
+ return jsonify({'error': 'An image file is required.'}), 400
1281
+ image_file = request.files['image']
1282
+ try:
1283
+ # Normalize to JPEG so the stored blob path/type is predictable
1284
+ pil_img = Image.open(io.BytesIO(image_file.read())).convert('RGB')
1285
+ buf = io.BytesIO()
1286
+ pil_img.save(buf, format='JPEG', optimize=True, quality=80)
1287
+ image_url = upload_to_storage(buf.getvalue(), f'catalogue/{item_id}/image.jpg', 'image/jpeg')
1288
+ except Exception as e:
1289
+ logger.error(f"[CATALOGUE] Image upload failed for {item_id}: {e}")
1290
+ return jsonify({'error': 'Failed to process or upload the image.'}), 500
1291
+ item_ref.update({'imageUrl': image_url})
1292
+ return jsonify({'itemId': item_id, 'imageUrl': image_url})
1293
+
1294
+
1295
+ @app.route('/api/projects/<string:project_id>/tool-matches', methods=['GET'])
1296
+ def get_project_tool_matches(project_id):
1297
+ """Returns cached catalogue matches for the project's tools, computing them on demand
1298
+ for projects generated before the catalogue existed."""
1299
+ uid = verify_token(request.headers.get('Authorization'))
1300
+ if not uid:
1301
+ return jsonify({'error': 'Unauthorized'}), 401
1302
+ project = db_ref.child(f'projects/{project_id}').get()
1303
+ if not project or project.get('uid') != uid:
1304
+ return jsonify({'error': 'Project not found or access denied'}), 404
1305
+
1306
+ matches = project.get('toolMatches')
1307
+ if matches is None:
1308
+ matches = match_tools_to_catalogue(project.get('toolsList') or [])
1309
+ db_ref.child(f'projects/{project_id}').update({
1310
+ 'toolMatches': matches,
1311
+ 'toolMatchesUpdatedAt': datetime.utcnow().isoformat(),
1312
+ })
1313
+ return jsonify({'matches': matches})
1314
+
1315
+
1316
  @app.route('/api/projects', methods=['POST'])
1317
  def create_project():
1318
  uid = verify_token(request.headers.get('Authorization'))
 
1397
  return jsonify({'error': f"An error occurred: {e}"}), 500
1398
 
1399
 
1400
+ def _progress_is_stale(progress):
1401
+ """True when a 'generating' project's heartbeat is older than GENERATION_STALE_S (e.g. server restarted mid-job)."""
1402
+ updated_at = (progress or {}).get('updatedAt')
1403
+ if not updated_at:
1404
+ return True
1405
+ try:
1406
+ last = datetime.fromisoformat(updated_at)
1407
+ except ValueError:
1408
+ return True
1409
+ return (datetime.utcnow() - last).total_seconds() > GENERATION_STALE_S
1410
 
 
 
 
1411
 
1412
+ def _call_with_timeout(fn, timeout_s, *args, **kwargs):
1413
+ """Runs fn in a worker thread; raises concurrent.futures.TimeoutError past timeout_s.
 
 
1414
 
1415
+ shutdown(wait=False) so a hung API call can't block the generation loop.
1416
+ """
1417
+ pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
1418
+ try:
1419
+ return pool.submit(fn, *args, **kwargs).result(timeout=timeout_s)
1420
+ finally:
1421
+ pool.shutdown(wait=False)
1422
 
 
 
1423
 
1424
+ def _request_step_image(chat, prompt_parts):
1425
+ """Sends one chat turn that must yield exactly one inline image."""
1426
+ resp = chat.send_message(prompt_parts)
1427
+ for part in resp.candidates[0].content.parts:
1428
+ if part.inline_data is not None:
1429
+ return Image.open(io.BytesIO(part.inline_data.data)).convert('RGB')
1430
+ raise ValueError('Model returned no image for this step')
 
1431
 
 
 
1432
 
1433
+ def _run_guide_generation(uid, project_id, selected_option):
1434
+ """Background job: text plan -> one image per chat turn (iterative refinement) -> upload/TTS -> finalize.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1435
 
1436
+ Progress is written to projects/{id}/generationProgress after every phase so the
1437
+ client can poll real state. Credits are deducted only on success; any failure
1438
+ flips the project to 'generation_failed' so the client can offer a retry.
 
 
 
 
 
 
 
 
1439
  """
1440
+ start_time = time.time()
1441
+ project_ref = db_ref.child(f'projects/{project_id}')
1442
+ user_ref = db_ref.child(f'users/{uid}')
1443
  try:
1444
+ project_data = project_ref.get()
 
 
1445
 
1446
+ response = requests.get(project_data['userImageURL'], timeout=30)
1447
+ response.raise_for_status()
1448
+ pil_image = Image.open(io.BytesIO(response.content)).convert('RGB')
1449
+
1450
+ context = (
1451
+ f"The user chose the upcycling project: '{selected_option}'."
1452
+ if selected_option
1453
+ else f"The user has approved the plan for '{project_data['projectTitle']}'."
1454
  )
1455
+
1456
+ # --- Phase A: text-only plan on a fast model (guarantees 5 parseable steps) ---
1457
+ _update_progress(project_id, phase='planning', message='Planning your step-by-step guide...')
1458
+
1459
+ plan_prompt = f"""
1460
+ You are a DIY expert. The user wants to proceed with the project titled "{project_data['projectTitle']}".
1461
+ {context}
1462
+ Provide a detailed guide with EXACTLY 5 steps. Do not include any images.
1463
+ Format your response EXACTLY like this:
1464
+ TOOLS AND MATERIALS:
1465
+ - Tool A
1466
+ - Material B
1467
+ STEPS(Maximum 5 steps):
1468
+ 1. First step instructions.
1469
+ 2. Second step instructions...
1470
+ """
1471
+ plan_resp = client.models.generate_content(model=PLAN_TEXT_MODEL, contents=[plan_prompt, pil_image])
1472
+ combined_text = "".join(part.text or "" for part in plan_resp.candidates[0].content.parts).strip()
1473
+ logger.info(f"[GUIDE GEN] Plan text generated in {time.time() - start_time:.1f}s, length {len(combined_text)}")
 
 
 
 
 
 
 
 
 
 
1474
 
1475
  tools_match = re.search(r"TOOLS AND MATERIALS:\s*(.*?)\s*(?=STEPS\s*\(|STEPS\s*:|$)", combined_text, re.DOTALL | re.IGNORECASE)
1476
  steps_match = re.search(r"STEPS\s*\([^)]*\):\s*(.*)|STEPS\s*:\s*(.*)", combined_text, re.DOTALL | re.IGNORECASE)
1477
+ if not tools_match or not steps_match:
1478
+ logger.error(f"[GUIDE GEN] Could not parse plan. Full text: {combined_text}")
1479
+ raise ValueError('AI response format error: could not parse the plan')
1480
 
1481
+ tools_section = tools_match.group(1).strip()
1482
+ steps_section = (steps_match.group(1) or steps_match.group(2) or '').strip()
 
 
1483
 
1484
+ tools_list = [line.strip('- ').strip() for line in tools_section.split('\n') if line.strip()]
1485
+ seen = set()
1486
+ tools_list = [x for x in tools_list if x and not (x in seen or seen.add(x))]
 
1487
 
1488
+ parsed_steps = parse_numbered_steps(steps_section)[:5]
1489
+ if len(parsed_steps) < 3 or not tools_list:
1490
+ logger.error(f"[GUIDE GEN] Incomplete plan — steps: {len(parsed_steps)}, tools: {len(tools_list)}")
1491
+ raise ValueError('AI plan was incomplete')
1492
 
1493
+ total_steps = len(parsed_steps)
1494
+ _update_progress(project_id, totalSteps=total_steps)
 
1495
 
1496
+ # --- Phase B: iterative refinement — one illustrative image per chat turn ---
1497
+ steps_block = '\n'.join(f"{s['stepNumber']}. {s['text']}" for s in parsed_steps)
 
1498
 
1499
+ def _make_image_chat(model_name):
1500
+ return client.chats.create(
1501
+ model=model_name,
1502
+ config=types.GenerateContentConfig(response_modalities=['Text', 'Image'])
1503
+ )
1504
 
1505
+ upload_pool = concurrent.futures.ThreadPoolExecutor(max_workers=total_steps)
1506
+
1507
+ def _upload_and_tts(idx, step_info, step_img):
1508
+ """Image upload (when present) and Deepgram TTS for one step; runs while later images generate."""
1509
+ img_url = ''
1510
+ if step_img is not None:
1511
+ buf = io.BytesIO()
1512
+ step_img.save(buf, format='JPEG', optimize=True, quality=70)
1513
+ img_url = upload_to_storage(
1514
+ buf.getvalue(),
1515
+ f"users/{uid}/projects/{project_id}/steps/step_{idx+1}_image.jpg",
1516
+ 'image/jpeg'
1517
+ ) or ''
1518
+ narration_url = generate_tts_audio_and_upload(step_info['text'], uid, project_id, idx + 1) or ''
1519
+ return idx, {**step_info, 'imageUrl': img_url, 'narrationUrl': narration_url, 'isDone': False, 'notes': ''}
1520
+
1521
+ chat = None
1522
+ needs_context = True
1523
+ current_model = GENERATION_MODEL
1524
+ fallback_used = False
1525
+ upload_futures = []
1526
+
1527
+ for idx, step in enumerate(parsed_steps):
1528
+ _update_progress(
1529
+ project_id, phase='generating_images', currentStep=idx, model=current_model,
1530
+ message=f"Illustrating step {idx + 1} of {total_steps}..."
1531
+ )
1532
+ step_img = None
1533
+ attempts = 0
1534
+ while step_img is None and attempts < 3:
1535
+ attempts += 1
1536
+ try:
1537
+ if chat is None:
1538
+ chat = _make_image_chat(current_model)
1539
+ needs_context = True
1540
+ if needs_context:
1541
+ prompt_parts = [(
1542
+ f'You are illustrating a DIY guide titled "{project_data["projectTitle"]}". The full plan is:\n'
1543
+ f"{steps_block}\n\n"
1544
+ "You will illustrate one step at a time. Keep the subject, style and lighting "
1545
+ "consistent with the attached photo of the user's item.\n"
1546
+ f"Generate ONLY the illustrative image for step {step['stepNumber']}: {step['text']}. "
1547
+ "Return exactly one image and no text."
1548
+ ), pil_image]
1549
+ else:
1550
+ prompt_parts = [
1551
+ f"Generate ONLY the illustrative image for step {step['stepNumber']}: {step['text']}. "
1552
+ "Return exactly one image and no text."
1553
+ ]
1554
+ ai_start = time.time()
1555
+ step_img = _call_with_timeout(_request_step_image, IMAGE_CALL_TIMEOUT_S, chat, prompt_parts)
1556
+ needs_context = False
1557
+ logger.info(f"[GUIDE GEN] Step {idx+1} image generated in {time.time() - ai_start:.1f}s on {current_model}")
1558
+ except Exception as img_err:
1559
+ logger.warning(f"[GUIDE GEN] Step {idx+1} image attempt {attempts} failed on {current_model}: {img_err}")
1560
+ chat = None # a timed-out/failed chat may be unusable; rebuild with context on retry
1561
+ if attempts >= 2 and not fallback_used:
1562
+ fallback_used = True
1563
+ current_model = FALLBACK_IMAGE_MODEL
1564
+ _update_progress(project_id, model=current_model,
1565
+ message=f"Switching to backup image model for step {idx + 1}...")
1566
+
1567
+ # Keep the text step even if illustration failed — never truncate the guide.
1568
+ if step_img is None:
1569
+ logger.error(f"[GUIDE GEN] Step {idx+1} has no image after retries/fallback; keeping text-only step")
1570
+ _update_progress(project_id, currentStep=idx + 1)
1571
+ upload_futures.append(upload_pool.submit(_upload_and_tts, idx, step, step_img))
1572
+
1573
+ # --- Phase C: wait for the uploads/TTS that were fired as images arrived ---
1574
+ _update_progress(project_id, phase='uploading', message='Uploading images and narrations...')
1575
+ final_steps = [None] * total_steps
1576
+ for future in concurrent.futures.as_completed(upload_futures):
1577
+ idx, completed = future.result()
1578
+ final_steps[idx] = completed
1579
+ upload_pool.shutdown(wait=True)
1580
+
1581
+ # Ground tools in the supplier catalogue (best effort — never fails the guide)
1582
+ tool_matches = []
1583
+ try:
1584
+ tool_matches = match_tools_to_catalogue(tools_list)
1585
+ except Exception as match_err:
1586
+ logger.warning(f"[GUIDE GEN] Catalogue matching failed: {match_err}")
1587
+
1588
+ project_ref.update({
1589
+ 'status': 'ready',
1590
+ 'toolsList': tools_list,
1591
+ 'steps': final_steps,
1592
+ 'selectedOption': selected_option or '',
1593
+ 'toolMatches': tool_matches,
1594
+ 'toolMatchesUpdatedAt': datetime.utcnow().isoformat(),
1595
+ })
1596
 
1597
+ current_credits = (user_ref.get() or {}).get('credits', 0)
1598
+ user_ref.update({'credits': current_credits - 5})
 
 
 
 
1599
 
1600
+ _update_progress(project_id, status='ready', phase='done', currentStep=total_steps,
1601
+ message='Your guide is ready!', error='')
1602
+ logger.info(f"[GUIDE GEN] SUCCESS project {project_id} in {time.time() - start_time:.1f}s "
1603
+ f"(steps: {total_steps}, fallback_used: {fallback_used})")
1604
 
1605
+ except Exception as e:
1606
+ logger.error(f"[GUIDE GEN] FAILED project {project_id} after {time.time() - start_time:.1f}s: {e}")
1607
+ logger.error(traceback.format_exc())
1608
+ try:
1609
+ project_ref.update({'status': 'generation_failed'})
1610
+ _update_progress(project_id, status='failed', phase='error',
1611
+ error='Guide generation failed. Please try again — no credits were used.')
1612
+ except Exception:
1613
+ pass
1614
 
 
 
 
1615
 
1616
+ @app.route('/api/projects/<string:project_id>/approve', methods=['PUT'])
1617
+ def approve_project_plan(project_id):
1618
+ """Validates the request, then hands off to a background generation job (202)."""
1619
+ uid = verify_token(request.headers.get('Authorization'))
1620
+ if not uid:
1621
+ return jsonify({'error': 'Unauthorized'}), 401
1622
 
1623
+ user_data = db_ref.child(f'users/{uid}').get()
1624
+ if not user_data or user_data.get('credits', 0) < 5:
1625
+ return jsonify({'error': 'Insufficient credits'}), 402
1626
 
1627
+ project_ref = db_ref.child(f'projects/{project_id}')
1628
+ project_data = project_ref.get()
1629
+ if not project_data or project_data.get('uid') != uid:
1630
+ return jsonify({'error': 'Project not found or access denied'}), 404
 
 
 
1631
 
1632
+ if project_data.get('status') == 'generating' and not _progress_is_stale(project_data.get('generationProgress')):
1633
+ return jsonify({'error': 'Guide generation is already in progress'}), 409
1634
+
1635
+ selected_option = (request.get_json(silent=True) or {}).get('selectedOption')
1636
+ logger.info(f"[PROJECT APPROVAL] Queuing generation for project {project_id} (option: {selected_option})")
1637
+
1638
+ now = datetime.utcnow().isoformat()
1639
+ project_ref.update({'status': 'generating'})
1640
+ db_ref.child(f'projects/{project_id}/generationProgress').set({
1641
+ 'status': 'generating',
1642
+ 'phase': 'planning',
1643
+ 'currentStep': 0,
1644
+ 'totalSteps': 5,
1645
+ 'message': 'Planning your step-by-step guide...',
1646
+ 'model': GENERATION_MODEL,
1647
+ 'error': '',
1648
+ 'startedAt': now,
1649
+ 'updatedAt': now,
1650
+ })
1651
+ threading.Thread(target=_run_guide_generation, args=(uid, project_id, selected_option), daemon=True).start()
1652
+ return jsonify({'status': 'generating', 'projectId': project_id}), 202
1653
 
 
 
 
1654
 
1655
+ @app.route('/api/projects/<string:project_id>/progress', methods=['GET'])
1656
+ def get_generation_progress(project_id):
1657
+ """Lightweight poll target: project status + generationProgress only."""
1658
+ uid = verify_token(request.headers.get('Authorization'))
1659
+ if not uid:
1660
+ return jsonify({'error': 'Unauthorized'}), 401
1661
 
1662
+ project_ref = db_ref.child(f'projects/{project_id}')
1663
+ if project_ref.child('uid').get() != uid:
1664
+ return jsonify({'error': 'Project not found or access denied'}), 404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1665
 
1666
+ status = project_ref.child('status').get()
1667
+ progress = project_ref.child('generationProgress').get()
1668
+
1669
+ # A dead background thread (server restart) leaves the project stuck in
1670
+ # 'generating'; surface it as a retryable failure once the heartbeat is stale.
1671
+ if status == 'generating' and _progress_is_stale(progress):
1672
+ project_ref.update({'status': 'generation_failed'})
1673
+ _update_progress(project_id, status='failed', phase='error',
1674
+ error='Generation timed out. Please try again — no credits were used.')
1675
+ status = 'generation_failed'
1676
+ progress = project_ref.child('generationProgress').get()
1677
+
1678
+ return jsonify({'status': status, 'progress': progress})
1679
 
1680
 
1681
  @app.route('/api/projects', methods=['GET'])