Trust and Safety
GitHub

Score text for defamation and hate, and mask the offensive words.

tnscope reads a sentence in English, Korean, Japanese or Chinese and reports a rank from 1 to 5 and a score from 0 to 100 for each hit. It masks the offending words without changing the length of the text. It only reports; your code decides whether to block.

Install View the source

Figure 1. Recorded output of tnscope with the English add-on installed. Scores are provisional and uncalibrated. The third column uses the optional veto fusion, which is not the default. Only English is shown here; the other languages work the same way.

Three stages, and only the first is required

Stage 1 is the core and needs no model. It normalizes the text, so spaced-out or leetspeak spellings still match, looks words up in a lexicon, splits the text into sentences, scores each sentence and masks the matches. Every span points into the original text, so masking never shifts a character.

Stage 2 is a small add-on for each language. It reads the words around a hit for cues a word list cannot hold: is the word quoted, denied, reported in the news or part of a film? Today these are rule-based baselines, not trained models.

Stage 3 is an optional LLM judge. It is off unless the caller asks for it, and it uses a model client the caller supplies. The package itself contains no model and no network code.

The tnscope pipeline Text enters stage 1, the lexicon. Definite hits go straight to the result. Undecided hits go to the language add-on, and still-unsure ones to the optional judge. A dashed path sends hits to the judge when no add-on exists for the language. All scores are fused into a score, an action and masked text. Text+ language 1 Lexiconscore + mask Routerwho is asked? 2 Add-onper language 3 Judgeoptional, LLM Fuse the scores, apply the floor and thresholds: score, action, masked text hit hit undecided still unsure definite: done no add-on for this language
Figure 2. Read it left to right. Stage 1 always runs. If its score is high and clear (85 or more, not ambiguous), the hit is done and goes straight to the result below. Otherwise the router asks the language add-on. If the add-on is still unsure and you opted in, the judge is asked too. Whatever answered, the scores are fused and turned into an action.

When the add-on is asked, and how the scores combine

Routing. A hit with a stage 1 score of 85 or more that is not ambiguous is called definite and stays at stage 1. Everything else goes to the language add-on. An add-on score that is still uncertain (between 20 and 80) can go on to the judge, if you opted in.

Fusion. The default, max, keeps the highest score, so a later stage can raise a score but never lower it. weighted averages them. veto lets the add-on lower a score when it sees a quote, a denial or a news report.

The floor. A rank 5 term never drops below 60, whatever a later stage says. A quoted slur can fall from block to review, but not to pass.

Choose the three inputs below to see what the router's own fusion code returns.

Stage 1 found
Stage 2 says
Fusion method

Final score Action

Figure 3. Each combination is computed by the router's own fusion function. The stage 1 and stage 2 inputs are presets, not model output. Thresholds: flag from 35, review from 60, block from 85.

Five languages, one add-on each

Each language has its own add-on, lexicon and gold set. What makes a word abusive or merely mentioned depends on the language, so a good English result must not hide a weak Korean one.

What is specific to each language
LanguageWhat the add-on has to handle
enNegation comes before the verb or the noun ("don't call him", "is not a"). A single quote is also an apostrophe, so only double quotes count as quote marks.
koQuotation and denial sit in endings and particles that attach to the word. Subjects are often dropped.
jaThere are no spaces, so cues are tied to the position right after the word or to one clause. A word can sit inside a longer compound.
zh-hansNegation comes before the noun. Simplified characters differ from Traditional ones, so this is a separate add-on.
zh-hantWritten separately from Simplified Chinese, with its own characters, quote marks and common words. The two share only the scoring policy.

How well it works

The chart shows F1 at the flag level (a score of at least 35) on two draft gold sets per language: a dev set the rules were tuned on, and a held-out set they were not. Both were written by an AI assistant and have had no native-speaker review. Read the numbers as a development signal, not as measured quality.

The lexicons are small, and most hate entries are long phrases that stage 1 already blocks. The gain comes from low-rank defamation terms such as "fraudster". On text from the real world these numbers would say much less.

View as a table
Add-onSetSamplesStage 1 (P / R)Stage 2, max (P / R)Stage 2, veto (P / R)
English (en)Held-out set840.86 / 0.670.89 / 0.890.93 / 0.89
English (en)Dev set1200.76 / 0.630.81 / 0.870.84 / 0.87
Korean (ko)Held-out set840.87 / 0.700.86 / 0.880.88 / 0.86
Korean (ko)Dev set1360.74 / 0.610.79 / 0.800.84 / 0.79
Japanese (ja)Held-out set840.80 / 0.700.82 / 0.890.88 / 0.88
Japanese (ja)Dev set1200.69 / 0.660.72 / 0.840.80 / 0.82
Simplified Chinese (zh-hans)Held-out set840.89 / 0.700.91 / 0.910.93 / 0.91
Simplified Chinese (zh-hans)Dev set1200.78 / 0.680.79 / 0.870.86 / 0.87
Traditional Chinese (zh-hant)Held-out set840.89 / 0.700.91 / 0.910.93 / 0.91
Traditional Chinese (zh-hant)Dev set1200.78 / 0.680.79 / 0.870.86 / 0.87
Figure 4. F1 at the flag level, computed by the evaluation scripts in the repository. The scale starts at zero. Precision (P) and recall (R) are in the table.

