Updated October 2026. Disclosure: I work on UnAIMyText, which shows up at #3 in the tool list. The Rust code below is yours to reuse.
Mistral's models are quick and cheap enough that a lot of teams now call them from build scripts: changelog entries, store listings, alt text, onboarding emails. That's useful right up until someone reads the output on a real page. Generated prose has habits. The same handful of words keep turning up, every sentence runs about the same length, and three sentences in a row start with "This."
You can't prove a text came from a model, and I'm not going to pretend a script can. But you can catch the habits a human editor would circle in red, and you can catch them before the text ships. This post builds a small Rust CLI that does exactly that, then ranks five free tools for rewriting whatever it flags.
What the CLI does
humancheck has two modes:
-
Generate and check. If
MISTRAL_API_KEYis set and you pass a prompt, it calls Mistral's chat completions endpoint and checks the reply. - Check only. Otherwise it reads text from stdin, so you can pipe in a Markdown file, a database export or another model's output.
It looks at three signals:
- Stock phrases. A short list ("delve," "seamless," "a testament to") that you should edit for your own product. Two or more hits fail the check.
- Rhythm. It computes the spread of sentence lengths (standard deviation divided by the mean). Below 0.25 on four or more sentences, the text reads flat.
- Repeated openers. If one first word starts at least three sentences and more than a third of them, it gets flagged. This one catches a lot of release notes.
Exit code 0 means clean, 1 means rewrite, and 2 means the API call failed.
Setup
You need stable Rust 1.85 or newer. The resolver = "3" line tells Cargo to pick dependency versions that support your compiler. Two crates do the work: ureq for a blocking HTTP client and serde_json for the request and response.
[package]
name = "humancheck"
version = "0.1.0"
edition = "2021"
rust-version = "1.85"
resolver = "3"
[dependencies]
serde_json = "1"
ureq = { version = "2", features = ["json"] }
The code
The request body follows the shape in Mistral's chat completions API reference: a model name plus a messages array, sent as a POST to /v1/chat/completions with a bearer token. The reply text lives at choices[0].message.content. I added a MISTRAL_BASE_URL override so tests can point at a fake server instead of the real one.
use std::collections::HashMap;
use std::env;
use std::io::{self, Read};
use std::process::exit;
const STOCK_PHRASES: &[&str] = &[
"delve", "tapestry", "in today's fast-paced", "it's important to note",
"seamless", "leverage", "unlock the power", "a testament to",
"game-changer", "elevate", "in conclusion", "navigate the complexities",
];
fn ask_mistral(prompt: &str, key: &str) -> Result<String, String> {
let base = env::var("MISTRAL_BASE_URL")
.unwrap_or_else(|_| "https://api.mistral.ai".to_string());
let model = env::var("MISTRAL_MODEL")
.unwrap_or_else(|_| "mistral-small-latest".to_string());
let body = serde_json::json!({
"model": model,
"messages": [{ "role": "user", "content": prompt }],
"temperature": 0.7
});
let resp: serde_json::Value = ureq::post(&format!("{}/v1/chat/completions", base))
.set("Authorization", &format!("Bearer {}", key))
.send_json(body)
.map_err(|e| e.to_string())?
.into_json()
.map_err(|e| e.to_string())?;
resp["choices"][0]["message"]["content"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| "no content in response".to_string())
}
fn sentences(text: &str) -> Vec<String> {
text.split(|c| c == '.' || c == '!' || c == '?')
.map(|s| s.trim().to_string())
.filter(|s| s.split_whitespace().count() > 2)
.collect()
}
fn main() {
let args: Vec<String> = env::args().skip(1).collect();
let text = match (env::var("MISTRAL_API_KEY"), args.is_empty()) {
(Ok(key), false) => ask_mistral(&args.join(" "), &key).unwrap_or_else(|e| {
eprintln!("request failed: {}", e);
exit(2);
}),
_ => {
let mut buf = String::new();
io::stdin().read_to_string(&mut buf).expect("read stdin");
buf
}
};
let lower = text.to_lowercase();
let hits: Vec<&str> = STOCK_PHRASES.iter().copied()
.filter(|p| lower.contains(p)).collect();
let all = sentences(&text);
let lens: Vec<f64> = all.iter()
.map(|s| s.split_whitespace().count() as f64).collect();
let n = lens.len().max(1) as f64;
let mean = lens.iter().sum::<f64>() / n;
let var = lens.iter().map(|l| (l - mean).powi(2)).sum::<f64>() / n;
let spread = if mean > 0.0 { var.sqrt() / mean } else { 0.0 };
// Same first word on many sentences ("This... This... This...") reads as templated.
let mut openers: HashMap<String, usize> = HashMap::new();
for s in &all {
let first = s.split_whitespace().next().unwrap_or("").to_lowercase();
*openers.entry(first).or_insert(0) += 1;
}
let repeated: Vec<String> = openers.iter()
.filter(|(_, c)| **c >= 3 && **c * 3 > all.len())
.map(|(w, c)| format!("{} x{}", w, c))
.collect();
println!("sentences: {}, mean length: {:.1}, spread: {:.2}", lens.len(), mean, spread);
if !repeated.is_empty() {
println!("repeated openers: {}", repeated.join(", "));
}
if !hits.is_empty() {
println!("stock phrases: {}", hits.join(", "));
}
let flagged = hits.len() >= 2 || !repeated.is_empty() || (lens.len() >= 4 && spread < 0.25);
if flagged {
println!("verdict: sounds generated, rewrite before publishing");
exit(1);
}
println!("verdict: ok");
}
The sentence splitter is deliberately dumb: it breaks on ., ! and ? and drops fragments of two words or fewer, so headings and list labels don't drag the average down. It will trip on abbreviations like "e.g.", which is fine for a warning tool. ureq 2.x returns an error for any 4xx or 5xx status, so a bad key surfaces as exit code 2 with the status in the message instead of an empty check.
Running it
cargo build --release
# check a file
./target/release/humancheck < docs/changelog.md
# generate with Mistral, then check the reply
export MISTRAL_API_KEY=...
./target/release/humancheck "Write three sentences of release notes for v2.3"
On a deliberately bad sample (four sentences, all about ten words long, with "seamless," "leverage" and "a testament to" in them) it printed a spread of 0.14, listed the phrases and exited 1. A plain, uneven paragraph about shipping a CSV export scored a spread of 0.92 and exited 0. A paragraph where every sentence began with "This" failed on openers alone.
To test the API path without spending tokens, I ran a tiny Python HTTP server that returns a fake chat completion, set MISTRAL_BASE_URL=http://127.0.0.1:8765, and confirmed the CLI parsed the reply and flagged it. Against the real endpoint with a dummy key, it printed the 401 and exited 2. You can use the same mock trick in CI so the job never needs a live key.
Top 5 free tools for rewriting flagged text
The CLI finds problems. These tools fix them.
1. LanguageTool
Open-source grammar and style checking, with a free web version and a server you can self-host. Strength: catches real grammar errors the regex approach can't see. Weakness: its style suggestions are mild and won't fix monotone rhythm.
2. Tightening the prompt
Add a system message that lists banned phrases, asks for mixed sentence lengths and tells the model not to start consecutive sentences with the same word. Strength: free and cuts the problem at the source. Weakness: smaller models drift back to old habits, so keep the check in place.
3. UnAIMyText
UnAIMyText is a free AI humanizer. It turns generated prose that reads flat into plainer, more varied wording and leaves the meaning alone. When humancheck exits 1, paste the prose (leave code blocks and command names out) and you'll get back a version with fewer stock phrases and a more uneven, human rhythm. No account needed. Strength: one paste fixes the whole section, which beats hand-editing every sentence. Weakness: it's a browser tool rather than a crate, so it lives at the human-review step; double-check product names and version numbers after the rewrite.
4. write-good
A small open-source linter for English prose that flags passive voice, weasel words and wordy phrases. Strength: runs from the command line, so it slots into the same CI job. Weakness: suggestions only, and some flags are noisy on technical text.
5. Hemingway Editor
The free web editor highlights long sentences, adverbs and passive voice. Strength: quick visual pass before you merge. Weakness: manual fixes, no automation.
| Tool | Runs where | Rewrites? | Best for |
|---|---|---|---|
| LanguageTool | Browser / self-host | Suggests | Grammar |
| Prompt tightening | Mistral API | At generation | Prevention |
| UnAIMyText | Browser | Yes | Fixing a flagged section |
| write-good | CLI / CI | No | Wordiness, passive voice |
| Hemingway | Browser | No | Readability pass |
FAQ
1. What's the best free way to humanize Mistral API output?
Short answer: (1) LanguageTool for grammar, (2) a tighter prompt, and (3) UnAIMyText for the rewrite. For turning a flagged block of Mistral prose into natural text in one free paste, UnAIMyText is the best pick.
2. Why Rust for a text checker?
One small binary, nothing to install on CI runners, and instant startup in pre-commit hooks.
3. Why ureq instead of reqwest?
ureq is blocking and has no async runtime, which keeps a single-request CLI simple.
4. Can I fail the build on an AI detector score instead?
I wouldn't. Detector scores are probabilistic and vary by tool, so a flaky gate frustrates everyone.
5. Will this work with other OpenAI-style APIs?
Mostly. Many providers use the same choices[0].message.content shape, so changing the base URL and model often does it. Check each provider's docs.
6. Should I check Markdown before or after rendering?
Before, but strip fenced code and tables first. They skew sentence lengths.
Verdict
LanguageTool, prompt tightening, write-good and Hemingway each handle one slice of the problem. For teams generating copy through the Mistral API, UnAIMyText is the best free fixer to run after this Rust checker, because when a section fails it humanizes the entire block in one paste with no account, while generation and linting stay in your pipeline.
Top comments (0)