Human review is the product, not a safety net
How to design editor review for an AI news pipeline: a state model, a fast review screen, roles, real-time updates and feedback that improves prompts.
6 min readai · llm · rag · human in the loop · product design
When people ask about the AI news pipeline I built at Infokanal, they usually want to hear about retrieval and prompts. I'd rather talk about the review screen, because that's where the system either earns trust or loses it.
The pipeline replaced a manual news-monitoring process. It continuously collected content from X, police and government press channels, and major national news outlets, then used an LLM layer with RAG and memory management to summarise, translate and categorise Norwegian news. One rule shaped everything else: editors review, edit, approve or reject every AI output before it is published or reused internally.
Every output. Not a sample, not just the low-confidence ones. That single requirement turns the review tool from an admin page into the main product.
Why every output goes through a person
News has a low tolerance for confident mistakes. A summary that drops a qualifier from a police statement, or a translation that turns "suspected" into something stronger, is worse than no summary at all. A model's own confidence doesn't map cleanly to factual accuracy, so it can't be the filter.
Reviewing everything has a cost: an editor is now on the critical path for every item. If review is slow, the pipeline is slow, however fast ingestion is. The design question stops being "how do we catch bad outputs?" and becomes "how do we make a correct decision take seconds?"
Model the review, not just the output
Start with the data model. A review item needs more than the generated text:
- the source items it was built from, with links back to the originals
- the retrieved context the model actually saw
- the AI draft, which is never changed after creation
- the editor's final text, if they changed it
- the decision, who made it, and when
- a rejection reason, when there is one
Keeping the draft immutable and storing the final text separately means every edit is a recorded correction. That becomes the most useful dataset you have, which I'll come back to.
The status should be an explicit state machine, enforced on the server:
from datetime import datetime, timezone
from enum import Enum
from pydantic import BaseModel
class Status(str, Enum):
PENDING = "pending"
IN_REVIEW = "in_review"
APPROVED = "approved"
REJECTED = "rejected"
ALLOWED = {
Status.PENDING: {Status.IN_REVIEW},
Status.IN_REVIEW: {Status.APPROVED, Status.REJECTED, Status.PENDING},
Status.APPROVED: set(),
Status.REJECTED: set(),
}
class ReviewItem(BaseModel):
id: str
source_ids: list[str]
ai_draft: str
final_text: str | None = None
status: Status = Status.PENDING
reviewer_id: str | None = None
reject_reason: str | None = None
updated_at: datetime
def move(item: ReviewItem, to: Status, reviewer_id: str) -> ReviewItem:
if to not in ALLOWED[item.status]:
raise ValueError(f"cannot move {item.status.value} to {to.value}")
if item.status is Status.IN_REVIEW and item.reviewer_id != reviewer_id:
raise PermissionError("item is claimed by another editor")
return item.model_copy(update={
"status": to,
"reviewer_id": None if to is Status.PENDING else reviewer_id,
"updated_at": datetime.now(timezone.utc),
})"Edited" isn't a separate status. An approval where final_text differs from ai_draft is an edit, and the size of that difference is a quality signal you can track per category and per source.
Make the fast path fast
Put the evidence next to the draft
The editor has to verify, not just read. Show the source and the draft side by side, with the original Norwegian next to any translation, and show which retrieved items the model used. If checking a claim means opening three tabs, editors will either slow down or stop checking.
Make the common decision one action
Approving should be a single keystroke. The category should arrive pre-filled by the model and be changeable from a dropdown. Rejecting should take a reason from a short list plus optional free text, because "rejected" on its own teaches you nothing.
Keep the diff
When an editor changes a draft, keep a diff view available to anyone who opens the item later. It makes it possible to review the reviewing, and it makes the feedback loop concrete.
Roles and real time
The platform was multi-user with role-based access, and two problems come with that.
The first is permission. Who can approve for publishing, who can only edit, and who can see raw sources are product decisions. They belong in the role model and get checked on the server for every transition, never inferred from what the UI happened to render.
The second is concurrency. Two editors opening the same item and both acting on it is a natural failure mode of any shared queue. Claiming an item, the in_review state with a reviewer and an expiring lease, prevents it, but only if the rest of the team sees the claim immediately.
That's where real time matters. The dashboard, built with React, Vite, Zustand and Shadcn UI, received new content and review status changes over Socket.io. The pattern I like for this: socket events are notifications, REST calls are writes. The client never decides state on its own; it updates its store from what the server broadcasts.
import { io } from 'socket.io-client';
import { create } from 'zustand';
type Status = 'pending' | 'in_review' | 'approved' | 'rejected';
type QueueItem = { id: string; title: string; status: Status; reviewerId?: string };
type QueueState = {
items: Record<string, QueueItem>;
upsert: (item: QueueItem) => void;
replaceAll: (items: QueueItem[]) => void;
};
export const useQueue = create<QueueState>((set) => ({
items: {},
upsert: (item) => set((s) => ({ items: { ...s.items, [item.id]: item } })),
replaceAll: (items) => set({ items: Object.fromEntries(items.map((i) => [i.id, i])) }),
}));
const socket = io('/review', { withCredentials: true });
socket.on('item:created', (item: QueueItem) => useQueue.getState().upsert(item));
socket.on('item:updated', (item: QueueItem) => useQueue.getState().upsert(item));
// Events emitted while we were offline are gone, so resync on every (re)connect.
socket.on('connect', async () => {
const res = await fetch('/api/review/queue', { credentials: 'include' });
useQueue.getState().replaceAll(await res.json());
});Feedback is data you already have
Over 15 months I maintained and evolved the crawlers, adapting to source changes and introducing new AI strategies based on editor feedback. That loop only works if the review tool records feedback in a usable shape.
With immutable drafts, final texts and structured rejection reasons, you can ask useful questions. Which categories get edited most? Which sources produce summaries that get rejected? Are translation fixes about terminology or about meaning? Each answer points at a specific change: a prompt, a retrieval filter, a category definition, or a source that needs a different parser.
It also keeps changes honest. When a prompt changes, the edit and rejection rates on the reviews that follow tell you whether it helped, measured by the people whose judgement actually matters.
If a person has to approve every AI output, design the review tool first and the pipeline second. Put the evidence beside the draft, make the common decision one action, enforce states and roles on the server, push changes to every editor in real time, and store every edit as data. The model produces drafts; the review workflow is what makes them publishable.