SimpleDocReview / DEPLOY.md
Eric Putnam
Claude Opus 5 (1M context)
feat: deploy simpledocreview as a browser-only static space
3d968d9
|
Raw History Blame Contribute Delete
11.7 kB

Deploying to Hugging Face

This repo deploys SimpleDocReview to a Hugging Face Static Space as a browser-only demo. It holds no application logic of its own β€” only the build tooling, the Space card, a vendored copy of upstream in app/, the browser-side API in overlay/, and the generated site at the repository root.

Why static

Per Hugging Face's own docs, Static Spaces are free for everyone β€” "served directly without running on compute, so unlike Gradio and Docker Spaces, creating them never requires a paid plan."

Upstream is a good fit in one respect and a poor fit in another, and both matter:

  • Good fit: the front end is plain HTML, CSS, and JavaScript with no build step, no framework, and no bundler. It is served byte-for-byte as upstream wrote it.
  • Poor fit: upstream is a server. app.py owns the review logic and the storage. A Static Space cannot run it, so the API is reimplemented in the browser and the storage becomes IndexedDB.

That second point is the whole design constraint of this repository, and it is why the Space is a demo. See What the demo cannot be.

The everyday workflow

After you change something in C:\dev\SimpleDocReview and commit it there:

cd C:\dev\sdr-streamlit
.\scripts\deploy.ps1

That syncs app/, builds the site into the repository root, commits with the upstream commit in the message, and pushes to hf. Because a Static Space serves files directly, the update is live as soon as the push lands.

To see what would change without committing:

.\scripts\deploy.ps1 -DryRun

Individual steps, when you want them:

.\scripts\sync-upstream.ps1          # refresh app/ from the local upstream checkout
.\scripts\build-static.ps1           # build the site into the repository root
node tests\parity\run-parity.mjs     # diff the browser API against the real app.py
node tests\browser\run-browser-check.mjs   # drive the built site in headless Chromium
python -m http.server 8000           # preview at http://localhost:8000

Useful flags

Flag Effect
-Message "feat: ..." Custom commit message
-AlsoPushOrigin Also push to the GitHub mirror of this repo
-UpstreamPath D:\other\checkout Sync from a different checkout
-AllowDirty Deploy from an uncommitted upstream working tree
-SkipSync Build and deploy app/ as it stands, no re-mirror
-SkipBuild Commit and push the existing site, no rebuild
-SkipTests Skip the parity and browser checks (they run by default)
-DryRun Show the plan, change nothing

By default a dirty upstream working tree is an error, because UPSTREAM.json would otherwise record a commit that does not describe what actually shipped. Commit upstream first, or opt in with -AllowDirty.

First-time setup

  1. Create the Space at https://huggingface.co/new-space β€” choose the Static SDK and the Blank template. Name it SimpleDocReview.

  2. Add it as a remote:

    git remote add hf https://huggingface.co/spaces/Hoctar77/SimpleDocReview
    
  3. Authenticate. Use a Hugging Face access token with write scope from https://huggingface.co/settings/tokens as the password when git prompts, or store it once:

    git config credential.helper manager
    
  4. Build and push:

    .\scripts\deploy.ps1 -AlsoPushOrigin
    

    A blank Space starts with its own initial commit, so the first push may be rejected as non-fast-forward. Reconcile once:

    git pull --rebase hf main
    git push hf main
    

How the build works

app/        clean upstream mirror        (generated by sync-upstream.ps1)
overlay/    the browser-side API         (hand-maintained)
   |
   v  copy + insert two <script> tags into index.html
.build/out/
   |
   v  copied to the repository root
index.html, static/, sdr-api-core.js, sdr-api-shim.js   (committed; HF serves these)

There is no compiler and no package manager. build-static.ps1 copies app/static/index.html to the site root, copies the rest of app/static/ to /static/, copies the two overlay scripts, and inserts their <script> tags immediately before upstream's app.js tag.

The repository root is the web root, which is why the site lives there rather than in a subdirectory β€” index.html asks for /static/styles.css by absolute path.

Everything the build emits is recorded in .space-build-manifest and deleted before the next copy, so nothing stale accumulates. The cleanup refuses to touch a protected list (README.md, app/, scripts/, …) even if the manifest somehow named one.

Why the script tags are inserted rather than committed

The overlay has to load before app.js, which fires its first three requests as soon as it is parsed. The alternative β€” keeping a modified index.html in overlay/ β€” would mean a second copy of an upstream file that silently rots every time upstream edits its markup. Inserting a tag is a transform of upstream's HTML, so there is nothing to drift.

build-static.ps1 fails loudly if it cannot find upstream's app.js tag, rather than shipping a page where the overlay never loads.

The browser-side API

overlay/sdr-api-core.js is a port of app.py: every route, the rubric analyzer, the DOCX text extractor, the status recalculation, the notification queue, and the record migrations. overlay/sdr-api-shim.js supplies what a browser has and Python does not β€” IndexedDB, an XMLHttpRequest interceptor, and a click handler for the Download button.

