DEV Community

ChrysostomHayes8537
ChrysostomHayes8537

Posted on

Use JSON Schema for Structured Summarization: A Node.js Portability Guide

Short answer: Use JSON Schema for structured summarization in Node.js, keep the contract in application-owned code, and put the selected model endpoint behind an adapter that returns untrusted data.

The portable unit is the schema contract, not the model request. For an education product extracting supplier invoices, keep the fields, null semantics, validation rules, and review states in application-owned code. A later endpoint can sit behind the same boundary. This preserves provider portability without pretending that valid JSON is valid accounting.

I would ship the smallest contract that the downstream workflow can defend: supplier name, invoice number, currency, total, issue date, line items, and review reasons. Missing scalar values are null. A model never gets to turn “not visible” into zero.

What should the contract guarantee?

It should guarantee shape and explicit absence. It should not claim that a number came from the document, that a total is payable, or that line items reconcile. Those are application checks after parsing. That separation is the useful design decision: changing an endpoint changes translation code, while changing invoice meaning requires an intentional schema migration.

Here is the contract used by the extraction boundary. It is ordinary JSON Schema data, so the same document can be passed to an endpoint adapter and to a local validator. The invoice-summary-v1 label belongs in persisted records as well as fixture names.

type InvoiceSummary = {
  supplierName: string | null;
  invoiceNumber: string | null;
  currency: string | null;
  total: number | null;
  issueDate: string | null;
  lineItems: Array<{
    description: string;
    quantity: number | null;
    unitAmount: number | null;
    amount: number | null;
  }>;
  reviewReasons: string[];
};

const invoiceSummarySchema = {
  type: "object",
  additionalProperties: false,
  required: [
    "supplierName",
    "invoiceNumber",
    "currency",
    "total",
    "issueDate",
    "lineItems",
    "reviewReasons"
  ],
  properties: {
    supplierName: { type: ["string", "null"] },
    invoiceNumber: { type: ["string", "null"] },
    currency: { type: ["string", "null"] },
    total: { type: ["number", "null"] },
    issueDate: { type: ["string", "null"] },
    lineItems: {
      type: "array",
      items: {
        type: "object",
        additionalProperties: false,
        required: ["description", "quantity", "unitAmount", "amount"],
        properties: {
          description: { type: "string" },
          quantity: { type: ["number", "null"] },
          unitAmount: { type: ["number", "null"] },
          amount: { type: ["number", "null"] }
        }
      }
    },
    reviewReasons: { type: "array", items: { type: "string" } }
  }
} as const;
Enter fullscreen mode Exit fullscreen mode

The contract is intentionally boring. That is a feature for a payment-adjacent workflow.

How can Node.js use JSON Schema for structured summarization?

The application asks for one logical operation. The adapter handles endpoint-specific request formatting and returns a string. It does not cast that string to InvoiceSummary, and it does not rename fields silently. This shape can target an OpenAI GPT endpoint today and a different hosted or self-hosted endpoint later without moving invoice rules into an SDK callback.

There are three reasonable adapter shapes. The choice is a portability trade-off, not a quality ranking.

Adapter shape Integration boundary Useful when Main limitation
SDK adapter A typed client function The team accepts one client library in a small service SDK types can pull provider concepts into application code
REST adapter A narrow fetch-style function Request details need to remain explicit and replaceable The team owns response parsing and transport policy
Self-hosted adapter An internal service contract Data residency or deployment control is the deciding constraint The team owns more capacity, upgrades, and operations

For this invoice workflow, the REST-style boundary is the easiest default to audit. It is also more code than an SDK call. That is the point: portability has a maintenance cost, and hiding that cost in a helper does not remove it.

type GenerationRequest = {
  system: string;
  user: string;
  schema: unknown;
  schemaVersion: string;
};

type GenerateStructured = (request: GenerationRequest) => Promise<string>;

function makeInvoiceRequest(invoiceText: string): GenerationRequest {
  return {
    schema: invoiceSummarySchema,
    schemaVersion: "invoice-summary-v1",
    system: [
      "Extract only facts present in the invoice text.",
      "Use null for an absent scalar value.",
      "Add a short review reason for missing or ambiguous values.",
      "Return JSON matching the supplied schema."
    ].join(" "),
    user: `Invoice text:\n${invoiceText}`
  };
}

async function summarizeInvoice(
  invoiceText: string,
  generateStructured: GenerateStructured,
  validate: (value: unknown) => InvoiceSummary
): Promise<InvoiceSummary> {
  const raw = await generateStructured(makeInvoiceRequest(invoiceText));
  let parsed: unknown;

  try {
    parsed = JSON.parse(raw);
  } catch {
    throw new Error("The structured response was not valid JSON");
  }

  return validate(parsed);
}
Enter fullscreen mode Exit fullscreen mode

The missing piece is deliberate. generateStructured is where the selected endpoint's documented request format belongs; the shared pipeline should not grow a list of vendor routes. A standards-based validator should check the exact JSON Schema draft selected by the service. After that, the application checks accepted currencies, rejects negative totals, and compares line-item arithmetic when all required values exist.

The seven top-level fields are enough for the first workflow, but they are not a universal invoice model. A rigid contract becomes a liability for an open-ended narrative summary, where fields change often and review has no clear owner. It is a good fit here because an absent amount must remain visible to the accounts workflow.

Which failures deserve review instead of retry?

These outcomes are different:

  • Invalid JSON or a schema mismatch is a failed extraction.
  • A missing total, incomplete source, or arithmetic discrepancy is a shaped result that needs human review.
  • A transport timeout can be retried, subject to the service's retry policy.

Collapsing those states into “the model failed” makes operations noisy and hides the real queue of accounting work. A retry identity is needed too. Derive an idempotency key from document identity and extraction version so a timeout cannot create two invoice records. Keep the raw document, normalized result, schema version, adapter identity, correlation ID, and validation decision together under the same access controls. In practice, the important version is the contract version: invoice-summary-v1 should remain attached even after the default adapter changes.

Supplier invoices are untrusted input. OCR may preserve a sentence that tries to redirect the model, and invoice text may be truncated by a context limit. Treat the text as data, preserve page boundaries, record truncation, and give the summarizer no write privileges. OWASP identifies prompt injection and insecure output handling as LLM application risks; a schema is one boundary in the pipeline, not a security policy.

How should a provider change be governed?

Treat the contract as a versioned release artifact. For invoice-summary-v1, keep fixtures for clean digital invoices, OCR-heavy scans, missing totals, multiple currencies, and repeated line items. Run the current and candidate adapters against the same fixtures. Compare null behavior, review reasons, validation decisions, latency, and token consumption field by field. A single pass percentage cannot show that a candidate started turning absent totals into zero.

The migration gate is not “the response parses.” The candidate must satisfy the same schema, domain checks, security handling, and review behavior. If it needs a provider-specific field to make the workflow work, either make that a deliberate contract version or stop the migration. That rule is stricter than swapping SDKs, but it keeps the accounting record understandable six months later.

The trade-off is visible in the deployment plan: an adapter reduces application coupling, while a shared contract adds fixture maintenance and a second validation step. That is a worthwhile exchange only when the invoice workflow may outlive the current endpoint. For a one-off internal script, the extra boundary can be unnecessary.

The operating checklist fits in a paragraph: freeze the schema version for a fixture run, validate locally, preserve the source, apply arithmetic and currency checks, route ambiguity to review, redact unnecessary supplier identifiers from telemetry, and make retries idempotent. Keep extraction separate from payment approval. Shape is the first gate, not permission to export a payment record.

Sources

Top comments (0)