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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user