DEV Community

Cover image for Recruiting with AI and Elixir
Mark Markaryan
Mark Markaryan

Posted on

Recruiting with AI and Elixir

Table of Contents

Let's Build Something Useful!

At this point, you might be wondering, "Sure, the useless machine was fun to make, and to see what's happening behind the scenes is super-neat, but can we build something a bit more... useful?"

Absolutely! Let's build a recruiting website that uses AI to review submissions and filters out spam and mismatches.

Source Code and Setup

The source code for this exercise is available at https://github.com/shipworthy/recruit

You can clone the repo and run the code on your machine, or you can just follow along.

Herbarium, Inc. Needs Help

Business is booming at Herbarium, Inc. The pile of plants to be examined is growing.

Mr. Hemulen would love some help, but looking for someone is just too much work. To make things worse, last time he tried, he got buried under a mountain of applications that all looked so... meaningless. As if they were all written by a trembling herd of electricity-hungry Hattifatteners. No good.

Snork, a famed inventor, has a solution to Mr. Hemulen's problem. AI!!

Mr. Hemulen will provide a job description and Snork's tool will give him candidates that seem real and look like a potential fit for the job!

Easy! We just need to come up with a way to evaluate and curate submissions!

The Candidate Workflow Graph: Components

Excited, Snork starts sketching the steps of the workflow.

Some of the steps are inputs, some are computations:

Initial Inputs

The inputs for the workflow are:

  1. the job description
  2. the resume
  3. the time of the submission
input(:job_description),
input(:resume),
input(:submitted)
Enter fullscreen mode Exit fullscreen mode

Computations

Once all the inputs are in place, the workflow will compute:

  1. before doing anything else, is this a real resume? and, if so,
  2. the summary of the candidate's background, as it applies to Mr. Hemulen's job description.
  3. the match score, 0..100 – is the candidate a good fit for the job?
compute(:resume_valid, ..., &is_resume_valid/1)
compute(:resume_summary, ..., &summarize_resume/1)
compute(:match_score, ..., &compute_match_score/1)
Enter fullscreen mode Exit fullscreen mode

Hemulen-in-the-Middle Input

After reviewing the submission, Mr. Hemulen can input his decision for next steps. Chat with the candidate? Or no?

input(:decision)
Enter fullscreen mode Exit fullscreen mode

The Candidate Workflow Graph: All Together Now

This all seems pretty straightforward!

Excited, Snork puts the whole thing together.

A couple of things become apparent in the process:

  1. each of the compute nodes is unblocked when its prerequisites are met (e.g. :match_score is unblocked – and its function gets called by Journey – when :resume_valid becomes true).

  2. thinking ahead, when something changes (e.g. we have the :match_score!), we'll want to let the candidate's web page know, so it can update itself.

  3. since LLM processing (computing a summary or scoring) can take a while, we'll give those computations a more generous abandon_after_seconds than the default of 60 seconds.

Here is the graph that Snork came up with:

Journey.new_graph(
  "candidate",
  "1.0",
  [
    input(:job_description),
    input(:resume),
    input(:submitted),
    compute(
      :resume_valid,
      unblocked_when(
        :and,
        [
          {:resume, &provided?/1},
          {:submitted, &provided?/1}
        ]
      ),
      &is_resume_valid/1
    ),
    compute(
      :resume_summary,
      unblocked_when(
        :and,
        [
          {:resume_valid, &true?/1},
          {:job_description, &provided?/1}
        ]
      ),
      &summarize_resume/1,
      abandon_after_seconds: 300
    ),
    compute(
      :match_score,
      unblocked_when(
        :and,
        [
          {:resume_valid, &true?/1},
          {:job_description, &provided?/1}
        ]
      ),
      &compute_match_score/1,
      abandon_after_seconds: 300
    ),
    input(:decision)
  ],
  execution_id_prefix: "c",
  f_on_save: fn execution_id, node_name, result ->
    Logger.info("f_on_save[#{execution_id}]: candidate updated, #{node_name}, #{inspect(result)}")

    Phoenix.PubSub.broadcast(
      ResumeScreener.PubSub,
      "candidate:#{execution_id}",
      {:refresh, node_name, result}
    )

    :ok
  end
)
Enter fullscreen mode Exit fullscreen mode

f_on_save: When Something Changes!

At the end of the graph, Snork added a bit of code to generate a PubSub message whenever any of the input or compute'd values get updated. The UI can listen to the message, and update itself whenever something changes.

Talking to AI: The 3 Functions

