""" Copyright 2024-2026 ReplyPier Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ import json import re from typing import Any, Dict, Type from app.core.logger import get_logger from app.models.schemas.chat import ChatResponse logger = get_logger(__name__) # What the customer sees when a turn fails, instead of anything half-made. GENERIC_ERROR_MESSAGE = "I apologize, but I encountered an error, please try again later." # The reply's "message" string, only when it arrived whole (closing quote and all). _MESSAGE_FIELD = re.compile(r'"message"\s*:\s*"((?:[^"\\]|\\.)*)"', re.DOTALL) def parse_response_content(response: Any, model: Type[ChatResponse] = ChatResponse) -> ChatResponse: """ Parse the response content from the LLM into a ChatResponse object. Handles various response formats including dictionaries and strings. Args: response: The response from the LLM, which can be in various formats Returns: ChatResponse: A properly formatted ChatResponse object """ response_content = None try: # Handle response with content attribute (common in Agno responses) if hasattr(response, 'content'): # If content is a dictionary, directly convert to ChatResponse if isinstance(response.content, dict): response_content = model(**response.content) # If content is a string, attempt to extract and parse JSON elif isinstance(response.content, str): try: logger.info(f"Response content is string: {response.content}") response_content = extract_json_from_text(response.content, model) except Exception as e: logger.error(f"Failed to parse response content: {e}") response_content = create_basic_chat_response(response.content) # Use content directly if not dict or string else: response_content = response.content # Handle direct response (not wrapped in content attribute) else: # If response is a dictionary, directly convert to ChatResponse if isinstance(response, dict): response_content = model(**response) # If response is a string, attempt to extract and parse JSON elif isinstance(response, str): try: response_content = extract_json_from_text(response, model) except Exception as e: logger.error(f"Failed to parse response: {e}") response_content = create_basic_chat_response(response) # Use response directly if not dict or string else: response_content = response except Exception as e: logger.error(f"Error in parse_response_content: {e}") return create_basic_chat_response(str(response) if response else "Error processing response") # If we still don't have a proper response_content, create a basic one if not isinstance(response_content, ChatResponse): text_content = str(response_content) if response_content else "No response generated" response_content = create_basic_chat_response(text_content) return response_content def extract_json_from_text(text: str, model: Type[ChatResponse] = ChatResponse) -> ChatResponse: """ Extract JSON from a string and convert it to a ChatResponse object. Args: text: The text containing JSON Returns: ChatResponse: A properly formatted ChatResponse object """ # Clean up the text by removing true/false values outside JSON structure cleaned_text = clean_malformed_output(text) # Try to extract JSON in multiple ways content_dict = None # Method 1: Handle simple JSON format first if cleaned_text.startswith('{') and '}' in cleaned_text: try: # Try to find a valid JSON substring end_idx = cleaned_text.find('}') + 1 json_str = cleaned_text[:end_idx] content_dict = json.loads(json_str) logger.debug(f"Extracted complete JSON: {content_dict}") except json.JSONDecodeError: # If fails, try to handle trailing characters try: # Find all closing braces all_brace_positions = [pos for pos, char in enumerate(cleaned_text) if char == '}'] # Try each closing brace position until we find a valid JSON for end_pos in all_brace_positions: try: json_str = cleaned_text[:end_pos + 1] content_dict = json.loads(json_str) logger.debug(f"Extracted JSON with truncation at position {end_pos}: {content_dict}") break except json.JSONDecodeError: continue except Exception as e: logger.debug(f"Failed to extract JSON with truncation: {e}") # Method 2: Handle function call format (e.g., function=search_knowledge_base>{"query": "the dal"} ) if not content_dict: function_pattern = r'function=(\w+)>({.*?})\s*' function_match = re.search(function_pattern, cleaned_text, re.DOTALL) if function_match: try: function_name = function_match.group(1) function_params = json.loads(function_match.group(2)) logger.debug(f"Found function call: {function_name} with params: {function_params}") # If this is a knowledge base search, we'll create a basic response if function_name == 'search_knowledge_base': # The rest of the response after the function call should be the actual content rest_of_text = cleaned_text.split('', 1) if len(rest_of_text) > 1 and rest_of_text[1].strip(): content = rest_of_text[1].strip() try: # Try to parse the content as JSON content_dict = json.loads(content) logger.debug(f"Parsed content after function call as JSON: {content_dict}") except json.JSONDecodeError: # If it's not valid JSON, use it as a plain message logger.debug(f"Content after function call is not valid JSON, using as message") content_dict = {"message": content} else: # If there's no content after the function call, use the query as a message content_dict = {"message": f"Query: {function_params.get('query', 'No query specified')}"} except json.JSONDecodeError: logger.debug("Failed to extract function parameters as JSON") # Method 3: Use regex to find JSON-like structure if not content_dict: json_pattern = r'({.*?})' match = re.search(json_pattern, cleaned_text, re.DOTALL) if match: try: json_str = match.group(1) content_dict = json.loads(json_str) logger.debug(f"Extracted JSON using regex: {content_dict}") except json.JSONDecodeError: logger.debug("Failed to extract JSON using regex") # Method 4: Find JSON by braces (if previous methods didn't work) if not content_dict: start_idx = cleaned_text.find('{') end_idx = cleaned_text.rfind('}') + 1 if start_idx != -1 and end_idx != -1: try: json_str = cleaned_text[start_idx:end_idx] content_dict = json.loads(json_str) logger.debug(f"Extracted JSON using braces: {content_dict}") except json.JSONDecodeError: logger.debug("Failed to extract JSON using braces") # It set out to be the JSON object we asked for and broke (cut off, malformed): # never show the customer raw JSON. if not content_dict and cleaned_text.lstrip().startswith('{'): return salvage_broken_json(cleaned_text) # If we failed to extract valid JSON, try to parse potential field values from the text if not content_dict: content_dict = extract_fields_from_text(cleaned_text) # If we successfully extracted a dictionary if content_dict: try: return model(**content_dict) except Exception as e: logger.error(f"Failed to create ChatResponse from dictionary: {e}") # In case of error, use the content dict's message or the original text message = content_dict.get('message', cleaned_text) return create_basic_chat_response(message) # If no JSON structure found, create a basic response return create_basic_chat_response(cleaned_text) def salvage_broken_json(text: str) -> ChatResponse: """A reply that began as a JSON object but does not parse. Keeps its message if that field arrived complete; otherwise the generic error, never the fragment.""" match = _MESSAGE_FIELD.search(text) if match: try: message = json.loads(f'"{match.group(1)}"') if message.strip(): return create_basic_chat_response(message) except json.JSONDecodeError: pass logger.warning(f"Unparseable structured reply, answering with the generic error: {text[:200]}") return create_basic_chat_response(GENERIC_ERROR_MESSAGE) def clean_malformed_output(text: str) -> str: """ Clean up malformed output with true/false values outside JSON structure. Args: text: The text to clean Returns: str: Cleaned text """ original_text = text # Replace patterns like "truefalse" with proper JSON values # This regex finds patterns like "truefalseXXX" and removes them text = re.sub(r'(true|false)(true|false)', ' ', text) # Replace literal "true" and "false" that appear outside of quotes def replace_literals(match): value = match.group(0) if value == "true": return "true" elif value == "false": return "false" return value # This is a simplified approach - a more robust solution would need to # identify whether true/false is inside quotes or not text = re.sub(r'\b(true|false)\b', replace_literals, text) # Fix malformed image objects in shopify_output # Pattern 1: "src":"url"}," followed by whitespace/newlines and then ,"alt" # Should be: "src":"url","alt" before_fix = text # This handles: }," \n\n ,"alt" text = re.sub(r'(\"src\":\"[^\"]+\")\}[,\s\n]*,[,\s\n]*\"alt\"', r'\1,"alt"', text, flags=re.MULTILINE) if text != before_fix: logger.warning("Fixed malformed image object: },' with newlines before 'alt' pattern") # Pattern 2: }," followed by whitespace/newlines and comma before next field before_fix = text text = re.sub(r'\}[,\s\n]+,[,\s\n]*\"', ',"', text, flags=re.MULTILINE) if text != before_fix: logger.warning("Fixed malformed JSON: },' with newlines pattern") # Pattern 3: Fix pattern where closing brace appears after URL before comma # "image":{"src":"url"}," should be "image":{"src":"url", before_fix = text text = re.sub(r'(\"image\":\{\"src\":\"[^\"]+\")\},[,\s\n]*\"', r'\1,"', text, flags=re.MULTILINE) if text != before_fix: logger.warning("Fixed malformed image closing brace pattern") if text != original_text: logger.debug(f"Cleaned JSON from: {original_text[:200]}... to: {text[:200]}...") return text def extract_fields_from_text(text: str) -> Dict[str, Any]: """ Extract field values from text when JSON parsing fails. Args: text: The text to extract field values from Returns: Dict[str, Any]: Dictionary of extracted fields """ fields = {} # Extract message content - everything before any markers message_match = re.search(r'^(.*?)(?:true|false|<|$)', text, re.DOTALL) if message_match: fields['message'] = message_match.group(1).strip() # Extract transfer_to_human if 'TRANSFER_REQUEST' in text or 'DIRECT_REQUEST' in text: fields['transfer_to_human'] = True # Extract transfer reason if present reason_match = re.search(r'(.*?)', text) if reason_match: fields['transfer_reason'] = reason_match.group(1) elif 'DIRECT_REQUEST' in text: fields['transfer_reason'] = 'DIRECT_REQUEST' # Extract transfer description if present desc_match = re.search(r'(.*?)', text) if desc_match: fields['transfer_description'] = desc_match.group(1) # Extract end_chat flags if 'end_chat_reason' in text: fields['end_chat'] = True return fields def create_basic_chat_response(message: str) -> ChatResponse: """ Create a basic ChatResponse with default values. Args: message: The message content Returns: ChatResponse: A ChatResponse object with the message and default values """ # Extract possible flags from the message transfer_to_human = False if "TRANSFER_REQUEST" in message or "DIRECT_REQUEST" in message: transfer_to_human = True return ChatResponse( message=message, transfer_to_human=transfer_to_human, end_chat=False, transfer_reason=None, transfer_description=None, request_rating=False, create_ticket=False )