Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
name: Pages

on:
push:
branches: [main]
paths:
- "site/**"
- "dynamic_hitl/web_app/**"
- ".github/workflows/pages.yml"
pull_request:
branches: [main]
paths:
- "site/**"
- "dynamic_hitl/web_app/**"
- ".github/workflows/pages.yml"
workflow_dispatch:

permissions:
contents: read

# Let an in-flight deploy finish rather than cancelling it half-published.
concurrency:
group: pages-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
build:
name: build site
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: dynamic_hitl/web_app/package-lock.json

- name: Install explainer dependencies
working-directory: dynamic_hitl/web_app
run: npm ci

- name: Build explainer
working-directory: dynamic_hitl/web_app
run: |
npm run build
npm run build:single

- name: Assemble site
run: |
set -euo pipefail
mkdir -p _site/dynamic-hitl
cp -r site/. _site/
cp -r dynamic_hitl/web_app/dist/. _site/dynamic-hitl/
cp dynamic_hitl/web_app/dist-standalone/dynamic-hitl-explainer.html _site/
# Pages artifacts are served as-is, but this keeps any future
# underscore-prefixed path from being swallowed by Jekyll.
touch _site/.nojekyll

- name: Check every local link resolves
run: |
set -euo pipefail
status=0
while IFS= read -r file; do
while IFS= read -r href; do
target="${href%%#*}"
target="${target%%\?*}"
[[ -z "${target}" ]] && continue
resolved="$(dirname "${file}")/${target}"
if [[ "${resolved}" == */ ]]; then
resolved="${resolved}index.html"
fi
if [[ ! -e "${resolved}" ]]; then
echo "::error file=${file}::broken local link ${href}"
status=1
fi
done < <(grep -oE '(href|src)="\./[^"]*"' "${file}" | sed -E 's/^[a-z]+="//; s/"$//')
done < <(find _site -name '*.html')
exit "${status}"

- uses: actions/upload-pages-artifact@v3
with:
path: _site

deploy:
name: deploy to github pages
# Pull requests build and link-check only; main is what gets published.
if: github.event_name != 'pull_request'
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
2 changes: 2 additions & 0 deletions dynamic_hitl/web_app/.gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
node_modules/
dist/
dist-standalone/
.single-file-build/
.vite/
*.tsbuildinfo
*.local
Expand Down
35 changes: 21 additions & 14 deletions dynamic_hitl/web_app/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
# Dynamic HITL Explainer

An interactive, static site that explains — to customers and technical stakeholders — how to turn
**Azure AI Content Understanding** confidence scores into a defensible human-review
policy, and how much review effort that saves.
An interactive, static site that explains — to customers and technical stakeholders — how to turn **Azure AI Content Understanding** confidence scores into a defensible human-review policy, and how much review effort that saves.

Built for the Content Understanding product group to share with customers.

Expand All @@ -20,9 +18,21 @@ npm run build # writes dist/
npm run preview # serve dist/ locally
```

`dist/` is plain static files with relative asset paths, so it can be dropped onto Azure
Static Web Apps, GitHub Pages, a blob container, or any web server. **No backend, no API
keys, no Python at page-view time.**
`dist/` is plain static files with relative asset paths, so it can be dropped onto Azure Static Web Apps, GitHub Pages, a blob container, or any web server. **No backend, no API keys, no Python at page-view time.**

### Single-file build

```powershell
npm run build:single # writes dist-standalone/dynamic-hitl-explainer.html (~630 kB)
```

This folds the JS, the CSS and the precomputed payload into one self-contained HTML file that makes **no network requests at all** — it opens by double-clicking it from disk, on a plane, with no server, no `npm` and no install. Email it, drop it in a Teams channel, or attach it to a wiki page. The build fails if any external or unbundled reference survives, so it cannot silently regress to needing the network.

It writes to its own folder, so `npm run build` and `npm run build:single` do not overwrite each other and both outputs can sit side by side.

### Fonts

The page uses the same font stack Microsoft Learn computes to — `Segoe UI`, falling back through `-apple-system` / `Helvetica Neue` / `Arial` — and `Consolas`/`SF Mono` for figures. These are all system fonts, so nothing is downloaded and the offline file matches the Azure documentation on Windows. Learn self-hosts *Segoe UI Variable* as a webfont, but that file is licensed to Microsoft's own properties and is not redistributable, so it is deliberately not embedded here.

## What the page walks through

Expand All @@ -47,17 +57,13 @@ cd precompute
python build_payload.py # writes ../src/data/payload.json
```

