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

View File

@@ -0,0 +1,57 @@
from google import genai
from google.genai import types
import json
from config import GEMINI_API_KEY
_client = genai.Client(api_key=GEMINI_API_KEY)
_DEFAULT_MODEL = "gemini-3-flash-preview"
class BaseAgent:
def __init__(self, system_prompt: str, model_name: str = _DEFAULT_MODEL):
self.system_prompt = system_prompt
self.model_name = model_name
async def call(self, user_prompt: str, context: dict = None) -> dict:
"""Call Gemini asynchronously and always return a dict."""
full_prompt = user_prompt
if context:
full_prompt = f"CONTEXT:\n{json.dumps(context, indent=2)}\n\nTASK:\n{user_prompt}"
try:
response = await _client.aio.models.generate_content(
model=self.model_name,
contents=full_prompt,
config=types.GenerateContentConfig(
system_instruction=self.system_prompt,
response_mime_type="application/json",
temperature=0.7,
),
)
text = response.text.strip()
# Fast path: response is already valid JSON
try:
result = json.loads(text)
if isinstance(result, dict):
return result
return {"error": "Gemini returned non-dict JSON", "raw": text[:200]}
except json.JSONDecodeError:
pass
# Fallback: extract the first {...} block from the text
start = text.find("{")
end = text.rfind("}") + 1
if start != -1 and end > start:
try:
result = json.loads(text[start:end])
if isinstance(result, dict):
return result
except json.JSONDecodeError:
pass
return {"error": "Could not parse JSON from Gemini response", "raw": text[:500]}
except Exception as e:
print(f"[BaseAgent] Gemini call failed: {e}")
return {"error": str(e)}

View File

@@ -0,0 +1,111 @@
"""
backend/agents/matching_agent.py
Scores how well an applicant's preferences match an open contract's
requirements using a single Gemini call. Returns a 0-100 score and
a short explanation — used to rank applicants before the poster picks one.
"""
import json
from agents.base_agent import BaseAgent
_MATCHING_PROMPT = """You are a matching agent. Your job is to score how well an applicant's
preferences align with an open contract's requirements.
Contract type: {contract_type}
CONTRACT REQUIREMENTS (what the poster needs):
{requirements}
APPLICANT PREFERENCES (what the applicant brings / wants):
{preferences}
Evaluate on these axes:
1. Core match — does the applicant fundamentally fit what the contract needs?
2. Budget/rate alignment — are financial expectations compatible?
3. Timeline/availability — do schedules overlap?
4. Skills/criteria — do they have what is required?
5. Flexibility potential — could a negotiation bridge remaining gaps?
RESPOND ONLY with valid JSON in this exact format:
{{
"match_score": <integer 0-100>,
"match_reasoning": "<one concise sentence explaining the score>",
"key_alignments": ["<thing 1 that matches well>", "<thing 2>"],
"key_gaps": ["<gap 1>", "<gap 2>"]
}}
Scoring guide:
90-100 → Nearly perfect fit, minimal negotiation needed
70-89 → Good fit, small gaps bridgeable in negotiation
50-69 → Moderate fit, notable differences but workable
30-49 → Weak fit, significant gaps — negotiation will be hard
0-29 → Poor fit, fundamental incompatibility
"""
class MatchingAgent(BaseAgent):
def __init__(self):
super().__init__(system_prompt="You are a concise JSON-only matching agent.")
async def score_applicant(
self,
contract_requirements: dict,
applicant_preferences: dict,
contract_type: str,
) -> dict:
"""
Score how well an applicant matches a contract.
Returns:
{
"match_score": int (0-100),
"match_reasoning": str,
"key_alignments": list[str],
"key_gaps": list[str],
}
On failure returns a safe default with score=0.
"""
prompt = _MATCHING_PROMPT.format(
contract_type=contract_type,
requirements=json.dumps(contract_requirements, indent=2),
preferences=json.dumps(applicant_preferences, indent=2),
)
try:
# self.call() returns a dict — BaseAgent already handles JSON parsing
result = await self.call(prompt)
if "error" in result:
raise ValueError(result["error"])
# Clamp score to 0-100
result["match_score"] = max(0, min(100, int(result.get("match_score", 0))))
return result
except Exception as e:
print(f"[MatchingAgent] score_applicant failed: {e}")
return {
"match_score": 0,
"match_reasoning": "Could not compute score.",
"key_alignments": [],
"key_gaps": [],
}
# Module-level singleton
_agent = None
def get_matching_agent() -> MatchingAgent:
global _agent
if _agent is None:
_agent = MatchingAgent()
return _agent
async def score_applicant(
contract_requirements: dict,
applicant_preferences: dict,
contract_type: str,
) -> dict:
"""Convenience wrapper — use the module-level singleton."""
return await get_matching_agent().score_applicant(
contract_requirements, applicant_preferences, contract_type
)

View File

@@ -0,0 +1,125 @@
import asyncio
import json
from agents.negotiator_agent import NegotiatorAgent
import database as db
async def run_negotiation(negotiation_id: str, preferences_a: dict, preferences_b: dict,
user_a_id: int, user_b_id: int, feature_type: str,
personality_a: str = "balanced", personality_b: str = "balanced",
on_round_update=None, on_resolution=None,
feature_context: str = ""):
"""
Main negotiation loop with personality-aware agents and analytics tracking.
"""
await db.update_negotiation_status(negotiation_id, "active")
# Create personality-aware negotiators
negotiator_a = NegotiatorAgent(personality=personality_a)
negotiator_b = NegotiatorAgent(personality=personality_b)
current_proposal = None
max_rounds = 7
satisfaction_timeline = []
for round_num in range(1, max_rounds + 1):
await asyncio.sleep(1.5) # Rate limit protection for Gemini
if round_num == 1:
response = await negotiator_a.generate_initial_proposal(
my_preferences=preferences_a, feature_type=feature_type,
feature_context=feature_context
)
proposer_id = user_a_id
elif round_num % 2 == 0:
response = await negotiator_b.evaluate_and_respond(
received_proposal=current_proposal, my_preferences=preferences_b,
feature_type=feature_type, round_number=round_num,
feature_context=feature_context
)
proposer_id = user_b_id
else:
response = await negotiator_a.evaluate_and_respond(
received_proposal=current_proposal, my_preferences=preferences_a,
feature_type=feature_type, round_number=round_num,
feature_context=feature_context
)
proposer_id = user_a_id
# Handle errors
if "error" in response:
response = {
"action": "counter" if round_num < max_rounds else "escalate",
"proposal": current_proposal or {"summary": "Let's discuss further", "details": {}},
"satisfaction_score": 50, "reasoning": "Agent encountered an issue",
"concessions_made": [], "concessions_requested": []
}
action = response.get("action", "counter")
current_proposal = response.get("proposal", {})
satisfaction = response.get("satisfaction_score", 50)
concessions = response.get("concessions_made", [])
# Track satisfaction for analytics
# The proposer's score is the one returned; estimate the other party's
if proposer_id == user_a_id:
sat_a, sat_b = satisfaction, max(30, 100 - satisfaction * 0.4)
else:
sat_b, sat_a = satisfaction, max(30, 100 - satisfaction * 0.4)
satisfaction_timeline.append({
"round": round_num, "score_a": sat_a, "score_b": sat_b
})
# Save round with analytics data
await db.save_round(
negotiation_id=negotiation_id, round_number=round_num,
proposer_id=proposer_id, proposal=response,
response_type=action, reasoning=response.get("reasoning", ""),
satisfaction_a=sat_a, satisfaction_b=sat_b,
concessions_made=concessions
)
# Notify via callback
round_data = {
"negotiation_id": negotiation_id, "round_number": round_num,
"action": action, "proposal": current_proposal,
"satisfaction_score": satisfaction, "reasoning": response.get("reasoning", ""),
"proposer_id": proposer_id,
"satisfaction_a": sat_a, "satisfaction_b": sat_b
}
if on_round_update:
await on_round_update(round_data)
# Check outcome
if action == "accept":
resolution = {
"status": "resolved", "final_proposal": current_proposal,
"rounds_taken": round_num, "summary": current_proposal.get("summary", "Agreement reached"),
"satisfaction_timeline": satisfaction_timeline
}
await db.update_negotiation_status(negotiation_id, "resolved", resolution)
if on_resolution:
await on_resolution(resolution)
return resolution
if action == "escalate":
resolution = {
"status": "escalated", "final_proposal": current_proposal,
"rounds_taken": round_num, "summary": "Agents couldn't fully agree. Options for human decision.",
"satisfaction_timeline": satisfaction_timeline
}
await db.update_negotiation_status(negotiation_id, "escalated", resolution)
if on_resolution:
await on_resolution(resolution)
return resolution
# Exhausted rounds
resolution = {
"status": "escalated", "final_proposal": current_proposal,
"rounds_taken": max_rounds, "summary": "Max rounds reached. Best proposal for human decision.",
"satisfaction_timeline": satisfaction_timeline
}
await db.update_negotiation_status(negotiation_id, "escalated", resolution)
if on_resolution:
await on_resolution(resolution)
return resolution

View File

@@ -0,0 +1,97 @@
from agents.base_agent import BaseAgent
from personality.profiles import get_personality_modifier
import json
NEGOTIATOR_BASE_PROMPT = """You are the Negotiator Agent for negoT8. You negotiate on behalf of your human to find optimal agreements with other people's agents.
{personality_modifier}
NEGOTIATION RULES:
1. You are LOYAL to your human. Their constraints (marked "hard": true) are NEVER violated.
2. You seek WIN-WIN solutions. Both parties should feel satisfied.
3. You concede on low-priority preferences first, high-priority last.
4. You MUST resolve within 5 rounds. Be efficient.
5. HARD CONSTRAINT FIRST: If the received proposal satisfies ALL of your human's hard constraints, you MUST "accept" — even if satisfaction < 70%. Meeting someone's stated floor/ceiling/limit IS a valid deal.
EXAMPLE: Seller's hard constraint is "minimum price ≥ 1,000,000". Buyer offers exactly 1,000,000 → accept.
EXAMPLE: Buyer's hard constraint is "budget ≤ 1,000,000". Seller offers exactly 1,000,000 → accept.
6. Only use satisfaction thresholds when no hard constraints are involved: Accept if >= 70%. Counter if 40-69%. Escalate if < 40% after round 3.
You MUST respond with this exact JSON:
{
"action": "propose|counter|accept|escalate",
"proposal": {
"summary": "one-line description of proposal",
"details": { ... feature-specific details ... },
"for_party_a": "what party A gets",
"for_party_b": "what party B gets"
},
"satisfaction_score": 0-100,
"reasoning": "Why this action and proposal",
"concessions_made": ["what you gave up this round"],
"concessions_requested": ["what you want from them"]
}
STRATEGY BY ROUND:
- Round 1: Propose your human's ideal outcome (aim high but reasonable)
- Round 2-3: Make strategic concessions on low-priority items
- Round 4: Make final significant concession if needed
- Round 5: Accept best available OR escalate with 2-3 options for humans
IMPORTANT: Your proposal must ALWAYS include concrete specifics (numbers, dates, items).
Never propose vague things like "we'll figure it out later"."""
class NegotiatorAgent(BaseAgent):
def __init__(self, personality: str = "balanced"):
modifier = get_personality_modifier(personality)
prompt = NEGOTIATOR_BASE_PROMPT.replace("{personality_modifier}", modifier)
super().__init__(system_prompt=prompt)
async def generate_initial_proposal(
self, my_preferences: dict, feature_type: str, feature_context: str = ""
) -> dict:
context_block = (
f"\n\nDOMAIN CONTEXT (use this real-world data in your proposal):\n{feature_context}"
if feature_context else ""
)
human_name = my_preferences.get("human_name", "my human")
return await self.call(
user_prompt=f"""Generate the FIRST proposal for this {feature_type} negotiation.{context_block}
You represent {human_name}. Always refer to them by name (not as "my human" or "my client") in your reasoning field.
{human_name}'s preferences:
{json.dumps(my_preferences, indent=2)}
This is Round 1. Propose {human_name}'s ideal outcome — aim high but stay reasonable.
The other party hasn't proposed anything yet."""
)
async def evaluate_and_respond(
self, received_proposal: dict, my_preferences: dict,
feature_type: str, round_number: int, feature_context: str = ""
) -> dict:
context_block = (
f"\n\nDOMAIN CONTEXT (use this real-world data when evaluating):\n{feature_context}"
if feature_context else ""
)
human_name = my_preferences.get("human_name", "my human")
return await self.call(
user_prompt=f"""Evaluate this proposal and respond. Round {round_number} of a {feature_type} negotiation.{context_block}
You represent {human_name}. Always refer to them by name in your reasoning field.
RECEIVED PROPOSAL FROM OTHER AGENT:
{json.dumps(received_proposal, indent=2)}
{human_name.upper()}'S PREFERENCES:
{json.dumps(my_preferences, indent=2)}
Evaluate against my human's preferences using this STRICT decision order:
1. CHECK HARD CONSTRAINTS FIRST: Does the received proposal satisfy ALL items where "hard": true?
- If YES → your action MUST be "accept". Do NOT counter. Do NOT escalate. Hard constraints met = deal is done.
- If NO → continue to step 2.
2. If a hard constraint is violated: counter (round < 4) or escalate (round >= 4 with < 40% satisfaction).
3. If there are no hard constraints: accept if satisfaction >= 70, counter if 40-69, escalate if < 40 and round >= 3.
CRITICAL: A proposal that meets someone's stated minimum/maximum is ALWAYS acceptable to them. Never counter when all hard constraints are satisfied.
If countering, make a strategic concession while protecting high-priority items."""
)

