Files
plantid-discord-bot/lib/plantnet/parser.ex
T
owenrees 02372f2076 Feat/error logs (#16)
* added list of tasks to achieve

* update deps

* update elixir version in CI/CD pipeline

* update todo with branch push checks

* underscores separation of large numbers

* update elixir version and deps

* add basic module  docs

* remove IO.inspect call

* check if list is empty rather than traversing list for length

* revert empty list code

* duration tests

* add mime type tests

* use logger instread of inspect, remove todos

* replace list length check with Enum.empty

* replace list length check with !Enum.empty?

* numbers readability fix by using underscores on large numbers

* iucn parser replaced case with multiclause function and added test

* replace map and join with map_join

* fix multiple spec issues where map() must be inside a list
2025-12-06 23:41:59 +01:00

163 lines
5.5 KiB
Elixir

defmodule PlantIdDiscordBot.PlantNet.Parser do
@moduledoc """
Parses the response from the PlantNet API.
# Examples
iex> response = ~S({
...> "results": [
...> {
...> "score": 0.87871,
...> "species": {
...> "scientificNameWithoutAuthor": "Prunus cerasifera",
...> "commonNames": ["Cherry plum", "myrobalan", "Cherry Plum", "Purple-leaf Plum"]
...> },
...> "gbif": {"id": "3021730"},
...> "powo": {"id": "729568-1"},
...> "iucn": {"category": "DD"}
...> }
...> ]
...> })
iex> PlantIdDiscordBot.PlantNet.Parser.parse(response)
"My best guess is **Prunus cerasifera** with a confidence of **88%**. Common names include **Cherry plum, myrobalan, Cherry Plum, Purple-leaf Plum**.
[GBIF](<https://www.gbif.org/species/3021730>) | [PFAF](<https://pfaf.org/user/Plant.aspx?LatinName=Prunus+cerasifera>) | [POWO](<https://powo.science.kew.org/taxon/729568-1>)
Threat status: DD"
"""
@gbif_base_url "https://www.gbif.org/species"
@pfaf_base_url "https://pfaf.org/user/Plant.aspx?LatinName="
@powo_base_url "https://powo.science.kew.org/taxon"
@score_threshold Application.compile_env(:plantid_discord_bot, :score_threshold)
@doc """
Parses the response from the PlantNet API into a map.
"""
@spec parse(String.t()) :: String.t()
def parse(response) do
response
|> to_map!()
|> filter_by_score()
|> add_external_urls()
|> generate_response_message()
end
@spec to_map!(String.t()) :: map()
def to_map!(response), do: Jason.decode!(response)
@doc """
Filters the data by score. Data is a parsed response from the PlantNet API.
"""
@spec filter_by_score(map()) :: map()
def filter_by_score(data) do
updated_results =
data["results"]
|> Enum.filter(&(&1["score"] > @score_threshold))
Map.put(data, "results", updated_results)
end
@doc """
Adds external URLs to the data. Data is a parsed response from the PlantNet API.
"""
@spec add_external_urls(map()) :: map()
def add_external_urls(data) do
updated_results =
data["results"]
|> generate_gbif_url()
|> generate_pfaf_url()
|> generate_powo_url()
Map.put(data, "results", updated_results)
end
@doc """
Generates a response message from the data. Data is a parsed response from the PlantNet API.
"""
@spec generate_response_message(map()) :: String.t()
def generate_response_message(data) do
best_result = hd(data["results"])
other_results = tl(data["results"])
best_guess_name = best_result["species"]["scientificNameWithoutAuthor"]
best_result_iucn_category = best_result["iucn"]["category"]
score = round(best_result["score"] * 100) |> Integer.to_string()
"My best guess is **#{best_guess_name}** with a confidence of **#{score}%**.#{get_common_names(best_result)}\n\nSpecies info from plant databases:\n[GBIF](<#{best_result["gbif_url"]}>) | [PFAF](<#{best_result["pfaf_url"]}>) | [POWO](<#{best_result["powo_url"]}>)#{if best_result_iucn_category, do: "\n\nConservation status: #{iucn_parser(best_result_iucn_category)}"}#{get_alternatives(other_results)}"
end
@spec generate_gbif_url([map()]) :: [map()]
defp generate_gbif_url(data) do
Enum.map(data, fn result ->
gbif_id = result["gbif"]["id"]
if gbif_id, do: Map.put(result, "gbif_url", "#{@gbif_base_url}/#{gbif_id}"), else: result
end)
end
@spec generate_pfaf_url([map()]) :: [map()]
defp generate_pfaf_url(data) do
Enum.map(data, fn result ->
pfaf_slug = String.replace(result["species"]["scientificNameWithoutAuthor"], " ", "+")
if pfaf_slug,
do: Map.put(result, "pfaf_url", "#{@pfaf_base_url}/#{pfaf_slug}"),
else: result
end)
end
@spec generate_powo_url([map()]) :: [map()]
defp generate_powo_url(data) do
Enum.map(data, fn result ->
powo_id = result["powo"]["id"]
if powo_id, do: Map.put(result, "powo_url", "#{@powo_base_url}/#{powo_id}"), else: result
end)
end
@spec get_common_names(map()) :: String.t()
defp get_common_names(data) do
common_names = data["species"]["commonNames"]
if is_list(common_names) && not Enum.empty?(common_names) do
" Common names include **" <>
Enum.join(common_names, ", ") <> "**."
end
end
@spec iucn_parser(String.t()) :: String.t()
def iucn_parser("DD"), do: "Data Deficient"
def iucn_parser("LC"), do: "Least Concern"
def iucn_parser("NT"), do: "Near Threatened"
def iucn_parser("VU"), do: "Vulnerable"
def iucn_parser("EN"), do: "Endangered"
def iucn_parser("CR"), do: "Critically Endangered"
def iucn_parser("EW"), do: "Extinct in the Wild"
def iucn_parser("EX"), do: "Extinct"
def iucn_parser("NE"), do: "Not Evaluated"
def iucn_parser(_), do: "Unknown"
@spec get_alternatives(map()) :: String.t()
defp get_alternatives(data) do
if !Enum.empty?(data) do
alternatives =
Enum.map_join(data, ", ", & &1["species"]["scientificNameWithoutAuthor"])
"\n\nAlternatives include **#{alternatives}**."
end
# if !Enum.empty?(data) do
# alternatives =
# Enum.map(data, & &1["species"]["scientificNameWithoutAuthor"])
# |> Enum.join(", ")
# "\n\nAlternatives include **#{alternatives}**."
# end
# if length(data) > 0 do
# alternatives =
# Enum.map(data, & &1["species"]["scientificNameWithoutAuthor"])
# |> Enum.join(", ")
# "\n\nAlternatives include **#{alternatives}**."
# end
end
end