Three things in that split are worth knowing:

  • Requests are intercepted at XMLHttpRequest, not fetch, because that is what upstream's app.js uses. The interceptor is a Proxy over a real XMLHttpRequest, so anything the shim does not simulate still reaches the genuine object. fetch is covered too, defensively, in case upstream migrates.
  • Download is a click interception, not a request interception. Upstream navigates: window.location.href = "/api/documents/<id>/download". A page cannot cancel that assignment, so the click is caught in the capture phase before upstream's own handler runs, and the file is handed over as a blob URL. The shim tracks which document is on screen from the API traffic, because the click event does not carry the id.
  • DOCX extraction has no dependency. app.py uses zipfile; the port reads the ZIP central directory itself and inflates with DecompressionStream, which every current browser has. ZIP64 is rejected rather than guessed at.

Keeping the port honest

A port that silently diverges from the server it mirrors is worse than no port, so three things guard it.

A hash guard. overlay/ported-from.json records the SHA-256 of app.py and static/app.js as of the last time the port was reviewed. build-static.ps1 refuses to build when either changes:

Upstream changed in files the overlay reimplements: app.py

Read the upstream diff, carry any behavior change into sdr-api-core.js, re-run the tests, then acknowledge it:

.\scripts\build-static.ps1 -AcceptUpstreamChanges

This is deliberately annoying. An upstream change to a rubric rule or a status transition will not reach the Space on its own, and nothing else in a copy-and-inject build would notice.

A parity test. tests/parity/ replays a 67-request workflow through the real app.py β€” imported from app/, behind a real HTTP server, with its clock and uuid4 replaced by counters β€” and through the JavaScript port in Node, then diffs every response, the final record store, and the list of stored uploads. Making the clock sequential means the port has to call it the same number of times in the same order, so a missing timestamp is a failure rather than a value nobody compares.

One divergence is accepted and printed on every run: mimetypes.guess_type returns application/octet-stream for .docx on a Windows machine with no Office mapping, where the port always sends the real OOXML type.

A browser test. tests/browser/ serves the repository root, opens it in headless Chromium via the DevTools protocol, and runs upload β†’ review β†’ assign β†’ approve β†’ download β†’ reload through the real UI. That covers what the parity test cannot: the inserted script tags, the XHR interception, IndexedDB persistence across a reload, and the download click.

What the demo cannot be

Storage is the constraint, and it is not fixable inside a Static Space:

  • Records are per-browser. IndexedDB is scoped to one origin in one browser profile. A reviewer opening the same URL sees an empty workspace, so a real multi-person approval round cannot happen here.
  • Records are disposable. Clearing site data deletes them, with no export other than downloading the original files one at a time.
  • Records may not persist at all. The Space renders in an iframe on huggingface.co, and a browser that partitions or blocks storage for embedded third parties leaves the shim with an in-memory store for the length of the visit. It says so in the notice when that happens.

The Space says all of this in three places: the Space card, a badge next to the title that does not go away, and a dismissible notice on first load. Keep them accurate. Someone who mistakes this for a service and runs a real approval round through it loses the record with no warning, and that is the one failure mode this deployment can actually cause.

If you need the Space to be the real thing, it needs to run app.py, which means the Docker SDK on a paid plan β€” and even then, a Space's filesystem is ephemeral, so data/reviews.json and uploads/ would need a persistent volume or an external store. The Dockerfile here runs upstream unmodified for local use and as that starting point; the Space does not use it.

Troubleshooting

Symptom Cause and fix
Space shows a directory listing or 404 app_file: index.html must be in the front matter and index.html must exist at the repo root. Re-run build-static.ps1.
The page loads but stays empty, console shows 404s for /api/... The overlay did not load. Confirm both <script> tags are in the deployed index.html, before app.js.
sdr-api-core.js did not load in the console The tags are in the wrong order. sdr-api-core.js must come first.
Build fails: "Upstream changed in files the overlay reimplements" Working as intended. Review the upstream diff, update the overlay, then -AcceptUpstreamChanges.
Build fails: "Could not find upstream's app.js script tag" Upstream changed its markup. Update the anchor in build-static.ps1.
Parity test fails after an upstream change The port no longer matches app.py. The diff output names the exact field.
Browser test cannot find a browser Set BROWSER to a chrome.exe or msedge.exe path.
Documents vanish between visits Expected on a demo. See What the demo cannot be.
Uploading a DOCX fails with "appears to be invalid" The port rejects ZIP64 archives and unsupported compression methods. Re-save the file from Word.
Push rejected, non-fast-forward The Space has commits this repo lacks: git pull --rebase hf main.
robocopy exit code 8 or higher A real copy failure β€” a file was locked. Close anything watching app/ or .build/ and re-run.