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"]
}
but your application still needs to know:
- Does
seg_184exist? - Did that segment belong to this user's workspace?
- Does the cited text still match the version used during generation?
- Does the cited text actually support the claim?
- 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.
A plain summary might return:
The participant left the office at 8:30 PM and left alone.
It sounds fine.
But two pieces of source meaning have already become harder to see.
The speaker said:
I think
and:
around 8:30
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"]
}
]
}
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
Update package.json:
{
"name": "transcript-citations",
"type": "module",
"scripts": {
"start": "node src/index.js",
"test": "node --test"
}
}
Create this structure:
transcript-citations/
├── src/
│ ├── transcript.js
│ ├── schema.js
│ ├── summarize.js
│ ├── evidence.js
│ └── index.js
└── test/
└── evidence.test.js
Set your environment variables:
export OPENAI_API_KEY="your_api_key_here"
export OPENAI_MODEL="your_supported_model_here"
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.",
},
];
Do not use an array position as the citation:
transcript[3]
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
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),
});
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;
}
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,
};
}
Suppose the model returns:
{
"claim": "The participant left alone.",
"source_ids": ["seg_999"]
}
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),
};
}
),
})),
};
}
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": "..."
}
]
}
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.
Later:
[seg_002]
I think it was closer to 9 in the evening.
The source ID is still:
seg_002
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,
};
}
Now the application can distinguish:
citation exists
from:
citation exists and still points to the same source text
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;
});
Run:
npm start
The path is now:
transcript
↓
structured AI claims
↓
validate source IDs
↓
snapshot evidence
↓
detect later source changes
↓
review
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"
);
}
);
Run:
npm test
You now have automated checks for two failures that prompts alone should not be responsible for catching:
invented citation
changed source
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"]
}
seg_004 exists.
Its hash matches.
But the source says:
No. I left by myself.
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?
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.
should not quietly become:
The participant left at 8:30 PM.
The generated wording should retain uncertainty:
The participant said they thought they left at around 8:30 PM.
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
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
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]
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
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)