Current section
Files
Jump to
Current section
Files
lib/word_cloud/tokens.ex
defmodule Text.WordCloud.Tokens do
@moduledoc false
# Shared tokenization helpers for `Text.WordCloud` backends. Internal
# API — not part of the public surface.
#
# Centralises the `text → sentences → words → folded → stopword-filtered`
# pipeline so every backend works against a consistent token stream.
# YAKE additionally needs sentence boundaries (the "different sentence"
# feature), and TextRank needs the unfiltered token stream to build a
# co-occurrence graph that respects stopword positions, so we expose
# both shapes from the same call.
alias Text.Segment
@type sentence_tokens :: [String.t()]
@type prepared :: %{
sentences: [sentence_tokens()],
tokens: [String.t()],
stopwords: MapSet.t(String.t())
}
@doc """
Tokenises `input` and applies the orchestrator's pre-processing.
Returns a map with three keys:
* `:sentences` — list of sentence-grouped token lists, after folding
and stopword filtering.
* `:tokens` — flat list of all (folded, filtered) tokens.
* `:stopwords` — the resolved stopword set, useful to backends that
treat stopwords as phrase boundaries rather than dropping them
outright.
Accepts a binary or a list of binaries. List input is treated as a
corpus of independent documents; sentences from later documents
follow those of earlier ones in the output.
"""
@spec prepare(String.t() | [String.t()], keyword()) :: prepared()
def prepare(input, options) when is_binary(input), do: prepare([input], options)
def prepare(documents, options) when is_list(documents) do
case_fold? = Keyword.get(options, :case_fold, true)
locale = Keyword.get(options, :locale)
stopwords = resolve_stopword_set(options)
sentences =
documents
|> Enum.flat_map(&split_into_sentences(&1, locale))
|> Enum.map(&split_words(&1, locale))
|> Enum.map(&maybe_fold(&1, case_fold?))
|> Enum.map(&Enum.reject(&1, fn token -> MapSet.member?(stopwords, token) end))
|> Enum.reject(&(&1 == []))
tokens = Enum.concat(sentences)
%{sentences: sentences, tokens: tokens, stopwords: stopwords}
end
@doc """
Generates n-gram candidates for each sentence within `min..max`.
Skips n-grams that span across a stopword/punctuation boundary by
virtue of operating on the already-filtered sentence-grouped tokens.
Returns a flat list of `{ngram_tokens, kind}` pairs ready for
candidate counting; `kind` is `:word` for unigrams and `:phrase`
for n>1.
"""
@spec candidates([sentence_tokens()], pos_integer(), pos_integer()) ::
[{[String.t()], :word | :phrase}]
def candidates(sentences, min_n, max_n)
when is_integer(min_n) and is_integer(max_n) and min_n >= 1 and max_n >= min_n do
Enum.flat_map(sentences, fn sentence ->
Enum.flat_map(min_n..max_n//1, fn n ->
sentence
|> Enum.chunk_every(n, 1, :discard)
|> Enum.map(fn chunk -> {chunk, kind_for(n)} end)
end)
end)
end
@doc """
Counts identical n-grams, returning `[{[tokens], count, kind}]`.
Stable ordering: n-grams are returned in the order of their first
appearance, with `count` being the total number of occurrences.
"""
@spec count_candidates([{[String.t()], :word | :phrase}]) ::
[{[String.t()], pos_integer(), :word | :phrase}]
def count_candidates(candidates) do
candidates
|> Enum.reduce({%{}, []}, fn {tokens, kind}, {counts, order} ->
case Map.fetch(counts, tokens) do
{:ok, _} ->
{Map.update!(counts, tokens, &(&1 + 1)), order}
:error ->
{Map.put(counts, tokens, 1), [{tokens, kind} | order]}
end
end)
|> then(fn {counts, order} ->
order
|> Enum.reverse()
|> Enum.map(fn {tokens, kind} -> {tokens, Map.fetch!(counts, tokens), kind} end)
end)
end
# ---- internals --------------------------------------------------------
defp resolve_stopword_set(options) do
case Keyword.get(options, :stopwords, :auto) do
:none ->
MapSet.new()
:auto ->
language = Keyword.get(options, :language)
cond do
is_nil(language) -> MapSet.new()
Text.Stopwords.available?(language) -> Text.Stopwords.for(language)
true -> MapSet.new()
end
{:extend, extras} ->
language = Keyword.get(options, :language)
if language && Text.Stopwords.available?(language) do
Text.Stopwords.extend(language, extras)
else
MapSet.new(extras)
end
%MapSet{} = set ->
set
list when is_list(list) ->
MapSet.new(list)
end
end
# `unicode_string`'s sentence segmenter treats newlines as sentence
# boundaries (per UAX #29), which spuriously fragments multi-line
# input pasted from documents. Collapse all whitespace runs to a
# single space before segmenting.
defp normalise_whitespace(text), do: String.replace(text, ~r/\s+/u, " ")
defp split_into_sentences(text, nil), do: text |> normalise_whitespace() |> Segment.sentences()
defp split_into_sentences(text, locale) when is_binary(locale) do
text
|> normalise_whitespace()
|> Segment.sentences(locale: locale)
end
defp split_words(text, nil), do: Segment.words(text)
defp split_words(text, locale) when is_binary(locale) do
Segment.words(text, locale: locale)
end
defp maybe_fold(tokens, false), do: tokens
defp maybe_fold(tokens, true), do: Enum.map(tokens, &String.downcase/1)
defp kind_for(1), do: :word
defp kind_for(_), do: :phrase
end