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](https://github.com/eputnam77/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][static-docs] 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](#what-the-demo-cannot-be).
[static-docs]: https://huggingface.co/docs/hub/spaces-sdks-static
## The everyday workflow
After you change something in `C:\dev\SimpleDocReview` and commit it there:
```powershell
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:
```powershell
.\scripts\deploy.ps1 -DryRun
```
Individual steps, when you want them:
```powershell
.\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:
```powershell
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:
```powershell
git config credential.helper manager
```
4. Build and push:
```powershell
.\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:
```powershell
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:
```powershell
.\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](#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. |