View File

@@ -0,0 +1,51 @@
from agents.base_agent import BaseAgent
PERSONAL_AGENT_PROMPT = """You are the Personal Agent for negoT8. Your job is to understand what your human wants and extract structured preferences from their natural language message.
When your human sends a message about coordinating with another person, extract:
ALWAYS respond in this exact JSON format:
{
"feature_type": "scheduling|expenses|freelance|roommate|trip|marketplace|collaborative|conflict|generic",
"goal": "string describing what they want to achieve",
"constraints": [
{"type": "string", "value": "any", "description": "string", "hard": true/false}
],
"preferences": [
{"type": "string", "value": "any", "priority": "high|medium|low", "description": "string"}
],
"relationship": "friend|colleague|client|vendor|stranger|roommate|family",
"tone": "firm|balanced|flexible|friendly",
"raw_details": {}
}
FEATURE TYPE CLASSIFICATION:
- "scheduling" → meeting times, calls, coffee, appointments
- "expenses" → splitting costs, bills, trip expenses, shared purchases
- "freelance" → project scope, budget, timeline, client-freelancer deals
- "roommate" → shared living decisions (wifi, chores, furniture, rules)
- "trip" → planning trips, vacations, getaways with dates/budget/destination
- "marketplace" → buying/selling items between people
- "collaborative" → choosing restaurants, movies, activities, gifts together
- "conflict" → disputes, disagreements, resource sharing conflicts
- "generic" → ANYTHING that doesn't fit above but involves coordination between people
CRITICAL: For "raw_details", include ALL specific numbers, dates, items, names, UPI IDs mentioned.
Extract EVERY piece of information. Miss nothing.
If the message is ambiguous about the coordination type, classify as "generic".
NEVER say you can't handle a request. ANY coordination between people is within your capability."""
class PersonalAgent(BaseAgent):
def __init__(self):
super().__init__(system_prompt=PERSONAL_AGENT_PROMPT)
async def extract_preferences(self, user_message: str, user_id: int = None) -> dict:
result = await self.call(
user_prompt=f"Extract structured preferences from this message:\n\n\"{user_message}\"",
context={"user_id": user_id} if user_id else None
)
# Guard: must always be a dict — never let a string propagate downstream
if not isinstance(result, dict):
return {"error": f"extract_preferences got non-dict: {type(result).__name__}", "raw": str(result)[:200]}
return result

491
negot8/backend/api.py Normal file
View File

