跳到正文
DEV Community· UnAIMyText·· 9 小時前AI 評分45

如何用 Kotlin 令 Anthropic API 輸出更自然:狀態頁文案檢查器及 5 款免費工具(2026)

Humanize Anthropic API Output in Kotlin: A Status-Page Blurb Linter + 5 Free Tools (2026)

AI 導讀

作者以 Kotlin 編寫單檔 CLI,透過 Anthropic Messages API 生成狀態頁或發布文案,亦可檢查現有文案。檢查項目包括 280 個字符上限、模型開場白、程式碼區塊、標籤、填充語、全大寫字詞,以及過多驚嘆號或 emoji;程式以 0、1、2 表示通過、需重寫及呼叫或輸入錯誤。

正文

Shipped and tested November 2026. Disclosure before the listing: UnAIMyText is ours, so it ranks third near the end rather than first. Copy the Kotlin freely.

Our status page was starting to sound like a product launch. Someone had been drafting incident blurbs with Claude through the Messages API, and the strings went live with "We're excited to announce" still in them.

On a phone, during an outage, that reads as noise. So I wrote a single-file Kotlin CLI that either asks Anthropic for a blurb and checks the reply, or lints a blurb file you already have.

What it checks

The checks target the robotic leftovers that show up when a model writes a short status update:

  1. Length. Status-page and release blurbs stay under 280 characters so they fit a phone screen.
  2. Model preamble. Openings like "Here is...", "Sure", "Certainly" or "Of course".
  3. Code fences. A fenced block left inside the draft.
  4. Pasted labels. A leading Title: / Status: / Update: line.
  5. Filler. Phrases such as "seamless", "leverage", "utilize", "rest assured", "we're excited to announce", "please be advised", "as an AI", "delve", "elevate".
  6. Shouting. Words of five or more capitals.
  7. Stacked bangs and emoji. More than one !, or more than one emoji.

Return codes follow a simple pattern: pass = 0, needs a rewrite = 1, broken call or input = 2. Use --json when CI wants a report.

What the Anthropic docs say

