""" 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_.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