From 6414ca4f007168b51154ae694fb0e3cb7b1d7087 Mon Sep 17 00:00:00 2001 From: saket-bhadada Date: Thu, 20 Aug 2026 22:18:05 +0530 Subject: [PATCH] feat(use-cases): Material-change analysis by Saket Bhadada --- .../.env.example | 10 + .../saket-bhadada-material-change/README.md | 73 +++++ .../saket-bhadada-material-change/main.py | 261 ++++++++++++++++++ .../requirements.txt | 2 + .../sample_data/README.md | 7 + 5 files changed, 353 insertions(+) create mode 100644 use-cases/saket-bhadada-material-change/.env.example create mode 100644 use-cases/saket-bhadada-material-change/README.md create mode 100644 use-cases/saket-bhadada-material-change/main.py create mode 100644 use-cases/saket-bhadada-material-change/requirements.txt create mode 100644 use-cases/saket-bhadada-material-change/sample_data/README.md diff --git a/use-cases/saket-bhadada-material-change/.env.example b/use-cases/saket-bhadada-material-change/.env.example new file mode 100644 index 00000000..1967c34b --- /dev/null +++ b/use-cases/saket-bhadada-material-change/.env.example @@ -0,0 +1,10 @@ +# Copy this file to .env and fill in your own values. +# NEVER commit the real .env file (it's already in .gitignore). + +SUPERDOCS_API_KEY=your_api_key_here +SUPERDOCS_BASE_URL=https://docs.superdocs.app/api + +# Optional: cap how many documents/ops this script will burn through +# while you're developing/debugging (see "Budget Loop" trap in README). +SMALL_SAMPLE_MODE=true +MAX_OPERATIONS=5 diff --git a/use-cases/saket-bhadada-material-change/README.md b/use-cases/saket-bhadada-material-change/README.md new file mode 100644 index 00000000..1a17fb31 --- /dev/null +++ b/use-cases/saket-bhadada-material-change/README.md @@ -0,0 +1,73 @@ +# Material-Change Analysis Between Versions + +## Use Case + +**Legal & Procurement** — Automated detection of substantive contractual +changes between two versions of an agreement. + +## What it does + +This tool helps **legal and procurement teams** quickly understand what +actually changed between two versions of a contract, without manually +redlining every clause. Given two versions of an agreement, it: + +- Uploads both versions to SuperDocs +- Asks the AI to **separate substantive/material changes from pure + formatting or stylistic edits** +- Identifies **which party each material change favors**, and why + (liability shifts, indemnification scope, payment terms, etc.) +- Produces a list of concrete **pushback points** a reviewer on the + disadvantaged side could raise during negotiation +- Exports the final analysis as a shareable document + +The goal is to cut down the time a reviewer spends hunting for the clauses +that actually matter in a long redline. + +## How it works + +The script (`main.py`) makes exactly four calls to the SuperDocs API, in order: + +1. **Upload** — sends both contract versions as documents +2. **Chat** — instructs the AI to run the material-change comparison +3. **Approve** — programmatically approves the returned findings +4. **Export** — exports the final analysis document + +## Setup + +```bash +pip install -r requirements.txt +cp .env.example .env +# then fill in SUPERDOCS_API_KEY in .env +``` + +Drop two **synthetic or public** contract versions (never real, confidential +paperwork) into `sample_data/` as `contract_v1.txt` and `contract_v2.txt`, +then run: + +```bash +python main.py +``` + +`SMALL_SAMPLE_MODE` and `MAX_OPERATIONS` in `.env` cap how many API calls the +script will make in a single run, so you don't burn through your API budget +while debugging. + +## Known API quirks handled here + +- **JSON-in-JSON:** the findings/changes payload sometimes comes back as a + JSON-encoded string nested inside the response, so the script runs a + second `json.loads()` pass on it. +- **Long processing times:** deep comparisons on large documents can take + 30 seconds to several minutes with no visible progress. The script polls + patiently instead of timing out early. +- **Budget control:** `SMALL_SAMPLE_MODE` + `MAX_OPERATIONS` act as a hard + cap on API calls during development to prevent runaway usage. + +## SuperDocs API Steps + +| Step | Endpoint | Purpose | +|------|----------|---------| +| 1 | `POST /documents/upload` | Upload both contract versions | +| 2 | `POST /chat` | Send comparison instruction | +| 3 | `POST /approve` | Approve the AI findings | +| 4 | `POST /export` | Export the final analysis | diff --git a/use-cases/saket-bhadada-material-change/main.py b/use-cases/saket-bhadada-material-change/main.py new file mode 100644 index 00000000..4a2cadbc --- /dev/null +++ b/use-cases/saket-bhadada-material-change/main.py @@ -0,0 +1,261 @@ +""" +Material-Change Analysis Between Versions +------------------------------------------ +Use case: Legal & Procurement + +Compares two versions of a (synthetic / non-confidential) contract via the +SuperDocs API, separates substantive changes from formatting noise, +identifies which party each change favors (liability, indemnification, +payment terms, etc.), and lists concrete pushback points a legal/procurement +reviewer could raise. + +Hits exactly four SuperDocs endpoints, in order: + 1. Upload -> send both contract versions + 2. Chat -> instruct the AI to do the material-change comparison + 3. Approve -> approve the returned findings/changes + 4. Export -> export the final analysis + +Handles three known developer traps: + - The JSON Quirk: proposed-changes content comes back as a JSON-encoded + *string* inside the response and needs a second parse. + - The Timeout Trap: deep analysis can take 30s-several minutes with no + visible progress. We poll patiently instead of failing fast. + - The Budget Loop: SMALL_SAMPLE_MODE / MAX_OPERATIONS caps API usage + while debugging. + +NOTE: Endpoint paths/payload shapes below are written against the public +docs at docs.superdocs.app. Confirm exact field names there before running +against a live account -- adjust the constants in the CONFIG section if the +docs differ from what's assumed here. +""" + +import json +import os +import time +import sys + +import requests +from dotenv import load_dotenv + +load_dotenv() + +# ----------------------------- CONFIG ------------------------------------ + +BASE_URL = os.environ.get("SUPERDOCS_BASE_URL", "https://docs.superdocs.app/api") +API_KEY = os.environ.get("SUPERDOCS_API_KEY") + +SMALL_SAMPLE_MODE = os.environ.get("SMALL_SAMPLE_MODE", "true").lower() == "true" +MAX_OPERATIONS = int(os.environ.get("MAX_OPERATIONS", "5")) + +POLL_INTERVAL_SECONDS = 5 +MAX_POLL_MINUTES = 10 # generous ceiling; deep analysis can be slow (Timeout Trap) + +HEADERS = { + "Authorization": f"Bearer {API_KEY}", + "Content-Type": "application/json", +} + +COMPARISON_PROMPT = ( + "Compare version A and version B of this contract. " + "Separate substantive/material changes from pure formatting or " + "stylistic edits. For each material change, state which party " + "(the party represented in version A, or the counterparty) the " + "change favors and why — focus especially on liability shifts, " + "indemnification scope changes, and payment term modifications. " + "Finally, produce a list of concrete pushback points a reviewer " + "on the disadvantaged side could raise in redlines or negotiation." +) + + +# --------------------------- BUDGET GUARD --------------------------------- + +class OperationBudget: + """Simple guard so debugging runs don't silently burn through API calls.""" + + def __init__(self, max_ops: int, enabled: bool): + self.max_ops = max_ops + self.enabled = enabled + self.count = 0 + + def spend(self, label: str): + self.count += 1 + if self.enabled and self.count > self.max_ops: + raise RuntimeError( + f"Budget exceeded: {self.count} operations attempted " + f"(limit {self.max_ops}) while SMALL_SAMPLE_MODE is on. " + f"Last attempted call: {label}. Raise MAX_OPERATIONS or " + f"turn off SMALL_SAMPLE_MODE once you trust the flow." + ) + print(f" [budget] operation {self.count}/{self.max_ops if self.enabled else '∞'}: {label}") + + +budget = OperationBudget(MAX_OPERATIONS, SMALL_SAMPLE_MODE) + + +# ------------------------------ HELPERS ------------------------------------ + +def _require_api_key(): + if not API_KEY: + sys.exit( + "Missing SUPERDOCS_API_KEY. Copy .env.example to .env and fill " + "in your key before running this script." + ) + + +def _double_parse_if_needed(value): + """ + The JSON Quirk: SuperDocs sometimes returns proposed changes/findings as + a JSON-encoded *string* nested inside the JSON response, rather than as + a native object. If we get a string back where we expect an + object/list, run json.loads() a second time. + """ + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError: + # It really was just a plain string -- return as-is. + return value + return value + + +def _post(path: str, payload: dict, label: str) -> dict: + budget.spend(label) + resp = requests.post(f"{BASE_URL}{path}", headers=HEADERS, json=payload, timeout=60) + resp.raise_for_status() + return resp.json() + + +def _get(path: str, label: str) -> dict: + budget.spend(label) + resp = requests.get(f"{BASE_URL}{path}", headers=HEADERS, timeout=60) + resp.raise_for_status() + return resp.json() + + +# ------------------------------ STEP 1: UPLOAD ------------------------------ + +def upload_document(file_path: str, label: str) -> str: + """Upload one contract version. Returns the SuperDocs document id.""" + budget.spend(f"upload:{label}") + with open(file_path, "rb") as f: + files = {"file": (os.path.basename(file_path), f)} + headers = {"Authorization": HEADERS["Authorization"]} # no JSON content-type on multipart + resp = requests.post(f"{BASE_URL}/documents/upload", headers=headers, files=files, timeout=120) + resp.raise_for_status() + data = resp.json() + doc_id = data.get("document_id") or data.get("id") + if not doc_id: + raise RuntimeError(f"Upload response missing document id: {data}") + print(f"Uploaded {label} -> document_id={doc_id}") + return doc_id + + +# ------------------------------ STEP 2: CHAT -------------------------------- + +def request_comparison(doc_id_a: str, doc_id_b: str) -> str: + """Send the material-change comparison instruction. Returns a job/session id to poll.""" + payload = { + "document_ids": [doc_id_a, doc_id_b], + "message": COMPARISON_PROMPT, + } + data = _post("/chat", payload, "chat:request_comparison") + job_id = data.get("job_id") or data.get("session_id") or data.get("id") + if not job_id: + raise RuntimeError(f"Chat response missing job/session id: {data}") + print(f"Comparison requested -> job_id={job_id}") + return job_id + + +def poll_until_ready(job_id: str) -> dict: + """ + The Timeout Trap: deep analysis can take 30s to several minutes with no + visible progress. Poll patiently rather than failing fast. + """ + deadline = time.time() + MAX_POLL_MINUTES * 60 + while time.time() < deadline: + data = _get(f"/chat/{job_id}", "chat:poll_status") + status = data.get("status", "unknown") + print(f" ...status: {status}") + if status in ("complete", "completed", "done"): + return data + if status in ("failed", "error"): + raise RuntimeError(f"Comparison job failed: {data}") + time.sleep(POLL_INTERVAL_SECONDS) + raise TimeoutError( + f"Comparison job {job_id} did not complete within {MAX_POLL_MINUTES} minutes. " + f"This may still be processing on SuperDocs' side -- check the dashboard " + f"before assuming it crashed." + ) + + +def extract_findings(job_result: dict) -> dict: + """Pull out the analysis payload, applying the double-JSON-parse fix.""" + raw = job_result.get("result") or job_result.get("changes") or job_result.get("content") + findings = _double_parse_if_needed(raw) + if findings is None: + raise RuntimeError(f"No findings found in job result: {job_result}") + return findings + + +# ------------------------------ STEP 3: APPROVE ----------------------------- + +def approve_findings(job_id: str, findings: dict) -> dict: + """Programmatically approve the AI's findings to advance the workflow.""" + payload = {"job_id": job_id, "approved": True} + data = _post("/approve", payload, "approve:findings") + print("Findings approved.") + return data + + +# ------------------------------ STEP 4: EXPORT ------------------------------ + +def export_analysis(job_id: str, export_format: str = "pdf") -> str: + """Export the final analysis document and return the download URL.""" + payload = {"job_id": job_id, "format": export_format} + data = _post("/export", payload, "export:analysis") + export_url = data.get("export_url") or data.get("url") + if not export_url: + raise RuntimeError(f"Export response missing url: {data}") + print(f"Exported -> {export_url}") + return export_url + + +# ------------------------------ MAIN FLOW ----------------------------------- + +def main(): + _require_api_key() + + version_a_path = os.path.join("sample_data", "contract_v1.txt") + version_b_path = os.path.join("sample_data", "contract_v2.txt") + + for p in (version_a_path, version_b_path): + if not os.path.exists(p): + sys.exit( + f"Missing sample file: {p}\n" + f"Drop two synthetic/public contract versions into sample_data/ " + f"before running (never use real confidential paperwork)." + ) + + print("== Step 1: Upload ==") + doc_id_a = upload_document(version_a_path, "version_a") + doc_id_b = upload_document(version_b_path, "version_b") + + print("\n== Step 2: Chat (material-change comparison instruction) ==") + job_id = request_comparison(doc_id_a, doc_id_b) + job_result = poll_until_ready(job_id) + findings = extract_findings(job_result) + + print("\n== Findings summary ==") + print(json.dumps(findings, indent=2)[:2000]) + + print("\n== Step 3: Approve ==") + approve_findings(job_id, findings) + + print("\n== Step 4: Export ==") + export_url = export_analysis(job_id) + + print(f"\nDone. Final analysis available at: {export_url}") + + +if __name__ == "__main__": + main() diff --git a/use-cases/saket-bhadada-material-change/requirements.txt b/use-cases/saket-bhadada-material-change/requirements.txt new file mode 100644 index 00000000..2b1be2aa --- /dev/null +++ b/use-cases/saket-bhadada-material-change/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.31.0 +python-dotenv>=1.0.0 diff --git a/use-cases/saket-bhadada-material-change/sample_data/README.md b/use-cases/saket-bhadada-material-change/sample_data/README.md new file mode 100644 index 00000000..d77e1d31 --- /dev/null +++ b/use-cases/saket-bhadada-material-change/sample_data/README.md @@ -0,0 +1,7 @@ +Put two **synthetic or public** versions of a vendor contract / legal +agreement here: + +- `contract_v1.txt` +- `contract_v2.txt` + +Never use real, confidential paperwork for this task.