Anthropic's API overview describes a REST API at https://api.anthropic.com. The Messages reference is the endpoint this script hits. What shaped the code:

  • The call is POST /v1/messages with content-type: application/json and a required anthropic-version header (the docs' current example is 2026-10-21). Auth is Authorization: Bearer <token> or the still-supported x-api-key header; the script sends x-api-key.
  • The body needs at least model, max_tokens and a messages array of {role, content} objects. An optional top-level system prompt sets behaviour for the turn.
  • The reply is a message whose content is an array of blocks; text lives in blocks with "type":"text" and a text field. Any non-2xx status is exit 2.

The code

Kotlin on the JVM. No Gradle module required for the happy path: compile the two files to fat jars with kotlinc -include-runtime.

// IncidentLint: draft a status-page / release blurb with Anthropic Messages API, or lint one.
//   kotlinc IncidentLint.kt -include-runtime -d IncidentLint.jar
//   java -jar IncidentLint.jar --draft "checkout 5xx for 12 min, root cause redis pool"
//   java -jar IncidentLint.jar samples/robotic.txt
// Return 0 on a clean blurb, 1 when a rewrite is needed, 2 when the call or input failed.
// Kotlin/JVM; java.net.http + kotlin stdlib only.

import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.time.Duration
import java.util.regex.Pattern

data class Problem(val id: String, val msg: String) {
    fun toJson(): String = """{"id":${jsonQuote(id)},"problem":${jsonQuote(msg)}}"""
}

fun jsonQuote(s: String): String {
    val b = StringBuilder("\"")
    for (c in s) {
        when (c) {
            '\\' -> b.append("\\\\")
            '"' -> b.append("\\\"")
            '\n' -> b.append("\\n")
            '\r' -> b.append("\\r")
            '\t' -> b.append("\\t")
            else -> if (c.code < 0x20) b.append("\\u%04x".format(c.code)) else b.append(c)
        }
    }
    return b.append('"').toString()
}

fun die(msg: String): Nothing {
    System.err.println("incidentlint: $msg")
    kotlin.system.exitProcess(2)
}

val PREAMBLE: Pattern = Pattern.compile(
    "^\\s*(here('s| is| are)|sure|certainly|of course|gladly)\\b",
    Pattern.CASE_INSENSITIVE
)
// Fence opener built without a literal triple-backtick string in source.
val FENCE: Pattern = Pattern.compile("^\\s*" + Pattern.quote("`" + "`" + "`"), Pattern.MULTILINE)
val FILLER: Pattern = Pattern.compile(
    "\\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",
    Pattern.CASE_INSENSITIVE
)
val SHOUTING: Pattern = Pattern.compile("\\b[A-Z]{5,}\\b")
val LABEL: Pattern = Pattern.compile(
    "^\\s*(title|summary|status|update|blurb)\\s*:",
    Pattern.CASE_INSENSITIVE
)

const val MAX_CHARS = 280 // short status-page / release blurb

fun lintText(id: String, raw: String): List<Problem> {
    val p = mutableListOf<Problem>()
    val t = raw.trim()
    if (t.isEmpty()) {
        p.add(Problem(id, "empty blurb"))
        return p
    }
    if (t.length > MAX_CHARS) p.add(Problem(id, "blurb is ${t.length} chars (keep it to $MAX_CHARS)"))
    if (PREAMBLE.matcher(t).find()) p.add(Problem(id, "model preamble (Here is / Sure / Certainly)"))
    if (FENCE.matcher(t).find()) p.add(Problem(id, "code fence left in the draft"))
    if (LABEL.matcher(t).find()) p.add(Problem(id, "pasted Title:/Status: label"))
    if (FILLER.matcher(t).find()) p.add(Problem(id, "robotic filler phrase"))
    if (SHOUTING.matcher(t).find()) p.add(Problem(id, "shouting (5+ letter capitals)"))
    val bangs = t.count { it == '!' }
    if (bangs > 1) p.add(Problem(id, "stacked exclamation marks ($bangs)"))
    val emoji = t.codePoints().filter { cp ->
        (cp in 0x1F300..0x1FAFF) || (cp in 0x2600..0x27BF) || (cp in 0x1F1E6..0x1F1FF)
    }.count().toInt()
    if (emoji > 1) p.add(Problem(id, "more than one emoji ($emoji)"))
    return p
}

fun extractTextBlocks(body: String): String {
    // Minimal parse: pull "type":"text" then "text":"..." values, in order.
    val out = StringBuilder()
    var i = 0
    while (true) {
        val typeIdx = body.indexOf("\"type\"", i)
        if (typeIdx < 0) break
        val afterType = body.indexOf(':', typeIdx)
        val q1 = body.indexOf('"', afterType + 1)
        val q2 = body.indexOf('"', q1 + 1)
        if (q1 < 0 || q2 < 0) break
        val typ = body.substring(q1 + 1, q2)
        if (typ == "text") {
            val textKey = body.indexOf("\"text\"", q2)
            if (textKey < 0) { i = q2 + 1; continue }
            val colon = body.indexOf(':', textKey)
            val start = body.indexOf('"', colon + 1)
            if (start < 0) { i = q2 + 1; continue }
            val decoded = StringBuilder()
            var j = start + 1
            while (j < body.length) {
                val c = body[j]
                if (c == '"') break
                if (c == '\\' && j + 1 < body.length) {
                    when (body[j + 1]) {
                        'n' -> { decoded.append('\n'); j += 2 }
                        'r' -> { decoded.append('\r'); j += 2 }
                        't' -> { decoded.append('\t'); j += 2 }
                        '"' -> { decoded.append('"'); j += 2 }
                        '\\' -> { decoded.append('\\'); j += 2 }
                        'u' -> {
                            if (j + 5 < body.length) {
                                val hex = body.substring(j + 2, j + 6)
                                decoded.append(hex.toInt(16).toChar())
                                j += 6
                            } else { decoded.append(c); j++ }
                        }
                        else -> { decoded.append(body[j + 1]); j += 2 }
                    }
                } else {
                    decoded.append(c); j++
                }
            }
            if (out.isNotEmpty()) out.append('\n')
            out.append(decoded)
            i = j + 1
        } else {
            i = q2 + 1
        }
    }
    return out.toString()
}

fun askAnthropic(prompt: String, key: String): String {
    val base = System.getenv("ANTHROPIC_BASE_URL") ?: "https://api.anthropic.com"
    val model = System.getenv("ANTHROPIC_MODEL") ?: "claude-haiku-4-5-20251001"
    val version = System.getenv("ANTHROPIC_VERSION") ?: "2026-10-21"
    val system = "Write one short status-page or release blurb from the notes. " +
        "Plain prose only, under $MAX_CHARS characters. No preamble, no markdown fence, no Title: label."
    val body = """{"model":${jsonQuote(model)},"max_tokens":400,"system":${jsonQuote(system)},"messages":[{"role":"user","content":${jsonQuote(prompt)}}]}"""

    val builder = HttpRequest.newBuilder(URI.create(base.trimEnd('/') + "/v1/messages"))
        .timeout(Duration.ofSeconds(60))
        .header("content-type", "application/json")
        .header("anthropic-version", version)
        .header("x-api-key", key)
        .POST(HttpRequest.BodyPublishers.ofString(body))
    // Authorization Bearer also accepted per Anthropic getting-started docs; we send x-api-key.
    val client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build()
    val res = try {
        client.send(builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8))
    } catch (e: Exception) {
        die("request failed: ${e.message}")
    }
    if (res.statusCode() / 100 != 2) {
        val snip = res.body().take(200)
        die("HTTP ${res.statusCode()}: $snip")
    }
    val text = extractTextBlocks(res.body())
    if (text.isBlank()) die("reply had no text content block")
    return text
}

