Fix search outcomes

* Fix similarity order when searching outcomes
* Add support to remove stop words based on the Account language

closes OUT-4704, OUT-4705
flag=improved_outcomes_management

Test plan:
- Search for outcomes in the management screen and find outcomes modal
- Assert the outcomes appear "in the expected order"
- Also test adding stop words in the query an assert it doesnt influence
  in the result

Change-Id: Iecbf37551e40f7e0024a15e90b2d283e56e82cff
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/271715
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Brian Watson <bwatson@instructure.com>
Reviewed-by: Martin Yosifov <martin.yosifov@instructure.com>
Reviewed-by: Marcus Pompeu <marcus.pompeu@instructure.com>
QA-Review: Brian Watson <bwatson@instructure.com>
Product-Review: Ben Friedman <ben.friedman@instructure.com>
This commit is contained in:
Manoel Quirino Neto 2021-08-18 11:15:52 -03:00 committed by Manoel Quirino
parent f25f3df70f
commit e5568b0ed0
2 changed files with 195 additions and 31 deletions

View File

@ -24,9 +24,38 @@ module Outcomes
SHORT_DESCRIPTION = "coalesce(learning_outcomes.short_description, '')" SHORT_DESCRIPTION = "coalesce(learning_outcomes.short_description, '')"
# rubocop:disable Layout/LineLength
# E'<[^>]+>' -> removes html tags # E'<[^>]+>' -> removes html tags
# E'&\\w+;' -> removes html entities # E'&\\w+;' -> removes html entities
DESCRIPTION = "regexp_replace(regexp_replace(coalesce(learning_outcomes.description, ''), E'<[^>]+>', '', 'gi'), E'&\\w+;', ' ', 'gi')" DESCRIPTION = "regexp_replace(regexp_replace(coalesce(learning_outcomes.description, ''), E'<[^>]+>', '', 'gi'), E'&\\w+;', ' ', 'gi')"
# rubocop:enable Layout/LineLength
MAP_CANVAS_POSTGRES_LOCALES = {
"ar" => "arabic", # العربية
"ca" => "spanish", # Català
"da" => "danish", # Dansk
"da-x-k12" => "danish", # Dansk GR/GY
"de" => "german", # Deutsch
"en-AU" => "english", # English (Australia)
"en-CA" => "english", # English (Canada)
"en-GB" => "english", # English (United Kingdom)
"en" => "english", # English (US)
"es" => "spanish", # Español
"fr" => "french", # Français
"fr-CA" => "french", # Français (Canada)
"it" => "italian", # Italiano
"hu" => "hungarian", # Magyar
"nl" => "dutch", # Nederlands
"nb" => "norwegian", # Norsk (Bokmål)
"nb-x-k12" => "norwegian", # Norsk (Bokmål) GS/VGS
"pt" => "portuguese", # Português
"pt-BR" => "portuguese", # Português do Brasil
"ru" => "russian", # pу́сский
"fi" => "finnish", # Suomi
"sv" => "swedish", # Svenska
"sv-x-k12" => "swedish", # Svenska GR/GY
"tr" => "turkish" # Türkçe
}.freeze
def initialize(context = nil) def initialize(context = nil)
@context = context @context = context
@ -45,10 +74,10 @@ module Outcomes
def suboutcomes_by_group_id(learning_outcome_group_id, args={}) def suboutcomes_by_group_id(learning_outcome_group_id, args={})
learning_outcome_groups_ids = children_ids(learning_outcome_group_id) << learning_outcome_group_id learning_outcome_groups_ids = children_ids(learning_outcome_group_id) << learning_outcome_group_id
relation = ContentTag.active.learning_outcome_links. relation = ContentTag.active.learning_outcome_links
where(associated_asset_id: learning_outcome_groups_ids). .where(associated_asset_id: learning_outcome_groups_ids)
joins(:learning_outcome_content). .joins(:learning_outcome_content)
joins("INNER JOIN #{LearningOutcomeGroup.quoted_table_name} AS logs .joins("INNER JOIN #{LearningOutcomeGroup.quoted_table_name} AS logs
ON logs.id = content_tags.associated_asset_id") ON logs.id = content_tags.associated_asset_id")
if args[:search_query] if args[:search_query]
@ -73,13 +102,20 @@ module Outcomes
Rails.cache.delete(context_timestamp_cache_key) if improved_outcomes_management? Rails.cache.delete(context_timestamp_cache_key) if improved_outcomes_management?
end end
def self.supported_languages
# cache this in the class since this won't change so much
@supported_languages ||= ContentTag.connection.execute(
'SELECT cfgname FROM pg_ts_config'
).to_a.map {|r| r['cfgname']}
end
private private
def total_outcomes_for(learning_outcome_group_id, args={}) def total_outcomes_for(learning_outcome_group_id, args={})
learning_outcome_groups_ids = children_ids(learning_outcome_group_id) << learning_outcome_group_id learning_outcome_groups_ids = children_ids(learning_outcome_group_id) << learning_outcome_group_id
relation = ContentTag.active.learning_outcome_links. relation = ContentTag.active.learning_outcome_links
where(associated_asset_id: learning_outcome_groups_ids) .where(associated_asset_id: learning_outcome_groups_ids)
if args[:search_query] if args[:search_query]
relation = relation.joins(:learning_outcome_content) relation = relation.joins(:learning_outcome_content)
@ -90,18 +126,33 @@ module Outcomes
end end
def add_search_query(relation, search_query) def add_search_query(relation, search_query)
search_query_tokens = search_query.split(' ') # Tried to check if the lang is supported in the same query
# using a CASE WHEN but it wont work because it'll
# parse to_tsvector with the not supported lang, and it'll throw an error
short_description_query = ContentTag.sanitize_sql_array(["#{SHORT_DESCRIPTION} ~* ANY(array[?])", search_query_tokens]) sql = if self.class.supported_languages.include?(lang)
ContentTag.sanitize_sql_array([<<~SQL.squish, lang, search_query])
SELECT unnest(tsvector_to_array(to_tsvector(?, ?))) as token
SQL
else
ContentTag.sanitize_sql_array([<<~SQL.squish, search_query])
SELECT unnest(tsvector_to_array(to_tsvector(?))) as token
SQL
end
search_query_tokens = ContentTag.connection.execute(sql).to_a.map {|r| r['token']}.uniq
short_description_query = ContentTag.sanitize_sql_array(["#{SHORT_DESCRIPTION} ~* ANY(array[?])",
search_query_tokens])
description_query = ContentTag.sanitize_sql_array(["#{DESCRIPTION} ~* ANY(array[?])", search_query_tokens]) description_query = ContentTag.sanitize_sql_array(["#{DESCRIPTION} ~* ANY(array[?])", search_query_tokens])
relation.where("#{short_description_query} OR #{description_query}") relation.where("#{short_description_query} OR #{description_query}")
end end
def add_search_order(relation, search_query) def add_search_order(relation, search_query)
select_query = ContentTag.sanitize_sql_array([<<-SQL, search_query, search_query]) select_query = ContentTag.sanitize_sql_array([<<-SQL.squish, search_query, search_query])
"content_tags".*, "content_tags".*,
GREATEST(public.word_similarity(#{SHORT_DESCRIPTION}, ?), public.word_similarity(#{DESCRIPTION}, ?)) as sim GREATEST(public.word_similarity(?, #{SHORT_DESCRIPTION}), public.word_similarity(?, #{DESCRIPTION})) as sim
SQL SQL
relation.select(select_query).order( relation.select(select_query).order(
@ -127,7 +178,7 @@ module Outcomes
end end
def learning_outcome_group_descendants_query def learning_outcome_group_descendants_query
<<-SQL <<-SQL.squish
WITH RECURSIVE levels AS ( WITH RECURSIVE levels AS (
SELECT id, learning_outcome_group_id AS parent_id SELECT id, learning_outcome_group_id AS parent_id
FROM (#{LearningOutcomeGroup.active.where(context: context).to_sql}) AS data FROM (#{LearningOutcomeGroup.active.where(context: context).to_sql}) AS data
@ -163,15 +214,25 @@ module Outcomes
end end
def context_asset_string def context_asset_string
@_context_asset_string ||= (context || LearningOutcomeGroup.global_root_outcome_group).global_asset_string @context_asset_string ||= (context || LearningOutcomeGroup.global_root_outcome_group).global_asset_string
end end
def improved_outcomes_management? def improved_outcomes_management?
@improved_outcomes_management ||= begin @improved_outcomes_management ||= if context
return context.root_account.feature_enabled?(:improved_outcomes_management) if context context.root_account.feature_enabled?(:improved_outcomes_management)
else
LoadAccount.default_domain_root_account.feature_enabled?(:improved_outcomes_management) LoadAccount.default_domain_root_account.feature_enabled?(:improved_outcomes_management)
end end
end end
def lang
# lang can be nil, so we check with instance_variable_defined? method
unless instance_variable_defined?("@lang")
account = context&.root_account || LoadAccount.default_domain_root_account
@lang = MAP_CANVAS_POSTGRES_LOCALES[account.default_locale || "en"]
end
@lang
end
end end
end end

View File

@ -23,7 +23,7 @@ require 'spec_helper'
describe Outcomes::LearningOutcomeGroupChildren do describe Outcomes::LearningOutcomeGroupChildren do
subject { described_class.new(context) } subject { described_class.new(context) }
# rubocop:disable RSpec/LetSetup # rubocop:disable RSpec/LetSetup, Layout/LineLength
let!(:context) { Account.default } let!(:context) { Account.default }
let!(:global_group) { LearningOutcomeGroup.create(title: 'global') } let!(:global_group) { LearningOutcomeGroup.create(title: 'global') }
let!(:global_group_subgroup) { global_group.child_outcome_groups.build(title: 'global subgroup') } let!(:global_group_subgroup) { global_group.child_outcome_groups.build(title: 'global subgroup') }
@ -48,7 +48,7 @@ describe Outcomes::LearningOutcomeGroupChildren do
let!(:o9) { outcome_model(context: context, outcome_group: g6, title:'Outcome 7.1', short_description: 'Outcome 7.1') } let!(:o9) { outcome_model(context: context, outcome_group: g6, title:'Outcome 7.1', short_description: 'Outcome 7.1') }
let!(:o10) { outcome_model(context: context, outcome_group: g6, title:'Outcome 7.2', short_description: 'Outcome 7.2') } let!(:o10) { outcome_model(context: context, outcome_group: g6, title:'Outcome 7.2', short_description: 'Outcome 7.2') }
let!(:o11) { outcome_model(context: context, outcome_group: g6, title:'Outcome 7.3 mathematic', short_description: 'Outcome 7.3 mathematic') } let!(:o11) { outcome_model(context: context, outcome_group: g6, title:'Outcome 7.3 mathematic', short_description: 'Outcome 7.3 mathematic') }
# rubocop:enable RSpec/LetSetup # rubocop:enable RSpec/LetSetup, Layout/LineLength
# Outcome Structure for visual reference # Outcome Structure for visual reference
# Global # Global
@ -151,7 +151,8 @@ describe Outcomes::LearningOutcomeGroupChildren do
describe '#suboutcomes_by_group_id' do describe '#suboutcomes_by_group_id' do
it 'returns the outcomes ordered by parent group title then outcome short_description' do it 'returns the outcomes ordered by parent group title then outcome short_description' do
g_outcomes = subject.suboutcomes_by_group_id(global_group.id).map(&:learning_outcome_content).map(&:short_description) g_outcomes = subject.suboutcomes_by_group_id(global_group.id)
.map(&:learning_outcome_content).map(&:short_description)
expect(g_outcomes).to match_array(['G Outcome 1', 'G Outcome 2']) expect(g_outcomes).to match_array(['G Outcome 1', 'G Outcome 2'])
r_outcomes = subject.suboutcomes_by_group_id(g0.id).map(&:learning_outcome_content).map(&:short_description) r_outcomes = subject.suboutcomes_by_group_id(g0.id).map(&:learning_outcome_content).map(&:short_description)
expect(r_outcomes).to match_array( expect(r_outcomes).to match_array(
@ -226,7 +227,8 @@ describe Outcomes::LearningOutcomeGroupChildren do
subject { described_class.new } subject { described_class.new }
it 'returns global outcomes' do it 'returns global outcomes' do
outcomes = subject.suboutcomes_by_group_id(global_group.id).map(&:learning_outcome_content).map(&:short_description) outcomes = subject.suboutcomes_by_group_id(global_group.id).map(&:learning_outcome_content)
.map(&:short_description)
expect(outcomes).to match_array(['G Outcome 1', 'G Outcome 2']) expect(outcomes).to match_array(['G Outcome 1', 'G Outcome 2'])
end end
end end
@ -258,13 +260,15 @@ describe Outcomes::LearningOutcomeGroupChildren do
outcome_group: g1, outcome_group: g1,
title: "FO.3", title: "FO.3",
description: 'apply their growing knowledge of root words, prefixes and suffixes (etymology and morphology)'\ description: 'apply their growing knowledge of root words, prefixes and suffixes (etymology and morphology)'\
' as listed in English Appendix 1, both to read aloud and to understand the meaning of new words they meet' ' as listed in English Appendix 1, both to read aloud and to understand the meaning of new wor'\
'ds they meet'
) )
outcome_model( outcome_model(
context: context, context: context,
outcome_group: g1, outcome_group: g1,
title: "HT.ML.1.1", title: "HT.ML.1.1",
description: '<p>Pellentesque&nbsp;habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.</p>' description: '<p>Pellentesque&nbsp;habitant morbi tristique senectus et netus et malesuada fames ac turpis e'\
'gestas.</p>'
) )
outcome_model( outcome_model(
context: context, context: context,
@ -275,42 +279,141 @@ describe Outcomes::LearningOutcomeGroupChildren do
end end
it "filters title with non-alphanumerical chars" do it "filters title with non-alphanumerical chars" do
outcomes = subject.suboutcomes_by_group_id(g1.id, {search_query: "LA.1"}).map(&:learning_outcome_content).map(&:short_description) outcomes = subject.suboutcomes_by_group_id(g1.id, {search_query: "LA.1"})
.map(&:learning_outcome_content).map(&:short_description)
expect(outcomes).to eql([ expect(outcomes).to eql([
"LA.1.1.1", "LA.1.1.1.1" "LA.1.1.1", "LA.1.1.1.1"
]) ])
end end
it "filters description with text content" do it "filters description with text content" do
outcomes = subject.suboutcomes_by_group_id(g1.id, {search_query: "knowledge"}).map(&:learning_outcome_content).map(&:short_description) outcomes = subject.suboutcomes_by_group_id(g1.id, {search_query: "knowledge"})
.map(&:learning_outcome_content).map(&:short_description)
expect(outcomes).to eql([ expect(outcomes).to eql([
'LA.1.1.1', 'FO.3' 'FO.3', 'LA.1.1.1'
]) ])
end end
it "filters description with html content" do it "filters description with html content" do
outcomes = subject.suboutcomes_by_group_id(g1.id, {search_query: "Pellentesque"}).map(&:learning_outcome_content).map(&:short_description) outcomes = subject.suboutcomes_by_group_id(g1.id, {search_query: "Pellentesque"})
.map(&:learning_outcome_content).map(&:short_description)
expect(outcomes).to eql([ expect(outcomes).to eql([
'HT.ML.1.1' 'HT.ML.1.1'
]) ])
end end
it "filters more than 1 word" do it "filters more than 1 word" do
outcomes = subject.suboutcomes_by_group_id(g1.id, {search_query: "LA.1.1 Pellentesque"}).map(&:learning_outcome_content).map(&:short_description) outcomes = subject.suboutcomes_by_group_id(g1.id, {search_query: "LA.1.1 Pellentesque"})
.map(&:learning_outcome_content).map(&:short_description)
expect(outcomes).to eql([ expect(outcomes).to eql([
"HT.ML.1.1",
"LA.1.1.1", "LA.1.1.1",
"LA.1.1.1.1", "LA.1.1.1.1"
"HT.ML.1.1"
]) ])
end end
it "filters when words aren't all completed" do it "filters when words aren't all completed" do
outcomes = subject.suboutcomes_by_group_id(g1.id, {search_query: "recog awe"}).map(&:learning_outcome_content).map(&:short_description) outcomes = subject.suboutcomes_by_group_id(g1.id, {search_query: "recog awe"})
.map(&:learning_outcome_content).map(&:short_description)
expect(outcomes).to eql([ expect(outcomes).to eql([
"HT.ML.1.2", "LA.2.2.1.2",
"LA.2.2.1.2" "HT.ML.1.2"
]) ])
end end
context 'when lang is portuguese' do
it "filters outcomes removing portuguese stop words" do
account = context.root_account
account.default_locale = "pt-BR"
account.save!
outcome_model(
context: context,
outcome_group: g1,
title: "will bring",
description: '<p>Um texto <b>portugues</b>.</p>'
)
outcome_model(
context: context,
outcome_group: g1,
title: "won't bring",
description: '<p>Um animal bonito.</p>'
)
outcomes = subject.suboutcomes_by_group_id(
g1.id, {
search_query: "Um portugues"
}
).map(&:learning_outcome_content).map(&:short_description)
expect(outcomes).to eql([
"will bring"
])
end
context 'when context is nil' do
subject { described_class.new }
it 'filters outcomes removing portuguese stop words' do
account = Account.default
account.default_locale = "pt-BR"
account.save!
outcome_model(
global: true,
title: "will bring",
description: '<p>Um texto <b>portugues</b>.</p>'
)
outcome_model(
global: true,
title: "won't bring",
description: '<p>Um animal bonito.</p>'
)
outcomes = subject.suboutcomes_by_group_id(
LearningOutcomeGroup.find_or_create_root(nil, true).id, {
search_query: "Um portugues"
}
).map(&:learning_outcome_content).map(&:short_description)
expect(outcomes).to eql([
"will bring"
])
end
end
end
context 'when lang is not supported' do
before do
account = context.root_account
account.default_locale = "pl" # polski
account.save!
end
it "filters outcomes normally" do
outcome_model(
context: context,
outcome_group: g1,
title: "will bring",
description: '<p>Um texto <b>portugues</b>.</p>'
)
outcome_model(
context: context,
outcome_group: g1,
title: "will bring too",
description: '<p>Um animal bonito.</p>'
)
outcomes = subject.suboutcomes_by_group_id(
g1.id, {search_query: "Um portugues"}
).map(&:learning_outcome_content).map(&:short_description)
expect(outcomes).to eql([
"will bring",
"will bring too"
])
end
end
end end
end end