Recording disclosures¶
To record AI involvement from code, call ai_disclosure.recorder, which
implements DisclosureRecorderInterface. That interface is the only class a
calling module needs to know.
suggest()¶
suggest() leaves a non-binding suggestion for the editor to Accept or
Dismiss on the entity form. It never changes the field.
\Drupal::service('ai_disclosure.recorder')->suggest(
$entity,
'ai_partly_assisted',
'my_module',
'Three paragraphs were rewritten by the assistant.',
);
The entity must already be saved, and a second call from the same source on the same entity and language replaces the first one.
The note is shown to the editor as text. Markup in it is escaped, not rendered, so a link or an emphasis written there reaches the form as the characters that spell it.
apply() and applyProfile()¶
apply() and applyProfile() write the disclosure directly, for headless
pipelines and cron. Both refuse to downgrade: the write happens only when the
requested grade's severity is strictly higher than the entity's currently
effective grade. Both set the human review flag to FALSE, because an
automated caller cannot attest that a person reviewed anything.
\Drupal::service('ai_disclosure.recorder')->applyProfile(
$entity,
'machine_translation',
'my_module',
);
From inside hook_entity_presave(), pass $save = FALSE. The recorder then
changes the field values on the entity object and leaves persistence to the
save cycle already running. Calling $entity->save() there would recurse:
\Drupal::service('ai_disclosure.recorder')->apply(
$entity,
'ai_translated',
'my_module',
NULL,
FALSE,
);
What a binding write leaves behind¶
A write that actually happens is recorded in the same
ai_disclosure_suggestion table suggest() writes to, as a row with status
accepted carrying the $source and the $note. It is provenance for a
decision already made, not a proposal: the editor's pending callout and the
report's pending-suggestion count both look at pending rows only, and the
note is never shown to a reader. applyProfile() records the grade the
profile resolves to. Which profile it was is on the field itself.
Nothing is recorded when the downgrade rule refuses the write, and nothing is recorded when the write happens during an entity's first insert, because the row is keyed by an entity ID that does not exist yet.
A suggestion left by suggest() only turns accepted when the entity form
that shows it is actually saved with that suggestion's grade still selected -
clicking Accept in the editor's callout stages the intent on the form, it
does not write the row. ai_disclosure_settle_accepted_suggestions() checks,
after the save, that the field still carries the accepted grade before
flipping the status. A save that changes the grade again, or that never
happens, leaves the suggestion pending.
What accepted has meant, by version
As of this version, accepted means one thing only: a write actually
happened, through suggest()'s form settlement or through apply() /
applyProfile(). Rows a site wrote before this version may not carry
that same guarantee. Read them with that doubt, they are not
retroactively reconciled.
Errors never break the caller's save¶
An unknown grade, an unknown profile or a bundle without the field never
breaks the caller's save: they log a warning on the ai_disclosure channel
and return. The one exception is suggest() on an unsaved entity, which
throws \InvalidArgumentException, because there is no ID to key the
suggestion row on.
The $source argument¶
$source is the calling module's machine name, for example ai_automators
or ai_translate. Every recorder call stores it, so the
ai_disclosure_suggestion table is where a site answers "which module said
this, and why". The compliance report does not expose
that column (it reports on the field, not on this table), but the
suggestions listing does, so a source that
invents its own naming spoils the record for whoever reads that page or
queries the table directly.
What Views can build from suggestions now¶
The report's "Pending suggestions" column is a count: one number per
entity, pending rows only, computed against whichever field the row is
about. It answers "how many suggestions is this entity still waiting on",
and nothing about grade, source or note.
The ai_disclosure_suggestion table itself is a Views base table in its own
right, behind the access ai disclosure suggestions permission. That is a
separate, restricted permission, because a raw row exposes a note a
third-party module wrote, which the report never shows.
A view on it is rows: every suggestion, in every status, with its grade,
source, note and the user recorded against it, and a relationship toward
whichever entity type it names. That is where a site sees accepted and
dismissed rows at all, and where "which module suggested what, and why"
in $source and the note actually gets read back. The shipped
suggestions listing is one such view. A site can
build its own on the same table.
A suggestion row's langcode follows the translation it was recorded
against: deleting that translation deletes the row with it, in both kinds
of view.
Reacting when a suggestion is decided¶
Accepting or dismissing a suggestion fires an event, so a module can follow
those decisions without polling the table. The name is
AiDisclosureEvents::SUGGESTION_STATUS_CHANGE and the object is a
SuggestionStatusChangeEvent, carrying the old and the new status together
with the row's identity: its ID, the entity type, entity and langcode it is
about, the grade, the source, the note, and the uid stored on the row.
That last one is the uid the suggestion was recorded under, not the person
clicking Accept. For who is acting right now, read current_user inside the
subscriber.
Two silences are deliberate. An ID matching no row fires nothing, and
neither does a write that sets the status a row already had. The update
still refreshes the row's changed timestamp, but nothing decided, so
nothing is announced. The event is dispatched after the write has
succeeded, which means a subscriber reacts to a change and cannot veto it.
A subscriber that throws cannot break the change either. The exception is caught where the event is dispatched and written to the log, naming the suggestion, the two statuses and the subscriber that failed, and the call returns as it would have.
The audit trail submodule is the first consumer: it turns each of these into a row that says who decided what, and when.
Calling it from an agent¶
An AI agent reaches this service through the ai_disclosure_tool submodule,
which wraps it in one plugin for the contributed Tool API module. The plugin
adds no rule of its own: the invariants above are what an agent gets. What it
does add is the access check the service leaves to its caller, and a site
setting that keeps binding writes off until someone turns them on. See
The Tool plugin.