All that's left is to implement the three functions attached to the compute nodes!

  1. is_resume_valid/1
    the function that looks at the submission and determines if it's an actual resume.

  2. summarize_resume/1
    the function that looks at the submitted resume and summarizes and evaluates it in the context of the job description.

  3. compute_match_score/1
    the function that computes a score for "is the submission a good match for the job", from 0 (nope) to 100 (this is the perfect candidate for the job, at least according to the submission).

The first function, is_resume_valid/1, uses the facebook/bart-large-mnli model, executed via bumblebee / Nx to perform zero-shot classifications:

@resume_label "a professional resume describing a person's professional background and experiences"

def classifier_serving do
  Logger.info("Loading classifier model...")

  {:ok, model} = Bumblebee.load_model({:hf, "facebook/bart-large-mnli"})
  {:ok, tokenizer} = Bumblebee.load_tokenizer({:hf, "facebook/bart-large-mnli"})

  serving =
    Bumblebee.Text.zero_shot_classification(model, tokenizer, [
      @resume_label,
      "not a resume"
    ])

  Logger.info("Classifier model loaded")
  serving
end

def is_resume_valid(resume_text) do
  Logger.info("checking resume for validity")
  truncated = sanitize_and_truncate(resume_text)

  result = Nx.Serving.batched_run(ResumeScreener.ResumeClassifier, truncated)

  resume_score =
    Enum.find(result.predictions, fn p -> p.label == @resume_label end)

  {:ok, resume_score.score > 0.8}
end
Enter fullscreen mode Exit fullscreen mode

This makes is_resume_valid/1 extremely inexpensive and quick to run.

If the resume is deemed valid, we then proceed to summarization and scoring.

These are more expensive operations, for which we use an actual LLM, gemma3:4b, hosted by Ollama. You can see these functions implemented in https://github.com/shipworthy/recruit/blob/main/lib/resume_screener/candidate_graph.ex

When a compute node's unblocked_when conditions are met, Journey calls its compute function, with all of the execution's values, expecting the function to return {:ok, computed_value}. For more information about f_compute functions, see Journey documentation.

Let's Try It!!

Application 1: "zap barometer zap zap"

Snork will use his graph to power his new recruiting website, but for now, he just wants to try it in IEx:

~/src/resume_screener $ iex -S mix
Enter fullscreen mode Exit fullscreen mode
iex(1)> candidate = ResumeScreener.Candidate.Graph.new() |> Journey.start(); :ok
Enter fullscreen mode Exit fullscreen mode

Mr. Hemulen has provided his job description, and someone emailed their application ("How did they get my email address?").

Snork is eager to use his new system to review the submission!

iex(2)> Journey.set(candidate, :job_description, File.read!("example_job_description.txt")); :ok
iex(3)> Journey.set(candidate, :resume, File.read!("example_resume1.txt")); :ok
iex(4)> Journey.set(candidate, :submitted, System.os_time(:second)); :ok
Enter fullscreen mode Exit fullscreen mode

As soon as Snork "submits" the application, :resume_valid node becomes unblocked, and Journey calls its function:

09:03:38.579 [info] checking resume for validity
09:03:40.144 [info] f_on_save[C303R5YZYMVXXYGG4RETR]: candidate updated, resume_valid, {:ok, false}
Enter fullscreen mode Exit fullscreen mode

...wait a second. This is not a valid resume? What is it??

iex(5)> {:ok, resume_valid?, _revision} = Journey.get(candidate, :resume_valid, wait: :any); resume_valid?
false
iex(6)> {:ok, resume, _revision} = Journey.get(candidate, :resume, wait: :any); resume
"zap barometer zap zap\n"
Enter fullscreen mode Exit fullscreen mode

Oh. Right.

A little disappointing, but also... reassuring – this is what the system is supposed to do!

Application 2: "i would like to write my memoir."

Snork didn't have to wait long for another submission. Let's see what we got!!

~/src/resume_screener $ iex -S mix
Enter fullscreen mode Exit fullscreen mode
iex(1)> candidate = ResumeScreener.Candidate.Graph.new() |> Journey.start(); :ok
iex(2)> Journey.set(candidate, :job_description, File.read!("example_job_description.txt")); :ok
iex(3)> Journey.set(candidate, :resume, File.read!("example_resume2.txt")); :ok
iex(4)> Journey.set(candidate, :submitted, System.os_time(:second)); :ok
Enter fullscreen mode Exit fullscreen mode

Let's see!!!

