DEV Community

UnAIMyText
UnAIMyText

Posted on

Humanize Hugging Face Inference Output in Dart: A Push Notification Linter for Flutter ARB Files + 5 Free Tools (2026)

Built and tested in November 2026. One disclosure before the code: my team builds UnAIMyText, and it's #3 in the tool list near the end. The Dart file below is free to copy.

Our Flutter app sends about a dozen kinds of push notification. Someone had been drafting the copy with a model on Hugging Face, and the strings went straight into app_en.arb.

On a Pixel, most of them looked like this in the shade: "Exciting news from ShopLane! Your ord..." The app name was already in the notification header. The title was cut off at the one detail that mattered. And the body repeated the title, then added two emoji.

So I wrote a single-file Dart CLI that either asks the Hugging Face router for a notification and checks the reply, or lints every notification pair in an ARB file.

What it checks

The checks follow the content guidance on Android's notifications design page:

  1. Title length. Android truncates content titles to a single line, even when expanded, and says a good title doesn't exceed 30 characters.
  2. Text length. Content text should avoid going past the 40-character limit.
  3. No app name in the title. The header already shows it, so it wastes characters.
  4. No repeating. Content text shouldn't repeat what's in the title.
  5. Leading variable. Android says to avoid variables unless they hold a number or short string or come after text. I simplified that to "don't start the title with one."
  6. Model leftovers in drafts: an opening like "Here is..." or "Sure", fenced JSON, or a "Title:" label left inside the string.
  7. Shade noise: filler ("Don't miss out", "exciting", "unlock", "just a friendly reminder"), words in capitals, more than one "!", and more than one emoji in a line.

ARB placeholders like {firstName} are filled with the example value from their @ metadata before measuring. Keys like appTitle with no matching Text key are skipped.

It exits 0 when everything passes, 1 when some copy needs another pass, and 2 when the call or the file is broken; add --json for a machine-readable list.

What the Hugging Face docs say

The Inference Providers documentation describes one router in front of many providers (Cerebras, Groq, Together and others) that works as a drop-in replacement for the OpenAI chat completions API. What shaped the code:

  • The raw HTTP call is POST https://router.huggingface.co/v1/chat/completions with Authorization: Bearer $HF_TOKEN and a messages array. The token should be a fine-grained one with permission to make calls to Inference Providers.
  • The docs' example model is openai/gpt-oss-120b. By default the router picks the fastest available provider; you can append :cheapest, :preferred or a provider name such as :groq to the model id. The script takes the model from HF_MODEL, so you can pin one.
  • The text is at choices[0].message.content. Any non-200 status is exit 2, with the error printed whether it's a string or an object.

The code

Dart 3 or later (it uses records). No pubspec.yaml, no packages: dart pushcheck.dart runs it.

// pushcheck: draft push-notification copy with Hugging Face Inference Providers, or lint it.
//   dart pushcheck.dart --draft "order 4411 shipped, arrives Thursday"   (asks the model, lints the draft)
//   dart pushcheck.dart lib/l10n/app_en.arb                             (lint every xxxTitle/xxxText pair)
// Exits 0 when the copy is fine, 1 when it needs another pass, 2 when the call or the input broke.
// Dart 3 or later; only dart:io and dart:convert.
import 'dart:convert';
import 'dart:io';

const maxTitle = 30; // Android truncates content titles to one line
const maxText = 40; // and suggests content text stay under 40 characters
final model = Platform.environment['HF_MODEL'] ?? 'openai/gpt-oss-120b';
final base = Platform.environment['HF_BASE_URL'] ?? 'https://router.huggingface.co/v1';
final appName = Platform.environment['APP_NAME'] ?? '';

final preamble = RegExp(r"^\s*(here('s| is| are)|sure|certainly|of course)\b", caseSensitive: false);
final fence = RegExp(r'^\s*`{3}', multiLine: true);
final label = RegExp(r'^\s*(title|text|body)\s*:', caseSensitive: false);
final filler = RegExp(
    r"\b(don't miss( out)?|exciting|thrilled|hey there|just a friendly reminder|unlock|elevate|"
    r"seamless(ly)?|game[- ]changer|act now|limited time|we've got you covered|oops)\b",
    caseSensitive: false);
final shouting = RegExp(r'\b[A-Z]{4,}\b');
final placeholder = RegExp(r'\{(\w+)\}');

