Table of Contents
- Source Code and Setup
- Herbarium, Inc. Needs Help
- The Candidate Workflow Graph
- Talking to AI: The 3 Functions
- Let's Try It!
- Next Steps
- "SNORK RECRUITING DOES NOT SCALE"?
- References
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:
- the job description
- the resume
- the time of the submission
input(:job_description),
input(:resume),
input(:submitted)
Computations
Once all the inputs are in place, the workflow will compute:
- before doing anything else, is this a real resume? and, if so,
- the summary of the candidate's background, as it applies to Mr. Hemulen's job description.
- 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)
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)
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:
each of the
computenodes is unblocked when its prerequisites are met (e.g.:match_scoreis unblocked – and its function gets called by Journey – when:resume_validbecomes true).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.since LLM processing (computing a summary or scoring) can take a while, we'll give those computations a more generous
abandon_after_secondsthan 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
)
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!
is_resume_valid/1
the function that looks at the submission and determines if it's an actual resume.summarize_resume/1
the function that looks at the submitted resume and summarizes and evaluates it in the context of the job description.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
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
iex(1)> candidate = ResumeScreener.Candidate.Graph.new() |> Journey.start(); :ok
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
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}
...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"
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
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
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}
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"
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
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
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. <...>"}
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
- Resume Screener on GitHub: https://github.com/shipworthy/recruit
- Journey on Hex: https://hexdocs.pm/journey
- Journey on GitHub: https://github.com/shipworthy/journey
- Journey's Website: https://gojourney.dev
- Cover image: generated by Google Gemini, seemingly trained on Moomin characters. Moomin character rights are owned by Moomin Characters Oy Ltd., https://www.moomin.com/
Top comments (0)