●SPARK — Gemini 3.7 Flash became the engine behind Gemini Spark on August 13. Choosing round-trip speed over reasoning depth for agent work is a useful signal when picking your own model●AGENT — Spark carries out multi-step tasks autonomously once granted permission, handling things like booking appointments and filling forms rather than answering one command at a time●ASSISTANT — Fourteen days until Gemini replaces Google Assistant on September 4. Once a device migrates there is no going back, though cars with Google Built-in keep Assistant for now●EDUCATION — Since August 10, Gemini in Classroom is available to K-12 and higher-education students of any age, provided their administrator has granted access●SCALE — The Gemini app now generates 150 million images a day. The interesting part is less the volume than how that sustained load is absorbed in practice●MODELS — The split has settled: Gemini 3.1 Pro for deep reasoning, the Flash line for production work where speed and cost matter. Running the same job through both makes the gap concrete●SPARK — Gemini 3.7 Flash became the engine behind Gemini Spark on August 13. Choosing round-trip speed over reasoning depth for agent work is a useful signal when picking your own model●AGENT — Spark carries out multi-step tasks autonomously once granted permission, handling things like booking appointments and filling forms rather than answering one command at a time●ASSISTANT — Fourteen days until Gemini replaces Google Assistant on September 4. Once a device migrates there is no going back, though cars with Google Built-in keep Assistant for now●EDUCATION — Since August 10, Gemini in Classroom is available to K-12 and higher-education students of any age, provided their administrator has granted access●SCALE — The Gemini app now generates 150 million images a day. The interesting part is less the volume than how that sustained load is absorbed in practice●MODELS — The split has settled: Gemini 3.1 Pro for deep reasoning, the Flash line for production work where speed and cost matter. Running the same job through both makes the gap concrete
Why My Length Limit Only Failed on the English Notifications, and the Width-Based Fit That Replaced It
I had Gemini write push notifications in Japanese and English, and only the English ones came out truncated on real devices. The culprit was measuring length in characters. Here are the measured widths and the post-generation fitting code that fixed it.
A reader in the US wrote in during the third week of the rollout: the notification text stops mid-sentence.
The app is a wallpaper app I run on my own. It sends a short announcement whenever new pieces are added, in Japanese and English. Gemini writes the copy, and my prompt said, plainly, "keep it under 40 characters." The Japanese notifications had been landing perfectly every time, so I assumed the same instruction was doing its job for English.
Every device I had checked on was set to a Japanese locale. That is why I never saw it.
The problem was not the quality of Gemini's output. It was my choice of unit. Below is what I measured on the way to understanding that, and the implementation I ended up with.
The short version: I stopped asking the model to respect a length
Here is the shape of the current pipeline.
Gemini is never told to be brief. Instead it returns two fields — lead and detail — as structured output.
All length adjustment happens afterward, in deterministic code.
The unit is display width, not character count, and the way width is counted changes per language.
The whole change is a move from "make the model obey" to "have the model return something that degrades gracefully, and do the trimming myself." Here is why.
The same sentence is more than twice as long in English
I started by measuring strings close to what I was actually shipping. Display width here means: every character whose East Asian Width property is W, F, or A counts as 2, everything else counts as 1.
Language
Characters
Display width
Text
ja
34
67
新しい浮世絵の壁紙を8枚追加しました。歌川広重の東海道シリーズです。
en
81
81
8 new ukiyo-e wallpapers are now available, including Hiroshige's Tokaido series.
ja
25
49
今週の追加分は葛飾北斎の富嶽三十六景から6枚です。
en
74
74
This week we added 6 pieces from Hokusai's Thirty-six Views of Mount Fuji.
The same message: 34 characters in Japanese, 81 in English. A factor of 2.4.
Now apply a flat "under 40 characters" to both. Japanese gets to use 80 units of display width. English gets 40. The same number was acting as a limit twice as tight in one language as the other. The English copy was not losing information because the model ignored me. It was losing information because my instruction was unreasonable in English.
There is a second reason not to push this onto the model at all. Gemini works in tokens, and "40 characters" is not a quantity it can count accurately while generating. Writing maxLength into your response_schema does not change this either — the API does not enforce string length, so an over-long value can come back and validate fine. The moment you depend on the model for a length guarantee, your pipeline has an unbounded failure mode.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Be able to tell, in your own code, whether a character-count limit or a token limit is the thing quietly failing on your multilingual strings
✦Understand why the same sentence measures 67 in Japanese and 81 in English, and budget display width per language instead of guessing
✦Catch the class of bug where one locale ships truncated text for weeks before anyone reports it, using a check that runs before delivery
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
My first fix was to trim the returned string myself, with a plain slice: text[:30].
Language
Resulting display width
Result
ja
59
新しい浮世絵の壁紙を8枚追加しました。歌川広重の東海道シリー
en
30
8 new ukiyo-e wallpapers are n
Japanese consumes 59 units of width. English consumes 30. There is twice as much room left on the notification shade, and only the English is being cut short.
Character count treats a full-width glyph and a half-width glyph as equivalent. In a language where one character is two units wide, that is generous; in a Latin-script language where one character is one unit, it is punitive. Do this in a multilingual app and the Latin-script locales always lose.
Cutting by width still breaks English mid-word
So I switched the unit to display width and cut at 60.
Language
Width
Result
ja
59
新しい浮世絵の壁紙を8枚追加しました。歌川広重の東海道シリー
en
60
8 new ukiyo-e wallpapers are now available, including Hirosh
The budget gets used now. But the English breaks at Hirosh — inside a proper noun. To a reader that does not look truncated so much as broken.
Adding a rollback to the previous word boundary fixed the appearance and dropped the width from 60 to 55. Roughly 8% of the budget goes unused as trailing slack.
Language
Width
Result
ja
59
新しい浮世絵の壁紙を8枚追加しました。歌川広重の東海道シリ…
en
55
8 new ukiyo-e wallpapers are now available, including…
This is where it clicked for me. English carries less information than Japanese at the same width budget. Because you cannot cut inside a word, there is always a remainder at the tail. Japanese has no word boundaries to respect, so it spends nearly the whole budget.
"The English one looks cut off" turned out to be three separate things stacked: the character-count instruction, the wrong unit for trimming, and the word-boundary remainder.
The Ambiguous-width trap: I was counting é as two columns
Once you use East Asian Width, you have to decide what to do with the A (Ambiguous) class. My implementation lumped W, F, and A together as width 2. It is worth looking at what actually falls into A.
Character
east_asian_width
…
A
é
A
×
A
“
A
①
A
é is Ambiguous. If I had been shipping French or Spanish notifications, every accented character would have been counted as two columns, and those locales alone would have been trimmed even harder. In a Latin-script font, é renders at essentially the same width as e.
Treating Ambiguous as wide is a convention that assumes a CJK font environment. The fix was to make the rule locale-dependent: 2 for Japanese, Chinese, and Korean; 1 everywhere else. The ellipsis follows the same logic and counts as one column in Latin-script locales.
This is not something the documentation is going to tell you. It surfaced as "why is this one language always shorter than the others."
One emoji and your slice falls apart
Notifications often carry an emoji. I ran 🎨 新作を8枚追加👨👩👧 through a naive slice.
Operation
Result
len()
14
UTF-16 code units
18
s[:10]
🎨 新作を8枚追加👨
s[:11]
🎨 新作を8枚追加👨
At s[:11] the family emoji splits at the zero-width joiner, and the string ships with an invisible control character at the tail. Depending on the device you get a tofu box or an unintended glyph.
The nastier detail is the mismatch between 14 and 18. Kotlin's String.length, Swift's utf16.count, and JavaScript's .length all return UTF-16 code units. A length check on your Python backend and a length check in your client are not measuring the same thing. Put a guard in both places and one of them will silently pass.
The fix is to move the cutting unit up from code points to grapheme clusters. The reliable implementation uses the regex module's \X, but I did not want another dependency, so I approximated it: absorb combining marks, ZWJ, variation selectors, and skin-tone modifiers into the preceding character.
Ask Gemini to split, not to shorten
With all of that understood, I rewrote the instruction. No length constraint; a structure instead.
from google import genaifrom google.genai import typesclient = genai.Client(api_key="YOUR_API_KEY")NOTIFICATION_SCHEMA = { "type": "object", "properties": { # The one sentence that must survive. Never dropped. "lead": {"type": "string"}, # Extra context, included only if it fits. Must be droppable whole. "detail": {"type": "string"}, }, "required": ["lead", "detail"], "propertyOrdering": ["lead", "detail"],}PROMPT = """Write push notification copy for the following release.lead: one sentence stating what happened. No proper nouns.detail: context supporting the lead. Put artwork and artist names here.Write detail so that removing it entirely still leaves lead coherent.Language: {lang}Release: {payload}"""def generate_notification(lang: str, payload: str) -> dict: res = client.models.generate_content( model="gemini-3.7-flash", contents=PROMPT.format(lang=lang, payload=payload), config=types.GenerateContentConfig( response_mime_type="application/json", response_schema=NOTIFICATION_SCHEMA, temperature=0.4, ), ) import json return json.loads(res.text)
Splitting into lead and detail exists to give the trimmer options. With a single string, the only move available when it does not fit is to cut it. With two, the first move is to drop detail whole — a far less damaging failure.
Leaving length out of the prompt is deliberate too. Give the model a length and it will start dropping information to hit it. The decision about what to drop is better made where the actual width budget is known, which is not inside the model.
The fitting code that runs right before delivery
This is what every notification passes through before it goes out, with the app-specific parts stripped away.
import reimport unicodedata# Modifiers that attach to the preceding character:# ZWJ, variation selectors, keycap, skin tone, combining marks_MODIFIER = re.compile( r"[️︎⃣\U0001F3FB-\U0001F3FF̀-ͯ]")# Locales where Ambiguous-width characters render at two columns_WIDE_AMBIGUOUS_LANGS = {"ja", "zh", "zh-TW", "zh-CN", "ko"}def grapheme_clusters(text: str) -> list[str]: """Group combining marks with the preceding character so we never cut inside one.""" out: list[str] = [] for ch in text: if out and _MODIFIER.match(ch): out[-1] += ch elif out and _MODIFIER.match(out[-1][-1]): # A character right after a ZWJ belongs to the previous cluster out[-1] += ch else: out.append(ch) return outdef display_width(text: str, lang: str) -> int: wide_ambiguous = lang.split("-")[0] in _WIDE_AMBIGUOUS_LANGS total = 0 for cluster in grapheme_clusters(text): head = cluster[0] eaw = unicodedata.east_asian_width(head) if eaw in ("W", "F"): total += 2 elif eaw == "A": total += 2 if wide_ambiguous else 1 elif ord(head) >= 0x1F000: # emoji render at two columns even when Neutral total += 2 else: total += 1 return totaldef cut_to_width(text: str, limit: int, lang: str) -> str: """Cut on grapheme cluster boundaries without exceeding the width limit.""" used = 0 out: list[str] = [] for cluster in grapheme_clusters(text): w = display_width(cluster, lang) if used + w > limit: break out.append(cluster) used += w return "".join(out)def fit(text: str, limit: int, lang: str) -> str: """Return as-is if it fits; otherwise trim, respecting word boundaries.""" if display_width(text, lang) <= limit: return text ellipsis = "…" body = cut_to_width(text, limit - display_width(ellipsis, lang), lang) # In space-delimited languages, roll back only when we landed mid-word if " " in body and len(text) > len(body) and not text[len(body)].isspace(): head = body.rsplit(" ", 1)[0] if head: body = head return body.rstrip(" ,.;:、。") + ellipsisdef build_notification(parts: dict, limit: int, lang: str) -> str: """Always keep lead; append detail only when it fits.""" joiner = "" if lang.split("-")[0] in _WIDE_AMBIGUOUS_LANGS else " " terminator = "。" if lang.split("-")[0] == "ja" else "." lead = parts["lead"].rstrip("。.") detail = parts.get("detail", "").rstrip("。.") full = f"{lead}{terminator}{joiner}{detail}{terminator}" if display_width(full, lang) <= limit: return full # Drop detail wholesale. Only trim lead if that still does not fit. lead_only = f"{lead}{terminator}" if display_width(lead_only, lang) <= limit: return lead_only return fit(lead_only, limit, lang)
The ordering inside build_notification is the part that matters. It does not reach for fit first; it drops detail whole and checks again. On the sample copy, the joined strings came to width 61 in Japanese and 63 in English against a 60 budget — both just over. Dropping detail brought them to 30 and 31.
No ellipsis was needed. From the reader's side that is a short notification rather than a damaged one.
The pre-delivery assertions, and the one I got wrong first
Fixing the implementation does not protect you from the next person — or the next you — changing the copy template. So the delivery batch runs a mechanical check first.
My first version had three assertions:
def assert_notification_safe(text: str, limit: int, lang: str) -> None: w = display_width(text, lang) assert w <= limit # 1. fits the width budget assert not _MODIFIER.match(text[-1]) # 2. no invisible control char at the tail if text.endswith("…"): prev = text[-2] assert not (prev.isascii() and prev.isalpha()) # 3. not cut mid-word
Number three failed on correct output. 8 new ukiyo-e wallpapers are now available, including… — a clean word-boundary cut — raised AssertionError.
Obvious in hindsight: English words end in Latin letters. "Is the character before the ellipsis a Latin letter" cannot distinguish a cut inside a word from a cut at the end of one. What I actually wanted to check was not the character class but where the cut landed in the source string.
Passing the original in fixes it:
def assert_notification_safe(text: str, original: str, limit: int, lang: str) -> None: # 1. Fits the width budget w = display_width(text, lang) assert w <= limit, f"[{lang}] width {w} > {limit}: {text!r}" # 2. Does not end on an invisible modifier (catches a split ZWJ sequence) assert not _MODIFIER.match(text[-1]), f"[{lang}] broken grapheme at tail: {text!r}" # 3. If trimmed, the cut point is a word boundary in the source if text.endswith("…"): body = text[:-1] assert original.startswith(body), f"[{lang}] not a prefix of source: {text!r}" nxt = original[len(body):len(body) + 1] cut_inside_word = ( nxt.isascii() and nxt.isalpha() and body[-1].isascii() and body[-1].isalpha() ) assert not cut_inside_word, f"[{lang}] cut inside a word: {text!r}"
If the characters on both sides of the cut are Latin letters, you are inside a word. In a language without word boundaries the preceding character is non-ASCII, the condition never holds, and the check passes through. One assertion covers both.
I only found this by running the check after writing the implementation. Truncation correctness is hard to eyeball, because correct and incorrect output look like the same kind of string. So I added a habit: whenever you write trimming logic, verify that correctly trimmed output passes your checks, not just that broken output fails them.
One operational rule came out of this too. Never preview notifications in a single locale. That was the actual root cause of the three weeks of silence. The batch now logs the final string for every language alongside its measured width, and I read both before anything goes out.
If the structured output itself comes back in an unexpected shape, that is a separate problem — I wrote up how I handle it in fixing structured output schema validation errors. The fit here is strictly post-validation.
What to do next
Pick one place in your app where you truncate a translated string and check what unit it uses. If you find text[:n] or substring(0, n), there is a good chance your Latin-script locales are the only ones paying for it.
I understand the pull toward handing the length constraint to the model — it looks like a one-line addition to the prompt. But if you end up needing your own code to verify compliance anyway, you may as well own the trimming from the start, and get to choose how it degrades.
I have not yet confirmed whether this same implementation holds up once Korean or Arabic enters the mix. If you get there before I do, I would be glad to learn from it.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.