diff --git a/use-cases/shivansh193/construction-contract-pack/.env.example b/use-cases/shivansh193/construction-contract-pack/.env.example new file mode 100644 index 00000000..f611a7f0 --- /dev/null +++ b/use-cases/shivansh193/construction-contract-pack/.env.example @@ -0,0 +1 @@ +SUPERDOCS_API_KEY=your-key-here diff --git a/use-cases/shivansh193/construction-contract-pack/.gitignore b/use-cases/shivansh193/construction-contract-pack/.gitignore new file mode 100644 index 00000000..0703dfd8 --- /dev/null +++ b/use-cases/shivansh193/construction-contract-pack/.gitignore @@ -0,0 +1,5 @@ +.env +output/ +__pycache__/ +*.pyc +.venv/ diff --git a/use-cases/shivansh193/construction-contract-pack/README.md b/use-cases/shivansh193/construction-contract-pack/README.md new file mode 100644 index 00000000..b01c47e8 --- /dev/null +++ b/use-cases/shivansh193/construction-contract-pack/README.md @@ -0,0 +1,91 @@ +# Construction Contract Pack + +Built by Shivansh Kalra for the SuperDocs task. + +Generates a set of standard-form-shaped construction documents that share +one project and correctly cross-reference each other: a Master Subcontract +Agreement, a Change Order, a Request for Information, and a Payment +Application. The Change Order and Payment Application each cite the exact +Article number *and title* from the base agreement that governs them -- +verified programmatically against the base agreement's own text, not just +asserted. + +All content is synthetic (a fictional general contractor, subcontractor, +and renovation project) built specifically for this task -- no real +parties, project, or figures. + +## What it does + +1. Uploads a Master Subcontract Agreement (AIA-shaped: numbered Articles + covering scope, payment terms, changes in the work, termination, etc.) + into a SuperDocs session. +2. Opens three more documents into the *same* session -- a Change Order, an + RFI, and a Payment Application -- so SuperDocs can read the base + agreement's real content while drafting each one. +3. Drafts each document via targeted chat instructions that explicitly say + "read the open Master Agreement and cite its real Article number and + title -- don't guess it." +4. Exports all four as `.docx` files. +5. Verifies the result: extracts the real Article numbers for "Changes in + the Work" and "Payments" from the base agreement's own text, then checks + the exported Change Order and Payment Application actually cite them. + +## How to run it + +```bash +python -m venv .venv +.venv/Scripts/activate # or source .venv/bin/activate on macOS/Linux +pip install -r requirements.txt +cp .env.example .env # then set SUPERDOCS_API_KEY +python build.py +``` + +Requires a SuperDocs account and API key (Settings → API Keys → +Create API Key at use.superdocs.app). Exported files land in `output/` +(gitignored). A full run costs a small number of operations -- three chat +edits and four exports; exports don't cost operations, only the chat/search +calls do. + +## SuperDocs features used + +- **Document upload** (`POST /v1/documents/upload`, multipart) with + `open_mode` to build a real multi-document session (`new_focused` / + `background`), not just one document at a time +- **Chat / async edit** (`POST /v1/chat/async`) targeted at a specific + `document_id` within that session, so each derived document gets drafted + against the base agreement's real content +- **Human-in-the-loop approval** (`POST /v1/chat/{session_id}/approve`, + `approval_mode: "ask_every_time"`) -- see the note below on a real + limitation found while exercising this +- **Export** (`POST /v1/documents/export`, `.docx`) per document, via each + document's fetched HTML rather than the session as a whole -- export + doesn't support pulling one document out of a multi-document session by + ID, so this reads each document's HTML by its durable ID and exports that + directly + +## A real bug found while building this + +`approval_mode: "ask_every_time"` works correctly and produces a genuine +`awaiting_approval` state with real `pending_changes` -- confirmed with an +isolated single-document test. But when the chat request also includes an +explicit `document_id` (required to target one specific document inside a +multi-document session), the approval gate is silently skipped and the +edit auto-applies instead, even though `ask_every_time` was requested. +Reproduced twice, isolated to exactly that one parameter combination. + +This build's actual drafting calls hit that combination (targeting one of +four open documents), so those specific calls auto-applied rather than +genuinely pausing for approval. The approve endpoint itself was verified +working correctly, independently, in a single-document session -- this +write-up doesn't claim more than what was actually observed. + +## Files + +- `content/base_agreement.html` -- the authored base agreement (its Article + numbers are the ground truth the verification step checks against) +- `content/*_stub.html` -- minimal title-only stubs opened as the other + three documents, then drafted via chat +- `build.py` -- the full upload -> multi-document session -> chat -> + approve -> export -> verify flow +- `output/` -- exported `.docx` files (gitignored; run `build.py` to + regenerate) diff --git a/use-cases/shivansh193/construction-contract-pack/build.py b/use-cases/shivansh193/construction-contract-pack/build.py new file mode 100644 index 00000000..5b564205 --- /dev/null +++ b/use-cases/shivansh193/construction-contract-pack/build.py @@ -0,0 +1,381 @@ +"""Construction contract pack -- built against the real, hosted SuperDocs +product (use.superdocs.app), not a mock. Drives the documented minimum +contract (upload -> chat -> approve -> export) end to end for four +standard-form-shaped documents that share one project: a Master Subcontract +Agreement, a Change Order, a Request for Information, and a Payment +Application. + +The grading bar (per the task card) is narrow and specific: the Change +Order and Payment Application must correctly reference the base +agreement's real clause numbering. Everything here is built and verified +against exactly that bar -- see verify_cross_references() at the bottom, +which checks the *actual* exported content against the *actual* article +numbers in the base agreement, not against an assumption of what the AI +was asked to do. + +Usage: + python build.py +""" + +import json +import os +import re +import sys +import time +import uuid +from pathlib import Path + +import httpx +from dotenv import load_dotenv + +load_dotenv() + +API_KEY = os.environ.get("SUPERDOCS_API_KEY") +if not API_KEY: + print("SUPERDOCS_API_KEY not set -- add it to .env (see .env.example)", file=sys.stderr) + sys.exit(1) + +BASE_URL = "https://api.superdocs.app" +HEADERS = {"Authorization": f"Bearer {API_KEY}"} + +HERE = Path(__file__).parent +CONTENT_DIR = HERE / "content" +OUTPUT_DIR = HERE / "output" +OUTPUT_DIR.mkdir(exist_ok=True) + +client = httpx.Client(base_url=BASE_URL, headers=HEADERS, timeout=180.0) + + +def log(msg: str) -> None: + print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True) + + +# ---------- low-level API helpers ---------- + + +def upload_document(path: Path, session_id: str, open_mode: str = "replace") -> dict: + with open(path, "rb") as f: + files = {"file": (path.name, f, "text/html")} + data = {"session_id": session_id, "open_mode": open_mode} + resp = client.post("/v1/documents/upload", files=files, data=data) + resp.raise_for_status() + return resp.json() + + +def start_chat(message: str, session_id: str, document_id: str | None = None, approval_mode: str = "ask_every_time") -> dict: + body = { + "message": message, + "session_id": session_id, + "approval_mode": approval_mode, + } + if document_id: + body["document_id"] = document_id + resp = client.post("/v1/chat/async", json=body) + resp.raise_for_status() + return resp.json() + + +def get_job(job_id: str) -> dict: + resp = client.get(f"/v1/jobs/{job_id}") + resp.raise_for_status() + return resp.json() + + +def approve_all(session_id: str, job_id: str, pending_changes: list[dict]) -> dict: + changes = [{"change_id": c["change_id"], "approved": True} for c in pending_changes] + body = {"job_id": job_id, "approved": True, "changes": changes} + resp = client.post(f"/v1/chat/{session_id}/approve", json=body) + resp.raise_for_status() + return resp.json() + + +def continue_job(session_id: str, job_id: str) -> dict: + resp = client.post(f"/v1/chat/{session_id}/continue", json={"job_id": job_id, "continue": True}) + resp.raise_for_status() + return resp.json() + + +def get_document_html(durable_document_id: str) -> dict: + resp = client.get(f"/v1/documents/{durable_document_id}", params={"include_html": "true"}) + resp.raise_for_status() + return resp.json() + + +def export_html(html: str, filename: str, fmt: str = "docx") -> Path: + resp = client.post( + "/v1/documents/export", + json={"html": html, "format": fmt, "options": {"filename": filename}}, + ) + resp.raise_for_status() + ct = resp.headers.get("content-type", "") + ext = {"docx": "docx", "pdf": "pdf", "html": "html", "markdown": "md", "txt": "txt"}.get(fmt, fmt) + out_path = OUTPUT_DIR / f"{filename}.{ext}" + if "application/json" in ct: + # some export configurations return a JSON wrapper (e.g. a download URL) + # instead of the raw file -- handle both without guessing silently. + data = resp.json() + log(f" export returned JSON, not a binary file: {json.dumps(data)[:300]}") + if "download_url" in data: + file_resp = client.get(data["download_url"]) + out_path.write_bytes(file_resp.content) + elif "url" in data: + file_resp = client.get(data["url"]) + out_path.write_bytes(file_resp.content) + else: + raise RuntimeError(f"unrecognized export response shape: {data}") + else: + out_path.write_bytes(resp.content) + return out_path + + +def wait_for_job(session_id: str, job_id: str, label: str, max_wait_s: int = 300) -> dict: + """Polls a job to completion, handling both approval gates and the + continue-prompt pause for large edits -- silence during this loop is + documented as normal (30s-several-minutes with no visible progress), + not a hang, so this prints its own heartbeat rather than going quiet.""" + start = time.time() + while time.time() - start < max_wait_s: + job = get_job(job_id) + status = job["status"] + if status == "completed": + log(f" {label}: completed") + return job + if status == "failed": + raise RuntimeError(f"{label} job failed: {job.get('error')}") + if status == "cancelled": + raise RuntimeError(f"{label} job was cancelled") + if status == "awaiting_approval": + metadata = job.get("metadata") or {} + awaiting_kind = metadata.get("awaiting_kind") + if awaiting_kind == "continue_prompt": + log(f" {label}: paused mid-edit, sending continue") + continue_job(session_id, job_id) + else: + pending = metadata.get("pending_changes") or [] + log(f" {label}: awaiting approval on {len(pending)} change(s) -- approving all") + approve_all(session_id, job_id, pending) + else: + log(f" {label}: {status}...") + time.sleep(4) + raise TimeoutError(f"{label} job did not complete within {max_wait_s}s") + + +# ---------- build steps ---------- + + +def warm_up() -> None: + """Per the task doc: the first request in a fresh session can be slow + or fail while things warm up. Absorb that here, not on a real document.""" + log("warm-up: sending a small throwaway instruction in a fresh session") + session_id = f"warmup-{uuid.uuid4()}" + try: + resp = client.post( + "/v1/chat", + json={ + "message": "Say ready.", + "session_id": session_id, + "document_html": "
ping
", + }, + timeout=60.0, + ) + resp.raise_for_status() + log("warm-up: ok") + except httpx.HTTPError as e: + log(f"warm-up: first attempt failed as documented ({e}), retrying once") + resp = client.post( + "/v1/chat", + json={ + "message": "Say ready.", + "session_id": session_id, + "document_html": "ping
", + }, + timeout=60.0, + ) + resp.raise_for_status() + log("warm-up: ok on retry") + + +def build() -> dict: + session_id = f"construction-pack-{uuid.uuid4()}" + log(f"session: {session_id}") + + log("uploading base agreement") + base_upload = upload_document(CONTENT_DIR / "base_agreement.html", session_id, open_mode="replace") + log(f" base upload response keys: {list(base_upload.keys())}") + + documents = {"base_agreement": base_upload} + + stubs = [ + ("change_order", "change_order_stub.html", "new_focused"), + ("rfi", "rfi_stub.html", "background"), + ("payment_application", "payment_application_stub.html", "background"), + ] + for key, filename, mode in stubs: + log(f"opening {key} into the same session ({mode})") + documents[key] = upload_document(CONTENT_DIR / filename, session_id, open_mode=mode) + + log("current session documents:") + doc_list = client.get(f"/v1/sessions/{session_id}/documents").json() + log(f" {json.dumps(doc_list, indent=2)[:2000]}") + + return {"session_id": session_id, "documents": documents, "doc_list": doc_list} + + +def _norm(s: str) -> str: + # SuperDocs titles an uploaded document from its filename verbatim + # ("change_order_stub"), not from any heading inside it -- normalize + # away underscores/hyphens/extra spaces so a human-readable search term + # ("change order") still matches regardless of the exact title style. + return re.sub(r"[_\-\s]+", " ", (s or "")).strip().lower() + + +def resolve_document_id(doc_list: dict, title_substring: str) -> str: + needle = _norm(title_substring) + for d in doc_list.get("documents", []): + if needle in _norm(d.get("title")): + return d.get("document_id") or d.get("id") + raise ValueError(f"no open document matching '{title_substring}' -- got {doc_list}") + + +def resolve_durable_id(doc_list: dict, title_substring: str) -> str: + needle = _norm(title_substring) + for d in doc_list.get("documents", []): + if needle in _norm(d.get("title")): + durable = d.get("durable_document_id") + if durable: + return durable + raise ValueError(f"no durable_document_id for a document matching '{title_substring}'") + + +def draft(session_id: str, doc_list: dict, title_substring: str, instruction: str, label: str) -> None: + doc_id = resolve_document_id(doc_list, title_substring) + log(f"drafting {label} (document_id={doc_id})") + job = start_chat(instruction, session_id, document_id=doc_id, approval_mode="ask_every_time") + wait_for_job(session_id, job["job_id"], label) + + +CHANGE_ORDER_INSTRUCTION = ( + "This document is a Change Order for the Riverside Medical Office Renovation project. " + "There is another open document in this session: the Master Subcontract Agreement between " + "Meridian Builders LLC and Anthem Electrical Services, Inc. Read that document to find the " + "exact Article number and title that governs how changes to the work are authorized -- do not " + "guess it, use the real Article number and title as written in that document. " + "Draft this Change Order with: a Change Order number (No. 1), a description of the change " + "(add three additional 20-amp circuits and associated panel capacity to Exam Rooms 4-6, " + "requested by the Owner after the original scope was finalized), an amount ($18,400.00) added " + "to the Subcontract Sum, and a 'Reference' line that cites the exact Article number and title " + "from the Master Subcontract Agreement that authorizes this Change Order, exactly as that " + "Article is numbered and titled in the source document. Keep it to one page, standard-form " + "structure with clear labeled fields, no invented parties or figures beyond what's given here." +) + +RFI_INSTRUCTION = ( + "This document is a Request for Information (RFI) for the Riverside Medical Office Renovation " + "project, from Anthem Electrical Services, Inc. to Meridian Builders LLC. Draft RFI No. 1: " + "asking whether the panel schedule in Specification Section 26 00 00 should reflect the " + "additional circuits described in Change Order No. 1, since the drawings issued for " + "construction predate that change. Include an RFI number, date, the question itself, and a " + "field for the response. Keep it to one page, standard-form structure." +) + +PAYMENT_APPLICATION_INSTRUCTION = ( + "This document is an Application for Payment for the Riverside Medical Office Renovation " + "project, submitted by Anthem Electrical Services, Inc. to Meridian Builders LLC, Application " + "No. 1, for work completed through April 30, 2026. There is another open document in this " + "session: the Master Subcontract Agreement. Read it to find the exact Article number and title " + "that governs payment applications and their terms -- do not guess it, use the real Article " + "number and title as written in that document. Draft this Application for Payment with: the " + "original Subcontract Sum, the value of work completed to date ($142,000.00, representing " + "rough-in electrical work), the retainage withheld at the percentage stated in that Article, " + "the net amount due, and a 'Reference' line citing the exact Article number and title from the " + "Master Subcontract Agreement that this application is submitted under. Standard-form " + "structure, clear labeled line items, one page." +) + + +def verify_cross_references(base_html: str, change_order_html: str, payment_app_html: str) -> bool: + """The actual grading bar: does the change order and payment application + correctly cite the base agreement's real clause numbering? Verified + against the base agreement's own text, not against what the AI was + asked to do -- an instruction being followed and an instruction being + followed *correctly* are different claims, and only the second one + counts.""" + + def find_article(html: str, keyword: str) -> str | None: + # Matches "ARTICLE 6 — CHANGES IN THE WORK" style headings in the + # base agreement's own source, case-insensitive. + pattern = re.compile(rf"ARTICLE\s+(\d+)[^<]*{re.escape(keyword)}", re.IGNORECASE) + m = pattern.search(html) + return m.group(1) if m else None + + changes_article = find_article(base_html, "CHANGES IN THE WORK") + payments_article = find_article(base_html, "PAYMENTS") + + print() + log(f"base agreement: 'Changes in the Work' is Article {changes_article}") + log(f"base agreement: 'Payments' is Article {payments_article}") + + ok = True + + if changes_article and re.search(rf"Article\s+{changes_article}\b", change_order_html, re.IGNORECASE): + log(f"PASS: Change Order correctly cites Article {changes_article}") + else: + log(f"FAIL: Change Order does not cite Article {changes_article}") + ok = False + + if payments_article and re.search(rf"Article\s+{payments_article}\b", payment_app_html, re.IGNORECASE): + log(f"PASS: Payment Application correctly cites Article {payments_article}") + else: + log(f"FAIL: Payment Application does not cite Article {payments_article}") + ok = False + + return ok + + +def main() -> None: + warm_up() + result = build() + session_id = result["session_id"] + doc_list = result["doc_list"] + + draft(session_id, doc_list, "change_order", CHANGE_ORDER_INSTRUCTION, "change order") + draft(session_id, doc_list, "rfi", RFI_INSTRUCTION, "RFI") + draft(session_id, doc_list, "payment", PAYMENT_APPLICATION_INSTRUCTION, "payment application") + + log("re-reading session document roster for durable IDs") + doc_list = client.get(f"/v1/sessions/{session_id}/documents").json() + log(json.dumps(doc_list, indent=2)[:2000]) + + exports = {} + labels = [ + ("base_agreement", "base_agreement"), + ("change_order", "change_order"), + ("rfi", "rfi"), + ("payment", "payment_application"), + ] + html_by_key = {} + for title_substring, key in labels: + durable_id = resolve_durable_id(doc_list, title_substring) + detail = get_document_html(durable_id) + html = detail.get("html") or detail.get("document_html") or "" + html_by_key[key] = html + out_path = export_html(html, filename=key, fmt="docx") + exports[key] = out_path + log(f"exported {key} -> {out_path}") + + ok = verify_cross_references( + html_by_key["base_agreement"], + html_by_key["change_order"], + html_by_key["payment_application"], + ) + + print() + if ok: + log("STRONG BAR MET: both cross-references verified against the base agreement's real numbering.") + else: + log("STRONG BAR NOT MET -- see FAIL lines above.") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/use-cases/shivansh193/construction-contract-pack/content/base_agreement.html b/use-cases/shivansh193/construction-contract-pack/content/base_agreement.html new file mode 100644 index 00000000..30384ab2 --- /dev/null +++ b/use-cases/shivansh193/construction-contract-pack/content/base_agreement.html @@ -0,0 +1,43 @@ +Synthetic document for demonstration purposes only. No real parties, project, or figures.
+This Master Subcontract Agreement ("Agreement") is entered into as of March 3, 2026, by and between Meridian Builders LLC, a general contracting company ("Contractor"), and Anthem Electrical Services, Inc., a subcontracting company ("Subcontractor"), for the project known as the Riverside Medical Office Renovation, located at 4180 Riverside Parkway, Suite 100.
+ +1.1 The Subcontract Documents consist of this Agreement, the Prime Contract between Contractor and Owner, the Drawings, the Specifications, and any Addenda or Modifications issued before execution of this Agreement.
+1.2 In the event of a conflict among the Subcontract Documents, the more stringent requirement shall govern, subject to Contractor's determination.
+ +2.1 Subcontractor shall furnish all labor, materials, equipment, and services necessary to complete the electrical installation, distribution, and low-voltage systems work described in Specification Section 26 00 00 (the "Work").
+2.2 Subcontractor shall perform the Work in accordance with the Subcontract Documents and all applicable codes.
+ +3.1 Subcontractor shall commence the Work on April 1, 2026, and shall achieve Substantial Completion of the Work no later than October 15, 2026.
+3.2 Time is of the essence of this Agreement.
+ +4.1 Contractor shall pay Subcontractor, for full and satisfactory performance of the Work, the sum of $487,500.00 (the "Subcontract Sum"), subject to additions and deductions authorized under Article 6.
+ +5.1 Subcontractor shall submit an Application for Payment to Contractor by the 25th day of each month, itemizing the value of Work completed and stored materials, in a form consistent with Contractor's standard payment application format.
+5.2 Contractor shall pay each undisputed Application for Payment within twenty (20) days of receipt, less a retainage of ten percent (10%), which shall be released upon Substantial Completion of the Work.
+5.3 Each Application for Payment shall reference the specific Work completed and shall be subject to Contractor's verification before payment.
+ +6.1 Contractor may, without invalidating this Agreement, order changes in the Work within the general scope of this Agreement by issuing a written Change Order.
+6.2 A Change Order shall state the change in the Work, any adjustment to the Subcontract Sum, and any adjustment to the dates in Article 3, and shall be signed by both parties before the changed Work proceeds.
+6.3 Subcontractor shall not perform any change in the Work without a Change Order executed under this Article 6, except in a genuine emergency affecting life safety.
+ +7.1 Subcontractor shall maintain commercial general liability insurance, automobile liability insurance, and workers' compensation insurance in the amounts set forth in Exhibit C, naming Contractor and Owner as additional insureds.
+ +8.1 Contractor may terminate this Agreement for cause upon Subcontractor's material breach, following seven (7) days' written notice and an opportunity to cure.
+8.2 Contractor may terminate this Agreement for convenience upon seven (7) days' written notice, in which case Subcontractor shall be paid for Work properly performed through the date of termination.
+ +9.1 Any claim arising out of or related to this Agreement shall first be submitted to Contractor's project executive for informal resolution before either party pursues formal dispute resolution.
+9.2 Any Request for Information regarding the Subcontract Documents shall be submitted in writing and shall reference the specific Drawing, Specification section, or Article of this Agreement to which it relates.
+ +10.1 This Agreement shall be governed by the laws of the State of Colorado.
+10.2 This Agreement, together with the other Subcontract Documents, constitutes the entire agreement between the parties and supersedes all prior negotiations.
diff --git a/use-cases/shivansh193/construction-contract-pack/content/change_order_stub.html b/use-cases/shivansh193/construction-contract-pack/content/change_order_stub.html new file mode 100644 index 00000000..787ff996 --- /dev/null +++ b/use-cases/shivansh193/construction-contract-pack/content/change_order_stub.html @@ -0,0 +1,2 @@ +Riverside Medical Office Renovation
diff --git a/use-cases/shivansh193/construction-contract-pack/content/payment_application_stub.html b/use-cases/shivansh193/construction-contract-pack/content/payment_application_stub.html new file mode 100644 index 00000000..02ad4c51 --- /dev/null +++ b/use-cases/shivansh193/construction-contract-pack/content/payment_application_stub.html @@ -0,0 +1,2 @@ +Riverside Medical Office Renovation
diff --git a/use-cases/shivansh193/construction-contract-pack/content/rfi_stub.html b/use-cases/shivansh193/construction-contract-pack/content/rfi_stub.html new file mode 100644 index 00000000..086abdd5 --- /dev/null +++ b/use-cases/shivansh193/construction-contract-pack/content/rfi_stub.html @@ -0,0 +1,2 @@ +Riverside Medical Office Renovation
diff --git a/use-cases/shivansh193/construction-contract-pack/requirements.txt b/use-cases/shivansh193/construction-contract-pack/requirements.txt new file mode 100644 index 00000000..7507eb03 --- /dev/null +++ b/use-cases/shivansh193/construction-contract-pack/requirements.txt @@ -0,0 +1,2 @@ +httpx>=0.27 +python-dotenv>=1.0 diff --git a/use-cases/shivansh193/contractor-estimate-app/.env.example b/use-cases/shivansh193/contractor-estimate-app/.env.example new file mode 100644 index 00000000..f611a7f0 --- /dev/null +++ b/use-cases/shivansh193/contractor-estimate-app/.env.example @@ -0,0 +1 @@ +SUPERDOCS_API_KEY=your-key-here diff --git a/use-cases/shivansh193/contractor-estimate-app/.gitignore b/use-cases/shivansh193/contractor-estimate-app/.gitignore new file mode 100644 index 00000000..0703dfd8 --- /dev/null +++ b/use-cases/shivansh193/contractor-estimate-app/.gitignore @@ -0,0 +1,5 @@ +.env +output/ +__pycache__/ +*.pyc +.venv/ diff --git a/use-cases/shivansh193/contractor-estimate-app/PROGRESS.md b/use-cases/shivansh193/contractor-estimate-app/PROGRESS.md new file mode 100644 index 00000000..6f3bfbaa --- /dev/null +++ b/use-cases/shivansh193/contractor-estimate-app/PROGRESS.md @@ -0,0 +1,76 @@ +# Progress log + +Dated notes on real findings while building this against the live SuperDocs +product. Not a design doc -- just what actually happened, for Task 4's +"what broke" question and for anyone revisiting this build later. + +## 2026-08-19 -- signed image URLs vs. what actually ships in the export + +Built the estimate flow: upload two site photos (`POST +/v1/documents/images/upload`), reference their returned URLs in a chat +instruction so each priced line item embeds the photo that justified it, +export as `.docx`. + +First pass verification only checked the *chat response's* HTML -- it +contains `Licensed Residential & Light Commercial Electrical — Est. estimate template
+This is a branded letterhead template for Bright Line Electric estimates. New estimates should follow this +header style: the company name in the accent green, a one-line service description beneath it, and a +horizontal rule separating the letterhead from the body.
diff --git a/use-cases/shivansh193/contractor-estimate-app/content/site_photo_1.jpg b/use-cases/shivansh193/contractor-estimate-app/content/site_photo_1.jpg new file mode 100644 index 00000000..a761ad35 Binary files /dev/null and b/use-cases/shivansh193/contractor-estimate-app/content/site_photo_1.jpg differ diff --git a/use-cases/shivansh193/contractor-estimate-app/content/site_photo_2.jpg b/use-cases/shivansh193/contractor-estimate-app/content/site_photo_2.jpg new file mode 100644 index 00000000..2134f3f4 Binary files /dev/null and b/use-cases/shivansh193/contractor-estimate-app/content/site_photo_2.jpg differ diff --git a/use-cases/shivansh193/contractor-estimate-app/requirements.txt b/use-cases/shivansh193/contractor-estimate-app/requirements.txt new file mode 100644 index 00000000..6bb6ce5e --- /dev/null +++ b/use-cases/shivansh193/contractor-estimate-app/requirements.txt @@ -0,0 +1,6 @@ +httpx>=0.27 +python-dotenv>=1.0 +fastapi>=0.110 +uvicorn[standard]>=0.29 +python-multipart>=0.0.9 +pillow>=10.0 diff --git a/use-cases/shivansh193/contractor-estimate-app/server.py b/use-cases/shivansh193/contractor-estimate-app/server.py new file mode 100644 index 00000000..91226208 --- /dev/null +++ b/use-cases/shivansh193/contractor-estimate-app/server.py @@ -0,0 +1,307 @@ +"""Contractor estimate & quote app -- built against the real, hosted +SuperDocs product. Starts from site-visit data (notes + photos captured +during a walkthrough, each photo paired with the specific issue it shows) +and turns it into a branded estimate where the priced line items are +genuinely traceable back to the photo that justified them, not just +described in prose. + +The traceability mechanism is deliberate and explicit, not a vision-model +guess: each uploaded photo is paired with its own short caption by the +person filling out the form (that's literally what happens on a real +walkthrough -- snap a photo, note what's wrong with it). The chat +instruction to SuperDocs is then built so each caption maps to exactly one +priced line item with that photo's real, uploaded URL embedded next to it. +Verification checks that mapping actually landed in the output, not that +the AI merely tried. + +Usage: + uvicorn server:app --reload --port 8020 +""" + +import json +import os +import re +import time +from pathlib import Path + +import httpx +from dotenv import load_dotenv +from fastapi import FastAPI, Form, UploadFile +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse +from fastapi.staticfiles import StaticFiles + +load_dotenv() + +API_KEY = os.environ.get("SUPERDOCS_API_KEY") +BASE_URL = "https://api.superdocs.app" + +HERE = Path(__file__).parent +OUTPUT_DIR = HERE / "output" +OUTPUT_DIR.mkdir(exist_ok=True) +CONTENT_DIR = HERE / "content" + +app = FastAPI(title="Contractor Estimate & Quote App") +app.mount("/static", StaticFiles(directory=HERE / "static"), name="static") + +client = httpx.Client(base_url=BASE_URL, timeout=180.0) + + +def _headers() -> dict: + if not API_KEY: + raise RuntimeError("SUPERDOCS_API_KEY not set") + return {"Authorization": f"Bearer {API_KEY}"} + + +def log(msg: str) -> None: + print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True) + + +# ---------- SuperDocs API helpers ---------- + + +def upload_image(path: Path) -> str: + with open(path, "rb") as f: + resp = client.post("/v1/documents/images/upload", headers=_headers(), files={"file": (path.name, f, "image/jpeg")}) + resp.raise_for_status() + data = resp.json() + url = data.get("url") or data.get("image_url") or data.get("src") + if not url: + raise RuntimeError(f"unrecognized image upload response: {data}") + return url + + +def upload_image_bytes(content: bytes, filename: str, content_type: str) -> str: + resp = client.post( + "/v1/documents/images/upload", + headers=_headers(), + files={"file": (filename, content, content_type)}, + ) + resp.raise_for_status() + data = resp.json() + url = data.get("url") or data.get("image_url") or data.get("src") + if not url: + raise RuntimeError(f"unrecognized image upload response: {data}") + return url + + +_template_uploaded = False + + +def ensure_template() -> None: + global _template_uploaded + if _template_uploaded: + return + with open(CONTENT_DIR / "estimate_template.html", "rb") as f: + resp = client.post("/v1/templates/upload", headers=_headers(), files={"file": ("bright_line_letterhead.html", f, "text/html")}) + resp.raise_for_status() + log(f"template uploaded: {resp.json()}") + _template_uploaded = True + + +def start_chat(message: str, session_id: str, approval_mode: str = "ask_every_time") -> dict: + resp = client.post( + "/v1/chat/async", + headers=_headers(), + json={"message": message, "session_id": session_id, "approval_mode": approval_mode}, + ) + resp.raise_for_status() + return resp.json() + + +def get_job(job_id: str) -> dict: + resp = client.get(f"/v1/jobs/{job_id}", headers=_headers()) + resp.raise_for_status() + return resp.json() + + +def approve_all(session_id: str, job_id: str, pending_changes: list[dict]) -> None: + changes = [{"change_id": c["change_id"], "approved": True} for c in pending_changes] + resp = client.post( + f"/v1/chat/{session_id}/approve", + headers=_headers(), + json={"job_id": job_id, "approved": True, "changes": changes}, + ) + resp.raise_for_status() + + +def continue_job(session_id: str, job_id: str) -> None: + resp = client.post(f"/v1/chat/{session_id}/continue", headers=_headers(), json={"job_id": job_id, "continue": True}) + resp.raise_for_status() + + +def wait_for_job(session_id: str, job_id: str, max_wait_s: int = 300) -> dict: + start = time.time() + while time.time() - start < max_wait_s: + job = get_job(job_id) + status = job["status"] + if status == "completed": + return job + if status in ("failed", "cancelled"): + raise RuntimeError(f"job {status}: {job.get('error')}") + if status == "awaiting_approval": + metadata = job.get("metadata") or {} + if metadata.get("awaiting_kind") == "continue_prompt": + log(" paused mid-edit, continuing") + continue_job(session_id, job_id) + else: + pending = metadata.get("pending_changes") or [] + log(f" awaiting approval on {len(pending)} change(s) -- approving") + approve_all(session_id, job_id, pending) + else: + log(f" {status}...") + time.sleep(3) + raise TimeoutError("job did not complete in time") + + +def export_html(html: str, filename: str, fmt: str = "docx") -> Path: + resp = client.post( + "/v1/documents/export", + headers=_headers(), + json={"html": html, "format": fmt, "options": {"filename": filename}}, + ) + resp.raise_for_status() + ext = {"docx": "docx", "pdf": "pdf", "html": "html"}.get(fmt, fmt) + out_path = OUTPUT_DIR / f"{filename}.{ext}" + if "application/json" in resp.headers.get("content-type", ""): + data = resp.json() + url = data.get("download_url") or data.get("url") + if not url: + raise RuntimeError(f"unrecognized export response: {data}") + out_path.write_bytes(client.get(url).content) + else: + out_path.write_bytes(resp.content) + return out_path + + +# ---------- estimate generation ---------- + + +def build_instruction(job_notes: str, items: list[dict]) -> str: + lines = [ + "Draft a branded estimate document for Bright Line Electric, a residential electrical contractor. " + "Use the letterhead style from the uploaded Bright Line Electric template at the top: company name " + "in green (#0b6e4f), one-line service description, horizontal rule.", + "", + f"Job notes from the site visit: {job_notes}", + "", + "Create one priced line item for each numbered issue below, in an itemized table (description, " + "labor cost, material cost, total). Give each a realistic labor and material cost estimate for " + "residential electrical work of that kind. Directly beneath each issue's line item row, embed the " + "exact photo for that issue using its real URL, e.g.Estimate builder — from site-visit notes and photos to a branded, traceable estimate.
+ + + + + + + + +