如何建立標示 AI 摘要及未摘要內容的社羣摘要
Build a Community Digest That Shows What AI Did—and Did Not—Summarize
這篇 TypeScript 教程示範如何建立社羣摘要流程,讓 AI 只撰寫摘要,不能自行選取或移除訊息。流程以作者同意及凍結的有序清單決定收錄項目,並要求策展人逐項審核 AI 草稿;來源變更、摘要失敗和傳送結果不確定都有明確處理狀態。教程亦示範如何接駁 Tencent RTC Social Messaging,並要求核實發佈後才推進檢查點。
Community opportunity channels create an uncomfortable trade-off: read every message or trust a summary that may omit the one deadline you needed.
An AI-generated digest can reduce reading time, but it cannot remove that uncertainty by itself. A model may shorten text reasonably well, yet still skip a qualification, alter a date, or give disproportionate attention to one post. The human concern underneath the AI discussion is not simply accuracy. It is loss of control over what gets noticed.
The safer design is to keep selection deterministic and use AI only for presentation:
- People explicitly consent to having a post included.
- The application freezes an ordered manifest of every eligible post.
- AI may draft a summary for each item, but it cannot select or remove items.
- A curator reviews AI-written text alongside the original.
- The checkpoint advances only after publication is verified.
This tutorial builds that control layer in TypeScript, then shows where it connects to a Tencent RTC Social Messaging community.
Define the integrity rule
Our digest will follow one central invariant:
Every consented, eligible message inside the frozen sequence window must have a visible manifest entry, even if summarization fails.
That is stronger than asking an LLM to “summarize everything important.” Importance is subjective, while inclusion can be tested.
We will also separate four decisions that are often accidentally bundled together:
| Decision | Authority |
|---|---|
| Whether a post enters the digest | Author consent plus deterministic channel policy |
| Whether content violates community rules | Existing moderation process |
| How a post is shortened | Optional AI draft |
| Whether the draft is published | Human curator |
AI is useful here as a compression tool. It is not demonstrated to be a reliable authority for deadlines, eligibility, safety, or editorial priority.
Use an application-owned sequence
Timestamps are not sufficient for a reliable cursor. Two messages can share a timestamp, clocks can differ, and late ingestion can put an older timestamp after the checkpoint.
Assign each ingested community message a monotonically increasing application sequence:
101 Message A
102 Message B
103 Message C
^ frozen ceiling
A digest records both its starting checkpoint and its frozen ceiling. New messages receive higher sequence values and belong to the next run.
The minimum source record looks like this:
export interface SourceMessage {
communityId: string;
messageId: string;
sequence: number;
version: number;
authorId: string;
text: string | null;
digestConsent: boolean;
deleted: boolean;
}
digestConsent should come from a visible posting control such as “Include this resource in the scheduled digest.” Do not infer consent from public visibility.
version changes whenever the source content or consent state changes. That lets the reviewer detect edits made after collection.
Create the project
Use a current Node.js release with TypeScript support:
mkdir controlled-community-digest
cd controlled-community-digest
npm init -y
npm install --save-dev typescript tsx @types/node
npm pkg set type=module
npm pkg set scripts.test="tsx --test src/digest.test.ts"
mkdir src
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"outDir": "dist"
},
"include": ["src"]
}
Represent omissions as states, not missing array entries
Create src/digest.ts:
export interface SourceMessage {
communityId: string;
messageId: string;
sequence: number;
version: number;
authorId: string;
text: string | null;
digestConsent: boolean;
deleted: boolean;
}
export type ItemState =
| "ai_draft"
| "verbatim_fallback"
| "unsupported_content"
| "changed_after_collection"
| "withdrawn";
export interface DigestItem {
messageId: string;
sourceSequence: number;
sourceVersion: number;
originalText: string | null;
summary?: string;
state: ItemState;
failureReason?: string;
}
export type DigestState =
| "collecting"
| "awaiting_review"
| "approved"
| "sending"
| "delivery_unknown"
| "checkpoint_pending"
| "published";
export interface Digest {
id: string;
communityId: string;
afterSequence: number;
throughSequence: number;
state: DigestState;
items: DigestItem[];
reviewerId?: string;
receiptId?: string;
lastError?: string;
}
export interface Summarizer {
summarize(input: {
messageId: string;
text: string;
}): Promise<string>;
}
Notice that an unsupported, withdrawn, or failed item still occupies a position in items. If the implementation simply filtered those entries out, a reviewer could not distinguish “intentionally excluded” from “silently lost.”
Freeze the manifest before calling AI
Add the collection function:
export async function buildDigest(input: {
id: string;
communityId: string;
afterSequence: number;
throughSequence: number;
messages: SourceMessage[];
summarizer?: Summarizer;
}): Promise<Digest> {
const eligible = input.messages
.filter((message) =>
message.communityId === input.communityId &&
message.sequence > input.afterSequence &&
message.sequence <= input.throughSequence &&
message.digestConsent
)
.sort((a, b) => a.sequence - b.sequence);
const digest: Digest = {
id: input.id,
communityId: input.communityId,
afterSequence: input.afterSequence,
throughSequence: input.throughSequence,
state: "collecting",
items: eligible.map((message) => ({
messageId: message.messageId,
sourceSequence: message.sequence,
sourceVersion: message.version,
originalText: message.text,
state: message.deleted ? "withdrawn" : "verbatim_fallback"
}))
};
for (const item of digest.items) {
if (item.state === "withdrawn") continue;
if (item.originalText === null) {
item.state = "unsupported_content";
item.failureReason = "This digest only summarizes text content.";
continue;
}
if (!input.summarizer) {
item.state = "verbatim_fallback";
continue;
}
try {
const summary = await input.summarizer.summarize({
messageId: item.messageId,
text: item.originalText
});
if (summary.trim().length === 0) {
throw new Error("The summarizer returned empty text.");
}
item.summary = summary.trim();
item.state = "ai_draft";
} catch (error) {
item.state = "verbatim_fallback";
item.failureReason =
error instanceof Error ? error.message : "Unknown summarization failure";
}
}
digest.state = "awaiting_review";
return digest;
}
The manifest is built before the first model request. A model timeout therefore changes only an item's presentation state; it cannot make the source disappear.
In production, consider bounded concurrency rather than the serial loop shown here. Preserve the frozen manifest and item order regardless of how many model calls run concurrently.
Revalidate immediately before approval
A post may be edited, deleted, or withdrawn while the AI is working. Approval must compare the draft with current source versions.
Add this function:
export function revalidateDigest(
digest: Digest,
currentMessages: Map<string, SourceMessage>
): Digest {
if (digest.state !== "awaiting_review") {
throw new Error(`Cannot revalidate a digest in state ${digest.state}`);
}
for (const item of digest.items) {
const current = currentMessages.get(item.messageId);
if (!current || current.deleted || !current.digestConsent) {
item.state = "withdrawn";
item.summary = undefined;
item.originalText = null;
item.failureReason = "Source was deleted or digest consent was withdrawn.";
continue;
}
if (current.version !== item.sourceVersion) {
item.state = "changed_after_collection";
item.summary = undefined;
item.originalText = current.text;
item.sourceVersion = current.version;
item.failureReason = "Source changed; regenerate or review the current text.";
}
}
return digest;
}
A changed item is not automatically sent back to AI. That choice should be visible to the curator: regenerate it, publish a verbatim excerpt, or defer it to the next digest.
Make human approval item-specific
A single “Approve digest” button can conceal unread AI output. Require the reviewer to acknowledge every AI-authored item:
export function approveDigest(input: {
digest: Digest;
reviewerId: string;
reviewedAiMessageIds: Set<string>;
}): Digest {
const { digest, reviewerId, reviewedAiMessageIds } = input;
if (digest.state !== "awaiting_review") {
throw new Error(`Cannot approve a digest in state ${digest.state}`);
}
const unresolved = digest.items.filter(
(item) => item.state === "changed_after_collection"
);
if (unresolved.length > 0) {
throw new Error("Resolve changed source messages before approval.");
}
const unreviewedAiItems = digest.items.filter(
(item) =>
item.state === "ai_draft" &&
!reviewedAiMessageIds.has(item.messageId)
);
if (unreviewedAiItems.length > 0) {
throw new Error("Every AI draft must be acknowledged by the reviewer.");
}
digest.reviewerId = reviewerId;
digest.state = "approved";
return digest;
}
The review interface should place the original and draft side by side. Highlight dates, URLs, monetary amounts, eligibility language, and proper names for human comparison. Those highlights can be deterministic; they do not need another model.
Treat a send timeout as uncertainty
A network timeout does not prove that publication failed. Blindly retrying can produce duplicate digests.
Model the publisher outcome explicitly:
export type PublishOutcome =
| { kind: "accepted"; receiptId: string }
| { kind: "rejected"; reason: string }
| { kind: "unknown"; reason: string };
export interface DigestPublisher {
publish(input: {
communityId: string;
digestId: string;
body: string;
}): Promise<PublishOutcome>;
}
export interface CheckpointStore {
commit(input: {
digestId: string;
communityId: string;
expectedAfter: number;
through: number;
}): Promise<void>;
}
export function renderDigest(digest: Digest): string {
const lines = digest.items.map((item, index) => {
const heading = `${index + 1}. Source message ${item.messageId}`;
switch (item.state) {
case "ai_draft":
return `${heading}\nAI-assisted summary: ${item.summary}`;
case "verbatim_fallback":
return `${heading}\nOriginal excerpt: ${item.originalText}`;
case "unsupported_content":
return `${heading}\nNot summarized: unsupported content type.`;
case "withdrawn":
return `${heading}\nWithdrawn before publication.`;
case "changed_after_collection":
throw new Error("Changed items cannot be rendered for publication.");
}
});
return [
"Community resource digest",
"AI-assisted text is labeled. Verify details against the source message.",
"",
...lines
].join("\n");
}
export async function publishDigest(input: {
digest: Digest;
publisher: DigestPublisher;
checkpoints: CheckpointStore;
}): Promise<Digest> {
const { digest, publisher, checkpoints } = input;
if (digest.state !== "approved" && digest.state !== "checkpoint_pending") {
throw new Error(`Cannot publish a digest in state ${digest.state}`);
}
if (digest.state === "approved") {
digest.state = "sending";
const outcome = await publisher.publish({
communityId: digest.communityId,
digestId: digest.id,
body: renderDigest(digest)
});
if (outcome.kind === "unknown") {
digest.state = "delivery_unknown";
digest.lastError = outcome.reason;
return digest;
}
if (outcome.kind === "rejected") {
digest.state = "approved";
digest.lastError = outcome.reason;
return digest;
}
digest.receiptId = outcome.receiptId;
digest.state = "checkpoint_pending";
}
try {
await checkpoints.commit({
digestId: digest.id,
communityId: digest.communityId,
expectedAfter: digest.afterSequence,
through: digest.throughSequence
});
digest.state = "published";
digest.lastError = undefined;
} catch (error) {
digest.state = "checkpoint_pending";
digest.lastError =
error instanceof Error ? error.message : "Checkpoint commit failed";
}
return digest;
}
There are two important recovery paths:
-
delivery_unknown: inspect the community timeline or reconcile through an application-owned publication record before retrying. -
checkpoint_pending: publication was accepted, so recovery retries only the conditional checkpoint commit—not the message publication.
The production checkpoint operation should be conditional on expectedAfter and idempotent by digestId. That prevents two scheduler instances from advancing the same community cursor independently.
Reproduce the dangerous cases
Create src/digest.test.ts:
import test from "node:test";
import assert from "node:assert/strict";
import {
approveDigest,
buildDigest,
publishDigest,
revalidateDigest,
type SourceMessage
} from "./digest.js";
const source = (overrides: Partial<SourceMessage> = {}): SourceMessage => ({
communityId: "community-1",
messageId: "message-1",
sequence: 11,
version: 1,
authorId: "author-1",
text: "Applications close Friday. Check the source for eligibility.",
digestConsent: true,
deleted: false,
...overrides
});
test("a failed AI call preserves the source as a fallback", async () => {
const digest = await buildDigest({
id: "digest-1",
communityId: "community-1",
afterSequence: 10,
throughSequence: 11,
messages: [source()],
summarizer: {
summarize: async () => {
throw new Error("model timeout");
}
}
});
assert.equal(digest.items.length, 1);
assert.equal(digest.items[0]?.state, "verbatim_fallback");
assert.equal(digest.items[0]?.failureReason, "model timeout");
});
test("an edit after collection blocks approval", async () => {
const digest = await buildDigest({
id: "digest-2",
communityId: "community-1",
afterSequence: 10,
throughSequence: 11,
messages: [source()],
summarizer: {
summarize: async () => "Applications close Friday."
}
});
revalidateDigest(
digest,
new Map([
["message-1", source({ version: 2, text: "Deadline extended to Monday." })]
])
);
assert.equal(digest.items[0]?.state, "changed_after_collection");
assert.throws(() =>
approveDigest({
digest,
reviewerId: "curator-1",
reviewedAiMessageIds: new Set()
})
);
});
test("unknown delivery does not advance the checkpoint", async () => {
const digest = await buildDigest({
id: "digest-3",
communityId: "community-1",
afterSequence: 10,
throughSequence: 11,
messages: [source()]
});
approveDigest({
digest,
reviewerId: "curator-1",
reviewedAiMessageIds: new Set()
});
let checkpointCommits = 0;
await publishDigest({
digest,
publisher: {
publish: async () => ({
kind: "unknown",
reason: "request timed out after submission"
})
},
checkpoints: {
commit: async () => {
checkpointCommits += 1;
}
}
});
assert.equal(digest.state, "delivery_unknown");
assert.equal(checkpointCommits, 0);
});
Run the suite:
npm test
These tests verify the control boundary, not an LLM's prose quality. Summary quality needs an evaluation set containing the kinds of community posts you actually publish, especially posts with dates, restrictions, corrections, and multiple links.
Connect the workflow to Tencent RTC Social Messaging
Tencent RTC's Social Messaging solution covers group discussions and community experiences, which makes the community conversation the appropriate user-facing surface for this workflow:
https://trtc.io/solutions/social-messaging
Keep the integration behind adapters rather than inventing product methods:
interface CommunityMessagingAdapter {
ingestIntoApplicationLedger(): Promise<void>;
publishDigestMessage(input: {
communityId: string;
digestId: string;
body: string;
}): Promise<PublishOutcome>;
}
Implement those methods using the documented SDK and version selected by your application. The application-owned ledger remains responsible for sequences, source versions, consent, review state, and checkpoints.
A practical deployment flow is:
Tencent RTC community conversation
│
▼
Application ingestion ledger ── assigns sequence and version
│
▼
Frozen consented manifest
│
optional AI summaries
│
▼
Human review interface
│
▼
Community digest publication
│
▼
Conditional checkpoint commit
Do not send private, deleted, or consent-withdrawn content to a summarizer. Apply your privacy and moderation requirements before model invocation, and retain only the data required by your policy.
Translation remains reader-controlled
A multilingual community may also offer on-demand translation. Tencent RTC documents TUIChat message translation here:
https://trtc.io/document/60772
The documentation includes supported content types, languages, and edition limitations, so verify those conditions for your deployment rather than assuming every digest can be translated.
Translation should remain a display operation:
- Preserve the published digest in its original language.
- Label translated text.
- Keep source message references visible.
- Never feed translated text back into consent, moderation, checkpoint, or approval decisions.
If a translated deadline conflicts with the original, the original source remains authoritative and should be escalated to a person.
Decide the user-visible response to each failure
| Failure | Internal state | What the user or curator sees |
|---|---|---|
| AI request times out | verbatim_fallback |
Original excerpt plus a summarization-failed label |
| AI returns empty text | verbatim_fallback |
Same fallback; item is not removed |
| Non-text post is included | unsupported_content |
Visible source entry without an invented description |
| Author edits the post | changed_after_collection |
Approval blocked until the new version is handled |
| Author withdraws consent | withdrawn |
Content removed; withdrawal remains visible in the manifest |
| Publication is rejected | approved |
Correct the known error and retry |
| Publication result is uncertain | delivery_unknown |
Reconcile before retrying |
| Publication succeeds but cursor storage fails | checkpoint_pending |
Retry only the checkpoint commit |
| Curator is unavailable | awaiting_review |
Digest waits; AI cannot self-approve |
This table is also a useful product requirement. If a state has no understandable interface treatment, it is probably not ready for production.
Pre-release verification
Run these checks with a staging community:
- [ ] Two scheduler instances cannot claim the same checkpoint window.
- [ ] Every consented message in the frozen window creates one manifest item.
- [ ] AI failure cannot reduce the manifest item count.
- [ ] A source edit invalidates its old summary.
- [ ] Consent withdrawal removes source content before publication.
- [ ] A curator must acknowledge every AI-authored item.
- [ ] Dates, URLs, amounts, and eligibility wording are compared with the original.
- [ ] Unsupported content is labeled rather than guessed.
- [ ] An uncertain send does not advance the checkpoint.
- [ ] A checkpoint failure does not republish an accepted digest.
- [ ] Empty windows advance through an audited, conditional operation so they are not scanned forever.
- [ ] Translation support is checked against the documented content, language, and edition constraints.
- [ ] The interface clearly labels AI-assisted summaries and provides access to source messages.
- [ ] Retention and model-data policies match the community's consent language.
The skill that remains valuable
The durable engineering skill here is not writing a more persuasive summarization prompt. It is deciding which parts of a community workflow may be probabilistic.
AI can draft shorter text. It should not quietly decide who gets visibility, whether consent still applies, or whether a delivery succeeded. Once those boundaries are represented as data and states, people can inspect the process instead of merely trusting its output.
For your own community, the useful design question is: Which omission would be most costly, and can your manifest prove that it did not happen?
Disclosure: This article was created in collaboration with Tencent RTC. Official Tencent RTC documentation was used as the implementation reference.
來源:DEV Community · dev.to