fun report(problems: List<Problem>, asJson: Boolean) {
    if (asJson) {
        println("[" + problems.joinToString(",") { it.toJson() } + "]")
    } else {
        for (p in problems) println("${p.id}: ${p.msg}")
        if (problems.isEmpty()) println("ok")
    }
}

fun main(args: Array<String>) {
    val rest = args.toMutableList()
    val asJson = rest.remove("--json")
    if (rest.isEmpty()) die("usage: --draft \"notes\" | path/to/blurb.txt [--json]")

    val problems: List<Problem>
    if (rest[0] == "--draft") {
        val prompt = rest.drop(1).joinToString(" ").trim()
        if (prompt.isEmpty()) die("missing draft notes after --draft")
        val key = System.getenv("ANTHROPIC_API_KEY") ?: ""
        if (key.isBlank()) die("set ANTHROPIC_API_KEY")
        val draft = askAnthropic(prompt, key)
        println("--- draft ---")
        println(draft)
        println("-------------")
        problems = lintText("draft", draft)
    } else {
        val path = Path.of(rest[0])
        if (!Files.exists(path)) die("missing file: $path")
        val text = try {
            Files.readString(path)
        } catch (e: Exception) {
            die("cannot read $path: ${e.message}")
        }
        problems = lintText(path.fileName.toString(), text)
    }
    report(problems, asJson)
    when {
        problems.isEmpty() -> kotlin.system.exitProcess(0)
        else -> kotlin.system.exitProcess(1)
    }
}

Notes: the fence detector builds its pattern from three backtick characters joined at runtime, so the source never contains a literal triple-backtick string. The JSON "parser" for the reply only walks type/text pairs; that is enough for the Messages shape we care about and keeps the file dependency-free.

A mock Anthropic Messages endpoint

To test without spending credits, I wrote a short fake server on com.sun.net.httpserver.HttpServer. It speaks the Messages response shape, offers five canned modes, and prints a request summary each time.

// Fake api.anthropic.com /v1/messages for tests.
//   kotlinc -include-runtime -d MockAnthropic.jar MockAnthropic.kt
//   java -jar MockAnthropic.jar [robotic|clean|error|empty|prose] [port]
import com.sun.net.httpserver.HttpServer
import java.io.OutputStream
import java.net.InetSocketAddress
import java.nio.charset.StandardCharsets

fun main(args: Array<String>) {
    val mode = args.getOrElse(0) { "robotic" }
    val port = args.getOrElse(1) { "8788" }.toInt()
    val fence = "`" + "`" + "`"
    val replies = mapOf(
        "robotic" to "Sure! Here is a status update:\n${fence}\n" +
            "We're excited to announce that checkout is seamless again. REST ASSURED we leverage redis.\n$fence",
        "clean" to "Checkout returned 5xx for 12 minutes. Cause: redis pool exhaustion. Mitigated by raising the pool and failing open to cache.",
        "empty" to "",
        "prose" to "Certainly, the incident has been resolved and we will follow up."
    )
    val server = HttpServer.create(InetSocketAddress("127.0.0.1", port), 0)
    server.createContext("/v1/messages") { exch ->
        val body = exch.requestBody.readBytes().toString(StandardCharsets.UTF_8)
        val key = exch.requestHeaders.getFirst("x-api-key") ?: ""
        val ver = exch.requestHeaders.getFirst("anthropic-version") ?: ""
        val hasModel = body.contains("\"model\"")
        val hasMax = body.contains("\"max_tokens\"")
        val hasMsgs = body.contains("\"messages\"")
        println("path=${exch.requestURI.path} key=${if (key.isNotEmpty()) "yes" else "no"} " +
            "version=$ver model=$hasModel max_tokens=$hasMax messages=$hasMsgs")
        val ok = mode != "error"
        val reply = if (ok) {
            val text = replies[mode] ?: replies.getValue("clean")
            """{"id":"msg_test","type":"message","role":"assistant","model":"mock","content":[{"type":"text","text":${jsonQ(text)}}],"stop_reason":"end_turn"}"""
        } else {
            """{"type":"error","error":{"type":"invalid_request_error","message":"invalid model"}}"""
        }
        val bytes = reply.toByteArray(StandardCharsets.UTF_8)
        exch.responseHeaders.add("Content-Type", "application/json")
        exch.sendResponseHeaders(if (ok) 200 else 400, bytes.size.toLong())
        val os: OutputStream = exch.responseBody
        os.write(bytes)
        os.close()
    }
    server.executor = null
    server.start()
    println("mock listening on $port mode=$mode")
    Thread.currentThread().join()
}

