Short answer: treat text classification as a versioned evaluation problem before treating it as an API integration. For an e-commerce code-review service, the useful output is not a fluent explanation. It is a small, validated set of findings whose category, severity, file, and line can survive a regression test.
The simple approach is familiar: send a diff to a GPT API, ask for labels, parse the response, and let an empty array look like a clean review. The chosen approach has a stricter boundary. A Node.js worker sends bounded input, accepts unknown output, validates one schema, and records invalid, timed-out, and valid results as different outcomes. In 2026, that distinction is still operationally important: a valid empty result is data; a failed classification is an event.
That distinction is the experiment. Before copying this design, measure field validity, category agreement, line accuracy, false-clean results, latency, and token use on changes from the target repository. The trade-off is explicit: extra context can improve a label while increasing request size and making comparisons less clean. A prompt that sounds good in a demo is not evidence that its labels are dependable.
Consider a small change to checkout/discount.ts that changes rounding after a coupon is applied. The classifier may return pricing and data-integrity findings, or it may return a persuasive paragraph with no usable location. The second response is not a near miss. It is an invalid review result.
How should Node.js implement text classification using a typed boundary?
The application should own the vocabulary. A practical review contract can use security, data-integrity, pricing, performance, and style as categories, with blocker, warning, and note as severities. Those values are policy choices for this service, not universal meanings supplied by a model.
The contract also needs a policy version. A label is difficult to audit if the rules that produced it disappear when the prompt changes. Include the changed file, a positive line number, the rule identifier, and a bounded explanation in each finding. Keep the source diff separate from the result so a later reviewer can inspect what was actually classified. Six fields are enough for this example; adding fields without adding test cases only makes the contract look more precise.
Keep it boring.
Here is the domain shape. It contains no provider-specific response fields.
type Severity = "blocker" | "warning" | "note";
type Category = "security" | "data-integrity" | "pricing" | "performance" | "style";
type Finding = {
severity: Severity;
category: Category;
file: string;
line: number;
rule: string;
explanation: string;
};
type ReviewResult = {
policyVersion: "checkout-review-v2";
findings: Finding[];
};
The interesting design decision is not the union syntax. It is what happens outside the union. The model response remains unknown until the boundary checks it. A missing field cannot quietly become undefined, and a malformed response cannot quietly become findings: [].
How do you test the label boundary before tuning the prompt?
Start with a small, labeled corpus of real change shapes: a pricing calculation, an inventory update, an authorization check, a slow catalog query, and a harmless refactor. The labels should be adjudicated by the review policy, not inferred from whatever the model happened to say on the first run. Keep disagreements in the corpus; they expose ambiguous rules that a cleaner sample hides.
The test runner should compare fields, not just whole strings. A result can have the right category and the wrong line. It can find a real issue and assign a severity that should block a merge. It can produce valid JSON while missing the only important finding. Those are separate measurements.
type ExpectedFinding = Pick<Finding, "category" | "severity" | "file" | "line">;
type ReviewCase = {
name: string;
diff: string;
expected: ExpectedFinding[];
};
function scoreCase(actual: ReviewResult, expected: ExpectedFinding[]) {
const actualKeys = new Set(
actual.findings.map((item) => `${item.category}:${item.severity}:${item.file}:${item.line}`),
);
const expectedKeys = expected.map(
(item) => `${item.category}:${item.severity}:${item.file}:${item.line}`,
);
const matched = expectedKeys.filter((key) => actualKeys.has(key)).length;
return {
fieldValid: true,
matched,
expected: expectedKeys.length,
falseClean: expectedKeys.length > 0 && actual.findings.length === 0,
};
}
This score is intentionally modest. It does not claim that a model understands a repository. It tells the owner which part of the contract changed after a prompt, schema, model, or runtime change. A separate check should count invalid responses, refusals, timeouts, and transport errors, because none of them means “no findings.”
For a solo builder, this evaluation step is also a cost control. Sending only the changed files and the policy context limits unnecessary input. Reusing a stable policy prompt helps make comparisons interpretable. A shorter request is not automatically better if it removes the line context needed to locate a finding.
What does a safe response path look like in TypeScript?
Validation should reject values outside the contract and preserve the distinction between an empty valid result and a failed run. A hand-written guard is adequate for this small example; a maintained JSON Schema or validation library is easier to extend when the review policy grows.
function isReviewResult(value: unknown): value is ReviewResult {
if (!value || typeof value !== "object") return false;
const result = value as Record<string, unknown>;
if (typeof result.policyVersion !== "string" || !Array.isArray(result.findings)) {
return false;
}
return result.findings.every((item) => {
if (!item || typeof item !== "object") return false;
const finding = item as Record<string, unknown>;
const validSeverity = ["blocker", "warning", "note"].includes(String(finding.severity));
const validCategory = [
"security",
"data-integrity",
"pricing",
"performance",
"style",
].includes(String(finding.category));
return validSeverity &&
validCategory &&
typeof finding.file === "string" &&
Number.isInteger(finding.line) &&
Number(finding.line) > 0 &&
typeof finding.rule === "string" &&
typeof finding.explanation === "string" &&
String(finding.explanation).length <= 500;
});
}
async function classifyForReview(
request: (input: string) => Promise<unknown>,
input: { diff: string; policyVersion: string },
): Promise<ReviewResult> {
const raw = await request(JSON.stringify(input));
if (!isReviewResult(raw)) {
throw new Error("classifier returned an invalid review result");
}
return raw;
}
The 500 character ceiling is an example of an application constraint, not a claim about model behavior. The same is true of the allowed labels. Put these decisions in tests and review them like code. If a downstream system needs a new category, change the policy and the expected cases together.
The worker also needs an explicit status around this function. valid can annotate a review. empty can mean the validated result contains no findings. invalid, timeout, and transport-error should remain visible to the caller. Converting any of the latter states into approval creates a dangerous false-clean path.
That is the trap.
Where do latency, cost, and vendor boundaries belong?
Keep the provider call behind a narrow adapter. The review policy should not know whether the text-generation endpoint is hosted, self-hosted, or accessed through an SDK. The adapter can translate request and response formats, while the evaluation harness compares the same ReviewResult contract every time.
The boundary is easier to review when the choices are written down:
| Option | Access shape | Useful fit | Main limitation |
|---|---|---|---|
| Hosted API | SDK or REST adapter | Fast experiments and managed inference | Provider-specific response and failure semantics |
| Self-hosted runtime | Internal HTTP adapter | Data-residency or deployment control | The team owns serving capacity and upgrades |
| Local test double | In-process TypeScript adapter | Deterministic contract and regression tests | It cannot represent live model behavior |
This table describes integration boundaries, not a ranking. The test double belongs in the harness even when production uses an API, because a review worker should be testable without a network call.
That separation protects against two opposite mistakes. Binding the whole review service to one response format makes a future migration a rewrite. Pretending every runtime behaves identically hides differences in refusal behavior, structured-output support, context limits, and operational controls. The interface does not erase those differences; it gives them one place to measure.
interface ClassifierRuntime {
classify(input: {
diff: string;
policyVersion: string;
}): Promise<unknown>;
}
async function runReview(
runtime: ClassifierRuntime,
input: { diff: string; policyVersion: string },
): Promise<ReviewResult> {
const raw = await runtime.classify(input);
if (!isReviewResult(raw)) {
throw new Error("review result failed schema validation");
}
return raw;
}
Measure duration, input and output token counts when the runtime exposes them, validation status, policy version, and a redacted request identifier. Do not log a full diff by default: a code change can contain credentials, customer data, or proprietary pricing rules. The operational record should answer “which policy ran and what state did it produce?” without becoming a second data store for source code.
There is a real trade-off in sending more context. Repository conventions can improve classification, but extra context increases processing work and can make the relevant hunk harder to isolate. Start with the changed files and the smallest policy context that explains the labels. Add context only when the evaluation cases show a repeatable miss that it fixes.
A shipping rule for structured review findings
Run the classifier in advisory mode first. Compare its structured output with reviewed cases, inspect false-clean results separately, and set a release threshold for each category. A single aggregate accuracy number is too forgiving: a clean result on a harmless refactor should not cancel out a missed pricing defect.
The decision rule can stay plain: ship a change only when its evaluation results, validation behavior, failure states, and telemetry are understood. Keep the model call replaceable. Keep the schema owned by the application. Keep “no findings” different from “no trustworthy result.”
The Node.js example is therefore a testable boundary, not a long request snippet. It gives a code-review job a stable meaning for its labels while leaving runtime choice, deployment, and later migration open to evidence.
Top comments (0)