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

View File

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