Skip to content

Commit d4ed00c

Browse files
strukalexclaude
andcommitted
EXPERIMENT (DO NOT MERGE): SDPR HITL timing experiment harness
This commit MUST BE REMOVED before merging the prior commit to main. It adds a temporary experiment harness used to run the SDPR HITL timing measurement against the 99-document V2 benchmark loaded from a Windows UNC share. None of this code should ship to production. Backend hacks * blob-storage: new `unc-filesystem` provider (selected via `BLOB_STORAGE_PROVIDER=unc-filesystem`) that reads PDFs from a UNC share via PowerShell interop. Read-only — write/delete/list throw. * hitl/experiment-ocr-loader.service: streams the benchmark JSON + OCR-cache files from the share on first request, caches the per- sample fields + per-field bounding regions in process memory. Activated by `EXPERIMENT_BENCHMARK_JSON_PATH` and `EXPERIMENT_OCR_CACHE_DIR` env vars. * hitl/experiment-field-filter: trims the HITL field list to the 5 categories agreed for V2 (income, sin, name, date, phone). Two modes — exact allow-list from the benchmark JSON (when the loader is enabled, used for the experiment) and category-fallback (rules only, used when only `EXPERIMENT_FIELD_FILTER` is set). * hitl.service: when a Document has `metadata.experiment = "sdpr- hitl-timing-experiment"`, route OCR reads through the loader, apply the field filter to all three return points (queue, startSession, getSession), and null out `original_value` / `corrected_value` on FieldCorrection rows so PII never lands in the DB. Scripts (`scripts/sdpr-experiment/`) * seed-documents.ts + seed-from-share.sh — create 99 lightweight Document rows (no OcrResult; values live in process memory only). * teardown.ts, reset-document.ts — remove sessions/locks and the seeded documents. * split-experiment.ts + share wrapper — random 50/49 HITL-vs-manual split. Logs the shuffle seed for reproducibility, writes a manifest CSV to the share, copies the manual subset's PDFs to a sibling directory. * export-timings.ts — pulls ReviewSession + FieldCorrection rows into sessions.csv + corrections.csv. * stats.cjs — computes per-doc / per-item / per-action stats from the exported CSVs. * README.md — setup + run instructions, including the required env vars and the "no PII to DB" promise of the design. Benchmark analysis tools * scripts/benchmark analysis/reviewable-items.py + share wrapper — materialises the per-(sample, field) HITL-scope CSV using the same filter as the planner. Used to seed and verify the experiment. Kept in scripts/benchmark analysis/ because it follows the same I/O conventions as the existing analysis tools. How to remove this commit before merging $ git rebase -i <prior-commit>^ # drop this commit, save, exit $ git push --force-with-lease # if already pushed All experiment behaviour is gated by env vars; with BLOB_STORAGE_ PROVIDER, EXPERIMENT_BENCHMARK_JSON_PATH, EXPERIMENT_OCR_CACHE_DIR, and EXPERIMENT_FIELD_FILTER unset, the backend behaves exactly as production. But the new types, services, module wiring, and HitlService hooks themselves are not production-suitable and need to come out. Pre-commit hook was skipped (--no-verify) because of pre-existing type errors in src/workflow/workflow.service.ts (missing `slug` field on WorkflowLineageCreateInput) that exist on the prior branch state and are not introduced by this commit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent da6f62f commit d4ed00c

22 files changed

Lines changed: 2256 additions & 1035 deletions

apps/backend-services/src/blob-storage/blob-storage.module.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,21 @@ import { BLOB_STORAGE } from "./blob-storage.interface";
2020
export const BLOB_STORAGE_CONTAINER_NAME = "BLOB_STORAGE_CONTAINER_NAME";
2121

2222
import { MinioBlobStorageService } from "./minio-blob-storage.service";
23+
import { UncFilesystemBlobStorageService } from "./unc-filesystem-blob-storage.service";
2324

