This commit is contained in:
2026-04-05 00:43:23 +05:30
commit 8be37d3e92
425 changed files with 101853 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
from features.base_feature import get_feature, BaseFeature
__all__ = ["get_feature", "BaseFeature"]

View 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()

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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