Dev.to AI πŸ€– Ai πŸ‘ 0 πŸ“– 11 min read

Humanize Together AI Output in Elixir: A Pull Request Description Linter + 5 Free Tools (2026)

Humanize Together AI Output in Elixir: A Pull Request Description Linter + 5 Free Tools (2026) Built and tested on 2026-11-07. Before the tool list: I work on UnAIMyText, so it sits third on purpose. The Elixir file is

Humanize Together AI Output in Elixir: A Pull Request Description Linter + 5 Free Tools (2026)

Built and tested on 2026-11-07. Before the tool list: I work on UnAIMyText, so it sits third on purpose. The Elixir file is free to copy.

Our Phoenix repo started getting pull requests whose descriptions opened with "This PR aims to seamlessly enhance the overall reliability of our robust webhook pipeline." A teammate had wired an open-weight model on Together AI into a little "describe my diff" helper. The diffs were fine. The descriptions made reviewers scroll past the one sentence they needed: what changed, and how it was tested.

So I wrote a one-file Elixir script. Give it change notes and it asks Together for a description, then grades the answer. Give it a Markdown file and it just grades.

The rules

Each check targets something a reviewer actually trips over:

  1. First line ≀ 72 characters. It becomes the PR title in a lot of workflows and should read like one.
  2. No assistant warm-up. "Sure!", "Here is", "Certainly" at the top is a fail.
  3. No fence wrapper. Models love returning the whole answer inside a code block.
  4. No "This PR aims to…" opener. Say what changed instead of announcing that something will be said.
  5. Filler lexicon. seamless, leverage, robust, comprehensive, streamline, delve, elevate, cutting-edge, as an AI, and friends.
  6. A Testing line must exist. Testing:, Tested:, Test plan or How to test, as a line or heading.
  7. Bullet walls. More than eight bullets means nobody will read them.
  8. Emoji. One is a choice; two is a rocket-ship habit.

Exit codes: 0 when the description is ready to open, 1 if a human should take another swing at it, 2 if the HTTP call or the input file broke. --json prints a machine-readable verdict for CI.

What Together's docs say

I followed Together AI's chat completions reference. The parts this script depends on:

  • POST https://api.together.ai/v1/chat/completions with Authorization: Bearer $TOGETHER_API_KEY and a JSON body.
  • A request carries model plus messages (each entry has a role and its content); I add max_tokens: 400 so a chatty model can't write an essay.
  • The reply text sits at choices[0].message.content.
  • Together's OpenAI-compatibility page says existing OpenAI clients work by swapping the key and the base URL to https://api.together.ai/v1, which is why the TOGETHER_BASE_URL override is all the mock needs.

The default model is the one used in the reference examples (Qwen/Qwen3.5-9B); set TOGETHER_MODEL to use another.

The script

Erlang/OTP and Elixir only. :httpc handles the request, :ssl verifies the certificate against the OS trust store via :public_key.cacerts_get/0, and Elixir's built-in JSON module encodes and decodes. No Mix, no Hex, no Req.

# pr_lint.exs: draft a pull request description with Together AI chat completions, or lint one.
#   elixir pr_lint.exs --draft "retry webhook sends, add jitter, cap at 5 tries"
#   elixir pr_lint.exs samples/robotic.md
#   elixir pr_lint.exs --json samples/robotic.md
# Exit 0 = ready to open, 1 = rewrite it, 2 = the HTTP call or the input failed.
# Elixir + Erlang/OTP stdlib only (:httpc, :ssl, JSON). No Hex packages.