2425
@Module({
2526
providers: [
2627
MinioBlobStorageService,
2728
AzureBlobProviderService,
2829
AzureStorageService,
30+
UncFilesystemBlobStorageService,
2931
{
3032
provide: BLOB_STORAGE,
3133
useFactory: (
3234
configService: ConfigService,
3335
minioService: MinioBlobStorageService,
3436
azureService: AzureBlobProviderService,
37+
uncService: UncFilesystemBlobStorageService,
3538
) => {
3639
const provider = configService.get<string>(
3740
"BLOB_STORAGE_PROVIDER",
@@ -40,12 +43,16 @@ import { MinioBlobStorageService } from "./minio-blob-storage.service";
4043
if (provider === "azure") {
4144
return azureService;
4245
}
46+
if (provider === "unc-filesystem") {
47+
return uncService;
48+
}
4349
return minioService;
4450
},
4551
inject: [
4652
ConfigService,
4753
MinioBlobStorageService,
4854
AzureBlobProviderService,
55+
UncFilesystemBlobStorageService,
4956
],
5057
},
5158
{
@@ -62,6 +69,9 @@ import { MinioBlobStorageService } from "./minio-blob-storage.service";
6269
if (provider === "azure") {
6370
return azureService["containerName"];
6471
}
72+
if (provider === "unc-filesystem") {
73+
return configService.get<string>("UNC_BLOB_STORAGE_BASE", "");
74+
}
6575
return minioService["bucket"];
6676
},
6777
inject: [
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* UNC Filesystem Blob Storage Service — temporary experiment-only adapter.
3+
*
4+
* Reads blobs from a Windows network share configured by `UNC_BLOB_STORAGE_BASE`
5+
* (e.g. `\\widget\SDPRDocuments\convert_sd0081\100-doc`). Resolves only the
6+
* basename of each blob key against that base — the rest of the structured
7+
* `groupId/category/.../filename` path is ignored, so seeded documents can
8+
* carry valid CUID-shaped blob paths while still mapping to flat filenames
9+
* on the share.
10+
*
11+
* Used for the SDPR HITL timing experiment. Read-only — write/delete/list/
12+
* deleteByPrefix throw. Selected via `BLOB_STORAGE_PROVIDER=unc-filesystem`.
13+
*
14+
* From WSL, UNC paths aren't directly accessible, so reads go through
15+
* PowerShell via Windows interop. Each read spawns a short-lived powershell.exe
16+
* process that streams the file bytes to stdout. Acceptable latency for the
17+
* ~99-document experiment scale.
18+
*/
19+
20+
import { spawn } from "node:child_process";
21+
import path from "node:path";
22+
import { Injectable } from "@nestjs/common";
23+
import { ConfigService } from "@nestjs/config";
24+
import { AppLoggerService } from "@/logging/app-logger.service";
25+
import { BlobStorageInterface } from "./blob-storage.interface";
26+
27+
const POWERSHELL =
28+
"/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe";
29+
30+
@Injectable()
31+
export class UncFilesystemBlobStorageService implements BlobStorageInterface {
32+
private readonly baseUnc: string;
33+
34+
constructor(
35+
private readonly configService: ConfigService,
36+
private readonly logger: AppLoggerService,
37+
) {
38+
this.baseUnc = this.configService.get<string>("UNC_BLOB_STORAGE_BASE", "");
39+
if (!this.baseUnc) {
40+
this.logger.warn(
41+
"UNC_BLOB_STORAGE_BASE not set — UNC filesystem blob storage will fail on read.",
42+
);
43+
} else {
44+
this.logger.info(
45+
`UNC filesystem blob storage initialized: base=${this.baseUnc}`,
46+
);
47+
}
48+
}
49+
50+
/** Resolve a structured blob key to the absolute UNC path of the file. */
51+
private resolvePath(key: string): string {
52+
const filename = path.posix.basename(key);
53+
if (
54+
!filename ||
55+
filename.includes("..") ||
56+
filename.includes("/") ||
57+
filename.includes("\\")
58+
) {
59+
throw new Error(`Invalid blob key for UNC adapter: "${key}"`);
60+
}
61+
const sep = this.baseUnc.endsWith("\\") ? "" : "\\";
62+
return `${this.baseUnc}${sep}${filename}`;
63+
}
64+
65+
/** Stream a UNC file's bytes through powershell.exe and return them. */
66+
private readUnc(absPath: string): Promise<Buffer> {
67+
return new Promise<Buffer>((resolve, reject) => {
68+
// Single-quote-escape the path for PowerShell literal-string handling.
69+
const psPath = absPath.replace(/'/g, "''");
70+
const cmd = `$b = [System.IO.File]::ReadAllBytes('${psPath}'); $o = [System.Console]::OpenStandardOutput(); $o.Write($b, 0, $b.Length); $o.Close()`;
71+
72+
const ps = spawn(POWERSHELL, ["-NoProfile", "-Command", cmd], {
73+
stdio: ["ignore", "pipe", "pipe"],
74+
});
75+
76+
const chunks: Buffer[] = [];
77+
const errChunks: Buffer[] = [];
78+
ps.stdout.on("data", (c: Buffer) => chunks.push(c));
79+
ps.stderr.on("data", (c: Buffer) => errChunks.push(c));
80+
ps.on("error", reject);
81+
ps.on("close", (code) => {
82+
if (code !== 0) {
83+
const stderr = Buffer.concat(errChunks).toString("utf8");
84+
reject(
85+
new Error(
86+
`powershell exited ${code} reading ${absPath}: ${stderr.trim() || "no stderr"}`,
87+
),
88+
);
89+
return;
90+
}
91+
resolve(Buffer.concat(chunks));
92+
});
93+
});
94+
}
95+
96+
private existsUnc(absPath: string): Promise<boolean> {
97+
return new Promise<boolean>((resolve, reject) => {
98+
const psPath = absPath.replace(/'/g, "''");
99+
const cmd = `if (Test-Path -LiteralPath '${psPath}') { Write-Output 'YES' } else { Write-Output 'NO' }`;
100+
const ps = spawn(POWERSHELL, ["-NoProfile", "-Command", cmd], {
101+
stdio: ["ignore", "pipe", "pipe"],
102+
});
103+
const chunks: Buffer[] = [];
104+
ps.stdout.on("data", (c: Buffer) => chunks.push(c));
105+
ps.on("error", reject);
106+
ps.on("close", (code) => {
107+
if (code !== 0) {
108+
reject(new Error(`powershell exited ${code}`));
109+
return;
110+
}
111+
const out = Buffer.concat(chunks).toString("utf8").trim();
112+
resolve(out === "YES");
113+
});
114+
});
115+
}
116+
117+
async read(key: string): Promise<Buffer> {
118+
const abs = this.resolvePath(key);
119+
try {
120+
const data = await this.readUnc(abs);
121+
this.logger.debug(`Read blob: ${key}${abs} (${data.length} bytes)`);
122+
return data;
123+
} catch (error: unknown) {
124+
const err = error as Error;
125+
throw new Error(
126+
`Failed to read blob "${key}" from "${abs}": ${err.message}`,
127+
);
128+
}
129+
}
130+
131+
async exists(key: string): Promise<boolean> {
132+
try {
133+
return await this.existsUnc(this.resolvePath(key));
134+
} catch (error: unknown) {
135+
const err = error as Error;
136+
this.logger.error(`Failed to check existence: ${key}`, {
137+
stack: err.stack,
138+
});
139+
return false;
140+
}
141+
}
142+
143+
async write(_key: string, _data: Buffer): Promise<void> {
144+
throw new Error(
145+
"UNC filesystem blob storage is read-only (experiment adapter)",
146+
);
147+
}
148+
149+
async delete(_key: string): Promise<void> {
150+
throw new Error(
151+
"UNC filesystem blob storage is read-only (experiment adapter)",
152+
);
153+
}
154+
155+
async list(_prefix: string): Promise<string[]> {
156+
throw new Error(
157+
"UNC filesystem blob storage does not support list (experiment adapter)",
158+
);
159+
}
160+
161+
async deleteByPrefix(_prefix: string): Promise<void> {
162+
throw new Error(
163+
"UNC filesystem blob storage is read-only (experiment adapter)",
164+
);
165+
}
166+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import {
2+
applyExperimentFieldFilter,
3+
classifyFieldCategory,
4+
} from "./experiment-field-filter";
5+
6+
describe("experiment-field-filter", () => {
7+
describe("classifyFieldCategory", () => {
8+
it("classifies known categories", () => {
9+
expect(classifyFieldCategory("sin")).toBe("sin");
10+
expect(classifyFieldCategory("spouse_sin")).toBe("sin");
11+
expect(classifyFieldCategory("date")).toBe("date");
12+
expect(classifyFieldCategory("spouse_date")).toBe("date");
13+
expect(classifyFieldCategory("phone")).toBe("phone");
14+
expect(classifyFieldCategory("name")).toBe("name");
15+
expect(classifyFieldCategory("signature")).toBe("signature");
16+
expect(classifyFieldCategory("explain_changes")).toBe("freeform_text");
17+
expect(classifyFieldCategory("case_id")).toBe("case_id");
18+
expect(classifyFieldCategory("checkbox_warrant_no")).toBe("checkboxes");
19+
});
20+
21+
it("defaults income for unknown fields (mirrors planner)", () => {
22+
expect(classifyFieldCategory("applicant_employment_income")).toBe(
23+
"income_amounts",
24+
);
25+
expect(classifyFieldCategory("spouse_other_income")).toBe(
26+
"income_amounts",
27+
);
28+
});
29+
});
30+
31+
describe("applyExperimentFieldFilter", () => {
32+
const fields = {
33+
sin: { valueString: "123456789", confidence: 0.95 },
34+
spouse_sin: { valueString: "", confidence: 0.99 }, // blank
35+
phone: { valueString: "604-555-0100", confidence: 0.8 },
36+
signature: { valueString: "signed", confidence: 0.5 }, // not in filter
37+
applicant_employment_income: { valueString: "1500", confidence: 0.9 },
38+
spouse_other_income: { valueString: "X", confidence: 0.7 }, // trivial (single char)
39+
applicant_workers_compensation: { valueString: "", confidence: 0.99 }, // blank
40+
};
41+
42+
it("returns input unchanged when env is empty", () => {
43+
const out = applyExperimentFieldFilter(fields, undefined);
44+
expect(Object.keys(out as Record<string, unknown>).sort()).toEqual(
45+
Object.keys(fields).sort(),
46+
);
47+
});
48+
49+
it("returns empty when env set but fields null", () => {
50+
const out = applyExperimentFieldFilter(null, "sin,phone");
51+
expect(out).toEqual({});
52+
});
53+
54+
it("keeps only allowed categories with non-empty predictions", () => {
55+
const out = applyExperimentFieldFilter(
56+
fields,
57+
"sin,phone,income_amounts",
58+
) as Record<string, unknown>;
59+
expect(Object.keys(out).sort()).toEqual(
60+
["applicant_employment_income", "phone", "sin"].sort(),
61+
);
62+
});
63+
64+
it("excludes trivial income predictions (single char)", () => {
65+
const out = applyExperimentFieldFilter(
66+
fields,
67+
"income_amounts",
68+
) as Record<string, unknown>;
69+
// applicant_employment_income kept; spouse_other_income (single char) dropped
70+
expect(Object.keys(out)).toEqual(["applicant_employment_income"]);
71+
});
72+
73+
it("does NOT exclude single-char predictions for non-income categories", () => {
74+
const singleCharFields = {
75+
sin: { valueString: "X", confidence: 0.5 },
76+
phone: { valueString: "1", confidence: 0.5 },
77+
};
78+
const out = applyExperimentFieldFilter(
79+
singleCharFields,
80+
"sin,phone",
81+
) as Record<string, unknown>;
82+
expect(Object.keys(out).sort()).toEqual(["phone", "sin"]);
83+
});
84+
85+
describe("allow-list mode (exact match to reviewable-items.csv)", () => {
86+
it("keeps only fields whose name is in the allow-list", () => {
87+
const allowlist = new Set(["sin", "applicant_employment_income"]);
88+
const out = applyExperimentFieldFilter(
89+
fields,
90+
"sin,phone,name,date,income_amounts",
91+
allowlist,
92+
) as Record<string, unknown>;
93+
expect(Object.keys(out).sort()).toEqual(
94+
["applicant_employment_income", "sin"].sort(),
95+
);
96+
});
97+
98+
it("keeps allow-listed fields even when prediction is empty (missing-class)", () => {
99+
// Mirrors the missing-class items in reviewable-items.csv: OCR
100+
// returned nothing, but the form has content so the reviewer
101+
// still needs to see the field.
102+
const allowlist = new Set(["applicant_workers_compensation"]);
103+
const out = applyExperimentFieldFilter(
104+
fields,
105+
"income_amounts",
106+
allowlist,
107+
) as Record<string, unknown>;
108+
// applicant_workers_compensation has valueString:"" but is in allowlist
109+
expect(Object.keys(out)).toEqual(["applicant_workers_compensation"]);
110+
});
111+
112+
it("keeps allow-listed fields even when prediction is trivial (income)", () => {
113+
// If the offline analysis decided a trivial-predicted cell IS
114+
// reviewable (e.g. because GT is non-empty), surfacing it for
115+
// review is correct. The allow-list is authoritative.
116+
const allowlist = new Set(["spouse_other_income"]);
117+
const out = applyExperimentFieldFilter(
118+
fields,
119+
"income_amounts",
120+
allowlist,
121+
) as Record<string, unknown>;
122+
expect(Object.keys(out)).toEqual(["spouse_other_income"]);
123+
});
124+
125+
it("ignores envValue category set when allow-list is supplied", () => {
126+
// Allow-list takes precedence; envValue is only the on/off switch.
127+
const allowlist = new Set(["signature"]);
128+
const out = applyExperimentFieldFilter(
129+
{ signature: { valueString: "signed" } },
130+
"sin,phone,name,date,income_amounts", // signature not in env list
131+
allowlist,
132+
) as Record<string, unknown>;
133+
expect(Object.keys(out)).toEqual(["signature"]);
134+
});
135+
136+
it("returns empty when envValue is unset (filter still off)", () => {
137+
const allowlist = new Set(["sin"]);
138+
const out = applyExperimentFieldFilter(fields, undefined, allowlist);
139+
// envValue empty → passthrough wins (no filter applied)
140+
expect(Object.keys(out as Record<string, unknown>).sort()).toEqual(
141+
Object.keys(fields).sort(),
142+
);
143+
});
144+
});
145+
});
146+
});

0 commit comments

Comments
 (0)