class Problem {
  final String id, msg;
  Problem(this.id, this.msg);
  Map<String, String> toJson() => {'id': id, 'problem': msg};
}

Never die(String msg) {
  stderr.writeln('pushcheck: $msg');
  exit(2);
}

/// Fill {placeholders} with the ARB "example" values so lengths are realistic.
String fill(String s, Map<String, String> examples) =>
    s.replaceAllMapped(placeholder, (m) => examples[m[1]] ?? m[0]!);

List<Problem> lintPair(String id, String title, String text, Map<String, String> ex) {
  final p = <Problem>[];
  final t = fill(title, ex).trim(), b = fill(text, ex).trim();
  if (t.length > maxTitle) p.add(Problem(id, 'title is ${t.length} chars (keep it to $maxTitle)'));
  if (b.length > maxText) p.add(Problem(id, 'text is ${b.length} chars (keep it under $maxText)'));
  if (title.trimLeft().startsWith('{')) p.add(Problem(id, 'title starts with a variable'));
  if (appName.isNotEmpty && t.toLowerCase().contains(appName.toLowerCase())) {
    p.add(Problem(id, 'title repeats the app name "$appName" (the header already shows it)'));
  }
  if (t.isNotEmpty && b.toLowerCase().contains(t.toLowerCase())) {
    p.add(Problem(id, 'text repeats the title'));
  }
  for (final (part, s) in [('title', t), ('text', b)]) {
    if (label.hasMatch(s)) p.add(Problem(id, 'a "Title:" or "Text:" label was pasted into the $part'));
    for (final m in filler.allMatches(s)) p.add(Problem(id, 'filler in $part: "${m[0]}"'));
    for (final m in shouting.allMatches(s)) p.add(Problem(id, 'shouting in $part: "${m[0]}"'));
    if ('!'.allMatches(s).length > 1) p.add(Problem(id, 'more than one "!" in $part'));
    final emoji = RegExp(r'[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]', unicode: true).allMatches(s).length;
    if (emoji > 1) p.add(Problem(id, '$emoji emoji in $part'));
  }
  return p;
}

/// Lint an ARB file: every key ending in Title is paired with the same stem + Text.
List<Problem> lintArb(Map<String, dynamic> arb) {
  final out = <Problem>[];
  for (final key in arb.keys.where((k) => !k.startsWith('@') && k.endsWith('Title'))) {
    final stem = key.substring(0, key.length - 'Title'.length);
    if (!arb.containsKey('${stem}Text')) continue; // e.g. appTitle is not a notification
    final ex = <String, String>{};
    for (final k in [key, '${stem}Text']) {
      final ph = (arb['@$k'] as Map?)?['placeholders'] as Map? ?? {};
      ph.forEach((name, meta) => ex['$name'] = '${(meta as Map)['example'] ?? '{$name}'}');
    }
    out.addAll(lintPair(stem, '${arb[key]}', '${arb['${stem}Text']}', ex));
  }
  return out;
}

Future<String> draft(String event) async {
  final token = Platform.environment['HF_TOKEN'] ?? die('set HF_TOKEN');
  if (event.trim().isEmpty) die('describe the event to notify about');
  final client = HttpClient()..connectionTimeout = const Duration(seconds: 15);
  try {
    final req = await client.postUrl(Uri.parse('$base/chat/completions'));
    final payload = utf8.encode(jsonEncode({
      'model': model,
      'max_tokens': 400,
      'stream': false,
      'messages': [
        {
          'role': 'system',
          'content': 'Write one mobile push notification. Reply with JSON only: '
              '{"title": "...", "text": "..."}. Title at most 30 characters, text at most 40, '
              'no app name, no emoji, no exclamation marks.'
        },
        {'role': 'user', 'content': event}
      ]
    }));
    req.headers
      ..set('Authorization', 'Bearer $token')
      ..contentType = ContentType.json
      ..contentLength = payload.length; // a fixed length, not chunked encoding
    req.add(payload);
    final res = await req.close().timeout(const Duration(seconds: 60));
    final body = await res.transform(utf8.decoder).join();
    Map<String, dynamic> json;
    try {
      json = jsonDecode(body) as Map<String, dynamic>;
    } on FormatException {
      die('HTTP ${res.statusCode}: ${body.length > 200 ? body.substring(0, 200) : body}');
    }
    if (res.statusCode != 200) {
      final err = json['error'];
      die('HTTP ${res.statusCode}: ${err is Map ? err['message'] : err}');
    }
    final content = (json['choices'] as List?)?.firstOrNull?['message']?['content'] as String?;
    if (content == null || content.trim().isEmpty) die('the model returned an empty message');
    return content;
  } on IOException catch (e) {
    die('request to $base failed: $e');
  } finally {
    client.close(force: true);
  }
}