10:05:41.875 [info] checking resume for validity
10:05:43.480 [info] f_on_save[CXZH2T9J5XV5B3XEDMR41]: candidate updated, resume_valid, {:ok, true}
10:05:43.492 [info] summarizing resume for candidate
10:05:43.492 [info] computing match score for candidate
10:05:54.163 [info] resume summary composed for candidate
10:05:54.179 [info] f_on_save[CXZH2T9J5XV5B3XEDMR41]: candidate updated, resume_summary, {:ok, "1.  **Overview:** Moomin Papa presents as a self-described writer and philosopher with limited formal experience. His background is primarily focused on personal adventures and a memoir project.\n\n2.  **Impressive Skills/Deliverables:** Adventuring (a unique selling point), writing skills, and a personal project (Substack).\n\n3.  **Relevant Skills:** None explicitly relevant to the job description. \n\n4.  **Missing Skills:** Extensive experience (14+ years) in application development, particularly with Elixir.  Lack of experience with IoT, Cloud Computing, and Big Data, which are key to Kelvin’s work. No demonstrable leadership or mentoring experience.\n\n5.  **Things to Ask:** What specific types of writing experience does he have? Can he quantify his “adventuring” experience and relate it to problem-solving?  Describe his familiarity with Elixir beyond the substack.  How does he handle ambiguity and complex challenges?"}
10:05:55.415 [info] match score computed for candidate, 15
10:05:55.421 [info] f_on_save[CXZH2T9J5XV5B3XEDMR41]: candidate updated, match_score, {:ok, 15}
Enter fullscreen mode Exit fullscreen mode

YES!! We have a real resume. But why is the match score so low?

iex(5)> {:ok, resume, _revision} = Journey.get(candidate, :resume, wait: :any); resume
"Moomin Papa\npapa@valley.com | Moomin Valley | substack/adventures_in_the_valley\n\nSummary\nA writer philosopher with a lifetime of adventures is writing a memoir.\n\nSkills:\nWriting, adventuring.\n\nEXPERIENCE\nPresent: a writer.\n\nReferences available upon request.\n"
Enter fullscreen mode Exit fullscreen mode

Oh. Ok, Moomin Papa.

Application 102: Hm.

After a few more of those cryptic "zap barometer zap zap"-style submissions, and a "low-match-score" application from Snorkmaiden (I don't care what AI says, I love you sis! ❤️), Snork is looking at application number 102.

~/src/resume_screener $ iex -S mix
Enter fullscreen mode Exit fullscreen mode
iex(1)> candidate = ResumeScreener.Candidate.Graph.new() |> Journey.start(); :ok
iex(2)> Journey.set(candidate, :job_description, File.read!("example_job_description.txt")); :ok
iex(3)> Journey.set(candidate, :resume, File.read!("example_resume3.txt")); :ok
iex(4)> Journey.set(candidate, :submitted, System.os_time(:second)); :ok
Enter fullscreen mode Exit fullscreen mode

So exciting!!

10:25:49.118 [info] checking resume for validity
10:25:51.878 [info] f_on_save[CX9LH56G7V8M4GH9E71TZ]: candidate updated, resume_valid, {:ok, true}
10:25:51.889 [info] summarizing resume for candidate
10:25:51.889 [info] computing match score for candidate
10:25:59.252 [info] match score computed for candidate, 92
10:25:59.266 [info] f_on_save[CX9LH56G7V8M4GH9E71TZ]: candidate updated, match_score, {:ok, 92}
10:26:07.485 [info] resume summary composed for candidate
10:26:07.501 [info] f_on_save[CX9LH56G7V8M4GH9E71TZ]: candidate updated, resume_summary, {:ok, "1. **General Overview:** Morgan Elix is a highly experienced Staff Software Engineer with over 12 years specializing in highly concurrent, fault-tolerant distributed systems, primarily using Elixir and OTP. <...>"}
Enter fullscreen mode Exit fullscreen mode

We have our first candidate with a high match score!! Snork is so excited to pass Morgan's application to Mr. Hemulen!

Next Steps

Mr. Hemulen was excited to see Morgan's application. They had a good chat.

Morgan did not end up becoming Mr. Hemulen's assistant. Morgan wanted to put all the plants in rented buckets in "the cloud", and to charge everyone for looking at pictures of the plants. Mr. Hemulen didn't quite understand how this would make anything better. "This would just make EVERYTHING worse," he thought.

But Mr. Hemulen liked having an easy way to connect with qualified potential helpers, and he wanted to keep the system going.

Snork didn't want to keep running workflows by hand, so he put together a website around it. The website lets candidates upload their resumes, and shows Mr. Hemulen the list of real qualified candidates, with match scores and notes for each.

Snork was kind enough to open-source the website: https://github.com/shipworthy/recruit, complete with usage instructions in its README.md.

Peer Review: "SNORK RECRUITING DOES NOT SCALE"

It was spring, and Snufkin was visiting the Valley. He was curious about Snork's invention, and he seemed to really like it, but he also said "Snork Recruiting does not scale" before wandering off.

This made Snork think. Is this true?

Stay tuned for the next blog! : )

References

Top comments (0)