Loading...
← All projects

AI Humanizer for Joomla: an AI-text detector without LM Studio

AI Humanizer for Joomla is a package (a component plus an editor button) that scores how machine-generated a text looks and suggests edits. It handles Ukrainian and English and runs on Joomla 5 and 6. Under the hood it's my desktop AI Humanizer, an Electron app, with LM Studio taken out.

Where the component came from

The desktop version (Electron 33, React 19, TypeScript) does two things. Stylometric heuristics score the text locally, and deep analysis plus rewriting go through a model running in LM Studio. That setup doesn't translate to an ordinary website. Nobody editing articles on a Joomla site is going to install LM Studio and pull a 7B model just to check a post.

So the brief was simple to state. Same detector, shipped as a regular Joomla extension you install from one zip. LM Studio goes away entirely. A cloud model (OpenAI, Anthropic or Gemini) stays as an option that the site admin turns on with their own key.

What came over from the desktop app untouched

Reading through the Electron code, I found that half the work was already done. Nothing in shared/detector or src/lib touches Node. That covers the heuristics, the style lexicons for ChatGPT, Claude, Gemini, DeepSeek and Grok, DOCX parsing with fflate and DOMParser, and PDF text extraction with pdf.js. It also covers the most involved piece of the desktop app, TextMap, which writes edited text back into a DOCX without disturbing its formatting.

I copied those modules almost as they were. Two things changed: LoadedFile lost its path field, and the pdf.js worker URL now comes from PHP through Joomla.getOptions(). Everything tied to Electron (IPC, the tray icon, the global hotkey, printToPDF) was dropped.

The consequence that matters most is architectural. Analysis, edits, saving the file and the report all happen in the visitor's browser. Neither the text nor the file is sent to the server, so the check itself puts no load on the hosting at all.

Edits without a language model

Removing LM Studio left one obvious hole: rewriting. On the desktop, a model rewrote the flagged sentences. Here I needed something that works with no model at all.

I settled on a trade-off. A rule only ever replaces a phrase, and it keeps that phrase's grammatical slot (case, number, whatever follows it), so the sentence stays correct without any parsing. Template openers get dropped when they carry no meaning and swapped for a plainer word when they do:

ts
// Openers with no meaning of their own are dropped; ones that carry logic
// (therefore, таким чином) are swapped for a plainer word rather than lost.
// The replacement carries its own punctuation: "Also, " but "So " and «Тож ».
const OPENERS: Record<Lang, Pair[]> = {
  en: [
    ['moreover', ''], ['furthermore', ''], ['additionally', 'Also, '],
    // …
  ],
  uk: [
    ['крім того', ''], ['більше того', ''], ['таким чином', 'Тож '],
    // …
  ],
};

On top of that there's a cliché dictionary ("plays a pivotal role in" becomes "is central to", "leverage" becomes "use"), a rule that unfolds "not only X but also Y" into "X and Y", and, for English only, a rule that turns em-dash asides into commas or brackets. The first run on the English sample produced "remains a important aspect". I added fixArticles, which corrects the article only in front of a word that a rule just inserted, so "a user" elsewhere in the sentence is left alone.

Rules can't break up a list of three, change an even sentence rhythm, or find a concrete phrase for a cliché that has no ready replacement. The component doesn't fake it. Those sentences go into a "revise by hand" list with a hint for each.

With every edit accepted, the built-in Ukrainian sample drops from 95% to 73% and the English one from 98% to 66%. The text gets less formulaic, but by the heuristics it doesn't turn "human". For rules without a model, I think that's an honest result.

A cloud model behind a PHP proxy

The optional deep analysis does what it did on the desktop. The model returns a probability, a verdict, reasons with quotes and the numbers of suspicious sentences, and it can then rewrite those sentences. What changed is who talks to whom.

The API key lives in the component options and never reaches the browser. The browser sends the site an array of sentences and nothing else, and PHP builds the prompt. I ported both prompts from TypeScript to Prompts.php word for word. That's deliberate: if the prompt came from the browser, anyone could use the site as a free chat endpoint billed to whoever owns the key.

