marcinwolnyeu / slopshape-php
Explainable AI text detector for PHP: scores how likely an English text is AI/LLM-generated from discourse structure, with per-feature evidence. No dependencies; offline (reduced accuracy) or with any LLM. Based on SlopShape (arXiv:2609.15369).
Requires
- php: ^8.3
- ext-mbstring: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95.27
- infection/infection: ^0.35.6
- maglnet/composer-require-checker: ^4.20
- pestphp/pest: ^4.7.8
- phpstan/phpstan: ^2.2.16
- phpstan/phpstan-strict-rules: ^2.0.12
- rector/rector: ^2.6.7
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-03 22:50:26 UTC
README
Structural AI-text detection for PHP. Give it a text, plus a model callback if you want extra accuracy, and get back a probability of AI authorship, a confidence, and the evidence behind both.
Use it to add an AI-slop check to your CI pipeline, or to any system that needs a plain signal for whether a text smells like slop. The detection method comes from @jochenmadler's September 2026 paper, arXiv:2609.15369 SlopShape: Identifying AI-Generated Commercial Web Content.
Install
composer require marcinwolnyeu/slopshape-php
You cannot use this package on a commercial project. The code is PolyForm Noncommercial 1.0.0 license.
Usage
Package requires PHP 8.3+ with ext-mbstring
Plain
Without callback, it runs fully offline and scores only what it can count itself. It's much faster and free, but suffers from reduced accuracy.
$score = (new Detector())->detect($text); // a 720-word text $score->probabilityAi; // float, e.g. 0.926 $score->confidence; // float, e.g. 0.498
Minimalistic solution
When you only need the number, to sort, filter or threshold, the probabilityAi() function skips the Score object:
use function SlopShape\probabilityAi; if (probabilityAi($text, $bridge) > 0.8) { // ... }
2. OpenAI, in a closure
Uses openai-php/client (PHP 8.2+; it also needs a PSR-18 HTTP client such as Guzzle). It is not a dependency of this package.
composer require openai-php/client guzzlehttp/guzzle
use SlopShape\Detector; $client = OpenAI::client(getenv('OPENAI_API_KEY')); $detector = new Detector(static function (string $prompt) use ($client): string { $response = $client->responses()->create([ 'model' => 'gpt-6-luna', // swap in any model you prefer 'instructions' => 'You are a precise annotator. You answer with JSON only.', 'input' => $prompt, 'max_output_tokens' => 4000, ]); return $response->outputText ?? ''; }); $score = $detector->detect($text); echo $score, "\n"; // 91/100 P[AI]=0.913 likely AI-generated confidence 0.50 9/10 core values read
The closure returns the raw answer text. Let API errors propagate; the detector wraps them in BridgeFailure. The output above is illustrative; it depends on the model and the text.
3. OpenRouter, as an LlmBridge
A named bridge class for OpenRouter, which serves many providers' models behind one OpenAI-compatible endpoint. It needs only ext-curl and ext-json:
use SlopShape\Bridge\LlmBridge; final readonly class OpenRouterBridge implements LlmBridge { public function __construct( private string $apiKey, private string $model = 'openai/gpt-6-luna', private int $timeoutSeconds = 120, ) {} public function complete(string $prompt): string { $curl = curl_init('https://openrouter.ai/api/v1/chat/completions'); curl_setopt_array($curl, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $this->timeoutSeconds, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $this->apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'model' => $this->model, 'messages' => [ ['role' => 'system', 'content' => 'You are a precise annotator. You answer with JSON only.'], ['role' => 'user', 'content' => $prompt], ], 'max_tokens' => 4000, ], JSON_THROW_ON_ERROR), ]); $body = curl_exec($curl); $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); if (false === $body || 200 !== $status) { throw new RuntimeException(sprintf( 'OpenRouter request failed (HTTP %d): %s', $status, false === $body ? curl_error($curl) : $body, )); } $data = json_decode($body, true, flags: JSON_THROW_ON_ERROR); return $data['choices'][0]['message']['content'] ?? throw new RuntimeException('OpenRouter returned no answer: ' . $body); } }
Score a text and print the probability with the evidence on each side:
use SlopShape\Detector; $detector = new Detector(new OpenRouterBridge(getenv('OPENROUTER_API_KEY'))); $score = $detector->detect($text); printf("P[AI] = %.3f\n", $score->probabilityAi); echo "\nEvidence for AI:\n"; foreach ($score->positiveFactors() as $factor) { printf(" %+0.4f %s: %s\n", $factor->contribution, $factor->feature, $factor->value); } echo "\nEvidence for human:\n"; foreach ($score->negativeFactors() as $factor) { printf(" %+0.4f %s: %s\n", $factor->contribution, $factor->feature, $factor->value); }
P[AI] = 0.915
Evidence for AI:
+0.8373 Conclusion Behavior: restated_thesis_or_reframe
+0.7639 Functional Stage Inventory: summary_or_synthesis
+0.4070 Freshness Signaling: modern_or_next_generation_framing
+0.3910 Dominant Credibility Basis: confident_institutional_explanation
+0.3166 Functional Stage Inventory: mechanism_explanation
Evidence for human:
-0.3340 Article Length Class: short_under_800_words
Any HTTP error, or a reply with no answer in it, throws, so the detector reports a BridgeFailure instead of scoring an empty string. The output above is illustrative; it depends on the model and the text.
What it does
SlopShape (Madler 2026, arXiv:2609.15369) found that discourse-level structural choices separate AI-generated from human-written commercial web content at 0.9695 macro-F1, that ten values carry much of that signal, and that the signal survives rewording. None of it is a word list: the features describe how a post is built, not which words it uses.
SlopShape-PHP applies those ten values to one document:
-
Measure what can be measured. Word count is countable, so it is counted and never asked about. (On a 674-word article, one model studied by the paper answered "800-1499 words".)
-
Ask your model about the rest, using the study's own stage-5 annotation prompt, the study's own frozen answer menus, and its "never invent values, never omit a feature" discipline. The rules and menus are transcribed, not paraphrased. Three things differ from the study's main run:
- A defence against prompt injection: the post goes inside
<pasted_content id="...">tags with a random id that is new for every prompt, and the model is told to treat it as data. - One call, not eleven: the study scored one dimension per call (11 calls per post, with gemini-3.6-flash on a minimal thinking budget). This package asks for all eight answers in one call, which is the prompt template's single-call mode. The study checked that mode on a small sample and did not use it for the main run, where it answered 99.75% of items against 99.97%.
- A different cut-off for long posts: the study sent the first 2,600
words; this package sends the first
maxInputCharscharacters (24,000 by default). The word count always uses the whole text.
The title is left out: SlopShape v3 found that a title line moves some answers on its own.
- A defence against prompt injection: the post goes inside
-
Force every answer onto its menu. Exact matches pass through; near matches are repaired and the repair is recorded; an answer that matches nothing on the menu becomes a missing reading, not a guess, and that is recorded too.
-
Weight the readings by released calibration and sum them as log-odds:
weight(gap) = calibration * ln((1 + gap) / (1 - gap)) contribution = confidence * weight * direction * signal P[AI] = sigmoid(Σ contributions)
There is no learned model in here and no hidden state. Given a Score and the calibration constant, you can recompute the number by hand.
Development
composer install
composer check # validate + cs + phpstan + rector + deps + tests
| Command | What it does |
|---|---|
composer test |
Pest |
composer test:ci |
Pest in parallel, with coverage |
composer cs / cs:fix |
PHP-CS-Fixer |
composer stan |
PHPStan level max + strict rules |
composer rector / rector:fix |
Rector |
composer deps |
every used symbol is provided by a declared dependency |
composer test:infection |
mutation testing |
The suite pins the arithmetic to hand-derived golden numbers, so a change in the formula fails a test rather than quietly changing someone's report.
Bug reports and questions go in issues; pull requests are not accepted. See CONTRIBUTING.md.
Licence
PolyForm Noncommercial 1.0.0, inherited from upstream rather than chosen. See LICENSE.md.
Citation
See CITATION.cff. If you use this, cite SlopShape, not this package.