mirror of
https://github.com/arkorty/B.Tech-Project-III.git
synced 2026-04-19 12:41:48 +00:00
init
This commit is contained in:
3
negot8/backend/features/__init__.py
Normal file
3
negot8/backend/features/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from features.base_feature import get_feature, BaseFeature
|
||||
|
||||
__all__ = ["get_feature", "BaseFeature"]
|
||||
65
negot8/backend/features/base_feature.py
Normal file
65
negot8/backend/features/base_feature.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Base feature class for all negoT8 negotiation features.
|
||||
Every feature module must subclass BaseFeature and implement:
|
||||
- get_context() → pre-fetches tool data; returns string injected into negotiators
|
||||
- format_resolution() → returns a Telegram-ready Markdown string
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class BaseFeature(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def get_context(
|
||||
self,
|
||||
preferences_a: dict,
|
||||
preferences_b: dict,
|
||||
user_a_id: int = None,
|
||||
user_b_id: int = None,
|
||||
) -> str:
|
||||
"""
|
||||
Pre-fetch tool results (Tavily, Calculator, etc.) and return a
|
||||
formatted string to inject into the negotiator as domain context.
|
||||
Return "" if no external context is needed.
|
||||
user_a_id / user_b_id are optional — only SchedulingFeature uses them
|
||||
to query Google Calendar when the user hasn't specified times.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def format_resolution(
|
||||
self, resolution: dict, preferences_a: dict, preferences_b: dict
|
||||
) -> str:
|
||||
"""
|
||||
Transform the raw resolution dict into a nice Telegram Markdown string.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ─── Feature dispatcher ───────────────────────────────────────────────────────
|
||||
|
||||
def get_feature(feature_type: str) -> BaseFeature:
|
||||
"""Return the correct BaseFeature subclass for the given feature type."""
|
||||
from features.scheduling import SchedulingFeature
|
||||
from features.expenses import ExpensesFeature
|
||||
from features.freelance import FreelanceFeature
|
||||
from features.roommate import RoommateFeature
|
||||
from features.trip import TripFeature
|
||||
from features.marketplace import MarketplaceFeature
|
||||
from features.collaborative import CollaborativeFeature
|
||||
from features.conflict import ConflictFeature
|
||||
from features.generic import GenericFeature
|
||||
|
||||
mapping = {
|
||||
"scheduling": SchedulingFeature,
|
||||
"expenses": ExpensesFeature,
|
||||
"freelance": FreelanceFeature,
|
||||
"roommate": RoommateFeature,
|
||||
"trip": TripFeature,
|
||||
"marketplace": MarketplaceFeature,
|
||||
"collaborative": CollaborativeFeature,
|
||||
"conflict": ConflictFeature,
|
||||
"generic": GenericFeature,
|
||||
}
|
||||
cls = mapping.get(feature_type, GenericFeature)
|
||||
return cls()
|
||||
121
negot8/backend/features/collaborative.py
Normal file
121
negot8/backend/features/collaborative.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from features.base_feature import BaseFeature
|
||||
from tools.tavily_search import TavilySearchTool
|
||||
from tools.calculator import CalculatorTool
|
||||
from urllib.parse import quote as _url_quote
|
||||
|
||||
_tavily = TavilySearchTool()
|
||||
_calc = CalculatorTool()
|
||||
|
||||
|
||||
class CollaborativeFeature(BaseFeature):
|
||||
|
||||
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
|
||||
"""
|
||||
Use Tavily to fetch REAL restaurant/activity/venue options matching
|
||||
both parties' preferences. Inject real names so agents cite real places.
|
||||
"""
|
||||
raw_a = preferences_a.get("raw_details", {})
|
||||
raw_b = preferences_b.get("raw_details", {})
|
||||
|
||||
decision_type = (
|
||||
raw_a.get("decision_type") or raw_b.get("decision_type")
|
||||
or preferences_a.get("goal", "")
|
||||
)
|
||||
location = (
|
||||
raw_a.get("location") or raw_b.get("location")
|
||||
or raw_a.get("city") or raw_b.get("city")
|
||||
or "Mumbai"
|
||||
)
|
||||
cuisine_a = raw_a.get("cuisine") or raw_a.get("food_preference") or ""
|
||||
cuisine_b = raw_b.get("cuisine") or raw_b.get("food_preference") or ""
|
||||
budget_a = raw_a.get("budget") or raw_a.get("budget_per_person") or ""
|
||||
budget_b = raw_b.get("budget") or raw_b.get("budget_per_person") or ""
|
||||
|
||||
# Build a smart Tavily query
|
||||
cuisine_part = f"{cuisine_a} or {cuisine_b}" if cuisine_a and cuisine_b else (cuisine_a or cuisine_b or "good")
|
||||
query = f"best {cuisine_part} restaurants in {location}"
|
||||
|
||||
tavily_text = ""
|
||||
try:
|
||||
result = await _tavily.execute(query)
|
||||
answer = result.get("answer", "")
|
||||
results = result.get("results", [])[:4]
|
||||
place_lines = []
|
||||
if answer:
|
||||
place_lines.append(f"AI Summary: {answer[:300]}")
|
||||
for r in results:
|
||||
title = r.get("title", "")
|
||||
content = r.get("content", "")[:150]
|
||||
if title:
|
||||
place_lines.append(f" • {title}: {content}")
|
||||
tavily_text = "\n".join(place_lines)
|
||||
except Exception as e:
|
||||
tavily_text = f"Search unavailable ({e}). Use your knowledge of {location} restaurants."
|
||||
|
||||
lines = [
|
||||
"COLLABORATIVE DECISION DOMAIN RULES:",
|
||||
"• ONLY recommend real venues from the search results below. Do NOT invent names.",
|
||||
"• Budget ceiling = the LOWER of both parties' budgets.",
|
||||
"• Both parties' dietary restrictions are absolute (hard constraints).",
|
||||
"• Aim for cuisine intersection first; if no overlap, find a multi-cuisine option.",
|
||||
"",
|
||||
f"Current decision type: {decision_type}",
|
||||
f"Location: {location}",
|
||||
]
|
||||
if cuisine_a:
|
||||
lines.append(f"Person A prefers: {cuisine_a}")
|
||||
if cuisine_b:
|
||||
lines.append(f"Person B prefers: {cuisine_b}")
|
||||
if budget_a:
|
||||
lines.append(f"Person A budget: ₹{budget_a}/person")
|
||||
if budget_b:
|
||||
lines.append(f"Person B budget: ₹{budget_b}/person")
|
||||
|
||||
if tavily_text:
|
||||
lines.append(f"\nREAL OPTIONS from web search (use these):\n{tavily_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"⚠️ *Joint Decision — Your Input Needed*\n\n"
|
||||
f"_{summary}_\n\n"
|
||||
f"Agents proposed options but couldn't finalize. "
|
||||
f"Please pick from the options above."
|
||||
)
|
||||
|
||||
venue = (
|
||||
details.get("venue") or details.get("restaurant") or details.get("place")
|
||||
or details.get("recommendation") or final.get("summary", "")
|
||||
)
|
||||
cuisine = details.get("cuisine") or details.get("food_type") or ""
|
||||
price = details.get("price_range") or details.get("budget") or ""
|
||||
why = details.get("reason") or details.get("why") or summary
|
||||
alternatives = details.get("alternatives") or []
|
||||
|
||||
lines = ["🍽 *Decision Made!*\n"]
|
||||
if venue:
|
||||
venue_str = str(venue) if not isinstance(venue, str) else venue
|
||||
maps_url = f"https://maps.google.com/?q={_url_quote(venue_str)}"
|
||||
lines.append(f"📍 *Recommendation:* [{venue_str}]({maps_url})")
|
||||
if cuisine:
|
||||
lines.append(f"🍴 *Cuisine:* {cuisine}")
|
||||
if price:
|
||||
lines.append(f"💰 *Price range:* {price}")
|
||||
if why:
|
||||
lines.append(f"\n💬 _{why}_")
|
||||
if alternatives and isinstance(alternatives, list):
|
||||
alt_text = ", ".join(str(a) for a in alternatives[:2])
|
||||
lines.append(f"\n_Alternatives considered: {alt_text}_")
|
||||
lines.append(f"\n⏱ Decided in {rounds} round(s)")
|
||||
|
||||
return "\n".join(lines)
|
||||
104
negot8/backend/features/conflict.py
Normal file
104
negot8/backend/features/conflict.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from features.base_feature import BaseFeature
|
||||
|
||||
|
||||
class ConflictFeature(BaseFeature):
|
||||
|
||||
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
|
||||
"""
|
||||
Pure negotiation — no external tool calls needed.
|
||||
Inject mediation principles and relationship context.
|
||||
"""
|
||||
raw_a = preferences_a.get("raw_details", {})
|
||||
raw_b = preferences_b.get("raw_details", {})
|
||||
|
||||
conflict_type = (
|
||||
raw_a.get("conflict_type") or raw_b.get("conflict_type") or "general dispute"
|
||||
)
|
||||
relationship_importance = (
|
||||
raw_a.get("relationship_importance") or raw_b.get("relationship_importance") or "medium"
|
||||
)
|
||||
position_a = raw_a.get("position") or preferences_a.get("goal", "")
|
||||
position_b = raw_b.get("position") or preferences_b.get("goal", "")
|
||||
|
||||
# Concession speed based on relationship importance
|
||||
concession_note = ""
|
||||
if str(relationship_importance).lower() == "high":
|
||||
concession_note = (
|
||||
"⚠️ relationship_importance=HIGH: Both agents should be MORE concessive. "
|
||||
"Preserving the relationship is MORE important than winning every point. "
|
||||
"Accept at satisfaction >= 55 (not the usual 70)."
|
||||
)
|
||||
elif str(relationship_importance).lower() == "low":
|
||||
concession_note = "relationship_importance=LOW: Negotiate firmly on merits."
|
||||
|
||||
lines = [
|
||||
"CONFLICT RESOLUTION DOMAIN RULES:",
|
||||
"• Focus on UNDERLYING INTERESTS, not stated positions.",
|
||||
"• Creative compromise > splitting the difference mechanically.",
|
||||
"• Include a review/adjustment mechanism (e.g., trial period, revisit in 2 weeks).",
|
||||
"• NEVER make personal attacks or bring up unrelated past issues.",
|
||||
"• Propose solutions that both parties can say 'yes' to, even if not their first choice.",
|
||||
"• Frame resolutions as shared agreements, not winners and losers.",
|
||||
]
|
||||
if concession_note:
|
||||
lines.append(f"\n{concession_note}")
|
||||
if conflict_type:
|
||||
lines.append(f"\nConflict type: {conflict_type}")
|
||||
if position_a:
|
||||
lines.append(f"Person A's stated position: {position_a}")
|
||||
if position_b:
|
||||
lines.append(f"Person B's stated position: {position_b}")
|
||||
if relationship_importance:
|
||||
lines.append(f"Relationship importance: {relationship_importance}")
|
||||
|
||||
lines.append("\nAsk yourself: What does each person ACTUALLY need (not just what they said)?")
|
||||
lines.append("Propose something that addresses both underlying needs.")
|
||||
|
||||
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", "")
|
||||
|
||||
raw_a = preferences_a.get("raw_details", {})
|
||||
raw_b = preferences_b.get("raw_details", {})
|
||||
conflict_type = raw_a.get("conflict_type") or raw_b.get("conflict_type") or "Conflict"
|
||||
|
||||
if status == "escalated":
|
||||
return (
|
||||
f"⚠️ *{conflict_type.title()} — Mediation Needed*\n\n"
|
||||
f"_{summary}_\n\n"
|
||||
f"Agents couldn't find a mutually agreeable resolution in {rounds} round(s). "
|
||||
f"Consider a neutral third-party mediator."
|
||||
)
|
||||
|
||||
resolution_type = details.get("resolution_type") or details.get("type") or "compromise"
|
||||
terms = details.get("terms") or details.get("agreement") or []
|
||||
review_mechanism = details.get("review_mechanism") or details.get("review") or ""
|
||||
for_a = final.get("for_party_a") or details.get("for_a") or ""
|
||||
for_b = final.get("for_party_b") or details.get("for_b") or ""
|
||||
|
||||
lines = [f"⚖️ *{conflict_type.title()} — Resolved!*\n"]
|
||||
lines.append(f"🤝 *Resolution type:* {resolution_type}")
|
||||
if for_a:
|
||||
lines.append(f"\n👤 *Person A gets:* {for_a}")
|
||||
if for_b:
|
||||
lines.append(f"👤 *Person B gets:* {for_b}")
|
||||
if terms and isinstance(terms, list):
|
||||
lines.append("\n📋 *Agreed terms:*")
|
||||
for term in terms[:5]:
|
||||
lines.append(f" • {term}")
|
||||
elif terms:
|
||||
lines.append(f"📋 *Terms:* {terms}")
|
||||
if review_mechanism:
|
||||
lines.append(f"\n🔄 *Review:* {review_mechanism}")
|
||||
lines.append(f"\n⏱ Resolved in {rounds} round(s)")
|
||||
if summary and summary != "Agreement reached":
|
||||
lines.append(f"_{summary}_")
|
||||
|
||||
return "\n".join(lines)
|
||||
114
negot8/backend/features/expenses.py
Normal file
114
negot8/backend/features/expenses.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from features.base_feature import BaseFeature
|
||||
from tools.calculator import CalculatorTool
|
||||
|
||||
_calc = CalculatorTool()
|
||||
|
||||
|
||||
class ExpensesFeature(BaseFeature):
|
||||
|
||||
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
|
||||
"""
|
||||
Pre-calculate expense totals using the safe Calculator tool.
|
||||
Inject exact figures so the LLM never does arithmetic.
|
||||
"""
|
||||
raw_a = preferences_a.get("raw_details", {})
|
||||
raw_b = preferences_b.get("raw_details", {})
|
||||
|
||||
# Collect line items from both parties
|
||||
items = {}
|
||||
for raw in (raw_a, raw_b):
|
||||
expenses = raw.get("expenses") or raw.get("items") or raw.get("line_items") or []
|
||||
if isinstance(expenses, list):
|
||||
for item in expenses:
|
||||
if isinstance(item, dict):
|
||||
name = item.get("name") or item.get("item") or "item"
|
||||
amount = item.get("amount") or item.get("cost") or item.get("price") or 0
|
||||
try:
|
||||
amount = float(amount)
|
||||
except (TypeError, ValueError):
|
||||
amount = 0
|
||||
if amount > 0:
|
||||
items[name] = items.get(name, 0) + amount
|
||||
|
||||
lines = ["EXPENSE SPLITTING DOMAIN RULES:"]
|
||||
lines.append("• Use ONLY the pre-calculated amounts below. NEVER estimate or round differently.")
|
||||
lines.append("• Equal splits (50-50) are the default. Unequal splits need explicit justification.")
|
||||
lines.append("• After reaching agreement, include a 'settlement' key with who pays whom and how much.")
|
||||
lines.append("• Use the calculator results below — do NOT re-calculate with different numbers.")
|
||||
|
||||
if items:
|
||||
total = sum(items.values())
|
||||
lines.append(f"\nLine items (pre-verified by Calculator tool):")
|
||||
for name, amount in items.items():
|
||||
half = amount / 2
|
||||
lines.append(f" • {name}: ₹{amount:,.0f} → 50-50 split = ₹{half:,.2f} each")
|
||||
lines.append(f"\nTotal: ₹{total:,.0f} → 50-50 = ₹{total/2:,.2f} each")
|
||||
else:
|
||||
lines.append("\nNo line items found — extract amounts from the preferences and calculate fair splits.")
|
||||
|
||||
# UPI info
|
||||
upi_a = raw_a.get("upi_id") or raw_a.get("upi")
|
||||
upi_b = raw_b.get("upi_id") or raw_b.get("upi")
|
||||
if upi_a:
|
||||
lines.append(f"\nParty A UPI ID: {upi_a}")
|
||||
if upi_b:
|
||||
lines.append(f"\nParty B UPI ID: {upi_b}")
|
||||
if upi_a or upi_b:
|
||||
lines.append("Include the relevant UPI ID in the settlement details of your proposal.")
|
||||
|
||||
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", "Agreement reached")
|
||||
|
||||
if status == "escalated":
|
||||
return (
|
||||
f"⚠️ *Expenses — Human Review Needed*\n\n"
|
||||
f"_{summary}_\n\n"
|
||||
f"Agents couldn't fully agree in {rounds} round(s). "
|
||||
f"Please review the proposed split above."
|
||||
)
|
||||
|
||||
# Build breakdown table
|
||||
line_items = details.get("line_items") or details.get("items") or []
|
||||
raw_settlement = details.get("settlement") or {}
|
||||
# Guard: settlement may be a string summary instead of a dict
|
||||
settlement = raw_settlement if isinstance(raw_settlement, dict) else {}
|
||||
payer = settlement.get("payer") or settlement.get("from") or ""
|
||||
payee = settlement.get("payee") or settlement.get("to") or ""
|
||||
amount = (settlement.get("amount") or details.get("amount")
|
||||
or details.get("total_owed") or (str(raw_settlement) if isinstance(raw_settlement, str) else ""))
|
||||
|
||||
lines = ["💰 *Expenses Settled!*\n"]
|
||||
|
||||
if line_items and isinstance(line_items, list):
|
||||
lines.append("📊 *Breakdown:*")
|
||||
for item in line_items:
|
||||
if isinstance(item, dict):
|
||||
name = item.get("name") or item.get("item", "Item")
|
||||
cost = item.get("amount") or item.get("cost") or ""
|
||||
split = item.get("split") or item.get("ratio") or "50-50"
|
||||
a_pays = item.get("party_a") or item.get("a_pays") or ""
|
||||
b_pays = item.get("party_b") or item.get("b_pays") or ""
|
||||
if a_pays and b_pays:
|
||||
lines.append(f" • {name} (₹{cost}) — {split} → A: ₹{a_pays} / B: ₹{b_pays}")
|
||||
else:
|
||||
lines.append(f" • {name}: {split} split")
|
||||
lines.append("")
|
||||
|
||||
if payer and amount:
|
||||
lines.append(f"💸 *{payer} owes {payee}: ₹{amount}*")
|
||||
elif amount:
|
||||
lines.append(f"💸 *Settlement amount: ₹{amount}*")
|
||||
|
||||
lines.append(f"\n⏱ Agreed in {rounds} round(s)")
|
||||
if summary and summary != "Agreement reached":
|
||||
lines.append(f"_{summary}_")
|
||||
|
||||
return "\n".join(lines)
|
||||
142
negot8/backend/features/freelance.py
Normal file
142
negot8/backend/features/freelance.py
Normal file
@@ -0,0 +1,142 @@
|
||||
from features.base_feature import BaseFeature
|
||||
from tools.tavily_search import TavilySearchTool
|
||||
from tools.calculator import CalculatorTool
|
||||
|
||||
_tavily = TavilySearchTool()
|
||||
_calc = CalculatorTool()
|
||||
|
||||
|
||||
class FreelanceFeature(BaseFeature):
|
||||
|
||||
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
|
||||
"""
|
||||
Benchmark market rates via Tavily. Pre-calculate rate × hours
|
||||
and detect if budget is insufficient (forcing scope reduction).
|
||||
"""
|
||||
raw_a = preferences_a.get("raw_details", {})
|
||||
raw_b = preferences_b.get("raw_details", {})
|
||||
|
||||
# Identify freelancer vs client
|
||||
role_a = raw_a.get("role", preferences_a.get("goal", ""))
|
||||
if "client" in str(role_a).lower():
|
||||
freelancer_raw, client_raw = raw_b, raw_a
|
||||
else:
|
||||
freelancer_raw, client_raw = raw_a, raw_b
|
||||
|
||||
skill = (
|
||||
freelancer_raw.get("skill") or freelancer_raw.get("expertise")
|
||||
or freelancer_raw.get("tech_stack") or client_raw.get("project_type")
|
||||
or "software development"
|
||||
)
|
||||
rate = freelancer_raw.get("rate") or freelancer_raw.get("hourly_rate") or ""
|
||||
hours = freelancer_raw.get("hours") or freelancer_raw.get("estimated_hours") or ""
|
||||
client_budget = client_raw.get("budget") or client_raw.get("max_budget") or ""
|
||||
upfront_min = freelancer_raw.get("upfront_minimum") or freelancer_raw.get("upfront") or "50"
|
||||
scope = client_raw.get("required_features") or client_raw.get("scope") or []
|
||||
|
||||
# Pre-calculate rate × hours
|
||||
calc_text = ""
|
||||
if rate and hours:
|
||||
try:
|
||||
total_cost = float(str(rate).replace(",", "")) * float(str(hours).replace(",", ""))
|
||||
calc_text = f"Pre-calculated cost: ₹{rate}/hr × {hours} hrs = ₹{total_cost:,.0f}"
|
||||
if client_budget:
|
||||
budget_float = float(str(client_budget).replace(",", ""))
|
||||
if total_cost > budget_float:
|
||||
affordable_hours = budget_float / float(str(rate).replace(",", ""))
|
||||
calc_text += (
|
||||
f"\n⚠️ Budget shortfall: ₹{client_budget} budget covers only "
|
||||
f"{affordable_hours:.1f} hrs at ₹{rate}/hr. "
|
||||
f"Reduce scope to fit, removing nice-to-haves first."
|
||||
)
|
||||
else:
|
||||
calc_text += f"\n✅ Budget ₹{client_budget} is sufficient."
|
||||
except (ValueError, TypeError):
|
||||
calc_text = f"Rate: ₹{rate}/hr, Estimated hours: {hours}"
|
||||
|
||||
# Market rate benchmark
|
||||
market_text = ""
|
||||
try:
|
||||
query = f"average freelance rate {skill} developer India 2026"
|
||||
result = await _tavily.execute(query)
|
||||
answer = result.get("answer", "")
|
||||
results = result.get("results", [])[:2]
|
||||
parts = []
|
||||
if answer:
|
||||
parts.append(f"Market summary: {answer[:250]}")
|
||||
for r in results:
|
||||
content = r.get("content", "")[:100]
|
||||
title = r.get("title", "")
|
||||
if title:
|
||||
parts.append(f" • {title}: {content}")
|
||||
market_text = "\n".join(parts)
|
||||
except Exception as e:
|
||||
market_text = f"Market search unavailable. Use typical India rates for {skill}."
|
||||
|
||||
lines = [
|
||||
"FREELANCE NEGOTIATION DOMAIN RULES:",
|
||||
"• Budget is a hard constraint for the client — NEVER exceed it.",
|
||||
"• Freelancer's minimum rate is a hard constraint — NEVER go below it.",
|
||||
"• Non-negotiables (IP ownership, upfront minimum) are absolute hard constraints.",
|
||||
"• If budget < full scope cost: reduce scope (nice-to-haves first, then by priority).",
|
||||
"• Payment terms: freelancer pushes for more upfront, client for back-loaded.",
|
||||
"• Scope reduction must preserve the client's core 'must-have' features.",
|
||||
"• After agreement, include UPI ID and first milestone amount in settlement.",
|
||||
]
|
||||
if skill:
|
||||
lines.append(f"\nProject skill/type: {skill}")
|
||||
if calc_text:
|
||||
lines.append(f"\n{calc_text}")
|
||||
if upfront_min:
|
||||
lines.append(f"Freelancer's minimum upfront: {upfront_min}%")
|
||||
if scope and isinstance(scope, list):
|
||||
lines.append(f"Client's required features: {', '.join(str(s) for s in scope[:5])}")
|
||||
if market_text:
|
||||
lines.append(f"\nMARKET RATE DATA (cite this):\n{market_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"⚠️ *Project Deal — Human Review Needed*\n\n"
|
||||
f"_{summary}_\n\n"
|
||||
f"Agents couldn't finalize in {rounds} round(s). "
|
||||
f"Please negotiate scope/budget directly."
|
||||
)
|
||||
|
||||
budget = details.get("budget") or details.get("agreed_budget") or details.get("price") or ""
|
||||
timeline = details.get("timeline") or details.get("duration") or ""
|
||||
scope = details.get("scope") or details.get("deliverables") or []
|
||||
payment_schedule = details.get("payment_schedule") or details.get("payments") or ""
|
||||
milestone_1 = details.get("milestone_1") or details.get("upfront") or ""
|
||||
settlement = details.get("settlement") or {}
|
||||
|
||||
lines = ["💼 *Project Deal Agreed!*\n"]
|
||||
if budget:
|
||||
lines.append(f"💰 *Budget:* ₹{budget}")
|
||||
if timeline:
|
||||
lines.append(f"📅 *Timeline:* {timeline}")
|
||||
if scope and isinstance(scope, list):
|
||||
lines.append(f"📋 *Scope:*")
|
||||
for item in scope[:5]:
|
||||
lines.append(f" ✓ {item}")
|
||||
elif scope:
|
||||
lines.append(f"📋 *Scope:* {scope}")
|
||||
if payment_schedule:
|
||||
lines.append(f"💳 *Payment schedule:* {payment_schedule}")
|
||||
elif milestone_1:
|
||||
lines.append(f"💳 *First milestone payment:* ₹{milestone_1}")
|
||||
lines.append(f"\n⏱ Agreed in {rounds} round(s)")
|
||||
if summary and summary != "Agreement reached":
|
||||
lines.append(f"_{summary}_")
|
||||
|
||||
return "\n".join(lines)
|
||||
62
negot8/backend/features/generic.py
Normal file
62
negot8/backend/features/generic.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from features.base_feature import BaseFeature
|
||||
|
||||
|
||||
class GenericFeature(BaseFeature):
|
||||
"""Fallback feature for any coordination type not matched by the 8 specific features."""
|
||||
|
||||
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
|
||||
goal_a = preferences_a.get("goal", "")
|
||||
goal_b = preferences_b.get("goal", "")
|
||||
|
||||
lines = [
|
||||
"GENERIC COORDINATION RULES:",
|
||||
"• Find the solution that satisfies both parties' stated goals and hard constraints.",
|
||||
"• Be creative — there may be a win-win that isn't obvious from the positions stated.",
|
||||
"• Concede on nice-to-haves first, protect hard constraints at all costs.",
|
||||
"• If completely stuck, propose 2-3 concrete options for humans to choose from.",
|
||||
]
|
||||
if goal_a:
|
||||
lines.append(f"\nPerson A's goal: {goal_a}")
|
||||
if goal_b:
|
||||
lines.append(f"Person B's goal: {goal_b}")
|
||||
|
||||
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", "Agreement reached")
|
||||
|
||||
if status == "escalated":
|
||||
return (
|
||||
f"⚠️ *Coordination — Human Decision Needed*\n\n"
|
||||
f"_{summary}_\n\n"
|
||||
f"Agents explored options but couldn't decide in {rounds} round(s)."
|
||||
)
|
||||
|
||||
for_a = final.get("for_party_a") or details.get("for_a") or ""
|
||||
for_b = final.get("for_party_b") or details.get("for_b") or ""
|
||||
|
||||
lines = ["✅ *Agreement Reached!*\n"]
|
||||
lines.append(f"_{summary}_")
|
||||
if for_a:
|
||||
lines.append(f"\n👤 *For you:* {for_a}")
|
||||
if for_b:
|
||||
lines.append(f"👤 *For them:* {for_b}")
|
||||
|
||||
# Show key details generically
|
||||
filtered = {
|
||||
k: v for k, v in details.items()
|
||||
if k not in ("for_a", "for_b") and v
|
||||
}
|
||||
if filtered:
|
||||
lines.append("\n📋 *Details:*")
|
||||
for k, v in list(filtered.items())[:6]:
|
||||
lines.append(f" • {k.replace('_', ' ').title()}: {v}")
|
||||
|
||||
lines.append(f"\n⏱ Agreed in {rounds} round(s)")
|
||||
return "\n".join(lines)
|
||||
119
negot8/backend/features/marketplace.py
Normal file
119
negot8/backend/features/marketplace.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from features.base_feature import BaseFeature
|
||||
from tools.tavily_search import TavilySearchTool
|
||||
|
||||
_tavily = TavilySearchTool()
|
||||
|
||||
|
||||
class MarketplaceFeature(BaseFeature):
|
||||
|
||||
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
|
||||
"""
|
||||
Fetch real market prices via Tavily so agents negotiate around
|
||||
actual reference prices, not guesses.
|
||||
"""
|
||||
raw_a = preferences_a.get("raw_details", {})
|
||||
raw_b = preferences_b.get("raw_details", {})
|
||||
|
||||
item = (
|
||||
raw_a.get("item") or raw_b.get("item")
|
||||
or preferences_a.get("goal", "item")[:60]
|
||||
)
|
||||
seller_min = raw_a.get("minimum_price") or raw_a.get("min_price") or raw_a.get("asking_price") or ""
|
||||
seller_asking = raw_a.get("asking_price") or raw_a.get("price") or ""
|
||||
buyer_max = raw_b.get("maximum_budget") or raw_b.get("max_budget") or raw_b.get("budget") or ""
|
||||
buyer_offer = raw_b.get("offer_price") or raw_b.get("price") or ""
|
||||
|
||||
# Flip if B is selling
|
||||
role_a = raw_a.get("role", "")
|
||||
role_b = raw_b.get("role", "")
|
||||
if role_b == "seller":
|
||||
seller_min = raw_b.get("minimum_price") or raw_b.get("min_price") or ""
|
||||
seller_asking = raw_b.get("asking_price") or raw_b.get("price") or ""
|
||||
buyer_max = raw_a.get("maximum_budget") or raw_a.get("max_budget") or raw_a.get("budget") or ""
|
||||
buyer_offer = raw_a.get("offer_price") or raw_a.get("price") or ""
|
||||
|
||||
market_text = ""
|
||||
try:
|
||||
query = f"{item} used price India 2026"
|
||||
result = await _tavily.execute(query)
|
||||
answer = result.get("answer", "")
|
||||
results = result.get("results", [])[:3]
|
||||
parts = []
|
||||
if answer:
|
||||
parts.append(f"Market summary: {answer[:300]}")
|
||||
for r in results:
|
||||
title = r.get("title", "")
|
||||
content = r.get("content", "")[:120]
|
||||
if title:
|
||||
parts.append(f" • {title}: {content}")
|
||||
market_text = "\n".join(parts)
|
||||
except Exception as e:
|
||||
market_text = f"Market search unavailable ({e}). Use your knowledge of {item} pricing."
|
||||
|
||||
lines = [
|
||||
"MARKETPLACE NEGOTIATION DOMAIN RULES:",
|
||||
"• Seller must NOT go below their minimum price (hard constraint).",
|
||||
"• Buyer must NOT exceed their maximum budget (hard constraint).",
|
||||
"• Classic anchoring: seller starts at asking price, buyer starts with lower offer.",
|
||||
"• Concede in diminishing increments (e.g., ₹3K, ₹2K, ₹1K).",
|
||||
"• Delivery/pickup can be offered as a non-cash concession worth ₹500-1000.",
|
||||
"• If gap > 20% after 3 rounds, propose splitting the difference or escalate.",
|
||||
"• Cite the market price from the data below to justify your position.",
|
||||
]
|
||||
if item:
|
||||
lines.append(f"\nItem being traded: {item}")
|
||||
if seller_asking:
|
||||
lines.append(f"Seller asking: ₹{seller_asking}")
|
||||
if seller_min:
|
||||
lines.append(f"Seller minimum (hard floor): ₹{seller_min}")
|
||||
if buyer_max:
|
||||
lines.append(f"Buyer maximum budget (hard ceiling): ₹{buyer_max}")
|
||||
if buyer_offer:
|
||||
lines.append(f"Buyer's opening offer: ₹{buyer_offer}")
|
||||
if market_text:
|
||||
lines.append(f"\nMARKET PRICE DATA (cite this):\n{market_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", "")
|
||||
|
||||
raw_a = preferences_a.get("raw_details", {})
|
||||
raw_b = preferences_b.get("raw_details", {})
|
||||
item = raw_a.get("item") or raw_b.get("item") or "Item"
|
||||
|
||||
if status == "escalated":
|
||||
return (
|
||||
f"⚠️ *{item} Deal — Human Decision Needed*\n\n"
|
||||
f"_{summary}_\n\n"
|
||||
f"Agents couldn't bridge the price gap in {rounds} round(s). "
|
||||
f"Please negotiate directly."
|
||||
)
|
||||
|
||||
agreed_price = (
|
||||
details.get("agreed_price") or details.get("price")
|
||||
or details.get("final_price") or details.get("amount")
|
||||
or final.get("summary", "")
|
||||
)
|
||||
delivery = details.get("delivery") or details.get("handover") or ""
|
||||
market_ref = details.get("market_price") or details.get("market_reference") or ""
|
||||
|
||||
lines = [f"🛒 *Deal Closed!*\n"]
|
||||
lines.append(f"📦 *Item:* {item}")
|
||||
if agreed_price:
|
||||
lines.append(f"💰 *Agreed price:* ₹{agreed_price}")
|
||||
if delivery:
|
||||
lines.append(f"🚚 *Delivery/Handover:* {delivery}")
|
||||
if market_ref:
|
||||
lines.append(f"📊 *Market reference:* ₹{market_ref}")
|
||||
lines.append(f"\n⏱ Deal closed in {rounds} round(s)")
|
||||
if summary and summary != "Agreement reached":
|
||||
lines.append(f"_{summary}_")
|
||||
|
||||
return "\n".join(lines)
|
||||
123
negot8/backend/features/roommate.py
Normal file
123
negot8/backend/features/roommate.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from features.base_feature import BaseFeature
|
||||
from tools.tavily_search import TavilySearchTool
|
||||
|
||||
_tavily = TavilySearchTool()
|
||||
|
||||
|
||||
class RoommateFeature(BaseFeature):
|
||||
|
||||
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
|
||||
"""
|
||||
Fetch real product/plan options via Tavily (e.g., actual WiFi plans,
|
||||
furniture prices) so agents propose real options.
|
||||
"""
|
||||
raw_a = preferences_a.get("raw_details", {})
|
||||
raw_b = preferences_b.get("raw_details", {})
|
||||
|
||||
decision_type = (
|
||||
raw_a.get("decision_type") or raw_b.get("decision_type")
|
||||
or "shared living decision"
|
||||
)
|
||||
city = (
|
||||
raw_a.get("city") or raw_b.get("city")
|
||||
or raw_a.get("location") or raw_b.get("location")
|
||||
or "India"
|
||||
)
|
||||
budget_a = raw_a.get("budget") or raw_a.get("max_budget") or ""
|
||||
budget_b = raw_b.get("budget") or raw_b.get("max_budget") or ""
|
||||
|
||||
# Build a Tavily query based on decision type
|
||||
if "wifi" in str(decision_type).lower() or "internet" in str(decision_type).lower():
|
||||
query = f"best WiFi broadband plans {city} 2026 price speed"
|
||||
elif "furniture" in str(decision_type).lower():
|
||||
query = f"furniture prices India 2026 online shopping"
|
||||
elif "chore" in str(decision_type).lower() or "cleaning" in str(decision_type).lower():
|
||||
query = f"chore schedule roommates fair division strategies"
|
||||
else:
|
||||
query = f"{decision_type} options India 2026"
|
||||
|
||||
search_text = ""
|
||||
try:
|
||||
result = await _tavily.execute(query)
|
||||
answer = result.get("answer", "")
|
||||
results = result.get("results", [])[:4]
|
||||
parts = []
|
||||
if answer:
|
||||
parts.append(f"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 {decision_type} options in {city}."
|
||||
|
||||
lines = [
|
||||
"ROOMMATE DECISION DOMAIN RULES:",
|
||||
"• Only propose options (plans, products) that appear in the real data below.",
|
||||
"• Budget ceiling = lower of both parties' stated budgets.",
|
||||
"• Unequal cost splits need usage-based justification.",
|
||||
"• Both parties must stay within their stated budget constraints.",
|
||||
"• If no option satisfies both budgets, propose cheapest viable option + fair split.",
|
||||
]
|
||||
if decision_type:
|
||||
lines.append(f"\nDecision type: {decision_type}")
|
||||
if city:
|
||||
lines.append(f"Location: {city}")
|
||||
if budget_a:
|
||||
lines.append(f"Person A max budget: ₹{budget_a}/month")
|
||||
if budget_b:
|
||||
lines.append(f"Person B max budget: ₹{budget_b}/month")
|
||||
|
||||
if search_text:
|
||||
lines.append(f"\nREAL OPTIONS from web search (only propose from this list):\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", "")
|
||||
|
||||
raw_a = preferences_a.get("raw_details", {})
|
||||
raw_b = preferences_b.get("raw_details", {})
|
||||
decision_type = raw_a.get("decision_type") or raw_b.get("decision_type") or "Decision"
|
||||
|
||||
if status == "escalated":
|
||||
return (
|
||||
f"⚠️ *{decision_type.title()} — Human Decision Needed*\n\n"
|
||||
f"_{summary}_\n\n"
|
||||
f"Agents proposed options but couldn't finalize in {rounds} round(s)."
|
||||
)
|
||||
|
||||
chosen = (
|
||||
details.get("chosen_option") or details.get("plan") or details.get("option")
|
||||
or details.get("decision") or final.get("summary", "")
|
||||
)
|
||||
cost = details.get("monthly_cost") or details.get("cost") or details.get("price") or ""
|
||||
split = details.get("split") or details.get("each_pays") or ""
|
||||
rules = details.get("rules") or details.get("terms") or []
|
||||
|
||||
lines = [f"🏠 *{decision_type.title()} — Decision Made!*\n"]
|
||||
if chosen:
|
||||
lines.append(f"✅ *Choice:* {chosen}")
|
||||
if cost:
|
||||
lines.append(f"💰 *Cost:* ₹{cost}/month")
|
||||
if split:
|
||||
lines.append(f"💳 *Each pays:* ₹{split}")
|
||||
if rules and isinstance(rules, list):
|
||||
lines.append("📋 *Agreed rules:*")
|
||||
for rule in rules[:4]:
|
||||
lines.append(f" • {rule}")
|
||||
elif rules:
|
||||
lines.append(f"📋 *Terms:* {rules}")
|
||||
lines.append(f"\n⏱ Decided in {rounds} round(s)")
|
||||
if summary and summary != "Agreement reached":
|
||||
lines.append(f"_{summary}_")
|
||||
|
||||
return "\n".join(lines)
|
||||
148
negot8/backend/features/scheduling.py
Normal file
148
negot8/backend/features/scheduling.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from features.base_feature import BaseFeature
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote as _url_quote
|
||||
|
||||
|
||||
class SchedulingFeature(BaseFeature):
|
||||
|
||||
async def get_context(
|
||||
self,
|
||||
preferences_a: dict,
|
||||
preferences_b: dict,
|
||||
user_a_id: int = None,
|
||||
user_b_id: int = None,
|
||||
) -> str:
|
||||
"""
|
||||
Compute overlapping time windows. If a user hasn't provided any times
|
||||
in their message AND they have Google Calendar connected, automatically
|
||||
fetch their free slots from the calendar instead of leaving it empty.
|
||||
"""
|
||||
windows_a = self._extract_windows(preferences_a)
|
||||
windows_b = self._extract_windows(preferences_b)
|
||||
|
||||
# ── Google Calendar fallback: fetch free slots when no times given ──
|
||||
if not windows_a and user_a_id:
|
||||
windows_a = await self._fetch_calendar_slots(user_a_id, tag="A")
|
||||
if not windows_b and user_b_id:
|
||||
windows_b = await self._fetch_calendar_slots(user_b_id, tag="B")
|
||||
|
||||
overlap_lines = []
|
||||
if windows_a and windows_b:
|
||||
for wa in windows_a:
|
||||
for wb in windows_b:
|
||||
if wa.lower() == wb.lower():
|
||||
overlap_lines.append(f" • {wa}")
|
||||
# Simple keyword matching for day/time overlap
|
||||
elif any(
|
||||
word in wa.lower() for word in wb.lower().split()
|
||||
if len(word) > 3
|
||||
):
|
||||
overlap_lines.append(f" • {wa} (aligns with {wb})")
|
||||
|
||||
location_a = preferences_a.get("raw_details", {}).get("location", "")
|
||||
location_b = preferences_b.get("raw_details", {}).get("location", "")
|
||||
|
||||
lines = ["SCHEDULING DOMAIN RULES:"]
|
||||
lines.append("• Only propose times that appear in BOTH parties' available windows.")
|
||||
lines.append("• Duration is non-negotiable — respect it.")
|
||||
lines.append("• If no overlap exists, escalate immediately with closest alternatives.")
|
||||
|
||||
if windows_a:
|
||||
lines.append(f"\nPerson A available: {', '.join(windows_a)}")
|
||||
if windows_b:
|
||||
lines.append(f"Person B available: {', '.join(windows_b)}")
|
||||
if overlap_lines:
|
||||
lines.append(f"\nDetected overlapping windows:\n" + "\n".join(overlap_lines))
|
||||
else:
|
||||
lines.append("\nNo clear overlap detected — propose closest alternatives and offer to adjust.")
|
||||
|
||||
if location_a or location_b:
|
||||
loc = location_a or location_b
|
||||
lines.append(f"\nMeeting location preference: {loc}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _fetch_calendar_slots(
|
||||
self, user_id: int, tag: str = ""
|
||||
) -> list[str]:
|
||||
"""
|
||||
Query Google Calendar for the user's free slots over the next 7 days.
|
||||
Returns a list of human-readable strings like
|
||||
"Mon Mar 2 10:00-11:00 AM". Returns [] silently on any error so
|
||||
the negotiation always continues even without calendar access.
|
||||
"""
|
||||
try:
|
||||
from tools.google_calendar import GoogleCalendarTool
|
||||
tool = GoogleCalendarTool()
|
||||
slots = await tool.get_free_slots(user_id)
|
||||
if slots:
|
||||
label = f" (from Google Calendar{' — Person ' + tag if tag else ''})"
|
||||
print(f"[Calendar] Fetched {len(slots)} free slots for user {user_id}")
|
||||
# Attach the source label only to the first entry for readability
|
||||
return [slots[0] + label] + slots[1:]
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"[Calendar] Could not fetch slots for user {user_id}: {e}")
|
||||
return []
|
||||
|
||||
def _extract_windows(self, preferences: dict) -> list:
|
||||
windows = (
|
||||
preferences.get("raw_details", {}).get("available_windows")
|
||||
or preferences.get("constraints", [])
|
||||
)
|
||||
if isinstance(windows, list):
|
||||
return [str(w) for w in windows]
|
||||
if isinstance(windows, str):
|
||||
return [windows]
|
||||
return []
|
||||
|
||||
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"⚠️ *Meeting — Human Decision Needed*\n\n"
|
||||
f"_{summary}_\n\n"
|
||||
f"Our agents couldn't find a perfect time in {rounds} round(s). "
|
||||
f"Please compare calendars directly."
|
||||
)
|
||||
|
||||
proposed_time = (
|
||||
details.get("proposed_datetime")
|
||||
or details.get("date_time")
|
||||
or details.get("time")
|
||||
or final.get("summary", "")
|
||||
)
|
||||
duration = details.get("duration") or preferences_a.get("raw_details", {}).get("duration", "")
|
||||
location = (
|
||||
details.get("location")
|
||||
or preferences_a.get("raw_details", {}).get("location")
|
||||
or preferences_b.get("raw_details", {}).get("location")
|
||||
or "TBD"
|
||||
)
|
||||
meeting_type = details.get("meeting_type") or details.get("type") or "Meeting"
|
||||
reasoning = resolution.get("summary", "")
|
||||
|
||||
lines = [
|
||||
"✅ *Meeting Scheduled!*\n",
|
||||
f"📅 *When:* {proposed_time}",
|
||||
]
|
||||
if duration:
|
||||
lines.append(f"⏱ *Duration:* {duration}")
|
||||
if location and location != "TBD":
|
||||
maps_url = f"https://maps.google.com/?q={_url_quote(str(location))}"
|
||||
lines.append(f"📍 *Location:* [{location}]({maps_url})")
|
||||
else:
|
||||
lines.append(f"📍 *Location:* {location}")
|
||||
lines.append(f"📋 *Type:* {meeting_type}")
|
||||
lines.append(f"\n⏱ Agreed in {rounds} round(s)")
|
||||
if reasoning and reasoning != "Agreement reached":
|
||||
lines.append(f"_{reasoning}_")
|
||||
|
||||
return "\n".join(lines)
|
||||
338
negot8/backend/features/trip.py
Normal file
338
negot8/backend/features/trip.py
Normal file
@@ -0,0 +1,338 @@
|
||||
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
|
||||
Reference in New Issue
Block a user