""" Restaurant Intelligence Agent - Enhanced Gradio 6 Interface Professional UI with cards, plain English summaries, polished layout Hackathon: Anthropic MCP 1st Birthday - Track 2 (Productivity) Author: Tushar Pingle VERSION 4.1 UPDATES: 1. NEW SENTIMENT SCALE: - 🟒 Positive: >= 0.6 (customers clearly enjoyed/praised) - 🟑 Neutral: 0 to 0.59 (mixed feelings, average, okay) - πŸ”΄ Negative: < 0 (complaints, criticism, disappointment) 2. Updated all thresholds throughout the app for consistency 3. Improved Q&A prompt for balanced answers (pros AND cons) 4. Fixed PDF style conflicts with RIA prefix 5. Fixed Q&A "proxies" error with Anthropic SDK 6. Multi-platform support (OpenTable + Google Maps) """ import gradio as gr import os import ast import re import requests import smtplib from email.mime.multipart import MIMEMultipart from email.mime.base import MIMEBase from email.mime.text import MIMEText from email import encoders from typing import Optional, Tuple, List, Dict, Any import tempfile from datetime import datetime, timedelta # ============================================================================ # CONFIGURATION # ============================================================================ MODAL_API_URL = os.getenv( "MODAL_API_URL", "https://tushar-pingle--restaurant-intelligence-fastapi-app.modal.run" ) # Email configuration (set these as environment variables) SMTP_SERVER = os.getenv("SMTP_SERVER", "smtp.gmail.com") SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) SMTP_USER = os.getenv("SMTP_USER", "") SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") EMAIL_FROM = os.getenv("EMAIL_FROM", "Restaurant Intelligence Agent ") # ============================================================================ # URL DETECTION # ============================================================================ def detect_platform(url: str) -> str: """Detect which platform the URL is from.""" if not url: return "unknown" url_lower = url.lower() if 'opentable' in url_lower: return "opentable" elif any(x in url_lower for x in ['google.com/maps', 'goo.gl/maps', 'maps.google', 'maps.app.goo.gl']): return "google_maps" else: return "unknown" def get_platform_emoji(platform: str) -> str: """Get emoji for platform.""" return "🍽️" if platform == "opentable" else "πŸ—ΊοΈ" if platform == "google_maps" else "❓" # ============================================================================ # TREND CHART - Rating vs Sentiment Over Time # ============================================================================ def parse_opentable_date(date_str: str) -> Optional[datetime]: """Parse date formats like 'Dined 1 day ago', '2 weeks ago', etc.""" if not date_str: return None date_str = str(date_str).lower().strip() today = datetime.now() day_match = re.search(r'(\d+)\s*days?\s*ago', date_str) if day_match: return today - timedelta(days=int(day_match.group(1))) week_match = re.search(r'(\d+)\s*weeks?\s*ago', date_str) if week_match: return today - timedelta(weeks=int(week_match.group(1))) month_match = re.search(r'(\d+)\s*months?\s*ago', date_str) if month_match: return today - timedelta(days=int(month_match.group(1)) * 30) if 'yesterday' in date_str: return today - timedelta(days=1) if 'today' in date_str: return today simple_day = re.search(r'^(\d+)\s*day', date_str) if simple_day: return today - timedelta(days=int(simple_day.group(1))) simple_week = re.search(r'^(\d+)\s*week', date_str) if simple_week: return today - timedelta(weeks=int(simple_week.group(1))) return None def calculate_review_sentiment(text: str) -> float: """ Simple sentiment calculation from review text. Returns value from -1 (very negative) to +1 (very positive). NOTE: This matches the backend's calculate_sentiment() function. The backend pre-calculates sentiment in trend_data, but this is used as a fallback when text needs to be analyzed locally. """ if not text: return 0.0 text = str(text).lower() positive = ['amazing', 'excellent', 'fantastic', 'great', 'awesome', 'delicious', 'perfect', 'outstanding', 'loved', 'beautiful', 'fresh', 'friendly', 'best', 'wonderful', 'incredible', 'superb', 'exceptional', 'good', 'nice', 'tasty', 'recommend', 'enjoy', 'impressed', 'favorite'] negative = ['terrible', 'horrible', 'awful', 'bad', 'worst', 'disappointing', 'poor', 'overpriced', 'slow', 'rude', 'cold', 'bland', 'mediocre', 'disgusting', 'inedible', 'undercooked', 'overcooked'] pos = sum(1 for w in positive if w in text) neg = sum(1 for w in negative if w in text) if pos + neg == 0: return 0.0 return (pos - neg) / max(pos + neg, 1) def generate_trend_chart(trend_data: List[Dict], restaurant_name: str) -> Optional[str]: """ Generate Rating vs Sentiment trend chart. DEBUG VERSION - prints diagnostic info """ import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt import matplotlib.dates as mdates print(f"[TREND DEBUG] Input trend_data length: {len(trend_data) if trend_data else 0}") if not trend_data or len(trend_data) < 3: print(f"[TREND DEBUG] Not enough data, returning None") return None # Debug: print first 3 items for i, r in enumerate(trend_data[:3]): print(f"[TREND DEBUG] Item {i}: {r}") dated_reviews = [] parse_failures = 0 for r in trend_data: if not isinstance(r, dict): continue date = parse_opentable_date(r.get('date', '')) if date: rating = float(r.get('rating', 0) or 0) sentiment = float(r.get('sentiment', 0) or 0) dated_reviews.append({ 'date': date, 'rating': rating if rating > 0 else 3.5, 'sentiment': sentiment }) else: parse_failures += 1 print(f"[TREND DEBUG] Parsed {len(dated_reviews)} dates, {parse_failures} failures") # Fallback: if no dates parsed, use sequential ordering if len(dated_reviews) < 3 and len(trend_data) >= 3: print(f"[TREND DEBUG] Using sequential fallback") dated_reviews = [] for i, r in enumerate(trend_data): if not isinstance(r, dict): continue rating = float(r.get('rating', 0) or 3.5) sentiment = float(r.get('sentiment', 0) or 0) dated_reviews.append({ 'date': datetime.now() - timedelta(days=i), 'rating': rating if rating > 0 else 3.5, 'sentiment': sentiment }) print(f"[TREND DEBUG] Final dated_reviews count: {len(dated_reviews)}") if len(dated_reviews) < 3: print(f"[TREND DEBUG] Still not enough data after fallback") return None dated_reviews.sort(key=lambda x: x['date']) weekly = {} for r in dated_reviews: week = r['date'] - timedelta(days=r['date'].weekday()) key = week.strftime('%Y-%m-%d') if key not in weekly: weekly[key] = {'date': week, 'ratings': [], 'sentiments': []} weekly[key]['ratings'].append(r['rating']) weekly[key]['sentiments'].append(r['sentiment']) dates = [] ratings = [] sentiments = [] for k in sorted(weekly.keys()): w = weekly[k] dates.append(w['date']) ratings.append(sum(w['ratings']) / len(w['ratings'])) sentiments.append(sum(w['sentiments']) / len(w['sentiments'])) print(f"[TREND DEBUG] Weekly aggregated: {len(dates)} weeks") if len(dates) < 2: print(f"[TREND DEBUG] Not enough weeks for chart") return None BG = '#1f2937' TEXT = '#e5e7eb' GRID = '#374151' RATING_COLOR = '#f59e0b' SENTIMENT_COLOR = '#10b981' try: fig, ax1 = plt.subplots(figsize=(14, 6)) fig.patch.set_facecolor(BG) ax1.set_facecolor(BG) ax1.plot(dates, ratings, color=RATING_COLOR, linewidth=2.5, marker='o', markersize=8, label='Avg Rating (Stars)') ax1.fill_between(dates, ratings, alpha=0.2, color=RATING_COLOR) ax1.set_ylabel('Rating (1-5)', fontsize=12, color=RATING_COLOR) ax1.tick_params(axis='y', labelcolor=RATING_COLOR, labelsize=10) ax1.tick_params(axis='x', colors=TEXT, labelsize=10) ax1.set_ylim(1, 5) ax2 = ax1.twinx() ax2.set_facecolor(BG) sent_scaled = [(s + 1) * 2 + 1 for s in sentiments] ax2.plot(dates, sent_scaled, color=SENTIMENT_COLOR, linewidth=2.5, marker='s', markersize=8, linestyle='--', label='Sentiment') ax2.fill_between(dates, sent_scaled, alpha=0.15, color=SENTIMENT_COLOR) ax2.set_ylabel('Sentiment', fontsize=12, color=SENTIMENT_COLOR) ax2.tick_params(axis='y', labelcolor=SENTIMENT_COLOR, labelsize=10) ax2.set_ylim(1, 5) ax1.set_title(f'Rating vs Sentiment Trend', fontsize=15, fontweight='bold', color=TEXT, pad=20) ax1.xaxis.set_major_formatter(mdates.DateFormatter('%b %d')) ax1.xaxis.set_major_locator(mdates.WeekdayLocator(interval=1)) plt.setp(ax1.xaxis.get_majorticklabels(), rotation=30, ha='right', color=TEXT) ax1.grid(True, alpha=0.3, color=GRID) for spine in ax1.spines.values(): spine.set_color(GRID) for spine in ax2.spines.values(): spine.set_color(GRID) lines1, labels1 = ax1.get_legend_handles_labels() lines2, labels2 = ax2.get_legend_handles_labels() ax1.legend(lines1 + lines2, labels1 + labels2, loc='lower left', facecolor=BG, edgecolor=GRID, labelcolor=TEXT, fontsize=10) plt.tight_layout(pad=2.0) with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: plt.savefig(f.name, dpi=120, bbox_inches='tight', facecolor=BG) plt.close() print(f"[TREND DEBUG] Chart saved to: {f.name}") return f.name except Exception as e: print(f"[TREND CHART] Error: {e}") import traceback traceback.print_exc() return None def generate_trend_insight(trend_data: List[Dict], restaurant_name: str) -> str: """ Generate text insight from trend data. UPDATED: Now uses pre-calculated trend_data from backend. Format: [{"date": "...", "rating": 4.5, "sentiment": 0.6}, ...] """ if not trend_data or len(trend_data) < 3: return "Not enough data to analyze trends (need 3+ reviews)." ratings = [] sentiments = [] for r in trend_data: if isinstance(r, dict): rating = float(r.get('rating', 0) or r.get('overall_rating', 0) or 0) if rating > 0: ratings.append(rating) # Use pre-calculated sentiment if available, otherwise calculate sentiment = r.get('sentiment') if sentiment is not None: sentiments.append(float(sentiment)) else: text = r.get('text', '') or r.get('review_text', '') sentiments.append(calculate_review_sentiment(text)) if not ratings: return "No rating data available." avg_rating = sum(ratings) / len(ratings) avg_sentiment = sum(sentiments) / len(sentiments) if sentiments else 0 insight = f"**{restaurant_name}** has an average rating of **{avg_rating:.1f} stars** " if avg_sentiment >= 0.6: insight += "with **positive sentiment**. " if avg_rating >= 4.0: insight += "βœ… Ratings and sentiment are aligned!" else: insight += "πŸ€” Sentiment is positive but ratings are moderate." elif avg_sentiment < 0: insight += "but with **concerning sentiment**. " if avg_rating >= 4.0: insight += "⚠️ **Warning:** High ratings but negative sentiment detected." else: insight += "❌ Both ratings and sentiment suggest issues." else: insight += "with **neutral sentiment**. πŸ“Š Reviews are mixed." return insight # ============================================================================ # HELPER FUNCTIONS - IMPROVED SUMMARIES # ============================================================================ def clean_insight_text(data) -> str: """Convert various formats to clean bullet points.""" if not data: return "β€’ No data available" if isinstance(data, str): try: parsed = ast.literal_eval(data) if isinstance(parsed, list): data = parsed except: return data if isinstance(data, list): lines = [] for item in data: if isinstance(item, dict): action = item.get('action', item.get('recommendation', str(item))) priority = item.get('priority', '') if priority: lines.append(f"β€’ **[{priority.upper()}]** {action}") else: lines.append(f"β€’ {action}") else: lines.append(f"β€’ {item}") return "\n".join(lines) if lines else "β€’ No data available" return str(data) def format_insights(insights: dict, role: str) -> str: """Format insights for display.""" if not insights: return f"*No {role} insights available yet.*" emoji = "🍳" if role == "chef" else "πŸ“Š" title = "Chef" if role == "chef" else "Manager" summary = insights.get('summary', 'Analysis in progress...') strengths = clean_insight_text(insights.get('strengths', [])) concerns = clean_insight_text(insights.get('concerns', [])) recommendations = clean_insight_text(insights.get('recommendations', [])) return f"""### {emoji} {title} Insights **Summary:** {summary} **βœ… Strengths:** {strengths} **⚠️ Concerns:** {concerns} **πŸ’‘ Recommendations:** {recommendations} """ def translate_menu_performance(menu: dict, restaurant_name: str) -> str: """ Create simple summary of menu performance. Keep it clean - detailed info is in the dropdown. """ food_items = menu.get('food_items', []) drinks = menu.get('drinks', []) all_items = food_items + drinks if not all_items: return f"*No menu data available for {restaurant_name} yet.*" # Count categories - NEW thresholds: >= 0.6 positive, 0-0.59 neutral, < 0 negative stars = len([i for i in all_items if i.get('sentiment', 0) >= 0.6]) good = len([i for i in all_items if 0 <= i.get('sentiment', 0) < 0.6]) concerns = len([i for i in all_items if i.get('sentiment', 0) < 0]) # Simple summary summary = f"""### 🍽️ Menu Overview for {restaurant_name} **{len(all_items)} items analyzed** ({len(food_items)} food, {len(drinks)} drinks) | Category | Count | |----------|-------| | 🟒 Positive (β‰₯0.6) | {stars} | | 🟑 Neutral (0 to 0.59) | {good} | | πŸ”΄ Negative (<0) | {concerns} | πŸ‘‡ **Select an item from the dropdown below to see detailed customer feedback.** """ return summary def translate_aspect_performance(aspects: dict, restaurant_name: str) -> str: """ Create simple summary of aspect performance. Keep it clean - detailed info is in the dropdown. """ aspect_list = aspects.get('aspects', []) if not aspect_list: return f"*No aspect data available for {restaurant_name} yet.*" # Count categories - NEW thresholds: >= 0.6 positive, 0-0.59 neutral, < 0 negative strengths = len([a for a in aspect_list if a.get('sentiment', 0) >= 0.6]) neutral = len([a for a in aspect_list if 0 <= a.get('sentiment', 0) < 0.6]) weaknesses = len([a for a in aspect_list if a.get('sentiment', 0) < 0]) # Simple summary summary = f"""### πŸ“Š Customer Experience Overview for {restaurant_name} **{len(aspect_list)} aspects analyzed** | Category | Count | |----------|-------| | 🟒 Strengths (β‰₯0.6) | {strengths} | | 🟑 Neutral (0 to 0.59) | {neutral} | | πŸ”΄ Weaknesses (<0) | {weaknesses} | πŸ‘‡ **Select an aspect from the dropdown below to see detailed customer feedback.** """ return summary def generate_chart(items: list, title: str) -> Optional[str]: """Generate sentiment chart - top 10 by mentions, highest at TOP.""" if not items: return None try: import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt BG_COLOR = '#1f2937' TEXT_COLOR = '#e5e7eb' GRID_COLOR = '#374151' POSITIVE = '#10b981' NEUTRAL = '#f59e0b' NEGATIVE = '#ef4444' # Sort by mention_count descending, then REVERSE for display # (so highest mentions appear at TOP of horizontal bar chart) sorted_items = sorted(items, key=lambda x: x.get('mention_count', 0), reverse=True)[:10] sorted_items = sorted_items[::-1] # Reverse so highest is at top names = [f"{item.get('name', '?')[:18]} ({item.get('mention_count', 0)})" for item in sorted_items] sentiments = [item.get('sentiment', 0) for item in sorted_items] # NEW thresholds: >= 0.6 positive, >= 0 neutral, < 0 negative colors = [POSITIVE if s >= 0.6 else NEUTRAL if s >= 0 else NEGATIVE for s in sentiments] fig, ax = plt.subplots(figsize=(10, max(5, len(names) * 0.5))) fig.patch.set_facecolor(BG_COLOR) ax.set_facecolor(BG_COLOR) y_pos = range(len(names)) bars = ax.barh(y_pos, sentiments, color=colors, height=0.65, alpha=0.9) ax.set_yticks(y_pos) ax.set_yticklabels(names, fontsize=10, color=TEXT_COLOR, fontweight='medium') ax.set_xlabel('Sentiment Score', fontsize=11, color=TEXT_COLOR) ax.set_title(title, fontsize=14, fontweight='bold', color=TEXT_COLOR, pad=15) ax.axvline(x=0, color=GRID_COLOR, linestyle='-', linewidth=1.5, alpha=0.8) ax.set_xlim(-1, 1) for bar, sent in zip(bars, sentiments): label = f'{sent:+.2f}' x_pos = bar.get_width() + 0.05 if bar.get_width() >= 0 else bar.get_width() - 0.12 ax.text(x_pos, bar.get_y() + bar.get_height()/2, label, va='center', ha='left' if bar.get_width() >= 0 else 'right', fontsize=9, color=TEXT_COLOR, fontweight='bold') for spine in ax.spines.values(): spine.set_visible(False) ax.xaxis.grid(True, color=GRID_COLOR, linestyle='-', linewidth=0.5, alpha=0.5) ax.tick_params(axis='x', colors=TEXT_COLOR, labelsize=9) ax.tick_params(axis='y', colors=TEXT_COLOR, left=False) from matplotlib.patches import Patch legend_elements = [ Patch(facecolor=POSITIVE, label='Positive (>0.3)', alpha=0.9), Patch(facecolor=NEUTRAL, label='Mixed (-0.3 to 0.3)', alpha=0.9), Patch(facecolor=NEGATIVE, label='Negative (<-0.3)', alpha=0.9) ] ax.legend(handles=legend_elements, loc='lower left', fontsize=9, facecolor=BG_COLOR, edgecolor=GRID_COLOR, labelcolor=TEXT_COLOR) plt.tight_layout() with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: plt.savefig(f.name, dpi=120, bbox_inches='tight', facecolor=BG_COLOR) plt.close(fig) return f.name except Exception as e: print(f"Chart error: {e}") return None def extract_restaurant_name(url: str) -> str: """Extract restaurant name from URL.""" try: if 'opentable' in url.lower(): path = url.split('?')[0].rstrip('/') return path.split('/')[-1].replace('-', ' ').title() elif 'google' in url.lower(): if '/place/' in url: place = url.split('/place/')[1].split('/')[0] return place.replace('+', ' ').replace('%20', ' ').replace('%26', '&') return "Restaurant" except: return "Restaurant" def get_item_detail(item_name: str, state: dict) -> str: """Get DETAILED feedback for a selected menu item.""" if not item_name or not state: return "Select an item to see details." clean_name = item_name.split(' (')[0].strip().lower() menu = state.get('menu_analysis', {}) all_items = menu.get('food_items', []) + menu.get('drinks', []) for item in all_items: if item.get('name', '').lower() == clean_name: sentiment = item.get('sentiment', 0) mentions = item.get('mention_count', 0) summary = item.get('summary', '') related_reviews = item.get('related_reviews', []) # NEW thresholds: >= 0.6 positive, >= 0 neutral, < 0 negative emoji = "🟒" if sentiment >= 0.6 else "🟑" if sentiment >= 0 else "πŸ”΄" detail = f"""### {clean_name.title()} {emoji} **Sentiment Score:** {sentiment:+.2f} | **Total Mentions:** {mentions} --- **πŸ“ What Customers Are Saying:** {summary if summary else 'No detailed summary available.'} """ # Add sample reviews if available if related_reviews: detail += "\n**πŸ’¬ Sample Reviews:**\n\n" for i, review in enumerate(related_reviews[:3]): if isinstance(review, dict): text = review.get('review_text', str(review)) else: text = str(review) if text and len(text) > 20: detail += f"> *\"{text[:200]}{'...' if len(text) > 200 else ''}\"*\n\n" # Add actionable insight - NEW thresholds detail += "\n**🎯 Recommended Action:**\n" if sentiment >= 0.6: detail += f"This is a **star performer**! Consider featuring {clean_name.title()} in promotions and training staff to recommend it." elif sentiment >= 0: detail += f"Customers have neutral/mixed feelings about {clean_name.title()}. Monitor feedback and look for improvement opportunities." else: detail += f"⚠️ **Attention Needed:** {clean_name.title()} has negative feedback. Review preparation process and address customer complaints." return detail return f"No details found for '{item_name}'." def get_aspect_detail(aspect_name: str, state: dict) -> str: """Get DETAILED feedback for a selected aspect.""" if not aspect_name or not state: return "Select an aspect to see details." clean_name = aspect_name.split(' (')[0].strip().lower() aspects = state.get('aspect_analysis', {}).get('aspects', []) for aspect in aspects: if aspect.get('name', '').lower() == clean_name: sentiment = aspect.get('sentiment', 0) mentions = aspect.get('mention_count', 0) summary = aspect.get('summary', '') related_reviews = aspect.get('related_reviews', []) # NEW thresholds: >= 0.6 positive, >= 0 neutral, < 0 negative emoji = "🟒" if sentiment >= 0.6 else "🟑" if sentiment >= 0 else "πŸ”΄" detail = f"""### {clean_name.title()} {emoji} **Sentiment Score:** {sentiment:+.2f} | **Total Mentions:** {mentions} --- **πŸ“ Customer Feedback Summary:** {summary if summary else 'No detailed summary available.'} """ # Add sample reviews if available if related_reviews: detail += "\n**πŸ’¬ What Customers Said:**\n\n" for i, review in enumerate(related_reviews[:3]): if isinstance(review, dict): text = review.get('review_text', str(review)) else: text = str(review) if text and len(text) > 20: detail += f"> *\"{text[:200]}{'...' if len(text) > 200 else ''}\"*\n\n" # Add actionable insight - NEW thresholds detail += "\n**🎯 Recommended Action:**\n" if sentiment >= 0.6: detail += f"**{clean_name.title()}** is a major strength! Maintain current standards and use in marketing." elif sentiment >= 0: detail += f"**{clean_name.title()}** has neutral/mixed reviews. Identify specific areas to improve and make it exceptional." else: detail += f"⚠️ **Priority Issue:** **{clean_name.title()}** needs attention. Address customer complaints and consider staff training or process changes." return detail return f"No details found for '{aspect_name}'." # ============================================================================ # PDF GENERATION - FIXED # ============================================================================ def generate_pdf_report(state: dict) -> Optional[str]: """ Generate professional PDF report from analysis state. Uses ReportLab with custom styling for a polished output. """ if not state: print("[PDF] No state provided") return None try: from reportlab.lib.pagesizes import letter from reportlab.lib.units import inch from reportlab.lib import colors from reportlab.lib.colors import HexColor from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, HRFlowable from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.enums import TA_CENTER, TA_LEFT # Color scheme PRIMARY = HexColor('#2563eb') PRIMARY_LIGHT = HexColor('#dbeafe') POSITIVE = HexColor('#10b981') POSITIVE_LIGHT = HexColor('#d1fae5') WARNING = HexColor('#f59e0b') WARNING_LIGHT = HexColor('#fef3c7') NEGATIVE = HexColor('#ef4444') NEGATIVE_LIGHT = HexColor('#fee2e2') TEXT_DARK = HexColor('#1f2937') TEXT_LIGHT = HexColor('#6b7280') BACKGROUND = HexColor('#f9fafb') BORDER = HexColor('#e5e7eb') # Extract data restaurant_name = state.get('restaurant_name', 'Restaurant') source = state.get('source', 'unknown').replace('_', ' ').title() menu = state.get('menu_analysis', {}) aspects = state.get('aspect_analysis', {}) insights = state.get('insights', {}) # Use trend_data (slim) or fall back to raw_reviews for backward compatibility trend_data = state.get('trend_data', state.get('raw_reviews', [])) food_items = menu.get('food_items', []) drinks = menu.get('drinks', []) all_menu = food_items + drinks aspect_list = aspects.get('aspects', []) # Create file timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") safe_name = restaurant_name.lower().replace(" ", "_").replace("/", "_").replace("&", "and")[:30] output_path = os.path.join(tempfile.gettempdir(), f"{safe_name}_report_{timestamp}.pdf") print(f"[PDF] Generating professional report for {restaurant_name}") doc = SimpleDocTemplate( output_path, pagesize=letter, rightMargin=0.75*inch, leftMargin=0.75*inch, topMargin=0.75*inch, bottomMargin=0.75*inch ) styles = getSampleStyleSheet() # Custom styles - ALL use unique names to avoid conflicts with ReportLab defaults # ReportLab defaults include: Normal, BodyText, Italic, Heading1-6, Title, Bullet, Definition, Code styles.add(ParagraphStyle('RIACoverTitle', parent=styles['Heading1'], fontSize=32, textColor=PRIMARY, alignment=TA_CENTER, spaceAfter=10, fontName='Helvetica-Bold')) styles.add(ParagraphStyle('RIACoverSubtitle', parent=styles['Normal'], fontSize=16, textColor=TEXT_LIGHT, alignment=TA_CENTER, spaceAfter=30, fontName='Helvetica')) styles.add(ParagraphStyle('RIACoverRestaurant', parent=styles['Heading1'], fontSize=24, textColor=TEXT_DARK, alignment=TA_CENTER, spaceAfter=15, fontName='Helvetica-Bold')) styles.add(ParagraphStyle('RIASectionHeader', parent=styles['Heading1'], fontSize=18, textColor=PRIMARY, spaceBefore=20, spaceAfter=12, fontName='Helvetica-Bold')) styles.add(ParagraphStyle('RIASubHeader', parent=styles['Heading2'], fontSize=14, textColor=TEXT_DARK, spaceBefore=15, spaceAfter=8, fontName='Helvetica-Bold')) styles.add(ParagraphStyle('RIABody', parent=styles['Normal'], fontSize=10, textColor=TEXT_DARK, spaceAfter=8, leading=14, fontName='Helvetica')) styles.add(ParagraphStyle('RIABullet', parent=styles['Normal'], fontSize=10, textColor=TEXT_DARK, leftIndent=20, spaceAfter=5, fontName='Helvetica')) styles.add(ParagraphStyle('RIAQuote', parent=styles['Normal'], fontSize=10, textColor=TEXT_LIGHT, leftIndent=20, rightIndent=20, spaceAfter=10, fontName='Helvetica-Oblique')) styles.add(ParagraphStyle('RIAFooter', parent=styles['Normal'], fontSize=8, textColor=TEXT_LIGHT, alignment=TA_CENTER)) styles.add(ParagraphStyle('RIAPriorityHigh', parent=styles['Normal'], fontSize=10, textColor=NEGATIVE, leftIndent=20, spaceAfter=5, fontName='Helvetica-Bold')) styles.add(ParagraphStyle('RIAPriorityMedium', parent=styles['Normal'], fontSize=10, textColor=WARNING, leftIndent=20, spaceAfter=5, fontName='Helvetica-Bold')) styles.add(ParagraphStyle('RIAPriorityLow', parent=styles['Normal'], fontSize=10, textColor=POSITIVE, leftIndent=20, spaceAfter=5, fontName='Helvetica-Bold')) elements = [] # ==================== COVER PAGE ==================== elements.append(Spacer(1, 1.5*inch)) elements.append(Paragraph("RESTAURANT", styles['RIACoverTitle'])) elements.append(Paragraph("INTELLIGENCE REPORT", styles['RIACoverTitle'])) elements.append(Spacer(1, 0.3*inch)) elements.append(Paragraph("AI-Powered Customer Review Analysis", styles['RIACoverSubtitle'])) elements.append(HRFlowable(width="100%", thickness=1, color=BORDER, spaceBefore=10, spaceAfter=10)) elements.append(Spacer(1, 0.5*inch)) elements.append(Paragraph(restaurant_name, styles['RIACoverRestaurant'])) elements.append(Spacer(1, 0.3*inch)) elements.append(Paragraph(f"Data Source: {source}", styles['RIAFooter'])) elements.append(Spacer(1, 0.5*inch)) # Stats boxes stats_data = [[ str(len(trend_data)), str(len(all_menu)), str(len(aspect_list)) ], [ "Reviews", "Menu Items", "Aspects" ]] stats_table = Table(stats_data, colWidths=[2*inch, 2*inch, 2*inch]) stats_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, -1), BACKGROUND), ('TEXTCOLOR', (0, 0), (-1, 0), PRIMARY), ('TEXTCOLOR', (0, 1), (-1, 1), TEXT_LIGHT), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, 0), 24), ('FONTSIZE', (0, 1), (-1, 1), 10), ('ALIGN', (0, 0), (-1, -1), 'CENTER'), ('TOPPADDING', (0, 0), (-1, -1), 15), ('BOTTOMPADDING', (0, 0), (-1, -1), 15), ('BOX', (0, 0), (-1, -1), 1, BORDER), ])) elements.append(stats_table) elements.append(Spacer(1, 1*inch)) elements.append(Paragraph(f"Generated: {datetime.now().strftime('%B %d, %Y at %I:%M %p')}", styles['RIAFooter'])) elements.append(Paragraph("Powered by Claude AI β€’ Restaurant Intelligence Agent", styles['RIAFooter'])) elements.append(PageBreak()) # ==================== EXECUTIVE SUMMARY ==================== elements.append(Paragraph("Executive Summary", styles['RIASectionHeader'])) elements.append(HRFlowable(width="100%", thickness=1, color=BORDER, spaceBefore=5, spaceAfter=15)) # Calculate sentiment all_sentiments = [item.get('sentiment', 0) for item in all_menu] avg_sentiment = sum(all_sentiments) / len(all_sentiments) if all_sentiments else 0 # NEW thresholds: >= 0.6 positive, >= 0 neutral, < 0 negative sent_label = "Excellent" if avg_sentiment >= 0.8 else "Positive" if avg_sentiment >= 0.6 else "Neutral" if avg_sentiment >= 0 else "Needs Attention" sent_color = POSITIVE if avg_sentiment >= 0.6 else WARNING if avg_sentiment >= 0 else NEGATIVE sent_bg = POSITIVE_LIGHT if avg_sentiment >= 0.6 else WARNING_LIGHT if avg_sentiment >= 0 else NEGATIVE_LIGHT # Sentiment box sent_data = [[f"Overall Sentiment: {avg_sentiment:+.2f}", sent_label]] sent_table = Table(sent_data, colWidths=[3.5*inch, 2*inch]) sent_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, -1), sent_bg), ('TEXTCOLOR', (0, 0), (-1, -1), sent_color), ('FONTNAME', (0, 0), (-1, -1), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 14), ('ALIGN', (0, 0), (-1, -1), 'CENTER'), ('TOPPADDING', (0, 0), (-1, -1), 15), ('BOTTOMPADDING', (0, 0), (-1, -1), 15), ('BOX', (0, 0), (-1, -1), 2, sent_color), ])) elements.append(sent_table) elements.append(Spacer(1, 15)) # Key highlights elements.append(Paragraph("Key Highlights", styles['RIASubHeader'])) top_items = sorted(all_menu, key=lambda x: x.get('sentiment', 0), reverse=True)[:3] if top_items: elements.append(Paragraph("βœ… Top Performing Items:", styles['RIABody'])) for item in top_items: elements.append(Paragraph(f" β€’ {item.get('name', '?').title()} (sentiment: {item.get('sentiment', 0):+.2f})", styles['RIABullet'])) # NEW threshold: < 0 for concerns concern_items = [i for i in all_menu if i.get('sentiment', 0) < 0] if concern_items: elements.append(Spacer(1, 10)) elements.append(Paragraph("⚠️ Items Needing Attention:", styles['RIABody'])) for item in sorted(concern_items, key=lambda x: x.get('sentiment', 0))[:3]: elements.append(Paragraph(f" β€’ {item.get('name', '?').title()} (sentiment: {item.get('sentiment', 0):+.2f})", styles['RIABullet'])) elements.append(Spacer(1, 15)) # Summary stats - NEW thresholds positive = len([i for i in all_menu if i.get('sentiment', 0) >= 0.6]) neutral = len([i for i in all_menu if 0 <= i.get('sentiment', 0) < 0.6]) negative = len([i for i in all_menu if i.get('sentiment', 0) < 0]) summary_data = [ ['Metric', 'Value', 'Details'], ['Reviews Analyzed', str(len(trend_data)), f'From {source}'], ['Menu Items', str(len(all_menu)), f'{len(food_items)} food, {len(drinks)} drinks'], ['🟒 Positive', str(positive), 'Sentiment β‰₯ 0.6'], ['🟑 Neutral', str(neutral), 'Sentiment 0 to 0.59'], ['πŸ”΄ Negative', str(negative), 'Sentiment < 0'], ] summary_table = Table(summary_data, colWidths=[2*inch, 1.3*inch, 2.5*inch]) summary_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), PRIMARY), ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 10), ('ALIGN', (1, 0), (1, -1), 'CENTER'), ('TOPPADDING', (0, 0), (-1, -1), 8), ('BOTTOMPADDING', (0, 0), (-1, -1), 8), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, BACKGROUND]), ('GRID', (0, 0), (-1, -1), 0.5, BORDER), ])) elements.append(summary_table) elements.append(PageBreak()) # ==================== MENU ANALYSIS ==================== elements.append(Paragraph("Menu Performance Analysis", styles['RIASectionHeader'])) elements.append(HRFlowable(width="100%", thickness=1, color=BORDER, spaceBefore=5, spaceAfter=15)) if all_menu: elements.append(Paragraph( f"Analysis of {len(all_menu)} menu items ({len(food_items)} food, {len(drinks)} drinks) based on {len(trend_data)} customer reviews.", styles['RIABody'] )) elements.append(Spacer(1, 10)) sorted_menu = sorted(all_menu, key=lambda x: x.get('mention_count', 0), reverse=True)[:20] menu_data = [['#', 'Item', 'Sentiment', 'Mentions', 'Status']] for i, item in enumerate(sorted_menu, 1): sentiment = item.get('sentiment', 0) # NEW thresholds: >= 0.6 positive, >= 0 neutral, < 0 negative status = 'βœ“ Positive' if sentiment >= 0.6 else '~ Neutral' if sentiment >= 0 else 'βœ— Negative' menu_data.append([str(i), item.get('name', '?').title()[:22], f"{sentiment:+.2f}", str(item.get('mention_count', 0)), status]) menu_table = Table(menu_data, colWidths=[0.4*inch, 2.2*inch, 1*inch, 0.9*inch, 1.1*inch]) menu_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), POSITIVE), ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 9), ('ALIGN', (0, 0), (0, -1), 'CENTER'), ('ALIGN', (2, 0), (3, -1), 'CENTER'), ('TOPPADDING', (0, 0), (-1, -1), 6), ('BOTTOMPADDING', (0, 0), (-1, -1), 6), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, BACKGROUND]), ('GRID', (0, 0), (-1, -1), 0.5, BORDER), ])) elements.append(menu_table) elements.append(Spacer(1, 20)) # ==================== ASPECT ANALYSIS ==================== elements.append(Paragraph("Customer Experience Aspects", styles['RIASectionHeader'])) elements.append(HRFlowable(width="100%", thickness=1, color=BORDER, spaceBefore=5, spaceAfter=15)) if aspect_list: sorted_aspects = sorted(aspect_list, key=lambda x: x.get('mention_count', 0), reverse=True)[:20] aspect_data = [['#', 'Aspect', 'Sentiment', 'Mentions', 'Status']] for i, aspect in enumerate(sorted_aspects, 1): sentiment = aspect.get('sentiment', 0) # NEW thresholds: >= 0.6 positive, >= 0 neutral, < 0 negative status = 'βœ“ Strength' if sentiment >= 0.6 else '~ Neutral' if sentiment >= 0 else 'βœ— Weakness' aspect_data.append([str(i), aspect.get('name', '?').title()[:22], f"{sentiment:+.2f}", str(aspect.get('mention_count', 0)), status]) aspect_table = Table(aspect_data, colWidths=[0.4*inch, 2.2*inch, 1*inch, 0.9*inch, 1.1*inch]) aspect_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), WARNING), ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 9), ('ALIGN', (0, 0), (0, -1), 'CENTER'), ('ALIGN', (2, 0), (3, -1), 'CENTER'), ('TOPPADDING', (0, 0), (-1, -1), 6), ('BOTTOMPADDING', (0, 0), (-1, -1), 6), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, BACKGROUND]), ('GRID', (0, 0), (-1, -1), 0.5, BORDER), ])) elements.append(aspect_table) elements.append(PageBreak()) # ==================== CHEF INSIGHTS ==================== elements.append(Paragraph("🍳 Chef Insights", styles['RIASectionHeader'])) elements.append(HRFlowable(width="100%", thickness=1, color=BORDER, spaceBefore=5, spaceAfter=15)) chef_data = insights.get('chef', {}) if chef_data: if chef_data.get('summary'): elements.append(Paragraph("Summary", styles['RIASubHeader'])) elements.append(Paragraph(str(chef_data['summary']), styles['RIABody'])) if chef_data.get('strengths'): elements.append(Paragraph("βœ… Strengths", styles['RIASubHeader'])) strengths = chef_data['strengths'] if isinstance(strengths, list): for s in strengths[:8]: # Show up to 8 strengths text = s.get('action', str(s)) if isinstance(s, dict) else str(s) elements.append(Paragraph(f"β€’ {text}", styles['RIABullet'])) if chef_data.get('concerns'): elements.append(Paragraph("⚠️ Areas of Concern", styles['RIASubHeader'])) concerns = chef_data['concerns'] if isinstance(concerns, list): for c in concerns[:5]: # Show up to 5 concerns text = c.get('action', str(c)) if isinstance(c, dict) else str(c) elements.append(Paragraph(f"β€’ {text}", styles['RIABullet'])) if chef_data.get('recommendations'): elements.append(Paragraph("πŸ’‘ Recommendations", styles['RIASubHeader'])) recs = chef_data['recommendations'] if isinstance(recs, list): for r in recs[:8]: # Show up to 8 recommendations if isinstance(r, dict): priority = r.get('priority', 'medium').lower() action = r.get('action', str(r)) style_name = 'RIAPriorityHigh' if priority == 'high' else 'RIAPriorityMedium' if priority == 'medium' else 'RIAPriorityLow' elements.append(Paragraph(f"[{priority.upper()}] {action}", styles[style_name])) else: elements.append(Paragraph(f"β€’ {r}", styles['RIABullet'])) else: elements.append(Paragraph("Chef insights will be available after full analysis.", styles['RIABody'])) elements.append(Spacer(1, 20)) # ==================== MANAGER INSIGHTS ==================== elements.append(Paragraph("πŸ“Š Manager Insights", styles['RIASectionHeader'])) elements.append(HRFlowable(width="100%", thickness=1, color=BORDER, spaceBefore=5, spaceAfter=15)) manager_data = insights.get('manager', {}) if manager_data: if manager_data.get('summary'): elements.append(Paragraph("Summary", styles['RIASubHeader'])) elements.append(Paragraph(str(manager_data['summary']), styles['RIABody'])) if manager_data.get('strengths'): elements.append(Paragraph("βœ… Operational Strengths", styles['RIASubHeader'])) strengths = manager_data['strengths'] if isinstance(strengths, list): for s in strengths[:8]: # Show up to 8 strengths text = s.get('action', str(s)) if isinstance(s, dict) else str(s) elements.append(Paragraph(f"β€’ {text}", styles['RIABullet'])) if manager_data.get('concerns'): elements.append(Paragraph("⚠️ Operational Concerns", styles['RIASubHeader'])) concerns = manager_data['concerns'] if isinstance(concerns, list): for c in concerns[:5]: # Show up to 5 concerns text = c.get('action', str(c)) if isinstance(c, dict) else str(c) elements.append(Paragraph(f"β€’ {text}", styles['RIABullet'])) if manager_data.get('recommendations'): elements.append(Paragraph("πŸ’‘ Action Items", styles['RIASubHeader'])) recs = manager_data['recommendations'] if isinstance(recs, list): for r in recs[:8]: # Show up to 8 recommendations if isinstance(r, dict): priority = r.get('priority', 'medium').lower() action = r.get('action', str(r)) style_name = 'RIAPriorityHigh' if priority == 'high' else 'RIAPriorityMedium' if priority == 'medium' else 'RIAPriorityLow' elements.append(Paragraph(f"[{priority.upper()}] {action}", styles[style_name])) else: elements.append(Paragraph(f"β€’ {r}", styles['RIABullet'])) else: elements.append(Paragraph("Manager insights will be available after full analysis.", styles['RIABody'])) elements.append(PageBreak()) # ==================== CUSTOMER FEEDBACK HIGHLIGHTS ==================== elements.append(Paragraph("Customer Feedback Highlights", styles['RIASectionHeader'])) elements.append(HRFlowable(width="100%", thickness=1, color=BORDER, spaceBefore=5, spaceAfter=15)) positive_reviews = [] negative_reviews = [] # Extract sample reviews from menu items and aspects (they have related_reviews with text) all_related_reviews = [] # Get reviews from menu items for item in all_menu: for r in item.get('related_reviews', [])[:2]: if isinstance(r, dict): text = r.get('review_text', r.get('text', '')) else: text = str(r) if text and len(text) > 30: sentiment = item.get('sentiment', 0) all_related_reviews.append({'text': text, 'sentiment': sentiment}) # Get reviews from aspects for aspect in aspect_list: for r in aspect.get('related_reviews', [])[:2]: if isinstance(r, dict): text = r.get('review_text', r.get('text', '')) else: text = str(r) if text and len(text) > 30: sentiment = aspect.get('sentiment', 0) all_related_reviews.append({'text': text, 'sentiment': sentiment}) # Sort by sentiment to get best positive and worst negative for review in sorted(all_related_reviews, key=lambda x: x['sentiment'], reverse=True): text = review['text'] # NEW thresholds: >= 0.6 for positive, < 0 for negative if review['sentiment'] >= 0.6 and len(positive_reviews) < 3: positive_reviews.append(text[:180]) elif review['sentiment'] < 0 and len(negative_reviews) < 3: negative_reviews.append(text[:180]) elements.append(Paragraph("βœ… Positive Feedback", styles['RIASubHeader'])) if positive_reviews: for review in positive_reviews: elements.append(Paragraph(f'"{review}..."', styles['RIAQuote'])) else: elements.append(Paragraph("Detailed positive feedback samples not available.", styles['RIABody'])) elements.append(Spacer(1, 15)) elements.append(Paragraph("⚠️ Critical Feedback", styles['RIASubHeader'])) if negative_reviews: for review in negative_reviews: elements.append(Paragraph(f'"{review}..."', styles['RIAQuote'])) else: elements.append(Paragraph("No significant negative feedback identified. Great job!", styles['RIABody'])) # ==================== FOOTER ==================== elements.append(Spacer(1, 30)) elements.append(HRFlowable(width="100%", thickness=1, color=BORDER, spaceBefore=10, spaceAfter=10)) elements.append(Paragraph(f"Report generated for {restaurant_name}", styles['RIAFooter'])) elements.append(Paragraph(f"Generated on {datetime.now().strftime('%B %d, %Y at %I:%M %p')}", styles['RIAFooter'])) elements.append(Paragraph("Restaurant Intelligence Agent β€’ Powered by Claude AI", styles['RIAFooter'])) elements.append(Paragraph("Β© 2025 - Built for Anthropic MCP Hackathon", styles['RIAFooter'])) # Build PDF doc.build(elements) print(f"[PDF] Successfully generated professional report: {output_path}") return output_path except Exception as e: print(f"[PDF] Error generating report: {e}") import traceback traceback.print_exc() return None def download_pdf(state: dict) -> Optional[str]: """Generate PDF and return path for download.""" if not state: print("[PDF] No state for download") return None pdf_path = generate_pdf_report(state) print(f"[PDF] Download path: {pdf_path}") return pdf_path def send_email_report(email: str, state: dict) -> str: """Send PDF report via email.""" if not state: return "❌ No analysis data available. Please run analysis first." if not email or '@' not in email: return "❌ Please enter a valid email address." if not SMTP_USER or not SMTP_PASSWORD: return "⚠️ Email sending is not configured. Please download the PDF instead." try: pdf_path = generate_pdf_report(state) if not pdf_path: return "❌ Failed to generate PDF report." restaurant_name = state.get('restaurant_name', 'Restaurant') msg = MIMEMultipart() msg['From'] = EMAIL_FROM msg['To'] = email msg['Subject'] = f"Restaurant Intelligence Report - {restaurant_name}" body = f""" Hello, Please find attached your Restaurant Intelligence Report for {restaurant_name}. This report includes: - Executive Summary - Menu Performance Analysis - Customer Experience Aspects - Chef & Manager Insights Generated by Restaurant Intelligence Agent Powered by Claude AI """ msg.attach(MIMEText(body, 'plain')) with open(pdf_path, 'rb') as f: part = MIMEBase('application', 'octet-stream') part.set_payload(f.read()) encoders.encode_base64(part) part.add_header('Content-Disposition', f'attachment; filename="{restaurant_name}_report.pdf"') msg.attach(part) with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as server: server.starttls() server.login(SMTP_USER, SMTP_PASSWORD) server.send_message(msg) try: os.remove(pdf_path) except: pass return f"βœ… Report sent successfully to **{email}**!" except Exception as e: return f"❌ Failed to send email: {str(e)}" # ============================================================================ # RAG Q&A FUNCTIONS - FIXED PROXIES ERROR # ============================================================================ FOOD_WORDS = {'food', 'dish', 'dishes', 'menu', 'eat', 'taste', 'flavor', 'best', 'try', 'order', 'recommend'} SERVICE_WORDS = {'service', 'staff', 'waiter', 'server', 'waitress', 'attentive', 'friendly', 'rude', 'slow'} AMBIANCE_WORDS = {'ambiance', 'atmosphere', 'vibe', 'decor', 'noise', 'loud', 'quiet', 'romantic', 'cozy'} def find_relevant_reviews(question: str, state: dict, top_k: int = 8) -> List[str]: """Find relevant reviews for the question using menu/aspect related_reviews.""" if not state: return [] q = question.lower() q_words = set(q.split()) menu = state.get('menu_analysis', {}) aspects = state.get('aspect_analysis', {}) all_items = menu.get('food_items', []) + menu.get('drinks', []) all_aspects = aspects.get('aspects', []) relevant_reviews = [] # Search in menu items for item in all_items: name = item.get('name', '').lower() if name in q or any(w in name for w in q_words): for r in item.get('related_reviews', [])[:2]: text = r.get('review_text', str(r)) if isinstance(r, dict) else str(r) if text not in relevant_reviews and len(text) > 20: relevant_reviews.append(text) # Search in aspects for aspect in all_aspects: name = aspect.get('name', '').lower() if name in q or any(w in name for w in q_words): for r in aspect.get('related_reviews', [])[:2]: text = r.get('review_text', str(r)) if isinstance(r, dict) else str(r) if text not in relevant_reviews and len(text) > 20: relevant_reviews.append(text) # Category-based search if q_words & SERVICE_WORDS: for aspect in all_aspects: if any(w in aspect.get('name', '').lower() for w in ['service', 'staff', 'wait']): for r in aspect.get('related_reviews', [])[:2]: text = r.get('review_text', str(r)) if isinstance(r, dict) else str(r) if text not in relevant_reviews: relevant_reviews.append(text) if q_words & FOOD_WORDS: sorted_items = sorted(all_items, key=lambda x: x.get('sentiment', 0), reverse=True) for item in sorted_items[:3]: for r in item.get('related_reviews', [])[:2]: text = r.get('review_text', str(r)) if isinstance(r, dict) else str(r) if text not in relevant_reviews: relevant_reviews.append(text) # Fallback: if still no reviews, gather from all items/aspects if not relevant_reviews: # Collect from top items by mentions sorted_items = sorted(all_items, key=lambda x: x.get('mention_count', 0), reverse=True) for item in sorted_items[:5]: for r in item.get('related_reviews', [])[:2]: text = r.get('review_text', str(r)) if isinstance(r, dict) else str(r) if text and len(text) > 20 and text not in relevant_reviews: relevant_reviews.append(text) # Also from top aspects sorted_aspects = sorted(all_aspects, key=lambda x: x.get('mention_count', 0), reverse=True) for aspect in sorted_aspects[:5]: for r in aspect.get('related_reviews', [])[:2]: text = r.get('review_text', str(r)) if isinstance(r, dict) else str(r) if text and len(text) > 20 and text not in relevant_reviews: relevant_reviews.append(text) return relevant_reviews[:top_k] def generate_answer_with_claude(question: str, reviews: list, restaurant_name: str) -> str: """ Generate answer using Claude - FIXED PROXIES ERROR. Uses direct HTTP request as fallback. """ api_key = os.getenv("ANTHROPIC_API_KEY") if not api_key: return "⚠️ API key not configured for AI-powered answers." reviews_text = "" for i, review in enumerate(reviews[:8], 1): text = str(review)[:300] + "..." if len(str(review)) > 300 else str(review) reviews_text += f"\n[Review {i}]: {text}\n" prompt = f"""You are a helpful assistant answering questions about {restaurant_name} based on customer reviews. CUSTOMER REVIEWS: {reviews_text} QUESTION: {question} Instructions: - Answer based ONLY on the reviews provided above - Be specific - mention actual dishes, staff behavior, or details from the reviews - If reviews mention specific examples, include them - Keep your answer helpful and concise (3-5 sentences) - If the reviews don't contain relevant information, say so honestly - Provide BALANCED answers - mention both pros AND cons when relevant - If customers have mixed opinions, acknowledge both positive and negative feedback - Don't oversell or undersell - be honest about what customers actually said Answer:""" # Try Anthropic SDK first try: from anthropic import Anthropic client = Anthropic(api_key=api_key) response = client.messages.create( model="claude-sonnet-4-20250514", max_tokens=400, messages=[{"role": "user", "content": prompt}] ) return response.content[0].text except TypeError as e: if 'proxies' in str(e): print("[RAG] Anthropic SDK proxies error, using HTTP fallback...") # Fallback to direct HTTP request try: import httpx response = httpx.post( "https://api.anthropic.com/v1/messages", headers={ "Content-Type": "application/json", "X-API-Key": api_key, "anthropic-version": "2023-06-01" }, json={ "model": "claude-sonnet-4-20250514", "max_tokens": 400, "messages": [{"role": "user", "content": prompt}] }, timeout=30.0 ) if response.status_code == 200: data = response.json() return data['content'][0]['text'] else: return f"⚠️ API error: {response.status_code}" except Exception as http_e: return f"⚠️ Could not generate answer: {str(http_e)}" else: return f"⚠️ Could not generate answer: {str(e)}" except Exception as e: return f"⚠️ Could not generate answer: {str(e)}" def answer_question(question: str, state: dict) -> str: """RAG Q&A function.""" if not question or not question.strip(): return "❓ Please type a question above." if not state: return "⚠️ Please analyze a restaurant first." restaurant = state.get("restaurant_name", "the restaurant") relevant_reviews = find_relevant_reviews(question, state, top_k=8) if not relevant_reviews: return f"""**Q:** {question} **A:** I couldn't find relevant reviews to answer this question. πŸ’‘ **Try asking:** β€’ "What are the best dishes?" β€’ "How is the service?" β€’ "Is it good for a date?" β€’ "What do customers like most?" """ answer = generate_answer_with_claude(question, relevant_reviews, restaurant) return f"""**Q:** {question} **A:** {answer} --- *πŸ€– Based on {len(relevant_reviews)} customer reviews*""" EXAMPLE_QUESTIONS = [ "What are the best dishes to order?", "How is the service quality?", "Is this restaurant good for a date?", "What do people say about the ambiance?", "Is the food worth the price?", "Any complaints about wait times?", ] # ============================================================================ # MAIN ANALYSIS FUNCTION # ============================================================================ def analyze_restaurant(url: str, review_count: int): """Main analysis function - calls Modal API with robust error handling.""" empty = {} default_summary = "Run analysis to see performance overview." default_insight = "Run analysis to see insights." default_detail = "Select an item to see details." empty_dropdown = gr.update(choices=[], value=None) if not url or not url.strip(): return ( "❌ **Error:** Please enter a restaurant URL.", None, "No trend data available.", default_summary, None, default_insight, empty_dropdown, default_detail, default_summary, None, default_insight, empty_dropdown, default_detail, empty ) url = url.strip() platform = detect_platform(url) if platform == "unknown": return ( "❌ **Error:** URL not recognized. Please use OpenTable or Google Maps URL.", None, "No trend data available.", default_summary, None, default_insight, empty_dropdown, default_detail, default_summary, None, default_insight, empty_dropdown, default_detail, empty ) restaurant_name = extract_restaurant_name(url) platform_emoji = get_platform_emoji(platform) try: print(f"[ANALYZE] {platform_emoji} Analyzing {restaurant_name} from {platform}...") print(f"[ANALYZE] Calling Modal API: {MODAL_API_URL}/analyze") # Use a session with retry logic import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() retries = Retry( total=2, backoff_factor=1, status_forcelist=[502, 503, 504], allowed_methods=["POST"] ) session.mount("https://", HTTPAdapter(max_retries=retries)) # Make request with streaming disabled for stability response = session.post( f"{MODAL_API_URL}/analyze", json={"url": url, "max_reviews": review_count}, timeout=(30, 2100), # 30s connect, 600s read (10 min) headers={"Connection": "keep-alive"} ) print(f"[ANALYZE] Response status: {response.status_code}") if response.status_code != 200: error_text = response.text[:500] if response.text else "No error details" print(f"[ANALYZE] Error response: {error_text}") return ( f"❌ **API Error ({response.status_code}):** {error_text[:200]}", None, "No trend data available.", default_summary, None, default_insight, empty_dropdown, default_detail, default_summary, None, default_insight, empty_dropdown, default_detail, empty ) # Parse response try: data = response.json() print(f"[ANALYZE] Response received, success={data.get('success')}") except Exception as json_err: print(f"[ANALYZE] JSON parse error: {json_err}") return ( f"❌ **Parse Error:** Could not parse API response. Try again.", None, "No trend data available.", default_summary, None, default_insight, empty_dropdown, default_detail, default_summary, None, default_insight, empty_dropdown, default_detail, empty ) if not data.get("success"): return ( f"❌ **Analysis Failed:** {data.get('error', 'Unknown error')}", None, "No trend data available.", default_summary, None, default_insight, empty_dropdown, default_detail, default_summary, None, default_insight, empty_dropdown, default_detail, empty ) menu = data.get('menu_analysis', {}) aspects = data.get('aspect_analysis', {}) insights = data.get('insights', {}) # Use slim trend_data (pre-calculated sentiment, no text) # Falls back to raw_reviews for backward compatibility trend_data = data.get('trend_data', data.get('raw_reviews', [])) food_items = menu.get('food_items', []) drinks = menu.get('drinks', []) aspect_list = aspects.get('aspects', []) all_menu = food_items + drinks print(f"[ANALYZE] Data extracted: {len(all_menu)} menu items, {len(aspect_list)} aspects, {len(trend_data)} trend points") state = { "menu_analysis": menu, "aspect_analysis": aspects, "insights": insights, "restaurant_name": restaurant_name, "trend_data": trend_data, # Store for PDF if needed "source": platform } trend_chart = generate_trend_chart(trend_data, restaurant_name) trend_insight = generate_trend_insight(trend_data, restaurant_name) # Use improved detailed summaries menu_summary = translate_menu_performance(menu, restaurant_name) aspect_summary = translate_aspect_performance(aspects, restaurant_name) chef_insights = format_insights(insights.get('chef', {}), 'chef') manager_insights = format_insights(insights.get('manager', {}), 'manager') chef_chart = generate_chart(all_menu, f"Menu Item Sentiment (Top 10 by Mentions)") manager_chart = generate_chart(aspect_list, f"Aspect Sentiment (Top 10 by Mentions)") chef_sorted = sorted(all_menu, key=lambda x: x.get('mention_count', 0), reverse=True) manager_sorted = sorted(aspect_list, key=lambda x: x.get('mention_count', 0), reverse=True) chef_choices = [f"{i.get('name', '?')} ({i.get('mention_count', 0)})" for i in chef_sorted] manager_choices = [f"{a.get('name', '?')} ({a.get('mention_count', 0)})" for a in manager_sorted] chef_dropdown_update = gr.update(choices=chef_choices, value=None) manager_dropdown_update = gr.update(choices=manager_choices, value=None) status = f"""βœ… **Analysis Complete for {restaurant_name}!** {platform_emoji} **πŸ“Š Summary:** β€’ Source: **{platform.replace('_', ' ').title()}** β€’ Reviews analyzed: **{len(trend_data)}** β€’ Menu items found: **{len(all_menu)}** ({len(food_items)} food, {len(drinks)} drinks) β€’ Aspects discovered: **{len(aspect_list)}** πŸ‘‡ **Explore the tabs below for detailed insights!** """ print(f"[ANALYZE] βœ… Analysis complete for {restaurant_name}") return ( status, trend_chart, trend_insight, menu_summary, chef_chart, chef_insights, chef_dropdown_update, default_detail, aspect_summary, manager_chart, manager_insights, manager_dropdown_update, default_detail, state ) except requests.exceptions.Timeout: print("[ANALYZE] ❌ Timeout error") return ( "❌ **Timeout:** Request took too long. Try with fewer reviews (50-100).", None, "No trend data available.", default_summary, None, default_insight, empty_dropdown, default_detail, default_summary, None, default_insight, empty_dropdown, default_detail, empty ) except requests.exceptions.ConnectionError as ce: print(f"[ANALYZE] ❌ Connection error: {ce}") return ( "❌ **Connection Error:** Could not reach analysis server. Please try again in a moment.", None, "No trend data available.", default_summary, None, default_insight, empty_dropdown, default_detail, default_summary, None, default_insight, empty_dropdown, default_detail, empty ) except Exception as e: import traceback traceback.print_exc() print(f"[ANALYZE] ❌ Exception: {e}") return ( f"❌ **Error:** {str(e)[:200]}", None, "No trend data available.", default_summary, None, default_insight, empty_dropdown, default_detail, default_summary, None, default_insight, empty_dropdown, default_detail, empty ) # ============================================================================ # GRADIO INTERFACE # ============================================================================ def create_app() -> gr.Blocks: """Create enhanced Gradio interface.""" with gr.Blocks(title="Restaurant Intelligence Agent") as app: # ==================== HEADER ==================== gr.Markdown(""" # 🍽️ Restaurant Intelligence Agent **AI-Powered Review Analysis for Restaurant Owners, Chefs & Managers** *Uncover what customers really think β€” beyond star ratings.* **Supported Platforms:** 🍽️ OpenTable | πŸ—ΊοΈ Google Maps """) gr.Markdown("---") # ==================== INPUT SECTION ==================== gr.Markdown("### πŸ“ Enter Restaurant Details") with gr.Row(): with gr.Column(scale=5): url_input = gr.Textbox( label="Restaurant URL", placeholder="Paste OpenTable or Google Maps URL", info="Supports: opentable.com, google.com/maps", max_lines=1 ) with gr.Column(scale=1): review_count = gr.Dropdown( choices=[50, 100, 200, 500, 1000], value=100, label="Reviews", info="More = better insights" ) with gr.Column(scale=1): analyze_btn = gr.Button("πŸš€ Analyze", variant="primary", size="lg") status_box = gr.Markdown( value="*Enter a restaurant URL above and click **Analyze** to start.*" ) analysis_state = gr.State(value={}) gr.Markdown("---") # ==================== RESULTS TABS ==================== with gr.Tabs(): # ========== TRENDS TAB ========== with gr.Tab("πŸ“ˆ Trends"): gr.Markdown(""" ### Rating vs Sentiment Over Time This chart reveals the **disconnect** between what customers **rate** (stars) vs what they **say** (sentiment). """) trend_chart = gr.Image(label="Rating vs Sentiment Trend", height=450) gr.Markdown("---") trend_insight = gr.Markdown(value="*Run analysis to see trend insights.*") # ========== CHEF TAB ========== with gr.Tab("🍳 Chef Insights"): chef_summary = gr.Markdown(value="*Run analysis to see menu performance overview.*") gr.Markdown("---") with gr.Row(): with gr.Column(scale=1): chef_chart = gr.Image(label="Menu Sentiment Chart", height=420) with gr.Column(scale=1): chef_insights = gr.Markdown(value="*AI-generated insights will appear here.*") gr.Markdown("---") gr.Markdown("**πŸ” Drill Down:** Select a menu item to see detailed feedback") chef_dropdown = gr.Dropdown(choices=[], label="Select Menu Item", interactive=True) chef_detail = gr.Markdown(value="*Select an item above to see detailed feedback.*") # ========== MANAGER TAB ========== with gr.Tab("πŸ“Š Manager Insights"): manager_summary = gr.Markdown(value="*Run analysis to see aspect performance overview.*") gr.Markdown("---") with gr.Row(): with gr.Column(scale=1): manager_chart = gr.Image(label="Aspect Sentiment Chart", height=420) with gr.Column(scale=1): manager_insights = gr.Markdown(value="*AI-generated insights will appear here.*") gr.Markdown("---") gr.Markdown("**πŸ” Drill Down:** Select an aspect to see detailed feedback") manager_dropdown = gr.Dropdown(choices=[], label="Select Aspect", interactive=True) manager_detail = gr.Markdown(value="*Select an aspect above to see detailed feedback.*") # ========== Q&A TAB ========== with gr.Tab("πŸ’¬ Ask Questions"): gr.Markdown(""" ### Ask Questions About the Reviews Get AI-powered answers based on actual customer feedback. """) gr.Markdown("**πŸ’‘ Try these example questions:**") with gr.Row(): for q in EXAMPLE_QUESTIONS[:3]: gr.Button(q, size="sm") question_input = gr.Textbox( label="Your Question", placeholder="e.g., What do customers think about the pasta dishes?", lines=2 ) with gr.Row(): ask_btn = gr.Button("πŸ” Ask", variant="primary") clear_btn = gr.Button("Clear", variant="secondary") answer_output = gr.Markdown(value="*Analyze a restaurant first, then ask questions.*") clear_btn.click(fn=lambda: ("", "*Ask a question above.*"), outputs=[question_input, answer_output]) # ========== EXPORT TAB ========== with gr.Tab("πŸ“€ Export Report"): gr.Markdown(""" ### Export Your Analysis Download a comprehensive PDF report or have it emailed directly to you. """) gr.Markdown("---") gr.Markdown("#### πŸ“₯ Download PDF Report") gr.Markdown("Get a professional PDF with all analysis results, charts, and recommendations.") with gr.Row(): download_btn = gr.Button("πŸ“„ Generate & Download PDF", variant="primary", size="lg") pdf_output = gr.File(label="Download Your Report", visible=True) download_status = gr.Markdown(value="") gr.Markdown("---") gr.Markdown("#### πŸ“§ Email Report") gr.Markdown("Enter your email address to receive the PDF report directly in your inbox.") with gr.Row(): email_input = gr.Textbox( label="Email Address", placeholder="your@email.com", max_lines=1, scale=3 ) send_btn = gr.Button("πŸ“¨ Send Report", variant="secondary", scale=1) email_status = gr.Markdown(value="") # ==================== FOOTER ==================== gr.Markdown("---") gr.Markdown("""
**Built for** [Anthropic MCP 1st Birthday Hackathon](https://huggingface.co/MCP-1st-Birthday) πŸŽ‚ | **Track:** Productivity | **By:** Tushar Pingle *Powered by Claude AI β€’ Modal Cloud β€’ MCP Integration*
""") # ==================== EVENT HANDLERS ==================== analyze_btn.click( fn=analyze_restaurant, inputs=[url_input, review_count], outputs=[ status_box, trend_chart, trend_insight, chef_summary, chef_chart, chef_insights, chef_dropdown, chef_detail, manager_summary, manager_chart, manager_insights, manager_dropdown, manager_detail, analysis_state ] ) chef_dropdown.change( fn=get_item_detail, inputs=[chef_dropdown, analysis_state], outputs=chef_detail ) manager_dropdown.change( fn=get_aspect_detail, inputs=[manager_dropdown, analysis_state], outputs=manager_detail ) ask_btn.click( fn=answer_question, inputs=[question_input, analysis_state], outputs=answer_output ) download_btn.click( fn=download_pdf, inputs=[analysis_state], outputs=pdf_output ) send_btn.click( fn=send_email_report, inputs=[email_input, analysis_state], outputs=email_status ) return app # ============================================================================ # MAIN # ============================================================================ if __name__ == "__main__": app = create_app() app.launch( server_name="0.0.0.0", server_port=7860, share=True, theme=gr.themes.Soft(primary_hue="orange", secondary_hue="slate") )