Update utility.py
Browse files- utility.py +683 -266
utility.py
CHANGED
|
@@ -1,348 +1,765 @@
|
|
|
|
|
| 1 |
import json
|
| 2 |
import os
|
| 3 |
import logging
|
| 4 |
from datetime import datetime
|
| 5 |
-
from typing import List, Dict, Union, Optional
|
| 6 |
from google.cloud import firestore
|
| 7 |
import pandas as pd
|
|
|
|
|
|
|
| 8 |
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 9 |
import google.generativeai as genai
|
|
|
|
| 10 |
|
| 11 |
logger = logging.getLogger(__name__)
|
| 12 |
|
| 13 |
db = firestore.Client.from_service_account_json("firestore-key.json")
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
top_k=1,
|
| 20 |
-
top_p=0.01,
|
| 21 |
-
)
|
| 22 |
-
|
| 23 |
-
def generateResponse(prompt: str) -> str:
|
| 24 |
-
"""Generate structured response from user input"""
|
| 25 |
-
system_prompt = """You MUST format responses EXACTLY as follows:
|
| 26 |
-
|
| 27 |
-
*Intent*: [create/read/update/delete]
|
| 28 |
-
*Transaction Type*: [purchase/sale/inventory/etc]
|
| 29 |
-
*Details*:
|
| 30 |
-
- Field1: Value1
|
| 31 |
-
- Field2: Value2
|
| 32 |
-
- Field3: Value3
|
| 33 |
-
|
| 34 |
-
For multiple transactions, repeat this pattern for each transaction."""
|
| 35 |
-
|
| 36 |
-
genai.configure(api_key=os.environ.get("GOOGLE_API_KEY"))
|
| 37 |
model = genai.GenerativeModel(
|
| 38 |
-
|
| 39 |
generation_config={
|
| 40 |
-
"temperature": 0.1,
|
| 41 |
-
"top_p": 0.
|
| 42 |
-
"top_k":
|
| 43 |
-
"max_output_tokens":
|
|
|
|
| 44 |
}
|
| 45 |
)
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
try:
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
except Exception as e:
|
| 51 |
-
logger.error(f"Response generation failed: {e}")
|
| 52 |
-
|
|
|
|
|
|
|
| 53 |
|
| 54 |
def parse_multiple_transactions(response_text: str) -> List[Dict]:
|
| 55 |
-
"""Parse response into structured transactions"""
|
| 56 |
transactions = []
|
| 57 |
-
current_trans = {}
|
| 58 |
-
|
| 59 |
try:
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
def add_timestamp(transaction: Dict) -> Dict:
|
| 92 |
-
"""Add timestamp to transaction"""
|
| 93 |
-
|
|
|
|
| 94 |
return transaction
|
| 95 |
|
|
|
|
|
|
|
| 96 |
def create_inventory(user_phone: str, transaction_data: List[Dict]) -> bool:
|
| 97 |
-
"""Create/update inventory items"""
|
| 98 |
batch = db.batch()
|
| 99 |
inventory_ref = db.collection("users").document(user_phone).collection("inventory")
|
| 100 |
-
|
|
|
|
| 101 |
for transaction in transaction_data:
|
| 102 |
-
|
|
|
|
| 103 |
if not item_name:
|
|
|
|
|
|
|
| 104 |
continue
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
'
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
'details': {
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
},
|
| 118 |
-
'last_updated': datetime.now().isoformat()
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
try:
|
| 122 |
batch.commit()
|
| 123 |
-
|
|
|
|
| 124 |
except Exception as e:
|
| 125 |
-
logger.error(f"Inventory
|
| 126 |
return False
|
| 127 |
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
inventory_ref = db.collection("users").document(user_phone).collection("inventory")
|
| 132 |
sales_ref = db.collection("users").document(user_phone).collection("sales")
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
try:
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
except Exception as e:
|
| 165 |
-
logger.error(f"Sales processing failed: {e}")
|
| 166 |
-
|
|
|
|
|
|
|
| 167 |
|
| 168 |
def read_datalake(user_phone: str, query: str) -> Union[str, Dict]:
|
| 169 |
-
"""Query user's transaction data"""
|
| 170 |
-
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
try:
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
except Exception as e:
|
| 197 |
-
logger.error(f"Data query failed: {e}")
|
| 198 |
-
return "Sorry, I
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
"""Update existing transaction"""
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
if
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
}
|
| 211 |
-
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
try:
|
| 214 |
-
doc_ref.
|
| 215 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
except Exception as e:
|
| 217 |
-
logger.error(f"Update failed: {e}")
|
| 218 |
-
return False
|
|
|
|
| 219 |
|
| 220 |
-
def delete_transaction(user_phone: str, transaction_data: List[Dict]) -> bool:
|
| 221 |
-
"""Delete specified transactions"""
|
| 222 |
batch = db.batch()
|
| 223 |
-
collection_map = {
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
for transaction in transaction_data:
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
| 231 |
if not item_name:
|
|
|
|
| 232 |
continue
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
batch.delete(doc_ref)
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
try:
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
except Exception as e:
|
| 244 |
-
logger.error(f"Deletion failed: {e}")
|
| 245 |
-
|
|
|
|
|
|
|
| 246 |
|
| 247 |
def persist_temporary_transaction(transactions: List[Dict], mobile: str) -> bool:
|
| 248 |
-
"""Store transactions temporarily before confirmation"""
|
|
|
|
|
|
|
|
|
|
| 249 |
try:
|
| 250 |
doc_ref = db.collection("users").document(mobile).collection("temp_transactions").document("pending")
|
| 251 |
doc_ref.set({
|
| 252 |
-
"transactions": transactions,
|
| 253 |
"timestamp": datetime.now().isoformat(),
|
| 254 |
"status": "pending_confirmation"
|
| 255 |
})
|
|
|
|
| 256 |
return True
|
| 257 |
except Exception as e:
|
| 258 |
-
logger.error(f"
|
| 259 |
return False
|
| 260 |
|
| 261 |
-
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
| 263 |
if not transactions:
|
| 264 |
-
return "No transaction data"
|
| 265 |
-
|
| 266 |
if isinstance(transactions, dict):
|
| 267 |
-
transactions = [transactions]
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
item_name: Optional[str] = None,
|
| 291 |
-
collection: str = "inventory") -> Union[Dict, List[Dict]]:
|
| 292 |
-
"""Retrieve transaction(s) from Firestore"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
col_ref = db.collection("users").document(user_phone).collection(collection)
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
|
| 301 |
def process_intent(parsed_trans_data: List[Dict], mobile: str) -> str:
|
| 302 |
-
"""Route transactions to appropriate CRUD operation"""
|
| 303 |
if not parsed_trans_data:
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
transaction_summary = format_transaction_response(parsed_trans_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
|
| 310 |
-
if intent == 'create':
|
| 311 |
-
if trans_type in ('purchase', 'inventory'):
|
| 312 |
-
success = create_inventory(mobile, parsed_trans_data)
|
| 313 |
-
return ("Inventory updated successfully!\n\n" + transaction_summary) if success else \
|
| 314 |
-
("Failed to update inventory!\n\n" + transaction_summary)
|
| 315 |
-
elif trans_type == 'sale':
|
| 316 |
-
success = create_sale(mobile, parsed_trans_data)
|
| 317 |
-
return ("Sale recorded successfully!\n\n" + transaction_summary) if success else \
|
| 318 |
-
("Failed to record sale! Check inventory.\n\n" + transaction_summary)
|
| 319 |
else:
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
if
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
return f"Item details:\n\n{format_transaction_response(result)}" if result else f"Item '{item}' not found"
|
| 327 |
else:
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
updates = {k:v for k,v in parsed_trans_data[0]['details'].items() if k != 'item'}
|
| 337 |
-
success = update_transaction(mobile, item, updates, trans_type if trans_type else 'inventory')
|
| 338 |
-
return (f"Updated {item} successfully!\n\n{transaction_summary}") if success else \
|
| 339 |
-
(f"Failed to update {item}!\n\n{transaction_summary}")
|
| 340 |
-
|
| 341 |
-
elif intent == 'delete':
|
| 342 |
-
item = parsed_trans_data[0]['details'].get('item', 'item')
|
| 343 |
-
success = delete_transaction(mobile, parsed_trans_data)
|
| 344 |
-
return (f"Deleted {item} successfully!") if success else \
|
| 345 |
-
(f"Failed to delete {item}!")
|
| 346 |
-
|
| 347 |
-
else:
|
| 348 |
-
return f"Unsupported intent: {intent}\n\n{transaction_summary}"
|
|
|
|
| 1 |
+
# utility.py
|
| 2 |
import json
|
| 3 |
import os
|
| 4 |
import logging
|
| 5 |
from datetime import datetime
|
| 6 |
+
from typing import List, Dict, Union, Optional, Any
|
| 7 |
from google.cloud import firestore
|
| 8 |
import pandas as pd
|
| 9 |
+
# Remove pandasai if not strictly needed or causing issues, replace with direct Firestore queries
|
| 10 |
+
# from pandasai import SmartDatalake
|
| 11 |
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 12 |
import google.generativeai as genai
|
| 13 |
+
import re # For currency check
|
| 14 |
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
db = firestore.Client.from_service_account_json("firestore-key.json")
|
| 18 |
|
| 19 |
+
# Configure Google Generative AI (Consider making model configurable)
|
| 20 |
+
try:
|
| 21 |
+
genai.configure(api_key=os.environ["GOOGLE_API_KEY"])
|
| 22 |
+
GENERATIVE_MODEL_NAME = "gemini-2.0-flash" # Or another suitable model like gemini-pro
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
model = genai.GenerativeModel(
|
| 24 |
+
GENERATIVE_MODEL_NAME,
|
| 25 |
generation_config={
|
| 26 |
+
"temperature": 0.1, # Slightly higher temp might allow more flexibility if needed
|
| 27 |
+
"top_p": 0.9, # Adjust Top P and K for desired creativity/determinism
|
| 28 |
+
"top_k": 10, # Example adjustment
|
| 29 |
+
"max_output_tokens": 2048, # Increased token limit
|
| 30 |
+
"response_mime_type": "application/json", # Explicitly request JSON
|
| 31 |
}
|
| 32 |
)
|
| 33 |
+
# Also configure LangChain LLM if used elsewhere (e.g., potentially for pandasai if kept)
|
| 34 |
+
llm = ChatGoogleGenerativeAI(
|
| 35 |
+
model=GENERATIVE_MODEL_NAME,
|
| 36 |
+
temperature=0.1,
|
| 37 |
+
# Add other relevant LangChain config if needed
|
| 38 |
+
convert_system_message_to_human=True # Often helpful for Gemini
|
| 39 |
+
)
|
| 40 |
+
logger.info(f"Using Generative Model: {GENERATIVE_MODEL_NAME}")
|
| 41 |
+
|
| 42 |
+
except KeyError:
|
| 43 |
+
logger.error("GOOGLE_API_KEY environment variable not set!")
|
| 44 |
+
# Handle this case gracefully, maybe disable LLM features
|
| 45 |
+
model = None
|
| 46 |
+
llm = None
|
| 47 |
+
except Exception as e:
|
| 48 |
+
logger.error(f"Error configuring Generative AI: {e}")
|
| 49 |
+
model = None
|
| 50 |
+
llm = None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def generateResponse(prompt: str) -> str:
|
| 54 |
+
"""Generate structured JSON response from user input using Generative AI."""
|
| 55 |
+
if not model:
|
| 56 |
+
logger.error("Generative AI model not configured. Cannot generate response.")
|
| 57 |
+
return '{"error": "Model not available"}' # Return JSON error
|
| 58 |
+
|
| 59 |
+
# Refined System Prompt requesting JSON
|
| 60 |
+
system_prompt = """
|
| 61 |
+
Analyze the user's request for business transaction management (like sales, purchases, inventory checks).
|
| 62 |
+
Your goal is to extract structured information about one or more transactions.
|
| 63 |
+
You MUST output your response as a valid JSON list containing one or more transaction objects.
|
| 64 |
+
Each transaction object MUST have the following keys:
|
| 65 |
+
- "intent": A string representing the user's goal (e.g., "create", "read", "update", "delete").
|
| 66 |
+
- "transaction_type": A string categorizing the transaction (e.g., "sale", "purchase", "inventory", "query", "other").
|
| 67 |
+
- "details": An object containing key-value pairs extracted from the request. Keys should be lowercase strings (e.g., "item", "quantity", "price", "customer", "date", "description"). Values should be strings or numbers as appropriate. Include a "currency" key (e.g., "$", "£", "€", "ZAR") if a monetary value is present. If no specific type is clear, use "query" or "other".
|
| 68 |
+
|
| 69 |
+
Example Input: "Record a sale of 5 apples at $2 each to John Doe"
|
| 70 |
+
Example Output:
|
| 71 |
+
[
|
| 72 |
+
{
|
| 73 |
+
"intent": "create",
|
| 74 |
+
"transaction_type": "sale",
|
| 75 |
+
"details": {
|
| 76 |
+
"item": "apples",
|
| 77 |
+
"quantity": 5,
|
| 78 |
+
"price": 2,
|
| 79 |
+
"currency": "$",
|
| 80 |
+
"customer": "John Doe"
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
]
|
| 84 |
+
|
| 85 |
+
Example Input: "Add 10 bananas to stock and how many apples do I have?"
|
| 86 |
+
Example Output:
|
| 87 |
+
[
|
| 88 |
+
{
|
| 89 |
+
"intent": "create",
|
| 90 |
+
"transaction_type": "inventory",
|
| 91 |
+
"details": {
|
| 92 |
+
"item": "bananas",
|
| 93 |
+
"quantity": 10
|
| 94 |
+
}
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
"intent": "read",
|
| 98 |
+
"transaction_type": "query",
|
| 99 |
+
"details": {
|
| 100 |
+
"item": "apples",
|
| 101 |
+
"query": "check stock"
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
]
|
| 105 |
+
|
| 106 |
+
If the request is unclear or doesn't seem like a transaction, try to interpret it as a "query" intent or respond with an appropriate JSON structure indicating ambiguity.
|
| 107 |
+
Do NOT add any text before or after the JSON list. Just output the JSON.
|
| 108 |
+
"""
|
| 109 |
+
|
| 110 |
try:
|
| 111 |
+
# Using the newer API structure if applicable, might need adjustment based on library version
|
| 112 |
+
full_prompt = [system_prompt, prompt] # Or construct as per specific library requirements
|
| 113 |
+
response = model.generate_content(full_prompt)
|
| 114 |
+
|
| 115 |
+
# Accessing the response text might differ slightly based on the exact genai version
|
| 116 |
+
response_text = response.text
|
| 117 |
+
logger.info(f"LLM Raw Response: {response_text}") # Log the raw response for debugging
|
| 118 |
+
|
| 119 |
+
# Basic validation: Check if it looks like JSON
|
| 120 |
+
if response_text.strip().startswith('[') and response_text.strip().endswith(']'):
|
| 121 |
+
return response_text
|
| 122 |
+
else:
|
| 123 |
+
logger.warning(f"LLM response does not look like a JSON list: {response_text}")
|
| 124 |
+
# Attempt to wrap it if it's a single object, otherwise return error
|
| 125 |
+
if response_text.strip().startswith('{') and response_text.strip().endswith('}'):
|
| 126 |
+
return f"[{response_text.strip()}]"
|
| 127 |
+
return '{"error": "Invalid format from LLM"}'
|
| 128 |
+
|
| 129 |
except Exception as e:
|
| 130 |
+
logger.error(f"LLM Response generation failed: {e}", exc_info=True)
|
| 131 |
+
# Return a JSON formatted error message
|
| 132 |
+
return '{"error": "Failed to process request with LLM"}'
|
| 133 |
+
|
| 134 |
|
| 135 |
def parse_multiple_transactions(response_text: str) -> List[Dict]:
|
| 136 |
+
"""Parse JSON response from LLM into structured transactions."""
|
| 137 |
transactions = []
|
|
|
|
|
|
|
| 138 |
try:
|
| 139 |
+
# Prioritize JSON parsing
|
| 140 |
+
parsed_data = json.loads(response_text)
|
| 141 |
+
|
| 142 |
+
if isinstance(parsed_data, list):
|
| 143 |
+
# Ensure each item in the list is a dictionary and add timestamp
|
| 144 |
+
for item in parsed_data:
|
| 145 |
+
if isinstance(item, dict):
|
| 146 |
+
transactions.append(add_timestamp(item))
|
| 147 |
+
else:
|
| 148 |
+
logger.warning(f"Skipping non-dictionary item in JSON list: {item}")
|
| 149 |
+
if transactions: # Return only if we successfully parsed some transactions
|
| 150 |
+
return transactions
|
| 151 |
+
elif isinstance(parsed_data, dict) and 'error' in parsed_data:
|
| 152 |
+
logger.error(f"LLM returned an error: {parsed_data['error']}")
|
| 153 |
+
return [] # Return empty list on error
|
| 154 |
+
else:
|
| 155 |
+
logger.warning(f"Parsed JSON is not a list: {parsed_data}")
|
| 156 |
+
# Handle case where LLM might return a single object instead of a list
|
| 157 |
+
if isinstance(parsed_data, dict):
|
| 158 |
+
return [add_timestamp(parsed_data)]
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
except json.JSONDecodeError as json_err:
|
| 162 |
+
logger.error(f"JSONDecodeError: {json_err} - Response text: {response_text}")
|
| 163 |
+
# Fallback: Try simple text parsing (less reliable) ONLY if JSON fails completely
|
| 164 |
+
# This part is kept as a last resort but should ideally not be needed with JSON output
|
| 165 |
+
logger.info("Falling back to text parsing (less reliable).")
|
| 166 |
+
lines = [line.strip() for line in response_text.split('\n') if line.strip()]
|
| 167 |
+
current_trans = {}
|
| 168 |
+
in_details_section = False
|
| 169 |
+
|
| 170 |
+
for line in lines:
|
| 171 |
+
line_lower = line.lower()
|
| 172 |
+
if line_lower.startswith('*intent*:'):
|
| 173 |
+
if current_trans: transactions.append(add_timestamp(current_trans))
|
| 174 |
+
current_trans = {'details': {}} # Reset with details object
|
| 175 |
+
current_trans['intent'] = line.split(':', 1)[1].strip().lower()
|
| 176 |
+
in_details_section = False
|
| 177 |
+
elif line_lower.startswith('*transaction type*:'):
|
| 178 |
+
current_trans['transaction_type'] = line.split(':', 1)[1].strip().lower()
|
| 179 |
+
in_details_section = False
|
| 180 |
+
elif line_lower.startswith('*details*:'):
|
| 181 |
+
in_details_section = True
|
| 182 |
+
elif line.startswith('-') and in_details_section and 'details' in current_trans:
|
| 183 |
+
key_value = line[1:].strip().split(':', 1)
|
| 184 |
+
if len(key_value) == 2:
|
| 185 |
+
key = key_value[0].strip().lower().replace(" ", "_") # Normalize key
|
| 186 |
+
value = key_value[1].strip()
|
| 187 |
+
current_trans['details'][key] = value
|
| 188 |
+
|
| 189 |
+
if current_trans: # Add the last transaction
|
| 190 |
+
transactions.append(add_timestamp(current_trans))
|
| 191 |
+
|
| 192 |
+
if not transactions: # If fallback also failed
|
| 193 |
+
logger.error("Failed to parse LLM response using both JSON and text methods.")
|
| 194 |
+
return []
|
| 195 |
+
|
| 196 |
+
except Exception as e:
|
| 197 |
+
logger.error(f"Unexpected error during parsing: {e}", exc_info=True)
|
| 198 |
+
return [] # Return empty on unexpected error
|
| 199 |
+
|
| 200 |
+
# Final check for essential keys before returning
|
| 201 |
+
valid_transactions = []
|
| 202 |
+
for t in transactions:
|
| 203 |
+
if isinstance(t, dict) and 'intent' in t and 'transaction_type' in t and 'details' in t:
|
| 204 |
+
valid_transactions.append(t)
|
| 205 |
+
else:
|
| 206 |
+
logger.warning(f"Skipping invalid transaction structure after parsing: {t}")
|
| 207 |
+
|
| 208 |
+
return valid_transactions
|
| 209 |
+
|
| 210 |
|
| 211 |
def add_timestamp(transaction: Dict) -> Dict:
|
| 212 |
+
"""Add created_at timestamp to transaction if not present."""
|
| 213 |
+
if 'created_at' not in transaction:
|
| 214 |
+
transaction['created_at'] = datetime.now().isoformat()
|
| 215 |
return transaction
|
| 216 |
|
| 217 |
+
# --- Firestore CRUD Operations (Largely unchanged, minor logging improvements) ---
|
| 218 |
+
|
| 219 |
def create_inventory(user_phone: str, transaction_data: List[Dict]) -> bool:
|
| 220 |
+
"""Create/update inventory items in Firestore."""
|
| 221 |
batch = db.batch()
|
| 222 |
inventory_ref = db.collection("users").document(user_phone).collection("inventory")
|
| 223 |
+
success = True
|
| 224 |
+
|
| 225 |
for transaction in transaction_data:
|
| 226 |
+
details = transaction.get('details', {})
|
| 227 |
+
item_name = details.get('item')
|
| 228 |
if not item_name:
|
| 229 |
+
logger.warning(f"Skipping inventory update: 'item' missing in details. Data: {transaction}")
|
| 230 |
+
success = False # Mark as partial failure if any item is skipped
|
| 231 |
continue
|
| 232 |
+
|
| 233 |
+
try:
|
| 234 |
+
# Attempt to convert quantity to int, default to 0 if missing/invalid
|
| 235 |
+
quantity = int(details.get('quantity', 0))
|
| 236 |
+
except (ValueError, TypeError):
|
| 237 |
+
logger.warning(f"Invalid quantity for item '{item_name}'. Defaulting to 0. Data: {details.get('quantity')}")
|
| 238 |
+
quantity = 0
|
| 239 |
+
|
| 240 |
+
doc_ref = inventory_ref.document(item_name) # Use item name as document ID
|
| 241 |
+
|
| 242 |
+
# Prepare data, ensuring quantity is handled as an increment
|
| 243 |
+
item_data = {
|
| 244 |
+
'intent': transaction.get('intent', 'create'),
|
| 245 |
+
'transaction_type': transaction.get('transaction_type', 'inventory'),
|
| 246 |
'details': {
|
| 247 |
+
# Merge existing details, ensuring item name is present
|
| 248 |
+
**{k: v for k, v in details.items() if k != 'quantity'}, # Keep other details
|
| 249 |
+
'item': item_name, # Ensure item name is stored
|
| 250 |
+
'quantity': firestore.Increment(quantity), # Use Increment for atomic adds
|
| 251 |
},
|
| 252 |
+
'last_updated': datetime.now().isoformat(),
|
| 253 |
+
'created_at': transaction.get('created_at', datetime.now().isoformat()) # Store creation time
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
# Use set with merge=True to create or update the document
|
| 257 |
+
# We merge the 'details' field specifically if needed, but Increment handles quantity well.
|
| 258 |
+
# Let's refine the merge logic: update specific fields, especially quantity.
|
| 259 |
+
# Using set with merge=True might overwrite details if not careful.
|
| 260 |
+
# Let's use update for existing, set for new, or carefully manage merge.
|
| 261 |
+
|
| 262 |
+
# Simpler approach: Set with merge=True, but ensure Increment works.
|
| 263 |
+
# Firestore's Increment needs to be applied to an existing numeric field or it initializes to the increment value.
|
| 264 |
+
# This requires checking if the doc exists first for perfect merging, or rely on set(merge=True)
|
| 265 |
+
# which is generally fine if the structure is consistent.
|
| 266 |
+
|
| 267 |
+
batch.set(doc_ref, item_data, merge=True) # merge=True helps preserve other fields if doc exists
|
| 268 |
+
logger.info(f"Batched inventory update for item: {item_name}, quantity change: {quantity}")
|
| 269 |
+
|
| 270 |
try:
|
| 271 |
batch.commit()
|
| 272 |
+
logger.info(f"Inventory batch committed successfully for user {user_phone}.")
|
| 273 |
+
return success # Return True if all items processed, False if any were skipped
|
| 274 |
except Exception as e:
|
| 275 |
+
logger.error(f"Inventory batch commit failed for user {user_phone}: {e}", exc_info=True)
|
| 276 |
return False
|
| 277 |
|
| 278 |
+
|
| 279 |
+
def create_sale(user_phone: str, transaction_data: List[Dict]) -> tuple[bool, str]:
|
| 280 |
+
"""Process sales, validate inventory, update stock, and record sale."""
|
| 281 |
inventory_ref = db.collection("users").document(user_phone).collection("inventory")
|
| 282 |
sales_ref = db.collection("users").document(user_phone).collection("sales")
|
| 283 |
+
feedback_messages = []
|
| 284 |
+
all_successful = True
|
| 285 |
+
|
| 286 |
+
# Use a transaction for read-modify-write operations on inventory
|
| 287 |
+
@firestore.transactional
|
| 288 |
+
def process_sale_transaction(transaction, sale_details_list):
|
| 289 |
+
nonlocal feedback_messages, all_successful
|
| 290 |
+
sale_batch = db.batch() # Batch for creating sale records (can be outside transaction if preferred)
|
| 291 |
+
|
| 292 |
+
for sale_details in sale_details_list:
|
| 293 |
+
item_name = sale_details.get('item')
|
| 294 |
+
if not item_name:
|
| 295 |
+
feedback_messages.append("Sale skipped: Missing item name.")
|
| 296 |
+
all_successful = False
|
| 297 |
+
continue
|
| 298 |
+
|
| 299 |
+
try:
|
| 300 |
+
quantity_sold = int(sale_details.get('quantity', 0))
|
| 301 |
+
if quantity_sold <= 0:
|
| 302 |
+
feedback_messages.append(f"Sale skipped for '{item_name}': Invalid quantity ({quantity_sold}).")
|
| 303 |
+
all_successful = False
|
| 304 |
+
continue
|
| 305 |
+
except (ValueError, TypeError):
|
| 306 |
+
feedback_messages.append(f"Sale skipped for '{item_name}': Invalid quantity format.")
|
| 307 |
+
all_successful = False
|
| 308 |
+
continue
|
| 309 |
+
|
| 310 |
+
item_doc_ref = inventory_ref.document(item_name)
|
| 311 |
+
item_snapshot = item_doc_ref.get(transaction=transaction)
|
| 312 |
+
|
| 313 |
+
if not item_snapshot.exists:
|
| 314 |
+
feedback_messages.append(f"Sale failed for '{item_name}': Item not found in inventory.")
|
| 315 |
+
all_successful = False
|
| 316 |
+
continue
|
| 317 |
+
|
| 318 |
+
item_data = item_snapshot.to_dict()
|
| 319 |
+
current_stock = int(item_data.get('details', {}).get('quantity', 0))
|
| 320 |
+
|
| 321 |
+
if current_stock < quantity_sold:
|
| 322 |
+
feedback_messages.append(f"Sale failed for '{item_name}': Insufficient stock (Have: {current_stock}, Need: {quantity_sold}).")
|
| 323 |
+
all_successful = False
|
| 324 |
+
continue
|
| 325 |
+
|
| 326 |
+
# Update inventory within the transaction
|
| 327 |
+
transaction.update(item_doc_ref, {
|
| 328 |
+
'details.quantity': firestore.Increment(-quantity_sold),
|
| 329 |
+
'last_updated': datetime.now().isoformat()
|
| 330 |
+
})
|
| 331 |
+
|
| 332 |
+
# Prepare sale record (can be added to a separate batch commit after transaction)
|
| 333 |
+
sale_record = {
|
| 334 |
+
**sale_details, # Include original details like price, customer etc.
|
| 335 |
+
'timestamp': datetime.now().isoformat(), # Record time of sale processing
|
| 336 |
+
'status': 'completed'
|
| 337 |
+
}
|
| 338 |
+
# Add sale record to a batch (commit after transaction succeeds)
|
| 339 |
+
sale_batch.set(sales_ref.document(), sale_record) # Auto-generate sale ID
|
| 340 |
+
|
| 341 |
+
feedback_messages.append(f"Sale processed for {quantity_sold} x '{item_name}'.")
|
| 342 |
+
|
| 343 |
+
# Return the batch to be committed outside the transaction function
|
| 344 |
+
return sale_batch
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
# Prepare the list of sale details dictionaries
|
| 348 |
+
sales_to_process = [t.get('details', {}) for t in transaction_data if t.get('transaction_type') == 'sale']
|
| 349 |
+
|
| 350 |
+
if not sales_to_process:
|
| 351 |
+
return False, "No valid sale transactions found."
|
| 352 |
+
|
| 353 |
try:
|
| 354 |
+
# Execute the Firestore transaction
|
| 355 |
+
db_transaction = db.transaction()
|
| 356 |
+
sale_record_batch = process_sale_transaction(db_transaction, sales_to_process)
|
| 357 |
+
|
| 358 |
+
# If transaction successful, commit the batch of sale records
|
| 359 |
+
if all_successful and sale_record_batch:
|
| 360 |
+
sale_record_batch.commit()
|
| 361 |
+
logger.info(f"Sales records committed successfully for user {user_phone}.")
|
| 362 |
+
elif not all_successful:
|
| 363 |
+
logger.warning(f"Partial or full failure in sale processing for user {user_phone}. See feedback.")
|
| 364 |
+
else:
|
| 365 |
+
logger.info(f"No sales to record for user {user_phone}.")
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
return all_successful, "\n".join(feedback_messages)
|
| 369 |
+
|
| 370 |
except Exception as e:
|
| 371 |
+
logger.error(f"Sales processing failed for user {user_phone}: {e}", exc_info=True)
|
| 372 |
+
feedback_messages.append("An unexpected error occurred during sale processing.")
|
| 373 |
+
return False, "\n".join(feedback_messages)
|
| 374 |
+
|
| 375 |
|
| 376 |
def read_datalake(user_phone: str, query: str) -> Union[str, Dict]:
|
| 377 |
+
"""Query user's transaction data from Firestore. (Replaces pandasai for simplicity/reliability)."""
|
| 378 |
+
# This function now performs direct Firestore queries based on keywords in the query.
|
| 379 |
+
# It's less "smart" than pandasai but more predictable.
|
| 380 |
+
# You could enhance this with more sophisticated query parsing if needed.
|
| 381 |
+
|
| 382 |
+
query_lower = query.lower()
|
| 383 |
+
results = []
|
| 384 |
+
queried_collections = set()
|
| 385 |
+
|
| 386 |
try:
|
| 387 |
+
# Simple keyword matching for now
|
| 388 |
+
if "inventory" in query_lower or "stock" in query_lower or "how many" in query_lower or "what do i have" in query_lower:
|
| 389 |
+
queried_collections.add("inventory")
|
| 390 |
+
inventory_ref = db.collection("users").document(user_phone).collection("inventory")
|
| 391 |
+
docs = inventory_ref.stream()
|
| 392 |
+
inventory_data = [doc.to_dict() for doc in docs]
|
| 393 |
+
if inventory_data:
|
| 394 |
+
results.append({"collection": "Inventory", "data": inventory_data})
|
| 395 |
+
else:
|
| 396 |
+
results.append({"collection": "Inventory", "message": "No inventory data found."})
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
if "sale" in query_lower or "sold" in query_lower or "revenue" in query_lower or "customer" in query_lower:
|
| 400 |
+
queried_collections.add("sales")
|
| 401 |
+
sales_ref = db.collection("users").document(user_phone).collection("sales")
|
| 402 |
+
# Add ordering or filtering based on query if needed (e.g., by date, item)
|
| 403 |
+
docs = sales_ref.order_by("timestamp", direction=firestore.Query.DESCENDING).limit(20).stream() # Example: Get recent sales
|
| 404 |
+
sales_data = [doc.to_dict() for doc in docs]
|
| 405 |
+
if sales_data:
|
| 406 |
+
results.append({"collection": "Sales", "data": sales_data})
|
| 407 |
+
else:
|
| 408 |
+
results.append({"collection": "Sales", "message": "No sales data found."})
|
| 409 |
+
|
| 410 |
+
# If no specific collection keywords, maybe return summary or ask for clarification
|
| 411 |
+
if not queried_collections:
|
| 412 |
+
# Could try fetching both, or just return a message
|
| 413 |
+
return "Please specify if you want to query 'inventory' or 'sales' data."
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
# Format the results for display
|
| 417 |
+
if not results:
|
| 418 |
+
return "No data found matching your query."
|
| 419 |
+
|
| 420 |
+
# Use format_transaction_response to display the results
|
| 421 |
+
formatted_output = f"Query Results for '{query}':\n\n"
|
| 422 |
+
for res_block in results:
|
| 423 |
+
formatted_output += f"--- {res_block.get('collection', 'Data')} ---\n"
|
| 424 |
+
if "data" in res_block:
|
| 425 |
+
# Pass the list of dictionaries to the formatter
|
| 426 |
+
formatted_output += format_transaction_response(res_block["data"]) + "\n"
|
| 427 |
+
elif "message" in res_block:
|
| 428 |
+
formatted_output += res_block["message"] + "\n"
|
| 429 |
+
formatted_output += "\n"
|
| 430 |
+
|
| 431 |
+
return formatted_output.strip()
|
| 432 |
+
|
| 433 |
+
|
| 434 |
except Exception as e:
|
| 435 |
+
logger.error(f"Data query failed for user {user_phone}, query '{query}': {e}", exc_info=True)
|
| 436 |
+
return "Sorry, I encountered an error while retrieving your data."
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
def update_transaction(user_phone: str, transaction_data: List[Dict]) -> tuple[bool, str]:
|
| 440 |
+
"""Update existing transaction(s) based on provided data."""
|
| 441 |
+
# This needs refinement. How do we identify *which* transaction to update?
|
| 442 |
+
# Assuming for now the primary identifier is 'item' within the 'details'.
|
| 443 |
+
# And we update based on the first transaction in the list if multiple are provided for update.
|
| 444 |
+
if not transaction_data:
|
| 445 |
+
return False, "No update data provided."
|
| 446 |
+
|
| 447 |
+
update_info = transaction_data[0] # Process the first update instruction
|
| 448 |
+
details = update_info.get('details', {})
|
| 449 |
+
item_name = details.get('item')
|
| 450 |
+
trans_type = update_info.get('transaction_type', 'inventory').lower() # Default to inventory
|
| 451 |
+
|
| 452 |
+
if not item_name:
|
| 453 |
+
return False, "Cannot update: 'item' name not specified in details."
|
| 454 |
+
|
| 455 |
+
collection_map = {'purchase': 'inventory', 'sale': 'sales', 'inventory': 'inventory'}
|
| 456 |
+
collection_name = collection_map.get(trans_type, 'inventory')
|
| 457 |
+
doc_ref = db.collection("users").document(user_phone).collection(collection_name).document(item_name)
|
| 458 |
+
|
| 459 |
try:
|
| 460 |
+
doc_snapshot = doc_ref.get()
|
| 461 |
+
if not doc_snapshot.exists:
|
| 462 |
+
return False, f"Cannot update: Item '{item_name}' not found in {collection_name}."
|
| 463 |
+
|
| 464 |
+
# Prepare updates, excluding the item identifier itself
|
| 465 |
+
updates = {f"details.{k}": v for k, v in details.items() if k != 'item'}
|
| 466 |
+
if not updates:
|
| 467 |
+
return False, "No update fields provided (only item name was given)."
|
| 468 |
+
|
| 469 |
+
updates['last_updated'] = datetime.now().isoformat()
|
| 470 |
+
|
| 471 |
+
doc_ref.update(updates)
|
| 472 |
+
logger.info(f"Successfully updated item '{item_name}' in {collection_name} for user {user_phone}.")
|
| 473 |
+
# Format the *intended* update for confirmation message
|
| 474 |
+
formatted_update = format_transaction_response([update_info])
|
| 475 |
+
return True, f"Successfully updated '{item_name}'.\n\n{formatted_update}"
|
| 476 |
+
|
| 477 |
except Exception as e:
|
| 478 |
+
logger.error(f"Update failed for item '{item_name}' in {collection_name}, user {user_phone}: {e}", exc_info=True)
|
| 479 |
+
return False, f"Failed to update '{item_name}'. An error occurred."
|
| 480 |
+
|
| 481 |
|
| 482 |
+
def delete_transaction(user_phone: str, transaction_data: List[Dict]) -> tuple[bool, str]:
|
| 483 |
+
"""Delete specified transactions (identified by item name)."""
|
| 484 |
batch = db.batch()
|
| 485 |
+
collection_map = {'purchase': 'inventory', 'sale': 'sales', 'inventory': 'inventory'}
|
| 486 |
+
deleted_items = []
|
| 487 |
+
errors = []
|
| 488 |
+
processed = False
|
| 489 |
+
|
|
|
|
| 490 |
for transaction in transaction_data:
|
| 491 |
+
details = transaction.get('details', {})
|
| 492 |
+
item_name = details.get('item')
|
| 493 |
+
trans_type = transaction.get('transaction_type', '').lower() # Get type if specified
|
| 494 |
+
|
| 495 |
if not item_name:
|
| 496 |
+
errors.append("Skipped delete: 'item' name missing.")
|
| 497 |
continue
|
| 498 |
+
|
| 499 |
+
# Determine collection: Use specified type, default to inventory if ambiguous/missing
|
| 500 |
+
collection_name = collection_map.get(trans_type, 'inventory')
|
| 501 |
+
# If type is 'sale', we might need a different identifier than item_name (e.g., sale ID)
|
| 502 |
+
# For simplicity now, we assume deleting from inventory or sales based on item name if type is sale
|
| 503 |
+
# This might need adjustment based on how sales are uniquely identified.
|
| 504 |
+
# If deleting sales, you likely need a sale ID, not just item name.
|
| 505 |
+
# Let's assume for now delete primarily targets inventory items by name.
|
| 506 |
+
if trans_type == 'sale':
|
| 507 |
+
errors.append(f"Deletion of specific sales by item name ('{item_name}') is not fully supported. Please specify a Sale ID or delete from inventory.")
|
| 508 |
+
continue # Skip deletion of sales by item name for now
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
doc_ref = db.collection("users").document(user_phone).collection(collection_name).document(item_name)
|
| 512 |
+
# Check if doc exists before adding delete to batch (optional but good practice)
|
| 513 |
+
# doc_snapshot = doc_ref.get()
|
| 514 |
+
# if doc_snapshot.exists:
|
| 515 |
batch.delete(doc_ref)
|
| 516 |
+
deleted_items.append(f"'{item_name}' from {collection_name}")
|
| 517 |
+
processed = True
|
| 518 |
+
# else:
|
| 519 |
+
# errors.append(f"Item '{item_name}' not found in {collection_name}, cannot delete.")
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
if not processed and not errors:
|
| 523 |
+
return False, "No valid items found to delete."
|
| 524 |
+
|
| 525 |
try:
|
| 526 |
+
if processed: # Only commit if there are items to delete
|
| 527 |
+
batch.commit()
|
| 528 |
+
logger.info(f"Deletion batch committed for user {user_phone}. Items: {deleted_items}")
|
| 529 |
+
|
| 530 |
+
final_message = ""
|
| 531 |
+
if deleted_items:
|
| 532 |
+
final_message += f"Successfully deleted: {', '.join(deleted_items)}."
|
| 533 |
+
if errors:
|
| 534 |
+
final_message += f"\nErrors/Skipped: {'; '.join(errors)}"
|
| 535 |
+
|
| 536 |
+
return processed, final_message.strip() # Return True if at least one delete was attempted
|
| 537 |
+
|
| 538 |
except Exception as e:
|
| 539 |
+
logger.error(f"Deletion batch commit failed for user {user_phone}: {e}", exc_info=True)
|
| 540 |
+
errors.append("An error occurred during the delete operation.")
|
| 541 |
+
return False, "\n".join(errors)
|
| 542 |
+
|
| 543 |
|
| 544 |
def persist_temporary_transaction(transactions: List[Dict], mobile: str) -> bool:
|
| 545 |
+
"""Store transactions temporarily in Firestore before confirmation."""
|
| 546 |
+
if not transactions:
|
| 547 |
+
logger.warning(f"Attempted to persist empty transaction list for {mobile}")
|
| 548 |
+
return False
|
| 549 |
try:
|
| 550 |
doc_ref = db.collection("users").document(mobile).collection("temp_transactions").document("pending")
|
| 551 |
doc_ref.set({
|
| 552 |
+
"transactions": transactions, # Store the list of transactions
|
| 553 |
"timestamp": datetime.now().isoformat(),
|
| 554 |
"status": "pending_confirmation"
|
| 555 |
})
|
| 556 |
+
logger.info(f"Temporary transaction persisted for user {mobile}.")
|
| 557 |
return True
|
| 558 |
except Exception as e:
|
| 559 |
+
logger.error(f"Failed to persist temporary transaction for user {mobile}: {e}", exc_info=True)
|
| 560 |
return False
|
| 561 |
|
| 562 |
+
# Regex to check for common currency symbols at the start of a string
|
| 563 |
+
CURRENCY_SYMBOL_REGEX = re.compile(r"^\s*[\$\£\€\¥\₹]") # Add more symbols as needed (e.g., ZAR R)
|
| 564 |
+
|
| 565 |
+
def format_transaction_response(transactions: Union[List[Dict], Dict, None]) -> str:
|
| 566 |
+
"""Format transaction data for user display, fixing double currency symbols."""
|
| 567 |
if not transactions:
|
| 568 |
+
return "No transaction data to display."
|
| 569 |
+
|
| 570 |
if isinstance(transactions, dict):
|
| 571 |
+
transactions = [transactions] # Wrap single dict in a list
|
| 572 |
+
|
| 573 |
+
if not isinstance(transactions, list) or not transactions:
|
| 574 |
+
return "Invalid transaction data format."
|
| 575 |
+
|
| 576 |
+
output_lines = []
|
| 577 |
+
for idx, trans in enumerate(transactions):
|
| 578 |
+
if not isinstance(trans, dict):
|
| 579 |
+
logger.warning(f"Skipping non-dictionary item in format_transaction_response: {trans}")
|
| 580 |
+
continue
|
| 581 |
+
|
| 582 |
+
# Use a more descriptive title if possible
|
| 583 |
+
trans_desc = trans.get('transaction_type', 'Unknown Type').replace("_", " ").title()
|
| 584 |
+
intent_desc = trans.get('intent', 'Unknown Intent').title()
|
| 585 |
+
title = f"{intent_desc}: {trans_desc}"
|
| 586 |
+
if len(transactions) > 1:
|
| 587 |
+
output_lines.append(f"--- Transaction {idx + 1}: {title} ---")
|
| 588 |
+
else:
|
| 589 |
+
output_lines.append(f"--- {title} ---")
|
| 590 |
+
|
| 591 |
+
details = trans.get('details')
|
| 592 |
+
if isinstance(details, dict):
|
| 593 |
+
# Try to display key info first: item, quantity, price/amount
|
| 594 |
+
order = ['item', 'quantity', 'price', 'amount', 'cost', 'customer', 'date']
|
| 595 |
+
displayed_keys = set()
|
| 596 |
+
|
| 597 |
+
for key in order:
|
| 598 |
+
if key in details:
|
| 599 |
+
value = details[key]
|
| 600 |
+
key_title = key.replace("_", " ").title()
|
| 601 |
+
currency = details.get('currency', '$') # Default currency
|
| 602 |
+
value_str = str(value)
|
| 603 |
|
| 604 |
+
# Check for currency fields and apply symbol *only if not present*
|
| 605 |
+
if 'price' in key or 'amount' in key or 'cost' in key:
|
| 606 |
+
if not CURRENCY_SYMBOL_REGEX.match(value_str):
|
| 607 |
+
output_lines.append(f"• {key_title}: {currency}{value_str}")
|
| 608 |
+
else:
|
| 609 |
+
output_lines.append(f"• {key_title}: {value_str}") # Already has symbol
|
| 610 |
+
else:
|
| 611 |
+
output_lines.append(f"• {key_title}: {value_str}")
|
| 612 |
+
displayed_keys.add(key)
|
| 613 |
+
|
| 614 |
+
# Display remaining details
|
| 615 |
+
for key, value in details.items():
|
| 616 |
+
if key not in displayed_keys and key != 'currency': # Don't display currency key itself
|
| 617 |
+
key_title = key.replace("_", " ").title()
|
| 618 |
+
output_lines.append(f"• {key_title}: {value}")
|
| 619 |
+
elif details: # If details is not a dict but exists
|
| 620 |
+
output_lines.append(f"• Details: {details}")
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
# Add timestamp if available
|
| 624 |
+
ts = trans.get('timestamp') or trans.get('created_at') or trans.get('last_updated')
|
| 625 |
+
if ts:
|
| 626 |
+
try:
|
| 627 |
+
# Attempt to parse and format timestamp nicely
|
| 628 |
+
dt_obj = datetime.fromisoformat(ts)
|
| 629 |
+
output_lines.append(f"• Recorded: {dt_obj.strftime('%Y-%m-%d %H:%M:%S')}")
|
| 630 |
+
except ValueError:
|
| 631 |
+
output_lines.append(f"• Recorded: {ts}") # Fallback to raw string
|
| 632 |
+
|
| 633 |
+
output_lines.append("") # Add spacing between transactions
|
| 634 |
+
|
| 635 |
+
return "\n".join(output_lines).strip()
|
| 636 |
+
|
| 637 |
+
|
| 638 |
+
def fetch_transaction(user_phone: str,
|
| 639 |
item_name: Optional[str] = None,
|
| 640 |
+
collection: str = "inventory") -> Union[Dict, List[Dict], None]:
|
| 641 |
+
"""Retrieve transaction(s) from Firestore."""
|
| 642 |
+
# Ensure collection is valid
|
| 643 |
+
valid_collections = ["inventory", "sales"] # Add others if needed
|
| 644 |
+
if collection not in valid_collections:
|
| 645 |
+
logger.warning(f"Invalid collection specified for fetch: {collection}")
|
| 646 |
+
collection = "inventory" # Default to inventory
|
| 647 |
+
|
| 648 |
col_ref = db.collection("users").document(user_phone).collection(collection)
|
| 649 |
+
|
| 650 |
+
try:
|
| 651 |
+
if item_name:
|
| 652 |
+
# Fetch a specific document by ID (item_name)
|
| 653 |
+
doc_ref = col_ref.document(item_name)
|
| 654 |
+
doc_snapshot = doc_ref.get()
|
| 655 |
+
if doc_snapshot.exists:
|
| 656 |
+
return doc_snapshot.to_dict()
|
| 657 |
+
else:
|
| 658 |
+
logger.info(f"Item '{item_name}' not found in {collection} for user {user_phone}.")
|
| 659 |
+
return None
|
| 660 |
+
else:
|
| 661 |
+
# Fetch all documents in the collection
|
| 662 |
+
docs = col_ref.stream()
|
| 663 |
+
results = [doc.to_dict() for doc in docs]
|
| 664 |
+
logger.info(f"Fetched {len(results)} items from {collection} for user {user_phone}.")
|
| 665 |
+
return results if results else [] # Return empty list if collection is empty
|
| 666 |
+
|
| 667 |
+
except Exception as e:
|
| 668 |
+
logger.error(f"Error fetching from Firestore ({collection}, item: {item_name}) for user {user_phone}: {e}", exc_info=True)
|
| 669 |
+
return None # Return None on error
|
| 670 |
+
|
| 671 |
|
| 672 |
def process_intent(parsed_trans_data: List[Dict], mobile: str) -> str:
|
| 673 |
+
"""Route transactions to appropriate CRUD operation based on parsed intent."""
|
| 674 |
if not parsed_trans_data:
|
| 675 |
+
logger.warning(f"process_intent called with empty data for user {mobile}")
|
| 676 |
+
return "I couldn't understand the transaction details. Could you please try again?"
|
| 677 |
+
|
| 678 |
+
# Note: This function now handles only ONE intent type per call, determined by the FIRST transaction.
|
| 679 |
+
# The CRUD functions themselves handle lists if multiple items share the same intent/type.
|
| 680 |
+
# If the LLM returns mixed intents (e.g., add inventory AND check stock), this needs more complex handling.
|
| 681 |
+
# For now, we process based on the first transaction's intent.
|
| 682 |
+
|
| 683 |
+
first_transaction = parsed_trans_data[0]
|
| 684 |
+
intent = first_transaction.get('intent', '').lower()
|
| 685 |
+
trans_type = first_transaction.get('transaction_type', '').lower()
|
| 686 |
+
|
| 687 |
+
# Generate a summary of *all* transactions received for context, even if only processing the first intent type.
|
| 688 |
transaction_summary = format_transaction_response(parsed_trans_data)
|
| 689 |
+
logger.info(f"Processing intent '{intent}' for type '{trans_type}' for user {mobile}")
|
| 690 |
+
|
| 691 |
+
response_message = ""
|
| 692 |
+
success = False
|
| 693 |
+
|
| 694 |
+
try:
|
| 695 |
+
if intent == 'create':
|
| 696 |
+
if trans_type in ('purchase', 'inventory'):
|
| 697 |
+
# Pass all transactions that are inventory creation
|
| 698 |
+
inventory_creates = [t for t in parsed_trans_data if t.get('intent','').lower() == 'create' and t.get('transaction_type','').lower() in ('purchase', 'inventory')]
|
| 699 |
+
if inventory_creates:
|
| 700 |
+
success = create_inventory(mobile, inventory_creates)
|
| 701 |
+
response_message = "Inventory updated!" if success else "Failed to update inventory."
|
| 702 |
+
else:
|
| 703 |
+
response_message = "No valid inventory creation details found."
|
| 704 |
+
|
| 705 |
+
elif trans_type == 'sale':
|
| 706 |
+
# Pass all transactions that are sales
|
| 707 |
+
sales_creates = [t for t in parsed_trans_data if t.get('intent','').lower() == 'create' and t.get('transaction_type','').lower() == 'sale']
|
| 708 |
+
if sales_creates:
|
| 709 |
+
success, feedback = create_sale(mobile, sales_creates)
|
| 710 |
+
response_message = f"Sale processing results:\n{feedback}" # Use detailed feedback
|
| 711 |
+
else:
|
| 712 |
+
response_message = "No valid sale creation details found."
|
| 713 |
+
else:
|
| 714 |
+
response_message = f"Sorry, I can't 'create' transactions of type: {trans_type}"
|
| 715 |
+
|
| 716 |
+
elif intent == 'read' or trans_type == 'query':
|
| 717 |
+
# Use the original user query text if possible, otherwise generate a query string
|
| 718 |
+
# For simplicity, let's just use the formatted details as a pseudo-query
|
| 719 |
+
query_details = first_transaction.get('details', {})
|
| 720 |
+
query_str = " ".join([f"{k}:{v}" for k,v in query_details.items()]) if query_details else "Show summary" # Simple query string
|
| 721 |
+
logger.info(f"Performing read/query for user {mobile}: {query_str}")
|
| 722 |
+
# Call the simplified read_datalake
|
| 723 |
+
read_result = read_datalake(mobile, query_str) # Pass the generated query string
|
| 724 |
+
# Check if read_result is an image path (if you re-introduce image reports)
|
| 725 |
+
# For now, assume it returns text
|
| 726 |
+
response_message = str(read_result)
|
| 727 |
+
# No separate summary needed as the result is the response
|
| 728 |
+
return response_message # Return directly, don't add summary again
|
| 729 |
+
|
| 730 |
+
elif intent == 'update':
|
| 731 |
+
# Pass all update transactions (assuming they target different items or update sequentially)
|
| 732 |
+
updates = [t for t in parsed_trans_data if t.get('intent','').lower() == 'update']
|
| 733 |
+
if updates:
|
| 734 |
+
# Update logic currently processes one item at a time, needs enhancement for batch updates
|
| 735 |
+
# For now, process the first update instruction found
|
| 736 |
+
success, feedback = update_transaction(mobile, updates) # Pass the list, function handles first
|
| 737 |
+
response_message = feedback # Use feedback from function
|
| 738 |
+
else:
|
| 739 |
+
response_message = "No valid update details found."
|
| 740 |
+
|
| 741 |
+
|
| 742 |
+
elif intent == 'delete':
|
| 743 |
+
# Pass all delete transactions
|
| 744 |
+
deletes = [t for t in parsed_trans_data if t.get('intent','').lower() == 'delete']
|
| 745 |
+
if deletes:
|
| 746 |
+
success, feedback = delete_transaction(mobile, deletes)
|
| 747 |
+
response_message = feedback # Use feedback from function
|
| 748 |
+
else:
|
| 749 |
+
response_message = "No valid deletion details found."
|
| 750 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 751 |
else:
|
| 752 |
+
response_message = f"Sorry, I don't know how to handle the intent: '{intent}'"
|
| 753 |
+
|
| 754 |
+
# Combine the action result message with the summary of what was processed
|
| 755 |
+
# Avoid adding summary if the response message already contains it (like read_datalake)
|
| 756 |
+
if intent != 'read' and trans_type != 'query':
|
| 757 |
+
full_response = f"{response_message}\n\nSummary of processed data:\n{transaction_summary}"
|
|
|
|
| 758 |
else:
|
| 759 |
+
full_response = response_message # Read intent already formatted output
|
| 760 |
+
|
| 761 |
+
return full_response.strip()
|
| 762 |
+
|
| 763 |
+
except Exception as e:
|
| 764 |
+
logger.error(f"Error processing intent '{intent}' for user {mobile}: {e}", exc_info=True)
|
| 765 |
+
return f"Sorry, an error occurred while processing your request for '{intent}'.\n\nDetails received:\n{transaction_summary}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|