Skip to content
Merged
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
110 changes: 110 additions & 0 deletions eval-rewrite.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>resumelint — rewrite eval (dev only)</title>
<meta
name="description"
content="Dev-only eval harness for rewrite quality (issue #65). Not part of the production bundle."
/>
<meta name="color-scheme" content="light dark" />
<!--
Dev-only tool page; not subject to the design-token policy that
governs feature code under src/components/**. CSS keeps to system
colors (Canvas / CanvasText) and a small set of inline rules so
the page renders standalone without loading the app's design system.
-->
<style>
:root {
color-scheme: light dark;
font-family: ui-sans-serif, system-ui, sans-serif;
}
body {
max-width: 880px;
margin: 2rem auto;
padding: 0 1rem;
line-height: 1.5;
color: CanvasText;
background: Canvas;
}
h1 { margin-bottom: 0.25rem; }
.subtitle { color: GrayText; margin-top: 0; }
button {
padding: 0.6rem 1.2rem;
font-size: 1rem;
cursor: pointer;
border-radius: 6px;
}
button[disabled] { opacity: 0.5; cursor: not-allowed; }
#status { font-weight: 600; margin: 1rem 0 0.25rem; }
#progress { font-family: ui-monospace, monospace; color: GrayText; }
#log {
background: color-mix(in srgb, CanvasText 92%, Canvas);
color: Canvas;
padding: 0.75rem;
border-radius: 6px;
font-family: ui-monospace, monospace;
font-size: 0.85rem;
height: 320px;
overflow-y: auto;
white-space: pre-wrap;
margin-top: 1rem;
}
.downloads {
margin-top: 1rem;
display: flex;
gap: 0.75rem;
}
.downloads a[hidden] { display: none; }
.downloads a {
padding: 0.5rem 0.9rem;
border: 1px solid currentColor;
border-radius: 6px;
text-decoration: none;
}
.note {
font-size: 0.9rem;
color: GrayText;
margin-top: 1.5rem;
}
</style>
</head>
<body>
<h1>Rewrite eval harness</h1>
<p class="subtitle">
Issue #65 · dev-only · WebGPU required · not bundled into production.
</p>
<p>
Runs the selected model against every prompt variant against every
fixture under <code>tests/fixtures/rewrite/</code>. Deterministic
rubric only — no judge model is loaded.
</p>
<p>
One model per tab on purpose: cycling multiple multi-GB models in a
single browser tab is fragile on consumer GPUs (eviction-then-reload
can OOM). Run each model in a fresh tab, download its report, then
open a new tab for the next one.
</p>
<p>
<label for="model">Model:&nbsp;</label>
<select id="model"></select>
&nbsp;
<button id="run" type="button">Run eval</button>
</p>
<p id="status">Idle. Click &ldquo;Run eval&rdquo; to start.</p>
<p id="progress">&nbsp;</p>
<div class="downloads">
<a id="download-json" hidden>Download JSON report</a>
<a id="download-md" hidden>Download Markdown report</a>
</div>
<pre id="log"></pre>
<p class="note">
The downloaded report goes under
<code>tests/fixtures/rewrite/reports/</code> when you commit a fresh
run. See <code>src/lib/webllm/eval/README.md</code> for the
report-commit workflow.
</p>
<script type="module" src="/src/lib/webllm/eval/run-eval-browser.ts"></script>
</body>
</html>
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"test": "vitest run",
"test:watch": "vitest",
"bake-fixtures": "UPDATE_FIXTURES=1 vitest run src/lib/heuristics/corpus.test.ts",
"eval:rewrite": "vite --open=/resumelint/eval-rewrite.html",
"typecheck": "tsc -b --noEmit",
"lint": "eslint .",
"deploy": "./scripts/deploy_resumelint.sh",
Expand Down
36 changes: 24 additions & 12 deletions scripts/run_resumelint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@ set -euo pipefail
# Commands: ./scripts/run_resumelint.sh <command> [args]
#
# Commands:
# dev Start Vite dev server (http://localhost:5173)
# build Build static bundle into dist/
# preview Serve the built bundle (builds first if dist/ is missing)
# test Run vitest
# test:watch Run vitest in watch mode
# typecheck tsc -b --noEmit (lint alias)
# install npm install
# clean Remove dist/ and node_modules/ (asks for confirmation)
# deploy Build and deploy to GCS — forwards args to deploy_resumelint.sh
# (e.g. ./scripts/run_resumelint.sh deploy --dry-run)
# dev Start Vite dev server (http://localhost:5173)
# build Build static bundle into dist/
# preview Serve the built bundle (builds first if dist/ is missing)
# test Run vitest
# test:watch Run vitest in watch mode
# typecheck tsc -b --noEmit (lint alias)
# install npm install
# clean Remove dist/ and node_modules/ (asks for confirmation)
# eval:rewrite Open the dev-only rewrite-quality eval page (#65). WebGPU required.
# deploy Build and deploy to GCS — forwards args to deploy_resumelint.sh
# (e.g. ./scripts/run_resumelint.sh deploy --dry-run)

source "$(dirname "${BASH_SOURCE[0]}")/common.sh"

Expand Down Expand Up @@ -85,6 +86,14 @@ cmd_clean() {
npm_clean "$WEB_DIR"
}

cmd_eval_rewrite() {
cd "$WEB_DIR"
ensure_npm_deps "$WEB_DIR"
log_info "Opening rewrite-eval page on http://localhost:$DEV_PORT/resumelint/eval-rewrite.html"
log_info "Ctrl+C to stop the dev server when done. WebGPU required to run inference."
npm run eval:rewrite
}

cmd_deploy() {
# Forward any remaining args (e.g. --dry-run, --mode=modified) verbatim.
if [[ ! -x "$DEPLOY_SCRIPT" ]]; then
Expand All @@ -111,6 +120,7 @@ ${BLUE}4)${NC} Run tests (vitest run)
${BLUE}5)${NC} Test watch (vitest, watch mode)
${BLUE}6)${NC} Typecheck (tsc -b --noEmit)
${BLUE}7)${NC} Install deps (npm install)
${BLUE}e)${NC} Rewrite eval page (dev-only, WebGPU; issue #65)
${BLUE}d)${NC} Deploy to GCS (scripts/deploy_resumelint.sh)
${BLUE}p)${NC} Deploy --dry-run (preview what would upload)
${BLUE}c)${NC} Clean (rm -rf dist/ node_modules/)
Expand All @@ -134,6 +144,7 @@ interactive_menu() {
5) cmd_test_watch || true ;;
6) cmd_typecheck || true ;;
7) cmd_install || true ;;
e|E) cmd_eval_rewrite || true ;;
d|D) cmd_deploy || true ;;
p|P) cmd_deploy --dry-run || true ;;
c|C) cmd_clean || true ;;
Expand All @@ -142,7 +153,7 @@ interactive_menu() {
esac
# Skip the Enter-prompt for foregrounded long-runners — they already
# blocked until the user was ready to come back.
if [[ ! "$choice" =~ ^[135]$ ]] && [[ "${choice:-}" != "p" ]] && [[ "${choice:-}" != "P" ]]; then
if [[ ! "$choice" =~ ^[135]$ ]] && [[ "${choice:-}" != "p" ]] && [[ "${choice:-}" != "P" ]] && [[ "${choice:-}" != "e" ]] && [[ "${choice:-}" != "E" ]]; then
echo ""
read -rp "Press Enter to continue..."
fi
Expand All @@ -167,13 +178,14 @@ else
typecheck|lint) cmd_typecheck ;;
install) cmd_install ;;
clean) cmd_clean ;;
eval:rewrite|eval-rewrite) cmd_eval_rewrite ;;
deploy) cmd_deploy "$@" ;;
-h|--help|help)
sed -n '3,21p' "${BASH_SOURCE[0]}"
;;
*)
log_error "Unknown command: $subcommand"
echo "Usage: $0 [dev|build|preview|test|test:watch|typecheck|install|clean|deploy [args...]]"
echo "Usage: $0 [dev|build|preview|test|test:watch|typecheck|install|clean|eval:rewrite|deploy [args...]]"
echo " $0 (interactive menu)"
exit 1
;;
Expand Down
7 changes: 6 additions & 1 deletion src/hooks/useModelSelection.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,12 @@ function Probe({
}

beforeEach(() => {
localStorage.clear();
// Node 22+ ships a built-in global `localStorage` that shadows jsdom's
// `Storage` and exposes no `clear()` method, so a bare `localStorage.clear()`
// throws on newer runtimes (green on CI's Node 20, red on Node 25 locally).
// Optional-chain it to match the store's own defensive access; the per-key
// cleanup that actually matters is done by the reset helper below.
globalThis.localStorage?.clear?.();
_resetPersistedModelSelectionForTesting();
});

Expand Down
14 changes: 13 additions & 1 deletion src/lib/score/score.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,19 @@ function bulletHasMetric(text: string): boolean {
return ANY_DIGIT.test(stripped);
}

const ACTION_VERBS = new Set([
/**
* Curated past-tense action verbs used to grade the user's *existing*
* bullets. Exported so the rewrite eval (`src/lib/webllm/eval/verbs.ts`)
* can reuse this as the base set without duplicating it — the eval set
* adds present-tense and IC-discipline verbs on top, but the scorer's
* specificity-dimension semantics stay anchored here.
*
* Kept narrow on purpose: weak generic verbs ("worked", "helped",
* "supported", "responsible", "assisted", "participated") are deliberately
* NOT here. A bullet leading with one of those SHOULD fail the
* specificity check.
*/
export const ACTION_VERBS: ReadonlySet<string> = new Set([
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
"led", "managed", "developed", "built", "designed", "implemented",
"created", "launched", "drove", "increased", "reduced", "improved",
"delivered", "established", "optimized", "architected", "scaled",
Expand Down
114 changes: 114 additions & 0 deletions src/lib/webllm/eval/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Rewrite-quality eval harness

Phase 3 of the in-browser AI rewrite epic (issue #65). Scores
section-rewrite outputs against a deterministic rubric so the default
model + prompt are picked from measurement rather than vibes.

## Layout

```
src/lib/webllm/eval/
├── types.ts # FixtureKind, RubricResult, EvalReport, RewriteFn
├── verbs.ts # curated action-verb set (superset of scorer's)
├── fixtures.ts # loads + validates JSON fixtures
├── rubric.ts # the six deterministic criteria
├── prompt-variants.ts # the shipped prompt + experimental variants
├── runner.ts # iterates (model × variant × fixture)
├── report.ts # JSON + Markdown formatters
└── run-eval-browser.ts # browser entry — wires real WebLLM engine
```

Fixtures live under `tests/fixtures/rewrite/`; reports get committed to
`tests/fixtures/rewrite/reports/`.

## Two execution legs

### 1. Scoring leg (CI)

Pure scoring logic — rubric, runner, formatters, fixture loading — all
unit-tested under `*.test.ts` siblings. Runs in the default
`npm run test` and is exercised on every PR via the existing CI gate.
No model, no WebGPU, no network.

### 2. Inference leg (local, WebGPU)

Real models run only in a browser. The entry point is the dev-only
`eval-rewrite.html` page at the project root:

```sh
npm run eval:rewrite
# opens http://localhost:5173/resumelint/eval-rewrite.html
```

**One model per tab.** The page asks you to pick a model from the
dropdown, then click **Run eval** — it loads that model only, runs every
prompt variant against every fixture, scores with the rubric, and
exposes JSON + Markdown report downloads. To compare another model,
open a fresh tab (or refresh) and pick a different one.

This is intentional: cycling several multi-GB models in a single tab
kept crashing Chrome on consumer GPUs during the WebGPU
eviction-then-reload path. Closing and reopening the tab between
models reclaims VRAM cleanly. The downside is the maintainer commits
one report file per model and reviewers compare them side-by-side —
still cheap.

Each downloaded report includes the model slug in the filename
(`eval-rewrite-qwen2-5-1-5b-…-{timestamp}.{json,md}`) so the three
per-model files coexist under `tests/fixtures/rewrite/reports/` without
collision. Reports are append-only — never overwrite a prior run.

`eval-rewrite.html` is NOT included in `build.rollupOptions.input`, so
the production bundle is unaffected.

## Reading the report

The Markdown report leads with a per-`(model, variant)` aggregate row.
Six rates (numbers / one-line / verb / length / no-preamble / dedup) and
the equal-weight composite `Aggregate` column drive the model choice.
Per-cell records below the aggregate let you trace a failure to a
specific fixture.

The dedup column is `—` for non-redundant fixtures (the criterion
doesn't apply); the aggregate's dedup rate is computed over `redundant`
fixtures only.

The judge column is `—` until the optional LLM-judge gate is enabled.
That path is flag-plumbed (`runEval({ judgeEnabled })`) but the
implementation is intentionally stubbed — coherence judging is a follow-up.

## Adding a fixture

Drop a JSON file under `tests/fixtures/rewrite/` with this shape:

```json
{
"id": "kebab-case-id",
"kind": "weak | strong | numeric | redundant",
"description": "What this fixture stresses, for the report's prose.",
"bullets": ["...", "..."]
}
```

Then append an `import` + entry in `fixtures.ts::REWRITE_FIXTURES`.
`parseFixture` validates shape at module load — a malformed fixture
throws with a precise pointer before any eval runs.

**PII policy still applies.** Bullet fixtures are persona-free by
construction (no contact info), but keep employer names, dates, and
résumé details synthetic. The repo is public.

## Adding a prompt variant

Append to `prompt-variants.ts::PROMPT_VARIANTS`. The runner enumerates
the array; the browser entry picks all of them up automatically. Keep
deltas small — one or two rule changes per variant — so a regression in
any one criterion traces cleanly to the prompt change.

## Choosing a default model

The aggregate's `Aggregate` column is the equal-weight mean of the
deterministic rates. If two models tie within ~3 points, prefer the
smaller / Apache-2.0 one — the eval is a measurement floor, not the only
input (license, download size, and consent friction matter for the
shipped default).
Loading
Loading