Fast enough for comments, with a streaming mode

Stage 1 plus a language add-on takes about 0.2 ms for a comment-sized text, so one process handles thousands of messages per second. A few thousand characters take about ten milliseconds, and the cost grows in step with the length. Build the engine once per process (about 0.3 s) and reuse it.

These numbers come from short sentences of the gold sets on a 2-core development machine, most of them without any hit, and they vary from run to run. Text with many hits costs more. The judge at stage 3 is another matter: it is one model call for each uncertain sentence, so keep it off the synchronous path.

Text that arrives in pieces can be scanned with a stream scanner. It holds back only the sentence still being written, and the masked pieces it hands back add up to exactly what one scan of the whole text would give. Each sentence is scanned once, so the add-on and the judge are not called again for every chunk.

Measured speed
CaseTime
One comment-sized text, stage 1 and add-on≈ 0.2 ms
About 3500 characters≈ 10 ms
About 35000 characters≈ 120 ms
Streaming in 256-character chunks≈ 1.3 ×
sc = engine.stream("en")
for chunk in chunks:
    update = sc.feed(chunk)     # sentences that just became final
    send(update.text)           # masked text, ready to forward
send(sc.finish().text)          # the stream ended

Install and run

Nothing is on PyPI yet. Clone the repository with its submodules and install the core and the add-ons you need.

git clone --recurse-submodules https://github.com/zafrem/tnscope-forge
cd tnscope-forge
pip install -e tnscope -e tnscope-en -e tnscope-ko -e tnscope-ja \
            -e tnscope-zh-hans -e tnscope-zh-hant

Add-ons are found on their own; there is nothing to import. From Python, or from the command line:

import tnscope

r = tnscope.scan("Don't trust Mike, he's a fraudster.", "en")
d = r.sentences[0].detections[0]
print(d.score, d.action, d.stage_reached)   # 60.0 review 2
print(r.masked_text)

# tnscope scan "Go back, wetback." --lang en --json
# add --max-stage 1 to turn add-ons off, --verify-definite to re-check strong hits

Stage 3 needs a model client that you provide, and runs only when you ask for it. Without one, tnscope keeps the stage 2 score and adds a warning.

import tnscope, tnscope_judge

class MyClient:
    model_id = "my-model"
    def complete(self, prompt: str, schema: dict) -> str: ...

tnscope_judge.configure(MyClient())
r = tnscope.scan("...", "en", max_stage=3)

One core, one add-on per language, one optional judge

The core holds stage 1, the router and the command line. The language add-ons and the judge are separate packages that depend on the core, never the other way round. Each is versioned, calibrated and rolled back on its own. The data (lexicons and rules) lives in its own repository and wins when documents disagree.

Core, language add-ons and judge The tnscope core contains stage 1, the router and the command line. Five language add-ons and an optional judge plug into it. The data repository feeds the core with word lists, rules and schemas. It holds data and contracts only, no code. tnscope (core) Stage 1: lexicon + mask Router + fusion Plugin loader + CLI enEnglish koKorean jaJapanese zh-hansSimplified zh-hantTraditional judgestage 3, LLM tns-context-engine: data and contracts, no code Add-ons register under the entry-point groups tnscope.stage2 and tnscope.stage3
Figure 5. The core and its plugins. The core never imports an add-on by name.

What is not finished

  • Everything here is a draft. Scores, ranks and word lists are provisional and wait for native-speaker and policy review.
  • The gold sets were written by an AI assistant. The results are a development signal, not measured quality.
  • The add-ons are rule-based baselines. A quoted or negated strong slur keeps a floor of 60, so it lands in review, not in pass.
  • The judge has only been tested with fake model clients, never with a real model.
  • Recall is capped by the word lists. A sentence with no listed term is never passed to an add-on or to the judge, so abuse in words the lexicon lacks goes unseen at every stage.
  • A strong hit (score 85 or more) is not re-checked by default, so a quoted or reported slur is blocked. Turning on verify together with veto lowers it to review.
  • Nothing is released. It is not on PyPI, and no license has been chosen yet.