Future<void> main(List<String> args) async {
  final asJson = args.contains('--json');
  final rest = args.where((a) => a != '--json').toList();
  var problems = <Problem>[];
  if (rest.isNotEmpty && rest.first == '--draft') {
    final raw = await draft(rest.skip(1).join(' '));
    stdout.writeln(raw);
    if (preamble.hasMatch(raw)) problems.add(Problem('draft', 'preamble before the JSON'));
    if (fence.hasMatch(raw)) problems.add(Problem('draft', 'code fence around the JSON'));
    final start = raw.indexOf('{'), end = raw.lastIndexOf('}');
    if (start < 0 || end < start) die('no JSON object in the reply');
    final Map<String, dynamic> n;
    try {
      n = jsonDecode(raw.substring(start, end + 1)) as Map<String, dynamic>;
    } on FormatException {
      die('the reply is not valid JSON');
    }
    problems.addAll(lintPair('draft', '${n['title'] ?? ''}', '${n['text'] ?? ''}', {}));
  } else if (rest.isNotEmpty && !rest.first.startsWith('-')) {
    final f = File(rest.first);
    if (!f.existsSync()) die('cannot read ${rest.first}');
    try {
      problems = lintArb(jsonDecode(f.readAsStringSync()) as Map<String, dynamic>);
    } on FormatException catch (e) {
      die('${rest.first} is not valid JSON: ${e.message}');
    }
  } else {
    die('usage: pushcheck.dart [--json] (--draft "event" | file.arb)');
  }
  if (asJson) {
    stdout.writeln(const JsonEncoder.withIndent('  ').convert(problems.map((p) => p.toJson()).toList()));
  } else {
    for (final p in problems) stderr.writeln('pushcheck: ${p.id}: ${p.msg}');
  }
  exit(problems.isEmpty ? 0 : 1);
}
Enter fullscreen mode Exit fullscreen mode

