Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .github/workflows/retrain.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: Retrain detector weights

# Refits the detector's metric weights from training/corpus.jsonl and opens a
# pull request when the fit improves — the model never updates itself unattended.
# See training/README.md.

on:
workflow_dispatch: {}
push:
branches: [main, master]
paths:
- 'training/corpus.jsonl'
schedule:
# Weekly, Monday 06:00 UTC — picks up any corpus growth merged during the week.
- cron: '0 6 * * 1'

# The job needs to push a branch and open a PR.
permissions:
contents: write
pull-requests: write

concurrency:
group: retrain-weights
cancel-in-progress: true

jobs:
retrain:
name: Fit + gate + propose
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Install package
run: |
python -m pip install --upgrade pip
pip install -e .

- name: Fit weights (writes only if the held-out gate passes)
id: fit
run: |
python training/train_weights.py --write | tee /tmp/train_report.txt
# Stamp the training date (Date is unavailable inside the trainer).
python - <<'PY'
import json, datetime, pathlib
p = pathlib.Path("texthumanize/detector_weights.json")
d = json.loads(p.read_text())
if d.get("fitted"):
d["trained_at"] = datetime.date.today().isoformat()
p.write_text(json.dumps(d, indent=2, ensure_ascii=False) + "\n")
PY

- name: Verify the fitted weights still pass the suite
run: |
pip install -e ".[dev]" || pip install pytest
pytest tests/ -k "detect or golden or snapshot" -q --timeout=120

# Open a PR with the gh CLI (pre-installed on the runner) rather than a
# third-party action — nothing to pin, no supply-chain surface.
- name: Open a pull request if the weights changed
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if git diff --quiet -- texthumanize/detector_weights.json; then
echo "weights unchanged — nothing to propose."
exit 0
fi
branch="auto/retrain-weights"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$branch"
git add texthumanize/detector_weights.json
git commit -m "chore(detector): refit weights from corpus"
git push --force-with-lease origin "$branch"
if gh pr view "$branch" --json state -q .state 2>/dev/null | grep -q OPEN; then
echo "PR already open; branch updated."
else
gh pr create --base "${GITHUB_REF_NAME}" --head "$branch" \
--title "Retrain: updated detector weights" \
--body-file /tmp/train_report.txt \
--label automated --label detector
fi
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,38 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

## [0.35.0] - 2026-07-21

### Detection quality (evidence-driven; see the extension's research/ catalogs)
- **New `structure` metric** — scores the enumeration/list-intro/participial-tail/
negative-parallelism scaffolding of instruction-tuned writing. This is the tell
that survives paraphrase and model-generation changes, and the one the old
metric set missed entirely (assistant-register text scored ~40%).
- **Fixed two inverted signals.** `voice` no longer scores passive as AI: in
English GPT-4o uses agentless passive at ~half the human rate, and in RU/UK/DE
passive+nominal is ordinary bureaucratic register — counting it inverted the
metric on edited human prose. It now leans on nominalization (the robust ×1.5–2
tell). `punctuation` no longer scores semicolons/colons/em-dashes/« » as AI
(marks of careful human editing; the metric was firing on classic prose).
- **Anti-evasion normalization** — homoglyph and zero-width insertion (which
collapse token-frequency detectors by 42–76 pp in the RAID benchmark) are now
stripped/folded before analysis. Whole-word Cyrillic/Greek text is preserved.
- **Metric weights rebalanced** against measured per-metric separation; weight
moved off metrics that neither separate nor survive paraphrase.
- **AI-cliché dictionary expanded** with the 2025–2026 assistant register
("Great question", "Let's break down", "Here's the deal", "I hope this helps",
and RU equivalents), feeding both detection and humanization.

### Media provenance
- **Fixed a false-positive class**: a human image carrying a `Description`/
`Title`/`Comment`/`Software` text chunk was reported as AI. Generic keywords
now require an actual generation-parameter value; only tool-unique chunk keys
(`parameters`, `workflow`, `sd-metadata`, `invokeai_metadata`,
`sui_image_params`, `dream`) are conclusive on the key name alone.
- **Wider coverage**: ComfyUI `class_type`/`sampler_name`, SwarmUI, and 2026
generator signatures (Recraft, Seedream/Seedance, Hunyuan, Reve, IPTC
`trainedAlgorithmicMedia`).