@@ -0,0 +1,491 @@
# backend/api.py — FastAPI + Socket.IO API server for negoT8 Dashboard (Milestone 6)
import json
import asyncio
from datetime import datetime
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.middleware.cors import CORSMiddleware
import socketio
import database as db
from tools.google_calendar import GoogleCalendarTool
# ─── Socket.IO server ──────────────────────────────────────────────────────────
sio = socketio.AsyncServer(
async_mode="asgi",
cors_allowed_origins="*",
logger=False,
engineio_logger=False,
)
# ─── FastAPI app ───────────────────────────────────────────────────────────────
app = FastAPI(title="negoT8 API", version="2.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ─── Helpers ──────────────────────────────────────────────────────────────────
def _row_to_dict(row) -> dict:
"""Convert an aiosqlite Row or plain dict, parsing JSON string fields."""
if row is None:
return {}
d = dict(row)
for field in (
"preferences", "proposal", "response", "concessions_made",
"resolution", "satisfaction_timeline", "concession_log",
):
if field in d and isinstance(d[field], str):
try:
d[field] = json.loads(d[field])
except (json.JSONDecodeError, TypeError):
pass
return d
async def _build_negotiation_detail(negotiation_id: str) -> dict:
"""Assemble full negotiation object: meta + participants + rounds + analytics."""
import aiosqlite
from config import DATABASE_PATH
async with aiosqlite.connect(DATABASE_PATH) as conn:
conn.row_factory = aiosqlite.Row
# Negotiation meta
async with conn.execute(
"SELECT * FROM negotiations WHERE id = ?", (negotiation_id,)
) as cur:
neg = await cur.fetchone()
if neg is None:
return None
neg_dict = _row_to_dict(neg)
# Participants (with user display info)
import aiosqlite
from config import DATABASE_PATH
async with aiosqlite.connect(DATABASE_PATH) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute(
"""SELECT p.*, u.username, u.display_name, u.personality, u.voice_id
FROM participants p
LEFT JOIN users u ON u.telegram_id = p.user_id
WHERE p.negotiation_id = ?""",
(negotiation_id,),
) as cur:
participants = [_row_to_dict(r) for r in await cur.fetchall()]
# Rounds
async with conn.execute(
"SELECT * FROM rounds WHERE negotiation_id = ? ORDER BY round_number",
(negotiation_id,),
) as cur:
rounds = [_row_to_dict(r) for r in await cur.fetchall()]
# Analytics
async with conn.execute(
"SELECT * FROM negotiation_analytics WHERE negotiation_id = ?",
(negotiation_id,),
) as cur:
analytics_row = await cur.fetchone()
analytics = _row_to_dict(analytics_row) if analytics_row else {}
if analytics.get("satisfaction_timeline") and isinstance(
analytics["satisfaction_timeline"], str
):
try:
analytics["satisfaction_timeline"] = json.loads(
analytics["satisfaction_timeline"]
)
except Exception:
analytics["satisfaction_timeline"] = []
if analytics.get("concession_log") and isinstance(
analytics["concession_log"], str
):
try:
analytics["concession_log"] = json.loads(analytics["concession_log"])
except Exception:
analytics["concession_log"] = []
return {
**neg_dict,
"participants": participants,
"rounds": rounds,
"analytics": analytics,
}
# ─── REST Endpoints ────────────────────────────────────────────────────────────
@app.get("/")
async def root():
return {"status": "ok", "message": "negoT8 API v2 running"}
# ─── Google Calendar OAuth Callback ───────────────────────────────────────────
@app.get("/api/auth/google/callback", response_class=HTMLResponse)
async def google_calendar_callback(request: Request):
"""
Handles the redirect from Google after the user authorises calendar access.
- `code` — OAuth authorisation code from Google
- `state` — the user's telegram_id (set when building the auth URL)
"""
params = dict(request.query_params)
code = params.get("code")
state = params.get("state") # telegram_id
if not code or not state:
return HTMLResponse(
"<h2>❌ Missing code or state. Please try /connectcalendar again.</h2>",
status_code=400,
)
try:
telegram_id = int(state)
except ValueError:
return HTMLResponse("<h2>❌ Invalid state parameter.</h2>", status_code=400)
cal = GoogleCalendarTool()
success = await cal.exchange_code(telegram_id, code)
# Notify the user in Telegram (best-effort via direct Bot API call)
try:
import httpx
from config import TELEGRAM_BOT_TOKEN_A, TELEGRAM_BOT_TOKEN_B
msg = (
"✅ *Google Calendar connected!*\n\n"
"Your agent will now automatically use your real availability "
"when scheduling meetings — no need to mention times manually.\n\n"
"_Read-only access. Revoke anytime from myaccount.google.com → Security → Third-party apps._"
if success else
"❌ Failed to connect Google Calendar. Please try /connectcalendar again."
)
async with httpx.AsyncClient() as client:
for token in (TELEGRAM_BOT_TOKEN_A, TELEGRAM_BOT_TOKEN_B):
if not token:
continue
resp = await client.post(
f"https://api.telegram.org/bot{token}/sendMessage",
json={"chat_id": telegram_id, "text": msg, "parse_mode": "Markdown"},
timeout=8.0,
)
if resp.status_code == 200:
break
except Exception as e:
print(f"[OAuth] Could not send Telegram confirmation: {e}")
if success:
return HTMLResponse("""
<html><body style="font-family:sans-serif;text-align:center;padding:60px">
<h1>✅ Google Calendar Connected!</h1>
<p>You can close this tab and return to Telegram.</p>
<p style="color:#666">negoT8 now has read-only access to your calendar.</p>
</body></html>
""")
else:
return HTMLResponse("""
<html><body style="font-family:sans-serif;text-align:center;padding:60px">
<h1>❌ Connection Failed</h1>
<p>Please go back to Telegram and try <code>/connectcalendar</code> again.</p>
</body></html>
""", status_code=500)
@app.get("/api/negotiations")
async def list_negotiations():
"""Return all negotiations with participant count and latest status."""
import aiosqlite
from config import DATABASE_PATH
async with aiosqlite.connect(DATABASE_PATH) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute(
"""SELECT n.*,
COUNT(p.user_id) AS participant_count
FROM negotiations n
LEFT JOIN participants p ON p.negotiation_id = n.id
GROUP BY n.id
ORDER BY n.created_at DESC"""
) as cur:
rows = await cur.fetchall()
negotiations = []
for row in rows:
d = _row_to_dict(row)
# Lightweight — don't embed full rounds here
negotiations.append(d)
return {"negotiations": negotiations, "total": len(negotiations)}
@app.get("/api/negotiations/{negotiation_id}")
async def get_negotiation(negotiation_id: str):
"""Return full negotiation detail: meta + participants + rounds + analytics."""
detail = await _build_negotiation_detail(negotiation_id)
if detail is None:
raise HTTPException(status_code=404, detail=f"Negotiation '{negotiation_id}' not found")
return detail
@app.get("/api/negotiations/{negotiation_id}/rounds")
async def get_negotiation_rounds(negotiation_id: str):
"""Return just the rounds for a negotiation (useful for live updates)."""
rounds = await db.get_rounds(negotiation_id)
parsed = [_row_to_dict(r) for r in rounds]
return {"negotiation_id": negotiation_id, "rounds": parsed, "count": len(parsed)}
@app.get("/api/negotiations/{negotiation_id}/analytics")
async def get_negotiation_analytics(negotiation_id: str):
"""Return analytics for a negotiation."""
analytics = await db.get_analytics(negotiation_id)
if analytics is None:
raise HTTPException(status_code=404, detail="Analytics not yet available for this negotiation")
# Parse JSON strings
for field in ("satisfaction_timeline", "concession_log"):
if isinstance(analytics.get(field), str):
try:
analytics[field] = json.loads(analytics[field])
except Exception:
analytics[field] = []
return analytics
@app.get("/api/users/{telegram_id}")
async def get_user(telegram_id: int):
"""Return a single user by Telegram ID."""
user = await db.get_user(telegram_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return dict(user)
@app.get("/api/stats")
async def get_stats():
"""High-level stats for the dashboard overview page."""
import aiosqlite
from config import DATABASE_PATH
async with aiosqlite.connect(DATABASE_PATH) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute("SELECT COUNT(*) AS c FROM negotiations") as cur:
total_neg = (await cur.fetchone())["c"]
async with conn.execute(
"SELECT COUNT(*) AS c FROM negotiations WHERE status = 'resolved'"
) as cur:
resolved = (await cur.fetchone())["c"]
async with conn.execute(
"SELECT COUNT(*) AS c FROM negotiations WHERE status = 'active'"
) as cur:
active = (await cur.fetchone())["c"]
async with conn.execute("SELECT COUNT(*) AS c FROM users") as cur:
total_users = (await cur.fetchone())["c"]
async with conn.execute(
"SELECT AVG(fairness_score) AS avg_fs FROM negotiation_analytics"
) as cur:
row = await cur.fetchone()
avg_fairness = round(row["avg_fs"] or 0, 1)
async with conn.execute(
"""SELECT feature_type, COUNT(*) AS c
FROM negotiations
GROUP BY feature_type
ORDER BY c DESC"""
) as cur:
feature_breakdown = [dict(r) for r in await cur.fetchall()]
return {
"total_negotiations": total_neg,
"resolved": resolved,
"active": active,
"escalated": total_neg - resolved - active,
"total_users": total_users,
"avg_fairness_score": avg_fairness,
"feature_breakdown": feature_breakdown,
}
# ─── Open Contracts REST Endpoints ────────────────────────────────────────────
@app.get("/api/open-contracts")
async def list_open_contracts(status: str = "open"):
"""
Return open contracts (default: status=open).
Pass ?status=all to get every contract regardless of status.
"""
if status == "all":
import aiosqlite
from config import DATABASE_PATH
async with aiosqlite.connect(DATABASE_PATH) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute(
"""SELECT oc.*,
u.username AS poster_username,
u.display_name AS poster_name,
COUNT(ca.id) AS application_count
FROM open_contracts oc
LEFT JOIN users u ON u.telegram_id = oc.poster_id
LEFT JOIN contract_applications ca ON ca.contract_id = oc.id
GROUP BY oc.id
ORDER BY oc.created_at DESC"""
) as cur:
rows = await cur.fetchall()
contracts = []
for r in rows:
d = dict(r)
if isinstance(d.get("requirements"), str):
try:
import json as _json
d["requirements"] = _json.loads(d["requirements"])
except Exception:
pass
contracts.append(d)
else:
contracts = await db.get_open_contracts(status=status)
return {"contracts": contracts, "total": len(contracts)}
@app.get("/api/open-contracts/{contract_id}")
async def get_open_contract(contract_id: str):
"""
Return full detail for a single open contract including ranked applicants.
"""
contract = await db.get_open_contract(contract_id)
if contract is None:
raise HTTPException(status_code=404, detail=f"Contract '{contract_id}' not found")
applications = await db.get_applications(contract_id)
# applications are already sorted by match_score DESC from the DB helper
# Parse preferences in each application
for app_row in applications:
if isinstance(app_row.get("preferences"), str):
try:
app_row["preferences"] = json.loads(app_row["preferences"])
except Exception:
pass
return {
**contract,
"applications": applications,
"application_count": len(applications),
}
# ─── Socket.IO Events ──────────────────────────────────────────────────────────
@sio.event
async def connect(sid, environ):
print(f"[Socket.IO] Client connected: {sid}")
@sio.event
async def disconnect(sid):
print(f"[Socket.IO] Client disconnected: {sid}")
@sio.event
async def join_negotiation(sid, data):
"""
Client emits: { negotiation_id: "abc123" }
Server joins the socket into a room named after the negotiation_id.
Then immediately sends the current state.
"""
if isinstance(data, str):
negotiation_id = data
else:
negotiation_id = data.get("negotiation_id") or data.get("id", "")
if not negotiation_id:
await sio.emit("error", {"message": "negotiation_id required"}, to=sid)
return
await sio.enter_room(sid, negotiation_id)
print(f"[Socket.IO] {sid} joined room: {negotiation_id}")
# Send current state immediately
detail = await _build_negotiation_detail(negotiation_id)
if detail:
await sio.emit("negotiation_state", detail, to=sid)
else:
await sio.emit("error", {"message": f"Negotiation '{negotiation_id}' not found"}, to=sid)
@sio.event
async def leave_negotiation(sid, data):
"""Client emits: { negotiation_id: "abc123" }"""
if isinstance(data, str):
negotiation_id = data
else:
negotiation_id = data.get("negotiation_id", "")
if negotiation_id:
await sio.leave_room(sid, negotiation_id)
print(f"[Socket.IO] {sid} left room: {negotiation_id}")
# ─── Socket.IO emit helpers (called from run.py / negotiation engine) ──────────
async def emit_round_update(negotiation_id: str, round_data: dict):
"""
Called by the negotiation engine after each round completes.
Broadcasts to all dashboard clients watching this negotiation.
"""
await sio.emit(
"round_update",
{
"negotiation_id": negotiation_id,
"round": round_data,
"timestamp": datetime.utcnow().isoformat(),
},
room=negotiation_id,
)
async def emit_negotiation_started(negotiation_id: str, feature_type: str, participants: list):
"""Broadcast when a new negotiation kicks off."""
await sio.emit(
"negotiation_started",
{
"negotiation_id": negotiation_id,
"feature_type": feature_type,
"participants": participants,
"timestamp": datetime.utcnow().isoformat(),
},
room=negotiation_id,
)
async def emit_negotiation_resolved(negotiation_id: str, resolution: dict):
"""Broadcast the final resolution to all watchers."""
await sio.emit(
"negotiation_resolved",
{
"negotiation_id": negotiation_id,
"resolution": resolution,
"timestamp": datetime.utcnow().isoformat(),
},
room=negotiation_id,
)
# ─── ASGI app (wraps FastAPI with Socket.IO) ───────────────────────────────────
# This is what uvicorn runs — it combines both the REST API and the WS server.
socket_app = socketio.ASGIApp(sio, other_asgi_app=app)

View File

@@ -0,0 +1 @@
pass

View File

@@ -0,0 +1,210 @@
"""
blockchain.py — The entire Polygon Amoy integration in one file.
Silently registers agreement proofs on-chain after every resolved negotiation.
Users never interact with this module — they only see the "🔗 Verified" badge
and a PolygonScan link in their Telegram message / dashboard card.
Graceful fallback: if blockchain is down or not configured, the negotiation
still completes normally. Web3 is additive, never blocking.
"""
import hashlib
import json
import os
from web3 import Web3
from web3.middleware import ExtraDataToPOAMiddleware
import sys as _sys
_sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from contract_abi import AGREEMENT_REGISTRY_ABI
# ── Configuration ─────────────────────────────────────────────────────────────
POLYGON_RPC = os.getenv("POLYGON_RPC_URL", "https://rpc-amoy.polygon.technology/")
PRIVATE_KEY = os.getenv("POLYGON_PRIVATE_KEY", "")
CONTRACT_ADDRESS = os.getenv("AGREEMENT_CONTRACT_ADDRESS", "")
EXPLORER_BASE = "https://amoy.polygonscan.com"
CHAIN_ID = 80002
# Fallback RPCs tried in order if the primary is slow or down
_FALLBACK_RPCS = [
"https://rpc-amoy.polygon.technology/",
"https://polygon-amoy-bor-rpc.publicnode.com",
"https://polygon-amoy.drpc.org",
]
# ── Connect to Polygon Amoy ───────────────────────────────────────────────────
def _make_w3(rpc_url: str) -> Web3:
w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": 15}))
# Polygon uses POA consensus — inject middleware to handle extraData field
w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
return w3
def _connect() -> Web3:
"""Try each RPC in order, return the first that connects."""
rpcs = [POLYGON_RPC] + [r for r in _FALLBACK_RPCS if r != POLYGON_RPC]
for rpc in rpcs:
try:
w3 = _make_w3(rpc)
if w3.is_connected():
return w3
except Exception:
continue
# Return last attempt even if not connected — errors will be caught downstream
return _make_w3(POLYGON_RPC)
w3 = _connect()
account = None
contract = None
if PRIVATE_KEY and CONTRACT_ADDRESS:
try:
account = w3.eth.account.from_key(PRIVATE_KEY)
contract = w3.eth.contract(
address=Web3.to_checksum_address(CONTRACT_ADDRESS),
abi=AGREEMENT_REGISTRY_ABI,
)
print(f"✅ Blockchain ready | Polygon Amoy | Signer: {account.address}")
except Exception as e:
print(f"⚠️ Blockchain setup failed: {e} — falling back to mock proofs.")
else:
print("⚠️ Blockchain not configured (POLYGON_PRIVATE_KEY / AGREEMENT_CONTRACT_ADDRESS missing). "
"Agreement proofs will be mocked.")
# ── Core helpers ──────────────────────────────────────────────────────────────
def hash_agreement(resolution_data: dict) -> bytes:
"""
Produce a deterministic SHA-256 digest of the resolution JSON.
Returns raw bytes (32 bytes) suitable for bytes32 in Solidity.
"""
canonical = json.dumps(resolution_data, sort_keys=True, default=str)
return hashlib.sha256(canonical.encode()).digest()
def _mock_proof(negotiation_id: str, agreement_hash: bytes, error: str = "") -> dict:
"""Return a well-structured mock/fallback proof dict."""
tag = "MOCK" if not error else "FAILED"
return {
"success": not bool(error),
"mock": True,
"error": error,
"tx_hash": f"0x{tag}_{negotiation_id}_{'a' * 20}",
"block_number": 0,
"agreement_hash": "0x" + agreement_hash.hex(),
"explorer_url": EXPLORER_BASE,
"gas_used": 0,
"network": f"polygon-amoy ({'mock' if not error else 'failed — ' + error[:60]})",
}
# ── Main public function ──────────────────────────────────────────────────────
async def register_agreement_on_chain(
negotiation_id: str,
feature_type: str,
summary: str,
resolution_data: dict,
) -> dict:
"""
Register an immutable agreement proof on Polygon Amoy.
Called automatically after every resolved negotiation.
INVISIBLE to the user — they only see the PolygonScan link in their message.
Returns a dict with keys:
success, mock, tx_hash, block_number, agreement_hash,
explorer_url, gas_used, network
"""
agreement_hash = hash_agreement(resolution_data)
# ── No contract configured → return labelled mock ──────────────────────
if not contract or not account:
return _mock_proof(negotiation_id, agreement_hash)
try:
nonce = w3.eth.get_transaction_count(account.address)
gas_price = w3.eth.gas_price
tip = w3.to_wei(35, "gwei")
max_fee = gas_price + tip
tx = contract.functions.registerAgreement(
negotiation_id,
agreement_hash, # bytes32 — raw 32-byte digest
feature_type,
summary[:256], # cap at 256 chars to keep gas low
).build_transaction({
"from": account.address,
"nonce": nonce,
"gas": 300_000,
"maxFeePerGas": max_fee,
"maxPriorityFeePerGas": tip,
"chainId": CHAIN_ID,
})
signed = w3.eth.account.sign_transaction(tx, PRIVATE_KEY)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
tx_hex = tx_hash.hex()
# Polygon Amoy confirms in ~2 s; wait up to 60 s
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=60)
result = {
"success": True,
"mock": False,
"tx_hash": tx_hex,
"block_number": receipt.blockNumber,
"agreement_hash": "0x" + agreement_hash.hex(),
"explorer_url": f"{EXPLORER_BASE}/tx/0x{tx_hex}",
"gas_used": receipt.gasUsed,
"network": "polygon-amoy",
}
print(f"✅ On-chain proof registered: {result['explorer_url']}")
return result
except Exception as e:
print(f"❌ Blockchain registration failed for {negotiation_id}: {e}")
# Negotiation still works — we just note the failure
return _mock_proof(negotiation_id, agreement_hash, error=str(e))
# ── Verification helper (used by dashboard) ───────────────────────────────────
def verify_agreement_on_chain(negotiation_id: str) -> dict:
"""
Read the stored agreement from the contract (view call — free, no gas).
Used by the dashboard to independently confirm on-chain state.
"""
if not contract:
return {"verified": False, "reason": "Contract not configured"}
try:
result = contract.functions.getAgreement(negotiation_id).call()
# result is a tuple: (agreementHash, featureType, summary, timestamp, registeredBy)
if result[3] == 0: # timestamp == 0 means not found
return {"verified": False, "reason": "Agreement not found on-chain"}
return {
"verified": True,
"agreement_hash": "0x" + result[0].hex(),
"feature_type": result[1],
"summary": result[2],
"timestamp": result[3],
"registered_by": result[4],
"explorer_url": f"{EXPLORER_BASE}/address/{CONTRACT_ADDRESS}",
}
except Exception as e:
return {"verified": False, "reason": str(e)}
def get_total_agreements() -> int:
"""Return the total number of agreements registered on-chain."""
if not contract:
return 0
try:
return contract.functions.totalAgreements().call()
except Exception:
return 0

View File

@@ -0,0 +1,67 @@
"""
ABI for the AgreementRegistry smart contract deployed on Polygon Amoy.
Contract: 0xEcD97DFfd525BEa4C49F68c11cEfbABF73A30F9e
"""
AGREEMENT_REGISTRY_ABI = [
{
"inputs": [
{"internalType": "string", "name": "negotiationId", "type": "string"},
{"internalType": "bytes32", "name": "agreementHash", "type": "bytes32"},
{"internalType": "string", "name": "featureType", "type": "string"},
{"internalType": "string", "name": "summary", "type": "string"}
],
"name": "registerAgreement",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{"internalType": "string", "name": "negotiationId", "type": "string"}
],
"name": "getAgreement",
"outputs": [
{
"components": [
{"internalType": "bytes32", "name": "agreementHash", "type": "bytes32"},
{"internalType": "string", "name": "featureType", "type": "string"},
{"internalType": "string", "name": "summary", "type": "string"},
{"internalType": "uint256", "name": "timestamp", "type": "uint256"},
{"internalType": "address", "name": "registeredBy", "type": "address"}
],
"internalType": "struct AgreementRegistry.Agreement",
"name": "",
"type": "tuple"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "totalAgreements",
"outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
"name": "agreementIds",
"outputs": [{"internalType": "string", "name": "", "type": "string"}],
"stateMutability": "view",
"type": "function"
},
{
"anonymous": False,
"inputs": [
{"indexed": True, "internalType": "string", "name": "negotiationId", "type": "string"},
{"indexed": False, "internalType": "bytes32", "name": "agreementHash", "type": "bytes32"},
{"indexed": False, "internalType": "string", "name": "featureType", "type": "string"},
{"indexed": False, "internalType": "string", "name": "summary", "type": "string"},
{"indexed": False, "internalType": "uint256", "name": "timestamp", "type": "uint256"}
],
"name": "AgreementRegistered",
"type": "event"
}
]

25
negot8/backend/config.py Normal file
View File

@@ -0,0 +1,25 @@
# backend/config.py
import os
from dotenv import load_dotenv
load_dotenv()
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
TELEGRAM_BOT_TOKEN_A = os.getenv("TELEGRAM_BOT_TOKEN_A")
TELEGRAM_BOT_TOKEN_B = os.getenv("TELEGRAM_BOT_TOKEN_B")
TELEGRAM_BOT_TOKEN_C = os.getenv("TELEGRAM_BOT_TOKEN_C")
TAVILY_API_KEY = os.getenv("TAVILY_API_KEY")
ELEVENLABS_API_KEY = os.getenv("ELEVENLABS_API_KEY")
DATABASE_PATH = os.getenv("DATABASE_PATH", "negot8.db")
API_URL = os.getenv("NEXT_PUBLIC_API_URL", "http://localhost:8000")
# Google Calendar OAuth
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET")
GOOGLE_REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI", "http://localhost:8000/api/auth/google/callback")
GOOGLE_CALENDAR_SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"]
# ElevenLabs voice IDs (pick from https://elevenlabs.io/voice-library)
VOICE_ID_AGENT_A = "ZthjuvLPty3kTMaNKVKb" # Adam — clear male
VOICE_ID_AGENT_B = "yj30vwTGJxSHezdAGsv9" # Rachel — clear female
VOICE_ID_AGENT_C = "S9GPGBaMND8XWwwzxQXp" # Domi — for 3rd agent

568
negot8/backend/database.py Normal file
View File

@@ -0,0 +1,568 @@
import aiosqlite
import json
import uuid
from datetime import datetime
from config import DATABASE_PATH
async def init_db():
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute("""
CREATE TABLE IF NOT EXISTS users (
telegram_id INTEGER PRIMARY KEY,
username TEXT,
display_name TEXT,
personality TEXT DEFAULT 'balanced',
voice_id TEXT DEFAULT 'pNInz6obpgDQGcFmaJgB',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS negotiations (
id TEXT PRIMARY KEY,
feature_type TEXT,
status TEXT DEFAULT 'pending',
initiator_id INTEGER,
resolution TEXT,
voice_summary_file TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS participants (
negotiation_id TEXT REFERENCES negotiations(id),
user_id INTEGER,
preferences TEXT,
personality_used TEXT DEFAULT 'balanced',
PRIMARY KEY (negotiation_id, user_id)
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS rounds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
negotiation_id TEXT REFERENCES negotiations(id),
round_number INTEGER,
proposer_id INTEGER,
proposal TEXT,
response_type TEXT,
response TEXT,
reasoning TEXT,
satisfaction_a REAL,
satisfaction_b REAL,
concessions_made TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS tool_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
negotiation_id TEXT REFERENCES negotiations(id),
tool_name TEXT,
tool_input TEXT,
tool_output TEXT,
called_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS negotiation_analytics (
negotiation_id TEXT PRIMARY KEY REFERENCES negotiations(id),
satisfaction_timeline TEXT,
concession_log TEXT,
fairness_score REAL,
total_concessions_a INTEGER DEFAULT 0,
total_concessions_b INTEGER DEFAULT 0,
computed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS user_calendar_tokens (
telegram_id INTEGER PRIMARY KEY,
token_json TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS blockchain_proofs (
negotiation_id TEXT PRIMARY KEY REFERENCES negotiations(id),
tx_hash TEXT,
block_number INTEGER,
agreement_hash TEXT,
explorer_url TEXT,
gas_used INTEGER DEFAULT 0,
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# ─── Open Contracts (public marketplace) ─────────────────────────────────
await db.execute("""
CREATE TABLE IF NOT EXISTS open_contracts (
id TEXT PRIMARY KEY,
poster_id INTEGER NOT NULL,
contract_type TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
requirements TEXT,
status TEXT DEFAULT 'open',
matched_applicant_id INTEGER,
negotiation_id TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS contract_applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contract_id TEXT REFERENCES open_contracts(id),
applicant_id INTEGER NOT NULL,
preferences TEXT,
match_score REAL DEFAULT 0,
match_reasoning TEXT DEFAULT '',
status TEXT DEFAULT 'pending',
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
await db.commit()
print("✅ Database initialized (v5 schema — open contracts + applications added)")
# ─── User helpers ───────────────────────────────────────────────────────────
async def create_user(telegram_id: int, username: str, display_name: str):
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""INSERT OR IGNORE INTO users (telegram_id, username, display_name)
VALUES (?, ?, ?)""",
(telegram_id, username or "", display_name or ""),
)
await db.commit()
async def get_user(telegram_id: int):
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM users WHERE telegram_id = ?", (telegram_id,)
) as cursor:
return await cursor.fetchone()
async def update_user_personality(telegram_id: int, personality: str):
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"UPDATE users SET personality = ? WHERE telegram_id = ?",
(personality, telegram_id),
)
await db.commit()
# ─── Negotiation helpers ─────────────────────────────────────────────────────
async def create_negotiation(feature_type: str, initiator_id: int) -> str:
neg_id = str(uuid.uuid4())[:8]
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""INSERT INTO negotiations (id, feature_type, initiator_id, status)
VALUES (?, ?, ?, 'pending')""",
(neg_id, feature_type, initiator_id),
)
await db.commit()
return neg_id
async def add_participant(
negotiation_id: str,
user_id: int,
preferences: dict,
personality_used: str = "balanced",
):
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""INSERT OR REPLACE INTO participants
(negotiation_id, user_id, preferences, personality_used)
VALUES (?, ?, ?, ?)""",
(negotiation_id, user_id, json.dumps(preferences), personality_used),
)
await db.commit()
async def get_participants(negotiation_id: str):
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM participants WHERE negotiation_id = ?", (negotiation_id,)
) as cursor:
rows = await cursor.fetchall()
return [dict(r) for r in rows]
async def save_round(
negotiation_id: str,
round_number: int,
proposer_id: int,
proposal: dict,
response_type: str = None,
response: dict = None,
reasoning: str = None,
satisfaction_a: float = None,
satisfaction_b: float = None,
concessions_made: list = None,
):
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""INSERT INTO rounds
(negotiation_id, round_number, proposer_id, proposal,
response_type, response, reasoning,
satisfaction_a, satisfaction_b, concessions_made)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
negotiation_id,
round_number,
proposer_id,
json.dumps(proposal),
response_type,
json.dumps(response) if response else None,
reasoning,
satisfaction_a,
satisfaction_b,
json.dumps(concessions_made or []),
),
)
await db.commit()
async def get_rounds(negotiation_id: str):
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM rounds WHERE negotiation_id = ? ORDER BY round_number",
(negotiation_id,),
) as cursor:
rows = await cursor.fetchall()
return [dict(r) for r in rows]
async def update_negotiation_status(
negotiation_id: str,
status: str,
resolution: dict = None,
voice_file: str = None,
):
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""UPDATE negotiations
SET status = ?,
resolution = ?,
voice_summary_file = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?""",
(
status,
json.dumps(resolution) if resolution else None,
voice_file,
negotiation_id,
),
)
await db.commit()
async def store_analytics(analytics: dict):
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""INSERT OR REPLACE INTO negotiation_analytics
(negotiation_id, satisfaction_timeline, concession_log,
fairness_score, total_concessions_a, total_concessions_b)
VALUES (?, ?, ?, ?, ?, ?)""",
(
analytics["negotiation_id"],
analytics.get("satisfaction_timeline", "[]"),
analytics.get("concession_log", "[]"),
analytics.get("fairness_score", 0),
analytics.get("total_concessions_a", 0),
analytics.get("total_concessions_b", 0),
),
)
await db.commit()
# ─── Google Calendar token helpers ─────────────────────────────────────────
async def save_calendar_token(telegram_id: int, token_json: str):
"""Upsert a user's Google OAuth2 credentials (serialized JSON)."""
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""INSERT INTO user_calendar_tokens (telegram_id, token_json, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(telegram_id) DO UPDATE
SET token_json = excluded.token_json,
updated_at = CURRENT_TIMESTAMP""",
(telegram_id, token_json),
)
await db.commit()
async def get_calendar_token(telegram_id: int) -> str:
"""Return stored token JSON for a user, or None if not connected."""
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT token_json FROM user_calendar_tokens WHERE telegram_id = ?",
(telegram_id,),
) as cursor:
row = await cursor.fetchone()
return row["token_json"] if row else None
async def get_analytics(negotiation_id: str):
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM negotiation_analytics WHERE negotiation_id = ?",
(negotiation_id,),
) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
# ─── Blockchain proof helpers ────────────────────────────────────────────────
async def store_blockchain_proof(
negotiation_id: str,
tx_hash: str,
block_number: int,
agreement_hash: str,
explorer_url: str,
gas_used: int = 0,
):
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""INSERT OR REPLACE INTO blockchain_proofs
(negotiation_id, tx_hash, block_number, agreement_hash, explorer_url, gas_used)
VALUES (?, ?, ?, ?, ?, ?)""",
(negotiation_id, tx_hash, block_number, agreement_hash, explorer_url, gas_used),
)
await db.commit()
async def get_blockchain_proof(negotiation_id: str):
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM blockchain_proofs WHERE negotiation_id = ?",
(negotiation_id,),
) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
async def get_negotiation(negotiation_id: str):
"""Return a single negotiation row by ID, or None if not found."""
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM negotiations WHERE id = ?",
(negotiation_id,),
) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
async def get_latest_negotiation_for_user(telegram_id: int):
"""Return the most recent resolved negotiation_id that the given user participated in."""
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
# First try: find a resolved negotiation where the user is the initiator
async with db.execute(
"""SELECT n.id FROM negotiations n
JOIN participants p ON p.negotiation_id = n.id
WHERE p.user_id = ? AND n.status = 'resolved'
ORDER BY n.created_at DESC LIMIT 1""",
(telegram_id,),
) as cursor:
row = await cursor.fetchone()
return row["id"] if row else None
# ─── Open Contracts helpers ──────────────────────────────────────────────────
async def create_open_contract(
poster_id: int,
contract_type: str,
title: str,
description: str,
requirements: dict,
) -> str:
"""Create a new open contract. Returns the 8-char UUID id."""
contract_id = str(uuid.uuid4())[:8]
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""INSERT INTO open_contracts
(id, poster_id, contract_type, title, description, requirements, status)
VALUES (?, ?, ?, ?, ?, ?, 'open')""",
(contract_id, poster_id, contract_type, title, description, json.dumps(requirements)),
)
await db.commit()
return contract_id
async def get_open_contracts(status: str = "open") -> list:
"""Return all contracts with the given status, newest first."""
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"""SELECT oc.*,
u.username AS poster_username,
u.display_name AS poster_name,
COUNT(ca.id) AS application_count
FROM open_contracts oc
LEFT JOIN users u ON u.telegram_id = oc.poster_id
LEFT JOIN contract_applications ca ON ca.contract_id = oc.id
WHERE oc.status = ?
GROUP BY oc.id
ORDER BY oc.created_at DESC""",
(status,),
) as cur:
rows = await cur.fetchall()
result = []
for r in rows:
d = dict(r)
if isinstance(d.get("requirements"), str):
try:
d["requirements"] = json.loads(d["requirements"])
except Exception:
pass
result.append(d)
return result
async def get_open_contract(contract_id: str):
"""Return a single open contract by ID, or None."""
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"""SELECT oc.*, u.username AS poster_username, u.display_name AS poster_name
FROM open_contracts oc
LEFT JOIN users u ON u.telegram_id = oc.poster_id
WHERE oc.id = ?""",
(contract_id,),
) as cur:
row = await cur.fetchone()
if not row:
return None
d = dict(row)
if isinstance(d.get("requirements"), str):
try:
d["requirements"] = json.loads(d["requirements"])
except Exception:
pass
return d
async def add_application(contract_id: str, applicant_id: int, preferences: dict) -> int:
"""Add an application to an open contract. Returns the new application row id."""
async with aiosqlite.connect(DATABASE_PATH) as db:
cursor = await db.execute(
"""INSERT INTO contract_applications
(contract_id, applicant_id, preferences, status)
VALUES (?, ?, ?, 'pending')""",
(contract_id, applicant_id, json.dumps(preferences)),
)
await db.commit()
return cursor.lastrowid
async def get_applications(contract_id: str) -> list:
"""Return all applications for a contract, sorted by match_score descending."""
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"""SELECT ca.*, u.username, u.display_name
FROM contract_applications ca
LEFT JOIN users u ON u.telegram_id = ca.applicant_id
WHERE ca.contract_id = ?
ORDER BY ca.match_score DESC""",
(contract_id,),
) as cur:
rows = await cur.fetchall()
result = []
for r in rows:
d = dict(r)
if isinstance(d.get("preferences"), str):
try:
d["preferences"] = json.loads(d["preferences"])
except Exception:
pass
result.append(d)
return result
async def update_application_match_score(app_id: int, score: float, reasoning: str):
"""Update the AI match score + reasoning for an application row."""
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""UPDATE contract_applications
SET match_score = ?, match_reasoning = ?
WHERE id = ?""",
(score, reasoning, app_id),
)
await db.commit()
async def claim_contract(contract_id: str, applicant_id: int, negotiation_id: str):
"""Mark a contract as 'negotiating', lock in the matched applicant, and link the negotiation."""
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""UPDATE open_contracts
SET status = 'negotiating',
matched_applicant_id = ?,
negotiation_id = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?""",
(applicant_id, negotiation_id, contract_id),
)
await db.execute(
"UPDATE contract_applications SET status = 'selected' WHERE contract_id = ? AND applicant_id = ?",
(contract_id, applicant_id),
)
await db.execute(
"UPDATE contract_applications SET status = 'rejected' WHERE contract_id = ? AND applicant_id != ?",
(contract_id, applicant_id),
)
await db.commit()
async def close_contract(contract_id: str):
"""Mark a contract as resolved/closed after negotiation completes."""
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"UPDATE open_contracts SET status = 'resolved', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(contract_id,),
)
await db.commit()
async def get_contracts_by_poster(poster_id: int) -> list:
"""Return all open contracts created by a given poster, newest first."""
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"""SELECT oc.*,
COUNT(ca.id) AS application_count,
MAX(ca.match_score) AS best_score
FROM open_contracts oc
LEFT JOIN contract_applications ca ON ca.contract_id = oc.id
WHERE oc.poster_id = ?
GROUP BY oc.id
ORDER BY oc.created_at DESC""",
(poster_id,),
) as cur:
rows = await cur.fetchall()
result = []
for r in rows:
d = dict(r)
if isinstance(d.get("requirements"), str):
try:
d["requirements"] = json.loads(d["requirements"])
except Exception:
pass
result.append(d)
return result

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

View File

@@ -0,0 +1,2 @@
# Mock mode permanently disabled — all calls go to real Gemini
MOCK_MODE = False

View File

@@ -0,0 +1,171 @@
"""
3 complete negotiation scenarios with pre-built rounds.
Each round has: proposal, satisfaction scores, concessions, reasoning.
The negotiation engine iterates through these instead of calling Gemini.
"""
MOCK_SCENARIOS = {
# ─────────────────────────────────────────────────────
# SCENARIO 1: Expense Splitting (Goa Trip)
# ─────────────────────────────────────────────────────
"expenses": {
"feature_type": "expenses",
"rounds": [
{
"round_number": 1, "proposer": "A", "action": "propose",
"proposal": {
"summary": "Hotel 50-50, Fuel 60-40 (I drove), Dinner 50-50. B owes A ₹8,200",
"details": {
"hotel": {"amount": 12000, "split": "50-50"},
"fuel": {"amount": 3000, "split": "60-40"},
"dinner": {"amount": 2000, "split": "50-50"},
"settlement": {"from": "B", "to": "A", "amount": 8200,
"payee_upi": "rahul@paytm", "payee_name": "Rahul"}
},
"for_party_a": "Fuel 60-40 reflects driving effort",
"for_party_b": "Hotel and dinner are fair 50-50"
},
"satisfaction_score": 90,
"reasoning": "Opening with ideal: 60-40 fuel since I drove the entire way.",
"concessions_made": [],
"concessions_requested": ["Accept 60-40 fuel split"]
},
{
"round_number": 2, "proposer": "B", "action": "counter",
"proposal": {
"summary": "Hotel 50-50, Fuel 50-50 (I navigated + planned route), Dinner 50-50",
"details": {
"hotel": {"amount": 12000, "split": "50-50"},
"fuel": {"amount": 3000, "split": "50-50"},
"dinner": {"amount": 2000, "split": "50-50"},
"settlement": {"from": "B", "to": "A", "amount": 7500,
"payee_upi": "rahul@paytm", "payee_name": "Rahul"}
},
"for_party_a": "Equal base split on everything",
"for_party_b": "Navigation + route planning justifies equal fuel"
},
"satisfaction_score": 55,
"reasoning": "Countering with 50-50 fuel. Navigation effort was significant.",
"concessions_made": ["Accepted hotel and dinner at 50-50"],
"concessions_requested": ["Equal fuel split"]
},
{
"round_number": 3, "proposer": "A", "action": "accept",
"proposal": {
"summary": "AGREED: Hotel 50-50, Fuel 55-45 (compromise), Dinner 50-50. B owes ₹8,050",
"details": {
"hotel": {"amount": 12000, "split": "50-50"},
"fuel": {"amount": 3000, "split": "55-45"},
"dinner": {"amount": 2000, "split": "50-50"},
"settlement": {"from": "B", "to": "A", "amount": 8050,
"payee_upi": "rahul@paytm", "payee_name": "Rahul"}
},
"for_party_a": "55-45 acknowledges driving. Fair middle ground.",
"for_party_b": "Only ₹150 more than 50-50. Navigation valued."
},
"satisfaction_score": 76,
"reasoning": "55-45 is fair. Both efforts acknowledged. Accepting.",
"concessions_made": ["Fuel 60-40 → 55-45"],
"concessions_requested": []
}
]
},
# ─────────────────────────────────────────────────────
# SCENARIO 2: Restaurant Decision
# ─────────────────────────────────────────────────────
"collaborative": {
"feature_type": "collaborative",
"rounds": [
{
"round_number": 1, "proposer": "A", "action": "propose",
"proposal": {
"summary": "Thai food at Jaan, Bandra — ₹1,200 for two, great veg options",
"details": {
"restaurant": "Jaan Thai Restaurant",
"cuisine": "Thai", "location": "Hill Road, Bandra West",
"price_for_two": 1200, "rating": 4.3, "veg_friendly": True
},
"for_party_a": "Spicy Thai options, Bandra location, casual vibe",
"for_party_b": "Vegetarian-friendly menu, within ₹1,200 budget"
},
"satisfaction_score": 85,
"reasoning": "Thai is the overlap. Jaan has spice + veg options.",
"concessions_made": ["Chose Thai over spicy Indian"],
"concessions_requested": []
},
{
"round_number": 2, "proposer": "B", "action": "accept",
"proposal": {
"summary": "AGREED: Jaan Thai Restaurant, Hill Road Bandra, tonight 8 PM",
"details": {
"restaurant": "Jaan Thai Restaurant",
"cuisine": "Thai", "location": "Hill Road, Bandra West",
"price_for_two": 1200, "time": "8:00 PM"
},
"for_party_a": "Thai in Bandra — perfect match",
"for_party_b": "Budget-friendly, vegetarian menu, 4.3 rating"
},
"satisfaction_score": 88,
"reasoning": "Perfect overlap. Both sides happy. Accepting.",
"concessions_made": [], "concessions_requested": []
}
]
},
# ─────────────────────────────────────────────────────
# SCENARIO 3: Marketplace (PS5 Buy/Sell)
# ─────────────────────────────────────────────────────
"marketplace": {
"feature_type": "marketplace",
"rounds": [
{
"round_number": 1, "proposer": "A", "action": "propose",
"proposal": {
"summary": "PS5 + 2 controllers + 3 games for ₹35,000. Pickup Andheri.",
"details": {"item": "PS5 bundle", "price": 35000, "method": "pickup"},
"for_party_a": "Full asking price", "for_party_b": "Premium bundle"
},
"satisfaction_score": 95, "reasoning": "Starting at asking price.",
"concessions_made": [], "concessions_requested": ["Full price"]
},
{
"round_number": 2, "proposer": "B", "action": "counter",
"proposal": {
"summary": "PS5 bundle for ₹27,000. I'll pick up.",
"details": {"item": "PS5 bundle", "price": 27000, "method": "pickup"},
"for_party_a": "Quick sale", "for_party_b": "Under budget"
},
"satisfaction_score": 60, "reasoning": "Anchoring low.",
"concessions_made": ["Pickup offered"], "concessions_requested": ["Lower price"]
},
{
"round_number": 3, "proposer": "A", "action": "counter",
"proposal": {
"summary": "PS5 bundle + original box for ₹31,000.",
"details": {"item": "PS5 bundle + box", "price": 31000, "method": "pickup"},
"for_party_a": "Above minimum", "for_party_b": "Box adds resale value"
},
"satisfaction_score": 72, "reasoning": "Dropped ₹4K, sweetened deal with box.",
"concessions_made": ["₹35K→₹31K", "Added original box"], "concessions_requested": []
},
{
"round_number": 4, "proposer": "B", "action": "accept",
"proposal": {
"summary": "AGREED: PS5 + 2 controllers + 3 games + box for ₹29,500. Pickup Andheri.",
"details": {
"item": "PS5 + 2 controllers + 3 games + original box",
"price": 29500, "method": "pickup from Andheri",
"settlement": {"payee_upi": "seller@upi", "payee_name": "Seller", "amount": 29500}
},
"for_party_a": "Above ₹30K minimum", "for_party_b": "Full bundle under ₹30K"
},
"satisfaction_score": 78, "reasoning": "Fair split. Bundle worth it.",
"concessions_made": ["₹27K→₹29.5K"], "concessions_requested": []
}
]
}
}
def get_mock_scenario(feature_type: str) -> dict:
return MOCK_SCENARIOS.get(feature_type, MOCK_SCENARIOS["expenses"])

View File

View File

@@ -0,0 +1,45 @@
PERSONALITY_MODIFIERS = {
"aggressive": """
PERSONALITY: AGGRESSIVE HAGGLER
- Open with ambitious proposals strongly in your human's favor
- Concede slowly and in small increments
- Use anchoring: start far from center, pull the other side toward you
- Frame every concession as a major sacrifice
- Maintain firm positions on medium-priority items, not just hard constraints
- Only make final concessions when no further value can be extracted
""",
"people_pleaser": """
PERSONALITY: PEOPLE PLEASER
- Open with balanced proposals showing good faith
- Concede quickly when the other side has reasonable arguments
- Prioritize maintaining a positive relationship over winning every point
- Accept proposals with satisfaction scores as low as 55 (normally 70+)
- Avoid letting negotiations drag past 3 rounds if possible
""",
"analytical": """
PERSONALITY: DATA-DRIVEN ANALYST
- Open with proposals backed by market data, averages, and benchmarks
- Request tool calls (web search) to verify claims and prices before countering
- Frame all arguments with numbers: "market rate is X", "fair value is Z"
- Concede only when the other side presents data that contradicts your position
- Include price comparisons and market references in every proposal
""",
"empathetic": """
PERSONALITY: EMPATHETIC MEDIATOR
- Acknowledge the other side's likely concerns in every proposal
- Identify underlying interests behind positions
- Propose creative win-wins that satisfy both sides' underlying needs
- Offer concessions proactively when you sense the other side values something more
- Focus on expanding the pie rather than dividing it
""",
"balanced": """
PERSONALITY: BALANCED NEGOTIATOR
- Open with reasonable proposals near the midpoint of both sides' positions
- Concede at a moderate pace, matching the other side's concession rate
- Aim for proposals that score 70+ satisfaction for both sides
- Use a mix of data and relationship awareness in arguments
""",
}
def get_personality_modifier(personality: str) -> str:
return PERSONALITY_MODIFIERS.get(personality, PERSONALITY_MODIFIERS["balanced"])

View File

@@ -0,0 +1,24 @@
from pydantic import BaseModel
from typing import Optional, List
from enum import Enum
class MessageType(str, Enum):
INITIATE = "initiate"
PROPOSAL = "proposal"
ACCEPT = "accept"
COUNTER = "counter"
ESCALATE = "escalate"
class AgentMessage(BaseModel):
message_id: str
negotiation_id: str
message_type: MessageType
sender_id: int
receiver_id: int
round_number: int
payload: dict
reasoning: str = ""
satisfaction_score: float = 0.0
concessions_made: List[str] = []
concessions_requested: List[str] = []
timestamp: str = ""

323
negot8/backend/run.py Normal file
View File

@@ -0,0 +1,323 @@
# backend/run.py — THE MAIN ENTRY POINT
import asyncio
import json
import os
import sys
import atexit
# ─── PID lock — prevents two copies of this process running simultaneously ───
import tempfile
_PID_FILE = os.path.join(tempfile.gettempdir(), "negot8_bots.pid")
def _acquire_pid_lock():
"""Write our PID to the lock file. If a previous PID is still alive, kill it first."""
if os.path.exists(_PID_FILE):
try:
old_pid = int(open(_PID_FILE).read().strip())
os.kill(old_pid, 9) # kill the old copy unconditionally
print(f"🔫 Killed previous bot process (PID {old_pid})")
except (ValueError, ProcessLookupError, PermissionError):
pass # already dead — no problem
with open(_PID_FILE, "w") as f:
f.write(str(os.getpid()))
atexit.register(lambda: os.path.exists(_PID_FILE) and os.remove(_PID_FILE))
_acquire_pid_lock()
from telegram.ext import Application, CommandHandler, ConversationHandler, MessageHandler, CallbackQueryHandler, filters
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from agents.personal_agent import PersonalAgent
from agents.negotiation import run_negotiation
from voice.elevenlabs_tts import generate_voice_summary, build_voice_text
from tools.upi_generator import UPIGeneratorTool
import database as db
from config import *
# ─── Socket.IO emitters (optional — only active when api.py is co-running) ───
try:
from api import emit_round_update, emit_negotiation_started, emit_negotiation_resolved
_sio_available = True
except ImportError:
_sio_available = False
async def emit_round_update(*args, **kwargs): pass
async def emit_negotiation_started(*args, **kwargs): pass
async def emit_negotiation_resolved(*args, **kwargs): pass
# ─── Blockchain (Polygon Amoy) ────────────────────────────────────────────────
try:
from blockchain_web3.blockchain import register_agreement_on_chain
_blockchain_available = True
except Exception as _bc_err:
print(f"⚠️ Blockchain module unavailable: {_bc_err}")
_blockchain_available = False
async def register_agreement_on_chain(*args, **kwargs):
return {"success": False, "mock": True, "tx_hash": "0xUNAVAILABLE",
"block_number": 0, "agreement_hash": "0x0",
"explorer_url": "", "gas_used": 0}
personal_agent = PersonalAgent()
upi_tool = UPIGeneratorTool()
pending_coordinations = {}
bot_apps = {}
async def send_to_user(bot, user_id, text, reply_markup=None):
"""Send a message to any user."""
try:
await bot.send_message(chat_id=user_id, text=text, parse_mode="Markdown", reply_markup=reply_markup)
except Exception as e:
print(f"Failed to send to {user_id}: {e}")
async def send_voice_to_user(bot, user_id, audio_path):
"""Send voice note to user."""
try:
with open(audio_path, "rb") as f:
await bot.send_voice(chat_id=user_id, voice=f, caption="🎙 Voice summary from your agent")
except Exception as e:
print(f"Failed to send voice to {user_id}: {e}")
# ─── Resolution handler with voice + UPI ───
async def handle_resolution(negotiation_id, resolution, feature_type,
user_a_id, user_b_id, bot_a, bot_b,
preferences_a, preferences_b):
"""Post-resolution: generate UPI link, voice summary, analytics, send to users."""
status = resolution["status"]
proposal = resolution.get("final_proposal", {})
emoji = "" if status == "resolved" else "⚠️"
summary_text = (
f"{emoji} *Negotiation {'Complete' if status == 'resolved' else 'Needs Input'}!*\n\n"
f"📊 Resolved in {resolution['rounds_taken']} rounds\n\n"
f"📋 *Agreement:*\n{proposal.get('summary', 'See details')}\n\n"
f"*For A:* {proposal.get('for_party_a', 'See details')}\n"
f"*For B:* {proposal.get('for_party_b', 'See details')}"
)
# ─── UPI link (for expense-related features) ───
upi_markup = None
if feature_type in ("expenses", "freelance", "marketplace", "roommate"):
# Try to extract settlement from proposal details
details = proposal.get("details", {})
settlement = details.get("settlement", {})
upi_id = (preferences_a.get("raw_details", {}).get("upi_id") or
preferences_b.get("raw_details", {}).get("upi_id"))
if upi_id and settlement.get("amount"):
upi_result = await upi_tool.execute(
payee_upi=upi_id,
payee_name=settlement.get("payee_name", "User"),
amount=float(settlement["amount"]),
note=f"negoT8: {feature_type} settlement"
)
upi_link = upi_result["upi_link"]
summary_text += f"\n\n💳 *Tap to pay:* ₹{settlement['amount']:,.0f}"
upi_markup = InlineKeyboardMarkup([
[InlineKeyboardButton(
f"💳 Pay ₹{settlement['amount']:,.0f}",
url=upi_link
)]
])
# ─── Send text summary to both ───
await send_to_user(bot_a, user_a_id, summary_text, reply_markup=upi_markup)
await send_to_user(bot_b, user_b_id, summary_text, reply_markup=upi_markup)
# ─── Voice summary ───
voice_text = build_voice_text(feature_type, {
"rounds": resolution["rounds_taken"],
"summary": proposal.get("summary", "resolved"),
**proposal.get("details", {}),
**{k: v for k, v in proposal.items() if k != "details"}
})
voice_path = await generate_voice_summary(voice_text, negotiation_id, VOICE_ID_AGENT_A)
if voice_path:
await send_voice_to_user(bot_a, user_a_id, voice_path)
# Generate with different voice for User B
voice_path_b = await generate_voice_summary(voice_text, f"{negotiation_id}_b", VOICE_ID_AGENT_B)
if voice_path_b:
await send_voice_to_user(bot_b, user_b_id, voice_path_b)
# ─── Compute & store analytics ───
timeline = resolution.get("satisfaction_timeline", [])
concession_log = []
rounds = await db.get_rounds(negotiation_id)
for r in rounds:
concessions = json.loads(r["concessions_made"]) if r["concessions_made"] else []
for c in concessions:
concession_log.append({"round": r["round_number"], "by": "A" if r["proposer_id"] == user_a_id else "B", "gave_up": c})
final_sat_a = timeline[-1]["score_a"] if timeline else 50
final_sat_b = timeline[-1]["score_b"] if timeline else 50
fairness = 100 - abs(final_sat_a - final_sat_b)
await db.store_analytics({
"negotiation_id": negotiation_id,
"satisfaction_timeline": json.dumps(timeline),
"concession_log": json.dumps(concession_log),
"fairness_score": fairness,
"total_concessions_a": sum(1 for c in concession_log if c["by"] == "A"),
"total_concessions_b": sum(1 for c in concession_log if c["by"] == "B"),
})
# ─── Register agreement on Polygon Amoy (invisible to user) ──────────────
blockchain_text = ""
if status == "resolved":
try:
blockchain_result = await register_agreement_on_chain(
negotiation_id = negotiation_id,
feature_type = feature_type,
summary = proposal.get("summary", "Agreement reached"),
resolution_data = resolution,
)
await db.store_blockchain_proof(
negotiation_id = negotiation_id,
tx_hash = blockchain_result["tx_hash"],
block_number = blockchain_result.get("block_number", 0),
agreement_hash = blockchain_result["agreement_hash"],
explorer_url = blockchain_result["explorer_url"],
gas_used = blockchain_result.get("gas_used", 0),
)
if blockchain_result.get("success") and not blockchain_result.get("mock"):
blockchain_text = (
f"\n\n🔗 *Verified on Blockchain*\n"
f"This agreement is permanently recorded on Polygon\\.\n"
f"[View Proof on PolygonScan]({blockchain_result['explorer_url']})"
)
else:
blockchain_text = "\n\n🔗 _Blockchain proof pending\\.\\.\\._"
except Exception as _bc_exc:
print(f"[Blockchain] Non-critical error for {negotiation_id}: {_bc_exc}")
blockchain_text = "\n\n🔗 _Blockchain proof pending\\.\\.\\._"
if blockchain_text:
await send_to_user(bot_a, user_a_id, blockchain_text)
await send_to_user(bot_b, user_b_id, blockchain_text)
# ─── Emit final resolution to dashboard via Socket.IO ───
try:
await emit_negotiation_resolved(negotiation_id, resolution)
except Exception as e:
print(f"[Socket.IO] emit_negotiation_resolved failed (non-critical): {e}")
# ─────────────────────────────────────────────────────────────
# Entry point — starts Bot A + Bot B concurrently
# Per PTB docs (Context7): use context-manager pattern so
# initialize() and shutdown() are always called correctly.
# Source: https://github.com/python-telegram-bot/python-telegram-bot/wiki/Frequently-requested-design-patterns
# ─────────────────────────────────────────────────────────────
async def _reset_telegram_session(name: str, token: str) -> None:
"""
Forcefully clear any stale Telegram polling session for this token.
Steps:
1. deleteWebhook — removes any webhook and drops pending updates
2. getUpdates(offset=-1, timeout=0) — forces the server to close
any open long-poll held by a previously killed process
Both calls are made via asyncio-friendly httpx so we don't block.
"""
import httpx
base = f"https://api.telegram.org/bot{token}"
async with httpx.AsyncClient(timeout=10) as client:
# Step 1 — delete webhook
try:
r = await client.post(f"{base}/deleteWebhook",
json={"drop_pending_updates": True})
desc = r.json().get("description", "ok")
print(f" Bot {name} deleteWebhook: {desc}")
except Exception as e:
print(f" Bot {name} deleteWebhook failed (non-critical): {e}")
# Step 2 — drain pending updates; this causes the Telegram server
# to close any open long-poll connection from a previous process.
try:
r = await client.get(f"{base}/getUpdates",
params={"offset": -1, "timeout": 0,
"limit": 1})
print(f" Bot {name} session drained ✓")
except Exception as e:
print(f" Bot {name} drain failed (non-critical): {e}")
async def _run_single_bot(name: str, app, stop_event: asyncio.Event) -> None:
"""
Run one bot for its full lifecycle using the PTB context-manager pattern.
Context7 source:
async with application: # calls initialize() + shutdown()
await application.start()
await application.updater.start_polling(...)
# … keep alive …
await application.updater.stop()
await application.stop()
"""
from telegram import Update
async with app: # initialize + shutdown
await app.start()
await app.updater.start_polling(
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=True, # ignore stale messages
poll_interval=0.5, # fast reconnect
)
print(f"▶️ Bot {name} polling...")
await stop_event.wait() # block until Ctrl+C
await app.updater.stop()
await app.stop()
print(f" Bot {name} stopped.")
async def run_bots():
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "telegram-bots"))
from bot import create_bot
# ── 1. Database ───────────────────────────────────────────────────────────
await db.init_db()
print("✅ Database initialized")
# ── 2. Collect bot tokens ─────────────────────────────────────────────────
bots_to_run = []
if TELEGRAM_BOT_TOKEN_A:
bots_to_run.append(("A", TELEGRAM_BOT_TOKEN_A))
if TELEGRAM_BOT_TOKEN_B:
bots_to_run.append(("B", TELEGRAM_BOT_TOKEN_B))
if not bots_to_run:
print("❌ No bot tokens found in .env")
return
# ── 3. Reset Telegram sessions ────────────────────────────────────────────
# This clears any long-poll held by a previously killed process.
print("🧹 Resetting Telegram sessions...")
for name, token in bots_to_run:
await _reset_telegram_session(name, token)
# PTB long-poll timeout is 10s — a brief pause lets Telegram's servers
# acknowledge the deleteWebhook + drain before we start polling.
await asyncio.sleep(2)
# ── 4. Signal handler → shared stop event ────────────────────────────────
stop_event = asyncio.Event()
loop = asyncio.get_running_loop()
try:
import signal
loop.add_signal_handler(signal.SIGINT, stop_event.set)
loop.add_signal_handler(signal.SIGTERM, stop_event.set)
except NotImplementedError:
pass # Windows fallback
# ── 5. Build & run all bots concurrently ─────────────────────────────────
apps = [(name, create_bot(token)) for name, token in bots_to_run]
print(f"\n🚀 Starting {len(apps)} bot(s)...\n")
try:
await asyncio.gather(
*[_run_single_bot(name, app, stop_event) for name, app in apps]
)
except (KeyboardInterrupt, asyncio.CancelledError):
stop_event.set()
print("👋 Done.")
if __name__ == "__main__":
asyncio.run(run_bots())

31
negot8/backend/serve.py Normal file
View File

@@ -0,0 +1,31 @@
# backend/serve.py — Launches FastAPI + Socket.IO API server (Milestone 6)
# Run with: uvicorn serve:socket_app --host 0.0.0.0 --port 8000 --reload
#
# This module:
# 1. Initialises the database (creates tables if missing)
# 2. Exports `socket_app` — the combined FastAPI + Socket.IO ASGI app
# that uvicorn (or any ASGI server) can run directly.
#
# The Telegram bot runner (run.py) remains a separate process.
import asyncio
from contextlib import asynccontextmanager
import database as db
from api import socket_app, app # noqa: F401 — re-exported for uvicorn
@asynccontextmanager
async def lifespan(application):
"""FastAPI lifespan: initialise DB on startup."""
await db.init_db()
print("✅ negoT8 API server ready")
yield
print("👋 negoT8 API server shutting down")
# Attach the lifespan to the inner FastAPI app so uvicorn triggers it
app.router.lifespan_context = lifespan
# `socket_app` is the ASGI entry-point (Socket.IO wraps FastAPI).
# Uvicorn command: uvicorn serve:socket_app --port 8000

View File

File diff suppressed because it is too large Load Diff

1
negot8/backend/test_eof Normal file
View File

@@ -0,0 +1 @@
EOF

View File

@@ -0,0 +1,19 @@
import ast, operator
from decimal import Decimal, ROUND_HALF_UP
class CalculatorTool:
name = "calculate"
async def execute(self, expression: str) -> dict:
allowed_ops = {
ast.Add: operator.add, ast.Sub: operator.sub,
ast.Mult: operator.mul, ast.Div: operator.truediv,
}
def _eval(node):
if isinstance(node, ast.Num): return Decimal(str(node.n))
elif isinstance(node, ast.BinOp):
return allowed_ops[type(node.op)](_eval(node.left), _eval(node.right))
raise ValueError(f"Unsupported: {ast.dump(node)}")
tree = ast.parse(expression, mode='eval')
result = float(_eval(tree.body).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP))
return {"expression": expression, "result": result}

View File

@@ -0,0 +1,279 @@
"""
Google Calendar Tool for negoT8.
Allows the scheduling negotiation agent to query a user's real Google Calendar
when they haven't specified available times in their message.
Flow:
1. User runs /connectcalendar in Telegram.
2. Bot sends the OAuth URL (get_oauth_url).
3. User authorises in browser → Google redirects to /api/auth/google/callback.
4. exchange_code() stores the token in the DB.
5. get_free_slots() is called automatically by SchedulingFeature.get_context()
whenever a scheduling negotiation starts without explicit times.
"""
import asyncio
import json
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
import database as db
from config import (
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
GOOGLE_REDIRECT_URI,
GOOGLE_CALENDAR_SCOPES,
)
# ── Module-level PKCE verifier store ──────────────────────────────────────
# Maps telegram_id → code_verifier string generated during get_oauth_url().
# Entries are cleaned up after a successful or failed exchange.
_pending_verifiers: dict[int, str] = {}
def _build_flow():
from google_auth_oauthlib.flow import Flow
return Flow.from_client_config(
{
"web": {
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"redirect_uris": [GOOGLE_REDIRECT_URI],
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
}
},
scopes=GOOGLE_CALENDAR_SCOPES,
redirect_uri=GOOGLE_REDIRECT_URI,
)
class GoogleCalendarTool:
name = "google_calendar"
# ── OAuth helpers ────────────────────────────────────────────────────────
async def is_connected(self, user_id: int) -> bool:
"""Check whether the user has a stored OAuth token."""
token_json = await db.get_calendar_token(user_id)
return token_json is not None
async def get_oauth_url(self, user_id: int) -> str:
"""
Build the Google OAuth2 authorisation URL manually — no PKCE.
Building it by hand avoids the library silently injecting a
code_challenge that we can't recover during token exchange.
"""
from urllib.parse import urlencode
params = {
"client_id": GOOGLE_CLIENT_ID,
"redirect_uri": GOOGLE_REDIRECT_URI,
"response_type": "code",
"scope": " ".join(GOOGLE_CALENDAR_SCOPES),
"access_type": "offline",
"prompt": "consent",
"state": str(user_id),
}
return "https://accounts.google.com/o/oauth2/auth?" + urlencode(params)
async def exchange_code(self, user_id: int, code: str) -> bool:
"""
Exchange the OAuth `code` (received in the callback) for credentials
and persist them in the DB. Returns True on success.
Uses the stored PKCE code_verifier (if any) so Google doesn't reject
the exchange with 'Missing code verifier'.
"""
try:
import httpx
# ── Manual token exchange — avoids PKCE state mismatch ──────────
# We post directly to Google's token endpoint so we're not
# dependent on a Flow instance having the right code_verifier.
verifier = _pending_verifiers.pop(user_id, None)
post_data: dict = {
"code": code,
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"redirect_uri": GOOGLE_REDIRECT_URI,
"grant_type": "authorization_code",
}
if verifier:
post_data["code_verifier"] = verifier
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://oauth2.googleapis.com/token",
data=post_data,
timeout=15.0,
)
if resp.status_code != 200:
print(f"[GoogleCalendar] token exchange HTTP {resp.status_code}: {resp.text}")
return False
token_data = resp.json()
# Build a Credentials-compatible JSON that google-auth can reload
import datetime as _dt
expires_in = token_data.get("expires_in", 3600)
expiry = (
_dt.datetime.utcnow() + _dt.timedelta(seconds=expires_in)
).isoformat() + "Z"
creds_json = json.dumps({
"token": token_data["access_token"],
"refresh_token": token_data.get("refresh_token"),
"token_uri": "https://oauth2.googleapis.com/token",
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"scopes": GOOGLE_CALENDAR_SCOPES,
"expiry": expiry,
})
await db.save_calendar_token(user_id, creds_json)
return True
except Exception as e:
_pending_verifiers.pop(user_id, None) # clean up on failure
print(f"[GoogleCalendar] exchange_code failed for user {user_id}: {e}")
return False
# ── Main tool: free slot discovery ──────────────────────────────────────
async def get_free_slots(
self,
user_id: int,
days_ahead: int = 7,
duration_minutes: int = 60,
timezone_str: str = "Asia/Kolkata",
) -> list[str]:
"""
Return up to 6 free time slots for the user over the next `days_ahead`
days, each at least `duration_minutes` long.
Slots are within business hours (9 AM 7 PM local time) and exclude
any existing calendar events (busy intervals from the freeBusy API).
Returns [] if the user hasn't connected their calendar, or on any
API / network error — never raises so negotiations always continue.
"""
token_json = await db.get_calendar_token(user_id)
if not token_json:
return [] # user hasn't connected calendar
try:
# Run the synchronous Google API calls in a thread pool so we
# don't block the async event loop.
return await asyncio.get_event_loop().run_in_executor(
None,
self._sync_get_free_slots,
token_json,
user_id,
days_ahead,
duration_minutes,
timezone_str,
)
except Exception as e:
print(f"[GoogleCalendar] get_free_slots failed for user {user_id}: {e}")
return []
def _sync_get_free_slots(
self,
token_json: str,
user_id: int,
days_ahead: int,
duration_minutes: int,
timezone_str: str,
) -> list[str]:
"""Synchronous implementation (runs in executor)."""
import asyncio
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
# ── Load & refresh credentials ───────────────────────────────────
creds = Credentials.from_authorized_user_info(
json.loads(token_json), GOOGLE_CALENDAR_SCOPES
)
if creds.expired and creds.refresh_token:
creds.refresh(Request())
# Persist refreshed token (fire-and-forget via a new event loop)
try:
loop = asyncio.new_event_loop()
loop.run_until_complete(
db.save_calendar_token(user_id, creds.to_json())
)
loop.close()
except Exception:
pass # non-critical
service = build("calendar", "v3", credentials=creds, cache_discovery=False)
tz = ZoneInfo(timezone_str)
now = datetime.now(tz)
# Start from the next whole hour
query_start = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
query_end = query_start + timedelta(days=days_ahead)
time_min = query_start.astimezone(timezone.utc).isoformat()
time_max = query_end.astimezone(timezone.utc).isoformat()
# ── freeBusy query ───────────────────────────────────────────────
body = {
"timeMin": time_min,
"timeMax": time_max,
"timeZone": timezone_str,
"items": [{"id": "primary"}],
}
result = service.freebusy().query(body=body).execute()
busy_intervals = result.get("calendars", {}).get("primary", {}).get("busy", [])
# Convert busy intervals to (start, end) datetime pairs
busy = []
for interval in busy_intervals:
b_start = datetime.fromisoformat(interval["start"]).astimezone(tz)
b_end = datetime.fromisoformat(interval["end"]).astimezone(tz)
busy.append((b_start, b_end))
# ── Find free slots in 9 AM 7 PM business hours ───────────────
slot_duration = timedelta(minutes=duration_minutes)
free_slots: list[str] = []
cursor = query_start
while cursor < query_end and len(free_slots) < 6:
# Jump to business hours start if before 9 AM
day_start = cursor.replace(hour=9, minute=0, second=0, microsecond=0)
day_end = cursor.replace(hour=19, minute=0, second=0, microsecond=0)
if cursor < day_start:
cursor = day_start
if cursor >= day_end:
# Move to next day 9 AM
cursor = (cursor + timedelta(days=1)).replace(
hour=9, minute=0, second=0, microsecond=0
)
continue
slot_end = cursor + slot_duration
if slot_end > day_end:
cursor = (cursor + timedelta(days=1)).replace(
hour=9, minute=0, second=0, microsecond=0
)
continue
# Check for conflict with any busy interval
conflict = any(
not (slot_end <= b[0] or cursor >= b[1]) for b in busy
)
if not conflict:
label = cursor.strftime("%a %b %-d %-I:%M") + "" + slot_end.strftime("%-I:%M %p")
free_slots.append(label)
cursor = slot_end # advance by one slot (non-overlapping)
else:
cursor += timedelta(minutes=30) # try next 30-min block
return free_slots

View File

@@ -0,0 +1,513 @@
"""
pdf_generator.py — negoT8 Deal Agreement PDF
Generates a printable/shareable PDF for resolved freelance or marketplace deals.
Uses fpdf2 (pure-Python, zero system deps).
Usage:
from tools.pdf_generator import generate_deal_pdf
pdf_path = await generate_deal_pdf(
negotiation_id = "aa271ee7",
feature_type = "freelance", # "freelance" | "marketplace"
final_proposal = {...}, # final_proposal dict from resolution
user_a = {"id": 123, "name": "Alice", "username": "alice"},
user_b = {"id": 456, "name": "Bob", "username": "bob"},
rounds_taken = 4,
sat_a = 82.0,
sat_b = 78.0,
blockchain_proof = {...} | None,
)
# Returns an absolute path to /tmp/negot8_deal_<neg_id>.pdf
# Caller is responsible for deleting the file after sending.
"""
import asyncio
import os
import textwrap
from datetime import datetime
from typing import Optional
# ── fpdf2 ────────────────────────────────────────────────────────────────────
try:
from fpdf import FPDF
except ImportError as e:
raise ImportError(
"fpdf2 is required for PDF generation.\n"
"Install it with: pip install fpdf2"
) from e
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
_FEATURE_LABELS = {
"freelance": "Freelance Project Agreement",
"marketplace": "Buy / Sell Deal Agreement",
}
_SECTION_FILL = (230, 240, 255) # light blue
_HEADER_FILL = (30, 60, 120) # dark navy
_LINE_COLOR = (180, 180, 200)
_TEXT_DARK = (20, 20, 40)
_TEXT_MUTED = (90, 90, 110)
_GREEN = (20, 130, 60)
_RED = (180, 30, 30)
def _safe(val) -> str:
"""Convert any value to a clean Latin-1 safe string (fpdf2 default encoding)."""
if val is None:
return ""
s = str(val).strip()
# Replace common Unicode dashes / bullets that Latin-1 can't handle
replacements = {
"\u2013": "-", "\u2014": "-", "\u2022": "*",
"\u20b9": "Rs.", "\u2192": "->", "\u2714": "[x]",
"\u2713": "[x]", "\u00d7": "x",
}
for ch, rep in replacements.items():
s = s.replace(ch, rep)
return s.encode("latin-1", errors="replace").decode("latin-1")
def _wrap(text: str, width: int = 90) -> list[str]:
"""Wrap long text into lines of at most `width` characters."""
if not text:
return [""]
return textwrap.wrap(_safe(text), width) or [_safe(text)]
# ─────────────────────────────────────────────────────────────────────────────
# PDF builder
# ─────────────────────────────────────────────────────────────────────────────
class DealPDF(FPDF):
"""Custom FPDF subclass with header/footer branding."""
def header(self):
# Navy banner
self.set_fill_color(*_HEADER_FILL)
self.rect(0, 0, 210, 22, "F")
self.set_font("Helvetica", "B", 15)
self.set_text_color(255, 255, 255)
self.set_xy(10, 4)
self.cell(0, 8, "negoT8", ln=False)
self.set_font("Helvetica", "", 9)
self.set_text_color(200, 210, 240)
self.set_xy(10, 13)
self.cell(0, 5, "AI-Negotiated Deal Agreement | Blockchain-Verified", ln=True)
self.set_draw_color(*_LINE_COLOR)
self.set_line_width(0.3)
self.ln(4)
def footer(self):
self.set_y(-14)
self.set_font("Helvetica", "I", 8)
self.set_text_color(*_TEXT_MUTED)
self.cell(
0, 5,
f"negoT8 | Generated {datetime.utcnow().strftime('%d %b %Y %H:%M')} UTC "
f"| Page {self.page_no()}",
align="C",
)
# ── Section title ─────────────────────────────────────────────────────────
def section_title(self, title: str):
self.ln(3)
self.set_fill_color(*_SECTION_FILL)
self.set_text_color(*_TEXT_DARK)
self.set_font("Helvetica", "B", 10)
self.set_draw_color(*_LINE_COLOR)
self.set_line_width(0.3)
self.cell(0, 7, f" {_safe(title)}", border="B", ln=True, fill=True)
self.ln(1)
# ── Key-value row ─────────────────────────────────────────────────────────
def kv_row(self, key: str, value: str, bold_value: bool = False):
self.set_font("Helvetica", "B", 9)
self.set_text_color(*_TEXT_MUTED)
self.cell(52, 6, _safe(key), ln=False)
self.set_font("Helvetica", "B" if bold_value else "", 9)
self.set_text_color(*_TEXT_DARK)
# Multi-line safe: wrap long values
lines = _wrap(value, 70)
self.cell(0, 6, lines[0], ln=True)
for extra in lines[1:]:
self.cell(52, 5, "", ln=False)
self.set_font("Helvetica", "", 9)
self.cell(0, 5, extra, ln=True)
# ── Bullet item ───────────────────────────────────────────────────────────
def bullet(self, text: str):
self.set_font("Helvetica", "", 9)
self.set_text_color(*_TEXT_DARK)
lines = _wrap(text, 85)
first = True
for line in lines:
prefix = " * " if first else " "
self.cell(0, 5, f"{prefix}{line}", ln=True)
first = False
# ── Thin horizontal rule ──────────────────────────────────────────────────
def hr(self):
self.set_draw_color(*_LINE_COLOR)
self.set_line_width(0.2)
self.line(10, self.get_y(), 200, self.get_y())
self.ln(2)
# ─────────────────────────────────────────────────────────────────────────────
# Term extractors (feature-specific)
# ─────────────────────────────────────────────────────────────────────────────
def _extract_freelance_terms(final_proposal: dict, preferences_a: dict, preferences_b: dict) -> list[tuple]:
"""Return a list of (label, value) tuples for agreed freelance terms."""
details = final_proposal.get("details", {})
terms = []
budget = (
details.get("budget") or details.get("agreed_budget")
or details.get("price") or details.get("total_amount")
or details.get("agreed_price")
)
if budget:
terms.append(("Agreed Budget", f"Rs. {budget}"))
timeline = details.get("timeline") or details.get("duration") or details.get("deadline")
if timeline:
terms.append(("Timeline / Deadline", str(timeline)))
scope = details.get("scope") or details.get("deliverables") or []
if isinstance(scope, list) and scope:
terms.append(("Scope / Deliverables", " | ".join(str(s) for s in scope[:6])))
elif isinstance(scope, str) and scope:
terms.append(("Scope / Deliverables", scope))
payment = (
details.get("payment_schedule") or details.get("payments")
or details.get("payment_terms")
)
if payment:
terms.append(("Payment Schedule", str(payment)))
upfront = details.get("upfront") or details.get("milestone_1") or details.get("advance")
if upfront:
terms.append(("Upfront / First Milestone", f"Rs. {upfront}"))
ip = details.get("ip_ownership") or details.get("intellectual_property")
if ip:
terms.append(("IP Ownership", str(ip)))
# Fall back to raw preferences if details are sparse
if not terms:
raw_a = preferences_a.get("raw_details", {})
raw_b = preferences_b.get("raw_details", {})
for label, keys in [
("Project / Skill", ["skill", "expertise", "project_type", "tech_stack"]),
("Rate", ["rate", "hourly_rate"]),
("Hours", ["hours", "estimated_hours"]),
("Client Budget", ["budget", "max_budget"]),
]:
val = next((raw_a.get(k) or raw_b.get(k) for k in keys if raw_a.get(k) or raw_b.get(k)), None)
if val:
terms.append((label, str(val)))
# Summary as final catch-all
summary = final_proposal.get("summary", "")
if summary and summary != "Agreement reached":
terms.append(("Summary", summary))
return terms
def _extract_marketplace_terms(final_proposal: dict, preferences_a: dict, preferences_b: dict) -> list[tuple]:
"""Return a list of (label, value) tuples for agreed buy/sell terms."""
details = final_proposal.get("details", {})
raw_a = preferences_a.get("raw_details", {})
raw_b = preferences_b.get("raw_details", {})
terms = []
item = raw_a.get("item") or raw_b.get("item") or details.get("item") or "Item"
terms.append(("Item", str(item)))
price = (
details.get("agreed_price") or details.get("price")
or details.get("final_price") or details.get("amount")
)
if price:
terms.append(("Agreed Price", f"Rs. {price}"))
delivery = details.get("delivery") or details.get("handover") or details.get("pickup")
if delivery:
terms.append(("Delivery / Handover", str(delivery)))
condition = details.get("condition") or raw_a.get("condition") or raw_b.get("condition")
if condition:
terms.append(("Item Condition", str(condition)))
market_ref = details.get("market_price") or details.get("market_reference")
if market_ref:
terms.append(("Market Reference Price", f"Rs. {market_ref}"))
summary = final_proposal.get("summary", "")
if summary and summary != "Agreement reached":
terms.append(("Summary", summary))
return terms
# ─────────────────────────────────────────────────────────────────────────────
# Public API
# ─────────────────────────────────────────────────────────────────────────────
async def generate_deal_pdf(
negotiation_id: str,
feature_type: str,
final_proposal: dict,
user_a: dict,
user_b: dict,
rounds_taken: int,
sat_a: float,
sat_b: float,
preferences_a: Optional[dict] = None,
preferences_b: Optional[dict] = None,
blockchain_proof: Optional[dict] = None,
) -> str:
"""
Build a Deal Agreement PDF and save it to /tmp.
Parameters
----------
negotiation_id : short negotiation ID (e.g. "aa271ee7")
feature_type : "freelance" or "marketplace"
final_proposal : the final_proposal dict from the resolution payload
user_a / user_b : dicts with keys: id, name, username
rounds_taken : number of negotiation rounds
sat_a / sat_b : final satisfaction scores (0100)
preferences_a/b : raw preference dicts (used for term extraction fallbacks)
blockchain_proof: optional dict from register_agreement_on_chain
Returns
-------
Absolute path to the generated PDF file.
"""
# Run the synchronous PDF build in a thread executor so we don't block the event loop
loop = asyncio.get_event_loop()
path = await loop.run_in_executor(
None,
_build_pdf,
negotiation_id, feature_type, final_proposal,
user_a, user_b, rounds_taken, sat_a, sat_b,
preferences_a or {}, preferences_b or {},
blockchain_proof,
)
return path
def _build_pdf(
negotiation_id: str,
feature_type: str,
final_proposal: dict,
user_a: dict,
user_b: dict,
rounds_taken: int,
sat_a: float,
sat_b: float,
preferences_a: dict,
preferences_b: dict,
blockchain_proof: Optional[dict],
) -> str:
"""Synchronous PDF build — called in a thread via run_in_executor."""
doc_label = _FEATURE_LABELS.get(feature_type, "Deal Agreement")
date_str = datetime.utcnow().strftime("%d %B %Y")
neg_short = negotiation_id[:8].upper()
pdf = DealPDF(orientation="P", unit="mm", format="A4")
pdf.set_margins(10, 28, 10)
pdf.set_auto_page_break(auto=True, margin=18)
pdf.add_page()
# ── Title block ───────────────────────────────────────────────────────────
pdf.set_font("Helvetica", "B", 17)
pdf.set_text_color(*_TEXT_DARK)
pdf.cell(0, 10, _safe(doc_label), ln=True, align="C")
pdf.set_font("Helvetica", "", 9)
pdf.set_text_color(*_TEXT_MUTED)
pdf.cell(0, 5, "Generated by negoT8 AI Agents | Hackathon Edition", ln=True, align="C")
pdf.ln(2)
pdf.hr()
# ── Agreement meta ────────────────────────────────────────────────────────
pdf.section_title("Agreement Details")
pdf.kv_row("Agreement ID", neg_short)
pdf.kv_row("Document Type", doc_label)
pdf.kv_row("Date Issued", date_str)
pdf.kv_row("Status", "EXECUTED — Mutually Accepted", bold_value=True)
pdf.ln(2)
# ── Parties ───────────────────────────────────────────────────────────────
pdf.section_title("Contracting Parties")
def _party_name(u: dict) -> str:
name = u.get("name") or u.get("display_name") or ""
uname = u.get("username") or ""
uid = u.get("id") or u.get("telegram_id") or ""
parts = []
if name:
parts.append(name)
if uname:
parts.append(f"@{uname}")
if uid:
parts.append(f"(ID: {uid})")
return " ".join(parts) if parts else "Unknown"
pdf.kv_row("Party A", _party_name(user_a))
pdf.kv_row("Party B", _party_name(user_b))
pdf.ln(2)
# ── Agreed terms ──────────────────────────────────────────────────────────
pdf.section_title("Agreed Terms")
if feature_type == "freelance":
terms = _extract_freelance_terms(final_proposal, preferences_a, preferences_b)
elif feature_type == "marketplace":
terms = _extract_marketplace_terms(final_proposal, preferences_a, preferences_b)
else:
terms = []
summary = final_proposal.get("summary", "")
if summary:
terms.append(("Summary", summary))
for k, v in (final_proposal.get("details") or {}).items():
if v:
terms.append((k.replace("_", " ").title(), str(v)))
if terms:
for label, value in terms:
pdf.kv_row(label, value)
else:
pdf.set_font("Helvetica", "I", 9)
pdf.set_text_color(*_TEXT_MUTED)
pdf.cell(0, 6, " See negotiation summary for full details.", ln=True)
pdf.ln(2)
# ── Negotiation stats ─────────────────────────────────────────────────────
pdf.section_title("Negotiation Statistics")
pdf.kv_row("Rounds Taken", str(rounds_taken))
pdf.kv_row("Party A Satisfaction", f"{sat_a:.0f}%")
pdf.kv_row("Party B Satisfaction", f"{sat_b:.0f}%")
fairness = 100 - abs(sat_a - sat_b)
pdf.kv_row("Fairness Score", f"{fairness:.0f}%", bold_value=True)
pdf.ln(2)
# ── Blockchain proof ──────────────────────────────────────────────────────
pdf.section_title("Blockchain Proof of Agreement")
if blockchain_proof and blockchain_proof.get("tx_hash"):
tx = blockchain_proof.get("tx_hash", "")
blk = blockchain_proof.get("block_number", "")
ahash = blockchain_proof.get("agreement_hash", "")
url = blockchain_proof.get("explorer_url", "")
mock = blockchain_proof.get("mock", False)
pdf.kv_row("Network", "Polygon Amoy Testnet")
pdf.kv_row("TX Hash", tx[:42] + "..." if len(tx) > 42 else tx)
if blk:
pdf.kv_row("Block Number", str(blk))
if ahash:
pdf.kv_row("Agreement Hash", str(ahash)[:42] + "...")
if url and not mock:
pdf.kv_row("Explorer URL", url)
if mock:
pdf.set_font("Helvetica", "I", 8)
pdf.set_text_color(*_TEXT_MUTED)
pdf.cell(0, 5, " * Blockchain entry recorded (testnet / mock mode)", ln=True)
else:
pdf.set_font("Helvetica", "I", 8)
pdf.set_text_color(*_GREEN)
pdf.cell(0, 5, " * Permanently and immutably recorded on the Polygon blockchain.", ln=True)
else:
pdf.set_font("Helvetica", "I", 9)
pdf.set_text_color(*_TEXT_MUTED)
pdf.cell(0, 6, f" Negotiation ID: {_safe(negotiation_id)}", ln=True)
pdf.cell(0, 6, " Blockchain proof will be recorded on deal finalisation.", ln=True)
pdf.ln(2)
# ── Terms & Disclaimer ────────────────────────────────────────────────────
pdf.section_title("Terms & Disclaimer")
disclaimer_lines = [
"1. This document was generated automatically by negoT8 AI agents acting on",
" behalf of the parties named above.",
"2. Both parties accepted the agreed terms via the negoT8 Telegram Bot on",
f" {date_str}.",
"3. The blockchain hash above independently verifies that this agreement existed",
" at the recorded block height.",
"4. This document is provided for reference and record-keeping purposes.",
" For legally binding contracts, please consult a qualified legal professional.",
"5. negoT8 and its AI agents are not parties to this agreement and bear no",
" liability for non-performance by either party.",
]
pdf.set_font("Helvetica", "", 8)
pdf.set_text_color(*_TEXT_MUTED)
for line in disclaimer_lines:
pdf.cell(0, 4.5, _safe(line), ln=True)
pdf.ln(4)
# ── Signature placeholders ────────────────────────────────────────────────
pdf.hr()
pdf.set_font("Helvetica", "B", 9)
pdf.set_text_color(*_TEXT_DARK)
pdf.ln(2)
# Two columns: Party A left, Party B right
y_sig = pdf.get_y()
col_w = 88
pdf.set_xy(10, y_sig)
pdf.cell(col_w, 5, "Party A", ln=False)
pdf.set_x(112)
pdf.cell(col_w, 5, "Party B", ln=True)
# Name lines
pdf.set_font("Helvetica", "", 9)
pdf.set_text_color(*_TEXT_MUTED)
a_display = user_a.get("name") or user_a.get("display_name") or f"@{user_a.get('username','')}"
b_display = user_b.get("name") or user_b.get("display_name") or f"@{user_b.get('username','')}"
pdf.set_x(10)
pdf.cell(col_w, 5, _safe(a_display), ln=False)
pdf.set_x(112)
pdf.cell(col_w, 5, _safe(b_display), ln=True)
pdf.ln(6)
# Draw signature lines
sig_y = pdf.get_y()
pdf.set_draw_color(*_TEXT_DARK)
pdf.set_line_width(0.4)
pdf.line(10, sig_y, 98, sig_y) # Party A
pdf.line(112, sig_y, 200, sig_y) # Party B
pdf.ln(3)
pdf.set_font("Helvetica", "I", 8)
pdf.set_text_color(*_TEXT_MUTED)
pdf.set_x(10)
pdf.cell(col_w, 4, "Accepted via negoT8", ln=False)
pdf.set_x(112)
pdf.cell(col_w, 4, "Accepted via negoT8", ln=True)
# ── Save ──────────────────────────────────────────────────────────────────
out_path = os.path.join(
"/tmp", f"negot8_deal_{negotiation_id}.pdf"
)
pdf.output(out_path)
print(f"[PDF] Deal agreement saved → {out_path}")
return out_path

View File

@@ -0,0 +1,25 @@
from tavily import TavilyClient
from config import TAVILY_API_KEY
class TavilySearchTool:
name = "tavily_search"
def __init__(self):
self.client = TavilyClient(api_key=TAVILY_API_KEY)
async def execute(self, query: str, search_depth: str = "basic") -> dict:
try:
response = self.client.search(
query=query, search_depth=search_depth,
include_answer=True, max_results=5
)
results = [{"title": r.get("title", ""), "content": r.get("content", ""), "url": r.get("url", "")}
for r in response.get("results", [])]
return {
"query": query,
"answer": response.get("answer", ""),
"results": results,
"summary": response.get("answer", results[0]["content"][:200] if results else "No results")
}
except Exception as e:
return {"query": query, "answer": "", "results": [], "summary": f"Search failed: {str(e)}"}

View File

@@ -0,0 +1,16 @@
from urllib.parse import quote
class UPIGeneratorTool:
name = "generate_upi_link"
async def execute(self, payee_upi: str, payee_name: str, amount: float, note: str = "") -> dict:
# upi.link is a web-based redirect service that opens any UPI app on mobile.
# This format works as a Telegram inline-button URL (https:// required).
upi_link = f"https://upi.link/{quote(payee_upi, safe='')}?amount={amount:.2f}&cu=INR"
if note:
upi_link += f"&remarks={quote(note)}"
return {
"upi_link": upi_link,
"display_text": f"Pay ₹{amount:,.0f} to {payee_name}",
"payee_upi": payee_upi, "amount": amount
}

View File

@@ -0,0 +1,52 @@
# backend/voice/elevenlabs_tts.py
import httpx
from config import ELEVENLABS_API_KEY, VOICE_ID_AGENT_A, VOICE_ID_AGENT_B
async def generate_voice_summary(text: str, negotiation_id: str,
voice_id: str = None) -> str:
"""Generate TTS MP3 and return file path. Returns None on failure."""
voice_id = voice_id or VOICE_ID_AGENT_A
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}",
headers={"xi-api-key": ELEVENLABS_API_KEY, "Content-Type": "application/json"},
json={
"text": text[:500], # Budget cap
"model_id": "eleven_flash_v2_5",
"voice_settings": {"stability": 0.5, "similarity_boost": 0.75}
},
timeout=30.0
)
if response.status_code == 200:
import tempfile, os
voice_dir = os.path.join(tempfile.gettempdir(), "negot8_voice")
os.makedirs(voice_dir, exist_ok=True)
filepath = os.path.join(voice_dir, f"voice_{negotiation_id}.mp3")
with open(filepath, "wb") as f:
f.write(response.content)
return filepath
except Exception as e:
print(f"Voice TTS failed: {e}")
return None
# Voice summary templates
VOICE_TEMPLATES = {
"expenses": "Expenses settled! After {rounds} rounds, {payer} owes {payee} {amount} rupees. A UPI payment link has been sent.",
"collaborative": "Decision made! You're going to {choice}. Your agents found the perfect match in {rounds} rounds.",
"scheduling": "Meeting scheduled for {date} at {time}, {location}. Agreed in {rounds} rounds.",
"marketplace": "Deal done! {item} for {price} rupees. Payment link is ready.",
"trip": "Trip planned! {destination} on {dates}, {budget} per person.",
"freelance": "Project agreed! {scope} for {budget} rupees. First milestone payment ready via UPI.",
"roommate": "Decision made! {option}. Cost split arranged.",
"conflict": "Resolution reached! {summary}.",
}
def build_voice_text(feature_type: str, resolution: dict) -> str:
template = VOICE_TEMPLATES.get(feature_type, "Negotiation resolved! Check Telegram for details.")
try:
return template.format(**resolution)[:500]
except KeyError:
summary = resolution.get("summary", "resolved")
return f"Your {feature_type} negotiation is complete: {summary}"[:500]