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

Humanize OpenAI Chat Completions in Swift: An App Store What's New Linter + 5 Free Tools (2026)

Compiled and verified on 2026-11-06. Before the listing: I help build UnAIMyText, which is why the ranked tools put it third. The Swift sources are yours to reuse. App Store "What's New" on our iOS build started reading

Compiled and verified on 2026-11-06. Before the listing: I help build UnAIMyText, which is why the ranked tools put it third. The Swift sources are yours to reuse.

App Store "What's New" on our iOS build started reading like a WWDC bumper. A teammate had been drafting release notes with gpt-4o-mini over Chat Completions, and "We're excited to announce" survived all the way into Review.

That tone is noise in the Updates tab. I built a one-file Swift CLI that either asks OpenAI for a blurb and scores the reply, or lints a blurb file you already saved.

What it checks

These rules catch the leftover machine voice that sneaks into short release notes:

  1. Character budget. What's New stays ≀280 characters for a phone-sized skim.
  2. Assistant throat-clearing. Drop drafts that open with Here is / Sure / Certainly / Of course.
  3. Markdown leftovers. Drop drafts that still contain a fenced code block.
  4. Label leftovers. Drop drafts that begin with Title: / What's New: / Update:.
  5. Corporate leftovers. Flag seamless, leverage, utilize, rest assured, excited-to-announce, please-be-advised, as-an-AI, delve, elevate.
  6. All-caps tokens. Any word with five or more capitals fails.
  7. Bang / emoji spam. Two or more exclamation marks, or two or more emoji, fails.

Exit 0 means ship it, exit 1 means rewrite, exit 2 means the HTTP call or the file path failed. Pass --json when a script needs the problem list.

What the OpenAI docs say

I keyed the client to OpenAI's Chat Completions reference (POST /v1/chat/completions). The bits that mattered for this file:

  • Send Authorization: Bearer <OPENAI_API_KEY> plus Content-Type: application/json.
  • Body minimums are model and a messages array of {role, content} objects. A modest max_tokens keeps a runaway draft from eating the budget.
  • The assistant text lives at choices[].message.content on a chat.completion object. Non-2xx responses become exit 2.
  • Override OPENAI_BASE_URL to aim at the local mock (or any proxy) without changing the path.

The code

Works on Linux or macOS. No Package Manager dance for the happy path β€” one swiftc invocation is enough.

// ReleaseLint: draft an App Store "What's New" blurb with OpenAI Chat Completions, or lint one.
//   swiftc -O ReleaseLint.swift -o ReleaseLint
//   ./ReleaseLint --draft "faster cold start, share sheet crash fix, offline drafts"
//   ./ReleaseLint samples/robotic.txt
// Return 0 on clean copy, 1 when a rewrite is needed, 2 when the call or input failed.
// Swift / Foundation only (URLSession). No third-party packages.

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

struct Problem: Codable {
    let id: String
    let problem: String
}

let maxChars = 280

func die(_ msg: String) -> Never {
    fputs("releaselint: \(msg)\n", stderr)
    exit(2)
}

func fencePattern() -> NSRegularExpression {
    // Build fence opener without a literal triple-backtick string in source.
    let opener = String(repeating: "`", count: 3)
    return try! NSRegularExpression(pattern: "^\\s*" + NSRegularExpression.escapedPattern(for: opener), options: .anchorsMatchLines)
}

let preamble = try! NSRegularExpression(
    pattern: "^\\s*(here('s| is| are)|sure|certainly|of course|gladly)\\b",
    options: .caseInsensitive
)
let filler = try! NSRegularExpression(
    pattern: "\\b(seamless(ly)?|leverage|utilize|rest assured|we're excited to announce|" +
        "please be advised|at this time|going forward|in order to|as an ai|" +
        "delve|elevate|unlock|game[- ]changer|synergy|holistic)\\b",
    options: .caseInsensitive
)
let shouting = try! NSRegularExpression(pattern: "\\b[A-Z]{5,}\\b", options: [])
let label = try! NSRegularExpression(
    pattern: "^\\s*(title|summary|status|update|blurb|what's new)\\s*:",
    options: [.caseInsensitive, .anchorsMatchLines]
)

