From 03c168d7c960d4ff99de6a5a7100763c6b0d923e Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 14:45:32 +0400 Subject: [PATCH] refactor(hud): share the warning line and trim the summary helpers One provenanceLine() builds STATE | text | LAYERS for the HUD and get_hud_caption. hasWord is one lookaround regex, capAtWord one grapheme pass. Feed-state helpers are imported from layerSnapshot.js directly, and the kept-whole sanitizer cases are one table. --- server/providers/openai/hud-summary.js | 27 ++++----- src/data/layerSnapshot.js | 26 ++++++++- src/hud.js | 30 +++++----- src/hudSummaryResponse.js | 35 ++++-------- src/hudSummaryResponse.test.mjs | 76 ++++++++------------------ src/tools/queries/brief.js | 16 +++--- 6 files changed, 93 insertions(+), 117 deletions(-) diff --git a/server/providers/openai/hud-summary.js b/server/providers/openai/hud-summary.js index 7f2ad552f..a95de17ea 100644 --- a/server/providers/openai/hud-summary.js +++ b/server/providers/openai/hud-summary.js @@ -1,5 +1,5 @@ +import { SNAPSHOT_FEED_STATES } from '../../../src/data/layerSnapshot.js'; import { - HUD_SUMMARY_FEED_STATES, HUD_SUMMARY_MAX_CHARS, HUD_SUMMARY_MAX_UNSPACED_CHARS, HUD_SUMMARY_RESPONSE_FORMAT, @@ -33,7 +33,7 @@ function parseHudSummaryReply(text, locale) { } catch { /* not JSON: no summary */ } - const feedState = HUD_SUMMARY_FEED_STATES.includes(reply?.feedState) + const feedState = SNAPSHOT_FEED_STATES.includes(reply?.feedState) ? reply.feedState : null; return { @@ -64,24 +64,19 @@ function toHudSummary(value, requested) { * than that is cut where the cap falls. */ function capAtWord(text, max, locale) { - const graphemes = (value) => [ - ...new Intl.Segmenter(locale, { granularity: 'grapheme' }).segment(value), + const graphemes = [ + ...new Intl.Segmenter(locale, { granularity: 'grapheme' }).segment(text), ]; - if (graphemes(text).length <= max) return text; - let kept = ''; - for (const { segment } of new Intl.Segmenter(locale, { + if (graphemes.length <= max) return text; + const cut = graphemes[max].index; + let end = 0; + for (const { index, segment } of new Intl.Segmenter(locale, { granularity: 'word', }).segment(text)) { - if (graphemes(kept + segment).length > max) break; - kept += segment; + if (index + segment.length > cut) break; + end = index + segment.length; } - return ( - kept.trim() || - graphemes(text) - .slice(0, max) - .map(({ segment }) => segment) - .join('') - ); + return text.slice(0, end).trim() || text.slice(0, cut); } async function handleHudSummary(req, res) { diff --git a/src/data/layerSnapshot.js b/src/data/layerSnapshot.js index 05b685edb..db0f326c8 100644 --- a/src/data/layerSnapshot.js +++ b/src/data/layerSnapshot.js @@ -274,14 +274,36 @@ export function provenanceTag(envelope) { ) .sort( (a, b) => - (FEED_STATE_SEVERITY[a.feedState] ?? Infinity) - - (FEED_STATE_SEVERITY[b.feedState] ?? Infinity), + FEED_STATE_SEVERITY[a.feedState] - FEED_STATE_SEVERITY[b.feedState], ) .map((s) => String(s.name || s.id || 'LAYER')) .slice(0, 2); return { state: overall, names }; } +const upperCase = (word) => word.toUpperCase(); + +/** + * `STATE | text | LAYERS`: the state leads so a narrow readout never clips + * it; the layer names trail so they never push out the text. + * @param {string} text + * @param {{ state: string, names: string[] }|null} tag From `provenanceTag`. + * @param {(state: string) => string} [stateLabel] + * @param {(name: string) => string} [layerLabel] + * @returns {string} + */ +export function provenanceLine( + text, + tag, + stateLabel = upperCase, + layerLabel = upperCase, +) { + if (!tag) return text; + return [stateLabel(tag.state), text, tag.names.map(layerLabel).join('/')] + .filter(Boolean) + .join(' | '); +} + /** * Require a matching feedState for warning states: a view that is not * nominal falls back on a missing or different state. diff --git a/src/hud.js b/src/hud.js index 6dbd90c20..7e61fe91b 100644 --- a/src/hud.js +++ b/src/hud.js @@ -35,12 +35,15 @@ import { hasWord, hudSummaryHasEnglish, hudSummaryIsLatin, - hudSummaryMatchesProvenance, hudSummaryLayerContext, hudTelemetryProvenance, isHudSummaryUnconfigured, - provenanceTag, } from './hudSummaryResponse.js'; +import { + hudSummaryMatchesProvenance, + provenanceLine, + provenanceTag, +} from './data/layerSnapshot.js'; import { applyHudUiTheme, DEFAULT_HUD_LAYOUT, @@ -119,20 +122,17 @@ const FEED_STATE_LABELS = Object.freeze({ unavailable: msg('UNAVAILABLE'), }); -/** - * A summary line with its translated warning: - * `UNAVAILABLE | | LIVE AIS VESSELS`. The state leads so a narrow - * readout never clips it; the layer names trail so they never push out the text. - */ +/** A summary line with its warning in the active language. */ function withProvenance(text, provenance) { - if (!provenance) return text; - const state = FEED_STATE_LABELS[provenance.state] - ? t(FEED_STATE_LABELS[provenance.state]) - : provenance.state.toUpperCase(); - const layers = provenance.names - .map((name) => localizeLayerName(name).toUpperCase()) - .join('/'); - return [state, text, layers].filter(Boolean).join(' | '); + return provenanceLine( + text, + provenance, + (state) => + FEED_STATE_LABELS[state] + ? t(FEED_STATE_LABELS[state]) + : state.toUpperCase(), + (name) => localizeLayerName(name).toUpperCase(), + ); } /** diff --git a/src/hudSummaryResponse.js b/src/hudSummaryResponse.js index 72003ef75..1f41b994d 100644 --- a/src/hudSummaryResponse.js +++ b/src/hudSummaryResponse.js @@ -6,11 +6,6 @@ import { } from './data/layerSnapshot.js'; import { DEFAULT_LOCALE, normalizeLocale } from './i18n/locale.js'; -export { - hudSummaryMatchesProvenance, - provenanceTag, -} from './data/layerSnapshot.js'; - export const HUD_SUMMARY_UNCONFIGURED_CODE = 'OPENAI_NOT_CONFIGURED'; /** @@ -75,9 +70,6 @@ export const HUD_SUMMARY_MAX_UNSPACED_CHARS = 20; /** Longest summary, in characters; the cut falls between words. */ export const HUD_SUMMARY_MAX_CHARS = 80; -/** feedState values the model may return besides null. */ -export const HUD_SUMMARY_FEED_STATES = SNAPSHOT_FEED_STATES; - /** Responses API structured output: the summary plus the canonical state. */ export const HUD_SUMMARY_RESPONSE_FORMAT = Object.freeze({ type: 'json_schema', @@ -91,7 +83,7 @@ export const HUD_SUMMARY_RESPONSE_FORMAT = Object.freeze({ // summary of a view without layers. feedState: { anyOf: [ - { type: 'string', enum: [...HUD_SUMMARY_FEED_STATES] }, + { type: 'string', enum: [...SNAPSHOT_FEED_STATES] }, { type: 'null' }, ], }, @@ -131,7 +123,7 @@ const WORD_SCRIPTS = [ 'Hiragana', 'Katakana', 'Hangul', -].map((script) => new RegExp(`\\p{Script=${script}}`, 'u')); +].map((script) => `\\p{Script=${script}}`); /** Lower case, with punctuation and brackets as spaces: `Earthquakes 24h`. */ function flatWords(value) { @@ -145,8 +137,8 @@ function flatWords(value) { 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)); +function wordScript(char) { + return WORD_SCRIPTS.find((script) => new RegExp(script, 'u').test(char)); } /** @@ -162,17 +154,14 @@ export function hasWord(text, word) { 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; - at = haystack.indexOf(needle, at + 1) - ) { - const before = haystack[at - 1]; - const after = haystack[at + needle.length]; - if (!sameScript(before, needle[0]) && !sameScript(after, needle.at(-1))) - return true; - } - return false; + const chars = [...needle]; + const before = wordScript(chars[0]); + const after = wordScript(chars.at(-1)); + // flatWords leaves no regex syntax in the needle. + return new RegExp( + `${before ? `(? { @@ -356,34 +359,31 @@ test('a Japanese summary is capped in characters, not words', async () => { assert.ok([...shown].length >= 10, shown); }); +// Within the cap a reply comes back whole, in NFC: marks, in-word +// apostrophes and dots, multi-word names and Han names included. for (const [locale, summary] of [ ['ar', 'دبي حركة الطيران محدودة حاليًا'], ['ar', 'القاهرة تتبّع الرحلات الآن'], ['fr', "Vols au-dessus d'Orléans aujourd'hui partiels"], ['it', "Voli sull'aeroporto di Roma parziali"], ['fr', 'Données de vol à Paris'.normalize('NFD')], - ['ja', 'サンフランシスコ・ベイエリア'], -]) { - test(`a ${locale} summary keeps marks, apostrophes and dots: ${summary}`, async () => { - const { response } = await postHudSummary( - { locale, feedProvenance: { overall: null } }, - JSON.stringify({ summary, feedState: null }), - ); - assert.equal(response.body.summary, summary.normalize('NFC')); - }); -} - -for (const [locale, summary] of [ ['fr', 'Rue de Rivoli Vols en direct'], ['es', 'Times Square Nueva York Vuelos en vivo'], ['ru', 'Times Square Рейсы онлайн и суда'], + ['en', 'Shibuya 渋谷 live flights overhead'], + ['es', 'Vuelos sobre 東京駅 datos antiguos'], + ['ru', 'Башня Тайбэй 101 台北101 рейсы'], + ['ja', 'サンフランシスコ・ベイエリア'], + ['ja', '東京 上空 の 航空 情報 は 古い'], + ['zh-CN', 'SFO 附近 4 架 航班 数据 过期'], + ['zh-Hant', 'SFO 附近 4 架 航班 數據 過期'], ]) { - 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 ${locale} summary within the cap is kept whole: ${summary}`, () => { + const reply = JSON.stringify({ summary, feedState: null }); + assert.equal( + parseHudSummaryReply(reply, locale).summary, + summary.normalize('NFC'), + ); }); } @@ -395,20 +395,6 @@ test('a summary is capped in characters', async () => { assert.equal(response.body.summary, 'Berlin'); }); -for (const [locale, summary] of [ - ['en', 'Shibuya 渋谷 live flights overhead'], - ['es', 'Vuelos sobre 東京駅 datos antiguos'], - ['ru', 'Башня Тайбэй 101 台北101 рейсы'], -]) { - 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' }), - ); - assert.equal(response.body.summary, summary); - }); -} - test('a ja cap steps back to a space instead of cutting a Latin word', async () => { const { response } = await postHudSummary( { locale: 'ja', feedProvenance: { overall: 'stale' } }, @@ -431,20 +417,6 @@ test('a ja cap keeps the text before a Latin word it would split', async () => { assert.equal(response.body.summary, '東京 羽田空港周辺の'); }); -for (const [locale, summary] of [ - ['ja', '東京 上空 の 航空 情報 は 古い'], - ['zh-CN', 'SFO 附近 4 架 航班 数据 过期'], - ['zh-Hant', 'SFO 附近 4 架 航班 數據 過期'], -]) { - test(`a ${locale} phrase within the character cap is kept whole`, async () => { - const { response } = await postHudSummary( - { locale, feedProvenance: { overall: 'stale' } }, - JSON.stringify({ summary, feedState: 'stale' }), - ); - assert.equal(response.body.summary, summary); - }); -} - test('a reply that skips the format carries no summary and no state', async () => { const { response } = await postHudSummary( { locale: 'ru', feedProvenance: { overall: 'stale' } }, diff --git a/src/tools/queries/brief.js b/src/tools/queries/brief.js index 86915a72a..1ffc5bee3 100644 --- a/src/tools/queries/brief.js +++ b/src/tools/queries/brief.js @@ -4,6 +4,7 @@ import { suggestView } from '../views.js'; import { feedProvenanceEnvelope, hudSummaryMatchesProvenance, + provenanceLine, provenanceTag, } from '../../data/layerSnapshot.js'; import { defineTool, ToolError } from '../catalog.js'; @@ -258,15 +259,12 @@ export const getHudCaption = defineTool({ throw new ToolError('unavailable', 'The caption service did not answer'); // As on the HUD: the app's own state leads and its layers trail, and a // reply that names another state than the app's keeps only the place. - const tag = provenanceTag(feedProvenance); - const text = hudSummaryMatchesProvenance(response.data, feedProvenance) - ? words - : area.label; - const caption = tag - ? [tag.state.toUpperCase(), text, tag.names.join('/').toUpperCase()] - .filter(Boolean) - .join(' | ') - : text; + const caption = provenanceLine( + hudSummaryMatchesProvenance(response.data, feedProvenance) + ? words + : area.label, + provenanceTag(feedProvenance), + ); return { summary: caption, data: {