Powered by AppSignal & Oban Pro

BumblebeeNLU — Teste standalone

notebooks/nlu_test.livemd

BumblebeeNLU — Teste standalone

Mix.install([
  {:bumblebee, "~> 0.6"},
  {:nx, "~> 0.10"},
  {:exla, "~> 0.10"},
  {:axon, "~> 0.7"}
], system_env: %{
  "EXLA_CPU_ONLY" => "true",
  "CFLAGS" => "-Wno-error -Wno-invalid-specialization",
  "CXXFLAGS" => "-Wno-error -Wno-invalid-specialization"
})

Section

Notebook completamente autônomo: instala só os deps de ML, sobe os servings e testa o parse sem precisar do app Phoenix rodando.

> Primeira execução: o download dos modelos (~500 MB) pode demorar alguns minutos.

1. Instalar dependências

Nx.global_default_backend(EXLA.Backend)
Nx.Defn.global_default_options(compiler: EXLA)
{:ok, model} = Bumblebee.load_model(
  {:hf, "pierreguillou/ner-bert-large-cased-pt-lenerbr"}
)

{:ok, tokenizer} = Bumblebee.load_tokenizer(
  {:hf, "pierreguillou/ner-bert-large-cased-pt-lenerbr"}
)

serving_ner_pt = Bumblebee.Text.token_classification(model, tokenizer,
  aggregation: :same,
  compile: [batch_size: 1, sequence_length: 512],
  defn_options: [compiler: EXLA]
)

2. Subir os servings (embedding + NER)

defmodule Servings do
  @intent_serving NLUTest.IntentServing
  @ner_serving    NLUTest.NERServing

  def start do
    start_intent()
    start_ner()
    :ok
  end

  defp start_intent do
    repo = "intfloat/multilingual-e5-base"
    IO.puts("Carregando modelo de embedding: #{repo} ...")
    {:ok, model}     = Bumblebee.load_model({:hf, repo})
    {:ok, tokenizer} = Bumblebee.load_tokenizer({:hf, repo})

    serving =
      Bumblebee.Text.text_embedding(model, tokenizer,
        compile: [batch_size: 16, sequence_length: 128],
        defn_options: [compiler: EXLA],
        output_pool: :mean_pooling,
        output_attribute: :hidden_state
      )

    {:ok, _} =
      Nx.Serving.start_link(serving: serving, name: @intent_serving,
                            batch_size: 16, batch_timeout: 50)

    IO.puts("✅ Embedding pronto.")
  end

  defp start_ner do
    IO.puts("Carregando modelo NER ...")
    # {:ok, model}     = Bumblebee.load_model({:hf, "dslim/bert-base-NER"})
    # {:ok, tokenizer} = Bumblebee.load_tokenizer({:hf, "google-bert/bert-base-cased"})

    {:ok, model} = Bumblebee.load_model({:hf, "pierreguillou/ner-bert-large-cased-pt-lenerbr"})
    {:ok, tokenizer} = Bumblebee.load_tokenizer({:hf, "pierreguillou/ner-bert-large-cased-pt-lenerbr"})

    
    # serving =
    #   Bumblebee.Text.token_classification(model, tokenizer,
    #     aggregation: :same,
    #     compile: [batch_size: 4, sequence_length: 128],
    #     defn_options: [compiler: EXLA]
    #   )

    serving = 
      Bumblebee.Text.token_classification(model, tokenizer,
        aggregation: :same,
        compile: [batch_size: 1, sequence_length: 512],
        defn_options: [compiler: EXLA]
      )

    
    {:ok, _} =
      Nx.Serving.start_link(serving: serving, name: @ner_serving,
                            batch_size: 1, batch_timeout: 100)

    IO.puts("✅ NER pronto.")
  end
end

Servings.start()
texto = "O senador collor, do PSD de Minas Gerais, votou pela aprovação da PEC 45/2019 no Senado Federal."
Nx.Serving.batched_run(NLUTest.NERServing, texto)

3. Definir o NLU inline