## [0.34.0] - 2026-07-16

### Security
Expand Down
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ readability, and internal risk signals; it is not a bypass guarantee.
[![Tests](https://img.shields.io/badge/tests-2269%20passed-2ea44f.svg?logo=pytest&logoColor=white)](https://github.com/ksanyok/TextHumanize/actions/workflows/ci.yml)
&nbsp;&nbsp;
[![Zero Dependencies](https://img.shields.io/badge/dependencies-zero-brightgreen.svg)]()
[![PyPI](https://img.shields.io/badge/pypi-v0.34.0-3775A9.svg?logo=pypi&logoColor=white)](https://pypi.org/project/texthumanize/)
[![PyPI](https://img.shields.io/badge/pypi-v0.35.0-3775A9.svg?logo=pypi&logoColor=white)](https://pypi.org/project/texthumanize/)
[![License](https://img.shields.io/badge/license-Dual%20(Free%20%2B%20Commercial)-blue.svg)](LICENSE)

<br/>
Expand Down Expand Up @@ -195,7 +195,7 @@ git clone https://github.com/ksanyok/TextHumanize.git
cd TextHumanize && pip install -e .
```

> **Tip:** Pin your version for production: `pip install texthumanize==0.34.0`
> **Tip:** Pin your version for production: `pip install texthumanize==0.35.0`

<details>
<summary><b>PHP / TypeScript</b></summary>
Expand Down Expand Up @@ -1475,7 +1475,7 @@ reporting rules, and detector limitations.

```
┌──────────────────────────────────────────────────────────┐
│ TextHumanize v0.34.0 — AI Score Benchmark │
│ TextHumanize v0.35.0 — AI Score Benchmark │
├──────────────────────────────────────────────────────────┤
│ EN (web/50): 94% → 27% (reduction: -67pp) │
│ EN (web/60): 94% → 23% (reduction: -71pp) │
Expand Down Expand Up @@ -1887,7 +1887,18 @@ Try the [Live Demo](https://texthumanize.link/). For local use, the REST API + S

---

## 🆕 What's New in v0.34.0
## 🆕 What's New in v0.35.0

### Detection quality — evidence-driven overhaul (0.35.0)
- **New `structure` metric** — scores the enumeration / list-intro / participial-tail / negative-parallelism *scaffolding* of instruction-tuned writing. This is the tell that survives paraphrasing and model-generation changes, and the one the old metric set missed entirely (chat "assistant register" text used to score ~40% human).
- **Two inverted signals fixed.** `voice` no longer scores passive as AI (English GPT-4o uses agentless passive at ~half the human rate; in RU/UK/DE passive+nominal is ordinary bureaucratic register) — it now leans on nominalization, the robust ×1.5–2 tell. `punctuation` no longer treats semicolons / colons / em-dashes / « » as AI — those are marks of careful human editing, and the metric had been firing on well-edited prose.
- **Anti-evasion normalization** — homoglyph and zero-width insertion (which collapse token-frequency detectors by 42–76 pp in the RAID benchmark) are stripped/folded before analysis; whole-word Cyrillic/Greek text is preserved.
- **Weights rebalanced** against measured per-metric separation, and the AI-cliché dictionary gained the 2025–2026 assistant register — feeding both detection and humanization.
- **Self-improving weights** — a new offline training pipeline (`training/`) fits the metric weights from a labelled corpus and is gated by a benchmark so a release never ships a regression. See [`training/README.md`](training/README.md).

### Media provenance (0.35.0)
- **Fixed a false-positive class** — a human photo carrying a `Description` / `Title` / `Comment` / `Software` metadata chunk was reported as AI. Generic keys now require an actual generation-parameter value; only tool-unique chunk keys (`parameters`, `workflow`, `sd-metadata`, `invokeai_metadata`, `sui_image_params`, `dream`) are conclusive on the key name alone.
- **Wider coverage** — ComfyUI `class_type` / `sampler_name`, SwarmUI, and 2026 generator signatures (Recraft, Seedream/Seedance, Hunyuan, Reve, IPTC `trainedAlgorithmicMedia`).

### Security & REST API hardening (0.34.0)
- **Fixed an unauthenticated reflected SSRF (CWE-918)** in the REST API. `POST /humanize` forwarded a client-supplied `oss_api_url` straight into an outbound request, so an anonymous caller could make the server fetch internal/loopback/cloud-metadata URLs and read the response. Reported responsibly by Natnael Wodsnoen.
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ksanyok/text-humanize",
"version": "0.34.0",
"version": "0.35.0",
"description": "Zero-dependency PHP library for algorithmic text humanization — transforms machine-generated text into natural prose",
"type": "library",
"keywords": [
Expand Down
4 changes: 2 additions & 2 deletions js/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion js/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "texthumanize",
"version": "0.34.0",
"version": "0.35.0",
"description": "Algorithmic text humanization — transforms AI-generated text into natural-sounding content",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion js/src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const VERSION = '0.34.0';
export const VERSION = '0.35.0';
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "texthumanize",
"version": "0.34.0",
"version": "0.35.0",
"private": true,
"description": "Text style normalization & readability engine — Python, TypeScript, PHP",
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion php/composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ksanyok/text-humanize",
"version": "0.34.0",
"version": "0.35.0",
"description": "Zero-dependency PHP library for algorithmic text humanization — transforms machine-generated text into natural prose",
"type": "library",
"license": "proprietary",
Expand Down
2 changes: 1 addition & 1 deletion php/src/TextHumanize.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
*/
class TextHumanize
{
public const VERSION = '0.34.0';
public const VERSION = '0.35.0';

/**
* Humanize text — the primary API method.
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "texthumanize"
version = "0.34.0"
version = "0.35.0"
description = "Algorithmic text humanization with AI detection, tone analysis, paraphrasing, and spinning — 38-stage pipeline, 25 languages, SentenceValidator™, PHANTOM™"
readme = "README.md"
license = "LicenseRef-Proprietary"
Expand Down Expand Up @@ -110,7 +110,7 @@ Demo = "https://humanizekit.tester-buyreadysite.website/"
include = ["texthumanize*"]

[tool.setuptools.package-data]
texthumanize = ["data/*.json", "weights/*.zb85", "_data/*.json.gz"]
texthumanize = ["data/*.json", "weights/*.zb85", "_data/*.json.gz", "detector_weights.json"]

[tool.pytest.ini_options]
testpaths = ["tests"]
Expand Down
50 changes: 50 additions & 0 deletions tests/test_detector_weights.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""The fitted-weights loading mechanism (training/ pipeline output)."""
from __future__ import annotations

import json
from importlib.resources import files

from texthumanize.detectors import AIDetector


def test_shipped_weights_file_is_valid():
raw = (files("texthumanize") / "detector_weights.json").read_text("utf-8")
data = json.loads(raw)
assert data["schema"] == "texthumanize.detector_weights.v1"
w = data["weights"]
# Every shipped weight is a known metric with a sane, non-negative value.
for metric, value in w.items():
assert metric in AIDetector._WEIGHTS, f"unknown metric {metric}"
assert isinstance(value, (int, float)) and value >= 0
assert sum(w.values()) > 0


def test_resolved_weights_cover_all_metrics():
resolved = AIDetector._load_fitted_weights()
# Merge keeps every default metric even if the file omits some.
assert set(resolved) == set(AIDetector._WEIGHTS)
assert all(v >= 0 for v in resolved.values())


def test_invalid_weights_fall_back_to_defaults(monkeypatch):
# A broken payload must not throw and must yield the hand-tuned defaults.
def boom(*a, **k):
raise ValueError("corrupt")
monkeypatch.setattr("texthumanize.detectors.json.loads", boom)
fell_back = AIDetector._load_fitted_weights()
assert fell_back == dict(AIDetector._WEIGHTS)


def test_detection_unchanged_by_bootstrap_weights():
# The shipped bootstrap equals the hand-tuned weights, so a detection with
# the file present must match one forced onto the raw defaults.
text = ("In today's rapidly evolving digital landscape, it is important to "
"note that leveraging synergistic solutions can significantly enhance "
"productivity. Furthermore, organizations must carefully consider the "
"multifaceted implications of these transformative technologies.")
AIDetector._fitted_weights_cache = None
with_file = AIDetector().detect(text, "en").ai_probability
AIDetector._fitted_weights_cache = dict(AIDetector._WEIGHTS)
forced = AIDetector().detect(text, "en").ai_probability
AIDetector._fitted_weights_cache = None
assert abs(with_file - forced) < 1e-9
12 changes: 11 additions & 1 deletion tests/test_golden.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,17 @@ class TestFrozenSnapshots:
"is being actively pursued."
)

EXPECTED_EN_HASH = "c07a5f8d19d78cbaec35465f04190435"
# Обновлён после добавления словаря ИИ-клише (lang/_ai_cliches.py) и
# перехода в decancel на «сначала совпадение, потом монетка».
#
# ВНИМАНИЕ: этот снапшот фиксирует, что вывод стабилен, а НЕ что он
# хорош. На этом входе Python-конвейер до сих пор стакает вставки —
# «Here's the deal: and,», «Now, actively, plus,», «(and this is key)» —
# потому что несколько независимых этапов вставляют разговорный
# маркер в одно и то же начало предложения, не зная друг о друге.
# Так было и до этой правки. JS-порт в расширении такого не делает;
# библиотеке нужна та же работа по схлопыванию наложенных вставок.
EXPECTED_EN_HASH = "7eb50b5da1f66edd9acb0d21a304920c"

def _md5(self, text: str) -> str:
return hashlib.md5(text.encode()).hexdigest()
Expand Down
2 changes: 1 addition & 1 deletion texthumanize/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
import types as _types
from typing import Any

__version__ = "0.34.0"
__version__ = "0.35.0"
__author__ = "TextHumanize Contributors"
__license__ = "Personal Use Only"

Expand Down
18 changes: 14 additions & 4 deletions texthumanize/decancel.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,10 +273,17 @@ def _replace_phrases(self, text: str, prob: float) -> str:
if self._changes_made >= self._max_changes:
break

if not coin_flip(prob, self.rng):
# Сначала ищем совпадение, только потом бросаем монетку: иначе
# каждая фраза словаря тратит случайное число независимо от того,
# есть ли она в тексте, и пополнение словарей сдвигает весь поток
# RNG — из-за чего менялись замены в местах, которых правка
# вообще не касалась.
matches = list(pattern.finditer(text))
if not matches:
continue

matches = list(pattern.finditer(text))
if not coin_flip(prob, self.rng):
continue

for match in matches:
if has_placeholder(text[max(0, match.start()-5):match.end()+5]):
Expand Down Expand Up @@ -325,10 +332,13 @@ def _replace_words(self, text: str, prob: float) -> str:
if self._changes_made >= self._max_changes:
break

if not coin_flip(prob, self.rng):
# Как и в _replace_phrases: сперва совпадение, потом монетка.
matches = list(pattern.finditer(text))
if not matches:
continue

matches = list(pattern.finditer(text))
if not coin_flip(prob, self.rng):
continue

for match in reversed(matches): # Обратный порядок, чтобы не сбить индексы
if self._changes_made >= self._max_changes:
Expand Down
32 changes: 32 additions & 0 deletions texthumanize/detector_weights.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"schema": "texthumanize.detector_weights.v1",
"fitted": false,
"note": "Bootstrap = current hand-tuned weights. The training pipeline (training/) refits these from a labelled corpus and opens a PR when it beats the benchmark. Values are the per-metric contribution to P(AI); non-negative, need not sum to 1 (normalized at use).",
"trained_at": null,
"corpus_size": null,
"metrics": {
"accuracy": null,
"auroc": null
},
"weights": {
"pattern": 0.18,
"burstiness": 0.13,
"voice": 0.1,
"stylometry": 0.1,
"entity": 0.08,
"structure": 0.08,
"discourse": 0.06,
"rhythm": 0.05,
"entropy": 0.04,
"opening": 0.03,
"grammar": 0.03,
"vocabulary": 0.02,
"perplexity": 0.02,
"semantic_rep": 0.02,
"topic_sentence": 0.02,
"coherence": 0.02,
"readability": 0.005,
"punctuation": 0.005,
"zipf": 0.01
}
}
Loading
Loading