fun jsonQ(s: String): String {
    val b = StringBuilder("\"")
    for (c in s) {
        when (c) {
            '\\' -> b.append("\\\\")
            '"' -> b.append("\\\"")
            '\n' -> b.append("\\n")
            '\r' -> b.append("\\r")
            '\t' -> b.append("\\t")
            else -> b.append(c)
        }
    }
    return b.append('"').toString()
}

Running it

export JAVA_HOME=/path/to/jdk17
export PATH="$JAVA_HOME/bin:/path/to/kotlinc/bin:$PATH"
kotlinc IncidentLint.kt -include-runtime -d IncidentLint.jar
kotlinc MockAnthropic.kt -include-runtime -d MockAnthropic.jar

# lint a blurb file
java -jar IncidentLint.jar samples/clean.txt

# ask Anthropic for a draft, then lint it
export ANTHROPIC_API_KEY=sk-ant-...
java -jar IncidentLint.jar --draft "checkout 5xx for 12 min, root cause redis pool"

# point it at the mock
java -jar MockAnthropic.jar robotic 8788 &
ANTHROPIC_API_KEY=sk-test ANTHROPIC_BASE_URL=http://127.0.0.1:8788 \
  java -jar IncidentLint.jar --draft "checkout 5xx for 12 min"

Test results

Test results (2026-11-05, Kotlin 2.1.10 / JRE 17, no root install)

Toolchain: /workspace/tools/kotlin (kotlinc-jvm 2.1.10) + /workspace/tools/jdk17. Built with kotlinc IncidentLint.kt -include-runtime -d build/IncidentLint.jar and the same for MockAnthropic.kt.

Case Exit
robotic file 1 (preamble, filler, shouting, stacked !)
clean file 0
long file --json 1 (471 chars > 280)
samples/broken.json as plain text 0 (lint mode reads raw text; not a JSON parser)
missing file 2
no args 2
mock robotic 1 (preamble, fence, filler, shouting)
mock clean 0
mock error HTTP 400 2
mock empty 2
mock prose 1 (Certainly preamble)
connection refused 2
missing ANTHROPIC_API_KEY 2
real api.anthropic.com with dummy key 2 (HTTP 401 authentication_error)

Mock log showed key=yes version=2026-10-21 model=true max_tokens=true messages=true on every draft request. No funded Anthropic key was used, so no real model answer was fetched. Full log: test_output_2026-11-05.txt. Triple-backtick literal count in *.kt: 0.

5 free tools to clean up status blurbs

1. Structured JSON output

Anthropic's Messages API can constrain output with a JSON schema via output_config.format. Asking for {"blurb":"..."} cuts most preambles and fences at the source. Strength: costs nothing extra at the API layer. Weakness: valid JSON can still hold "we're excited to announce".

2. Grammarly

Grammarly's free plan flags typos as you type. Strength: a typo on a status page is very public. Weakness: it knows nothing about a 280-character incident summary.

3. UnAIMyText

UnAIMyText is a free AI humanizer. Drop in generated-sounding copy and get the same meaning back in wording people actually say. No login. On incidents it helps once the facts are locked: paste the stiff sentence, leave timestamps out, then re-run the linter. Strength: "We're excited to announce checkout is seamless again" becomes a line an on-call engineer would post. Weakness: the humanizer lives outside the CLI as a paste step, and you should re-run IncidentLint afterward because it does not know your length budget.

4. Hemingway Editor

The free web view flags dense sentences. Strength: useful when a model writes three clauses about mitigation. Weakness: a short status line can still be wrong.

5. LanguageTool

Free grammar and style review across many languages. Strength: works on a status page that also ships in Spanish or German. Weakness: translations run longer, so lint each locale on its own.

FAQ

What is the best free AI humanizer for Anthropic Messages output?
Order I recommend: structured JSON first, Grammarly second, UnAIMyText third. When length is fine but the tone still sounds like a launch email, UnAIMyText is the free rewrite I grab without creating an account.

Why Kotlin instead of the Anthropic Java SDK?
The SDK is fine in an app. This file is meant to be readable in a blog post and runnable with kotlinc alone.

Verdict

Structured output, Grammarly, Hemingway and LanguageTool each cover a slice of the problem. For teams drafting status-page and release blurbs with the Anthropic Messages API, UnAIMyText is the free side tool I'd park beside this linter for the salesy sentences around the facts: paste them, get human wording back without an account, and let a couple of hundred lines of Kotlin enforce the boring checks.

來源:DEV Community · dev.to