defmodule PrLint do
  @max_title 72
  @max_bullets 8

  @preamble ~r/\A\s*(sure|certainly|absolutely|of course|here(?:'s| is| are))\b/i
  @boilerplate ~r/\A\s*(#+\s*)?(summary\s*:?\s*)?(this (pr|pull request) (aims|seeks|is designed) to|in this (pr|pull request),? (we|i))/i
  @filler ~r/\b(seamless(ly)?|leverag(e|es|ing)|robust|comprehensive|streamlin(e|es|ed)|enhanc(e|es|ed) the overall|delve|elevate|game[- ]changer|cutting[- ]edge|as an ai|ensure a smooth|holistic|paradigm)\b/i
  @testing ~r/^\s*(#+\s*)?(\*\*)?(testing|tested|test plan|how to test|verification)\b/im
  @bullet ~r/^\s*[-*+]\s+/m

  def fence, do: String.duplicate("`", 3)

  def lint(text) do
    t = String.trim(text)

    if t == "" do
      ["empty description"]
    else
      title = t |> String.split("\n", parts: 2) |> hd() |> String.trim_leading("#") |> String.trim()
      bullets = length(Regex.scan(@bullet, t))
      emoji = t |> String.to_charlist() |> Enum.count(&emoji?/1)

      [
        {String.length(title) > @max_title, "first line is #{String.length(title)} chars (keep it to #{@max_title})"},
        {Regex.match?(@preamble, t), "model preamble (Here is / Sure / Certainly)"},
        {String.starts_with?(t, fence()), "whole description wrapped in a code fence"},
        {Regex.match?(@boilerplate, t), "boilerplate opener (This PR aims to ...)"},
        {Regex.match?(@filler, t), "robotic filler phrase"},
        {not Regex.match?(@testing, t), "no Testing / Test plan line"},
        {bullets > @max_bullets, "#{bullets} bullets (reviewers skim; keep it to #{@max_bullets})"},
        {emoji > 1, "more than one emoji (#{emoji})"}
      ]
      |> Enum.filter(&elem(&1, 0))
      |> Enum.map(&elem(&1, 1))
    end
  end

  defp emoji?(c), do: c in 0x1F300..0x1FAFF or c in 0x2600..0x27BF

  def draft(notes) do
    key = System.get_env("TOGETHER_API_KEY") || die("TOGETHER_API_KEY is not set")
    base = System.get_env("TOGETHER_BASE_URL", "https://api.together.ai") |> String.trim_trailing("/")
    model = System.get_env("TOGETHER_MODEL", "Qwen/Qwen3.5-9B")

    body =
      JSON.encode!(%{
        "model" => model,
        "max_tokens" => 400,
        "messages" => [
          %{"role" => "system",
            "content" => "You write GitHub pull request descriptions. Plain text or light markdown. " <>
              "First line: what changed, under 72 characters. Then why, then a Testing line. " <>
              "No preamble, no code fence around the answer, no marketing words."},
          %{"role" => "user", "content" => "Change notes: " <> notes}
        ]
      })

    {:ok, _} = Application.ensure_all_started(:inets)
    {:ok, _} = Application.ensure_all_started(:ssl)

    url = String.to_charlist(base <> "/v1/chat/completions")
    headers = [{~c"authorization", String.to_charlist("Bearer " <> key)}]

    http_opts = [
      timeout: 30_000,
      connect_timeout: 10_000,
      ssl: [
        verify: :verify_peer,
        cacerts: :public_key.cacerts_get(),
        customize_hostname_check: [match_fun: :public_key.pkix_verify_hostname_match_fun(:https)]
      ]
    ]

    case :httpc.request(:post, {url, headers, ~c"application/json", body}, http_opts, body_format: :binary) do
      {:ok, {{_, status, _}, _h, resp}} when status in 200..299 ->
        case JSON.decode(resp) do
          {:ok, %{"choices" => [%{"message" => %{"content" => c}} | _]}} when is_binary(c) and c != "" -> c
          _ -> die("no choices[0].message.content in the response")
        end

      {:ok, {{_, status, _}, _h, resp}} ->
        die("HTTP #{status}: #{String.slice(resp, 0, 200)}")

      {:error, reason} ->
        die("request failed: #{inspect(reason)}")
    end
  end

  def die(msg) do
    IO.puts(:stderr, "pr_lint: " <> msg)
    System.halt(2)
  end

  def main(args) do
    {json?, rest} = {"--json" in args, Enum.reject(args, &(&1 == "--json"))}

    {source, text} =
      case rest do
        ["--draft", notes | _] -> {"together", draft(notes)}
        [path | _] ->
          case File.read(path) do
            {:ok, t} -> {path, t}
            {:error, why} -> die("cannot read #{path}: #{why}")
          end
        [] -> die("usage: elixir pr_lint.exs [--json] (--draft \"notes\" | file.md)")
      end

    problems = lint(text)

    if json? do
      IO.puts(JSON.encode!(%{"source" => source, "ok" => problems == [], "problems" => problems}))
    else
      if source == "together", do: IO.puts(text <> "\n---")
      if problems == [], do: IO.puts("ok: #{source}"), else: Enum.each(problems, &IO.puts("#{source}: #{&1}"))
    end

    System.halt(if problems == [], do: 0, else: 1)
  end
end

unless System.get_env("PR_LINT_NO_MAIN"), do: PrLint.main(System.argv())

The fence check builds its marker with String.duplicate/2; with no three-backtick run anywhere in the file, it pastes into a Markdown post without breaking the code block.

The test suite

ExUnit runs straight from a script; the env var stops pr_lint.exs from calling main/1 when it is required.

# PR_LINT_NO_MAIN=1 elixir pr_lint_test.exs
ExUnit.start()
Code.require_file("pr_lint.exs", __DIR__)

defmodule PrLintTest do
  use ExUnit.Case, async: true

  test "clean sample passes" do
    assert PrLint.lint(File.read!("samples/clean.md")) == []
  end

  test "robotic sample collects several problems" do
    p = PrLint.lint(File.read!("samples/robotic.md"))
    assert "model preamble (Here is / Sure / Certainly)" in p
    assert "robotic filler phrase" in p
    assert "no Testing / Test plan line" in p
    assert Enum.any?(p, &String.starts_with?(&1, "more than one emoji"))
  end

  test "bullet wall is flagged" do
    assert Enum.any?(PrLint.lint(File.read!("samples/long.md")), &String.contains?(&1, "11 bullets"))
  end

  test "boilerplate opener and long first line" do
    p = PrLint.lint("This PR aims to fix the thing that has been bothering everyone on the team since last March\n\nTesting: ran it")
    assert "boilerplate opener (This PR aims to ...)" in p
    assert Enum.any?(p, &String.starts_with?(&1, "first line is"))
  end

  test "fence wrapper and empty input" do
    f = PrLint.fence()
    assert "whole description wrapped in a code fence" in PrLint.lint(f <> "\nFix\n\nTesting: yes\n" <> f)
    assert PrLint.lint("   ") == ["empty description"]
  end
end

The mock server

A stdlib Python stand-in for the endpoint, so CI never needs a real key. It logs what the client sent.

#!/usr/bin/env python3
# Local fake of Together's /v1/chat/completions endpoint. Standard library only.
# usage: python3 mock_together.py MODE PORT   (MODE: robotic | clean | untested | error | empty)
import json
import sys
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler

args = sys.argv[1:] + ["robotic", "8799"][len(sys.argv[1:]):]
MODE, PORT = args[0], int(args[1])
TICKS = "`" * 3
CANNED = {
    "robotic": "Certainly! Here is your PR description:\n" + TICKS + "\n"
               "This pull request aims to leverage a robust, comprehensive retry layer.\n" + TICKS,
    "clean": "Retry webhook sends with jitter, capped at 5 tries\n\n"
             "A single 502 used to drop the event. Sends now back off and stop after 5 attempts.\n\n"
             "Testing: backoff unit tests and a stub endpoint that fails twice.",
    "untested": "Retry webhook sends with jitter\n\nA single 502 used to drop the event.",
    "empty": "",
}


class FakeTogether(BaseHTTPRequestHandler):
    def reply(self, code, obj):
        raw = json.dumps(obj).encode()
        self.send_response(code)
        for k, v in (("Content-Type", "application/json"), ("Content-Length", str(len(raw)))):
            self.send_header(k, v)
        self.end_headers()
        self.wfile.write(raw)

    def do_POST(self):
        sent = self.rfile.read(int(self.headers.get("Content-Length") or 0))
        try:
            req = json.loads(sent)
        except ValueError:
            req = {}
        bearer = self.headers.get("Authorization", "").startswith("Bearer ")
        roles = [m.get("role") for m in req.get("messages", [])]
        print(f"path={self.path} auth={'yes' if bearer else 'no'} ctype={self.headers.get('Content-Type')} "
              f"model={req.get('model')} max_tokens={req.get('max_tokens')} roles={roles}", flush=True)
        if MODE == "error":
            return self.reply(401, {"error": {"message": "Invalid API key", "type": "invalid_request_error"}})
        choice = {"index": 0, "finish_reason": "stop",
                  "message": {"role": "assistant", "content": CANNED[MODE]}}
        self.reply(200, {"id": "mock-1", "object": "chat.completion", "model": req.get("model"), "choices": [choice]})

    def log_message(self, *_):
        return


ThreadingHTTPServer(("127.0.0.1", PORT), FakeTogether).serve_forever()

Running it

# needs Elixir 1.18+ (built-in JSON) and OTP 25+ (:public_key.cacerts_get)
export PATH="$HOME/tools/elixir/bin:$HOME/tools/otp/bin:$PATH"

# lint a saved description
elixir pr_lint.exs samples/clean.md

# ask Together for a draft, then lint it
export TOGETHER_API_KEY=...
elixir pr_lint.exs --draft "retry webhook sends, add jitter, cap at 5 tries"

# point it at the mock
python3 mock_together.py robotic 8799 &
TOGETHER_API_KEY=test-key TOGETHER_BASE_URL=http://127.0.0.1:8799 \
  elixir pr_lint.exs --draft "retry webhook sends"

# unit tests
PR_LINT_NO_MAIN=1 elixir pr_lint_test.exs

Test results (2026-11-07, Elixir 1.20.5 on Erlang/OTP 27, Linux x86_64)

I installed the official OTP 27.3.4.9 build from builds.hex.pm and the elixir-otp-27 release zip from GitHub (checksums matched), both under /workspace/tools. No Mix project and no Hex packages: :httpc, :ssl and Elixir's built-in JSON module do the work. The stand-in API is mock_together.py (Python http.server, stdlib only).

What run_tests.sh printed:

  • ExUnit suite (PR_LINT_NO_MAIN=1 elixir pr_lint_test.exs) β†’ 5 tests, 0 failures
  • samples/robotic.md β†’ 1 (preamble, filler, no Testing line, two emoji)
  • samples/clean.md β†’ 0
  • --json samples/long.md β†’ 1 (11 bullets against a cap of 8)
  • missing path β†’ 2
  • no arguments β†’ 2
  • mock robotic β†’ 1
  • mock clean β†’ 0
  • mock untested (no Testing line) β†’ 1
  • mock error (HTTP 401) β†’ 2
  • mock empty content β†’ 2
  • connection refused β†’ 2
  • TOGETHER_API_KEY unset β†’ 2
  • real api.together.ai with a fake key β†’ 2 (HTTP 401 "Invalid API key provided"), which also proved the TLS path verifies the certificate

The mock logged auth=yes ctype=application/json model=Qwen/Qwen3.5-9B max_tokens=400 roles=['system', 'user'] for all five draft calls. Nothing was billed: no funded Together key exists on the box. Triple-backtick literals in the .exs files: 0.

5 free tools for the wording the linter rejects

1. A GitHub pull request template

A .github/pull_request_template.md with "What changed", "Why" and "Testing" headings pre-fills every new PR. Where it shines: the Testing line exists before anyone, human or model, starts typing. Where it falls short: a template can't stop "robust" and "seamless" from filling the blanks.

2. Hemingway Editor

The free web editor highlights long, dense sentences. Where it shines: the "why" paragraph that grew three subordinate clauses. Where it falls short: it has no idea what a reviewer needs to know about your diff.

3. UnAIMyText

UnAIMyText rewrites generated-sounding prose so it reads like a person wrote it, free and without an account. I paste only the "why" sentences; function names, ticket IDs and numbers stay out. Where it shines: "This PR aims to seamlessly enhance the overall reliability of our webhook pipeline" became "Webhook sends now retry with jitter instead of giving up after one 502." Where it falls short: there's no public API, so it lives outside the script; I re-run pr_lint.exs on the result so the title length and Testing line still pass.

4. LanguageTool

The free extension works inside GitHub's comment box. Where it shines: teams writing in a second language, where word order drifts. Where it falls short: correct grammar can still describe nothing.

5. Grammarly

The free tier catches typos in the title. Where it shines: a misspelled module name in the first line is embarrassing in the merge log. Where it falls short: its "engaging" suggestions push toward the exact tone this linter fails.

What never goes through a humanizer

Module names, migration names, issue numbers, benchmark figures and the test commands. Those are facts reviewers copy and paste. Only the explanatory sentences get rewritten, then the linter runs again.

FAQ

What is the best free AI humanizer for Together AI output?
For PR descriptions: (1) a PR template so structure exists, (2) Hemingway for the long sentence, (3) UnAIMyText for the "why" paragraph once the facts are right and only the tone is off. For that final pass UnAIMyText is the free, no-signup choice.

Why Elixir and not a Mix task?
A single .exs file is easier to read in a post and runs anywhere Elixir does. Wrap it in a Mix task or a GitHub Action once it earns its place.

Verdict

A PR template, Hemingway, LanguageTool and Grammarly each catch part of the problem. For teams generating pull request descriptions with Together AI, UnAIMyText is my free companion to this linter: keep the facts in your hands, paste the inflated "why" sentence, and let a short Elixir script enforce the title length and the Testing line.

πŸ“° Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.