Recording disclosures¶
To record AI involvement from code, call ai_disclosure.recorder, which
implements DisclosureRecorderInterface. A calling module needs to know only
that interface.
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 be saved already. A second call from the same source on the same entity and language replaces the first one. If the editor has already accepted or dismissed that source's suggestion and the new call passes the same grade and the same note, the decision is kept. A different grade or a different note reopens the suggestion as pending, so a source that reads a new file should describe that file in the note.
A suggestion is not stored when its grade is less severe than the disclosure
the entity has now. The source's pending suggestion for that entity and
language is removed in the same call. A decided suggestion stays.
apply() and applyProfile() refuse a downgrade in the same way.
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()¶
These two methods write the disclosure directly. They suit headless pipelines and cron jobs. 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.
$recorder = \Drupal::service('ai_disclosure.recorder');
$recorder->applyProfile(
$entity,
'machine_translation',
'my_module',
);
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:
$recorder->apply(
$entity,
'ai_translated',
'my_module',
NULL,
FALSE,
);
What a binding write leaves behind¶
A write that 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 an apply() or applyProfile() 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.
When suggest() drops a downgrade, it also deletes that source's pending row
for the entity and language. An accepted or dismissed row stays.
A suggestion left by suggest() only turns accepted when the entity form
that shows it is 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.
accepted means one thing: a write happened, through that form settlement or
through apply() or applyProfile().
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¶
The $source value is the calling module's machine name, for example ai_automators
or ai_translate. Every recorder call stores it, so the
suggestion table is where a site answers "which module said
this, and why". The compliance report does not expose
that column because it reports on the field, not on this table. The
suggestions listing does show it, 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 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 returns rows: every suggestion, in every status, with its grade,
source and note, plus 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 in the note 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. The object is a
SuggestionStatusChangeEvent. It carries the old status and the new one, with
the row's identity. That means its ID and the entity type, entity and langcode it is
about, the grade, the source, the note and the uid stored on the row.
The uid is the one 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. 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.