[`precompute/build_payload.py`](precompute/build_payload.py) imports the calibration code
from the sibling [`../calibration_lab/`](../calibration_lab/) folder — the same code the
customer-facing notebook runs — and sweeps it across:
[`precompute/build_payload.py`](precompute/build_payload.py) imports the calibration code from the sibling [`../calibration_lab/`](../calibration_lab/) folder — the same code the customer-facing notebook runs — and sweeps it across:

- every coverage target from 50% to 99% in 1% steps,
- both scoring engines (raw confidence and fitted `P(correct)`),
- the training split (expected results) and the held-out test split (measured results).

`src/data/payload.json` (~240 KB) is **committed**, so the site builds and runs with Node
alone. Regenerating it is only necessary after changing the dataset or the calibration
logic, and takes the better part of an hour. The two folders ship together.
`src/data/payload.json` (~240 KB) is **committed**, so the site builds and runs with Node alone. Regenerating it is only necessary after changing the dataset or the calibration logic, and takes the better part of an hour. The two folders ship together.

## Layout

Expand All @@ -71,7 +77,8 @@ src/
data/payload.json precomputed results (committed)
precompute/
build_payload.py regenerates the payload from ../calibration_lab
scripts/
build-single-file.mjs inlines the build output into one offline HTML file
```

No chart library — the charts are plain SVG so the animations and the visual language stay
under our control.
No chart library — the charts are plain SVG so the animations and the visual language stay under our control.
8 changes: 4 additions & 4 deletions dynamic_hitl/web_app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
name="description"
content="How to turn Azure Content Understanding confidence scores into a defensible human-review policy."
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- Data URI, not a file: keeps the single-file build free of external requests. -->
<link
href="https://fonts.googleapis.com/css2?family=Segoe+UI:wght@400;600&family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 21 21'%3E%3Crect width='10' height='10' fill='%23f25022'/%3E%3Crect x='11' width='10' height='10' fill='%237fba00'/%3E%3Crect y='11' width='10' height='10' fill='%2300a4ef'/%3E%3Crect x='11' y='11' width='10' height='10' fill='%23ffb900'/%3E%3C/svg%3E"
/>
</head>
<body>
Expand Down
1 change: 1 addition & 0 deletions dynamic_hitl/web_app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:single": "tsc -b && node scripts/build-single-file.mjs",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit"
},
Expand Down
99 changes: 99 additions & 0 deletions dynamic_hitl/web_app/scripts/build-single-file.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Builds the site, then folds the emitted JS/CSS back into one self-contained HTML file.
// Output: dist-standalone/dynamic-hitl-explainer.html — no other files, no server, no
// network at view time. The regular `npm run build` output in dist/ is left alone.
import { build } from 'vite';
import { readFile, writeFile, rm, mkdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const stageDir = path.join(root, '.single-file-build');
const outDir = path.join(root, 'dist-standalone');
const outFile = path.join(outDir, 'dynamic-hitl-explainer.html');

// A literal </script> or <!-- inside a string in the bundle would terminate the host tag early.
const escapeForInlineScript = (code) =>
code.replace(/<\/script/gi, '<\\/script').replace(/<!--/g, '<\\!--');

const readAsset = async (url) => {
const relative = url.replace(/^\.?\//, '').split('?')[0];
return readFile(path.join(stageDir, relative), 'utf8');
};

const isLocal = (url) => url && !/^(https?:)?\/\//i.test(url) && !url.startsWith('data:');

async function main() {
await rm(stageDir, { recursive: true, force: true });

await build({
root,
configFile: path.join(root, 'vite.config.ts'),
logLevel: 'warn',
build: {
outDir: stageDir,
emptyOutDir: true,
// One chunk in, one chunk out.
cssCodeSplit: false,
modulePreload: { polyfill: false },
chunkSizeWarningLimit: 4096,
rollupOptions: {
output: {
inlineDynamicImports: true,
manualChunks: undefined,
},
},
},
});

let html = await readFile(path.join(stageDir, 'index.html'), 'utf8');

// Preloads are meaningless once everything lives in the document.
html = html.replace(/\s*<link[^>]+rel="modulepreload"[^>]*>/gi, '');

const scriptTags = [...html.matchAll(/<script\b[^>]*\bsrc="([^"]+)"[^>]*>\s*<\/script>/gi)];
for (const [tag, src] of scriptTags) {
if (!isLocal(src)) continue;
const code = escapeForInlineScript(await readAsset(src));
// Function replacement: `$&` and friends occur naturally in minified code.
html = html.replace(tag, () => `<script type="module">\n${code}\n</script>`);
}

const styleTags = [...html.matchAll(/<link\b[^>]*\brel="stylesheet"[^>]*>/gi)];
for (const tag of styleTags) {
const href = /\bhref="([^"]+)"/i.exec(tag[0])?.[1];
if (!isLocal(href)) continue;
const css = await readAsset(href);
html = html.replace(tag[0], () => `<style>\n${css}\n</style>`);
}

const leftover = [
...html.matchAll(/<(?:script|link)\b[^>]*\b(?:src|href)="(\.?\/[^"]+)"[^>]*>/gi),
];
if (leftover.length) {
throw new Error(`Unbundled asset references remain: ${leftover.map((m) => m[1]).join(', ')}`);
}

// The whole point of this build: opening it on a plane must look identical.
const remote = [
...html.matchAll(/<(?:script|link|img|iframe|source)\b[^>]*\b(?:src|href)="((?:https?:)?\/\/[^"]+)"/gi),
...html.matchAll(/@import\s+(?:url\()?["']?(https?:\/\/[^"')]+)/gi),
];
if (remote.length) {
throw new Error(
`Page still reaches the network, so it is not offline-safe: ${remote.map((m) => m[1]).join(', ')}`,
);
}

await mkdir(outDir, { recursive: true });
await writeFile(outFile, html, 'utf8');
await rm(stageDir, { recursive: true, force: true });

const kb = (Buffer.byteLength(html, 'utf8') / 1024).toFixed(0);
console.log(`${path.relative(root, outFile).replace(/\\/g, '/')} — ${kb} kB, self-contained and offline.`);
}

main().catch(async (error) => {
await rm(stageDir, { recursive: true, force: true });
console.error(error);
process.exit(1);
});
15 changes: 10 additions & 5 deletions dynamic_hitl/web_app/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,15 @@
--shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 4px 16px rgba(16, 24, 40, 0.06);
--shadow-lg: 0 2px 6px rgba(16, 24, 40, 0.08), 0 12px 40px rgba(16, 24, 40, 0.1);

--font: 'Inter', 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--mono: 'Cascadia Code', 'SF Mono', 'Consolas', monospace;
/* The stack Microsoft Learn computes to. All system fonts: nothing to download. */
--font: 'Segoe UI', 'Segoe UI Variable Text', -apple-system, BlinkMacSystemFont,
'Helvetica Neue', Helvetica, Arial, sans-serif;
--mono: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, Courier, monospace;

--page: 1120px;
/* One measure for every block of body copy. `none` lets copy run the full
content column, stopping at the page gutter. */
--measure: none;
--dial-bar: 92px;
}

Expand Down Expand Up @@ -83,7 +88,6 @@ a {
}

.section__head {
max-width: 760px;
margin-bottom: 36px;
}

Expand Down Expand Up @@ -115,6 +119,7 @@ a {
margin-top: 14px;
font-size: 18px;
color: var(--ink-2);
max-width: var(--measure);
}

.stack {
Expand Down Expand Up @@ -191,7 +196,7 @@ a {
margin-top: 22px;
font-size: 20px;
color: var(--ink-2);
max-width: 62ch;
max-width: var(--measure);
}

.hero__meta {
Expand Down Expand Up @@ -614,7 +619,7 @@ a {
margin-top: 16px;
color: rgba(255, 255, 255, 0.82);
font-size: 17px;
max-width: 60ch;
max-width: var(--measure);
}

.cta__steps {
Expand Down
7 changes: 7 additions & 0 deletions site/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading