Every two hours a cron job on TopVideoHub pulls a few thousand fresh trending records from a dozen regional feeds — Japan, Korea, Taiwan, Vietnam, Thailand, Hong Kong and more. Each record is four short strings: a title, a description, a channel name, and a tag list. A surprising fraction of that text is unusable — reupload spam with a wall of unrelated keywords, crypto giveaway scams buried in the description, adult bait dressed up as a kids cartoon, or a title that is 90% emoji. If any of it reaches our search index, someone searching in Japanese or Korean gets scam results, and the Cloudflare cache only makes the bad page stick around longer. This post walks through the moderation pipeline we built with Python and the Claude API to score that metadata before it ever reaches our SQLite FTS5 index. It runs the trending pages on TopVideoHub.
Why keyword blocklists fail for CJK metadata
We started, like everyone, with a regex blocklist. It fell apart fast for Asia-Pacific content:
-
No word boundaries. Japanese and Chinese do not use spaces. A blocklist token like
無料(free) matches inside dozens of legitimate compound words.\bmeans nothing here. -
Script mixing. Scam titles routinely mix full-width Latin, half-width katakana, hanzi, and emoji in one line:
$500 無料 GIVEAWAY🎁. A blocklist tuned for one script misses the others. - Semantic spam. The worst offenders are grammatically fine. The sentence 'Watch the full movie free before it gets taken down' contains no banned word at all.
-
Homoglyphs and width tricks.
Giveawayin full-width characters sails straight past a blocklist built for ASCIIGiveaway.
We needed something that reads the text the way a bilingual person does, in the original language, and returns a decision we can act on programmatically. That is exactly the shape of job an LLM is good at — and Claude handles CJK context well without us shipping per-language rules.
The pipeline shape
The design goal was to keep the LLM off the hot path. Ingest writes raw rows fast; moderation runs as a separate async stage that promotes rows into the searchable index only after they clear.
-
Stage 1 — Ingest. The PHP cron writes raw feed records into a
videos_rawtable withstatus = 'pending'. No LLM here; it has to stay fast. -
Stage 2 — Moderate. A Python worker pulls
pendingbatches, calls the Claude API, and writes back a verdict plus a reason. -
Stage 3 — Promote. Rows marked
approvedget copied intovideosand indexed in the FTS5 virtual table.rejectedandreviewrows stay out of search.
This split matters operationally: if the Claude API is slow or we hit a rate limit, ingest keeps running and the moderation backlog drains later. Search never blocks on moderation, and the risky rows never become public URLs that Cloudflare might cache.
Classifying metadata with the Claude API
The core call uses tool use to force a structured verdict. We do not parse free text — we give Claude a single tool whose input schema is our verdict object, and we read the tool call. Here is the classifier:
import os
from anthropic import Anthropic
client = Anthropic(api_key=os.environ['ANTHROPIC_API_KEY'])
MODERATION_TOOL = {
'name': 'record_verdict',
'description': 'Record a moderation verdict for one video metadata record.',
'input_schema': {
'type': 'object',
'properties': {
'verdict': {
'type': 'string',
'enum': ['approved', 'rejected', 'review'],
'description': 'approved = safe to index, rejected = spam/scam/unsafe, review = needs a human',
},
'categories': {
'type': 'array',
'items': {
'type': 'string',
'enum': ['spam', 'scam', 'adult', 'violence', 'misleading', 'clean'],
},
},
'language': {'type': 'string', 'description': 'BCP-47 code of the dominant language, e.g. ja, ko, zh-Hant'},
'reason': {'type': 'string', 'description': 'One short English sentence explaining the verdict.'},
},
'required': ['verdict', 'categories', 'language', 'reason'],
},
}
SYSTEM = (
'You moderate short video metadata (title, description, channel, tags) '
'for a multi-language Asia-Pacific video index. Judge the text in its '
'original language. Flag reupload spam, giveaway or crypto scams, adult '
'bait, and misleading clickbait. Legitimate trending entertainment is '
'approved. When genuinely unsure, use review.'
)
def render(record):
return '\n'.join([
'title: ' + record['title'],
'channel: ' + record['channel'],
'description: ' + record['description'][:600],
'tags: ' + ', '.join(record.get('tags', [])),
])
def moderate(record):
resp = client.messages.create(
model='claude-haiku-4-5',
max_tokens=300,
system=SYSTEM,
tools=[MODERATION_TOOL],
tool_choice={'type': 'tool', 'name': 'record_verdict'},
messages=[{'role': 'user', 'content': render(record)}],
)
for block in resp.content:
if block.type == 'tool_use' and block.name == 'record_verdict':
return block.input
raise RuntimeError('no verdict returned')
Two decisions worth calling out:
- We pass the original-language text untouched. No translation, no normalization. Translating first throws away exactly the signal — script mixing, homoglyphs, width tricks — that we care about.
- We use
tool_choiceto force the tool, so every response is a valid verdict object. There is no failure mode where the model chats instead of answering.
Structured output you can trust
The tool schema is the contract, and it is small on purpose:
-
verdictis a three-value enum —approved,rejected,review. Three states, not two, because 'I am not sure' is a real and useful answer that should route to a human rather than force a guess. -
categoriesis an enumerated array, so the output maps straight onto columns and dashboards with no free-text label drift. -
reasonis one English sentence regardless of the content language, which keeps the admin review queue readable by our whole team.
Because we force the tool with tool_choice, the model cannot respond with prose, an apology, or a 'here is what I found' preamble. Every response is a verdict object or an exception. In three months of production we have not written a single defensive JSON-repair branch — the malformed-output failure mode simply does not exist when you read the tool input instead of parsing text.
What the prompt actually needs to say
A few lines in the system prompt did more work than any code change:
- 'Judge the text in its original language.' Without it, the model sometimes narrated a translation and moderated the English, missing script-mixing scams entirely.
- 'Legitimate trending entertainment is approved.' Early versions were trigger-happy and rejected loud-but-fine variety and music titles full of emoji. One sentence recalibrated the baseline.
-
'When genuinely unsure, use review.' This is what keeps
rejectedprecise. We would rather send a borderline case to a human than auto-hide a real creator.
We version the system prompt in git next to the code and treat edits to it like any deploy — a wording tweak shifts the approve/reject boundary as surely as a threshold change would.
Wiring it into the PHP ingest path
The ingest and promote stages are ordinary PHP against SQLite. After the Python worker has written verdicts back into videos_raw, this script copies approved rows into the live videos table and the FTS5 index:
<?php
// promote.php — copy approved rows into the searchable FTS5 index.
$db = new PDO('sqlite:/var/www/data/videos.db');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$db->exec('PRAGMA journal_mode = WAL');
// trigram tokenizer gives usable substring matching for CJK (no word spaces).
$db->exec(<<<SQL
CREATE VIRTUAL TABLE IF NOT EXISTS videos_fts USING fts5(
title, description, channel,
content='videos', content_rowid='id', tokenize='trigram'
);
SQL);
$rows = $db->query(
'SELECT id, title, description, channel FROM videos_raw '
. 'WHERE status = ' . $db->quote('approved') . ' AND indexed = 0 LIMIT 500'
);
$insertMain = $db->prepare(
'INSERT OR REPLACE INTO videos (id, title, description, channel) '
. 'VALUES (:id, :title, :description, :channel)'
);
$insertFts = $db->prepare(
'INSERT INTO videos_fts (rowid, title, description, channel) '
. 'VALUES (:id, :title, :description, :channel)'
);
$markDone = $db->prepare('UPDATE videos_raw SET indexed = 1 WHERE id = :id');
$db->beginTransaction();
foreach ($rows as $row) {
$params = [
':id' => $row['id'],
':title' => $row['title'],
':description' => $row['description'],
':channel' => $row['channel'],
];
$insertMain->execute($params);
$insertFts->execute($params);
$markDone->execute([':id' => $row['id']]);
}
$db->commit();
Because we only ever insert approved rows, the FTS5 index never spends storage on scam n-grams, and the whole thing runs behind LiteSpeed with no external database.
Batching and rate limits with a Go worker
At a few thousand records per cycle you will hit an API rate limit if you fan out naively. We front the classifier with a small Go dispatcher that bounds concurrency and paces requests with a token bucket. It reads a pending batch, calls the Python classifier over an internal endpoint, and leaves anything that errors as pending so the next run retries it:
package main
import (
`context`
`log`
`sync`
`time`
`golang.org/x/time/rate`
)
// Record is one metadata row awaiting moderation.
type Record struct {
ID int64
Title string
}
// moderate posts the record to the internal Python classifier and
// returns the verdict string. HTTP body elided for brevity.
func moderate(ctx context.Context, r Record) (string, error) {
return `approved`, nil
}
func fetchPending() []Record { return nil } // SELECT ... WHERE status='pending'
func main() {
// Stay comfortably under the account RPM ceiling: ~8 req/s, burst 5.
limiter := rate.NewLimiter(rate.Every(120*time.Millisecond), 5)
jobs := make(chan Record, 1000)
const workers = 8
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for r := range jobs {
if err := limiter.Wait(context.Background()); err != nil {
log.Printf(`worker %d limiter: %v`, id, err)
continue
}
verdict, err := moderate(context.Background(), r)
if err != nil {
// leave the row pending; the next cron run retries it.
log.Printf(`record %d failed: %v`, r.ID, err)
continue
}
log.Printf(`record %d -> %s`, r.ID, verdict)
}
}(i)
}
for _, r := range fetchPending() {
jobs <- r
}
close(jobs)
wg.Wait()
}
The pattern is deliberately dull: bounded workers, a shared limiter, and errors that fall back to a retry rather than a crash. A single stuck feed never stalls the pool.
The CJK tokenizer gotcha
Two lessons collided here. The moderation model reads raw CJK text, but our search index also has to. SQLite unicode61, the default FTS5 tokenizer, splits on whitespace and punctuation — useless for Japanese, where a whole clause is one token. We switched the FTS5 table to the trigram tokenizer (see the PHP above), which indexes overlapping 3-character sequences and gives usable substring matching for CJK without a language-specific segmenter.
The catch: trigram needs at least three characters to match, so a two-character Korean query returns nothing. We handle short queries with a LIKE fallback on the main table. The moderation stage feeds this cleanly — because we only promote approved rows, the trigram index stays roughly 18% smaller than when we indexed everything and filtered at query time.
- Moderate before indexing, not after. Filtering at query time still pays the storage and cache cost of garbage.
- Store the verdict
reasonandcategories. When a creator disputes a takedown, you have an audit trail. - Never delete rejected rows. Keep them in
videos_rawso the same video ID from the next feed pull is not re-moderated from scratch.
Escalating only what is uncertain
Most records get a final answer from the cheap Haiku pass. The uncertain remainder — anything the model marks review — is worth a second, stronger opinion before it costs a human any attention:
def moderate_with_escalation(record):
verdict = moderate(record) # cheap Haiku pass
if verdict['verdict'] != 'review':
return verdict
# Uncertain: re-run once on a stronger model before queuing a human.
resp = client.messages.create(
model='claude-sonnet-5',
max_tokens=300,
system=SYSTEM,
tools=[MODERATION_TOOL],
tool_choice={'type': 'tool', 'name': 'record_verdict'},
messages=[{'role': 'user', 'content': render(record)}],
)
for block in resp.content:
if block.type == 'tool_use':
return block.input
return verdict
What it costs
Titles and descriptions are short, so each call is around 300 input tokens and well under 100 output tokens. The economics come out of the tiering:
- Bulk pass on Haiku. Roughly 95% of records get a final verdict here — fast and cheap.
-
Escalate only
review. The uncertain remainder re-runs on Sonnet, which is a few dozen calls per cycle, not thousands. - Cache the system prompt. The system text and tool schema are identical on every call, so prompt caching trims the repeated-call cost further.
-
Human queue stays tiny. Anything still
reviewafter the Sonnet pass — a handful a day — lands in an admin list that LiteSpeed serves uncached, while everything public stays cacheable behind Cloudflare.
One rule underpins all of it: if the API is unreachable, the worker leaves rows pending and exits nonzero, and the next cron run retries them. We never fail open. An un-moderated row is never promoted to search. That single principle — 'default to pending, never to approved' — has kept the scam-of-the-week out of the index even during provider incidents.
Conclusion
The pipeline is deliberately boring: fast ingest, async LLM moderation, promote-on-approval. The Claude API earns its place at exactly one step — reading messy multi-script metadata the way a bilingual moderator would and returning a structured verdict we can store in a column. Everything around it is ordinary PHP, SQLite, and a Go worker. If you run any user-facing aggregator in CJK or mixed-script markets, resist the urge to bolt an LLM onto the query path; put it on the ingest path, force structured output with a tool, and let a cheap model do the bulk while you escalate only what is genuinely uncertain. That division of labor is what makes it affordable to moderate every single record instead of sampling.
Top comments (0)