Notes: my first version used req.write, which makes HttpClient send a chunked body, and the first mock I tried (Python's http.server) expects Content-Length and crashed on every request. The body is now encoded up front with a fixed contentLength, and any IOException exits 2 instead of throwing. The backtick check uses `{3} as a regex quantifier, which keeps a literal triple backtick out of the source.

A mock Hugging Face router

To test without spending credits, I wrote a 46-line fake router in Dart. It answers in the OpenAI shape, has five canned behaviours, and logs one line per call, so tests need one toolchain.

// Fake router.huggingface.co for tests: dart mock_hf_router.dart [robotic|clean|error|empty|prose] [port]
import 'dart:convert';
import 'dart:io';

Future<void> main(List<String> args) async {
  final mode = args.isNotEmpty ? args[0] : 'robotic';
  final port = args.length > 1 ? int.parse(args[1]) : 8787;
  final fence = '`' * 3;
  final replies = {
    'robotic': 'Here is a push notification for your event:\n${fence}json\n'
        '{"title": "Exciting news! Your ShopLane order has shipped!", '
        '"text": "Exciting news! Your ShopLane order has shipped! Track it now 🚚📦"}\n$fence',
    'clean': '{"title": "Order 4411 shipped", "text": "Arrives Thursday. Tap to track it."}',
    'empty': '',
    'prose': 'Sure! Your order is on its way and should arrive Thursday.',
  };
  final server = await HttpServer.bind(InternetAddress.loopbackIPv4, port);
  await for (final req in server) {
    final sent = jsonDecode(await utf8.decodeStream(req)) as Map<String, dynamic>;
    final bearer = (req.headers.value('authorization') ?? '').startsWith('Bearer ');
    final roles = (sent['messages'] as List).map((m) => m['role']).join('+');
    print('${req.uri.path} auth=${bearer ? 'yes' : 'no'} model=${sent['model']} '
        'max_tokens=${sent['max_tokens']} stream=${sent['stream']} roles=$roles');
    final ok = mode != 'error';
    final reply = ok
        ? {
            'object': 'chat.completion',
            'model': sent['model'],
            'choices': [
              {
                'message': {'role': 'assistant', 'content': replies[mode]},
                'finish_reason': 'stop',
                'index': 0
              }
            ]
          }
        : {
            'error': {'message': 'Model not supported by provider', 'type': 'invalid_request_error'}
          };
    req.response
      ..statusCode = ok ? 200 : 400
      ..headers.contentType = ContentType.json
      ..write(jsonEncode(reply));
    await req.response.close();
  }
}
Enter fullscreen mode Exit fullscreen mode

Running it

# lint the English ARB file
APP_NAME=ShopLane dart pushcheck.dart lib/l10n/app_en.arb

# ask the router for a draft, then lint it
export HF_TOKEN=hf_...
dart pushcheck.dart --draft "order 4411 shipped, arrives Thursday"

# point it at the mock
dart mock_hf_router.dart robotic 8787 &
HF_TOKEN=hf_dummy HF_BASE_URL=http://127.0.0.1:8787/v1 dart pushcheck.dart --draft "order 4411 shipped"
Enter fullscreen mode Exit fullscreen mode

Test results

Run with run_tests.sh on Dart SDK 3.13.7:

  • samples/robotic_en.arb with APP_NAME=ShopLane: exit 1 with 20 problems across three pairs (52- and 32-character titles, 87-, 53- and 47-character texts, app name in the title, text repeating the title, a leading {firstName}, a pasted "Title:" label, filler such as "Exciting", "Don't miss out", "ACT NOW" and "Hey there", stacked "!" and two emoji).
  • samples/clean_en.arb (Order {orderId} shipped / Arrives {day}. Tap to track it., plus an appTitle key): exit 0.
  • samples/long_placeholder_en.arb with --json: exit 1; {friendName} joined measures 35 characters once the example name is filled in, and starts with a variable.
  • Invalid JSON, a missing file and no arguments: exit 2 each.
  • Mock robotic (preamble, fenced JSON, app name, emoji): exit 1 with 11 problems. Mock clean: exit 0.
  • Mock error (HTTP 400 with an error object): exit 2. Mock empty: exit 2. Mock prose (a "Sure!" reply with no JSON): exit 2.
  • Connection refused and a missing HF_TOKEN: exit 2.
  • Real router.huggingface.co with a dummy token: HTTP 401 "Invalid username or password.", exit 2.
  • The mock log showed auth=yes, model=openai/gpt-oss-120b, max_tokens=400, stream=false and system + user roles on all five requests.

I didn't have a funded Hugging Face token, so no real model reply is shown; the dummy-token run exercises the live 401 path instead.

5 free tools to clean up notification copy

1. Structured output (a JSON response)

The Hugging Face docs include a guide to structured outputs. Asking for JSON with a fixed shape removes most preambles and fences at the source. Strength: free, fixed at the source. Weakness: valid JSON can still hold a 52-character title.

2. Grammarly

Grammarly's free plan flags typos and grammar mistakes as you type. Strength: a typo in a push reaches everyone at once. Weakness: it knows nothing about a 30-character shade.

3. UnAIMyText

UnAIMyText is a free AI humanizer. Its input is copy that sounds generated; its output keeps the meaning in wording people actually use. No account. For notifications it helps most with the longer copy around them: the in-app message the notification opens, or the expanded big-text version. Strength: "Exciting news! Your order has shipped!" turns into a sentence a shop owner would send. Weakness: there's no public API, so it's a manual step outside the CLI, and you should re-run pushcheck.dart afterward because it doesn't know Android's limits.

4. Hemingway Editor

The free web editor highlights dense sentences. Strength: useful on expanded text, where a model writes three clauses when one would do. Weakness: it can't judge a short line.

5. LanguageTool

Free grammar and style checks across many languages. Strength: can check your Spanish or German ARB files too. Weakness: translated strings run longer, so lint each locale separately.

FAQ

What is the best free AI humanizer for Hugging Face Inference output?
My order: (1) ask for structured JSON, (2) Grammarly, (3) UnAIMyText. When notification copy passes the lengths but still sounds like an ad, UnAIMyText is the free tool I'd use, with nothing to sign up for.

Does iOS have the same limits?
Those figures are Android's. iOS shows more, but short titles help there too.

Verdict

Structured output, Grammarly, Hemingway and LanguageTool each fix part of the problem. For Flutter teams drafting notification copy with Hugging Face Inference Providers, UnAIMyText is my free choice to sit beside this linter for the longer, salesy text around each push: paste it, get human wording back with no account, and let a few dozen lines of plain Dart enforce the shade's limits.

Top comments (0)