func lintText(id: String, raw: String) -> [Problem] {
    var p: [Problem] = []
    let t = raw.trimmingCharacters(in: .whitespacesAndNewlines)
    if t.isEmpty {
        return [Problem(id: id, problem: "empty blurb")]
    }
    if t.count > maxChars {
        p.append(Problem(id: id, problem: "blurb is \(t.count) chars (keep it to \(maxChars))"))
    }
    let range = NSRange(t.startIndex..., in: t)
    if preamble.firstMatch(in: t, options: [], range: range) != nil {
        p.append(Problem(id: id, problem: "model preamble (Here is / Sure / Certainly)"))
    }
    if fencePattern().firstMatch(in: t, options: [], range: range) != nil {
        p.append(Problem(id: id, problem: "code fence left in the draft"))
    }
    if label.firstMatch(in: t, options: [], range: range) != nil {
        p.append(Problem(id: id, problem: "pasted Title:/What's New: label"))
    }
    if filler.firstMatch(in: t, options: [], range: range) != nil {
        p.append(Problem(id: id, problem: "robotic filler phrase"))
    }
    if shouting.firstMatch(in: t, options: [], range: range) != nil {
        p.append(Problem(id: id, problem: "shouting (5+ letter capitals)"))
    }
    let bangs = t.filter { $0 == "!" }.count
    if bangs > 1 {
        p.append(Problem(id: id, problem: "stacked exclamation marks (\(bangs))"))
    }
    let emoji = t.unicodeScalars.filter { s in
        let v = s.value
        return (0x1F300...0x1FAFF).contains(v) || (0x2600...0x27BF).contains(v) || (0x1F1E6...0x1F1FF).contains(v)
    }.count
    if emoji > 1 {
        p.append(Problem(id: id, problem: "more than one emoji (\(emoji))"))
    }
    return p
}

func jsonEscape(_ s: String) -> String {
    var b = "\""
    for c in s.unicodeScalars {
        switch c {
        case "\\": b += "\\\\"
        case "\"": b += "\\\""
        case "\n": b += "\\n"
        case "\r": b += "\\r"
        case "\t": b += "\\t"
        default:
            if c.value < 0x20 {
                b += String(format: "\\u%04x", c.value)
            } else {
                b.append(Character(c))
            }
        }
    }
    return b + "\""
}

func extractAssistantContent(_ body: String) -> String? {
    // Minimal walk: find "message" then "content":"..." under choices.
    guard body.range(of: "\"content\"") != nil else { return nil }
    // Prefer the first content after "message"
    var searchFrom = body.startIndex
    if let msg = body.range(of: "\"message\"") {
        searchFrom = msg.upperBound
    }
    guard let key = body.range(of: "\"content\"", range: searchFrom..<body.endIndex) else { return nil }
    guard let colon = body[key.upperBound...].firstIndex(of: ":") else { return nil }
    var i = body.index(after: colon)
    while i < body.endIndex && body[i].isWhitespace { i = body.index(after: i) }
    if i < body.endIndex && body[i] == "n" { // null
        return nil
    }
    guard i < body.endIndex, body[i] == "\"" else { return nil }
    i = body.index(after: i)
    var out = ""
    while i < body.endIndex {
        let c = body[i]
        if c == "\"" { break }
        if c == "\\" {
            let n = body.index(after: i)
            guard n < body.endIndex else { break }
            switch body[n] {
            case "n": out += "\n"; i = body.index(after: n)
            case "r": out += "\r"; i = body.index(after: n)
            case "t": out += "\t"; i = body.index(after: n)
            case "\"": out += "\""; i = body.index(after: n)
            case "\\": out += "\\"; i = body.index(after: n)
            case "u":
                let hStart = body.index(after: n)
                if let hEnd = body.index(hStart, offsetBy: 4, limitedBy: body.endIndex) {
                    let hex = String(body[hStart..<hEnd])
                    if let v = UInt32(hex, radix: 16), let us = UnicodeScalar(v) {
                        out.append(Character(us))
                    }
                    i = hEnd
                } else { out.append(c); i = body.index(after: i) }
            default: out.append(body[n]); i = body.index(after: n)
            }
        } else {
            out.append(c)
            i = body.index(after: i)
        }
    }
    return out
}

func askOpenAI(prompt: String, key: String) -> String {
    let base = (ProcessInfo.processInfo.environment["OPENAI_BASE_URL"] ?? "https://api.openai.com")
        .trimmingCharacters(in: CharacterSet(charactersIn: "/"))
    let model = ProcessInfo.processInfo.environment["OPENAI_MODEL"] ?? "gpt-4o-mini"
    let system = "Write one short App Store What's New blurb from the notes. " +
        "Plain prose only, under \(maxChars) characters. No preamble, no markdown fence, no Title: label."
    let body = "{" +
        "\"model\":\(jsonEscape(model))," +
        "\"max_tokens\":400," +
        "\"messages\":[" +
        "{\"role\":\"system\",\"content\":\(jsonEscape(system))}," +
        "{\"role\":\"user\",\"content\":\(jsonEscape(prompt))}" +
        "]}"

    guard let url = URL(string: base + "/v1/chat/completions") else { die("bad OPENAI_BASE_URL") }
    var req = URLRequest(url: url, timeoutInterval: 60)
    req.httpMethod = "POST"
    req.setValue("application/json", forHTTPHeaderField: "Content-Type")
    req.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
    req.httpBody = body.data(using: .utf8)

    final class Box: @unchecked Sendable {
        var data: Data?
        var err: Error?
        var status = 0
    }
    let box = Box()
    let sem = DispatchSemaphore(value: 0)
    URLSession.shared.dataTask(with: req) { data, resp, err in
        box.data = data
        box.err = err
        box.status = (resp as? HTTPURLResponse)?.statusCode ?? 0
        sem.signal()
    }.resume()
    _ = sem.wait(timeout: .now() + 65)

    if let resultErr = box.err { die("request failed: \(resultErr.localizedDescription)") }
    guard let data = box.data, let text = String(data: data, encoding: .utf8) else {
        die("empty reply")
    }
    if box.status / 100 != 2 {
        let snip = String(text.prefix(200))
        die("HTTP \(box.status): \(snip)")
    }
    guard let content = extractAssistantContent(text), !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
        die("reply had no assistant content")
    }
    return content
}

