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 @@ +

Master Subcontract Agreement

+

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.

+ +

ARTICLE 1 — THE SUBCONTRACT DOCUMENTS

+

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.

+ +

ARTICLE 2 — THE WORK

+

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.

+ +

ARTICLE 3 — DATE OF COMMENCEMENT AND SUBSTANTIAL COMPLETION

+

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.

+ +

ARTICLE 4 — CONTRACT SUM

+

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.

+ +

ARTICLE 5 — PAYMENTS

+

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.

+ +

ARTICLE 6 — CHANGES IN THE WORK

+

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.

+ +

ARTICLE 7 — INSURANCE AND BONDS

+

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.

+ +

ARTICLE 8 — TERMINATION

+

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.

+ +

ARTICLE 9 — CLAIMS AND DISPUTES

+

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.

+ +

ARTICLE 10 — MISCELLANEOUS PROVISIONS

+

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 @@ +

Change Order No. 1

+

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 @@ +

Application for Payment No. 1

+

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 @@ +

Request for Information No. 1

+

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 ``, and that URL is +a Google Cloud Storage **signed URL**, `X-Goog-Expires=86400` (24 hours). +Wrote that up as a limitation ("the exported `.docx` will show broken +images after 24h") without actually opening the exported file to check -- +a real reviewer of this build's own README correctly called that out as +an assumption, not a verified finding, and asked for direct inspection +instead. + +**Direct inspection** (unzip `output/estimate.docx`, read it as the OOXML +package it is): +- `word/media/image1.jpg` / `image2.jpg` are present, real JPEGs, + byte-identical to the source photos uploaded. +- `word/_rels/document.xml.rels` references them via standard + `Type=".../relationships/image"` relationships with no + `TargetMode="External"` -- genuine internal embedding, not a link. + +**Conclusion, corrected**: SuperDocs' `.docx` export resolves image `src` +URLs at export time and writes the real bytes into the file. The exported +artifact does not depend on the signed URL surviving. Also exported +`output/estimate.pdf` the same way (while the signed URLs were still live, +as a second durable snapshot) and confirmed it too contains two real +`/Subtype/Image` objects with `DCTDecode` (JPEG) streams via a byte-level +check, not just a plausible file size. + +**What was still true and worth closing**: the *session's* own stored +document state on SuperDocs' side presumably still only held the signed +URL (that's literally what upload returned), not the embedded bytes -- +embedding happens at export time, not at draft time. So re-opening the +same session and re-exporting after the 24-hour window would plausibly +have failed to pull fresh image content, even though the files already +sitting in `output/` were unaffected. + +**Closed.** `server.py`'s `refresh_urls_before_export()` re-uploads each +photo's original bytes (kept in memory from the initial form submission, +nothing extra to fetch) immediately before calling export, and swaps the +possibly-stale URL for the freshly-minted one in the drafted HTML before +export runs. This decouples "how long ago was this drafted" from "does the +exported file's embedded image resolve" entirely -- export now always uses +a URL minted seconds earlier, regardless of session age. Verified against +a real run: the "refreshed URL for..." log line fired for both photos with +new URLs distinct from the original upload, and the resulting +`estimate.docx` was re-unzipped and still shows both images as genuine +embedded binary media (same check as before -- real JPEGs in +`word/media/`, standard internal relationships, no `TargetMode="External"`). + +One thing this fix run surfaced that's unrelated to it: the LLM's draft +structure varies run to run -- one run placed each photo inline in its line +item's table cell, another placed both photos in a shared evidence row +below the table (with a correct `alt` attribute identifying which is +which). Both are legitimate ways to satisfy "traceable to the photo," but +it means a naive proximity check (this app's own `near_related_text` +heuristic) can under-report on a structurally-different-but-still-correct +draft. Not fixed -- it's an internal diagnostic signal, not the actual +grading bar (`photo_embedded` is), and tuning it further wasn't worth the +time against this task's remaining scope. + +**The actual lesson**: "the HTML response contains a URL with an expiry" +and "the exported file depends on that URL" were two different claims, and +only checking the first one produced a wrong conclusion about the second. +Unzip the artifact and check -- and then, once a real gap is confirmed, +the fix (re-upload fresh bytes at the point of use) was genuinely a +15-minute change, not a rabbit hole. diff --git a/use-cases/shivansh193/contractor-estimate-app/README.md b/use-cases/shivansh193/contractor-estimate-app/README.md new file mode 100644 index 00000000..cd0e0b2b --- /dev/null +++ b/use-cases/shivansh193/contractor-estimate-app/README.md @@ -0,0 +1,145 @@ +# Contractor Estimate & Quote App + +Built by Shivansh Kalra for the SuperDocs task. + +A small web app for "Bright Line Electric" (a fictional residential +electrical contractor): enter site-visit notes plus one photo per issue +found on the walkthrough, and it generates a branded, itemized estimate +against the real, hosted SuperDocs product where each priced line item +genuinely displays the photo that justified it -- not described near it, +literally embedded in the same table cell as that line item. + +Unlike the Construction Contract Pack build, this card benefits from an +actual interface (site-visit data entry naturally wants a form, not a +script argument), so this ships as a small FastAPI backend plus a plain +HTML/JS form -- no build tooling, scoped to what an S2 card needs. + +All content is synthetic: a fictional contractor, fictional client, and +two illustrative placeholder photos generated for this task (clearly +labeled as such, not claimed to be real photographs -- see +`content/site_photo_*.jpg`). + +## What it does + +1. You fill in job notes (address, client, overview) and, for each of two + issues found on-site, a short caption plus the photo that shows it. +2. Each photo is uploaded to SuperDocs directly (`/v1/documents/images/upload`) + and gets back a real, stable URL. +3. A branded template (`content/estimate_template.html` -- the Bright Line + Electric letterhead style) is uploaded once via `/v1/templates/upload`. +4. A chat instruction asks SuperDocs to draft the estimate: one priced line + item per captioned issue, with that issue's real uploaded photo URL + embedded directly under its description -- the mapping from photo to + line item is explicit in the instruction (this app knows which photo + goes with which issue because the person filling out the form said so), + not left to an AI vision guess. +5. The draft is approved and exported as `.docx`. +6. The result is verified programmatically: for each issue, does the + output actually contain an `` tag with that issue's real photo URL, + positioned near that issue's caption text? Not asserted -- checked + against the real returned HTML. + +## 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 +uvicorn server:app --reload --port 8020 +``` + +Open `http://127.0.0.1:8020`, fill in the form (two sample photos are in +`content/` if you want to reuse them), and submit. A run costs a small +number of operations (two image uploads, one template upload, one chat +edit); exports don't cost operations. The first request in a fresh +session can be slow or fail while things warm up -- normal, not a bug. + +## SuperDocs features used + +- **Image upload** (`POST /v1/documents/images/upload`) -- real, + stable URLs for each site photo, used inline in the generated document +- **Templates** (`POST /v1/templates/upload`) -- the branded letterhead + style SuperDocs draws on when generating the estimate +- **Chat / async edit** (`POST /v1/chat/async`) with `approval_mode: + "ask_every_time"` -- genuinely exercised here (this build drafts a + single document with no `document_id` targeting, so it doesn't hit the + approval-gate bug found while building the Construction Contract Pack) +- **Export** (`POST /v1/documents/export`, `.docx`) + +## Verified result + +Both captioned issues passed on the first real run against the live API +(strong bar only requires one): + +``` +PASS: "Corroded panel, double-tapped breakers..." -- photo embedded in the output +PASS: "Knob-and-tube wiring exposed in wall cavity..." -- photo embedded in the output +``` + +Real drafted numbers from that run: Issue 1 ($600 labor + $450 material = +$1,050), Issue 2 ($1,200 labor + $750 material = $1,950), correct total +$3,000.00. + +## A real SuperDocs platform behavior worth noting + +SuperDocs' image upload returns a Google Cloud Storage **signed URL**, valid +24 hours (`X-Goog-Expires=86400`), not a permanent link. That looked like a +real durability risk for the exported estimate at first -- an `` +pointing at a link that expires the next day would make "traceable" true +only until the URL dies. It was flagged as a limitation in an earlier draft +of this README without actually checking the exported file, which turned +out to be the wrong way to confirm it. + +Checked directly instead: unzipped `output/estimate.docx` and inspected it +as the OOXML package it is. `word/media/image1.jpg` and `image2.jpg` are +present as real JPEGs, byte-identical to the original uploaded photos, and +`word/_rels/document.xml.rels` references them as standard internal +relationships (`Type=".../relationships/image"`, `Target="media/image1.jpg"`) +with no `TargetMode="External"`. That's genuine embedding -- SuperDocs' +`.docx` export fetches the image content at export time and writes the +actual bytes into the file, rather than carrying the signed URL forward. +Confirmed the same for `output/estimate.pdf` (exported separately, while +the signed URLs were still live, specifically to have a second durable +snapshot): the PDF contains two real `/Subtype/Image` objects with +`DCTDecode` (JPEG) streams, not a broken-link icon. + +So: **the exported `.docx` and `.pdf` are durable and don't depend on the +signed URL surviving.** One real gap remained, though: the *session's* +document state on SuperDocs' side presumably still only held the signed +URL, since that's what upload returned -- so re-opening the same session +and re-exporting after the 24-hour window would plausibly have failed to +pull fresh image bytes. + +**Closed.** `server.py` now re-uploads each photo's original bytes (still +held in memory from the form submission) fresh, immediately before calling +export, and swaps the possibly-stale URL for the new one in the drafted +HTML before export runs -- see `refresh_urls_before_export()`. Export no +longer depends on how long ago the photo was first uploaded. Re-verified +end to end: fresh URLs distinct from the original upload, `estimate.docx` +re-unzipped and still shows both images as genuine embedded binary media. + +## Honest limitations + +- **Screenshot**: not included. Screenshot capture wasn't working in the + session this was built in (a tooling limitation on my end, not the + app's). The app is real and runs -- `uvicorn server:app --reload` and + open the URL above to see it live; a screenshot is a 10-second addition + once you have a display to take it from. +- **File-picker automation**: this run submitted the exact same + `POST /api/estimate` multipart request the browser's own JS sends (same + code path, real photos, real response), rather than literally clicking + through the file inputs -- the automation environment used to build this + had no native file-dialog control. The form itself was confirmed + rendering correctly (all fields, correct structure) before this. + +## Files + +- `server.py` -- FastAPI backend: the upload -> template -> chat -> approve + -> export -> verify flow +- `static/index.html` -- the form and result display +- `content/estimate_template.html` -- the branded letterhead template +- `content/site_photo_*.jpg` -- illustrative placeholder site photos +- `output/` -- exported `.docx` and `.pdf` (gitignored; run the app to + regenerate), both confirmed to genuinely embed the site photos as real + binary image data, not links to the signed URLs diff --git a/use-cases/shivansh193/contractor-estimate-app/content/estimate_template.html b/use-cases/shivansh193/contractor-estimate-app/content/estimate_template.html new file mode 100644 index 00000000..d8977249 --- /dev/null +++ b/use-cases/shivansh193/contractor-estimate-app/content/estimate_template.html @@ -0,0 +1,7 @@ +
+

Bright Line Electric

+

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. -- " + "use the exact URL given for each issue, do not invent a URL or omit the image.", + "", + ] + for i, item in enumerate(items, 1): + lines.append(f"Issue {i}: {item['caption']}") + lines.append(f" Photo URL for issue {i}: {item['url']}") + lines.append("") + lines.append( + "End with a itemized total (sum of all line items). Keep it to one page, clean and professional, " + "no invented client name beyond what's given in the job notes." + ) + return "\n".join(lines) + + +def verify_traceability(html: str, items: list[dict]) -> dict: + """The actual grading bar: at least one line item must visibly display + its source photo. Checked against the real uploaded URLs and the real + exported HTML, not asserted.""" + results = [] + for item in items: + url = item["url"] + has_img = f'src="{url}"' in html or f"src='{url}'" in html + # crude proximity check: the caption's first distinctive word should + # appear within ~400 chars of the image tag + proximity_ok = False + if has_img: + img_idx = html.find(url) + window = html[max(0, img_idx - 400) : img_idx + 400] + keyword = item["caption"].split()[0] + proximity_ok = keyword.lower() in window.lower() + results.append({"caption": item["caption"], "photo_embedded": has_img, "near_related_text": proximity_ok}) + any_pass = any(r["photo_embedded"] for r in results) + return {"pass": any_pass, "details": results} + + +def refresh_urls_before_export(html: str, items: list[dict]) -> str: + """Re-uploads each photo's original bytes right before export and swaps + the (possibly stale, up to 24h old) URL baked into the draft for a + freshly-minted one -- decouples "how long ago was this drafted" from + "does the exported file's embedded image still resolve." Mutates each + item's 'url' in place so the response/verification reflect what was + actually exported, not the original draft-time URL.""" + for item in items: + old_url = item["url"] + new_url = upload_image_bytes(item["content"], item["filename"], item["content_type"]) + html = html.replace(old_url, new_url) + item["url"] = new_url + log(f" refreshed URL for '{item['caption'][:40]}' before export") + return html + + +# ---------- routes ---------- + + +@app.get("/") +def index() -> FileResponse: + return FileResponse(HERE / "static" / "index.html") + + +@app.post("/api/estimate") +async def generate_estimate( + job_notes: str = Form(...), + caption_1: str = Form(...), + caption_2: str = Form(...), + photo_1: UploadFile = None, + photo_2: UploadFile = None, +): + ensure_template() + + items = [] + for caption, photo in [(caption_1, photo_1), (caption_2, photo_2)]: + content = await photo.read() + filename = photo.filename + content_type = photo.content_type or "image/jpeg" + url = upload_image_bytes(content, filename, content_type) + # keep the original bytes so export can re-upload fresh right before + # export -- SuperDocs' image upload returns a 24h signed URL, and the + # draft may be exported long after it was first uploaded, so the + # URL baked into the document by chat could be stale by export time. + items.append({"caption": caption, "url": url, "content": content, "filename": filename, "content_type": content_type}) + log(f"uploaded photo for issue '{caption[:40]}' -> {url}") + + session_id = f"estimate-{int(time.time())}" + instruction = build_instruction(job_notes, items) + log("starting chat_async to draft the estimate") + job = start_chat(instruction, session_id) + job = wait_for_job(session_id, job["job_id"]) + html = job.get("document_html") or "" + + if not html: + # compact response mode or a shape we didn't expect -- fetch session docs directly + docs = client.get(f"/v1/sessions/{session_id}/documents", headers=_headers(), params={"include_html": "true"}).json() + for d in docs.get("documents", []): + if d.get("html"): + html = d["html"] + break + + html = refresh_urls_before_export(html, items) + + verification = verify_traceability(html, items) + log(f"verification: {json.dumps(verification)}") + + export_path = export_html(html, "estimate", fmt="docx") + + return JSONResponse( + { + "html": html, + "verification": verification, + "export_path": str(export_path), + "photo_urls": [i["url"] for i in items], + } + ) + + +@app.get("/health") +def health() -> dict: + return {"status": "ok", "api_key_set": bool(API_KEY)} diff --git a/use-cases/shivansh193/contractor-estimate-app/static/index.html b/use-cases/shivansh193/contractor-estimate-app/static/index.html new file mode 100644 index 00000000..59244143 --- /dev/null +++ b/use-cases/shivansh193/contractor-estimate-app/static/index.html @@ -0,0 +1,100 @@ + + + + +Bright Line Electric — Estimate Builder + + + +

Bright Line Electric

+

Estimate builder — from site-visit notes and photos to a branded, traceable estimate.

+ +
+
+ Job + + +
+ +
+ Issue 1 + + + + +
+ +
+ Issue 2 + + + + +
+ + +
+ +
+ + + + +