Spaces:
Sleeping
Sleeping
Merge branch async-generation-catalogue: async iterative generation + supplier catalogue
Browse files
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"
|
| 92 |
-
SUMMARY_MODEL = "gemini-3.1-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 |
-
|
| 1082 |
-
|
| 1083 |
-
|
| 1084 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1085 |
|
| 1086 |
-
uid = verify_token(request.headers.get('Authorization'))
|
| 1087 |
-
if not uid:
|
| 1088 |
-
return jsonify({'error': 'Unauthorized'}), 401
|
| 1089 |
|
| 1090 |
-
|
| 1091 |
-
|
| 1092 |
-
if not user_data or user_data.get('credits', 0) < 5:
|
| 1093 |
-
return jsonify({'error': 'Insufficient credits'}), 402
|
| 1094 |
|
| 1095 |
-
|
| 1096 |
-
|
| 1097 |
-
|
| 1098 |
-
|
|
|
|
|
|
|
|
|
|
| 1099 |
|
| 1100 |
-
selected_option = request.json.get('selectedOption')
|
| 1101 |
-
logger.info(f"[PROJECT APPROVAL] Selected option: {selected_option}")
|
| 1102 |
|
| 1103 |
-
|
| 1104 |
-
|
| 1105 |
-
|
| 1106 |
-
|
| 1107 |
-
|
| 1108 |
-
|
| 1109 |
-
|
| 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 |
-
|
| 1116 |
-
|
| 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 |
-
|
| 1134 |
-
|
| 1135 |
-
|
| 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 |
-
|
| 1146 |
-
|
| 1147 |
-
|
| 1148 |
try:
|
| 1149 |
-
|
| 1150 |
-
ai_start = time.time()
|
| 1151 |
-
logger.info(f"[PROJECT APPROVAL] Starting AI generation with model: {GENERATION_MODEL}")
|
| 1152 |
|
| 1153 |
-
|
| 1154 |
-
|
| 1155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1156 |
)
|
| 1157 |
-
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
-
|
| 1163 |
-
|
| 1164 |
-
|
| 1165 |
-
|
| 1166 |
-
|
| 1167 |
-
|
| 1168 |
-
|
| 1169 |
-
|
| 1170 |
-
|
| 1171 |
-
|
| 1172 |
-
|
| 1173 |
-
|
| 1174 |
-
combined_text =
|
| 1175 |
-
|
| 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 |
-
|
| 1191 |
-
|
| 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 |
-
|
| 1196 |
-
|
| 1197 |
-
|
| 1198 |
-
return jsonify({'error': 'AI response format error: Could not parse steps section'}), 500
|
| 1199 |
|
| 1200 |
-
|
| 1201 |
-
|
|
|
|
|
|
|
| 1202 |
|
| 1203 |
-
|
| 1204 |
-
|
| 1205 |
-
return jsonify({'error': 'AI response format error: Empty tools section'}), 500
|
| 1206 |
|
| 1207 |
-
|
| 1208 |
-
|
| 1209 |
-
return jsonify({'error': 'AI response format error: Empty steps section'}), 500
|
| 1210 |
|
| 1211 |
-
|
| 1212 |
-
|
| 1213 |
-
|
|
|
|
|
|
|
| 1214 |
|
| 1215 |
-
|
| 1216 |
-
|
| 1217 |
-
|
| 1218 |
-
|
| 1219 |
-
|
| 1220 |
-
|
| 1221 |
-
|
| 1222 |
-
|
| 1223 |
-
|
| 1224 |
-
|
| 1225 |
-
|
| 1226 |
-
|
| 1227 |
-
|
| 1228 |
-
|
| 1229 |
-
return
|
| 1230 |
-
|
| 1231 |
-
|
| 1232 |
-
|
| 1233 |
-
|
| 1234 |
-
|
| 1235 |
-
|
| 1236 |
-
|
| 1237 |
-
|
| 1238 |
-
|
| 1239 |
-
|
| 1240 |
-
|
| 1241 |
-
|
| 1242 |
-
|
| 1243 |
-
|
| 1244 |
-
|
| 1245 |
-
|
| 1246 |
-
|
| 1247 |
-
|
| 1248 |
-
|
| 1249 |
-
|
| 1250 |
-
|
| 1251 |
-
|
| 1252 |
-
|
| 1253 |
-
|
| 1254 |
-
|
| 1255 |
-
|
| 1256 |
-
|
| 1257 |
-
|
| 1258 |
-
|
| 1259 |
-
|
| 1260 |
-
|
| 1261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1262 |
|
| 1263 |
-
|
| 1264 |
-
|
| 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 |
-
|
| 1271 |
-
|
| 1272 |
-
|
| 1273 |
-
|
| 1274 |
|
| 1275 |
-
|
| 1276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1283 |
-
|
| 1284 |
-
|
|
|
|
|
|
|
|
|
|
| 1285 |
|
| 1286 |
-
|
| 1287 |
-
|
|
|
|
| 1288 |
|
| 1289 |
-
|
| 1290 |
-
|
| 1291 |
-
|
| 1292 |
-
|
| 1293 |
-
"notes": ""
|
| 1294 |
-
})
|
| 1295 |
-
return i, result
|
| 1296 |
|
| 1297 |
-
|
| 1298 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1305 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1306 |
|
| 1307 |
-
|
| 1308 |
-
|
| 1309 |
-
|
| 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 |
-
|
| 1351 |
-
|
| 1352 |
-
|
| 1353 |
-
|
| 1354 |
-
|
| 1355 |
-
|
| 1356 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'])
|