diff --git a/service/config.py b/service/config.py index 8bba447..fd5463c 100644 --- a/service/config.py +++ b/service/config.py @@ -7,7 +7,12 @@ #model_id = os.getenv('MODEL_ID', 'large-v3') model_id = os.getenv('MODEL_ID','small') model_path = os.getenv('MODEL_PATH', './models') -ollama_host = os.getenv("OLLAMA_HOST", "http://ollama:11434") +ollama_host = os.getenv("OLLAMA_HOST", "http://localhost:11434") ollama_model_name = os.getenv("OLLAMA_MODEL_NAME", "llama3.2") open_ai_model_name = os.getenv("OPENAI_MODEL_NAME", "gpt-4") open_ai_temperature = os.getenv("OPENAI_TEMPERATURE", 0.2) + +odoo_url = os.getenv("ODOO_URL") +odoo_db = os.getenv("ODOO_DB") +odoo_username = os.getenv("ODOO_USERNAME") +odoo_password = os.getenv("ODOO_PASSWORD") diff --git a/service/crm_client.py b/service/crm_client.py new file mode 100644 index 0000000..6d798a6 --- /dev/null +++ b/service/crm_client.py @@ -0,0 +1,104 @@ +import xmlrpc.client +from typing import Optional + + +class AuthenticationError(Exception): + """Raised when authentication with Odoo fails.""" + + +class OdooCRMClient: + def __init__(self, url: str, db: str, username: str, password: str): + self.url = url + self.db = db + self.username = username + + self.common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common", allow_none=True) + uid = self.common.authenticate(db, username, password, {}) + + if not uid: + raise AuthenticationError("Authentication failed. Check credentials or DB name.") + + self.uid = uid + self.models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object", allow_none=True) + + # Store auth securely (don’t keep password separately) + self._auth = (db, uid, password) + + def create_lead(self, name: str, email: Optional[str], phone: Optional[str], lead_type: str = "opportunity"): + return self.models.execute_kw( + *self._auth, + "crm.lead", "create", + [{ + "name": name, + "contact_name": name, + "email_from": email, + "phone": phone, + "type": lead_type, + }] + ) + + def update_lead(self, lead_id: int, vals: dict): + return self.models.execute_kw( + *self._auth, + "crm.lead", "write", + [[lead_id], vals] + ) + + def add_internal_note(self, lead_id: int, note_text: str): + return self.update_lead(lead_id, {"description": note_text}) + + def add_chatter_note(self, lead_id: int, note_text: str): + return self.models.execute_kw( + *self._auth, + "mail.message", "create", + [{ + "model": "crm.lead", + "res_id": lead_id, + "body": note_text, + "message_type": "comment", + "subtype_id": 2, + }] + ) + + def add_contact_details(self, lead_id: int, contact_name: Optional[str] = None, + email: Optional[str] = None, phone: Optional[str] = None): + vals = {} + if contact_name: + vals["name"] = contact_name + if email: + vals["email"] = email + if phone: + vals["phone"] = phone + + partner_id = self.models.execute_kw( + *self._auth, + "res.partner", "create", + [vals] + ) + + self.update_lead(lead_id, {"partner_id": partner_id}) + return partner_id + + def update_contact_address(self, partner_id: int, street: Optional[str] = None, + street2: Optional[str] = None, city: Optional[str] = None, + state_id: Optional[int] = None, zip_code: Optional[str] = None, + country_id: Optional[int] = None): + vals = {} + if street: + vals["street"] = street + if street2: + vals["street2"] = street2 + if city: + vals["city"] = city + if state_id: + vals["state_id"] = state_id + if zip_code: + vals["zip"] = zip_code + if country_id: + vals["country_id"] = country_id + + return self.models.execute_kw( + *self._auth, + "res.partner", "write", + [[partner_id], vals] + ) diff --git a/service/main.py b/service/main.py index 44f9981..79242fe 100644 --- a/service/main.py +++ b/service/main.py @@ -7,13 +7,17 @@ from audio_service import translate_with_whisper_timestamped, translate_with_whisper_from_upload from detect_intent import detect_intent_with_llama, format_intent_response from summarizer import summarize_using_openai -from summarizer import summarize_using_ollama +from summarizer import summarize_using_ollama, extract_contact_detailed_using_ollama from pydantic import BaseModel import traceback from util import generate_timestamp_json from fastapi_versionizer.versionizer import Versionizer, api_version import json from core_banking_mock import router as core_banking_mock_router +import os +import requests +from config import odoo_url, odoo_db, odoo_username, odoo_password +from crm_client import OdooCRMClient app = FastAPI() @@ -139,3 +143,201 @@ async def transcribe_intent(audio: UploadFile = File(...), session_id: str = For except Exception as e: logger.info(traceback.format_exc()) return JSONResponse(content={"message": str(e)}, status_code=500) +# async def save_crm_lead_data(lead_id, file_path, translation, extracted_data, summary, user_id=None, transcription_id=None): + +# """ +# Save CRM lead data to the database through the Next.js API. +# """ +# try: +# # Get base URL from environment or use default +# api_base_url = os.environ.get("NEXT_API_BASE_URL", "http://localhost:3000") + +# # Extract just the filename from the file path +# file_name = os.path.basename(file_path) + +# # Prepare the data to send +# crm_lead_data = { +# "leadId": str(lead_id), +# "crmUrl": odoo_url, # Using the odoo_url from config +# "fileName": file_name, +# "transcriptionId": transcription_id, # This might be None if not provided +# "extractedData": extracted_data, +# "translation": translation, +# "userId": user_id, # This might be None if not provided +# "isDefault": False # Adding the default field set to false +# } + +# # Make the API call +# response = requests.post( +# f"{api_base_url}/api/crm-leads", +# json=crm_lead_data, +# headers={"Content-Type": "application/json"} +# ) + +# if response.status_code == 200: +# logger.info(f"CRM lead data saved successfully for lead_id={lead_id}") +# return response.json() +# else: +# logger.error(f"Failed to save CRM lead data: {response.status_code} - {response.text}") +# return None + +# except Exception as e: +# logger.error(f"Exception saving CRM lead data: {str(e)}") +# return None + + + +# Add this function to retrieve default CRM leads +async def get_default_crm_leads(): + """ + Fetch CRM leads that are marked as default. + """ + try: + api_base_url = os.environ.get("NEXT_API_BASE_URL", "http://localhost:3000") + + response = requests.get( + f"{api_base_url}/api/crm-leads/default", + headers={"Content-Type": "application/json"}, + timeout=30 + ) + + if response.status_code == 200: + return response.json() + else: + logger.error(f"Failed to get default CRM leads: {response.status_code}") + return None + except Exception as e: + logger.error(f"Error fetching default CRM leads: {str(e)}") + return None + +# Add a route to expose this functionality +@app.get("/crm-leads/default") +async def fetch_default_crm_leads(): + try: + result = await get_default_crm_leads() + if result and result.get("success"): + return JSONResponse(content=result, status_code=200) + else: + return JSONResponse( + content={"message": "Failed to retrieve default CRM leads"}, + status_code=500 + ) + except Exception as e: + logger.error(f"Error in fetch_default_crm_leads: {str(e)}") + return JSONResponse(content={"message": str(e)}, status_code=500) +# Add this new, simpler endpoint + +@app.get("/crm-lead/{lead_id}") +async def get_crm_lead_direct(lead_id: str): + """ + Get CRM lead data directly by ID from the database. + Simple direct lookup without complex routing. + """ + try: + api_base_url = os.environ.get("NEXT_API_BASE_URL", "http://localhost:3000") + + # Make a direct GET request to a simple endpoint + simple_url = f"{api_base_url}/api/crm-leads/simple/{lead_id}" + logger.info(f"Making GET request to: {simple_url}") + + response = requests.get( + simple_url, + headers={"Content-Type": "application/json"}, + timeout=30 + ) + + if response.status_code == 200: + return JSONResponse(content=response.json(), status_code=200) + else: + return JSONResponse( + content={"message": "CRM lead not found", "id": lead_id}, + status_code=404 + ) + + except Exception as e: + logger.error(f"Error retrieving CRM lead: {str(e)}") + return JSONResponse(content={"message": str(e)}, status_code=500) + +@app.post("/upload-crm-audio") +async def upload_crm_audio(body: Body): + try: + if body.audio_file_link == "": + return JSONResponse(status_code=400, content={"message":"Invalid file link"}) + + translation = translate_with_whisper_timestamped(body.audio_file_link) + + # Extract detected language + detected_language = translation.get("detected_language", "unknown") + + logger.info("translation done") + summary = summarize_using_ollama(translation["text"]) + + logger.info("summary done") + + # Pass the translation object and detected_language to generate_timestamp_json + result = generate_timestamp_json(translation, summary, detected_language) + + + contact_info = extract_contact_detailed_using_ollama(translation["text"]) if "text" in translation else {"name": None, "phone": None, "address": None} + + logger.info(result) + + # Fire-and-forget CRM sync (do not block response) + lead_id = None + try: + if odoo_url and odoo_db and odoo_username and odoo_password: + client = OdooCRMClient(odoo_url, odoo_db, odoo_username, odoo_password) + lead_id = client.create_lead( + name=contact_info.get("name") or "Unknown", + email=None, + phone=contact_info.get("phone"), + ) + logger.info(f"CRM: Lead created lead_id={lead_id}") + partner_id = client.add_contact_details(lead_id, contact_info.get("name"), None, contact_info.get("phone")) + logger.info(f"CRM: Partner created/linked partner_id={partner_id} to lead_id={lead_id}") + + # Update: Use the complete street information from LLM extraction + street = contact_info.get("street") + logger.info(f"CRM: - Street: '{street}'") + + + street2 = None + city = contact_info.get("city") + state = contact_info.get("state") + zip_code = contact_info.get("zip") + country = contact_info.get("country") + + logger.info(f"CRM: Address components street={street}, city={city}") + + updated = client.update_contact_address( + partner_id, + street=street, + street2=street2, + city=city, + state_id=None, + zip_code=zip_code, + country_id=None + ) + logger.info(f"CRM: Address update result={updated} for partner_id={partner_id}") + + # Add CRM data to the result + result["leadId"] = str(lead_id) + result["crmUrl"] = odoo_url + result["extractedData"] = contact_info + result["transcriptionId"] = None # This will be determined when transcription is saved + result["translation"] = translation["text"] + result["userId"] = None # This will be set by frontend + result["isDefault"] = False + + + else: + logger.info("CRM: Odoo credentials not configured; skipping CRM sync") + except Exception as e: + logger.info(f"CRM: Exception during sync: {e}") + + return JSONResponse(content=result, status_code=200) + + except Exception as e: + logger.info(traceback.format_exc()) + return JSONResponse(content={"message": str(e)}, status_code=500) + diff --git a/service/summarizer.py b/service/summarizer.py index 46bd015..fa31fe4 100644 --- a/service/summarizer.py +++ b/service/summarizer.py @@ -4,6 +4,8 @@ from template_config import get_summarization_template import logging import ollama +import json +import re from config import ollama_host, ollama_model_name logging.basicConfig(level=logging.INFO) @@ -45,3 +47,187 @@ def summarize_using_ollama(text): response = ollama.Client(host=ollama_host).generate(model=ollama_model_name, prompt = text+"\n \n""Provide highlights above conversation in Markdown bullet points, ready for direct inclusion in a file, with no pretext, and formatted as a multiline string.") summary = response["response"] return summary + + +def _extract_first_json_block(text: str) -> dict | None: + """Helper to find the first JSON block in a string.""" + try: + start_index = text.find('{') + end_index = text.rfind('}') + if start_index != -1 and end_index != -1 and end_index > start_index: + json_str = text[start_index:end_index + 1] + return json.loads(json_str) + except json.JSONDecodeError: + return None + return None + +def extract_contact_detailed_using_ollama(text: str): + """Use LLM to extract detailed contact and address fields for CRM.""" + if not text or not text.strip(): + return {"name": None, "phone": None, "address": None, "street": None, "city": None, "state": None, "zip": None, "country": None} + + # Try to extract name using regex first as a fallback + name_pattern = r'(?:this is|my name is|I am|I\'m) ([A-Z][a-z]+ [A-Z][a-z]+)' + name_match = re.search(name_pattern, text) + extracted_name = name_match.group(1) if name_match else None + + # Common patterns for addresses with apartment/flat information + address_patterns = [ + # Pattern for addresses with flat/apartment info and city + r'(?:residence|address|live at|located at)[,\s]+(\d+\s+[\w\s]+(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Lane|Ln|Drive|Dr|Court|Ct|Place|Pl|Way)[,\s]+(?:flat|apartment|apt|suite|ste|unit|#)\s+[\w\d]+)[,\s]+([\w\s]+)', + + # Simpler pattern as fallback + r'(\d+\s+[\w\s]+(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Lane|Ln|Drive|Dr|Court|Ct|Place|Pl|Way)[,\s]+(?:flat|apartment|apt|suite|ste|unit|#)\s+[\w\d]+)[,\s]+([\w\s]+)' + ] + + street_address = None + city = None + + for pattern in address_patterns: + matches = re.search(pattern, text, re.IGNORECASE) + if matches: + street_address = matches.group(1).strip() + city = matches.group(2).strip() + break + + # Use a more specific prompt for the LLM + instruction = ( + "Extract contact fields from the transcript. Return STRICT JSON with keys: name, phone, street, city, state, zip, country.\n\n" + "VERY IMPORTANT INSTRUCTIONS:\n" + "1. For name extraction, look for full names like 'Sofia Martinez' or 'John Smith'. " + " Extract both first name and last name. The name should NEVER be null if a name is mentioned.\n\n" + "2. For address extraction:\n" + " - The 'street' field should include ONLY the street address with building number and apartment/flat info\n" + " - Do NOT include the city name in the street field\n" + " - Put the city name ONLY in the city field\n\n" + "Example 1: 'Good morning, this is Sofia Martinez here. Call me on 555-123-4567. My residence, 89 Queen Street, flat 7C, London.'\n" + "Correct extraction:\n" + "{\n" + " \"name\": \"Sofia Martinez\",\n" + " \"phone\": \"+5551234567\",\n" + " \"street\": \"89 Queen Street, flat 7C\",\n" + " \"city\": \"London\",\n" + " \"state\": null,\n" + " \"zip\": null,\n" + " \"country\": null\n" + "}\n\n" + "Example 2: 'Hello, I'm Alice Johnson. You can reach me at 987-654-3210. I live at 56 Park Avenue Suite 12, Boston.'\n" + "Correct extraction:\n" + "{\n" + " \"name\": \"Alice Johnson\",\n" + " \"phone\": \"+9876543210\",\n" + " \"street\": \"56 Park Avenue Suite 12\",\n" + " \"city\": \"Boston\",\n" + " \"state\": null,\n" + " \"zip\": null,\n" + " \"country\": null\n" + "}\n" + ) + prompt = f"{instruction}\n\nTranscript:\n{text}\n\nExtract only these fields and return as JSON." + + try: + client = ollama.Client(host=ollama_host) + resp = client.generate(model=ollama_model_name, prompt=prompt) + raw = resp.get("response", "") + parsed = _extract_first_json_block(raw) + if not parsed: + # Fallback if _extract_first_json_block fails + parsed = json.loads(raw.strip()) + + if not isinstance(parsed, dict): + raise ValueError("LLM returned non-dict JSON") + + def get_str(key): + val = parsed.get(key) + return val.strip() if isinstance(val, str) and val.strip() else None + + name = get_str("name") + phone = get_str("phone") + llm_street = get_str("street") + llm_city = get_str("city") + state = get_str("state") + zip_code = get_str("zip") + country = get_str("country") + + # Post-processing to fix common issues + + # 1. Fix name if it's null but we found one with regex + if not name and extracted_name: + name = extracted_name + + # 2. Try to extract name directly if still null + if not name: + # Look for common name patterns in the text + name_patterns = [ + r'((?:[A-Z][a-z]+ ){1,2}[A-Z][a-z]+) (?:here|speaking)', + r'(?:this is|my name is|I am|I\'m) ([A-Z][a-z]+ [A-Z][a-z]+)', + r'(?:name|caller):? ([A-Z][a-z]+ [A-Z][a-z]+)' + ] + + for pattern in name_patterns: + name_match = re.search(pattern, text, re.IGNORECASE) + if name_match: + name = name_match.group(1).strip() + break + + # 3. Clean up street address - remove city name from street if it appears there + if llm_street and llm_city and llm_city in llm_street: + # Remove the city and any trailing commas/spaces + llm_street = re.sub(r',?\s*' + re.escape(llm_city) + r'(?:,|\s|$)', '', llm_street).strip().rstrip(',') + + # Use LLM values with fallbacks + final_street = llm_street or street_address + final_city = llm_city or city + + # Final normalization and cleaning for phone + if phone: + phone = re.sub(r"[^\d+]", "", phone) + if phone.startswith("00"): + phone = "+" + phone[2:] + if phone and phone[0] != "+" and len(phone) >= 10: + phone = "+" + phone + + # Create full address string, ensuring no duplicates + address_parts = [] + if final_street: + address_parts.append(final_street) + if final_city and final_city not in final_street: + address_parts.append(final_city) + if state: + address_parts.append(state) + if zip_code: + address_parts.append(zip_code) + if country: + address_parts.append(country) + + address = ", ".join(address_parts) if address_parts else None + + result = { + "name": name, + "phone": phone, + "address": address, + "street": final_street, + "city": final_city, + "state": state, + "zip": zip_code, + "country": country, + } + + # Debug log the extraction + logger.info(f"Address extraction results - Input: '{text}', Extracted: {json.dumps(result, indent=2)}") + + return result + except Exception as e: + logger.warning(f"LLM detailed extraction failed: {e}") + # Return a basic extraction using regex patterns if LLM fails + phone_match = re.search(r'(\d{3}[-\.\s]?\d{3}[-\.\s]?\d{4})', text) + return { + "name": extracted_name, + "phone": phone_match.group(1).replace('-', '') if phone_match else None, + "address": f"{street_address}, {city}" if street_address and city else None, + "street": street_address, + "city": city, + "state": None, + "zip": None, + "country": None + }