fix(hud): catch the UNAVAILABLE word flush against Han and keep multi-word names whole

Han and kana have no word edges, so a CJK forbidden word now counts anywhere
in the reply. The proxy no longer counts words: the model reads Rue de Rivoli
as one, so only the 80-character word-boundary cap remains.

Also restore src/voice/gevActions.test.mjs, reformatted by mistake in 818d2c82.
This commit is contained in:
German Khokhlov committed 2026-11-02 17:13:58 +04:00
1 parent 818d2c8295
commit 645e13aeaa
5 files changed
+645 -2164

No files matched your search

+5 -4
View File
@@ -37,12 +37,12 @@ function parseHudSummaryReply(text, locale) {
? reply.feedState
: null;
return {
summary: toFiveWordHudSummary(reply?.summary, locale),
summary: toHudSummary(reply?.summary, locale),
feedState,
};
}
function toFiveWordHudSummary(value, requested) {
function toHudSummary(value, requested) {
const locale = hudSummaryLocale(requested);
const words = String(value || '')
.normalize('NFC')
@@ -55,7 +55,8 @@ function toFiveWordHudSummary(value, requested) {
// Japanese and Chinese have no spaces to count words by: cap characters.
if (hudSummaryIsUnspaced(locale))
return capAtWord(words.join(' '), HUD_SUMMARY_MAX_UNSPACED_CHARS, locale);
return capAtWord(words.slice(0, 5).join(' '), HUD_SUMMARY_MAX_CHARS, locale);
// No word count: the model counts Rue de Rivoli as one word.
return capAtWord(words.join(' '), HUD_SUMMARY_MAX_CHARS, locale);
}
/**
@@ -170,4 +171,4 @@ async function handleHudSummary(req, res) {
}
}
export { handleHudSummary };
export { handleHudSummary, parseHudSummaryReply };
+16
View File
@@ -258,6 +258,22 @@ test('with every feed healthy a reply calling one unavailable falls back', async
assert.equal(shown(), hud._composeSummary());
});
for (const [locale, place, summary] of [
['zh-CN', '北京', '北京航班不可用'],
['zh-Hant', '北京', '北京航班不可用'],
['ja', '東京', '東京航空便利用不可'],
]) {
test(`a ${locale} reply calling a feed unavailable flush against Han falls back`, async (t) => {
const { hud, shown } = createHud(
t,
{ summary, feedState: null },
{ locale, places: [place], layers: () => [] },
);
await hud._updateSummary(false, true);
assert.equal(shown(), hud._composeSummary());
});
}
test('an ar reply opening with a Latin name keeps reading right to left', async (t) => {
const { hud, shown } = createHud(
t,
+6 -1
View File
@@ -72,7 +72,7 @@ export const HUD_SUMMARY_INSTRUCTIONS = [
/** Longest summary, in characters, for a script written without spaces. */
export const HUD_SUMMARY_MAX_UNSPACED_CHARS = 20;
/** Longest five-word summary, in characters. */
/** Longest summary, in characters; the cut falls between words. */
export const HUD_SUMMARY_MAX_CHARS = 80;
/** feedState values the model may return besides null. */
@@ -142,6 +142,9 @@ function flatWords(value) {
.trim();
}
const UNSPACED_WORD =
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}ー]+$/u;
function sameScript(a, b) {
return WORD_SCRIPTS.some((script) => script.test(a ?? '') && script.test(b));
}
@@ -149,6 +152,7 @@ function sameScript(a, b) {
/**
* Whether `word` stands on its own in `text`. Only a letter of the same
* script extends it, so `stale` flush against kana or Han still counts.
* Han and kana have no word edges: a word in them counts anywhere.
* @param {string} text
* @param {string} word
* @returns {boolean}
@@ -157,6 +161,7 @@ export function hasWord(text, word) {
const haystack = flatWords(text);
const needle = flatWords(word);
if (!needle) return false;
if (UNSPACED_WORD.test(needle)) return haystack.includes(needle);
for (
let at = haystack.indexOf(needle);
at >= 0;
+16 -2
View File
@@ -373,7 +373,21 @@ for (const [locale, summary] of [
});
}
test('a five-word summary is capped in characters too', async () => {
for (const [locale, summary] of [
['fr', 'Rue de Rivoli Vols en direct'],
['es', 'Times Square Nueva York Vuelos en vivo'],
['ru', 'Times Square Рейсы онлайн и суда'],
]) {
test(`a ${locale} summary keeps a multi-word name whole: ${summary}`, async () => {
// The parser itself: the file's HUD requests share one rate limiter.
const { parseHudSummaryReply } =
await import('../server/providers/openai/hud-summary.js');
const reply = JSON.stringify({ summary, feedState: 'partial' });
assert.equal(parseHudSummaryReply(reply, locale).summary, summary);
});
}
test('a summary is capped in characters', async () => {
const { response } = await postHudSummary(
{ locale: 'de', feedProvenance: { overall: null } },
JSON.stringify({ summary: `Berlin ${'x'.repeat(200)}`, feedState: null }),
@@ -386,7 +400,7 @@ for (const [locale, summary] of [
['es', 'Vuelos sobre 東京駅 datos antiguos'],
['ru', 'Башня Тайбэй 101 台北101 рейсы'],
]) {
test(`a ${locale} summary with a Han place name keeps its five words`, async () => {
test(`a ${locale} summary with a Han place name keeps its words`, async () => {
const { response } = await postHudSummary(
{ locale, feedProvenance: { overall: 'stale' } },
JSON.stringify({ summary, feedState: 'stale' }),
File diff suppressed because it is too large. Load diff