| // Copyright 2019 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "ui/accessibility/ax_language_detection.h" |
| |
| #include <algorithm> |
| #include <functional> |
| #include <memory> |
| |
| #include "base/command_line.h" |
| #include "base/i18n/unicodestring.h" |
| #include "base/metrics/histogram_functions.h" |
| #include "base/metrics/histogram_macros.h" |
| #include "base/strings/utf_string_conversions.h" |
| #include "base/trace_event/trace_event.h" |
| #include "ui/accessibility/accessibility_features.h" |
| #include "ui/accessibility/accessibility_switches.h" |
| #include "ui/accessibility/ax_enums.mojom.h" |
| #include "ui/accessibility/ax_node.h" |
| #include "ui/accessibility/ax_tree.h" |
| |
| namespace ui { |
| |
| namespace { |
| // This is the maximum number of languages we assign per page, so only the top |
| // 3 languages on the top will be assigned to any node. |
| const int kMaxDetectedLanguagesPerPage = 3; |
| |
| // This is the maximum number of languages that cld3 will detect for each |
| // input we give it, 3 was recommended to us by the ML team as a good |
| // starting point. |
| const int kMaxDetectedLanguagesPerSpan = 3; |
| |
| const int kShortTextIdentifierMinByteLength = 1; |
| // TODO(crbug.com/41463459): Determine appropriate value for |
| // |kShortTextIdentifierMaxByteLength|. |
| const int kShortTextIdentifierMaxByteLength = 1000; |
| } // namespace |
| |
| using Result = chrome_lang_id::NNetLanguageIdentifier::Result; |
| using SpanInfo = chrome_lang_id::NNetLanguageIdentifier::SpanInfo; |
| |
| AXLanguageInfo::AXLanguageInfo() = default; |
| AXLanguageInfo::~AXLanguageInfo() = default; |
| |
| AXLanguageInfoStats::AXLanguageInfoStats() |
| : top_results_valid_(false), |
| disable_metric_clearing_(false), |
| count_detection_attempted_(0), |
| count_detection_results_(0), |
| count_labelled_(0), |
| count_labelled_with_top_result_(0), |
| count_overridden_(0) {} |
| |
| AXLanguageInfoStats::~AXLanguageInfoStats() = default; |
| |
| void AXLanguageInfoStats::Add(const std::vector<std::string>& languages) { |
| // Count this as a successful detection with results. |
| ++count_detection_results_; |
| |
| // Assign languages with higher probability a higher score. |
| // TODO(chrishall): consider more complex scoring |
| int score = kMaxDetectedLanguagesPerSpan; |
| for (const auto& lang : languages) { |
| lang_counts_[lang] += score; |
| |
| // Record the highest scoring detected languages for each node. |
| if (score == kMaxDetectedLanguagesPerSpan) |
| unique_top_lang_detected_.insert(lang); |
| |
| --score; |
| } |
| |
| InvalidateTopResults(); |
| } |
| |
| int AXLanguageInfoStats::GetScore(const std::string& lang) const { |
| const auto& lang_count_it = lang_counts_.find(lang); |
| if (lang_count_it == lang_counts_.end()) { |
| return 0; |
| } |
| return lang_count_it->second; |
| } |
| |
| void AXLanguageInfoStats::InvalidateTopResults() { |
| top_results_valid_ = false; |
| } |
| |
| // Check if a given language is within the top results. |
| bool AXLanguageInfoStats::CheckLanguageWithinTop(const std::string& lang) { |
| if (!top_results_valid_) { |
| GenerateTopResults(); |
| } |
| |
| for (const auto& item : top_results_) { |
| if (lang == item.second) { |
| return true; |
| } |
| } |
| |
| return false; |
| } |
| |
| void AXLanguageInfoStats::GenerateTopResults() { |
| top_results_.clear(); |
| |
| for (const auto& item : lang_counts_) { |
| top_results_.emplace_back(item.second, item.first); |
| } |
| |
| // Since we store the pair as (score, language) the default operator> on pairs |
| // does our sort appropriately. |
| // Sort in descending order. |
| std::sort(top_results_.begin(), top_results_.end(), std::greater<>()); |
| |
| // Resize down to remove all values greater than the N we are considering. |
| // TODO(chrishall): In the event of a tie, we want to include more than N. |
| top_results_.resize(kMaxDetectedLanguagesPerPage); |
| |
| top_results_valid_ = true; |
| } |
| |
| void AXLanguageInfoStats::RecordLabelStatistics( |
| const std::string& labelled_lang, |
| const std::string& author_lang, |
| bool labelled_with_first_result) { |
| // Count the number of nodes we labelled, and the number we labelled with |
| // our highest confidence result. |
| ++count_labelled_; |
| |
| if (labelled_with_first_result) |
| ++count_labelled_with_top_result_; |
| |
| // Record if we assigned a language that disagrees with the author |
| // provided language for that node. |
| if (author_lang != labelled_lang) |
| ++count_overridden_; |
| } |
| |
| void AXLanguageInfoStats::RecordDetectionAttempt() { |
| ++count_detection_attempted_; |
| } |
| |
| void AXLanguageInfoStats::ReportMetrics() { |
| // Only report statistics for pages which had detected results. |
| if (!count_detection_attempted_) |
| return; |
| |
| // 50 buckets exponentially covering the range from 1 to 1000. |
| base::UmaHistogramCustomCounts( |
| "Accessibility.LanguageDetection.CountDetectionAttempted", |
| count_detection_attempted_, 1, 1000, 50); |
| |
| int percentage_detected = |
| count_detection_results_ * 100 / count_detection_attempted_; |
| base::UmaHistogramPercentageObsoleteDoNotUse( |
| "Accessibility.LanguageDetection.PercentageLanguageDetected", |
| percentage_detected); |
| |
| // 50 buckets exponentially covering the range from 1 to 1000. |
| base::UmaHistogramCustomCounts( |
| "Accessibility.LanguageDetection.CountLabelled", count_labelled_, 1, 1000, |
| 50); |
| |
| // If no nodes were labelled, then the percentage labelled with the top result |
| // doesn't make sense to report. |
| if (count_labelled_) { |
| int percentage_top = |
| count_labelled_with_top_result_ * 100 / count_labelled_; |
| base::UmaHistogramPercentageObsoleteDoNotUse( |
| "Accessibility.LanguageDetection.PercentageLabelledWithTop", |
| percentage_top); |
| |
| int percentage_overridden = count_overridden_ * 100 / count_labelled_; |
| base::UmaHistogramPercentageObsoleteDoNotUse( |
| "Accessibility.LanguageDetection.PercentageOverridden", |
| percentage_overridden); |
| } |
| |
| // Exact count from 0 to 15, overflow is then truncated to 15. |
| base::UmaHistogramExactLinear("Accessibility.LanguageDetection.LangsPerPage", |
| unique_top_lang_detected_.size(), 15); |
| |
| // TODO(chrishall): Consider adding timing metrics for performance, consider: |
| // - detect step. |
| // - label step. |
| // - total initial static detection & label timing. |
| // - total incremental dynamic detection & label timing. |
| |
| // Reset statistics for metrics. |
| ClearMetrics(); |
| } |
| |
| void AXLanguageInfoStats::ClearMetrics() { |
| // Do not clear metrics if we are specifically testing metrics. |
| if (disable_metric_clearing_) |
| return; |
| |
| unique_top_lang_detected_.clear(); |
| count_detection_attempted_ = 0; |
| count_detection_results_ = 0; |
| count_labelled_ = 0; |
| count_labelled_with_top_result_ = 0; |
| count_overridden_ = 0; |
| } |
| |
| AXLanguageDetectionManager::AXLanguageDetectionManager(AXTree* tree) |
| : short_text_language_identifier_(kShortTextIdentifierMinByteLength, |
| kShortTextIdentifierMaxByteLength), |
| tree_(tree) {} |
| |
| AXLanguageDetectionManager::~AXLanguageDetectionManager() = default; |
| |
| bool AXLanguageDetectionManager::IsStaticLanguageDetectionEnabled() { |
| // Static language detection can be enabled by either: |
| // 1) The general language detection feature flag which gates both static and |
| // dynamic language detection (feature flag for experiment), or |
| // 2) The Static specific flag (user controlled switch). |
| return features::IsAccessibilityLanguageDetectionEnabled() || |
| ::switches::IsExperimentalAccessibilityLanguageDetectionEnabled(); |
| } |
| |
| bool AXLanguageDetectionManager::IsDynamicLanguageDetectionEnabled() { |
| // Dynamic language detection can be enabled by either: |
| // 1) The general language detection feature flag which gates both static and |
| // dynamic language detection (feature flag for experiment), or |
| // 2) The Dynamic specific flag (user controlled switch). |
| return features::IsAccessibilityLanguageDetectionEnabled() || |
| ::switches:: |
| IsExperimentalAccessibilityLanguageDetectionDynamicEnabled(); |
| } |
| |
| void AXLanguageDetectionManager::RegisterLanguageDetectionObserver() { |
| // Do not perform dynamic language detection unless explicitly enabled. |
| if (!IsDynamicLanguageDetectionEnabled()) { |
| return; |
| } |
| |
| // Construct our new Observer as requested. |
| // If there is already an Observer on this Manager then this will destroy it. |
| language_detection_observer_ = |
| std::make_unique<AXLanguageDetectionObserver>(tree_); |
| } |
| |
| // Detect languages for each node. |
| void AXLanguageDetectionManager::DetectLanguages() { |
| TRACE_EVENT0("accessibility", "AXLanguageInfo::DetectLanguages"); |
| |
| if (!IsStaticLanguageDetectionEnabled()) { |
| return; |
| } |
| |
| DetectLanguagesForSubtree(tree_->root()); |
| } |
| |
| // Detect languages for a subtree rooted at the given subtree_root. |
| // Will not check feature flag. |
| void AXLanguageDetectionManager::DetectLanguagesForSubtree( |
| AXNode* subtree_root) { |
| // Only perform detection for kStaticText nodes. |
| // |
| // Do not visit the children of kStaticText nodes as they don't have |
| // interesting children for language detection. |
| // |
| // Since kInlineTextBox(es) contain text from their parent, any detection on |
| // them is redundant. Instead they can inherit the detected language. |
| if (subtree_root->GetRole() == ax::mojom::Role::kStaticText) { |
| DetectLanguagesForNode(subtree_root); |
| } else { |
| // Otherwise, recurse into children for detection. |
| for (AXNode* child : subtree_root->children()) { |
| DetectLanguagesForSubtree(child); |
| } |
| } |
| } |
| |
| // Detect languages for a single node. |
| // Will not descend into children. |
| // Will not check feature flag. |
| void AXLanguageDetectionManager::DetectLanguagesForNode(AXNode* node) { |
| // Count this detection attempt. |
| lang_info_stats_.RecordDetectionAttempt(); |
| |
| // TODO(chrishall): implement strategy for nodes which are too small to get |
| // reliable language detection results. Consider combination of |
| // concatenation and bubbling up results. |
| auto text = node->GetStringAttribute(ax::mojom::StringAttribute::kName); |
| |
| // FindTopNMostFreqLangs() will pad the results with |
| // |NNetLanguageIdentifier::kUnknown| in order to reach the requested number |
| // of languages, this means we cannot rely on the results' length and we |
| // have to filter the results. |
| const std::vector<Result> results = |
| language_identifier_.FindTopNMostFreqLangs(text, |
| kMaxDetectedLanguagesPerSpan); |
| |
| std::vector<std::string> reliable_results; |
| |
| for (const auto& res : results) { |
| // The output of FindTopNMostFreqLangs() is already sorted by byte count, |
| // this seems good enough for now. |
| // Only consider results which are 'reliable', this will also remove |
| // 'unknown'. |
| if (res.is_reliable) { |
| reliable_results.push_back(res.language); |
| } |
| } |
| |
| // Only allocate a(n) LanguageInfo if we have results worth keeping. |
| if (reliable_results.size()) { |
| AXLanguageInfo* lang_info = node->GetLanguageInfo(); |
| if (lang_info) { |
| // Clear previously detected and labelled languages. |
| lang_info->detected_languages.clear(); |
| lang_info->language.clear(); |
| } else { |
| node->SetLanguageInfo(std::make_unique<AXLanguageInfo>()); |
| lang_info = node->GetLanguageInfo(); |
| } |
| |
| // Keep these results. |
| lang_info->detected_languages = std::move(reliable_results); |
| |
| // Update statistics to take these results into account. |
| lang_info_stats_.Add(lang_info->detected_languages); |
| } |
| } |
| |
| // Label languages for each node. This relies on DetectLanguages having already |
| // been run. |
| void AXLanguageDetectionManager::LabelLanguages() { |
| TRACE_EVENT0("accessibility", "AXLanguageInfo::LabelLanguages"); |
| |
| if (!IsStaticLanguageDetectionEnabled()) { |
| return; |
| } |
| |
| LabelLanguagesForSubtree(tree_->root()); |
| |
| // TODO(chrishall): consider refactoring to have a more clearly named entry |
| // point for static language detection. |
| // |
| // LabelLanguages is only called for the initial run of language detection for |
| // static content, this call to ReportMetrics therefore covers only the work |
| // we performed in response to a page load complete event. |
| lang_info_stats_.ReportMetrics(); |
| } |
| |
| // Label languages for each node in the subtree rooted at the given |
| // subtree_root. Will not check feature flag. |
| void AXLanguageDetectionManager::LabelLanguagesForSubtree( |
| AXNode* subtree_root) { |
| LabelLanguagesForNode(subtree_root); |
| |
| // Recurse into children to continue labelling. |
| for (AXNode* child : subtree_root->children()) { |
| LabelLanguagesForSubtree(child); |
| } |
| } |
| |
| // Label languages for a single node. |
| // Will not descend into children. |
| // Will not check feature flag. |
| void AXLanguageDetectionManager::LabelLanguagesForNode(AXNode* node) { |
| AXLanguageInfo* lang_info = node->GetLanguageInfo(); |
| if (!lang_info) |
| return; |
| |
| // There is no work to do if we already have an assigned (non-empty) language. |
| if (lang_info->language.size()) |
| return; |
| |
| // Assign the highest probability language which is both: |
| // 1) reliably detected for this node, and |
| // 2) one of the top (kMaxDetectedLanguagesPerPage) languages on this page. |
| // |
| // This helps guard against false positives for nodes which have noisy |
| // language detection results in isolation. |
| // |
| // Note that we assign a language even if it is the same as the author's |
| // annotation. This may not be needed in practice. In theory this would help |
| // if the author later on changed the language annotation to be incorrect, but |
| // this seems unlikely to occur in practice. |
| // |
| // TODO(chrishall): consider optimisation: only assign language if it |
| // disagrees with author's language annotation. |
| bool labelled_with_first_result = true; |
| for (const auto& lang : lang_info->detected_languages) { |
| if (lang_info_stats_.CheckLanguageWithinTop(lang)) { |
| lang_info->language = lang; |
| |
| const std::string& author_lang = node->GetInheritedStringAttribute( |
| ax::mojom::StringAttribute::kLanguage); |
| lang_info_stats_.RecordLabelStatistics(lang, author_lang, |
| labelled_with_first_result); |
| |
| // After assigning a label we no longer need detected languages. |
| // NB: clearing this invalidates the reference `lang`, so we must do this |
| // last and then immediately return. |
| lang_info->detected_languages.clear(); |
| |
| return; |
| } |
| labelled_with_first_result = false; |
| } |
| |
| // If we didn't label a language, then we can discard all language detection |
| // information for this node. |
| node->ClearLanguageInfo(); |
| } |
| |
| std::vector<AXLanguageSpan> |
| AXLanguageDetectionManager::GetLanguageAnnotationForStringAttribute( |
| const AXNode& node, |
| ax::mojom::StringAttribute attr) { |
| std::vector<AXLanguageSpan> language_annotation; |
| if (!node.HasStringAttribute(attr)) |
| return language_annotation; |
| |
| std::string attr_value = node.GetStringAttribute(attr); |
| |
| // Use author-provided language if present. |
| if (node.HasStringAttribute(ax::mojom::StringAttribute::kLanguage)) { |
| // Use author-provided language if present. |
| language_annotation.push_back(AXLanguageSpan{ |
| 0 /* start_index */, |
| static_cast<int>(attr_value.length()) /* end_index */, |
| node.GetStringAttribute( |
| ax::mojom::StringAttribute::kLanguage) /* language */, |
| 1 /* probability */}); |
| return language_annotation; |
| } |
| // Calculate top 3 languages. |
| // TODO(akihiroota): What's a reasonable number of languages to have |
| // cld_3 find? Should vary. |
| std::vector<Result> top_languages = |
| short_text_language_identifier_.FindTopNMostFreqLangs( |
| attr_value, kMaxDetectedLanguagesPerPage); |
| // Create vector of AXLanguageSpans. |
| for (const auto& result : top_languages) { |
| const std::vector<SpanInfo>& ranges = result.byte_ranges; |
| for (const auto& span_info : ranges) { |
| language_annotation.push_back( |
| AXLanguageSpan{span_info.start_index, span_info.end_index, |
| result.language, span_info.probability}); |
| } |
| } |
| // Sort Language Annotations by increasing start index. LanguageAnnotations |
| // with lower start index should appear earlier in the vector. |
| std::sort( |
| language_annotation.begin(), language_annotation.end(), |
| [](const AXLanguageSpan& left, const AXLanguageSpan& right) -> bool { |
| return left.start_index < right.start_index; |
| }); |
| // Ensure that AXLanguageSpans do not overlap. |
| for (size_t i = 0; i < language_annotation.size(); ++i) { |
| if (i > 0) { |
| DCHECK(language_annotation[i].start_index <= |
| language_annotation[i - 1].end_index); |
| } |
| } |
| return language_annotation; |
| } |
| |
| AXLanguageDetectionObserver::AXLanguageDetectionObserver(AXTree* tree) { |
| // We expect the feature flag to have be checked before this Observer is |
| // constructed, this should have been checked by |
| // RegisterLanguageDetectionObserver. |
| DCHECK(AXLanguageDetectionManager::IsDynamicLanguageDetectionEnabled()); |
| |
| observation_.Observe(tree); |
| } |
| |
| AXLanguageDetectionObserver::~AXLanguageDetectionObserver() = default; |
| |
| void AXLanguageDetectionObserver::OnAtomicUpdateFinished( |
| AXTree* tree, |
| bool root_changed, |
| const std::vector<Change>& changes) { |
| // TODO(chrishall): We likely want to re-consider updating or resetting |
| // AXLanguageInfoStats over time to better support detection on long running |
| // pages. |
| |
| // TODO(chrishall): To support pruning deleted node data from stats we should |
| // consider implementing OnNodeWillBeDeleted. Other options available include: |
| // 1) move lang info from AXNode into a map on AXTree so that we can fetch |
| // based on id in here |
| // 2) AXLanguageInfo destructor could remove itself |
| |
| // TODO(chrishall): Possible optimisation: only run detect/label for certain |
| // change.type(s)), at least NODE_CREATED, NODE_CHANGED, and SUBTREE_CREATED. |
| |
| DCHECK(tree->language_detection_manager); |
| |
| // Perform Detect and Label for each node changed or created. |
| // We currently only consider nodes with a role of kStaticText for detection. |
| // |
| // Note that language inheritance is now handled by AXNode::GetLanguage. |
| // |
| // Note that since Label no longer handles language inheritance, we only need |
| // to call Label and Detect on the nodes that changed and don't need to |
| // recurse. |
| // |
| // We do this in two passes because Detect updates page level statistics which |
| // are later used by Label in order to make more accurate decisions. |
| |
| for (auto& change : changes) { |
| if (change.node->GetRole() == ax::mojom::Role::kStaticText) { |
| tree->language_detection_manager->DetectLanguagesForNode(change.node); |
| } |
| } |
| |
| for (auto& change : changes) { |
| if (change.node->GetRole() == ax::mojom::Role::kStaticText) { |
| tree->language_detection_manager->LabelLanguagesForNode(change.node); |
| } |
| } |
| |
| // OnAtomicUpdateFinished is used for dynamic language detection, this call to |
| // ReportMetrics covers only the work we have performed in response to one |
| // update to the AXTree. |
| tree->language_detection_manager->lang_info_stats_.ReportMetrics(); |
| } |
| |
| } // namespace ui |