Skip to content

AI comment moderation with thresholds in the model

Version v1

Initialising viewer…

You can apply this model as a recipe (Drupal 10.3 or later) to your own Drupal site:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
## Import recipe
composer require drupal-eca-recipe/eca_lib_0051

# Apply recipe with Drush (requires version 13 or later):
drush recipe ../recipes/eca_lib_0051

# Apply recipe without Drush:
cd web && php core/scripts/drupal recipe ../recipes/eca_lib_0051

# Rebuilding caches is optional, sometimes required:
drush cr

Purpose

A comment is submitted, an AI model reads it and returns two scores and a one sentence justification. That is the whole of the AI's involvement. The decision about what happens to the comment is taken afterwards, by ordinary ECA conditions comparing those scores against thresholds that are stored in the model as plain values.

The split is deliberate. The agent supplies judgment, the workflow supplies policy. When someone asks months later why a particular comment was blocked, the answer is the model plus the log record of that run, not a prompt and a shrug.

What the AI is asked for

One call to the Chat action from AI Integration - ECA, with a strict JSON schema, returns exactly three values.

  • toxicity, an integer from 0 to 100.
  • spam, an integer from 0 to 100.
  • reason, one short sentence explaining both scores.

The prompt states no threshold and asks for no recommendation. It explicitly tells the model not to mention publishing, review or blocking, because the site decides what the scores mean. The response is a plain string, and Tamper: Encode/Decode in Json Decode mode turns it into structured data under the token verdict.

Where the thresholds live

Two "Token: set value" actions run before the AI call and hold the entire policy.

  • "Threshold: block from this score" sets threshold_block to 70.
  • "Threshold: hold for review from this score" sets threshold_review to 30.

To change the policy, open one of those two actions and type a different number. Nothing else in the model changes and the prompt is not touched, because the prompt never knew the numbers in the first place.

Both thresholds are applied to both scores. A comment is blocked when either score reaches threshold_block, held when either score reaches threshold_review, and published when both stay below it.

The three exits

The routing is a cascade of scalar comparisons, so exactly one exit runs. Each exit writes the comment status, records the outcome on the comment and writes one log record to the eca_comment_verdict channel naming both scores, both thresholds and the exit taken.

Exit status field_ai_moderation Log severity
Publish 1 approved Info
Hold for review 0 review Notice
Block 0 blocked Warning

Publish and the other two differ in the comment status. Hold and block differ in field_ai_moderation, which is the point of that field: an unpublished comment on its own does not say whether a human is expected to look at it. A held comment is waiting for a moderator, a blocked comment is not, and a site can act on that distinction with a view filtered on the field.

field_ai_reason carries the sentence the AI wrote and is set once, before the routing, because it is evidence rather than policy.

Both fields are deliberately absent from the comment form display, so that a commenter cannot set them, and from the comment view display, so that the assessment is not published alongside the comment. Add them to a view or to an administrative display to review them.

When the AI call fails

Nothing is published unless a decision says so. Before the AI call, two actions named "Until a decision is taken ..." unpublish the comment and set field_ai_moderation to review. Every later exit overwrites that, so a comment that reaches no exit at all, because the AI call raised an exception or because no chat model has been selected yet, is still held rather than left at whatever status it arrived with.

On top of that, two checks send an unusable answer down a branch that logs what happened.

  1. Before decoding, "Response contains a toxicity score" tests the raw string. An empty response, which is what the Chat action leaves behind when no provider can be loaded, and a refusal in prose both fail this test. The check exists because Json Decode raises an exception on input that is not JSON, and an exception ends the run without reaching any branch.
  2. After decoding, "Verdict carries a toxicity score" catches an answer that is valid JSON but does not carry the value the routing needs.

Either way the comment is unpublished, field_ai_moderation is review, and the raw response is written to the log at Error severity so that the string that could not be used is still available. An assessment that did not happen is treated as an assessment that could not clear the comment.

A response that contains "toxicity" but is still malformed, a truncated answer for example, raises the decoder exception anyway. ECA logs the exception on its own channel and the run ends there, with the comment left unpublished by the default above.

How it stays out of its own way

The event is Presave, not Insert, so the status and the two fields are changed on the entity Drupal is already about to write. The model never calls save on the comment and therefore cannot trigger itself.

The first condition on the event is "Entity: is new". A moderator who later publishes, edits or unpublishes the comment causes another presave, and that condition ends the run before the AI call. Each comment is assessed exactly once, when it is submitted.

Before you run it

Select a chat model in the "Ask AI to score the comment" action. No Drupal site has an AI provider configured out of the box, so this step is required, and the provider has to support structured output or the schema is ignored and the response will not decode. Expect the comment submission to take a few seconds while the provider answers.

Do this before the site accepts comments. With no model selected the Chat action has nothing to call and raises an exception, which ends the run. The comment is still saved and still unpublished, because that is the default the model sets before the call, but no verdict is logged and every comment piles up in the moderation queue.

Requirements

This recipe installs core's comment module, together with the eca_base, eca_content and eca_log submodules that ship inside the ECA project itself. Two genuinely separate contributed projects are also required: AI Integration - ECA (ai_integration_eca) for the Chat action, and ECA Tamper (eca_tamper) for the Json Decode step. Composer installs both alongside this recipe.

It includes the library's article_content_type recipe, which provides the Article content type with its body field. Drupal 11.4 removed that content type from the Standard profile and Drupal 12 removed core's recipe for it, so the library ships its own. Composer installs it alongside this recipe.

The recipe also ships the comment comment type that the model is restricted to, its comment body field, the two moderation fields, the comment and moderation field displays, and the comment field on the Article content type, so that a fresh site has something to comment on.

Dependencies

  • config
    • comment.type.comment
    • field.field.comment.comment.comment_body
    • field.field.comment.comment.field_ai_moderation
    • field.field.comment.comment.field_ai_reason
    • field.storage.comment.comment_body
    • field.storage.comment.field_ai_moderation
    • field.storage.comment.field_ai_reason
  • module
    • ai_integration_eca
    • comment
    • eca_base
    • eca_content
    • eca_log
    • eca_tamper
    • modeler_api
    • text

Used plugins

Events

Conditions

Actions

Changelog

Initial version