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.
This commit is contained in:
German Khokhlov committed 2026-11-03 14:45:32 +04:00
1 parent ab8691383b
commit 03c168d7c9
6 files changed
+92 -116

No files matched your search

+11 -16
View File
@@ -1,5 +1,5 @@
import { SNAPSHOT_FEED_STATES } from '../../../src/data/layerSnapshot.js';
import { import {
HUD_SUMMARY_FEED_STATES,
HUD_SUMMARY_MAX_CHARS, HUD_SUMMARY_MAX_CHARS,
HUD_SUMMARY_MAX_UNSPACED_CHARS, HUD_SUMMARY_MAX_UNSPACED_CHARS,
HUD_SUMMARY_RESPONSE_FORMAT, HUD_SUMMARY_RESPONSE_FORMAT,
@@ -33,7 +33,7 @@ function parseHudSummaryReply(text, locale) {
} catch { } catch {
/* not JSON: no summary */ /* not JSON: no summary */
} }
const feedState = HUD_SUMMARY_FEED_STATES.includes(reply?.feedState) const feedState = SNAPSHOT_FEED_STATES.includes(reply?.feedState)
? reply.feedState ? reply.feedState
: null; : null;
return { return {
@@ -64,24 +64,19 @@ function toHudSummary(value, requested) {
* than that is cut where the cap falls. * than that is cut where the cap falls.
*/ */
function capAtWord(text, max, locale) { function capAtWord(text, max, locale) {
const graphemes = (value) => [ const graphemes = [
...new Intl.Segmenter(locale, { granularity: 'grapheme' }).segment(value), ...new Intl.Segmenter(locale, { granularity: 'grapheme' }).segment(text),
]; ];
if (graphemes(text).length <= max) return text; if (graphemes.length <= max) return text;
let kept = ''; const cut = graphemes[max].index;
for (const { segment } of new Intl.Segmenter(locale, { let end = 0;
for (const { index, segment } of new Intl.Segmenter(locale, {
granularity: 'word', granularity: 'word',
}).segment(text)) { }).segment(text)) {
if (graphemes(kept + segment).length > max) break; if (index + segment.length > cut) break;
kept += segment; end = index + segment.length;
} }
return ( return text.slice(0, end).trim() || text.slice(0, cut);
kept.trim() ||
graphemes(text)
.slice(0, max)
.map(({ segment }) => segment)
.join('')
);
} }
async function handleHudSummary(req, res) { async function handleHudSummary(req, res) {
+24 -2
View File
@@ -274,14 +274,36 @@ export function provenanceTag(envelope) {
) )
.sort( .sort(
(a, b) => (a, b) =>
(FEED_STATE_SEVERITY[a.feedState] ?? Infinity) - FEED_STATE_SEVERITY[a.feedState] - FEED_STATE_SEVERITY[b.feedState],
(FEED_STATE_SEVERITY[b.feedState] ?? Infinity),
) )
.map((s) => String(s.name || s.id || 'LAYER')) .map((s) => String(s.name || s.id || 'LAYER'))
.slice(0, 2); .slice(0, 2);
return { state: overall, names }; 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 * Require a matching feedState for warning states: a view that is not
* nominal falls back on a missing or different state. * nominal falls back on a missing or different state.
+15 -15
View File
@@ -35,12 +35,15 @@ import {
hasWord, hasWord,
hudSummaryHasEnglish, hudSummaryHasEnglish,
hudSummaryIsLatin, hudSummaryIsLatin,
hudSummaryMatchesProvenance,
hudSummaryLayerContext, hudSummaryLayerContext,
hudTelemetryProvenance, hudTelemetryProvenance,
isHudSummaryUnconfigured, isHudSummaryUnconfigured,
provenanceTag,
} from './hudSummaryResponse.js'; } from './hudSummaryResponse.js';
import {
hudSummaryMatchesProvenance,
provenanceLine,
provenanceTag,
} from './data/layerSnapshot.js';
import { import {
applyHudUiTheme, applyHudUiTheme,
DEFAULT_HUD_LAYOUT, DEFAULT_HUD_LAYOUT,
@@ -119,20 +122,17 @@ const FEED_STATE_LABELS = Object.freeze({
unavailable: msg('UNAVAILABLE'), unavailable: msg('UNAVAILABLE'),
}); });
/** /** A summary line with its warning in the active language. */
* A summary line with its translated warning:
* `UNAVAILABLE | <text> | LIVE AIS VESSELS`. The state leads so a narrow
* readout never clips it; the layer names trail so they never push out the text.
*/
function withProvenance(text, provenance) { function withProvenance(text, provenance) {
if (!provenance) return text; return provenanceLine(
const state = FEED_STATE_LABELS[provenance.state] text,
? t(FEED_STATE_LABELS[provenance.state]) provenance,
: provenance.state.toUpperCase(); (state) =>
const layers = provenance.names FEED_STATE_LABELS[state]
.map((name) => localizeLayerName(name).toUpperCase()) ? t(FEED_STATE_LABELS[state])
.join('/'); : state.toUpperCase(),
return [state, text, layers].filter(Boolean).join(' | '); (name) => localizeLayerName(name).toUpperCase(),
);
} }
/** /**
+12 -23
View File
@@ -6,11 +6,6 @@ import {
} from './data/layerSnapshot.js'; } from './data/layerSnapshot.js';
import { DEFAULT_LOCALE, normalizeLocale } from './i18n/locale.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'; 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. */ /** Longest summary, in characters; the cut falls between words. */
export const HUD_SUMMARY_MAX_CHARS = 80; 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. */ /** Responses API structured output: the summary plus the canonical state. */
export const HUD_SUMMARY_RESPONSE_FORMAT = Object.freeze({ export const HUD_SUMMARY_RESPONSE_FORMAT = Object.freeze({
type: 'json_schema', type: 'json_schema',
@@ -91,7 +83,7 @@ export const HUD_SUMMARY_RESPONSE_FORMAT = Object.freeze({
// summary of a view without layers. // summary of a view without layers.
feedState: { feedState: {
anyOf: [ anyOf: [
{ type: 'string', enum: [...HUD_SUMMARY_FEED_STATES] }, { type: 'string', enum: [...SNAPSHOT_FEED_STATES] },
{ type: 'null' }, { type: 'null' },
], ],
}, },
@@ -131,7 +123,7 @@ const WORD_SCRIPTS = [
'Hiragana', 'Hiragana',
'Katakana', 'Katakana',
'Hangul', 'Hangul',
].map((script) => new RegExp(`\\p{Script=${script}}`, 'u')); ].map((script) => `\\p{Script=${script}}`);
/** Lower case, with punctuation and brackets as spaces: `Earthquakes 24h`. */ /** Lower case, with punctuation and brackets as spaces: `Earthquakes 24h`. */
function flatWords(value) { function flatWords(value) {
@@ -145,8 +137,8 @@ function flatWords(value) {
const UNSPACED_WORD = const UNSPACED_WORD =
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}ー]+$/u; /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}ー]+$/u;
function sameScript(a, b) { function wordScript(char) {
return WORD_SCRIPTS.some((script) => script.test(a ?? '') && script.test(b)); return WORD_SCRIPTS.find((script) => new RegExp(script, 'u').test(char));
} }
/** /**
@@ -162,17 +154,14 @@ export function hasWord(text, word) {
const needle = flatWords(word); const needle = flatWords(word);
if (!needle) return false; if (!needle) return false;
if (UNSPACED_WORD.test(needle)) return haystack.includes(needle); if (UNSPACED_WORD.test(needle)) return haystack.includes(needle);
for ( const chars = [...needle];
let at = haystack.indexOf(needle); const before = wordScript(chars[0]);
at >= 0; const after = wordScript(chars.at(-1));
at = haystack.indexOf(needle, at + 1) // flatWords leaves no regex syntax in the needle.
) { return new RegExp(
const before = haystack[at - 1]; `${before ? `(?<!${before})` : ''}${needle}${after ? `(?!${after})` : ''}`,
const after = haystack[at + needle.length]; 'u',
if (!sameScript(before, needle[0]) && !sameScript(after, needle.at(-1))) ).test(haystack);
return true;
}
return false;
} }
/** /**
+24 -52
View File
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { Readable } from 'node:stream'; import { Readable } from 'node:stream';
import { import {
HUD_SUMMARY_FEED_STATES,
HUD_SUMMARY_INSTRUCTIONS, HUD_SUMMARY_INSTRUCTIONS,
HUD_SUMMARY_MAX_UNSPACED_CHARS, HUD_SUMMARY_MAX_UNSPACED_CHARS,
HUD_SUMMARY_RESPONSE_FORMAT, HUD_SUMMARY_RESPONSE_FORMAT,
@@ -11,12 +10,16 @@ import {
hudSummaryInstructions, hudSummaryInstructions,
hudSummaryLayerContext, hudSummaryLayerContext,
hudSummaryLocale, hudSummaryLocale,
hudSummaryMatchesProvenance,
hudTelemetryProvenance, hudTelemetryProvenance,
isHudSummaryUnconfigured, isHudSummaryUnconfigured,
keylessHudSummaryResponse, keylessHudSummaryResponse,
provenanceTag,
} from './hudSummaryResponse.js'; } from './hudSummaryResponse.js';
import {
SNAPSHOT_FEED_STATES,
hudSummaryMatchesProvenance,
provenanceTag,
} from './data/layerSnapshot.js';
import { parseHudSummaryReply } from '../server/providers/openai/hud-summary.js';
import { openAiRealtimeProxy } from '../vite.config.js'; import { openAiRealtimeProxy } from '../vite.config.js';
const UNCONFIGURED_PAYLOAD = { const UNCONFIGURED_PAYLOAD = {
@@ -233,10 +236,10 @@ test('HUD summary instructions ask for the canonical feedState as its own field'
]); ]);
assert.deepEqual( assert.deepEqual(
HUD_SUMMARY_RESPONSE_FORMAT.schema.properties.feedState.anyOf, HUD_SUMMARY_RESPONSE_FORMAT.schema.properties.feedState.anyOf,
[{ type: 'string', enum: [...HUD_SUMMARY_FEED_STATES] }, { type: 'null' }], [{ type: 'string', enum: [...SNAPSHOT_FEED_STATES] }, { type: 'null' }],
); );
assert.ok(!HUD_SUMMARY_FEED_STATES.includes('none')); assert.ok(!SNAPSHOT_FEED_STATES.includes('none'));
assert.ok(HUD_SUMMARY_FEED_STATES.includes('partial')); assert.ok(SNAPSHOT_FEED_STATES.includes('partial'));
}); });
test('the instructions name the UI language, with English as the fallback', () => { test('the instructions name the UI language, with English as the fallback', () => {
@@ -356,34 +359,31 @@ test('a Japanese summary is capped in characters, not words', async () => {
assert.ok([...shown].length >= 10, shown); 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 [ for (const [locale, summary] of [
['ar', 'دبي حركة الطيران محدودة حاليًا'], ['ar', 'دبي حركة الطيران محدودة حاليًا'],
['ar', 'القاهرة تتبّع الرحلات الآن'], ['ar', 'القاهرة تتبّع الرحلات الآن'],
['fr', "Vols au-dessus d'Orléans aujourd'hui partiels"], ['fr', "Vols au-dessus d'Orléans aujourd'hui partiels"],
['it', "Voli sull'aeroporto di Roma parziali"], ['it', "Voli sull'aeroporto di Roma parziali"],
['fr', 'Données de vol à Paris'.normalize('NFD')], ['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'], ['fr', 'Rue de Rivoli Vols en direct'],
['es', 'Times Square Nueva York Vuelos en vivo'], ['es', 'Times Square Nueva York Vuelos en vivo'],
['ru', 'Times Square Рейсы онлайн и суда'], ['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 () => { test(`a ${locale} summary within the cap is kept whole: ${summary}`, () => {
// The parser itself: the file's HUD requests share one rate limiter. const reply = JSON.stringify({ summary, feedState: null });
const { parseHudSummaryReply } = assert.equal(
await import('../server/providers/openai/hud-summary.js'); parseHudSummaryReply(reply, locale).summary,
const reply = JSON.stringify({ summary, feedState: 'partial' }); summary.normalize('NFC'),
assert.equal(parseHudSummaryReply(reply, locale).summary, summary); );
}); });
} }
@@ -395,20 +395,6 @@ test('a summary is capped in characters', async () => {
assert.equal(response.body.summary, 'Berlin'); 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 () => { test('a ja cap steps back to a space instead of cutting a Latin word', async () => {
const { response } = await postHudSummary( const { response } = await postHudSummary(
{ locale: 'ja', feedProvenance: { overall: 'stale' } }, { 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, '東京 羽田空港周辺の'); 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 () => { test('a reply that skips the format carries no summary and no state', async () => {
const { response } = await postHudSummary( const { response } = await postHudSummary(
{ locale: 'ru', feedProvenance: { overall: 'stale' } }, { locale: 'ru', feedProvenance: { overall: 'stale' } },
+6 -8
View File
@@ -4,6 +4,7 @@ import { suggestView } from '../views.js';
import { import {
feedProvenanceEnvelope, feedProvenanceEnvelope,
hudSummaryMatchesProvenance, hudSummaryMatchesProvenance,
provenanceLine,
provenanceTag, provenanceTag,
} from '../../data/layerSnapshot.js'; } from '../../data/layerSnapshot.js';
import { defineTool, ToolError } from '../catalog.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'); 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 // 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. // reply that names another state than the app's keeps only the place.
const tag = provenanceTag(feedProvenance); const caption = provenanceLine(
const text = hudSummaryMatchesProvenance(response.data, feedProvenance) hudSummaryMatchesProvenance(response.data, feedProvenance)
? words ? words
: area.label; : area.label,
const caption = tag provenanceTag(feedProvenance),
? [tag.state.toUpperCase(), text, tag.names.join('/').toUpperCase()] );
.filter(Boolean)
.join(' | ')
: text;
return { return {
summary: caption, summary: caption,
data: { data: {