PHP doesn't parse the model's reply. It hands back the raw text, and the browser runs the same lenient parser the desktop app uses, which strips <think> blocks and ```json fences and closes a truncated JSON object. It already covers every reply shape I'd seen from local models, so duplicating it in PHP made no sense.

Each provider had its quirks. OpenAI's own models want max_completion_tokens, while compatible services such as OpenRouter still expect max_tokens, so the proxy decides based on the base URL. I send the OpenAI schema with strict: false rather than reshaping it to fit strict mode. Anthropic and Gemini get the schema as text in the system prompt.

To keep the bill predictable there are daily limits per site, per user and per guest. Guests are counted by a hash of their IP address salted with the Joomla secret; the address itself is never stored. Every request goes into #__aihumanizer_usage, and the backend has a page with 30 days of statistics.

An editor button that doesn't break the HTML

The "Check for AI" button opens the checker in a dialog above the article editor. The lazy way would be to take the text, fix it and paste it back as plain text. Links, bold and paragraphs would all be gone.

Instead, the article HTML goes into the analyzer as an .html file. htmlMap from TextMap reads it, and once edits are accepted the same mechanism writes changes only into the text nodes that changed. The component then hands body.innerHTML back to the editor. In the test, a sentence containing <strong>штучний інтелект</strong> kept its <strong> after the edit.

Small things that cost time

A separate class of problems was Joomla-specific behavior you don't see until you open a real site.

  • In Joomla 5 and 6, TinyMCE hides editors-xtd buttons inside a "CMS Content" dropdown. My test looked for the button on the toolbar and failed.
  • Joomla compiles Bootstrap without a prefix, so the variables are --body-bg, not --bs-body-bg. In Atum's dark theme the component's cards stayed white and the tab labels disappeared into the background.
  • On Joomla 6 the options page printed JGLOBAL_SHOW_PAGE_HEADING_LABEL raw. I replaced it with the component's own string.
  • The pdf.js worker ships as .js, not .mjs. Apache in XAMPP doesn't always serve .mjs with a JavaScript MIME type, and a module worker refuses to start without one.
  • For a fresh session, Session::checkToken() redirects. To fetch() that's an HTML page instead of JSON, so the AJAX controller answers with a JSON 403 on its own.

One more: the file-name chip had display: inline-block, which overrode its hidden attribute. It only showed up on a screenshot from the live site.

How I tested it

For tests I installed a clean Joomla 5.3.3 and a clean 6.1.3 on MariaDB, plus a small PHP server mimicking an OpenAI-compatible API. Playwright walks the site page with 25 checks (rules, proxy, guest limit, a request without a CSRF token, DOCX, PDF, a cp1251 TXT, the printed report, a 390 px viewport) and then the backend, including the editor button.

DOCX got its own check. I unzipped the original and the edited file and compared the package parts. Only word/document.xml had changed, and the replacement of «У сучасному світі» with «Сьогодні» stayed bold because it inherited the formatting of the run it landed in.

Working with an AI agent

I built this port together with Claude running as an agent. The split went like this. I set the constraints: no LM Studio, a cloud model only as an option behind a PHP proxy with the key in the backend, Joomla 5 and then Joomla 6 as well. The agent read the Electron source, proposed the architecture, wrote the TypeScript and PHP, spun up test Joomla sites and ran Playwright against them. I installed the package on my local site myself, through the backend.

The most useful part of working this way turned out to be checking on the real site. The white card in dark mode, the untranslated key on Joomla 6 and the chip that wouldn't hide all surfaced while the agent was taking screenshots for the user guide; the clean test installs never showed them. The guide itself (PDF, DOCX and Markdown) was made the same way.

What's still wrong, and what's next

Status: beta, version 1.0.1. It runs on my local Joomla 6.1.3 site, and there's no public release yet.

Known limitations:

  • accuracy rests on lexicons I picked by hand, not on a trained model, so the score is a reason to look closer, not proof;
  • the rules remove tells but don't rewrite rhythm or give the text an author's voice;
  • scanned PDFs without a text layer can't be read, and there's no OCR;
  • the SQL is MySQL and MariaDB only, PostgreSQL isn't supported yet.

The obvious candidates for the next version are lexicons stored in the database and editable in the backend, and SQL for PostgreSQL.