Skip to content

Annotations AI Draft — Developer Reference

Developer-focused reference. For module overview see README.md.


DraftGuidanceWithAi

src/Plugin/FieldWidgetAction/DraftGuidanceWithAi.php — a field_widget_actions plugin, not a Tool API plugin (unrelated to annotations_tool, which serves annotation content out to agents; this one drafts it). Delegates field-definition lookup, real-value sampling, and sibling-annotation gathering to this module's own AnnotationAiContextBuilder service (annotations_ai_draft.context_builder) — kept as a separate service from the button/prompt/AI-call plumbing in case a second consumer (e.g. annotations_ai_validate) ever needs it, but not a separate module.

Why AnnotationAiContextBuilder isn't its own module

It briefly was — a standalone annotations_ai module, justified by a planned-but-unbuilt second consumer (annotations_ai_validate) and by parallel with annotations_context's role for the read direction. Removed 2026-09-09: the parallel didn't hold up in practice — lookupFieldDefinition() duplicated ContextAssembler::getFieldDefinitions()'s guard/try-catch rather than reusing it (that method is private, so the split module couldn't call it anyway), so the extraction added a module, a services.yml, and a dependency edge without actually sharing code with anything. With only one real consumer, folding the class back into this module removes all of that for zero loss. If annotations_ai_validate gets built for real, re-extract at that point — don't recreate the split speculatively.

Prompt pitfall found during testing (2026-09-09): the first version's prompt included sibling annotations as loose context with no instruction beyond "match their voice and format." Tested against node__recipe/field_difficulty (which already has real editorial and rules annotations) — the model copied the editorial sibling back verbatim, even when asked to draft a different annotation type (technical) for the same field. Fixed by (a) telling the prompt explicitly which annotation type is being drafted ($entity->bundle()), and (b) telling it siblings are voice/format reference only, explicitly "do not copy their wording, even if it would fit." Re-tested: a technical-type request on the same field then correctly produced distinct validation/storage-mechanics guidance, clearly differentiated from the editorial (reader-judgment) and rules (business-logic) siblings. If this plugin is ever extended, keep that instruction — dropping it silently regresses to verbatim copying whenever a close-enough sibling exists.

Tested via drush eval with ReflectionMethod on the private buildPrompt() method against real content, not just unit-level — no headless browser is available in this environment to click the actual button (see root CLAUDE.md's "Not currently set up" section).

instruction_override config setting

The per-field config exposes one setting, instruction_override (buildConfigurationForm()/defaultConfiguration()), stored flat on the field_widget_actions third-party settings entry — a sibling of enabled/button_label/weight, not nested under a settings sub-key. This matters: an earlier version of this schema (and the field's earlier name, extra_instructions) declared it nested under settings.extra_instructions, which validated fine when hand-written via drush eval but failed Drupal's schema checker (Drupal\Core\Config\Development\ConfigSchemaChecker) the moment it was configured through the real Manage Form Display form — the form genuinely writes flat, matching FieldWidgetActionBase::buildConfigurationForm()'s own convention (see annotations_ai_draft.schema.yml; the earlier nested version was never the actual shape the code produces). If this plugin ever gains more config keys, keep them flat, and verify against ConfigSchemaChecker::checkConfigSchema() directly rather than trusting an eval-triggered $entity->save() — the latter doesn't reliably invoke the same validation path as a real form submission.

In buildPrompt(), when instruction_override is non-empty it replaces the fixed instruction sentence entirely (Draft a short, specific piece of editorial guidance... / the bundle-level equivalent) — it does not get appended alongside it. The auto-gathered context (field definition, sampled values, sibling annotations) is built the same way regardless of whether an override is set; only the instruction line changes. This was a deliberate design choice over letting it override the entire prompt (skipping context gathering too) — the auto-gathered facts are this plugin's whole value proposition, and losing them whenever a field sets a custom instruction would be a worse default.

Permission gating (isAvailable())

Found while reviewing the built feature (2026-09-10): nothing gated who could trigger the button beyond ordinary annotation-edit access — anyone who could edit a given annotation type could also burn real AI provider calls on it, with no way to separate the two.

field_widget_actions doesn't ship a permissions system of its own (no .permissions.yml, only one route for the unrelated modal-form plugin pattern, gated by access content). What it does provide is FieldWidgetActionInterface::isAvailable(): bool — the base class defaults it to TRUE. This is a real access-control point, not just a rendering nicety: hook_field_widget_complete_form_alter()/hook_field_widget_single_element_form_alter() (field_widget_actions/src/Hook/FieldWidgetAction.php:299,328) check it fresh on every form build, including the AJAX rebuild a click triggers, and skip adding the action's button to the form array at all when it's FALSE. Since Drupal's Form API rebuilds the whole form server-side and only accepts a triggering element that exists in that fresh build, a user without the permission never gets a button that could be forged into existence client-side — it's genuinely absent from the form, not just hidden by CSS or JS.

Implemented: a new permission, use annotations ai draft (annotations_ai_draft.permissions.yml, restrict access: true since it triggers real external calls and sends field/content data to whatever AI provider is configured). isAvailable() overrides the base default to check it via an injected AccountProxyInterface $currentUser (not \Drupal::currentUser() — matches this module's constructor-injection convention). ajaxCallback() also re-checks it directly as its first line, before touching anything else — the same defense-in-depth precedent annotations_tool's ReadAnnotations already sets (re-checking its own AI-context gate rather than trusting upstream filtering alone), in case isAvailable()'s protection is ever weakened by a future change elsewhere.

Not granted to any role by default. The site's administrator role has it implicitly (Drupal admin roles carry every permission), which is why testing as uid 1 kept working straight through — nothing to configure there. Any other role needs it granted explicitly at /admin/people/permissions before that role's users see the button.