DEV Community

Cover image for Node.js AI Summaries with Verifiable Transcript Citations

Node.js AI Summaries with Verifiable Transcript Citations

An AI-generated summary can be perfectly formatted and still leave your application unable to answer a basic question:

Which source text supports this claim?

That matters when a summary is going into a case note, compliance record, investigation workflow, customer document, or another output that somebody may review later.

A citation string from the model is not enough either.

The model can return:

{
  "claim": "The participant arrived at 8:30 PM.",
  "source_ids": ["seg_184"]
}
Enter fullscreen mode Exit fullscreen mode

but your application still needs to know:

  1. Does seg_184 exist?
  2. Did that segment belong to this user's workspace?
  3. Does the cited text still match the version used during generation?
  4. Does the cited text actually support the claim?
  5. Can a reviewer open the source without searching the whole transcript?

This article builds that boundary in Node.js.

We will create a small source-bound summarization pipeline with:

  • stable transcript segment IDs
  • OpenAI Structured Outputs
  • deterministic citation validation
  • source snapshots
  • SHA-256 hashes for detecting later transcript edits
  • review-ready evidence
  • unit tests for broken citations and stale sources

The model still generates the language.

The application keeps control of the evidence.

The output we want

Start with a transcript like this:

[seg_001] Interviewer: What time did you leave the office?

[seg_002] Participant: I think it was around 8:30 in the evening.

[seg_003] Interviewer: Did anyone leave with you?

[seg_004] Participant: No. I left by myself.
Enter fullscreen mode Exit fullscreen mode

A plain summary might return:

The participant left the office at 8:30 PM and left alone.
Enter fullscreen mode Exit fullscreen mode

It sounds fine.

But two pieces of source meaning have already become harder to see.

The speaker said:

I think
Enter fullscreen mode Exit fullscreen mode

and:

around 8:30
Enter fullscreen mode Exit fullscreen mode

The summary sounds more certain.

We want structured output that keeps each claim tied to its evidence:

