mirror of
https://github.com/arkorty/B.Tech-Project-III.git
synced 2026-04-19 12:41:48 +00:00
init
This commit is contained in:
0
negot8/backend/agents/__init__.py
Normal file
0
negot8/backend/agents/__init__.py
Normal file
57
negot8/backend/agents/base_agent.py
Normal file
57
negot8/backend/agents/base_agent.py
Normal 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)}
|
||||
111
negot8/backend/agents/matching_agent.py
Normal file
111
negot8/backend/agents/matching_agent.py
Normal 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
|
||||
)
|
||||
125
negot8/backend/agents/negotiation.py
Normal file
125
negot8/backend/agents/negotiation.py
Normal 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
|
||||
97
negot8/backend/agents/negotiator_agent.py
Normal file
97
negot8/backend/agents/negotiator_agent.py
Normal 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."""
|
||||
)
|
||||
51
negot8/backend/agents/personal_agent.py
Normal file
51
negot8/backend/agents/personal_agent.py
Normal 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
491
negot8/backend/api.py
Normal 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)
|
||||
1
negot8/backend/blockchain_web3/__init__.py
Normal file
1
negot8/backend/blockchain_web3/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
pass
|
||||
210
negot8/backend/blockchain_web3/blockchain.py
Normal file
210
negot8/backend/blockchain_web3/blockchain.py
Normal 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
|
||||
67
negot8/backend/blockchain_web3/contract_abi.py
Normal file
67
negot8/backend/blockchain_web3/contract_abi.py
Normal 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
25
negot8/backend/config.py
Normal 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
568
negot8/backend/database.py
Normal 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
|
||||
3
negot8/backend/features/__init__.py
Normal file
3
negot8/backend/features/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from features.base_feature import get_feature, BaseFeature
|
||||
|
||||
__all__ = ["get_feature", "BaseFeature"]
|
||||
65
negot8/backend/features/base_feature.py
Normal file
65
negot8/backend/features/base_feature.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Base feature class for all negoT8 negotiation features.
|
||||
Every feature module must subclass BaseFeature and implement:
|
||||
- get_context() → pre-fetches tool data; returns string injected into negotiators
|
||||
- format_resolution() → returns a Telegram-ready Markdown string
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class BaseFeature(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def get_context(
|
||||
self,
|
||||
preferences_a: dict,
|
||||
preferences_b: dict,
|
||||
user_a_id: int = None,
|
||||
user_b_id: int = None,
|
||||
) -> str:
|
||||
"""
|
||||
Pre-fetch tool results (Tavily, Calculator, etc.) and return a
|
||||
formatted string to inject into the negotiator as domain context.
|
||||
Return "" if no external context is needed.
|
||||
user_a_id / user_b_id are optional — only SchedulingFeature uses them
|
||||
to query Google Calendar when the user hasn't specified times.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def format_resolution(
|
||||
self, resolution: dict, preferences_a: dict, preferences_b: dict
|
||||
) -> str:
|
||||
"""
|
||||
Transform the raw resolution dict into a nice Telegram Markdown string.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ─── Feature dispatcher ───────────────────────────────────────────────────────
|
||||
|
||||
def get_feature(feature_type: str) -> BaseFeature:
|
||||
"""Return the correct BaseFeature subclass for the given feature type."""
|
||||
from features.scheduling import SchedulingFeature
|
||||
from features.expenses import ExpensesFeature
|
||||
from features.freelance import FreelanceFeature
|
||||
from features.roommate import RoommateFeature
|
||||
from features.trip import TripFeature
|
||||
from features.marketplace import MarketplaceFeature
|
||||
from features.collaborative import CollaborativeFeature
|
||||
from features.conflict import ConflictFeature
|
||||
from features.generic import GenericFeature
|
||||
|
||||
mapping = {
|
||||
"scheduling": SchedulingFeature,
|
||||
"expenses": ExpensesFeature,
|
||||
"freelance": FreelanceFeature,
|
||||
"roommate": RoommateFeature,
|
||||
"trip": TripFeature,
|
||||
"marketplace": MarketplaceFeature,
|
||||
"collaborative": CollaborativeFeature,
|
||||
"conflict": ConflictFeature,
|
||||
"generic": GenericFeature,
|
||||
}
|
||||
cls = mapping.get(feature_type, GenericFeature)
|
||||
return cls()
|
||||
121
negot8/backend/features/collaborative.py
Normal file
121
negot8/backend/features/collaborative.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from features.base_feature import BaseFeature
|
||||
from tools.tavily_search import TavilySearchTool
|
||||
from tools.calculator import CalculatorTool
|
||||
from urllib.parse import quote as _url_quote
|
||||
|
||||
_tavily = TavilySearchTool()
|
||||
_calc = CalculatorTool()
|
||||
|
||||
|
||||
class CollaborativeFeature(BaseFeature):
|
||||
|
||||
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
|
||||
"""
|
||||
Use Tavily to fetch REAL restaurant/activity/venue options matching
|
||||
both parties' preferences. Inject real names so agents cite real places.
|
||||
"""
|
||||
raw_a = preferences_a.get("raw_details", {})
|
||||
raw_b = preferences_b.get("raw_details", {})
|
||||
|
||||
decision_type = (
|
||||
raw_a.get("decision_type") or raw_b.get("decision_type")
|
||||
or preferences_a.get("goal", "")
|
||||
)
|
||||
location = (
|
||||
raw_a.get("location") or raw_b.get("location")
|
||||
or raw_a.get("city") or raw_b.get("city")
|
||||
or "Mumbai"
|
||||
)
|
||||
cuisine_a = raw_a.get("cuisine") or raw_a.get("food_preference") or ""
|
||||
cuisine_b = raw_b.get("cuisine") or raw_b.get("food_preference") or ""
|
||||
budget_a = raw_a.get("budget") or raw_a.get("budget_per_person") or ""
|
||||
budget_b = raw_b.get("budget") or raw_b.get("budget_per_person") or ""
|
||||
|
||||
# Build a smart Tavily query
|
||||
cuisine_part = f"{cuisine_a} or {cuisine_b}" if cuisine_a and cuisine_b else (cuisine_a or cuisine_b or "good")
|
||||
query = f"best {cuisine_part} restaurants in {location}"
|
||||
|
||||
tavily_text = ""
|
||||
try:
|
||||
result = await _tavily.execute(query)
|
||||
answer = result.get("answer", "")
|
||||
results = result.get("results", [])[:4]
|
||||
place_lines = []
|
||||
if answer:
|
||||
place_lines.append(f"AI Summary: {answer[:300]}")
|
||||
for r in results:
|
||||
title = r.get("title", "")
|
||||
content = r.get("content", "")[:150]
|
||||
if title:
|
||||
place_lines.append(f" • {title}: {content}")
|
||||
tavily_text = "\n".join(place_lines)
|
||||
except Exception as e:
|
||||
tavily_text = f"Search unavailable ({e}). Use your knowledge of {location} restaurants."
|
||||
|
||||
lines = [
|
||||
"COLLABORATIVE DECISION DOMAIN RULES:",
|
||||
"• ONLY recommend real venues from the search results below. Do NOT invent names.",
|
||||
"• Budget ceiling = the LOWER of both parties' budgets.",
|
||||
"• Both parties' dietary restrictions are absolute (hard constraints).",
|
||||
"• Aim for cuisine intersection first; if no overlap, find a multi-cuisine option.",
|
||||
"",
|
||||
f"Current decision type: {decision_type}",
|
||||
f"Location: {location}",
|
||||
]
|
||||
if cuisine_a:
|
||||
lines.append(f"Person A prefers: {cuisine_a}")
|
||||
if cuisine_b:
|
||||
lines.append(f"Person B prefers: {cuisine_b}")
|
||||
if budget_a:
|
||||
lines.append(f"Person A budget: ₹{budget_a}/person")
|
||||
if budget_b:
|
||||
lines.append(f"Person B budget: ₹{budget_b}/person")
|
||||
|
||||
if tavily_text:
|
||||
lines.append(f"\nREAL OPTIONS from web search (use these):\n{tavily_text}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_resolution(
|
||||
self, resolution: dict, preferences_a: dict, preferences_b: dict
|
||||
) -> str:
|
||||
status = resolution.get("status", "resolved")
|
||||
final = resolution.get("final_proposal", {})
|
||||
details = final.get("details", {})
|
||||
rounds = resolution.get("rounds_taken", "?")
|
||||
summary = resolution.get("summary", "")
|
||||
|
||||
if status == "escalated":
|
||||
return (
|
||||
f"⚠️ *Joint Decision — Your Input Needed*\n\n"
|
||||
f"_{summary}_\n\n"
|
||||
f"Agents proposed options but couldn't finalize. "
|
||||
f"Please pick from the options above."
|
||||
)
|
||||
|
||||
venue = (
|
||||
details.get("venue") or details.get("restaurant") or details.get("place")
|
||||
or details.get("recommendation") or final.get("summary", "")
|
||||
)
|
||||
cuisine = details.get("cuisine") or details.get("food_type") or ""
|
||||
price = details.get("price_range") or details.get("budget") or ""
|
||||
why = details.get("reason") or details.get("why") or summary
|
||||
alternatives = details.get("alternatives") or []
|
||||
|
||||
lines = ["🍽 *Decision Made!*\n"]
|
||||
if venue:
|
||||
venue_str = str(venue) if not isinstance(venue, str) else venue
|
||||
maps_url = f"https://maps.google.com/?q={_url_quote(venue_str)}"
|
||||
lines.append(f"📍 *Recommendation:* [{venue_str}]({maps_url})")
|
||||
if cuisine:
|
||||
lines.append(f"🍴 *Cuisine:* {cuisine}")
|
||||
if price:
|
||||
lines.append(f"💰 *Price range:* {price}")
|
||||
if why:
|
||||
lines.append(f"\n💬 _{why}_")
|
||||
if alternatives and isinstance(alternatives, list):
|
||||
alt_text = ", ".join(str(a) for a in alternatives[:2])
|
||||
lines.append(f"\n_Alternatives considered: {alt_text}_")
|
||||
lines.append(f"\n⏱ Decided in {rounds} round(s)")
|
||||
|
||||
return "\n".join(lines)
|
||||
104
negot8/backend/features/conflict.py
Normal file
104
negot8/backend/features/conflict.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from features.base_feature import BaseFeature
|
||||
|
||||
|
||||
class ConflictFeature(BaseFeature):
|
||||
|
||||
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
|
||||
"""
|
||||
Pure negotiation — no external tool calls needed.
|
||||
Inject mediation principles and relationship context.
|
||||
"""
|
||||
raw_a = preferences_a.get("raw_details", {})
|
||||
raw_b = preferences_b.get("raw_details", {})
|
||||
|
||||
conflict_type = (
|
||||
raw_a.get("conflict_type") or raw_b.get("conflict_type") or "general dispute"
|
||||
)
|
||||
relationship_importance = (
|
||||
raw_a.get("relationship_importance") or raw_b.get("relationship_importance") or "medium"
|
||||
)
|
||||
position_a = raw_a.get("position") or preferences_a.get("goal", "")
|
||||
position_b = raw_b.get("position") or preferences_b.get("goal", "")
|
||||
|
||||
# Concession speed based on relationship importance
|
||||
concession_note = ""
|
||||
if str(relationship_importance).lower() == "high":
|
||||
concession_note = (
|
||||
"⚠️ relationship_importance=HIGH: Both agents should be MORE concessive. "
|
||||
"Preserving the relationship is MORE important than winning every point. "
|
||||
"Accept at satisfaction >= 55 (not the usual 70)."
|
||||
)
|
||||
elif str(relationship_importance).lower() == "low":
|
||||
concession_note = "relationship_importance=LOW: Negotiate firmly on merits."
|
||||
|
||||
lines = [
|
||||
"CONFLICT RESOLUTION DOMAIN RULES:",
|
||||
"• Focus on UNDERLYING INTERESTS, not stated positions.",
|
||||
"• Creative compromise > splitting the difference mechanically.",
|
||||
"• Include a review/adjustment mechanism (e.g., trial period, revisit in 2 weeks).",
|
||||
"• NEVER make personal attacks or bring up unrelated past issues.",
|
||||
"• Propose solutions that both parties can say 'yes' to, even if not their first choice.",
|
||||
"• Frame resolutions as shared agreements, not winners and losers.",
|
||||
]
|
||||
if concession_note:
|
||||
lines.append(f"\n{concession_note}")
|
||||
if conflict_type:
|
||||
lines.append(f"\nConflict type: {conflict_type}")
|
||||
if position_a:
|
||||
lines.append(f"Person A's stated position: {position_a}")
|
||||
if position_b:
|
||||
lines.append(f"Person B's stated position: {position_b}")
|
||||
if relationship_importance:
|
||||
lines.append(f"Relationship importance: {relationship_importance}")
|
||||
|
||||
lines.append("\nAsk yourself: What does each person ACTUALLY need (not just what they said)?")
|
||||
lines.append("Propose something that addresses both underlying needs.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_resolution(
|
||||
self, resolution: dict, preferences_a: dict, preferences_b: dict
|
||||
) -> str:
|
||||
status = resolution.get("status", "resolved")
|
||||
final = resolution.get("final_proposal", {})
|
||||
details = final.get("details", {})
|
||||
rounds = resolution.get("rounds_taken", "?")
|
||||
summary = resolution.get("summary", "")
|
||||
|
||||
raw_a = preferences_a.get("raw_details", {})
|
||||
raw_b = preferences_b.get("raw_details", {})
|
||||
conflict_type = raw_a.get("conflict_type") or raw_b.get("conflict_type") or "Conflict"
|
||||
|
||||
if status == "escalated":
|
||||
return (
|
||||
f"⚠️ *{conflict_type.title()} — Mediation Needed*\n\n"
|
||||
f"_{summary}_\n\n"
|
||||
f"Agents couldn't find a mutually agreeable resolution in {rounds} round(s). "
|
||||
f"Consider a neutral third-party mediator."
|
||||
)
|
||||
|
||||
resolution_type = details.get("resolution_type") or details.get("type") or "compromise"
|
||||
terms = details.get("terms") or details.get("agreement") or []
|
||||
review_mechanism = details.get("review_mechanism") or details.get("review") or ""
|
||||
for_a = final.get("for_party_a") or details.get("for_a") or ""
|
||||
for_b = final.get("for_party_b") or details.get("for_b") or ""
|
||||
|
||||
lines = [f"⚖️ *{conflict_type.title()} — Resolved!*\n"]
|
||||
lines.append(f"🤝 *Resolution type:* {resolution_type}")
|
||||
if for_a:
|
||||
lines.append(f"\n👤 *Person A gets:* {for_a}")
|
||||
if for_b:
|
||||
lines.append(f"👤 *Person B gets:* {for_b}")
|
||||
if terms and isinstance(terms, list):
|
||||
lines.append("\n📋 *Agreed terms:*")
|
||||
for term in terms[:5]:
|
||||
lines.append(f" • {term}")
|
||||
elif terms:
|
||||
lines.append(f"📋 *Terms:* {terms}")
|
||||
if review_mechanism:
|
||||
lines.append(f"\n🔄 *Review:* {review_mechanism}")
|
||||
lines.append(f"\n⏱ Resolved in {rounds} round(s)")
|
||||
if summary and summary != "Agreement reached":
|
||||
lines.append(f"_{summary}_")
|
||||
|
||||
return "\n".join(lines)
|
||||
114
negot8/backend/features/expenses.py
Normal file
114
negot8/backend/features/expenses.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from features.base_feature import BaseFeature
|
||||
from tools.calculator import CalculatorTool
|
||||
|
||||
_calc = CalculatorTool()
|
||||
|
||||
|
||||
class ExpensesFeature(BaseFeature):
|
||||
|
||||
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
|
||||
"""
|
||||
Pre-calculate expense totals using the safe Calculator tool.
|
||||
Inject exact figures so the LLM never does arithmetic.
|
||||
"""
|
||||
raw_a = preferences_a.get("raw_details", {})
|
||||
raw_b = preferences_b.get("raw_details", {})
|
||||
|
||||
# Collect line items from both parties
|
||||
items = {}
|
||||
for raw in (raw_a, raw_b):
|
||||
expenses = raw.get("expenses") or raw.get("items") or raw.get("line_items") or []
|
||||
if isinstance(expenses, list):
|
||||
for item in expenses:
|
||||
if isinstance(item, dict):
|
||||
name = item.get("name") or item.get("item") or "item"
|
||||
amount = item.get("amount") or item.get("cost") or item.get("price") or 0
|
||||
try:
|
||||
amount = float(amount)
|
||||
except (TypeError, ValueError):
|
||||
amount = 0
|
||||
if amount > 0:
|
||||
items[name] = items.get(name, 0) + amount
|
||||
|
||||
lines = ["EXPENSE SPLITTING DOMAIN RULES:"]
|
||||
lines.append("• Use ONLY the pre-calculated amounts below. NEVER estimate or round differently.")
|
||||
lines.append("• Equal splits (50-50) are the default. Unequal splits need explicit justification.")
|
||||
lines.append("• After reaching agreement, include a 'settlement' key with who pays whom and how much.")
|
||||
lines.append("• Use the calculator results below — do NOT re-calculate with different numbers.")
|
||||
|
||||
if items:
|
||||
total = sum(items.values())
|
||||
lines.append(f"\nLine items (pre-verified by Calculator tool):")
|
||||
for name, amount in items.items():
|
||||
half = amount / 2
|
||||
lines.append(f" • {name}: ₹{amount:,.0f} → 50-50 split = ₹{half:,.2f} each")
|
||||
lines.append(f"\nTotal: ₹{total:,.0f} → 50-50 = ₹{total/2:,.2f} each")
|
||||
else:
|
||||
lines.append("\nNo line items found — extract amounts from the preferences and calculate fair splits.")
|
||||
|
||||
# UPI info
|
||||
upi_a = raw_a.get("upi_id") or raw_a.get("upi")
|
||||
upi_b = raw_b.get("upi_id") or raw_b.get("upi")
|
||||
if upi_a:
|
||||
lines.append(f"\nParty A UPI ID: {upi_a}")
|
||||
if upi_b:
|
||||
lines.append(f"\nParty B UPI ID: {upi_b}")
|
||||
if upi_a or upi_b:
|
||||
lines.append("Include the relevant UPI ID in the settlement details of your proposal.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_resolution(
|
||||
self, resolution: dict, preferences_a: dict, preferences_b: dict
|
||||
) -> str:
|
||||
status = resolution.get("status", "resolved")
|
||||
final = resolution.get("final_proposal", {})
|
||||
details = final.get("details", {})
|
||||
rounds = resolution.get("rounds_taken", "?")
|
||||
summary = resolution.get("summary", "Agreement reached")
|
||||
|
||||
if status == "escalated":
|
||||
return (
|
||||
f"⚠️ *Expenses — Human Review Needed*\n\n"
|
||||
f"_{summary}_\n\n"
|
||||
f"Agents couldn't fully agree in {rounds} round(s). "
|
||||
f"Please review the proposed split above."
|
||||
)
|
||||
|
||||
# Build breakdown table
|
||||
line_items = details.get("line_items") or details.get("items") or []
|
||||
raw_settlement = details.get("settlement") or {}
|
||||
# Guard: settlement may be a string summary instead of a dict
|
||||
settlement = raw_settlement if isinstance(raw_settlement, dict) else {}
|
||||
payer = settlement.get("payer") or settlement.get("from") or ""
|
||||
payee = settlement.get("payee") or settlement.get("to") or ""
|
||||
amount = (settlement.get("amount") or details.get("amount")
|
||||
or details.get("total_owed") or (str(raw_settlement) if isinstance(raw_settlement, str) else ""))
|
||||
|
||||
lines = ["💰 *Expenses Settled!*\n"]
|
||||
|
||||
if line_items and isinstance(line_items, list):
|
||||
lines.append("📊 *Breakdown:*")
|
||||
for item in line_items:
|
||||
if isinstance(item, dict):
|
||||
name = item.get("name") or item.get("item", "Item")
|
||||
cost = item.get("amount") or item.get("cost") or ""
|
||||
split = item.get("split") or item.get("ratio") or "50-50"
|
||||
a_pays = item.get("party_a") or item.get("a_pays") or ""
|
||||
b_pays = item.get("party_b") or item.get("b_pays") or ""
|
||||
if a_pays and b_pays:
|
||||
lines.append(f" • {name} (₹{cost}) — {split} → A: ₹{a_pays} / B: ₹{b_pays}")
|
||||
else:
|
||||
lines.append(f" • {name}: {split} split")
|
||||
lines.append("")
|
||||
|
||||
if payer and amount:
|
||||
lines.append(f"💸 *{payer} owes {payee}: ₹{amount}*")
|
||||
elif amount:
|
||||
lines.append(f"💸 *Settlement amount: ₹{amount}*")
|
||||
|
||||
lines.append(f"\n⏱ Agreed in {rounds} round(s)")
|
||||
if summary and summary != "Agreement reached":
|
||||
lines.append(f"_{summary}_")
|
||||
|
||||
return "\n".join(lines)
|
||||
142
negot8/backend/features/freelance.py
Normal file
142
negot8/backend/features/freelance.py
Normal file
@@ -0,0 +1,142 @@
|
||||
from features.base_feature import BaseFeature
|
||||
from tools.tavily_search import TavilySearchTool
|
||||
from tools.calculator import CalculatorTool
|
||||
|
||||
_tavily = TavilySearchTool()
|
||||
_calc = CalculatorTool()
|
||||
|
||||
|
||||
class FreelanceFeature(BaseFeature):
|
||||
|
||||
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
|
||||
"""
|
||||
Benchmark market rates via Tavily. Pre-calculate rate × hours
|
||||
and detect if budget is insufficient (forcing scope reduction).
|
||||
"""
|
||||
raw_a = preferences_a.get("raw_details", {})
|
||||
raw_b = preferences_b.get("raw_details", {})
|
||||
|
||||
# Identify freelancer vs client
|
||||
role_a = raw_a.get("role", preferences_a.get("goal", ""))
|
||||
if "client" in str(role_a).lower():
|
||||
freelancer_raw, client_raw = raw_b, raw_a
|
||||
else:
|
||||
freelancer_raw, client_raw = raw_a, raw_b
|
||||
|
||||
skill = (
|
||||
freelancer_raw.get("skill") or freelancer_raw.get("expertise")
|
||||
or freelancer_raw.get("tech_stack") or client_raw.get("project_type")
|
||||
or "software development"
|
||||
)
|
||||
rate = freelancer_raw.get("rate") or freelancer_raw.get("hourly_rate") or ""
|
||||
hours = freelancer_raw.get("hours") or freelancer_raw.get("estimated_hours") or ""
|
||||
client_budget = client_raw.get("budget") or client_raw.get("max_budget") or ""
|
||||
upfront_min = freelancer_raw.get("upfront_minimum") or freelancer_raw.get("upfront") or "50"
|
||||
scope = client_raw.get("required_features") or client_raw.get("scope") or []
|
||||
|
||||
# Pre-calculate rate × hours
|
||||
calc_text = ""
|
||||
if rate and hours:
|
||||
try:
|
||||
total_cost = float(str(rate).replace(",", "")) * float(str(hours).replace(",", ""))
|
||||
calc_text = f"Pre-calculated cost: ₹{rate}/hr × {hours} hrs = ₹{total_cost:,.0f}"
|
||||
if client_budget:
|
||||
budget_float = float(str(client_budget).replace(",", ""))
|
||||
if total_cost > budget_float:
|
||||
affordable_hours = budget_float / float(str(rate).replace(",", ""))
|
||||
calc_text += (
|
||||
f"\n⚠️ Budget shortfall: ₹{client_budget} budget covers only "
|
||||
f"{affordable_hours:.1f} hrs at ₹{rate}/hr. "
|
||||
f"Reduce scope to fit, removing nice-to-haves first."
|
||||
)
|
||||
else:
|
||||
calc_text += f"\n✅ Budget ₹{client_budget} is sufficient."
|
||||
except (ValueError, TypeError):
|
||||
calc_text = f"Rate: ₹{rate}/hr, Estimated hours: {hours}"
|
||||
|
||||
# Market rate benchmark
|
||||
market_text = ""
|
||||
try:
|
||||
query = f"average freelance rate {skill} developer India 2026"
|
||||
result = await _tavily.execute(query)
|
||||
answer = result.get("answer", "")
|
||||
results = result.get("results", [])[:2]
|
||||
parts = []
|
||||
if answer:
|
||||
parts.append(f"Market summary: {answer[:250]}")
|
||||
for r in results:
|
||||
content = r.get("content", "")[:100]
|
||||
title = r.get("title", "")
|
||||
if title:
|
||||
parts.append(f" • {title}: {content}")
|
||||
market_text = "\n".join(parts)
|
||||
except Exception as e:
|
||||
market_text = f"Market search unavailable. Use typical India rates for {skill}."
|
||||
|
||||
lines = [
|
||||
"FREELANCE NEGOTIATION DOMAIN RULES:",
|
||||
"• Budget is a hard constraint for the client — NEVER exceed it.",
|
||||
"• Freelancer's minimum rate is a hard constraint — NEVER go below it.",
|
||||
"• Non-negotiables (IP ownership, upfront minimum) are absolute hard constraints.",
|
||||
"• If budget < full scope cost: reduce scope (nice-to-haves first, then by priority).",
|
||||
"• Payment terms: freelancer pushes for more upfront, client for back-loaded.",
|
||||
"• Scope reduction must preserve the client's core 'must-have' features.",
|
||||
"• After agreement, include UPI ID and first milestone amount in settlement.",
|
||||
]
|
||||
if skill:
|
||||
lines.append(f"\nProject skill/type: {skill}")
|
||||
if calc_text:
|
||||
lines.append(f"\n{calc_text}")
|
||||
if upfront_min:
|
||||
lines.append(f"Freelancer's minimum upfront: {upfront_min}%")
|
||||
if scope and isinstance(scope, list):
|
||||
lines.append(f"Client's required features: {', '.join(str(s) for s in scope[:5])}")
|
||||
if market_text:
|
||||
lines.append(f"\nMARKET RATE DATA (cite this):\n{market_text}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_resolution(
|
||||
self, resolution: dict, preferences_a: dict, preferences_b: dict
|
||||
) -> str:
|
||||
status = resolution.get("status", "resolved")
|
||||
final = resolution.get("final_proposal", {})
|
||||
details = final.get("details", {})
|
||||
rounds = resolution.get("rounds_taken", "?")
|
||||
summary = resolution.get("summary", "")
|
||||
|
||||
if status == "escalated":
|
||||
return (
|
||||
f"⚠️ *Project Deal — Human Review Needed*\n\n"
|
||||
f"_{summary}_\n\n"
|
||||
f"Agents couldn't finalize in {rounds} round(s). "
|
||||
f"Please negotiate scope/budget directly."
|
||||
)
|
||||
|
||||
budget = details.get("budget") or details.get("agreed_budget") or details.get("price") or ""
|
||||
timeline = details.get("timeline") or details.get("duration") or ""
|
||||
scope = details.get("scope") or details.get("deliverables") or []
|
||||
payment_schedule = details.get("payment_schedule") or details.get("payments") or ""
|
||||
milestone_1 = details.get("milestone_1") or details.get("upfront") or ""
|
||||
settlement = details.get("settlement") or {}
|
||||
|
||||
lines = ["💼 *Project Deal Agreed!*\n"]
|
||||
if budget:
|
||||
lines.append(f"💰 *Budget:* ₹{budget}")
|
||||
if timeline:
|
||||
lines.append(f"📅 *Timeline:* {timeline}")
|
||||
if scope and isinstance(scope, list):
|
||||
lines.append(f"📋 *Scope:*")
|
||||
for item in scope[:5]:
|
||||
lines.append(f" ✓ {item}")
|
||||
elif scope:
|
||||
lines.append(f"📋 *Scope:* {scope}")
|
||||
if payment_schedule:
|
||||
lines.append(f"💳 *Payment schedule:* {payment_schedule}")
|
||||
elif milestone_1:
|
||||
lines.append(f"💳 *First milestone payment:* ₹{milestone_1}")
|
||||
lines.append(f"\n⏱ Agreed in {rounds} round(s)")
|
||||
if summary and summary != "Agreement reached":
|
||||
lines.append(f"_{summary}_")
|
||||
|
||||
return "\n".join(lines)
|
||||
62
negot8/backend/features/generic.py
Normal file
62
negot8/backend/features/generic.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from features.base_feature import BaseFeature
|
||||
|
||||
|
||||
class GenericFeature(BaseFeature):
|
||||
"""Fallback feature for any coordination type not matched by the 8 specific features."""
|
||||
|
||||
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
|
||||
goal_a = preferences_a.get("goal", "")
|
||||
goal_b = preferences_b.get("goal", "")
|
||||
|
||||
lines = [
|
||||
"GENERIC COORDINATION RULES:",
|
||||
"• Find the solution that satisfies both parties' stated goals and hard constraints.",
|
||||
"• Be creative — there may be a win-win that isn't obvious from the positions stated.",
|
||||
"• Concede on nice-to-haves first, protect hard constraints at all costs.",
|
||||
"• If completely stuck, propose 2-3 concrete options for humans to choose from.",
|
||||
]
|
||||
if goal_a:
|
||||
lines.append(f"\nPerson A's goal: {goal_a}")
|
||||
if goal_b:
|
||||
lines.append(f"Person B's goal: {goal_b}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_resolution(
|
||||
self, resolution: dict, preferences_a: dict, preferences_b: dict
|
||||
) -> str:
|
||||
status = resolution.get("status", "resolved")
|
||||
final = resolution.get("final_proposal", {})
|
||||
details = final.get("details", {})
|
||||
rounds = resolution.get("rounds_taken", "?")
|
||||
summary = resolution.get("summary", "Agreement reached")
|
||||
|
||||
if status == "escalated":
|
||||
return (
|
||||
f"⚠️ *Coordination — Human Decision Needed*\n\n"
|
||||
f"_{summary}_\n\n"
|
||||
f"Agents explored options but couldn't decide in {rounds} round(s)."
|
||||
)
|
||||
|
||||
for_a = final.get("for_party_a") or details.get("for_a") or ""
|
||||
for_b = final.get("for_party_b") or details.get("for_b") or ""
|
||||
|
||||
lines = ["✅ *Agreement Reached!*\n"]
|
||||
lines.append(f"_{summary}_")
|
||||
if for_a:
|
||||
lines.append(f"\n👤 *For you:* {for_a}")
|
||||
if for_b:
|
||||
lines.append(f"👤 *For them:* {for_b}")
|
||||
|
||||
# Show key details generically
|
||||
filtered = {
|
||||
k: v for k, v in details.items()
|
||||
if k not in ("for_a", "for_b") and v
|
||||
}
|
||||
if filtered:
|
||||
lines.append("\n📋 *Details:*")
|
||||
for k, v in list(filtered.items())[:6]:
|
||||
lines.append(f" • {k.replace('_', ' ').title()}: {v}")
|
||||
|
||||
lines.append(f"\n⏱ Agreed in {rounds} round(s)")
|
||||
return "\n".join(lines)
|
||||
119
negot8/backend/features/marketplace.py
Normal file
119
negot8/backend/features/marketplace.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from features.base_feature import BaseFeature
|
||||
from tools.tavily_search import TavilySearchTool
|
||||
|
||||
_tavily = TavilySearchTool()
|
||||
|
||||
|
||||
class MarketplaceFeature(BaseFeature):
|
||||
|
||||
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
|
||||
"""
|
||||
Fetch real market prices via Tavily so agents negotiate around
|
||||
actual reference prices, not guesses.
|
||||
"""
|
||||
raw_a = preferences_a.get("raw_details", {})
|
||||
raw_b = preferences_b.get("raw_details", {})
|
||||
|
||||
item = (
|
||||
raw_a.get("item") or raw_b.get("item")
|
||||
or preferences_a.get("goal", "item")[:60]
|
||||
)
|
||||
seller_min = raw_a.get("minimum_price") or raw_a.get("min_price") or raw_a.get("asking_price") or ""
|
||||
seller_asking = raw_a.get("asking_price") or raw_a.get("price") or ""
|
||||
buyer_max = raw_b.get("maximum_budget") or raw_b.get("max_budget") or raw_b.get("budget") or ""
|
||||
buyer_offer = raw_b.get("offer_price") or raw_b.get("price") or ""
|
||||
|
||||
# Flip if B is selling
|
||||
role_a = raw_a.get("role", "")
|
||||
role_b = raw_b.get("role", "")
|
||||
if role_b == "seller":
|
||||
seller_min = raw_b.get("minimum_price") or raw_b.get("min_price") or ""
|
||||
seller_asking = raw_b.get("asking_price") or raw_b.get("price") or ""
|
||||
buyer_max = raw_a.get("maximum_budget") or raw_a.get("max_budget") or raw_a.get("budget") or ""
|
||||
buyer_offer = raw_a.get("offer_price") or raw_a.get("price") or ""
|
||||
|
||||
market_text = ""
|
||||
try:
|
||||
query = f"{item} used price India 2026"
|
||||
result = await _tavily.execute(query)
|
||||
answer = result.get("answer", "")
|
||||
results = result.get("results", [])[:3]
|
||||
parts = []
|
||||
if answer:
|
||||
parts.append(f"Market summary: {answer[:300]}")
|
||||
for r in results:
|
||||
title = r.get("title", "")
|
||||
content = r.get("content", "")[:120]
|
||||
if title:
|
||||
parts.append(f" • {title}: {content}")
|
||||
market_text = "\n".join(parts)
|
||||
except Exception as e:
|
||||
market_text = f"Market search unavailable ({e}). Use your knowledge of {item} pricing."
|
||||
|
||||
lines = [
|
||||
"MARKETPLACE NEGOTIATION DOMAIN RULES:",
|
||||
"• Seller must NOT go below their minimum price (hard constraint).",
|
||||
"• Buyer must NOT exceed their maximum budget (hard constraint).",
|
||||
"• Classic anchoring: seller starts at asking price, buyer starts with lower offer.",
|
||||
"• Concede in diminishing increments (e.g., ₹3K, ₹2K, ₹1K).",
|
||||
"• Delivery/pickup can be offered as a non-cash concession worth ₹500-1000.",
|
||||
"• If gap > 20% after 3 rounds, propose splitting the difference or escalate.",
|
||||
"• Cite the market price from the data below to justify your position.",
|
||||
]
|
||||
if item:
|
||||
lines.append(f"\nItem being traded: {item}")
|
||||
if seller_asking:
|
||||
lines.append(f"Seller asking: ₹{seller_asking}")
|
||||
if seller_min:
|
||||
lines.append(f"Seller minimum (hard floor): ₹{seller_min}")
|
||||
if buyer_max:
|
||||
lines.append(f"Buyer maximum budget (hard ceiling): ₹{buyer_max}")
|
||||
if buyer_offer:
|
||||
lines.append(f"Buyer's opening offer: ₹{buyer_offer}")
|
||||
if market_text:
|
||||
lines.append(f"\nMARKET PRICE DATA (cite this):\n{market_text}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_resolution(
|
||||
self, resolution: dict, preferences_a: dict, preferences_b: dict
|
||||
) -> str:
|
||||
status = resolution.get("status", "resolved")
|
||||
final = resolution.get("final_proposal", {})
|
||||
details = final.get("details", {})
|
||||
rounds = resolution.get("rounds_taken", "?")
|
||||
summary = resolution.get("summary", "")
|
||||
|
||||
raw_a = preferences_a.get("raw_details", {})
|
||||
raw_b = preferences_b.get("raw_details", {})
|
||||
item = raw_a.get("item") or raw_b.get("item") or "Item"
|
||||
|
||||
if status == "escalated":
|
||||
return (
|
||||
f"⚠️ *{item} Deal — Human Decision Needed*\n\n"
|
||||
f"_{summary}_\n\n"
|
||||
f"Agents couldn't bridge the price gap in {rounds} round(s). "
|
||||
f"Please negotiate directly."
|
||||
)
|
||||
|
||||
agreed_price = (
|
||||
details.get("agreed_price") or details.get("price")
|
||||
or details.get("final_price") or details.get("amount")
|
||||
or final.get("summary", "")
|
||||
)
|
||||
delivery = details.get("delivery") or details.get("handover") or ""
|
||||
market_ref = details.get("market_price") or details.get("market_reference") or ""
|
||||
|
||||
lines = [f"🛒 *Deal Closed!*\n"]
|
||||
lines.append(f"📦 *Item:* {item}")
|
||||
if agreed_price:
|
||||
lines.append(f"💰 *Agreed price:* ₹{agreed_price}")
|
||||
if delivery:
|
||||
lines.append(f"🚚 *Delivery/Handover:* {delivery}")
|
||||
if market_ref:
|
||||
lines.append(f"📊 *Market reference:* ₹{market_ref}")
|
||||
lines.append(f"\n⏱ Deal closed in {rounds} round(s)")
|
||||
if summary and summary != "Agreement reached":
|
||||
lines.append(f"_{summary}_")
|
||||
|
||||
return "\n".join(lines)
|
||||
123
negot8/backend/features/roommate.py
Normal file
123
negot8/backend/features/roommate.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from features.base_feature import BaseFeature
|
||||
from tools.tavily_search import TavilySearchTool
|
||||
|
||||
_tavily = TavilySearchTool()
|
||||
|
||||
|
||||
class RoommateFeature(BaseFeature):
|
||||
|
||||
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
|
||||
"""
|
||||
Fetch real product/plan options via Tavily (e.g., actual WiFi plans,
|
||||
furniture prices) so agents propose real options.
|
||||
"""
|
||||
raw_a = preferences_a.get("raw_details", {})
|
||||
raw_b = preferences_b.get("raw_details", {})
|
||||
|
||||
decision_type = (
|
||||
raw_a.get("decision_type") or raw_b.get("decision_type")
|
||||
or "shared living decision"
|
||||
)
|
||||
city = (
|
||||
raw_a.get("city") or raw_b.get("city")
|
||||
or raw_a.get("location") or raw_b.get("location")
|
||||
or "India"
|
||||
)
|
||||
budget_a = raw_a.get("budget") or raw_a.get("max_budget") or ""
|
||||
budget_b = raw_b.get("budget") or raw_b.get("max_budget") or ""
|
||||
|
||||
# Build a Tavily query based on decision type
|
||||
if "wifi" in str(decision_type).lower() or "internet" in str(decision_type).lower():
|
||||
query = f"best WiFi broadband plans {city} 2026 price speed"
|
||||
elif "furniture" in str(decision_type).lower():
|
||||
query = f"furniture prices India 2026 online shopping"
|
||||
elif "chore" in str(decision_type).lower() or "cleaning" in str(decision_type).lower():
|
||||
query = f"chore schedule roommates fair division strategies"
|
||||
else:
|
||||
query = f"{decision_type} options India 2026"
|
||||
|
||||
search_text = ""
|
||||
try:
|
||||
result = await _tavily.execute(query)
|
||||
answer = result.get("answer", "")
|
||||
results = result.get("results", [])[:4]
|
||||
parts = []
|
||||
if answer:
|
||||
parts.append(f"Summary: {answer[:300]}")
|
||||
for r in results:
|
||||
title = r.get("title", "")
|
||||
content = r.get("content", "")[:150]
|
||||
if title:
|
||||
parts.append(f" • {title}: {content}")
|
||||
search_text = "\n".join(parts)
|
||||
except Exception as e:
|
||||
search_text = f"Search unavailable. Use your knowledge of {decision_type} options in {city}."
|
||||
|
||||
lines = [
|
||||
"ROOMMATE DECISION DOMAIN RULES:",
|
||||
"• Only propose options (plans, products) that appear in the real data below.",
|
||||
"• Budget ceiling = lower of both parties' stated budgets.",
|
||||
"• Unequal cost splits need usage-based justification.",
|
||||
"• Both parties must stay within their stated budget constraints.",
|
||||
"• If no option satisfies both budgets, propose cheapest viable option + fair split.",
|
||||
]
|
||||
if decision_type:
|
||||
lines.append(f"\nDecision type: {decision_type}")
|
||||
if city:
|
||||
lines.append(f"Location: {city}")
|
||||
if budget_a:
|
||||
lines.append(f"Person A max budget: ₹{budget_a}/month")
|
||||
if budget_b:
|
||||
lines.append(f"Person B max budget: ₹{budget_b}/month")
|
||||
|
||||
if search_text:
|
||||
lines.append(f"\nREAL OPTIONS from web search (only propose from this list):\n{search_text}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_resolution(
|
||||
self, resolution: dict, preferences_a: dict, preferences_b: dict
|
||||
) -> str:
|
||||
status = resolution.get("status", "resolved")
|
||||
final = resolution.get("final_proposal", {})
|
||||
details = final.get("details", {})
|
||||
rounds = resolution.get("rounds_taken", "?")
|
||||
summary = resolution.get("summary", "")
|
||||
|
||||
raw_a = preferences_a.get("raw_details", {})
|
||||
raw_b = preferences_b.get("raw_details", {})
|
||||
decision_type = raw_a.get("decision_type") or raw_b.get("decision_type") or "Decision"
|
||||
|
||||
if status == "escalated":
|
||||
return (
|
||||
f"⚠️ *{decision_type.title()} — Human Decision Needed*\n\n"
|
||||
f"_{summary}_\n\n"
|
||||
f"Agents proposed options but couldn't finalize in {rounds} round(s)."
|
||||
)
|
||||
|
||||
chosen = (
|
||||
details.get("chosen_option") or details.get("plan") or details.get("option")
|
||||
or details.get("decision") or final.get("summary", "")
|
||||
)
|
||||
cost = details.get("monthly_cost") or details.get("cost") or details.get("price") or ""
|
||||
split = details.get("split") or details.get("each_pays") or ""
|
||||
rules = details.get("rules") or details.get("terms") or []
|
||||
|
||||
lines = [f"🏠 *{decision_type.title()} — Decision Made!*\n"]
|
||||
if chosen:
|
||||
lines.append(f"✅ *Choice:* {chosen}")
|
||||
if cost:
|
||||
lines.append(f"💰 *Cost:* ₹{cost}/month")
|
||||
if split:
|
||||
lines.append(f"💳 *Each pays:* ₹{split}")
|
||||
if rules and isinstance(rules, list):
|
||||
lines.append("📋 *Agreed rules:*")
|
||||
for rule in rules[:4]:
|
||||
lines.append(f" • {rule}")
|
||||
elif rules:
|
||||
lines.append(f"📋 *Terms:* {rules}")
|
||||
lines.append(f"\n⏱ Decided in {rounds} round(s)")
|
||||
if summary and summary != "Agreement reached":
|
||||
lines.append(f"_{summary}_")
|
||||
|
||||
return "\n".join(lines)
|
||||
148
negot8/backend/features/scheduling.py
Normal file
148
negot8/backend/features/scheduling.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from features.base_feature import BaseFeature
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote as _url_quote
|
||||
|
||||
|
||||
class SchedulingFeature(BaseFeature):
|
||||
|
||||
async def get_context(
|
||||
self,
|
||||
preferences_a: dict,
|
||||
preferences_b: dict,
|
||||
user_a_id: int = None,
|
||||
user_b_id: int = None,
|
||||
) -> str:
|
||||
"""
|
||||
Compute overlapping time windows. If a user hasn't provided any times
|
||||
in their message AND they have Google Calendar connected, automatically
|
||||
fetch their free slots from the calendar instead of leaving it empty.
|
||||
"""
|
||||
windows_a = self._extract_windows(preferences_a)
|
||||
windows_b = self._extract_windows(preferences_b)
|
||||
|
||||
# ── Google Calendar fallback: fetch free slots when no times given ──
|
||||
if not windows_a and user_a_id:
|
||||
windows_a = await self._fetch_calendar_slots(user_a_id, tag="A")
|
||||
if not windows_b and user_b_id:
|
||||
windows_b = await self._fetch_calendar_slots(user_b_id, tag="B")
|
||||
|
||||
overlap_lines = []
|
||||
if windows_a and windows_b:
|
||||
for wa in windows_a:
|
||||
for wb in windows_b:
|
||||
if wa.lower() == wb.lower():
|
||||
overlap_lines.append(f" • {wa}")
|
||||
# Simple keyword matching for day/time overlap
|
||||
elif any(
|
||||
word in wa.lower() for word in wb.lower().split()
|
||||
if len(word) > 3
|
||||
):
|
||||
overlap_lines.append(f" • {wa} (aligns with {wb})")
|
||||
|
||||
location_a = preferences_a.get("raw_details", {}).get("location", "")
|
||||
location_b = preferences_b.get("raw_details", {}).get("location", "")
|
||||
|
||||
lines = ["SCHEDULING DOMAIN RULES:"]
|
||||
lines.append("• Only propose times that appear in BOTH parties' available windows.")
|
||||
lines.append("• Duration is non-negotiable — respect it.")
|
||||
lines.append("• If no overlap exists, escalate immediately with closest alternatives.")
|
||||
|
||||
if windows_a:
|
||||
lines.append(f"\nPerson A available: {', '.join(windows_a)}")
|
||||
if windows_b:
|
||||
lines.append(f"Person B available: {', '.join(windows_b)}")
|
||||
if overlap_lines:
|
||||
lines.append(f"\nDetected overlapping windows:\n" + "\n".join(overlap_lines))
|
||||
else:
|
||||
lines.append("\nNo clear overlap detected — propose closest alternatives and offer to adjust.")
|
||||
|
||||
if location_a or location_b:
|
||||
loc = location_a or location_b
|
||||
lines.append(f"\nMeeting location preference: {loc}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _fetch_calendar_slots(
|
||||
self, user_id: int, tag: str = ""
|
||||
) -> list[str]:
|
||||
"""
|
||||
Query Google Calendar for the user's free slots over the next 7 days.
|
||||
Returns a list of human-readable strings like
|
||||
"Mon Mar 2 10:00-11:00 AM". Returns [] silently on any error so
|
||||
the negotiation always continues even without calendar access.
|
||||
"""
|
||||
try:
|
||||
from tools.google_calendar import GoogleCalendarTool
|
||||
tool = GoogleCalendarTool()
|
||||
slots = await tool.get_free_slots(user_id)
|
||||
if slots:
|
||||
label = f" (from Google Calendar{' — Person ' + tag if tag else ''})"
|
||||
print(f"[Calendar] Fetched {len(slots)} free slots for user {user_id}")
|
||||
# Attach the source label only to the first entry for readability
|
||||
return [slots[0] + label] + slots[1:]
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"[Calendar] Could not fetch slots for user {user_id}: {e}")
|
||||
return []
|
||||
|
||||
def _extract_windows(self, preferences: dict) -> list:
|
||||
windows = (
|
||||
preferences.get("raw_details", {}).get("available_windows")
|
||||
or preferences.get("constraints", [])
|
||||
)
|
||||
if isinstance(windows, list):
|
||||
return [str(w) for w in windows]
|
||||
if isinstance(windows, str):
|
||||
return [windows]
|
||||
return []
|
||||
|
||||
def format_resolution(
|
||||
self, resolution: dict, preferences_a: dict, preferences_b: dict
|
||||
) -> str:
|
||||
status = resolution.get("status", "resolved")
|
||||
final = resolution.get("final_proposal", {})
|
||||
details = final.get("details", {})
|
||||
rounds = resolution.get("rounds_taken", "?")
|
||||
summary = resolution.get("summary", "")
|
||||
|
||||
if status == "escalated":
|
||||
return (
|
||||
f"⚠️ *Meeting — Human Decision Needed*\n\n"
|
||||
f"_{summary}_\n\n"
|
||||
f"Our agents couldn't find a perfect time in {rounds} round(s). "
|
||||
f"Please compare calendars directly."
|
||||
)
|
||||
|
||||
proposed_time = (
|
||||
details.get("proposed_datetime")
|
||||
or details.get("date_time")
|
||||
or details.get("time")
|
||||
or final.get("summary", "")
|
||||
)
|
||||
duration = details.get("duration") or preferences_a.get("raw_details", {}).get("duration", "")
|
||||
location = (
|
||||
details.get("location")
|
||||
or preferences_a.get("raw_details", {}).get("location")
|
||||
or preferences_b.get("raw_details", {}).get("location")
|
||||
or "TBD"
|
||||
)
|
||||
meeting_type = details.get("meeting_type") or details.get("type") or "Meeting"
|
||||
reasoning = resolution.get("summary", "")
|
||||
|
||||
lines = [
|
||||
"✅ *Meeting Scheduled!*\n",
|
||||
f"📅 *When:* {proposed_time}",
|
||||
]
|
||||
if duration:
|
||||
lines.append(f"⏱ *Duration:* {duration}")
|
||||
if location and location != "TBD":
|
||||
maps_url = f"https://maps.google.com/?q={_url_quote(str(location))}"
|
||||
lines.append(f"📍 *Location:* [{location}]({maps_url})")
|
||||
else:
|
||||
lines.append(f"📍 *Location:* {location}")
|
||||
lines.append(f"📋 *Type:* {meeting_type}")
|
||||
lines.append(f"\n⏱ Agreed in {rounds} round(s)")
|
||||
if reasoning and reasoning != "Agreement reached":
|
||||
lines.append(f"_{reasoning}_")
|
||||
|
||||
return "\n".join(lines)
|
||||
338
negot8/backend/features/trip.py
Normal file
338
negot8/backend/features/trip.py
Normal file
@@ -0,0 +1,338 @@
|
||||
import asyncio
|
||||
import json
|
||||
from features.base_feature import BaseFeature
|
||||
from tools.tavily_search import TavilySearchTool
|
||||
from tools.calculator import CalculatorTool
|
||||
|
||||
_tavily = TavilySearchTool()
|
||||
_calc = CalculatorTool()
|
||||
|
||||
|
||||
class TripFeature(BaseFeature):
|
||||
|
||||
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
|
||||
"""
|
||||
Compute date intersection across both parties.
|
||||
Fetch real destination options via Tavily.
|
||||
"""
|
||||
raw_a = preferences_a.get("raw_details", {})
|
||||
raw_b = preferences_b.get("raw_details", {})
|
||||
|
||||
dates_a = raw_a.get("available_dates") or raw_a.get("dates") or []
|
||||
dates_b = raw_b.get("available_dates") or raw_b.get("dates") or []
|
||||
budget_a = raw_a.get("budget_per_person") or raw_a.get("budget") or ""
|
||||
budget_b = raw_b.get("budget_per_person") or raw_b.get("budget") or ""
|
||||
dest_pref_a = raw_a.get("destination_preference") or raw_a.get("destination") or ""
|
||||
dest_pref_b = raw_b.get("destination_preference") or raw_b.get("destination") or ""
|
||||
from_city = raw_a.get("origin") or raw_b.get("origin") or raw_a.get("city") or "Mumbai"
|
||||
accom_a = raw_a.get("accommodation_type") or ""
|
||||
accom_b = raw_b.get("accommodation_type") or ""
|
||||
|
||||
# Compute date overlap (simple string intersection)
|
||||
dates_a_set = set(str(d).lower() for d in (dates_a if isinstance(dates_a, list) else [dates_a]))
|
||||
dates_b_set = set(str(d).lower() for d in (dates_b if isinstance(dates_b, list) else [dates_b]))
|
||||
common_dates = dates_a_set & dates_b_set
|
||||
|
||||
# Budget ceiling = lower budget
|
||||
budget_ceiling = ""
|
||||
if budget_a and budget_b:
|
||||
try:
|
||||
ba = float(str(budget_a).replace(",", ""))
|
||||
bb = float(str(budget_b).replace(",", ""))
|
||||
budget_ceiling = f"₹{min(ba, bb):,.0f}/person"
|
||||
except (ValueError, TypeError):
|
||||
budget_ceiling = f"{budget_a} or {budget_b} (take lower)"
|
||||
elif budget_a or budget_b:
|
||||
budget_ceiling = f"₹{budget_a or budget_b}/person"
|
||||
|
||||
# Destination type combined
|
||||
dest_type = " or ".join(filter(None, [dest_pref_a, dest_pref_b])) or "weekend getaway"
|
||||
|
||||
# Tavily: real destination options
|
||||
search_text = ""
|
||||
try:
|
||||
query = f"weekend getaway destinations from {from_city} {dest_type} budget India 2026"
|
||||
result = await _tavily.execute(query)
|
||||
answer = result.get("answer", "")
|
||||
results = result.get("results", [])[:4]
|
||||
parts = []
|
||||
if answer:
|
||||
parts.append(f"Destination summary: {answer[:300]}")
|
||||
for r in results:
|
||||
title = r.get("title", "")
|
||||
content = r.get("content", "")[:150]
|
||||
if title:
|
||||
parts.append(f" • {title}: {content}")
|
||||
search_text = "\n".join(parts)
|
||||
except Exception as e:
|
||||
search_text = f"Search unavailable. Use your knowledge of destinations from {from_city}."
|
||||
|
||||
lines = [
|
||||
"TRIP PLANNING DOMAIN RULES:",
|
||||
"• Date overlap is PRIORITY #1 — only propose dates both parties are available.",
|
||||
"• Budget ceiling = LOWEST budget in the group. No one should overspend.",
|
||||
"• Destination must satisfy at least one preference from each party.",
|
||||
"• Accommodation type: prefer the more comfortable option if budget allows.",
|
||||
"• If no date overlap: escalate immediately with adjusted date suggestions.",
|
||||
]
|
||||
if from_city:
|
||||
lines.append(f"\nOrigin city: {from_city}")
|
||||
if dates_a:
|
||||
lines.append(f"Person A available: {dates_a}")
|
||||
if dates_b:
|
||||
lines.append(f"Person B available: {dates_b}")
|
||||
if common_dates:
|
||||
lines.append(f"✅ OVERLAPPING DATES: {', '.join(common_dates)}")
|
||||
else:
|
||||
lines.append("⚠️ No exact date overlap found — propose closest alternatives.")
|
||||
if budget_ceiling:
|
||||
lines.append(f"Budget ceiling: {budget_ceiling}")
|
||||
if dest_pref_a:
|
||||
lines.append(f"Person A wants: {dest_pref_a}")
|
||||
if dest_pref_b:
|
||||
lines.append(f"Person B wants: {dest_pref_b}")
|
||||
if accom_a or accom_b:
|
||||
lines.append(f"Accommodation preferences: {accom_a or ''} / {accom_b or ''}")
|
||||
if search_text:
|
||||
lines.append(f"\nREAL DESTINATION OPTIONS:\n{search_text}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_resolution(
|
||||
self, resolution: dict, preferences_a: dict, preferences_b: dict
|
||||
) -> str:
|
||||
status = resolution.get("status", "resolved")
|
||||
final = resolution.get("final_proposal", {})
|
||||
details = final.get("details", {})
|
||||
rounds = resolution.get("rounds_taken", "?")
|
||||
summary = resolution.get("summary", "")
|
||||
|
||||
if status == "escalated":
|
||||
return (
|
||||
f"⚠️ *Trip Planning — Human Decision Needed*\n\n"
|
||||
f"_{summary}_\n\n"
|
||||
f"Agents proposed options but couldn't finalize in {rounds} round(s). "
|
||||
f"Please agree on dates and destination directly."
|
||||
)
|
||||
|
||||
destination = details.get("destination") or details.get("place") or final.get("summary", "")
|
||||
dates = details.get("dates") or details.get("travel_dates") or details.get("date") or ""
|
||||
budget = details.get("budget_per_person") or details.get("budget") or ""
|
||||
accommodation = details.get("accommodation") or details.get("stay") or ""
|
||||
activities = details.get("activities") or details.get("things_to_do") or []
|
||||
duration = details.get("duration") or details.get("nights") or ""
|
||||
|
||||
lines = ["✈️ *Trip Planned!*\n"]
|
||||
if destination:
|
||||
lines.append(f"🗺 *Destination:* {destination}")
|
||||
if dates:
|
||||
lines.append(f"📅 *Dates:* {dates}")
|
||||
if duration:
|
||||
lines.append(f"⏱ *Duration:* {duration}")
|
||||
if accommodation:
|
||||
lines.append(f"🏨 *Stay:* {accommodation}")
|
||||
if budget:
|
||||
lines.append(f"💰 *Budget/person:* ₹{budget}")
|
||||
if activities and isinstance(activities, list):
|
||||
lines.append("🎯 *Activities:*")
|
||||
for act in activities[:4]:
|
||||
lines.append(f" • {act}")
|
||||
elif activities:
|
||||
lines.append(f"🎯 *Activities:* {activities}")
|
||||
lines.append(f"\n⏱ Planned in {rounds} round(s)")
|
||||
if summary and summary != "Agreement reached":
|
||||
lines.append(f"_{summary}_")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Group negotiation for 3+ participants (trip planning)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def run_group_negotiation(
|
||||
negotiation_id: str,
|
||||
all_preferences: list,
|
||||
all_user_ids: list,
|
||||
feature_type: str = "trip",
|
||||
personalities: list = None,
|
||||
on_round_update=None,
|
||||
on_resolution=None,
|
||||
) -> dict:
|
||||
"""
|
||||
Multi-agent group negotiation using a mediator approach.
|
||||
One NegotiatorAgent acts as mediator, sees all preferences, proposes.
|
||||
Each participant's NegotiatorAgent scores the proposal.
|
||||
Iterates up to 5 rounds; escalates if no full agreement.
|
||||
"""
|
||||
import database as db
|
||||
from agents.negotiator_agent import NegotiatorAgent
|
||||
|
||||
if personalities is None:
|
||||
personalities = ["balanced"] * len(all_preferences)
|
||||
|
||||
await db.update_negotiation_status(negotiation_id, "active")
|
||||
|
||||
# Create mediator (uses balanced personality)
|
||||
mediator = NegotiatorAgent(personality="balanced")
|
||||
|
||||
# Create per-participant evaluators
|
||||
evaluators = [
|
||||
NegotiatorAgent(personality=p)
|
||||
for p in personalities
|
||||
]
|
||||
|
||||
# Pre-fetch trip context
|
||||
feature = TripFeature()
|
||||
feature_context = ""
|
||||
if len(all_preferences) >= 2:
|
||||
try:
|
||||
feature_context = await feature.get_context(all_preferences[0], all_preferences[1])
|
||||
except Exception:
|
||||
feature_context = ""
|
||||
|
||||
max_rounds = 5
|
||||
current_proposal = None
|
||||
satisfaction_timeline = []
|
||||
|
||||
for round_num in range(1, max_rounds + 1):
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
# Mediator generates/refines proposal
|
||||
mediator_prompt = f"""You are MEDIATING a group {feature_type} negotiation with {len(all_preferences)} participants.
|
||||
|
||||
{"DOMAIN CONTEXT:" + chr(10) + feature_context if feature_context else ""}
|
||||
|
||||
ALL PARTICIPANTS' PREFERENCES:
|
||||
{json.dumps(all_preferences, indent=2)}
|
||||
|
||||
{"PREVIOUS PROPOSAL (refine based on feedback below):" + chr(10) + json.dumps(current_proposal, indent=2) if current_proposal else ""}
|
||||
|
||||
Round {round_num} of {max_rounds}. Generate a proposal that maximizes GROUP satisfaction.
|
||||
Rules:
|
||||
- Budget ceiling = LOWEST budget among all participants.
|
||||
- Dates = intersection of all available dates (or closest compromise).
|
||||
- Every participant must get at least one preference honored.
|
||||
- Return the standard proposal JSON format."""
|
||||
|
||||
try:
|
||||
mediated = await mediator.call(user_prompt=mediator_prompt)
|
||||
except Exception:
|
||||
mediated = {
|
||||
"action": "propose",
|
||||
"proposal": {"summary": "Group proposal", "details": {}},
|
||||
"satisfaction_score": 60,
|
||||
"reasoning": "Mediator generated group proposal",
|
||||
"concessions_made": [],
|
||||
"concessions_requested": [],
|
||||
}
|
||||
|
||||
current_proposal = mediated.get("proposal", {})
|
||||
|
||||
# Score with each participant
|
||||
scores = []
|
||||
low_scorer = None
|
||||
low_score = 100
|
||||
|
||||
for i, (prefs, evaluator) in enumerate(zip(all_preferences, evaluators)):
|
||||
eval_prompt = f"""Evaluate this group {feature_type} proposal for Participant {i+1}.
|
||||
|
||||
PROPOSAL:
|
||||
{json.dumps(current_proposal, indent=2)}
|
||||
|
||||
YOUR PREFERENCES:
|
||||
{json.dumps(prefs, indent=2)}
|
||||
|
||||
Score it and decide: accept (>= 65), counter (40-64), or escalate (< 40 after round 3).
|
||||
Return standard JSON format."""
|
||||
try:
|
||||
eval_response = await evaluator.call(user_prompt=eval_prompt)
|
||||
except Exception:
|
||||
eval_response = {"action": "accept", "satisfaction_score": 65, "reasoning": ""}
|
||||
|
||||
score = eval_response.get("satisfaction_score", 65)
|
||||
scores.append(score)
|
||||
if score < low_score:
|
||||
low_score = score
|
||||
low_scorer = i
|
||||
|
||||
avg_score = sum(scores) / len(scores)
|
||||
sat_entry = {"round": round_num}
|
||||
for i, s in enumerate(scores):
|
||||
sat_entry[f"score_{chr(65+i)}"] = s
|
||||
satisfaction_timeline.append(sat_entry)
|
||||
|
||||
round_data = {
|
||||
"negotiation_id": negotiation_id,
|
||||
"round_number": round_num,
|
||||
"action": "counter" if avg_score < 65 else "accept",
|
||||
"proposal": current_proposal,
|
||||
"satisfaction_score": avg_score,
|
||||
"reasoning": mediated.get("reasoning", "")[:200],
|
||||
"group_scores": scores,
|
||||
"satisfaction_a": scores[0] if scores else 0,
|
||||
"satisfaction_b": scores[1] if len(scores) > 1 else 0,
|
||||
}
|
||||
|
||||
# Save round
|
||||
try:
|
||||
await db.save_round(
|
||||
negotiation_id=negotiation_id,
|
||||
round_number=round_num,
|
||||
proposer_id=all_user_ids[0],
|
||||
proposal=mediated,
|
||||
response_type=round_data["action"],
|
||||
reasoning=round_data["reasoning"],
|
||||
satisfaction_a=scores[0] if scores else 0,
|
||||
satisfaction_b=scores[1] if len(scores) > 1 else 0,
|
||||
concessions_made=[],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if on_round_update:
|
||||
await on_round_update(round_data)
|
||||
|
||||
# All participants satisfied
|
||||
if all(s >= 65 for s in scores):
|
||||
resolution = {
|
||||
"status": "resolved",
|
||||
"final_proposal": current_proposal,
|
||||
"rounds_taken": round_num,
|
||||
"summary": current_proposal.get("summary", "Group trip planned!"),
|
||||
"satisfaction_timeline": satisfaction_timeline,
|
||||
"group_scores": scores,
|
||||
}
|
||||
await db.update_negotiation_status(negotiation_id, "resolved", resolution)
|
||||
if on_resolution:
|
||||
await on_resolution(resolution)
|
||||
return resolution
|
||||
|
||||
# After round 3, if any score < 40, escalate
|
||||
if round_num >= 3 and low_score < 40:
|
||||
resolution = {
|
||||
"status": "escalated",
|
||||
"final_proposal": current_proposal,
|
||||
"rounds_taken": round_num,
|
||||
"summary": f"Participant {low_scorer+1} couldn't agree. Human decision needed.",
|
||||
"satisfaction_timeline": satisfaction_timeline,
|
||||
"group_scores": scores,
|
||||
}
|
||||
await db.update_negotiation_status(negotiation_id, "escalated", resolution)
|
||||
if on_resolution:
|
||||
await on_resolution(resolution)
|
||||
return resolution
|
||||
|
||||
# Max rounds exhausted
|
||||
resolution = {
|
||||
"status": "escalated",
|
||||
"final_proposal": current_proposal,
|
||||
"rounds_taken": max_rounds,
|
||||
"summary": "Max rounds reached. Best group proposal for human review.",
|
||||
"satisfaction_timeline": satisfaction_timeline,
|
||||
"group_scores": scores if scores else [],
|
||||
}
|
||||
await db.update_negotiation_status(negotiation_id, "escalated", resolution)
|
||||
if on_resolution:
|
||||
await on_resolution(resolution)
|
||||
return resolution
|
||||
2
negot8/backend/mock/__init__.py
Normal file
2
negot8/backend/mock/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Mock mode permanently disabled — all calls go to real Gemini
|
||||
MOCK_MODE = False
|
||||
171
negot8/backend/mock/responses.py
Normal file
171
negot8/backend/mock/responses.py
Normal 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"])
|
||||
0
negot8/backend/personality/__init__.py
Normal file
0
negot8/backend/personality/__init__.py
Normal file
45
negot8/backend/personality/profiles.py
Normal file
45
negot8/backend/personality/profiles.py
Normal 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"])
|
||||
24
negot8/backend/protocol/messages.py
Normal file
24
negot8/backend/protocol/messages.py
Normal 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
323
negot8/backend/run.py
Normal 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
31
negot8/backend/serve.py
Normal 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
|
||||
0
negot8/backend/telegram-bots/__init__.py
Normal file
0
negot8/backend/telegram-bots/__init__.py
Normal file
1718
negot8/backend/telegram-bots/bot.py
Normal file
1718
negot8/backend/telegram-bots/bot.py
Normal file
File diff suppressed because it is too large
Load Diff
1
negot8/backend/test_eof
Normal file
1
negot8/backend/test_eof
Normal file
@@ -0,0 +1 @@
|
||||
EOF
|
||||
19
negot8/backend/tools/calculator.py
Normal file
19
negot8/backend/tools/calculator.py
Normal 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}
|
||||
279
negot8/backend/tools/google_calendar.py
Normal file
279
negot8/backend/tools/google_calendar.py
Normal 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
|
||||
513
negot8/backend/tools/pdf_generator.py
Normal file
513
negot8/backend/tools/pdf_generator.py
Normal 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 (0–100)
|
||||
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
|
||||
25
negot8/backend/tools/tavily_search.py
Normal file
25
negot8/backend/tools/tavily_search.py
Normal 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)}"}
|
||||
16
negot8/backend/tools/upi_generator.py
Normal file
16
negot8/backend/tools/upi_generator.py
Normal 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
|
||||
}
|
||||
52
negot8/backend/voice/elevenlabs_tts.py
Normal file
52
negot8/backend/voice/elevenlabs_tts.py
Normal 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]
|
||||
Reference in New Issue
Block a user