import asyncio import json from features.base_feature import BaseFeature from tools.tavily_search import TavilySearchTool from tools.calculator import CalculatorTool _tavily = TavilySearchTool() _calc = CalculatorTool() class TripFeature(BaseFeature): async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str: """ Compute date intersection across both parties. Fetch real destination options via Tavily. """ raw_a = preferences_a.get("raw_details", {}) raw_b = preferences_b.get("raw_details", {}) dates_a = raw_a.get("available_dates") or raw_a.get("dates") or [] dates_b = raw_b.get("available_dates") or raw_b.get("dates") or [] budget_a = raw_a.get("budget_per_person") or raw_a.get("budget") or "" budget_b = raw_b.get("budget_per_person") or raw_b.get("budget") or "" dest_pref_a = raw_a.get("destination_preference") or raw_a.get("destination") or "" dest_pref_b = raw_b.get("destination_preference") or raw_b.get("destination") or "" from_city = raw_a.get("origin") or raw_b.get("origin") or raw_a.get("city") or "Mumbai" accom_a = raw_a.get("accommodation_type") or "" accom_b = raw_b.get("accommodation_type") or "" # Compute date overlap (simple string intersection) dates_a_set = set(str(d).lower() for d in (dates_a if isinstance(dates_a, list) else [dates_a])) dates_b_set = set(str(d).lower() for d in (dates_b if isinstance(dates_b, list) else [dates_b])) common_dates = dates_a_set & dates_b_set # Budget ceiling = lower budget budget_ceiling = "" if budget_a and budget_b: try: ba = float(str(budget_a).replace(",", "")) bb = float(str(budget_b).replace(",", "")) budget_ceiling = f"₹{min(ba, bb):,.0f}/person" except (ValueError, TypeError): budget_ceiling = f"{budget_a} or {budget_b} (take lower)" elif budget_a or budget_b: budget_ceiling = f"₹{budget_a or budget_b}/person" # Destination type combined dest_type = " or ".join(filter(None, [dest_pref_a, dest_pref_b])) or "weekend getaway" # Tavily: real destination options search_text = "" try: query = f"weekend getaway destinations from {from_city} {dest_type} budget India 2026" result = await _tavily.execute(query) answer = result.get("answer", "") results = result.get("results", [])[:4] parts = [] if answer: parts.append(f"Destination summary: {answer[:300]}") for r in results: title = r.get("title", "") content = r.get("content", "")[:150] if title: parts.append(f" • {title}: {content}") search_text = "\n".join(parts) except Exception as e: search_text = f"Search unavailable. Use your knowledge of destinations from {from_city}." lines = [ "TRIP PLANNING DOMAIN RULES:", "• Date overlap is PRIORITY #1 — only propose dates both parties are available.", "• Budget ceiling = LOWEST budget in the group. No one should overspend.", "• Destination must satisfy at least one preference from each party.", "• Accommodation type: prefer the more comfortable option if budget allows.", "• If no date overlap: escalate immediately with adjusted date suggestions.", ] if from_city: lines.append(f"\nOrigin city: {from_city}") if dates_a: lines.append(f"Person A available: {dates_a}") if dates_b: lines.append(f"Person B available: {dates_b}") if common_dates: lines.append(f"✅ OVERLAPPING DATES: {', '.join(common_dates)}") else: lines.append("⚠️ No exact date overlap found — propose closest alternatives.") if budget_ceiling: lines.append(f"Budget ceiling: {budget_ceiling}") if dest_pref_a: lines.append(f"Person A wants: {dest_pref_a}") if dest_pref_b: lines.append(f"Person B wants: {dest_pref_b}") if accom_a or accom_b: lines.append(f"Accommodation preferences: {accom_a or ''} / {accom_b or ''}") if search_text: lines.append(f"\nREAL DESTINATION OPTIONS:\n{search_text}") return "\n".join(lines) def format_resolution( self, resolution: dict, preferences_a: dict, preferences_b: dict ) -> str: status = resolution.get("status", "resolved") final = resolution.get("final_proposal", {}) details = final.get("details", {}) rounds = resolution.get("rounds_taken", "?") summary = resolution.get("summary", "") if status == "escalated": return ( f"⚠️ *Trip Planning — Human Decision Needed*\n\n" f"_{summary}_\n\n" f"Agents proposed options but couldn't finalize in {rounds} round(s). " f"Please agree on dates and destination directly." ) destination = details.get("destination") or details.get("place") or final.get("summary", "") dates = details.get("dates") or details.get("travel_dates") or details.get("date") or "" budget = details.get("budget_per_person") or details.get("budget") or "" accommodation = details.get("accommodation") or details.get("stay") or "" activities = details.get("activities") or details.get("things_to_do") or [] duration = details.get("duration") or details.get("nights") or "" lines = ["✈️ *Trip Planned!*\n"] if destination: lines.append(f"🗺 *Destination:* {destination}") if dates: lines.append(f"📅 *Dates:* {dates}") if duration: lines.append(f"⏱ *Duration:* {duration}") if accommodation: lines.append(f"🏨 *Stay:* {accommodation}") if budget: lines.append(f"💰 *Budget/person:* ₹{budget}") if activities and isinstance(activities, list): lines.append("🎯 *Activities:*") for act in activities[:4]: lines.append(f" • {act}") elif activities: lines.append(f"🎯 *Activities:* {activities}") lines.append(f"\n⏱ Planned in {rounds} round(s)") if summary and summary != "Agreement reached": lines.append(f"_{summary}_") return "\n".join(lines) # ──────────────────────────────────────────────────────────────────────────── # Group negotiation for 3+ participants (trip planning) # ──────────────────────────────────────────────────────────────────────────── async def run_group_negotiation( negotiation_id: str, all_preferences: list, all_user_ids: list, feature_type: str = "trip", personalities: list = None, on_round_update=None, on_resolution=None, ) -> dict: """ Multi-agent group negotiation using a mediator approach. One NegotiatorAgent acts as mediator, sees all preferences, proposes. Each participant's NegotiatorAgent scores the proposal. Iterates up to 5 rounds; escalates if no full agreement. """ import database as db from agents.negotiator_agent import NegotiatorAgent if personalities is None: personalities = ["balanced"] * len(all_preferences) await db.update_negotiation_status(negotiation_id, "active") # Create mediator (uses balanced personality) mediator = NegotiatorAgent(personality="balanced") # Create per-participant evaluators evaluators = [ NegotiatorAgent(personality=p) for p in personalities ] # Pre-fetch trip context feature = TripFeature() feature_context = "" if len(all_preferences) >= 2: try: feature_context = await feature.get_context(all_preferences[0], all_preferences[1]) except Exception: feature_context = "" max_rounds = 5 current_proposal = None satisfaction_timeline = [] for round_num in range(1, max_rounds + 1): await asyncio.sleep(1.5) # Mediator generates/refines proposal mediator_prompt = f"""You are MEDIATING a group {feature_type} negotiation with {len(all_preferences)} participants. {"DOMAIN CONTEXT:" + chr(10) + feature_context if feature_context else ""} ALL PARTICIPANTS' PREFERENCES: {json.dumps(all_preferences, indent=2)} {"PREVIOUS PROPOSAL (refine based on feedback below):" + chr(10) + json.dumps(current_proposal, indent=2) if current_proposal else ""} Round {round_num} of {max_rounds}. Generate a proposal that maximizes GROUP satisfaction. Rules: - Budget ceiling = LOWEST budget among all participants. - Dates = intersection of all available dates (or closest compromise). - Every participant must get at least one preference honored. - Return the standard proposal JSON format.""" try: mediated = await mediator.call(user_prompt=mediator_prompt) except Exception: mediated = { "action": "propose", "proposal": {"summary": "Group proposal", "details": {}}, "satisfaction_score": 60, "reasoning": "Mediator generated group proposal", "concessions_made": [], "concessions_requested": [], } current_proposal = mediated.get("proposal", {}) # Score with each participant scores = [] low_scorer = None low_score = 100 for i, (prefs, evaluator) in enumerate(zip(all_preferences, evaluators)): eval_prompt = f"""Evaluate this group {feature_type} proposal for Participant {i+1}. PROPOSAL: {json.dumps(current_proposal, indent=2)} YOUR PREFERENCES: {json.dumps(prefs, indent=2)} Score it and decide: accept (>= 65), counter (40-64), or escalate (< 40 after round 3). Return standard JSON format.""" try: eval_response = await evaluator.call(user_prompt=eval_prompt) except Exception: eval_response = {"action": "accept", "satisfaction_score": 65, "reasoning": ""} score = eval_response.get("satisfaction_score", 65) scores.append(score) if score < low_score: low_score = score low_scorer = i avg_score = sum(scores) / len(scores) sat_entry = {"round": round_num} for i, s in enumerate(scores): sat_entry[f"score_{chr(65+i)}"] = s satisfaction_timeline.append(sat_entry) round_data = { "negotiation_id": negotiation_id, "round_number": round_num, "action": "counter" if avg_score < 65 else "accept", "proposal": current_proposal, "satisfaction_score": avg_score, "reasoning": mediated.get("reasoning", "")[:200], "group_scores": scores, "satisfaction_a": scores[0] if scores else 0, "satisfaction_b": scores[1] if len(scores) > 1 else 0, } # Save round try: await db.save_round( negotiation_id=negotiation_id, round_number=round_num, proposer_id=all_user_ids[0], proposal=mediated, response_type=round_data["action"], reasoning=round_data["reasoning"], satisfaction_a=scores[0] if scores else 0, satisfaction_b=scores[1] if len(scores) > 1 else 0, concessions_made=[], ) except Exception: pass if on_round_update: await on_round_update(round_data) # All participants satisfied if all(s >= 65 for s in scores): resolution = { "status": "resolved", "final_proposal": current_proposal, "rounds_taken": round_num, "summary": current_proposal.get("summary", "Group trip planned!"), "satisfaction_timeline": satisfaction_timeline, "group_scores": scores, } await db.update_negotiation_status(negotiation_id, "resolved", resolution) if on_resolution: await on_resolution(resolution) return resolution # After round 3, if any score < 40, escalate if round_num >= 3 and low_score < 40: resolution = { "status": "escalated", "final_proposal": current_proposal, "rounds_taken": round_num, "summary": f"Participant {low_scorer+1} couldn't agree. Human decision needed.", "satisfaction_timeline": satisfaction_timeline, "group_scores": scores, } await db.update_negotiation_status(negotiation_id, "escalated", resolution) if on_resolution: await on_resolution(resolution) return resolution # Max rounds exhausted resolution = { "status": "escalated", "final_proposal": current_proposal, "rounds_taken": max_rounds, "summary": "Max rounds reached. Best group proposal for human review.", "satisfaction_timeline": satisfaction_timeline, "group_scores": scores if scores else [], } await db.update_negotiation_status(negotiation_id, "escalated", resolution) if on_resolution: await on_resolution(resolution) return resolution