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, Source:https://dev.to/susiewang/build-a-community-digest-that-shows-what-ai-did-and-did-not-summarize-16ja Related posts
