Spaces:
Sleeping
Sleeping
Update main.py
Browse files
main.py
CHANGED
|
@@ -1184,48 +1184,206 @@ def _get_price_trend_analysis_for_chat(crop_type=None, location=None):
|
|
| 1184 |
try:
|
| 1185 |
all_deals = db.reference('deals', app=db_app).order_by_child('status').equal_to('completed').get() or {}
|
| 1186 |
price_data_points = []
|
| 1187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1188 |
if not price_data_points: return f"Not enough historical data for trends for {crop_type or 'crops'} in {location or 'all locations'}.", []
|
| 1189 |
-
|
| 1190 |
-
|
| 1191 |
-
|
| 1192 |
-
|
| 1193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1194 |
|
| 1195 |
def _fetch_platform_data_for_chat(user_message):
|
| 1196 |
if not FIREBASE_INITIALIZED: return "Firebase not ready for platform data.", []
|
| 1197 |
try:
|
| 1198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1199 |
listings_ref = db.reference('listings', app=db_app).order_by_child('status').equal_to('active')
|
| 1200 |
-
|
| 1201 |
-
|
| 1202 |
-
|
|
|
|
|
|
|
| 1203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1204 |
|
|
|
|
| 1205 |
@app.route("/api/ai/chat", methods=["POST"])
|
| 1206 |
def ai_chat():
|
| 1207 |
auth_header = request.headers.get("Authorization", "");
|
| 1208 |
uid = None
|
|
|
|
| 1209 |
try:
|
| 1210 |
-
if not FIREBASE_INITIALIZED or not gemini_client:
|
|
|
|
|
|
|
| 1211 |
uid = verify_token(auth_header)
|
| 1212 |
-
if not uid:
|
| 1213 |
-
|
| 1214 |
-
|
| 1215 |
-
|
| 1216 |
-
|
| 1217 |
-
|
| 1218 |
-
|
| 1219 |
-
|
| 1220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1221 |
db.reference(f'ai_chat_history/{uid}/{str(uuid.uuid4())}', app=db_app).set({
|
| 1222 |
-
'user_message': user_message,
|
| 1223 |
-
'
|
|
|
|
|
|
|
| 1224 |
})
|
| 1225 |
return jsonify({"response": ai_response_text, "intent": intent})
|
| 1226 |
-
|
| 1227 |
-
except
|
| 1228 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1229 |
|
| 1230 |
@app.route('/api/user/ai-chat-history', methods=['GET'])
|
| 1231 |
def get_ai_chat_history():
|
|
|
|
| 1184 |
try:
|
| 1185 |
all_deals = db.reference('deals', app=db_app).order_by_child('status').equal_to('completed').get() or {}
|
| 1186 |
price_data_points = []
|
| 1187 |
+
if all_deals:
|
| 1188 |
+
for deal_id, deal in all_deals.items():
|
| 1189 |
+
if not deal: continue # Skip if deal data is malformed or None
|
| 1190 |
+
listing_id = deal.get('listing_id')
|
| 1191 |
+
listing_details = db.reference(f'listings/{listing_id}', app=db_app).get() if listing_id else None
|
| 1192 |
+
if listing_details:
|
| 1193 |
+
deal_crop_type = listing_details.get('crop_type')
|
| 1194 |
+
deal_location = listing_details.get('location')
|
| 1195 |
+
|
| 1196 |
+
match_crop = not crop_type or (deal_crop_type and crop_type.lower() in deal_crop_type.lower())
|
| 1197 |
+
match_location = not location or (deal_location and location.lower() in deal_location.lower())
|
| 1198 |
+
|
| 1199 |
+
if match_crop and match_location:
|
| 1200 |
+
price_data_points.append({
|
| 1201 |
+
'price': deal.get('agreed_price') or deal.get('proposed_price'),
|
| 1202 |
+
'date': (deal.get('admin_approved_at') or deal.get('created_at', ''))[:10], # Date only, ensure created_at exists
|
| 1203 |
+
'crop': deal_crop_type
|
| 1204 |
+
})
|
| 1205 |
if not price_data_points: return f"Not enough historical data for trends for {crop_type or 'crops'} in {location or 'all locations'}.", []
|
| 1206 |
+
|
| 1207 |
+
data_summary_for_gemini = f"Historical transaction data for {crop_type or 'various crops'} in {location or 'various locations'}:\n"
|
| 1208 |
+
for point in price_data_points[:15]: # Limit data points for chat context
|
| 1209 |
+
data_summary_for_gemini += f"- Crop: {point.get('crop')}, Price: {point.get('price')}, Date: {point.get('date')}\n"
|
| 1210 |
|
| 1211 |
+
prompt = f"""
|
| 1212 |
+
Analyze transaction data from Tunasonga Agri. Provide a brief price trend analysis for {crop_type if crop_type else 'the general market'}{f' in {location}' if location else ''}.
|
| 1213 |
+
Is price increasing, decreasing, or stable? Note patterns. Concise. State if data is sparse.
|
| 1214 |
+
Data: {data_summary_for_gemini}
|
| 1215 |
+
Analysis:
|
| 1216 |
+
"""
|
| 1217 |
+
response = gemini_client.generate_content(model='gemini-2.0-flash', contents=[{'parts': [{'text': prompt}]}])
|
| 1218 |
+
return response.text.strip(), price_data_points
|
| 1219 |
+
except firebase_exceptions.FirebaseError as fe:
|
| 1220 |
+
logger.error(f"Chat (Trend Helper): Firebase Error: {fe}")
|
| 1221 |
+
return "Database error occurred while fetching trend data.", []
|
| 1222 |
+
except Exception as e:
|
| 1223 |
+
logger.error(f"Chat (Trend Helper): Gemini Trend Error or other: {e}")
|
| 1224 |
+
return "Could not generate price trend analysis at this time.", []
|
| 1225 |
|
| 1226 |
def _fetch_platform_data_for_chat(user_message):
|
| 1227 |
if not FIREBASE_INITIALIZED: return "Firebase not ready for platform data.", []
|
| 1228 |
try:
|
| 1229 |
+
keywords = user_message.lower().split()
|
| 1230 |
+
extracted_entities = {'crop_type': None, 'location': None, 'listing_type': None}
|
| 1231 |
+
# Consider making these lists configurable or fetched from DB if they grow large
|
| 1232 |
+
common_crops = ["maize", "beans", "tomatoes", "potatoes", "cabbage", "onions", "sorghum", "millet", "groundnuts", "wheat", "soybeans", "sunflower"]
|
| 1233 |
+
common_locations = ["harare", "bulawayo", "mutare", "gweru", "masvingo", "chinhoyi", "bindura", "marondera", # Zimbabwe
|
| 1234 |
+
"lusaka", "ndola", "kitwe", "livingstone", "chipata", # Zambia
|
| 1235 |
+
"maputo", "beira", "nampula", "tete"] # Mozambique
|
| 1236 |
+
|
| 1237 |
+
for word in keywords:
|
| 1238 |
+
if word in common_crops and not extracted_entities['crop_type']: extracted_entities['crop_type'] = word
|
| 1239 |
+
if word in common_locations and not extracted_entities['location']: extracted_entities['location'] = word
|
| 1240 |
+
|
| 1241 |
+
if any(k in keywords for k in ["listings", "produce", "selling", "for sale", "offerings"]): extracted_entities['listing_type'] = 'produce'
|
| 1242 |
+
elif any(k in keywords for k in ["demands", "requests", "buying", "looking for", "needs"]): extracted_entities['listing_type'] = 'demand'
|
| 1243 |
+
|
| 1244 |
+
if not extracted_entities['listing_type'] and not extracted_entities['crop_type']:
|
| 1245 |
+
return "To search platform data, please specify if you're looking for produce for sale or buyer demands, and optionally a crop type or location.", []
|
| 1246 |
+
|
| 1247 |
listings_ref = db.reference('listings', app=db_app).order_by_child('status').equal_to('active')
|
| 1248 |
+
all_active_listings = listings_ref.get() or {}
|
| 1249 |
+
found_items = []
|
| 1250 |
+
|
| 1251 |
+
for lid, ldata in all_active_listings.items():
|
| 1252 |
+
if not isinstance(ldata, dict): continue # Skip malformed entries
|
| 1253 |
|
| 1254 |
+
match = True
|
| 1255 |
+
if extracted_entities['listing_type'] and ldata.get('listing_type') != extracted_entities['listing_type']:
|
| 1256 |
+
match = False
|
| 1257 |
+
# Use 'in' for partial matches on crop type and location if desired, or exact match
|
| 1258 |
+
if extracted_entities['crop_type'] and not (extracted_entities['crop_type'] in ldata.get('crop_type', '').lower()):
|
| 1259 |
+
match = False
|
| 1260 |
+
if extracted_entities['location'] and not (extracted_entities['location'] in ldata.get('location', '').lower()):
|
| 1261 |
+
match = False
|
| 1262 |
+
|
| 1263 |
+
if match:
|
| 1264 |
+
item_summary = f"- {ldata.get('listing_type', 'Item').capitalize()}: {ldata.get('crop_type', 'N/A')} in {ldata.get('location', 'N/A')}"
|
| 1265 |
+
if ldata.get('listing_type') == 'produce':
|
| 1266 |
+
item_summary += f", Price: {ldata.get('asking_price', 'N/A')}"
|
| 1267 |
+
elif ldata.get('listing_type') == 'demand':
|
| 1268 |
+
item_summary += f", Price Range: {ldata.get('price_range', 'N/A')}"
|
| 1269 |
+
item_summary += f" (Qty: {ldata.get('quantity', 'N/A')})"
|
| 1270 |
+
found_items.append(item_summary)
|
| 1271 |
+
|
| 1272 |
+
if not found_items:
|
| 1273 |
+
return f"No active {extracted_entities['listing_type'] or 'items'} currently found on the platform matching your criteria ({extracted_entities['crop_type'] or 'any crop'}, {extracted_entities['location'] or 'any location'}). You can browse all items in the marketplace section.", []
|
| 1274 |
+
|
| 1275 |
+
summary = f"Here's what I found on the Tunasonga Agri platform based on your query:\n" + "\n".join(found_items[:5]) # Show top 5
|
| 1276 |
+
if len(found_items) > 5:
|
| 1277 |
+
summary += f"\n...and {len(found_items) - 5} more. For a full list, please visit the marketplace section or refine your search terms."
|
| 1278 |
+
return summary, found_items
|
| 1279 |
+
except firebase_exceptions.FirebaseError as fe:
|
| 1280 |
+
logger.error(f"Chat (Platform Data Helper): Firebase Error: {fe}")
|
| 1281 |
+
return "Database error occurred while fetching platform data.", []
|
| 1282 |
+
except Exception as e:
|
| 1283 |
+
logger.error(f"Chat (Platform Data Helper): Error: {e}")
|
| 1284 |
+
return "Could not fetch platform data at this time.", []
|
| 1285 |
|
| 1286 |
+
# --- AI Chat Endpoint ---
|
| 1287 |
@app.route("/api/ai/chat", methods=["POST"])
|
| 1288 |
def ai_chat():
|
| 1289 |
auth_header = request.headers.get("Authorization", "");
|
| 1290 |
uid = None
|
| 1291 |
+
response_obj_gemini = None # For logging Gemini response object in case of AttributeError
|
| 1292 |
try:
|
| 1293 |
+
if not FIREBASE_INITIALIZED or not gemini_client:
|
| 1294 |
+
return jsonify({'error': 'Server or AI service not ready.'}), 503
|
| 1295 |
+
|
| 1296 |
uid = verify_token(auth_header)
|
| 1297 |
+
if not uid: # verify_token now raises, so this is a fallback if it somehow returns None without raising
|
| 1298 |
+
return jsonify({"error": "Authentication required.", "login_required": True}), 401
|
| 1299 |
+
|
| 1300 |
+
data = request.get_json()
|
| 1301 |
+
user_message = data.get("message", "").strip()
|
| 1302 |
+
if not user_message:
|
| 1303 |
+
return jsonify({"error": "Message cannot be empty."}), 400
|
| 1304 |
+
|
| 1305 |
+
# 1. Classify Intent
|
| 1306 |
+
classify_prompt = f"""
|
| 1307 |
+
Analyze the user's query for the Tunasonga Agri platform. Classify the primary intent into ONE of these categories:
|
| 1308 |
+
- "platform_data_query": User is asking about current listings, demands, or specific items available *right now* on the platform. (e.g., "any maize for sale?", "who needs beans in Harare?")
|
| 1309 |
+
- "price_trend_query": User is asking about historical price trends, market analysis, or future price predictions. (e.g., "how are maize prices trending?", "what was the price of sorghum last month?")
|
| 1310 |
+
- "general_agri_info": User is asking for general agricultural advice, farming techniques, business tips, or information not tied to current platform data or specific historical trends. (e.g., "how to grow tomatoes?", "best fertilizer for sandy soils", "exporting crops in SADC")
|
| 1311 |
+
- "other": If the query doesn't fit well or is a simple greeting/chit-chat.
|
| 1312 |
+
|
| 1313 |
+
User Query: "{user_message}"
|
| 1314 |
+
|
| 1315 |
+
Return ONLY the category string (e.g., "platform_data_query").
|
| 1316 |
+
"""
|
| 1317 |
+
response_obj_gemini = gemini_client.generate_content(model='gemini-2.0-flash', contents=[{'parts': [{'text': classify_prompt}]}])
|
| 1318 |
+
intent = response_obj_gemini.text.strip().replace('"', '')
|
| 1319 |
+
if intent not in ["platform_data_query", "price_trend_query", "general_agri_info", "other"]:
|
| 1320 |
+
logger.warning(f"AI Chat: Unexpected intent: '{intent}' for query '{user_message}'. Defaulting to general_agri_info.")
|
| 1321 |
+
intent = "general_agri_info"
|
| 1322 |
+
|
| 1323 |
+
# 2. Fetch Context Data based on Intent
|
| 1324 |
+
context_for_gemini = ""
|
| 1325 |
+
platform_data_summary = ""
|
| 1326 |
+
trend_analysis_summary = ""
|
| 1327 |
+
|
| 1328 |
+
if intent == "platform_data_query":
|
| 1329 |
+
platform_data_summary, _ = _fetch_platform_data_for_chat(user_message)
|
| 1330 |
+
if platform_data_summary:
|
| 1331 |
+
context_for_gemini += f"Current Platform Data Context (summarized for you):\n{platform_data_summary}\n\n"
|
| 1332 |
+
|
| 1333 |
+
elif intent == "price_trend_query":
|
| 1334 |
+
# Basic entity extraction for trend query (crop, location)
|
| 1335 |
+
crop_match = re.search(r"(?:trend for|prices of|price of|trend of)\s+([\w\s]+?)(?:\s+in\s+([\w\s]+))?$", user_message.lower())
|
| 1336 |
+
trend_crop, trend_location = (None, None)
|
| 1337 |
+
if crop_match:
|
| 1338 |
+
trend_crop = crop_match.group(1).strip()
|
| 1339 |
+
if crop_match.group(2):
|
| 1340 |
+
trend_location = crop_match.group(2).strip()
|
| 1341 |
+
|
| 1342 |
+
trend_analysis_summary, _ = _get_price_trend_analysis_for_chat(crop_type=trend_crop, location=trend_location)
|
| 1343 |
+
if trend_analysis_summary:
|
| 1344 |
+
context_for_gemini += f"Price Trend Analysis Context (generated for you):\n{trend_analysis_summary}\n\n"
|
| 1345 |
+
|
| 1346 |
+
# 3. Generate Final Response with Gemini
|
| 1347 |
+
main_prompt = f"""
|
| 1348 |
+
You are Tunasonga Agri Assistant, an AI for an agricultural marketplace in Zimbabwe and SADC.
|
| 1349 |
+
Your goal is to provide helpful, concise, and accurate information. Your persona is professional, friendly, and supportive of farmers and agri-businesses.
|
| 1350 |
+
|
| 1351 |
+
The user's original query intent was classified as: {intent}
|
| 1352 |
+
{context_for_gemini}
|
| 1353 |
+
Based on the user's query and any provided context above, please formulate your answer to the user.
|
| 1354 |
+
User Query: "{user_message}"
|
| 1355 |
+
|
| 1356 |
+
Specific Instructions based on intent:
|
| 1357 |
+
- If the intent was 'platform_data_query': Use the "Current Platform Data Context" to answer. If the context says no items were found, relay that and suggest they browse the marketplace or refine their search. Do not invent listings.
|
| 1358 |
+
- If the intent was 'price_trend_query': Use the "Price Trend Analysis Context". If the context says trends couldn't be generated, relay that. Do not invent trends.
|
| 1359 |
+
- For 'general_agri_info': Use your broad agricultural knowledge. Focus on practices relevant to the SADC region, smallholder farmers, climate-smart agriculture, market access, and agri-business development. Provide actionable advice if possible.
|
| 1360 |
+
- If the query is unclear, classified as "other", or if the context is insufficient for a specific query: Provide a polite general response, ask for clarification, or gently guide the user on how you can help (e.g., "I can help with finding produce, getting price trends, or general farming advice. What would you like to know?").
|
| 1361 |
+
|
| 1362 |
+
Keep your answers clear and easy to understand. Avoid overly technical jargon unless necessary and explain it.
|
| 1363 |
+
Answer:
|
| 1364 |
+
"""
|
| 1365 |
+
response_obj_gemini = gemini_client.generate_content(model='gemini-2.0-flash', contents=[{'parts': [{'text': main_prompt}]}])
|
| 1366 |
+
ai_response_text = response_obj_gemini.text.strip()
|
| 1367 |
+
|
| 1368 |
+
# 4. Store Chat History
|
| 1369 |
db.reference(f'ai_chat_history/{uid}/{str(uuid.uuid4())}', app=db_app).set({
|
| 1370 |
+
'user_message': user_message,
|
| 1371 |
+
'ai_response': ai_response_text,
|
| 1372 |
+
'intent_classified': intent,
|
| 1373 |
+
'timestamp': datetime.now(timezone.utc).isoformat()
|
| 1374 |
})
|
| 1375 |
return jsonify({"response": ai_response_text, "intent": intent})
|
| 1376 |
+
|
| 1377 |
+
except AttributeError as ae: # Gemini response structure issue
|
| 1378 |
+
logger.error(f"Gemini Response Attribute Error in ai_chat (UID: {uid}): {ae}. Response object: {response_obj_gemini}")
|
| 1379 |
+
ai_response_text = "I'm having a little trouble understanding that. Could you try rephrasing?"
|
| 1380 |
+
try: # Attempt to get text if it's a common alternative structure
|
| 1381 |
+
if response_obj_gemini and response_obj_gemini.candidates:
|
| 1382 |
+
ai_response_text = response_obj_gemini.candidates[0].content.parts[0].text
|
| 1383 |
+
except Exception: pass # Stick to default error message
|
| 1384 |
+
return jsonify({"response": ai_response_text, "intent": intent or "unknown", "error_detail": "AI_RESPONSE_FORMAT_ISSUE"}), 200 # Return 200 with a user-friendly error
|
| 1385 |
+
except Exception as e: # Catches errors from verify_token, Firebase, or other unexpected issues
|
| 1386 |
+
return handle_route_errors(e, uid_context=uid)
|
| 1387 |
|
| 1388 |
@app.route('/api/user/ai-chat-history', methods=['GET'])
|
| 1389 |
def get_ai_chat_history():
|