# Node.js Candidate Scoring Chatbots: Deterministic JSON Beats Model Brand Shortcuts

> Source: <https://dev.to/evanshepherd5623/nodejs-candidate-scoring-chatbots-deterministic-json-beats-model-brand-shortcuts-1o4a>
> Published: 2026-10-25 22:09:41+00:00

Short answer: choose the LLM API that gives a Node.js service a clear, testable contract for structured output. For an in-app chatbot that scores candidates against a job rubric, validate every response, log the decision path, retry only safe failures, and keep the rubric outside the model's memory.

The API brand is secondary. A malformed score can move a candidate into the wrong review queue. A slow answer can make a recruiter submit the same request twice.

| Criterion | Pick this when | Verify | 
|---|---|---|
| Strict structured output | A score drives a workflow or database write | Required keys, bounds, and refusal behavior | 
| Streaming text | The user needs visible progress | Partial-message handling and final validation | 
| Batch processing | Scoring can wait | Job state, replay safety, and correlation | 
| Minimal request | The chatbot answers a low-risk question | Timeout, retry, and fallback behavior | 

Start with the decision contract, not the prompt. Require exactly three fields: `score`, `matchedCriteria`, and `missingCriteria`. Make `score` an integer from 0 through 100. Make the arrays contain strings. Reject extra keys unless the contract explicitly permits them.

Here is a deliberately boring Node.js boundary. It assumes a generic OpenAI-compatible endpoint, but the rules do not depend on a vendor.

```
type CandidateScore = {
  score: number;
  matchedCriteria: string[];
  missingCriteria: string[];
};

function parseCandidateScore(raw: unknown): CandidateScore | null {
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
  const value = raw as Record<string, unknown>;
  const keys = Object.keys(value).sort();
  if (keys.join(",") !== "matchedCriteria,missingCriteria,score") return null;
  if (typeof value.score !== "number" || !Number.isInteger(value.score) || value.score < 0 || value.score > 100) return null;
  if (!isStringArray(value.matchedCriteria) || !isStringArray(value.missingCriteria)) return null;
  return {
    score: value.score,
    matchedCriteria: value.matchedCriteria,
    missingCriteria: value.missingCriteria
  };
}

function isStringArray(value: unknown): value is string[] {
  return Array.isArray(value) && value.every((item) => typeof item === "string");
}
```

The sorted-key check is strict on purpose. If extension fields are needed, use a schema validator and define a policy for unknown keys. Do not silently discard fields that could affect a hiring decision. Syntax is the first gate, not the last.

Strict means strict.

A short interactive score request belongs on the synchronous path. Set a deadline, attach a request ID, and return a neutral retryable error when the deadline expires. The UI can show that the score is pending instead of presenting an invented result.

Streaming helps with conversational explanation, but a partial explanation is not a valid score. Buffer the structured result, validate it after the stream ends, and render progress separately from the decision. A connection can close after the first half of a JSON object.

Batch processing fits a review backlog where an immediate answer has no product value. Treat each item as an idempotent job. Store an input fingerprint, rubric version, and result state. The public Batch API guide illustrates the broader pattern: asynchronous work needs explicit tracking.

The rule is simple: interactive work gets a bounded request; visible prose may stream; bulk scoring gets a tracked job. Choose the mode because the user can tolerate its failure behavior.

Logs answer “what happened?” Metrics answer “how often?” Alerts answer “when should someone act?” Keep those questions separate.

For each attempt, record a correlation ID, rubric version, request mode, latency, validation status, retry count, and reason code. Do not put resume text or the full prompt in ordinary application logs. A redacted hash can connect records without turning the log stream into a second document store. Keep the event useful enough to reconstruct the control flow: when a reviewer asks why a candidate received 72 rather than 70, the answer should identify the rubric version, the accepted response, and the validation result without copying sensitive source text into a log. Operational visibility and unrestricted data collection are different requirements.

Useful counters include validation failures, deadline expirations, and duplicate submissions. A latency histogram is more useful than an average because a small tail can cause duplicate clicks. Track accepted scores separately from rejected responses; the denominator matters.

```
type ScoreEvent = {
  requestId: string;
  rubricVersion: string;
  mode: "interactive" | "stream" | "batch";
  latencyMs: number;
  validation: "accepted" | "rejected";
  retryCount: number;
};

function recordScoreEvent(event: ScoreEvent): void {
  console.info(JSON.stringify({ event: "candidate_score", ...event }));
}
```

The event is operational metadata, not an evaluation of a person's worth. Keep access narrow, define retention, and let reviewers inspect the rubric version used for a result.

The first trap is treating valid JSON as valid business data. JSON can parse while the score is a string, the score is 140, or the criteria list is empty. The second trap is retrying every error. Retry a transient transport failure only when the operation is idempotent or carries a deduplication key. Do not retry a rejected schema response forever. Two attempts are a reasonable starting ceiling, but the correct limit depends on the deadline and workflow.

The third trap is changing the rubric without versioning it. A result produced against rubric `2026-10` should remain tied to that rubric after the next revision ships. The fourth is testing only happy paths. Add fixtures for missing fields, extra fields, invalid numbers, refusals, truncated streams, duplicate submissions, and timeout recovery.

There is a real trade-off here. This method is unsuitable when a team cannot retain rubric versions, review rejected results, or define who may see candidate data. In that case, a simpler non-scoring chatbot may be the safer product decision. A strict schema cannot prove that a candidate was assessed fairly, and a dashboard cannot tell you whether the rubric itself is appropriate.

Select the service that lets the team test these boundaries, export the signals it needs, and change the implementation without rewriting the product contract. Re-run the same fixture set after a model, prompt, SDK, or rubric change. The best fit is the one whose failure behavior the team can explain.
