An application that uses a multistep pipeline to remove any watermarks from AI generated text
What it does:
- takes text, strips the typographic markers LLMs usually leave behind (em dashes, zero-width characters, exotic spaces, curly quotes)
- runs it through a translation round trip (e.g. English -> intermediate language -> English), can select different translation provide (Google, Mymemory, Deepl)
- paraphrases the result with an LLM, keeping the meaning, quality and readability intact.
Paraphrasing runs through OpenRouter, OpenAI, Gemini, Claude or a local Ollama model.
Any step is optional, so you can configure watermark removing pipeline as you like.
uv sync
cp .env_example .env # then fill in the API key of the provider you use
cp config_example.yaml config.yaml # optional, the example is used until you douv run streamlit run app.pyPaste the text into Input text field, then press Ctrl + Enter to confirm it, then press Process.
config.yaml- which steps to run, translation provider, intermediate language, paraphrase provider and model. It is gitignored so your settings stay yours;config_example.yamlis the version in the repository and is read as a fallback whileconfig.yamldoes not exist..env- API keys (see.env_example). Only the key of the selected paraphrase provider is needed,ollamaneeds none. Thegoogleandmymemorytranslation providers are free and need no key;deeplneedsTRANSLATOR_API_KEY.src/prompts.py- all prompts.
Every setting in config.yaml can also be overridden per run in the sidebar of the GUI. Those
overrides live only in the browser session, until you press Save settings, which writes them
back to config.yaml (comments included). Secrets are never written there, they stay in .env.
watermark_detector/detect_watermark.ipynb takes one watermarked paragraph, runs it through each
route of the pipeline and scores what comes back. The source text was generated by gemma-2b-it with
a SynthID watermark applied under the public demo key set, so the detector can actually see it.
Gemini's own keys are private, see watermark_detector/README.md.
| metric | watermarked | en-de translated | en-de paraphrased | en-cn translated | en-cn paraphrased | en-ru translated | en-ru paraphrased | only paraphrased |
|---|---|---|---|---|---|---|---|---|
| score | 1.0000 | 0.9998 | 0.0000 | 0.3261 | 0.0042 | 0.1861 | 0.0000 | 0.0000 |
| z_score | +11.43 | +5.68 | +1.02 | +4.10 | +2.86 | +4.19 | +0.51 | -1.18 |
| avg g-value | 0.5599 | 0.5299 | 0.5053 | 0.5215 | 0.5153 | 0.5217 | 0.5026 | 0.4940 |
| is_watermarked | yes | yes | no | no | no | no | no | no |
score is the Bayesian posterior the verdict comes from. z_score says how many standard
deviations the mean g-value sits above the 0.5 that unwatermarked text gives, against a null
measured at mean -0.09 and standard deviation 0.90.
Translation on its own does not remove the watermark. The German round trip comes back at 0.9998 and is still flagged. Chinese and Russian drop under the threshold but keep z-scores above 4, so there the watermark is degraded rather than gone.
Paraphrasing is the step that clears it, and on this sample it does not need the translation to help. Paraphrasing alone gives the cleanest result in the table: z = -1.18 is the only negative score, meaning no residual signal at all rather than a signal pushed under a threshold. Adding a round trip in front of it does not improve on that, and in the Chinese case leaves a trace at z = +2.86.
These are single samples per route, so treat the ordering as indicative rather than measured.
| File | Purpose |
|---|---|
app.py |
Streamlit GUI |
src/pipeline.py |
Orchestrates clean -> translate -> paraphrase -> clean |
src/cleaner.py |
Symbol map, replacement and per-symbol statistics |
src/translator.py |
Round-trip translation with chunking |
src/paraphraser.py |
LangChain chain over the selected provider's model |
src/prompts.py |
Prompts |
src/config.py |
config.yaml + .env loading |
watermark_detector/ |
SynthID watermark detection, optional extra, see its own README |