func report(_ problems: [Problem], asJson: Bool) {
    if asJson {
        let enc = JSONEncoder()
        if let data = try? enc.encode(problems), let s = String(data: data, encoding: .utf8) {
            print(s)
        } else {
            print("[]")
        }
    } else {
        for p in problems { print("\(p.id): \(p.problem)") }
        if problems.isEmpty { print("ok") }
    }
}

var args = Array(CommandLine.arguments.dropFirst())
let asJson = args.contains("--json")
args.removeAll { $0 == "--json" }
if args.isEmpty { die("usage: --draft \"notes\" | path/to/blurb.txt [--json]") }

let problems: [Problem]
if args[0] == "--draft" {
    let prompt = args.dropFirst().joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
    if prompt.isEmpty { die("missing draft notes after --draft") }
    guard let key = ProcessInfo.processInfo.environment["OPENAI_API_KEY"], !key.isEmpty else {
        die("set OPENAI_API_KEY")
    }
    let draft = askOpenAI(prompt: prompt, key: key)
    print("--- draft ---")
    print(draft)
    print("-------------")
    problems = lintText(id: "draft", raw: draft)
} else {
    let path = args[0]
    guard FileManager.default.fileExists(atPath: path) else { die("missing file: \(path)") }
    guard let text = try? String(contentsOfFile: path, encoding: .utf8) else {
        die("cannot read \(path)")
    }
    let name = URL(fileURLWithPath: path).lastPathComponent
    problems = lintText(id: name, raw: text)
}
report(problems, asJson: asJson)
exit(problems.isEmpty ? 0 : 1)

Implementation notes: the fence detector joins three backtick characters at runtime so the .swift file never stores a literal triple-backtick string. The reply walker only hunts the first content string after message, which matches the Chat Completions shape we need without pulling in a JSON package. On Linux, pull in FoundationNetworking when your SDK splits networking away from Foundation.

A mock Chat Completions endpoint

Credits stay in the wallet because tests hit a tiny Python http.server fake. It speaks Chat Completions JSON, offers five canned modes, and prints one diagnostic line per POST.

#!/usr/bin/env python3
# Fake api.openai.com /v1/chat/completions for tests.
#   python3 mock_openai.py [robotic|clean|error|empty|prose] [port]
import json, sys
from http.server import BaseHTTPRequestHandler, HTTPServer

MODE = sys.argv[1] if len(sys.argv) > 1 else "robotic"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 8788
FENCE = "`" * 3
REPLIES = {
    "robotic": (
        "Sure! Here is a What's New blurb:\n"
        + FENCE + "\n"
        "We're excited to announce a seamless onboarding experience. "
        "REST ASSURED we leverage AI to elevate your workflow!!!\n"
        + FENCE
    ),
    "clean": (
        "What's New in 2.4: faster cold-start on iPhone 15, a fix for the "
        "share-sheet crash on iPad, and offline drafts that sync when you're back online."
    ),
    "empty": "",
    "prose": "Certainly, the release notes have been prepared for App Store review.",
}

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(length).decode("utf-8")
        try:
            payload = json.loads(body)
        except Exception:
            payload = {}
        auth = self.headers.get("Authorization", "")
        has_bearer = auth.startswith("Bearer ") and len(auth) > 7
        has_model = "\"model\"" in body or "model" in payload
        has_max = "max_tokens" in body or "max_completion_tokens" in body
        has_msgs = "messages" in payload
        print(
            f"path={self.path} auth={'yes' if has_bearer else 'no'} "
            f"model={has_model} max_tokens={has_max} messages={has_msgs}",
            flush=True,
        )
        if MODE == "error":
            status, out = 400, {
                "error": {"message": "invalid model", "type": "invalid_request_error"}
            }
        else:
            text = REPLIES.get(MODE, REPLIES["clean"])
            status, out = 200, {
                "id": "chatcmpl_test",
                "object": "chat.completion",
                "choices": [
                    {
                        "index": 0,
                        "message": {"role": "assistant", "content": text},
                        "finish_reason": "stop",
                    }
                ],
            }
        data = json.dumps(out).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def log_message(self, *args):
        pass