{
  "summary": "The participant described leaving the office at around 8:30 PM and said they left alone.",
  "claims": [
    {
      "claim": "The participant said they thought they left the office at around 8:30 PM.",
      "source_ids": ["seg_002"]
    },
    {
      "claim": "The participant said they left alone.",
      "source_ids": ["seg_004"]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The application can now inspect the references before anybody approves the output.

1. Create the Node.js project

Create a small project:

mkdir transcript-citations
cd transcript-citations

npm init -y
npm install openai zod
Enter fullscreen mode Exit fullscreen mode

Update package.json:

{
  "name": "transcript-citations",
  "type": "module",
  "scripts": {
    "start": "node src/index.js",
    "test": "node --test"
  }
}
Enter fullscreen mode Exit fullscreen mode

Create this structure:

transcript-citations/
├── src/
│   ├── transcript.js
│   ├── schema.js
│   ├── summarize.js
│   ├── evidence.js
│   └── index.js
└── test/
    └── evidence.test.js
Enter fullscreen mode Exit fullscreen mode

Set your environment variables:

export OPENAI_API_KEY="your_api_key_here"
export OPENAI_MODEL="your_supported_model_here"
Enter fullscreen mode Exit fullscreen mode

Keeping the model name in configuration is useful when the application may change model versions later.

2. Store stable transcript IDs

Create src/transcript.js:

export const transcript = [
  {
    id: "seg_001",
    speaker: "Interviewer",
    startMs: 0,
    endMs: 4200,
    text: "What time did you leave the office?",
  },
  {
    id: "seg_002",
    speaker: "Participant",
    startMs: 4300,
    endMs: 9100,
    text: "I think it was around 8:30 in the evening.",
  },
  {
    id: "seg_003",
    speaker: "Interviewer",
    startMs: 9200,
    endMs: 12800,
    text: "Did anyone leave with you?",
  },
  {
    id: "seg_004",
    speaker: "Participant",
    startMs: 12900,
    endMs: 16000,
    text: "No. I left by myself.",
  },
];
Enter fullscreen mode Exit fullscreen mode

Do not use an array position as the citation:

transcript[3]
Enter fullscreen mode Exit fullscreen mode

Transcript cleanup can reorder, split, or merge segments.

A stable ID gives the generated claim something durable to reference.

In a larger application, I would usually keep fields such as:

segment_id
workspace_id
case_id
recording_id
speaker
start_time
end_time
text
Enter fullscreen mode Exit fullscreen mode

The workspace and case fields are especially important because citation validation should never become a way to cross an access boundary.

3. Define the output schema with Zod

Create src/schema.js:

import { z } from "zod/v4";

export const ClaimSchema = z.object({
  claim: z.string(),
  source_ids: z.array(z.string()).min(1),
});

export const SummarySchema = z.object({
  summary: z.string(),
  claims: z.array(ClaimSchema),
});
Enter fullscreen mode Exit fullscreen mode

Every claim must include at least one source ID.

That does not prove the source supports the claim.

It does make an uncited claim structurally invalid.

4. Generate structured claims

Create src/summarize.js:

import OpenAI from "openai";
import { zodTextFormat } from "openai/helpers/zod";

import { SummarySchema } from "./schema.js";

const client = new OpenAI();

const model = process.env.OPENAI_MODEL;

if (!model) {
  throw new Error(
    "OPENAI_MODEL must be set to a supported model."
  );
}

function formatTranscript(segments) {
  return segments
    .map(
      (segment) =>
        `[${segment.id}] ${segment.speaker}: ${segment.text}`
    )
    .join("\n");
}

export async function summarizeTranscript(segments) {
  const response = await client.responses.parse({
    model,

    instructions: `
Create a source-bound summary from the supplied transcript.

Rules:

1. Use only the supplied transcript.
2. Every factual claim must cite one or more transcript segment IDs.
3. Never invent a source ID.
4. Preserve uncertainty from the speaker.
5. Words such as "think", "around", "possibly", "approximately",
   and similar qualifiers must not silently become certain facts.
6. If the transcript does not support a claim, omit the claim.
    `.trim(),

    input: formatTranscript(segments),

    text: {
      format: zodTextFormat(
        SummarySchema,
        "source_bound_summary"
      ),
    },
  });

  if (!response.output_parsed) {
    throw new Error(
      "The model did not return a parsed summary."
    );
  }

  return response.output_parsed;
}
Enter fullscreen mode Exit fullscreen mode

The current OpenAI Node SDK can take a Zod schema through zodTextFormat() and return validated structured output through response.output_parsed.

That saves us from manually parsing an arbitrary JSON string.

We still have more validation to do.

5. Validate citations in application code

Create src/evidence.js:

import { createHash } from "node:crypto";

export function hashText(text) {
  return createHash("sha256")
    .update(text, "utf8")
    .digest("hex");
}

export function validateSourceIds(result, segments) {
  const validIds = new Set(
    segments.map((segment) => segment.id)
  );

  const errors = [];

  result.claims.forEach((claim, claimIndex) => {
    for (const sourceId of claim.source_ids) {
      if (!validIds.has(sourceId)) {
        errors.push({
          claimIndex,
          sourceId,
          code: "UNKNOWN_SOURCE_ID",
        });
      }
    }
  });

  return {
    valid: errors.length === 0,
    errors,
  };
}
Enter fullscreen mode Exit fullscreen mode

Suppose the model returns:

{
  "claim": "The participant left alone.",
  "source_ids": ["seg_999"]
}
Enter fullscreen mode Exit fullscreen mode

The schema accepts the string.

Our application rejects the citation because the source does not exist.

That distinction matters.

Structured output controls the shape.

Application code controls the boundary.

6. Build an evidence snapshot

Now attach the source material used by each claim.

Add this to src/evidence.js:

export function buildEvidenceSnapshot(
  result,
  segments
) {
  const byId = new Map(
    segments.map((segment) => [
      segment.id,
      segment,
    ])
  );

  return {
    ...result,

    claims: result.claims.map((claim) => ({
      ...claim,

      evidence: claim.source_ids.map(
        (sourceId) => {
          const segment = byId.get(sourceId);

          if (!segment) {
            throw new Error(
              `Unknown source ID: ${sourceId}`
            );
          }

          return {
            sourceId: segment.id,
            speaker: segment.speaker,
            startMs: segment.startMs,
            endMs: segment.endMs,
            text: segment.text,
            textHash: hashText(segment.text),
          };
        }
      ),
    })),
  };
}
Enter fullscreen mode Exit fullscreen mode

A claim can now look like this:

{
  "claim": "The participant said they left alone.",
  "source_ids": ["seg_004"],
  "evidence": [
    {
      "sourceId": "seg_004",
      "speaker": "Participant",
      "startMs": 12900,
      "endMs": 16000,
      "text": "No. I left by myself.",
      "textHash": "..."
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

This structure can feed a review screen directly.

The reviewer sees the generated wording and the source beside it.

7. Why store a source hash?

Imagine the transcript is corrected after the summary was generated.

Originally:

[seg_002]
I think it was around 8:30 in the evening.
Enter fullscreen mode Exit fullscreen mode

Later:

[seg_002]
I think it was closer to 9 in the evening.
Enter fullscreen mode Exit fullscreen mode

The source ID is still:

seg_002
Enter fullscreen mode Exit fullscreen mode

A simple ID validator would say everything is fine.

It is not.

The generated claim was based on an older source version.

That is why the snapshot stores a SHA-256 hash of the source text used at generation time.

We can detect that change later.

8. Detect stale evidence

Add this to src/evidence.js:

export function detectStaleEvidence(
  storedResult,
  currentSegments
) {
  const currentById = new Map(
    currentSegments.map((segment) => [
      segment.id,
      segment,
    ])
  );

  const stale = [];

  storedResult.claims.forEach(
    (claim, claimIndex) => {
      claim.evidence.forEach((snapshot) => {
        const current =
          currentById.get(snapshot.sourceId);

        if (!current) {
          stale.push({
            claimIndex,
            sourceId: snapshot.sourceId,
            code: "SOURCE_REMOVED",
          });

          return;
        }

        const currentHash =
          hashText(current.text);

        if (
          currentHash !== snapshot.textHash
        ) {
          stale.push({
            claimIndex,
            sourceId: snapshot.sourceId,
            code: "SOURCE_CHANGED",
          });
        }
      });
    }
  );

  return {
    stale: stale.length > 0,
    changes: stale,
  };
}
Enter fullscreen mode Exit fullscreen mode

Now the application can distinguish:

citation exists
Enter fullscreen mode Exit fullscreen mode

from:

citation exists and still points to the same source text
Enter fullscreen mode Exit fullscreen mode

That is a much stronger review boundary.

9. Put the pipeline together

Create src/index.js:

import { transcript } from "./transcript.js";
import { summarizeTranscript } from "./summarize.js";

import {
  validateSourceIds,
  buildEvidenceSnapshot,
  detectStaleEvidence,
} from "./evidence.js";

async function main() {
  const generated =
    await summarizeTranscript(transcript);

  const citationCheck =
    validateSourceIds(
      generated,
      transcript
    );

  if (!citationCheck.valid) {
    console.error(
      "Citation validation failed:",
      citationCheck.errors
    );

    process.exitCode = 1;
    return;
  }

  const reviewReady =
    buildEvidenceSnapshot(
      generated,
      transcript
    );

  console.dir(reviewReady, {
    depth: null,
  });

  const staleCheck =
    detectStaleEvidence(
      reviewReady,
      transcript
    );

  console.log(
    "Evidence stale:",
    staleCheck.stale
  );
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});
Enter fullscreen mode Exit fullscreen mode

Run:

npm start
Enter fullscreen mode Exit fullscreen mode

The path is now:

transcript
   ↓
structured AI claims
   ↓
validate source IDs
   ↓
snapshot evidence
   ↓
detect later source changes
   ↓
review
Enter fullscreen mode Exit fullscreen mode

10. Test the boundary without calling the model

The strongest tests for this part of the system do not need an API request.

Create test/evidence.test.js:

import test from "node:test";
import assert from "node:assert/strict";

import {
  validateSourceIds,
  buildEvidenceSnapshot,
  detectStaleEvidence,
} from "../src/evidence.js";

const segments = [
  {
    id: "seg_001",
    speaker: "Participant",
    startMs: 0,
    endMs: 1000,
    text: "I arrived at around 8:30.",
  },
];

test(
  "accepts an existing source ID",
  () => {
    const result = {
      summary: "Example",
      claims: [
        {
          claim:
            "The participant said they arrived at around 8:30.",
          source_ids: ["seg_001"],
        },
      ],
    };

    const validation =
      validateSourceIds(
        result,
        segments
      );

    assert.equal(
      validation.valid,
      true
    );
  }
);

test(
  "rejects an unknown source ID",
  () => {
    const result = {
      summary: "Example",
      claims: [
        {
          claim: "Example claim",
          source_ids: ["seg_999"],
        },
      ],
    };

    const validation =
      validateSourceIds(
        result,
        segments
      );

    assert.equal(
      validation.valid,
      false
    );

    assert.equal(
      validation.errors[0].code,
      "UNKNOWN_SOURCE_ID"
    );
  }
);

test(
  "detects source text changed after generation",
  () => {
    const result = {
      summary: "Example",
      claims: [
        {
          claim:
            "The participant said they arrived at around 8:30.",
          source_ids: ["seg_001"],
        },
      ],
    };

    const stored =
      buildEvidenceSnapshot(
        result,
        segments
      );

    const editedSegments = [
      {
        ...segments[0],
        text:
          "I arrived closer to 9.",
      },
    ];

    const stale =
      detectStaleEvidence(
        stored,
        editedSegments
      );

    assert.equal(
      stale.stale,
      true
    );

    assert.equal(
      stale.changes[0].code,
      "SOURCE_CHANGED"
    );
  }
);
Enter fullscreen mode Exit fullscreen mode

Run:

npm test
Enter fullscreen mode Exit fullscreen mode

You now have automated checks for two failures that prompts alone should not be responsible for catching:

invented citation
changed source
Enter fullscreen mode Exit fullscreen mode

11. A valid source ID can still support the wrong claim

The pipeline has one boundary left.

Consider this output:

{
  "claim": "The participant left with another person.",
  "source_ids": ["seg_004"]
}
Enter fullscreen mode Exit fullscreen mode

seg_004 exists.

Its hash matches.

But the source says:

No. I left by myself.
Enter fullscreen mode Exit fullscreen mode

The citation is structurally valid and semantically wrong.

Do not hide that distinction.

There are now three separate checks:

1. Does the source exist?
2. Is it still the same source version?
3. Does the source support the claim?
Enter fullscreen mode Exit fullscreen mode

The first two can be deterministic.

The third may need a second model check, domain rules, or human review depending on the consequence of the output.

For sensitive document workflows, I would keep human approval before formal export.

12. Preserve uncertainty instead of polishing it away

Source citations are useful only if the generated language respects what the source actually says.

This:

I think it was around 8:30.
Enter fullscreen mode Exit fullscreen mode

should not quietly become:

The participant left at 8:30 PM.
Enter fullscreen mode Exit fullscreen mode

The generated wording should retain uncertainty:

The participant said they thought they left at around 8:30 PM.
Enter fullscreen mode Exit fullscreen mode

That is why the generation instruction explicitly tells the model to preserve qualifiers.

Watch for source phrases such as:

I think
around
approximately
possibly
I remember
maybe
Enter fullscreen mode Exit fullscreen mode

A polished summary can accidentally make uncertain language sound settled.

13. Keep workspace access outside the model

Source binding does not help if the wrong source material reaches the model in the first place.

Suppose your application serves multiple firms or customers.

Do not retrieve transcript segments from every workspace and ask the model to ignore material the current user should not see.

Apply access scope first:

authenticated user
      ↓
allowed workspace
      ↓
allowed case
      ↓
allowed transcript segments
      ↓
model
Enter fullscreen mode Exit fullscreen mode

The model works with material that application code has already authorized.

That keeps access control deterministic.

14. What the reviewer should see

The strongest interface is usually simple.

GENERATED CLAIM

The participant said they thought they
left at around 8:30 PM.

SOURCE

seg_002
Participant
00:04.3 → 00:09.1

"I think it was around 8:30
in the evening."

STATUS

Source unchanged

[Approve] [Edit] [Needs review]
Enter fullscreen mode Exit fullscreen mode

The reviewer should not need to search the transcript manually just to understand why a sentence exists.

The citation should be part of the workflow.

15. Where this pattern came from

We used the same product principle in an AI Investigation SaaS Platform built by Ascent Innovate Software.

The public workflow takes recorded interviews and case material through transcripts, structured statements, timelines, summaries, follow-up questions, and report outputs inside controlled firm workspaces.

A documented requirement of the product was that AI output stay tied to the original case material before export.

This article does not reproduce that platform's private architecture.

It generalizes one engineering pattern from the public workflow:

source material
      ↓
generated output
      ↓
traceable evidence
      ↓
human review
Enter fullscreen mode Exit fullscreen mode

That pattern also fits compliance workflows, support records, meeting intelligence, document analysis, and other products where generated wording needs to remain inspectable.

Related work

AI Investigation SaaS Platform | Ascent Innovate Software

Technical references

Editorial note

The technical claims, API usage, code examples, and final article were reviewed against the current OpenAI Node SDK documentation and the public Ascent Innovate Software project page before publication.

Top comments (0)