defmodule NLU do
  @intent_serving NLUTest.IntentServing
  @ner_serving    NLUTest.NERServing

  # ── Frases de referência por contexto ──────────────────────────────────

  @senate_phrases [
    {"consultar_senador",
     ["passage: quero ver informações senador", 
       "passage: quem é o senador",
      "passage: dados do parlamentar", 
       "passage: dados do senador", 
       "passage: fale sobre", 
       "passage: falar",
       "passage: consultar"]},
    
    {"seguir_senador",
     [
       "passage: acompanhar o senador", 
       "passage: seguir parlamentar",
       "passage: seguir senadores",
       "passage: receber alertas sobre senador",
       "passage: seguir",
       "passage: acompanhar",
       "passage: acompanhar",
       "passage: monitorar senador"]},

    {"ver_votacoes",
     ["passage: como o senador votou", "passage: votações do senado",
      "passage: resultado da votação", "passage: histórico de votos"]},
    {"politic_br_senate_list",
     ["passage: lista de senadores", "passage: quais são os senadores",
      "passage: mostrar todos os senadores"]},
    {"confirm",
     ["passage: sim confirmo está correto", "passage: pode ser concordo"]},
    {"deny",
     ["passage: não está errado não quero", "passage: no that is wrong"]},
    {"greet",
     ["passage: olá bom dia como vai", "passage: oi hello hi"]},
    {"goodbye",
     ["passage: tchau até logo encerrar", "passage: goodbye bye"]}
  ]

  @general_phrases [
    {"confirm",
     ["passage: sim confirmo está correto", "passage: pode ser concordo"]},
    {"deny",
     ["passage: não está errado não quero"]},
    {"cancel",
     ["passage: cancelar operação desistir", "passage: cancel abort"]},
    {"greet",
     ["passage: olá bom dia como vai", "passage: oi hello hi"]},
    {"goodbye",
     ["passage: tchau até logo encerrar", "passage: goodbye bye"]}
  ]

  # ── API pública ─────────────────────────────────────────────────────────

  def parse(text, context \\ nil) do
    threshold = 0.5

    with {:ok, intents}  <- classify_intent(text, threshold, context),
         {:ok, entities} <- extract_entities(text, threshold) do
      {:ok, %{text: text, intents: intents, entities: entities}}
    end
  end

  def top_intent(%{intents: []}), do: nil
  def top_intent(%{intents: intents}), do: intents |> Enum.max_by(&amp; &amp;1.confidence) |> Map.get(:name)

  def get_entity(%{entities: entities}, role) do
    case Enum.find(entities, &amp;(&amp;1.role == role or &amp;1.name == role)) do
      nil    -> nil
      entity -> entity.value
    end
  end

  # ── Intent via cosine similarity ────────────────────────────────────────

  defp classify_intent(text, threshold, context) do
    phrases = if context == "politic_br_senate", do: @senate_phrases, else: @general_phrases
    query   = "query: #{text}"
    %{embedding: user_emb} = Nx.Serving.batched_run(@intent_serving, query)

    scored =
      Enum.map(phrases, fn {name, refs} ->
        max_score =
          refs
          |> Enum.map(fn ref ->
            %{embedding: ref_emb} = Nx.Serving.batched_run(@intent_serving, ref)
            cosine(user_emb, ref_emb)
          end)
          |> Enum.max()

        %{name: name, confidence: max_score}
      end)

    top = Enum.max_by(scored, &amp; &amp;1.confidence)
    intents = if top.confidence >= threshold, do: [top], else: []
    {:ok, intents}
  end

  defp cosine(a, b) do
    Nx.dot(l2(a), l2(b)) |> Nx.to_number()
  end

  defp l2(t), do: Nx.divide(t, Nx.LinAlg.norm(t))

  # ── NER ─────────────────────────────────────────────────────────────────

  defp extract_entities(text, threshold) do
    result = Nx.Serving.batched_run(@ner_serving, text)

    entities =
      result.entities
      |> Enum.filter(&amp;(&amp;1.score >= threshold))
      |> Enum.map(fn e ->
        role = ner_to_role(e.label)
        %{name: e.label, role: role, value: e.phrase, confidence: e.score, body: e.phrase}
      end)

    {:ok, entities}
  end

  defp ner_to_role("PER"),  do: "wit$contact"
  defp ner_to_role("LOC"),  do: "wit$location"
  defp ner_to_role("ORG"),  do: "wit$organization"
  defp ner_to_role("DATE"), do: "wit$datetime"
  defp ner_to_role("TIME"), do: "wit$datetime"
  defp ner_to_role(l),      do: String.downcase(l)
end

IO.puts("✅ Módulo NLU carregado.")

4. Testar intents — contexto politic_br_senate

casos = [
  # {"greet",                  "olá, bom dia!"},
  # {"politic_br_senate_list", "quais são os senadores?"},
  # {"politic_br_senate_list", "me mostra a lista de senadores"},
  {"consultar_senador",      "quero ver informações do senador Romário"},
  # {"consultar_senador",      "me fala sobre Renan Calheiros"},
  # {"ver_votacoes",           "como o senador votou na última sessão?"},
  # {"seguir_senador",         "quero acompanhar o senador Flávio Bolsonaro"},
  # {"goodbye",                "tchau, até mais"},
  # {"deny",                   "não quero isso"}
]

IO.puts(String.pad_trailing("ESPERADO", 26) <>
        String.pad_trailing("DETECTADO", 26) <>
        String.pad_trailing("CONF%", 8) <>
        "FRASE")
IO.puts(String.duplicate("─", 90))

for {esperado, texto} <- casos do
  {:ok, result} = NLU.parse(texto, "politic_br_senate")
  intent = NLU.top_intent(result)
  conf   = result.intents |> List.first(%{confidence: 0.0}) |> Map.get(:confidence)
  ok?    = if intent == esperado, do: "✅", else: "⚠️ "

  IO.puts(
    "#{ok?} " <>
    String.pad_trailing(esperado, 24) <>
    String.pad_trailing(intent || "(none)", 26) <>
    String.pad_trailing("#{Float.round(conf * 100, 1)}%", 8) <>
    texto
  )
end

5. Testar extração de entidades (NER)

frases_ner = [
  "quero acompanhar o senador Romário",
  "como votou Flávio Bolsonaro",
  "informações sobre Renan Calheiros",
  "Simone Tebet votou a favor?",
  "lista de senadores",
  "cancelar"
]

IO.puts(String.pad_trailing("FRASE", 45) <> "ENTIDADE (wit\$contact)")
IO.puts(String.duplicate("─", 70))

for texto <- frases_ner do
  {:ok, result} = NLU.parse(texto, "politic_br_senate")
  # entidade = NLU.get_entity(result, "wit$contact")
  IO.puts(String.pad_trailing(texto, 45) <> inspect(result))
end

6. Inspecionar resultado completo

texto = "quero acompanhar o senador Romário"

{:ok, result} = NLU.parse(texto, "politic_br_senate")

IO.inspect(result, label: "Resultado completo", pretty: true)