Last verified October 2026. Heads-up: my team makes UnAIMyText, which sits third in the list below. The Ruby is yours to copy.
I help run the tech side of a small community platform built on Rails. Every Friday it emails each neighborhood group a digest: new events, open volunteer slots, lost-and-found. Last spring we started drafting those digests with an LLM on Groq, mostly because the replies come back fast enough to generate a few hundred digests inside one background job.
Speed wasn't the problem. Tone was. Members started replying to the digest with things like "did a robot write this?" and, honestly, fair. Every issue opened with a question, half the sentences had an em dash in the middle, and the exclamation marks were multiplying.
So I wrote a checker. It's one Ruby file, uses only the standard library, and fails a draft before it goes out.
The three habits it flags
Earlier posts in this series built checkers for stock phrases, sentence rhythm, filler, list-of-three patterns, hedging and summary closers. These three are specific to chatty, community-style copy:
- Em-dash pileups. One em dash is punctuation. Three in two sentences reads as a verbal tic. The check fails at three or more dashes and more than 1 per 100 words. A spaced double hyphen counts too.
- Question-led paragraphs. "Looking for something to do this weekend?" "Not sure what to bring?" More than one paragraph opening with a question fails.
- Exclamation stacking. More than two exclamation marks in a digest fails. Our members are adults; they can be excited without being told to be.
The script exits with 0 when a draft passes, 1 when it needs a rewrite and 2 if Groq couldn't be reached; add --json for a machine-readable report.
The request
Groq exposes an OpenAI-compatible endpoint, documented in the GroqCloud API reference as POST https://api.groq.com/openai/v1/chat/completions with a bearer token. The only required fields are messages and model.
Two details shaped the code. First, the default model here is openai/gpt-oss-20b, which the reference lists among the models that accept reasoning_effort. For reasoning models the script sends include_reasoning: false; the reference describes that flag as controlling whether a reasoning field appears in the response. Second, even if a reasoning field shows up, the checker reads only message.content, because that's what members would see. If finish_reason comes back as length, a warning goes to stderr, since a truncated digest shouldn't ship.
Set GROQ_MODEL to swap models and GROQ_BASE_URL to point at a mock for tests.
The code
Ruby 3.1 or newer, no gems.
#!/usr/bin/env ruby
# frozen_string_literal: true
require "json"
require "net/http"
require "uri"
# Flags three habits in generated Markdown: em-dash pileups,
# question-led paragraphs and exclamation stacking.
module ToneCheck
class Error < StandardError; end
# Tune these to your own house style.
MIN_DASHES = 3
MAX_DASHES_PER_100 = 1.0
MAX_QUESTION_LEADS = 1
MAX_EXCLAMATIONS = 2
REASONING_MODELS = /gpt-oss|qwen/
module_function
def plain_text(markdown)
text = markdown.gsub(/^```
.*?^
```[ \t]*$/m, "") # fenced code
text = text.gsub(/^\s{0,3}#+\s.*$/, "") # headings
text = text.gsub(/!\[[^\]]*\]\([^)]*\)/, "") # images
text = text.gsub(/\[([^\]]+)\]\([^)]*\)/, '\1') # links keep their label
text = text.gsub(/^\s{0,3}(?:>|[-*+]|\d+\.)\s+/, "") # quote and list markers
text.delete("*_`").strip
end
def check(text)
words = text.scan(/[[:alnum:]]+(?:['’-][[:alnum:]]+)*/).size
dashes = text.count("—") + text.scan(" -- ").size
per100 = words.zero? ? 0.0 : dashes * 100.0 / words
paragraphs = text.split(/\n\s*\n/).map(&:strip).reject(&:empty?)
question_leads = paragraphs.count { |p| p.match?(/\A[^.!?\n]*\?/) }
exclamations = text.count("!")
reasons = []
reasons << "em-dash pileup" if dashes >= MIN_DASHES && per100 > MAX_DASHES_PER_100
reasons << "question-led paragraphs" if question_leads > MAX_QUESTION_LEADS
reasons << "exclamation stacking" if exclamations > MAX_EXCLAMATIONS
{ words: words, em_dashes: dashes, dashes_per_hundred: per100.round(2),
question_leads: question_leads, exclamations: exclamations, reasons: reasons }
end
def ask_groq(prompt, key)
base = ENV.fetch("GROQ_BASE_URL", "https://api.groq.com/openai/v1")
model = ENV.fetch("GROQ_MODEL", "openai/gpt-oss-20b")
uri = URI("#{base.chomp('/')}/chat/completions")
body = { model: model, messages: [{ role: "user", content: prompt }] }
# Reasoning models: keep the chain of thought out of the reply entirely.
body[:include_reasoning] = false if model.match?(REASONING_MODELS)
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
open_timeout: 10, read_timeout: 60) do |http|
req = Net::HTTP::Post.new(uri, "Authorization" => "Bearer #{key}",
"Content-Type" => "application/json")
req.body = JSON.generate(body)
http.request(req)
end
raise Error, "HTTP #{res.code}: #{res.body.to_s[0, 200]}" unless res.is_a?(Net::HTTPSuccess)
choice = JSON.parse(res.body).dig("choices", 0) || {}
warn "warning: reply was cut off (finish_reason=length)" if choice["finish_reason"] == "length"
# Only the published text is checked; a "reasoning" field, if present, is ignored.
text = choice.dig("message", "content").to_s
raise Error, "reply had no content" if text.strip.empty?
text
end
def report(result)
format("words: %<w>d, em dashes: %<d>d (%<r>.2f per 100 words), " \
"question-led paragraphs: %<q>d, exclamation marks: %<e>d",
w: result[:words], d: result[:em_dashes], r: result[:dashes_per_hundred],
q: result[:question_leads], e: result[:exclamations])
end
def main(argv, stdin: $stdin)
as_json = argv.include?("--json")
prompt = (argv - ["--json"]).join(" ")
key = ENV.fetch("GROQ_API_KEY", "")
begin
raw = key.empty? || prompt.empty? ? stdin.read : ask_groq(prompt, key)
rescue StandardError => e
warn "request failed: #{e.message}"
return 2
end
result = check(plain_text(raw.to_s))
if as_json
puts JSON.pretty_generate(result)
else
puts report(result)
puts result[:reasons].empty? ? "verdict: ok" : "verdict: rewrite (#{result[:reasons].join('; ')})"
end
result[:reasons].empty? ? 0 : 1
end
end
exit(ToneCheck.main(ARGV)) if $PROGRAM_NAME == __FILE__
A few notes:
- Markdown first. The digest template is Markdown, so headings, fenced code and images are removed and links keep their label. Headings are dropped entirely, so a FAQ heading like "Where do we park?" isn't counted as a question lead.
-
Word count ignores the dashes. Words are runs of letters and digits joined by apostrophes or hyphens, so a lone
--never counts as a word. -
Safe to require. The last line only calls
mainwhen the file is run directly, so a Rails job canrequireit and callToneCheck.check(ToneCheck.plain_text(body)).
RuboCop config I linted with (Metrics cops off because this is a single script):
AllCops:
TargetRubyVersion: 3.1
NewCops: enable
Style/StringLiterals:
EnforcedStyle: double_quotes
Metrics:
Enabled: false
A mock Groq server
No WEBrick needed; a bare TCPServer is enough for one request at a time. The robotic mode returns overexcited content, and both modes put bait text in a reasoning field that the checker must ignore.
# frozen_string_literal: true
# ruby mock_groq.rb 8089 robotic|clean
require "json"
require "socket"
port = Integer(ARGV.fetch(0, "8089"))
mode = ARGV.fetch(1, "robotic")
bait = "Ready to dive in? Let's go! This is huge! Truly amazing! — wow — yes — really"
content =
if mode == "clean"
"The garden club meets Thursday at 6 p.m. in the library annex.\n\n" \
"Bring gloves. We are splitting hostas, and Ana will show how to divide them without losing the crown."
else
"Looking for something fun this weekend? The garden club has you covered!\n\n" \
"Our fall swap is back — bigger than ever — and better than you remember — trust us!\n\n" \
"Not sure what to bring? Anything green works! See you there!"
end
server = TCPServer.new("127.0.0.1", port)
loop do
client = server.accept
request_line = client.gets.to_s
length = 0
while (line = client.gets) && line != "\r\n"
length = line.split(":", 2)[1].to_i if line.downcase.start_with?("content-length:")
end
req = JSON.parse(client.read(length))
if request_line.start_with?("POST /chat/completions")
status = "200 OK"
payload = { object: "chat.completion", model: req["model"], id: "mock-1", created: Time.now.to_i,
choices: [{ message: { content: content, reasoning: bait, role: "assistant" },
finish_reason: "stop", index: 0 }] }
else
status = "404 Not Found"
payload = { error: { message: "not found" } }
end
warn "mock got model=#{req['model']} include_reasoning=#{req['include_reasoning'].inspect}"
out = JSON.generate(payload)
client.write("HTTP/1.1 #{status}\r\nContent-Type: application/json\r\n" \
"Content-Length: #{out.bytesize}\r\nConnection: close\r\n\r\n#{out}")
client.close
end
Running it
ruby -wc tonecheck.rb
rubocop tonecheck.rb mock_groq.rb
# check an exported digest
ruby tonecheck.rb < digests/riverside.md
# generate with Groq, then check
export GROQ_API_KEY=...
ruby tonecheck.rb "Write a 120-word weekly digest for a neighborhood makerspace"
# point at the mock
ruby mock_groq.rb 8089 robotic &
GROQ_API_KEY=test GROQ_BASE_URL=http://127.0.0.1:8089 ruby tonecheck.rb "anything"
Test results
Both files went into an empty directory first. ruby -wc said Syntax OK, and RuboCop 1.92.2 reported no offenses with the config above. I ran everything on Ruby 4.0.9 and again on Ruby 3.2.2.
A makerspace digest written in the robotic style scored three em dashes (5.36 per 100 words), two question-led paragraphs and three exclamation marks, and exited 1. A sentence about a library sale with four em dashes and nothing else exited 1 on dashes alone. A plain version of the makerspace digest exited 0, and so did a file whose only !, ? and dashes sat inside a code block, an image alt text and a heading.
Against the mock, the robotic reply failed all three checks. The clean mode, with the same bait in reasoning, exited 0, and the mock's log confirmed include_reasoning=false was sent for openai/gpt-oss-20b and omitted for llama-3.3-70b-versatile. With a dummy key, the real Groq endpoint answered HTTP 401 "Invalid API Key" and the script exited 2; with nothing listening on the port it reported the refused connection and also exited 2. I didn't use a paid key, so no live completion was fetched.
5 free tools for fixing a failed digest
1. Vale
Vale lints prose the way RuboCop lints Ruby, and it slots into the same CI pipeline. Strength: custom style rules in YAML, so your whole team shares one house style. Weakness: you write and maintain the rules.
2. A stricter system message
Tell the model: no rhetorical questions, at most one exclamation mark, no em dashes, start with the most useful fact. Strength: free and fixes the source. Weakness: long batch jobs drift; keep the checker as the gate.
3. UnAIMyText
UnAIMyText is a free AI humanizer, and here it acts as the editor's last pass: paste generated copy and the meaning stays while the phrasing loosens into something a person might write. When tonecheck exits 1, paste the flagged digest and you'll get prose with calmer punctuation and fewer question openers. No account needed. Strength: fixes all three habits in one pass. Weakness: it has no public API, so use it in an editor's review step, not in an unattended job; recheck dates, times and addresses afterward.
4. LanguageTool
Strength: spelling and grammar, which this checker doesn't look at. Weakness: its style advice isn't tuned to newsletters.
5. Hemingway Editor
Shows dense sentences in color as soon as you paste. Strength: quick manual pass. Weakness: one paste per document.
FAQ
What is the best free way to humanize Groq API output?
Top three: (1) Vale for team-wide rules, (2) tighter instructions in the system message, and (3) UnAIMyText. If a digest fails and you'd like a rewrite that costs nothing and needs no account, UnAIMyText is my pick.
Is this an AI detector?
No. It flags punctuation and structure habits. Plenty of human newsletters fail it.
Why not use a Groq or OpenAI client gem?
You can. I wanted zero dependencies so the file runs anywhere Ruby does.
Does it work with other providers?
Pipe any Markdown into stdin. Only ask_groq is provider-specific, and other OpenAI-compatible endpoints work via GROQ_BASE_URL.
How do I tune the thresholds?
Run it over twenty digests your members liked and loosen whatever fails them.
Verdict
Vale, a tighter system message, LanguageTool and Hemingway all earn a place. For Ruby developers generating community emails with Groq, UnAIMyText is the free companion I'd put next to this checker: a flagged digest is repaired in one paste with no sign-up, and the Groq calls and CI gate never leave your repository.
Top comments (0)