Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UHF-9962 recommendations algorithm #626

Merged
merged 23 commits into from
May 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
73b5724
UHF-9962: get recommended news
rpnykanen May 27, 2024
fcb640d
UHF-9962: added block config to config folder, added check for curren…
rpnykanen May 27, 2024
bc1bd1c
UHF-9962: put cache tag in array
rpnykanen May 27, 2024
b11acb5
UHF-9962: inject logger
rpnykanen May 27, 2024
b794a6f
UHF-9962: handle possible exception
rpnykanen May 27, 2024
4d99232
UHF-9962: added template and theme hook
rpnykanen May 27, 2024
b9730cb
UHF-9962: code fixes
rpnykanen May 27, 2024
3861c05
UHF-9962: code fixes
rpnykanen May 27, 2024
0a1551a
UHF-9962: wrong intendation
rpnykanen May 27, 2024
1516762
UHF-9962: phpstan fixes
rpnykanen May 27, 2024
5a2329c
UHF-9962: themes for the pilot. show 3 best hits
rpnykanen May 27, 2024
52950a0
UHF-9962: initially, keep the aipowered block disabled. also updated …
rpnykanen May 27, 2024
c261cf7
UHF-9962: add langcode to query
rpnykanen May 27, 2024
5b5bdd8
UHF-9962: proper way to use autowire for recommendation manager
rpnykanen May 27, 2024
38f36a0
UHF-9962: remove empty lines
rpnykanen May 28, 2024
78c63d4
UHF-9962: phpstan error
rpnykanen May 28, 2024
2bd3575
UHF-9962: try fixing assert error
rpnykanen May 28, 2024
622c1fe
UHF-9962: fix type-error when keywords is null
rpnykanen May 28, 2024
21fdbcc
UHF-9962: use entitycontextdefinition instead of contentdefinition
rpnykanen May 28, 2024
795e72c
UHF-9962: add spaces
rpnykanen May 28, 2024
266139c
UHF-9962: added getcachetags, removed useless try catch since the req…
rpnykanen May 28, 2024
e230169
UHF-9962: missing docblock
rpnykanen May 28, 2024
17c59b2
UHF-9962: unused use statement
rpnykanen May 28, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions conf/cmi/block.block.hdbt_subtheme_aipoweredrecommendations.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
uuid: 6ab1786a-6f04-44a4-afe2-b7c9829fd000
langcode: en
status: false
dependencies:
module:
- helfi_annif
- language
- node
theme:
- hdbt_subtheme
id: hdbt_subtheme_aipoweredrecommendations
theme: hdbt_subtheme
region: after_content
weight: -12
provider: null
plugin: helfi_recommendations
settings:
id: helfi_recommendations
label: 'AI powered recommendations'
label_display: visible
provider: helfi_annif
context_mapping:
node: '@node.node_route_context:node'
visibility:
language:
id: language
negate: false
context_mapping:
language: '@language.current_language_context:language_interface'
langcodes:
fi: fi
sv: sv
en: en
'entity_bundle:node':
id: 'entity_bundle:node'
negate: false
context_mapping:
node: '@node.node_route_context:node'
bundles:
news_item: news_item
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
uuid: 6ab1786a-6f04-44a4-afe2-b7c9829fd000
langcode: en
status: true
dependencies:
module:
- helfi_annif
- language
- node
theme:
- hdbt_subtheme
id: hdbt_subtheme_aipoweredrecommendations
theme: hdbt_subtheme
region: after_content
weight: -12
provider: null
plugin: helfi_recommendations
settings:
id: helfi_recommendations
label: 'AI powered recommendations'
label_display: visible
provider: helfi_annif
context_mapping:
node: '@node.node_route_context:node'
visibility:
language:
id: language
negate: false
context_mapping:
language: '@language.current_language_context:language_interface'
langcodes:
fi: fi
sv: sv
en: en
'entity_bundle:node':
id: 'entity_bundle:node'
negate: false
context_mapping:
node: '@node.node_route_context:node'
bundles:
news_item: news_item
14 changes: 14 additions & 0 deletions public/modules/custom/helfi_annif/helfi_annif.module
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\helfi_annif\KeywordManager;
use Drupal\helfi_annif\TextConverter\Document;

/**
* Implements hook_theme().
*/
function helfi_annif_theme() : array {
return [
'recommendations_block' => [
'variables' => [
'rows' => NULL,
],
'template' => 'recommendations-block',
],
];
}

/**
* Implements hook_entity_insert().
*/
Expand Down
4 changes: 4 additions & 0 deletions public/modules/custom/helfi_annif/helfi_annif.services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ services:
autowire: true
autoconfigure: true

logger.channel.helfi_annif:
parent: logger.channel_base
arguments: ['helfi_annif']

Drupal\helfi_annif\KeywordManager: ~

Drupal\helfi_annif\RecommendationManager: ~
Expand Down
3 changes: 2 additions & 1 deletion public/modules/custom/helfi_annif/src/KeywordManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ public function processEntity(EntityInterface $entity, bool $overwriteExisting =
return;
}

$this->saveKeywords($entity, $this->keywordGenerator->suggest($entity));
$keywords = $this->keywordGenerator->suggest($entity) ?? [];
$this->saveKeywords($entity, $keywords);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,105 @@

