如何用 Kotlin 令 Anthropic API 輸出更自然:狀態頁文案檢查器及 5 款免費工具(2026)
Humanize Anthropic API Output in Kotlin: A Status-Page Blurb Linter + 5 Free Tools (2026)
作者以 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:
- Length. Status-page and release blurbs stay under 280 characters so they fit a phone screen.
- Model preamble. Openings like "Here is...", "Sure", "Certainly" or "Of course".
- Code fences. A fenced block left inside the draft.
-
Pasted labels. A leading
Title:/Status:/Update:line. - Filler. Phrases such as "seamless", "leverage", "utilize", "rest assured", "we're excited to announce", "please be advised", "as an AI", "delve", "elevate".
- Shouting. Words of five or more capitals.
-
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/messageswithcontent-type: application/jsonand a requiredanthropic-versionheader (the docs' current example is2026-10-21). Auth isAuthorization: Bearer <token>or the still-supportedx-api-keyheader; the script sendsx-api-key. - The body needs at least
model,max_tokensand amessagesarray of{role, content}objects. An optional top-levelsystemprompt sets behaviour for the turn. - The reply is a
messagewhosecontentis an array of blocks; text lives in blocks with"type":"text"and atextfield. 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