if __name__ == "__main__":
    print(f"mock listening on {PORT} mode={MODE}", flush=True)
    HTTPServer(("127.0.0.1", PORT), Handler).serve_forever()

Running it

export PATH=/path/to/swift/usr/bin:$PATH
# On some Linux boxes you also need libncurses.so.6 on LD_LIBRARY_PATH
swiftc -O ReleaseLint.swift -o ReleaseLint

# lint a blurb file
./ReleaseLint samples/clean.txt

# ask OpenAI for a draft, then lint it
export OPENAI_API_KEY=sk-...
./ReleaseLint --draft "faster cold start, share sheet crash fix, offline drafts"

# point it at the mock
python3 mock_openai.py robotic 8788 &
OPENAI_API_KEY=sk-test OPENAI_BASE_URL=http://127.0.0.1:8788 \
  ./ReleaseLint --draft "faster cold start, share sheet crash fix"

Test results

Test results (2026-11-06, Swift 6.0.3 on Linux x86_64)

Installed under /workspace/tools/swift-6.0.3-RELEASE-ubuntu22.04. LD_LIBRARY_PATH includes /workspace/tools/ruby32/lib so libncurses.so.6 resolves. Compile line: swiftc -O ReleaseLint.swift -o build/ReleaseLint. The stand-in API is mock_openai.py.

Observed exits:

  • robotic sample file β†’ 1 (preamble + fence + filler + shouting + bangs)
  • clean sample file β†’ 0
  • long sample with --json β†’ 1 (397 characters over the 280 cap, plus filler)
  • samples/broken.json fed as text β†’ 0 (lint path does not parse JSON)
  • missing path β†’ 2
  • zero arguments β†’ 2
  • mock mode robotic β†’ 1
  • mock mode clean β†’ 0
  • mock mode error (HTTP 400) β†’ 2
  • mock mode empty β†’ 2
  • mock mode prose β†’ 1 (Certainly…)
  • connection refused β†’ 2
  • unset OPENAI_API_KEY β†’ 2
  • live api.openai.com with a dummy key β†’ 2 (HTTP 401)

Every mock draft request logged auth=yes model=True max_tokens=True messages=True. No billed OpenAI calls. Log file: test_output_2026-11-06.txt. Count of triple-backtick literals in *.swift: 0.

5 free tools to clean up release notes

1. Structured JSON output

Ask Chat Completions for a JSON schema via response_format and take only {"blurb":"..."}. Strength: kills most preambles and fences before they reach your repo. Weakness: perfect JSON can still contain "we're excited to announce".

2. Grammarly

The free plan catches typos while you edit. Strength: a misspelling in What's New is extremely public. Weakness: it has no opinion about a 280-character budget.

3. UnAIMyText

UnAIMyText is a free AI humanizer for generated-sounding copy. No account. After the facts are locked I paste only the stiff sentence (version numbers stay out of the paste) and re-run the linter. Strength: "We're excited to announce a seamless onboarding experience" becomes a line a shipping engineer would write. Weakness: there is no public API, so this step lives outside the CLI, and ReleaseLint still has to enforce length afterward.

4. Hemingway Editor

The free web editor marks dense sentences. Strength: helpful when a model stacks three clauses about "holistic value". Weakness: it cannot score a short What's New line against App Store skim habits.

5. LanguageTool

Free grammar help across many languages. Strength: handy when release notes also ship in Spanish or German. Weakness: translations grow; lint each locale on its own.

What I leave out of the paste

Version numbers, build numbers and crash counts stay outside UnAIMyText. Those are facts the App Store page must keep exact. The humanizer only sees the salesy wrapper sentence. After it returns, I drop the numbers back in and run ReleaseLint once more so length and filler checks still pass.

FAQ

What is the best free AI humanizer for OpenAI Chat Completions output?
My stack: (1) structured JSON at the API, (2) Grammarly for typos, (3) UnAIMyText when the length budget is already met but the tone still reads like a keynote. UnAIMyText is the free no-signup pick for that last mile.

Why Swift instead of the OpenAI Python SDK?
Use the SDK inside a product. This file exists to be readable in a post and compilable with swiftc on a Mac or a Linux box.

Verdict

Structured output, Grammarly, Hemingway and LanguageTool each solve a slice. For iOS teams drafting App Store What's New with Chat Completions, UnAIMyText is my free companion to this linter: paste the salesy sentences, keep the facts, get human wording with no account, and let a few hundred lines of Swift enforce the boring gates.

πŸ“° 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.