use Drupal\Core\Block\Attribute\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Plugin\Context\EntityContextDefinition;
use Drupal\Core\Plugin\ContextAwarePluginInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\helfi_annif\RecommendationManager;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
* Provides 'AI powered recommendations'.
*/
#[Block(
id: "helfi_recommendations",
admin_label: new TranslatableMarkup("AI powered recommendations"),
context_definitions: [
'node' => new EntityContextDefinition(
data_type: 'node',
label: new TranslatableMarkup('Node'),
required: TRUE,
),
]
)]
class RecommendationsBlock extends BlockBase {
final class RecommendationsBlock extends BlockBase implements ContainerFactoryPluginInterface, ContextAwarePluginInterface {

public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
private readonly RecommendationManager $recommendationManager,
private readonly AccountInterface $currentUser,
private readonly LoggerInterface $logger,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
}

/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) : static {
return new static($configuration, $plugin_id, $plugin_definition,
$container->get(RecommendationManager::class),
$container->get('current_user'),
$container->get('logger.channel.helfi_annif'),
);
}

/**
* {@inheritdoc}
*/
public function build() : array {
// @todo UHF-9962.
return [
'#markup' => $this->t('Hello, World!'),
$node = $this->getContextValue('node');

// @todo #UHF-9964 Lisätään suosittelulohkon piilotustoiminto.
$response = [
'#theme' => 'recommendations_block',
'#title' => $this->t('You might be interested in'),
];

$recommendations = $this->recommendationManager->getRecommendations($node);
if (!$recommendations) {
return $this->handleNoRecommendations($response);
}

$response['#rows'] = $recommendations;
return $response;
}

/**
* {@inheritdoc}
*/
public function getCacheContexts(): array {
return Cache::mergeContexts(parent::getCacheContexts(), ['languages:language_content']);
}

/**
* {@inheritdoc}
*/
public function getCacheTags(): array {
return Cache::mergeTags(parent::getCacheTags(), $this->getContextValue('node')->getCacheTags());
}

/**
* Return response when recommendations are not yet calculated.
*
* @param array $response
* Render array.
*
* @return array
* Render array.
*/
private function handleNoRecommendations(array $response): array {
if ($this->currentUser->isAnonymous()) {
return [];
}

$response['#no_results_message'] = $this->t('Calculating recommendations');
return $response;
}

}
86 changes: 83 additions & 3 deletions public/modules/custom/helfi_annif/src/RecommendationManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,90 @@

namespace Drupal\helfi_annif;

use Drupal\Core\Database\Connection;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;

/**
* The recommendation manager.
*
* @todo UHF-9962.
*/
class RecommendationManager {
class RecommendationManager implements LoggerAwareInterface {

use LoggerAwareTrait;

/**
* The constructor.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityManager
* The entity type manager.
* @param \Drupal\Core\Database\Connection $connection
* The connection.
*/
public function __construct(
private readonly EntityTypeManagerInterface $entityManager,
private readonly Connection $connection,
) {
}

/**
* Get recommendations for a node.
*
* @param \Drupal\Core\Entity\EntityInterface $node
* The node.
*
* @return array
* Array of recommendations.
*/
public function getRecommendations(EntityInterface $node): array {
// @todo #UHF-9964 exclude unwanted keywords and entities and refactor.
$query = "
select
n.nid,
count(n.nid) as relevancy
from node as n
left join node__field_annif_keywords as annif on n.nid = annif.entity_id
where annif.field_annif_keywords_target_id in
(select
field_annif_keywords_target_id
from node__field_annif_keywords
where entity_id = :nid and
langcode = :langcode)
and n.langcode = :langcode
and annif.langcode = :langcode
and n.nid != :nid
group by n.nid
order by relevancy DESC
limit 3;
";

$response = [];
try {
$results = $this->connection->query($query, [':nid' => $node->id(), ':langcode' => $node->language()->getId()])->fetchAll();
}
catch (\Exception $e) {
$this->logger->error($e->getMessage());
return $response;
}

if (!$results || !is_array($results)) {
return $response;
}

$nids = array_column($results, 'nid');

try {
$response = $this->entityManager
->getStorage($node->getEntityTypeId())
->loadMultiple($nids);
}
catch (\Exception $e) {
$this->logger->error($e->getMessage());
return [];
}

return $response;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{% for row in rows %}
{# Created date and modified date #}
{% set published_at = row.published_at.value %}
{% if published_at is not empty %}
{% set html_published_at %}
<time datetime="{{ published_at|format_date('custom', 'Y-m-d') ~ 'T' ~ published_at|format_date('custom', 'H:i') }}" class="news-listing__datetime news-listing__datetime--published" {{ alternative_language ? create_attribute(({ 'lang': lang_attributes.fallback_lang, 'dir': lang_attributes.fallback_dir })) }}>
<span class="visually-hidden">{{ 'Published'|t({}, {'context': 'The helper text before the node published timestamp'}) }}</span>
{{ published_at|format_date('publication_date_format') }}
</time>
{% endset %}
{% else %}
{% set html_published_at = '-' %}
{% endif %}

{% set card_url = url('entity.node.canonical', {'node': row.id})['#markup'] %}

{% embed '@hdbt/component/card.twig' with {
card_modifier_class: 'news-listing__item',
card_title_level: 'h3',
card_title: row.title.value,
card_url: card_url,
card_metas: [
{
icon: 'clock',
label: 'Published'|t({}, {'context': 'Label for news card published time'}),
content: html_published_at
},
],
} %}
{% endembed %}
{% endfor %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{% block paragraph %}
{% embed "@hdbt/misc/component.twig" with
{
component_classes: [ 'component--recommendations' ],
component_title: 'You might be interested in'|t({}, {'context': 'Front page recommendations block title'}),
use_component_title_lang_fallback: alternative_language ?? false,
component_content_class: 'recommendations',
}
%}
{% block component_content %}
{{ content }}
{% endblock component_content %}
{% endembed %}
{% endblock paragraph %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<ul class="news-listing news-listing--latest-tiny-teasers">
{% for row in content.rows %}
<li class="news-listing__item">
{{- row.content -}}
</li>
{% endfor %}
</ul>