Skip to content
Snippets Groups Projects
Commit ace7bf76 authored by Scott Euser's avatar Scott Euser
Browse files

Issue #3464960 by scott_euser: Change to ai module as dependency

parent 16bc8e94
No related branches found
No related tags found
1 merge request!5Resolve #3464960 "Change to ai"
Pipeline #240964 passed with warnings
......@@ -4,3 +4,5 @@ description: Automatically sets or suggests references to other content entities
package: AI Auto-reference
core_version_requirement: ^9 || ^10
configure: ai_auto_reference.ai_auto_reference_settings_form
dependencies:
- drupal:ai
ai-auto-reference-admin:
version: 1.0
css:
theme:
assets/css/ai-auto-reference.css: { }
......@@ -17,28 +17,32 @@ function ai_auto_reference_form_node_form_alter(&$form, FormStateInterface $form
$node = $form_state->getFormObject()->getEntity();
// Turning off the functionality on the 'create' page of nodes.
// The modules refreshes the page, which would just delete the progress on such pages.
if ($node instanceof NodeInterface && !$node->isNew()) {
if ($configuration = \Drupal::service('ai_auto_reference.ai_references_generator')
->getBundleAiReferencesConfiguration($node->bundle())) {
$apply_form = \Drupal::service('form_builder')->getForm(AutoReferenceApplyForm::class, $node, $configuration);
if (!empty($apply_form['container'])) {
// To prevent messages duplications.
\Drupal::messenger()->deleteByType('status');
\Drupal::messenger()->addMessage($apply_form);
}
if (
$node instanceof NodeInterface
&& !$node->isNew()
&& $configuration = \Drupal::service('ai_auto_reference.ai_references_generator')->getBundleAiReferencesConfiguration($node->bundle())
) {
$build_info = $form_state->getBuildInfo();
$node = $build_info['callback_object']->getEntity();
if ($node instanceof NodeInterface && $node->id()) {
$form['actions']['ai_auto_reference'] = [
'#type' => 'submit',
'#value' => t('Generate references with AI'),
'#name' => 'ai_auto_reference',
'#submit' => [
'ai_auto_reference_node_form_submit',
],
];
}
// Get the apply form when not auto-applied.
$apply_form = \Drupal::service('form_builder')->getForm(AutoReferenceApplyForm::class, $node, $configuration);
// To prevent messages duplications.
if (!empty($apply_form['container'])) {
\Drupal::messenger()->deleteByType('status');
\Drupal::messenger()->addMessage($apply_form);
}
$build_info = $form_state->getBuildInfo();
$node = $build_info['callback_object']->getEntity();
if ($node instanceof NodeInterface && $node->id()) {
$form['actions']['ai_auto_reference'] = [
'#type' => 'submit',
'#value' => t('Generate references with AI'),
'#name' => 'ai_auto_reference',
'#submit' => [
'ai_auto_reference_node_form_submit',
],
];
}
}
}
......
services:
ai_auto_reference.ai_references_generator:
class: Drupal\ai_auto_reference\AiReferenceGenerator
arguments: ['@entity_type.manager', '@renderer', '@config.factory', '@current_user', '@logger.factory', '@theme.initialization', '@theme.manager']
arguments: [
'@entity_type.manager',
'@renderer',
'@config.factory',
'@current_user',
'@logger.factory',
'@theme.initialization',
'@theme.manager',
'@ai.provider',
'@ai.tokenizer',
'@ai.text_chunker',
]
.fieldset__ai-auto-reference,
.fieldset__ai-auto-reference:not(.fieldgroup) {
/** Prevent Gin theme compatibility issue for the apply form */
background-color: white;
}
......@@ -15,9 +15,5 @@
"homepage": "https://dev-branch.com",
"role": "Maintainer"
}
],
"require": {
"openai-php/client": "^0.4.1",
"gioni06/gpt3-tokenizer": "^1.2"
}
]
}
......@@ -2,6 +2,13 @@
namespace Drupal\ai_auto_reference;
use Drupal\ai\AiProviderPluginManager;
use Drupal\ai\OperationType\Chat\ChatInput;
use Drupal\ai\OperationType\Chat\ChatMessage;
use Drupal\ai\OperationType\GenericType\AudioFile;
use Drupal\ai\OperationType\SpeechToText\SpeechToTextInput;
use Drupal\ai\Utility\TextChunkerInterface;
use Drupal\ai\Utility\TokenizerInterface;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\ImmutableConfig;
......@@ -14,56 +21,13 @@ use Drupal\Core\Theme\ThemeInitializationInterface;
use Drupal\Core\Theme\ThemeManagerInterface;
use Drupal\Core\Url;
use Drupal\node\NodeInterface;
use Gioni06\Gpt3Tokenizer\Gpt3Tokenizer;
use Gioni06\Gpt3Tokenizer\Gpt3TokenizerConfig;
use League\HTMLToMarkdown\HtmlConverter;
/**
* Class AiReferencesGenerator.
* Class for the AI references generation.
*/
class AiReferenceGenerator {
/**
* Drupal\Core\Entity\EntityTypeManagerInterface definition.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Renderer.
*
* @var \Drupal\Core\Render\Renderer
*/
protected $renderer;
/**
* The config factory service.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $config;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* Theme initialization service.
*
* @var \Drupal\Core\Theme\ThemeInitializationInterface
*/
protected $themeInitialization;
/**
* The theme manager.
*
* @var \Drupal\Core\Theme\ThemeManagerInterface
*/
protected $themeManager;
/**
* The logger service.
*
......@@ -73,23 +37,41 @@ class AiReferenceGenerator {
/**
* Constructs a new AiReferencesGenerator object.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
* The entity type manager.
* @param \Drupal\Core\Render\Renderer $renderer
* The renderer.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config
* The config interface.
* @param \Drupal\Core\Session\AccountInterface $currentUser
* The current user.
* @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $channelFactory
* The logger channel factory.
* @param \Drupal\Core\Theme\ThemeInitializationInterface $themeInitialization
* The theme init.
* @param \Drupal\Core\Theme\ThemeManagerInterface $themeManager
* The theme manager.
* @param \Drupal\ai\AiProviderPluginManager $aiProviderManager
* The AI provider plugin manager.
* @param \Drupal\ai\Utility\TokenizerInterface $tokenizer
* The tokenizer.
* @param \Drupal\ai\Utility\TextChunkerInterface
* The text chunker.
*/
public function __construct(
EntityTypeManagerInterface $entity_type_manager,
RendererInterface $renderer,
ConfigFactoryInterface $config,
AccountInterface $current_user,
LoggerChannelFactoryInterface $channel_factory,
ThemeInitializationInterface $theme_initialization,
ThemeManagerInterface $theme_manager
protected EntityTypeManagerInterface $entityTypeManager,
protected RendererInterface $renderer,
protected ConfigFactoryInterface $config,
protected AccountInterface $currentUser,
protected LoggerChannelFactoryInterface $channelFactory,
protected ThemeInitializationInterface $themeInitialization,
protected ThemeManagerInterface $themeManager,
protected AiProviderPluginManager $aiProviderManager,
protected TokenizerInterface $tokenizer,
protected TextChunkerInterface $textChunker,
) {
$this->entityTypeManager = $entity_type_manager;
$this->renderer = $renderer;
$this->config = $config;
$this->currentUser = $current_user;
$this->logger = $channel_factory->get('ai_auto_reference');
$this->themeInitialization = $theme_initialization;
$this->themeManager = $theme_manager;
$this->logger = $this->channelFactory->get('ai_auto_reference');
}
/**
......@@ -135,20 +117,22 @@ class AiReferenceGenerator {
*/
public function getAiSuggestions(NodeInterface $node, $field_name, $view_mode) {
$config = $this->config->get('ai_auto_reference.settings');
$chat_model_id = $this->aiProviderManager->getModelNameFromSimpleOption($config->get('provider'));
$chat_model_id = $chat_model_id ?: 'gpt-3.5';
$this->textChunker->setModel($chat_model_id);
// Node edit link to use in logs.
$url = Url::fromRoute('entity.node.edit_form', [
'node' => $node->id(),
]);
$node_edit_link = Link::fromTextAndUrl($node->label(), $url)->toString();
try {
// Might be 4000, 8000, 32000 depending on version used and
// beta / waiting list access approval.
$token_limit = $config->get('token_limit');
$possible_results = $this->getFieldAllowedValues($node, $field_name);
$imploded_possible_results = implode(',', $possible_results);
$imploded_possible_results = implode('|', $possible_results);
// Getting current (in most cases – admin) theme.
$active_theme = $this->themeManager->getActiveTheme();
......@@ -161,26 +145,24 @@ class AiReferenceGenerator {
// Get the rendered contents of the node.
$render_output = $render_controller->view($node, $view_mode);
$rendered_output = $this->renderer->renderPlain($render_output);
$contents = strip_tags($rendered_output);
$rendered_output = $this->renderer->renderInIsolation($render_output);
$converter = new HtmlConverter();
$contents = $converter->convert($rendered_output);
$contents = preg_replace('/\s+/S', " ", $contents);
// Switching back to admin theme.
$this->themeManager->setActiveTheme($active_theme);
// For counting the number of tokens.
$tokenizer_config = new Gpt3TokenizerConfig();
$tokenizer = new Gpt3Tokenizer($tokenizer_config);
// Build the prompt to send.
// @todo allow configuration of the prompt.
$prompt = 'For the contents within brackets: ({CONTENTS})';
$prompt .= 'Which two to four of the following | separated options are highly relevant and moderately relevant? [{POSSIBLE_RESULTS}]';
$prompt .= 'Return selections from within the square brackets only and as a valid json array within two array keys "highly" and "moderately" for your relevance';
$prompt = "For the contents within the block:\n```{CONTENTS}\n```\n\n";
$prompt .= "Which two to four of the following `|` separated options are highly relevant and moderately relevant?\n```{POSSIBLE_RESULTS}\n```\n\n";
$prompt .= "Return selections from within the square brackets only and as a valid RFC8259 compliant JSON array within two array keys `highly` and `moderately` without deviation.\n\n";
// If the prompt is longer than the limit, get a summary of the contents.
$prompt_length = $tokenizer->count($prompt);
$possible_results_length = $tokenizer->count($imploded_possible_results);
$contents_length = $tokenizer->count($contents);
$prompt_length = $this->tokenizer->countTokens($prompt);
$possible_results_length = $this->tokenizer->countTokens($imploded_possible_results);
$contents_length = $this->tokenizer->countTokens($contents);
if ($prompt_length + $possible_results_length > $token_limit) {
$this->logger->error('The prompt and possible results alone use more tokens than the token limit for %field_name for %node_edit. The job cannot be completed', [
'%field_name' => $field_name,
......@@ -192,12 +174,13 @@ class AiReferenceGenerator {
$available_contents_tokens = $token_limit - $prompt_length - $possible_results_length;
// @todo allow configuration of the text shortening prompt.
$summary_prompt = 'Shorten this text into a maximum of ' . $available_contents_tokens . ' tokens and minimum of ' . ($available_contents_tokens - 1000) . ' tokens: ';
$summary_prompt_length = $tokenizer->count($summary_prompt);
$summary_prompt_length = $this->tokenizer->countTokens($summary_prompt);
if ($contents_length + $summary_prompt_length > $token_limit) {
// If even this without the possible results is too long, we
// just crop it - no choice.
$content_chunks = $tokenizer->chunk($contents, ($token_limit - $summary_prompt_length));
$max_size = (int) ($token_limit - $summary_prompt_length);
$content_chunks = $this->textChunker->chunkText($contents, $max_size, 0);
$contents = reset($content_chunks);
}
......@@ -205,18 +188,19 @@ class AiReferenceGenerator {
$summary_prompt .= $contents;
// Get the summary back.
$response = $this->aiApiCall($config, $summary_prompt, FALSE);
if (isset($response->choices[0]->message->content)) {
$response = $this->aiApiCall($config, $summary_prompt);
if (!empty($response)) {
// Replace the contents with summarized contents.
$contents = $response->choices[0]->message->content;
$contents = $response;
}
}
// Run replacements in the prompt.
$prompt = str_replace('{POSSIBLE_RESULTS}', $imploded_possible_results, $prompt);
$prompt = str_replace('{CONTENTS}', $contents, $prompt);
// Get the array of 'highly' and 'moderately' related results back.
if ($answer = $this->aiApiCall($config, $prompt, TRUE)) {
if ($answer = $this->aiApiCall($config, $prompt)) {
$answer = Json::decode($answer);
$suggestions['h'] = !empty($answer['highly']) ? array_keys(array_intersect($possible_results, $answer['highly'])) : [];
$suggestions['m'] = !empty($answer['moderately']) ? array_keys(array_intersect($possible_results, $answer['moderately'])) : [];
......@@ -246,7 +230,7 @@ class AiReferenceGenerator {
* @return array
* Array with possible values.
*/
public function getFieldAllowedValues(NodeInterface $node, $field_name) {
public function getFieldAllowedValues(NodeInterface $node, $field_name): array {
if ($field_definition = $node->getFieldDefinition($field_name)) {
$options = $field_definition
->getFieldStorageDefinition()
......@@ -270,40 +254,20 @@ class AiReferenceGenerator {
* AI auto-reference config.
* @param string $prompt
* AI prompt.
* @param bool $should_return_json
* Whether the response should return JSON.
*
* @return string
* AI answer.
*/
public function aiApiCall(ImmutableConfig $config, $prompt, $should_return_json) {
$result = '';
$openai_client = \OpenAI::client($config->get('service_api_key'));
$service_model = $config->get('service_model');
// Default payload applicable to all models.
$payload = [
'model' => $service_model,
'messages' => [
[
'role' => 'user',
'content' => $prompt,
],
],
];
// Request a response in JSON for models that support it, which is anything
// after GPT 4 (eg, turbo, preview, etc).
if (!in_array($service_model, ['gpt-3.5-turbo', 'gpt-4']) && $should_return_json) {
$payload['response_format'] = [
'type' => 'json_object',
];
}
$response = $openai_client->chat()->create($payload);
if (isset($response->choices[0]->message->content)) {
$result = $response->choices[0]->message->content;
}
return $result;
public function aiApiCall(ImmutableConfig $config, string $prompt): string {
$provider = $config->get('provider');
$ai_provider = $this->aiProviderManager->loadProviderFromSimpleOption($provider);
$ai_model = $this->aiProviderManager->getModelNameFromSimpleOption($provider);
$messages = new ChatInput([
new ChatMessage('user', $prompt),
]);
$message = $ai_provider->chat($messages, $ai_model)->getNormalized()->getText();
$message = str_replace(['```json', '```'], '', $message);
return trim($message) ?? '';
}
}
......@@ -74,20 +74,32 @@ class AiReferenceBatch {
*
* @param \Drupal\node\NodeInterface $node
* Node object.
* @param array $ai_autorefernce
* @param array $ai_autoreference
* AI autoreference configuration.
* @param array $context
* Batch context.
*/
public static function batchOperation(NodeInterface $node, array $ai_autorefernce, array &$context) {
public static function batchOperation(
NodeInterface $node,
array $ai_autoreference,
array &$context,
) {
$context['results']['nid'] = $node->id();
$context['message'] = t('Generation autoreferences for %field.', ['%field' => $ai_autorefernce['field_name']]);
$suggestions = \Drupal::service('ai_auto_reference.ai_references_generator')->getAiSuggestions($node, $ai_autorefernce['field_name'], $ai_autorefernce['view_mode']);
$context['message'] = t('Generation autoreferences for %field.', [
'%field' => $ai_autoreference['field_name'],
]);
/** @var \Drupal\ai_auto_reference\AiReferenceGenerator $generator */
$generator = \Drupal::service('ai_auto_reference.ai_references_generator');
$suggestions = $generator->getAiSuggestions(
$node,
$ai_autoreference['field_name'],
$ai_autoreference['view_mode'],
);
$imploded_suggestions = [];
foreach ($suggestions as $relevance => $suggestion_ids) {
$imploded_suggestions[$relevance] = implode(',', $suggestion_ids);
}
$context['results']['query'][$ai_autorefernce['field_name']] = $imploded_suggestions;
$context['results']['query'][$ai_autoreference['field_name']] = $imploded_suggestions;
}
/**
......
......@@ -82,6 +82,9 @@ class AutoReferenceApplyForm extends FormBase {
return $form;
}
// Add CSS.
$form['#attached']['library'][] = 'ai_auto_reference/ai-auto-reference-admin';
$field_definitions = (array) $node->getFieldDefinitions();
foreach ($configuration as $ai_field_config) {
......@@ -91,6 +94,11 @@ class AutoReferenceApplyForm extends FormBase {
'#type' => 'fieldset',
'#collpsible' => FALSE,
'#title' => $this->t('The following relationships have been suggested by the AI tool:'),
'#attributes' => [
'class' => [
'fieldset__ai-auto-reference',
],
],
];
if ($auto_apply) {
$form['container']['#title'] = $this->t('The following relationships have been applied by the AI tool:');
......@@ -137,7 +145,7 @@ class AutoReferenceApplyForm extends FormBase {
// No sense in submit button for auto-apply case.
// Check if node id exists (in case we add a new node)
if (!$auto_apply && $node->id()) {
$form['submit'] = [
$submit = [
'#type' => 'submit',
'#value' => $this->t('Save selections'),
'#weight' => 50,
......@@ -146,6 +154,12 @@ class AutoReferenceApplyForm extends FormBase {
$node->toUrl('edit-form', ['nid' => $node->id()])
)->toString(),
];
if (isset($form['container']['#type']) && $form['container']['#type'] === 'fieldset') {
$form['container']['submit'] = $submit;
}
else {
$form['submit'] = $submit;
}
}
$form['#tree'] = TRUE;
......
......@@ -2,8 +2,12 @@
namespace Drupal\ai_auto_reference\Form;
use Drupal\ai\AiProviderPluginManager;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\TypedConfigManagerInterface;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* The AI Autoreference configurable settings.
......@@ -12,6 +16,35 @@ use Drupal\Core\Form\FormStateInterface;
*/
class SettingsForm extends ConfigFormBase {
/**
* Constructs a \Drupal\system\ConfigFormBase object.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The factory for configuration objects.
* @param \Drupal\Core\Config\TypedConfigManagerInterface|null $typedConfigManager
* The typed config manager.
* @param \Drupal\ai\AiProviderPluginManager $aiProviderManager
* The AI provider manager.
*/
public function __construct(
ConfigFactoryInterface $config_factory,
protected $typedConfigManager,
protected AiProviderPluginManager $aiProviderManager,
) {
parent::__construct($config_factory, $typedConfigManager);
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('config.factory'),
$container->get('config.typed'),
$container->get('ai.provider'),
);
}
/**
* {@inheritdoc}
*/
......@@ -33,33 +66,15 @@ class SettingsForm extends ConfigFormBase {
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$config = $this->config('ai_auto_reference.settings');
$form['service'] = [
$form['provider'] = [
'#type' => 'select',
'#title' => $this->t('AI API service'),
'#description' => $this->t('This is the AI API service to use. Currently only ChatGPT is supported.'),
'#options' => [
'gpt' => $this->t('ChatGPT'),
],
'#default_value' => $config->get('service'),
];
$form['service_model'] = [
'#type' => 'select',
'#title' => $this->t('AI API service model'),
'#description' => $this->t('This is the model within the AI API service to use.'),
'#options' => [
'gpt-3.5-turbo' => $this->t('gpt-3.5-turbo'),
'gpt-4' => $this->t('gpt-4'),
'gpt-4-1106-preview' => $this->t('gpt-4-1106-preview'),
'gpt-4-turbo' => $this->t('gpt-4-turbo'),
],
'#default_value' => $config->get('service_model'),
];
$form['service_api_key'] = [
'#type' => 'password',
'#title' => $this->t('AI API key'),
'#description' => $this->t('This is the API key for the selected service. Leave blank to leave unchanged.'),
'#default_value' => '',
'#title' => $this->t('AI provider'),
'#options' => $this->aiProviderManager->getSimpleProviderModelOptions('chat'),
'#required' => TRUE,
'#default_value' => $this->configuration['provider'] ?? $this->aiProviderManager->getSimpleDefaultProviderOptions('chat'),
'#description' => $this->t('Select which provider to use for this plugin. See the <a href=":link">Provider overview</a> for details about each provider.', [
':link' => '/admin/config/ai/providers',
]),
];
$form['token_limit'] = [
'#type' => 'number',
......@@ -100,19 +115,6 @@ class SettingsForm extends ConfigFormBase {
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
// Each model has its token limits.
$limits = [
'gpt-3.5-turbo' => 4000,
'gpt-4' => 8000,
'gpt-4-1106-preview' => 128000,
'gpt-4-turbo' => 128000,
];
$model = $form_state->getValue('service_model');
$token_limit = $form_state->getValue('token_limit');
if ($token_limit > $limits[$model]) {
$form_state->setErrorByName('token_limit', $this->t('Maximum token limit for selected model is %limit.', ['%limit' => $limits[$model]]));
}
// Ensure at least one of relevance level is applied when
// auto apply suggestions is in place.
if (
......@@ -132,19 +134,10 @@ class SettingsForm extends ConfigFormBase {
$relevance_levels = array_filter($form_state->getValue('auto_apply_relevance_levels'));
$config = $this->config('ai_auto_reference.settings')
->set('service', $form_state->getValue('service'))
->set('service_model', $form_state->getValue('service_model'))
->set('provider', $form_state->getValue('provider'))
->set('token_limit', $form_state->getValue('token_limit'))
->set('auto_apply_suggestions', $form_state->getValue('auto_apply_suggestions'))
->set('auto_apply_relevance_levels', array_keys($relevance_levels));
// Only save API key if there is a new value, otherwise leave
// old value unchanged.
$service_api_key = $form_state->getValue('service_api_key');
if (!empty($service_api_key)) {
$config->set('service_api_key', $service_api_key);
}
$config->save();
}
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment