mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
fix(hud): state word first, layer names last; CJK cap by locale
Follow-up review fixes:
- The HUD line is STATE | text | LAYERS for the AI summary and the
fallback line, so the narrow minimal and cyber layouts show both the
warning and the summary. get_hud_caption builds its caption the same
way. The unused "{state} {layers}" key is pruned from the catalogs.
- The 20-character cap applies only when the request locale is Japanese
or Chinese; other languages keep five words around a Han place name.
- enabledLayers names are localized too, and the instructions name layers
only from enabledLayerLabels, never from feedProvenance or its note.
- The keyless test restores GEV_RATELIMIT_OPENAI_PER_MIN again; comments
and docs updated.
This commit is contained in:
1 parent
f967218a04
commit
7b223d7d7b
17 files changed
+80
-51
No files matched your search
@@ -5257,8 +5257,8 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
- HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning).
|
||||
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers, the layer names as the user sees them) and `feedProvenance`; the model is instructed not to infer from coordinates.
|
||||
- Language (October 2026): the HUD sends `locale`, which is part of the dedupe signature. The proxy keeps a shipped locale (English otherwise) and names the language in the instructions and at the start of the input; Japanese and Chinese get a 20-character phrase instead of five words.
|
||||
- The reply is structured (`text.format` JSON schema): `{summary, feedState}`, `feedState` being the snapshot state or `none`. For a view that is not nominal, a missing or different `feedState` falls back to the deterministic line. An accepted summary is shown after the HUD's own translated state tag (`STALE LIVE FLIGHTS | …`), which leads so the one-line readout never clips it.
|
||||
- Output is sanitized to five words (20 characters for Japanese and Chinese); falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
|
||||
- The reply is structured (`text.format` JSON schema): `{summary, feedState}`, `feedState` being the snapshot state or `none`. For a view that is not nominal, a missing or different `feedState` falls back to the deterministic line. An accepted summary and the deterministic line carry the HUD's own translated state: the state word leads and the layer names trail (`STALE | … | LIVE FLIGHTS`), so the one-line readout clips neither the warning nor the text. `get_hud_caption` builds its caption the same way and returns `feedState`.
|
||||
- Output is sanitized to five words (20 characters when the request locale is Japanese or Chinese, whatever script the words use); falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
|
||||
- `partial` is a feed state of its own, ranked between `fallback` and `nominal`, so one partial layer no longer reads as off and partial plus nominal reads partial (HUD, voice view state, `analyst_query`, `brief`, the agent world packet). Its narration note says coverage is incomplete rather than not live.
|
||||
- A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
HUD_SUMMARY_RESPONSE_FORMAT,
|
||||
hudSummaryInput,
|
||||
hudSummaryInstructions,
|
||||
hudSummaryIsUnspaced,
|
||||
keylessHudSummaryResponse,
|
||||
} from '../../../src/hudSummaryResponse.js';
|
||||
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||
@@ -23,7 +24,7 @@ function extractOpenAiResponseText(data) {
|
||||
}
|
||||
|
||||
/** Parse the summary and normalize the canonical feed state. */
|
||||
function parseHudSummaryReply(text) {
|
||||
function parseHudSummaryReply(text, locale) {
|
||||
let reply = null;
|
||||
try {
|
||||
reply = JSON.parse(text);
|
||||
@@ -34,12 +35,12 @@ function parseHudSummaryReply(text) {
|
||||
? reply.feedState
|
||||
: null;
|
||||
return {
|
||||
summary: toFiveWordHudSummary(reply?.summary),
|
||||
summary: toFiveWordHudSummary(reply?.summary, hudSummaryIsUnspaced(locale)),
|
||||
feedState: feedState === 'none' ? null : feedState,
|
||||
};
|
||||
}
|
||||
|
||||
function toFiveWordHudSummary(value) {
|
||||
function toFiveWordHudSummary(value, unspaced) {
|
||||
const words = String(value || '')
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
|
||||
.trim()
|
||||
@@ -48,8 +49,8 @@ function toFiveWordHudSummary(value) {
|
||||
.slice(0, 5)
|
||||
.join(' ');
|
||||
// Japanese and Chinese have no spaces to count words by: cap characters.
|
||||
if (!/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(words))
|
||||
return words;
|
||||
// Other languages keep five words, even around a Han place name.
|
||||
if (!unspaced) return words;
|
||||
const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
||||
return [...graphemes.segment(words)]
|
||||
.slice(0, HUD_SUMMARY_MAX_UNSPACED_CHARS)
|
||||
@@ -117,6 +118,7 @@ async function handleHudSummary(req, res) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const { summary, feedState } = parseHudSummaryReply(
|
||||
extractOpenAiResponseText(data),
|
||||
context?.locale,
|
||||
);
|
||||
res.statusCode = response.ok && summary ? 200 : response.status || 502;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
|
||||
+21
-20
@@ -104,22 +104,21 @@ const FEED_STATE_LABELS = Object.freeze({
|
||||
});
|
||||
|
||||
/**
|
||||
* The summary's feed-state suffix in the active language, e.g.
|
||||
* `UNAVAILABLE: LIVE AIS VESSELS`. State ids and layer names stay English in
|
||||
* layer data; only this line translates them.
|
||||
* A summary line with its feed-state warning in the active language, e.g.
|
||||
* `UNAVAILABLE | <text> | LIVE AIS VESSELS`. The state word leads so the
|
||||
* one-line readout never clips it; the layer names trail so they never push
|
||||
* the text out. State ids and layer names stay English in layer data; only
|
||||
* this line translates them.
|
||||
*/
|
||||
function localizedProvenanceTag(provenance) {
|
||||
if (!provenance) return null;
|
||||
function withProvenance(text, provenance) {
|
||||
if (!provenance) return text;
|
||||
const state = FEED_STATE_LABELS[provenance.state]
|
||||
? t(FEED_STATE_LABELS[provenance.state])
|
||||
: provenance.state.toUpperCase();
|
||||
if (!provenance.names.length) return state;
|
||||
return t('{state} {layers}', {
|
||||
state,
|
||||
layers: provenance.names
|
||||
.map((name) => localizeLayerName(name).toUpperCase())
|
||||
.join('/'),
|
||||
});
|
||||
const layers = provenance.names
|
||||
.map((name) => localizeLayerName(name).toUpperCase())
|
||||
.join('/');
|
||||
return [state, text, layers].filter(Boolean).join(' | ');
|
||||
}
|
||||
|
||||
/** The name shown for a _viewBand() id. */
|
||||
@@ -764,8 +763,8 @@ export class IntelHUD {
|
||||
LOCALIZED_LOCALITY,
|
||||
);
|
||||
|
||||
const provenance = localizedProvenanceTag(
|
||||
hudTelemetryProvenance(this._dataManager?.getAll?.() || []),
|
||||
const provenance = hudTelemetryProvenance(
|
||||
this._dataManager?.getAll?.() || [],
|
||||
);
|
||||
// ALT, SUN and ONA are sensor codes, as in the corner readouts.
|
||||
const line = t(
|
||||
@@ -782,8 +781,7 @@ export class IntelHUD {
|
||||
zone: localTag,
|
||||
},
|
||||
);
|
||||
// The feed state leads: the one-line readout clips its end.
|
||||
return provenance ? `${provenance} | ${line}` : line;
|
||||
return withProvenance(line, provenance);
|
||||
}
|
||||
|
||||
/** Stop a summary still typing out, leaving what it has typed so far. */
|
||||
@@ -895,12 +893,11 @@ export class IntelHUD {
|
||||
throw new Error(data?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
if (hudSummaryMatchesProvenance(data, context.feedProvenance)) {
|
||||
// The warning is the HUD's own translated tag, not the model's
|
||||
// words, and it leads so the one-line readout never clips it.
|
||||
const tag = localizedProvenanceTag(
|
||||
// The warning is the HUD's own translated state, not the model's words.
|
||||
this._lastSummaryText = withProvenance(
|
||||
data.summary,
|
||||
provenanceTag(context.feedProvenance),
|
||||
);
|
||||
this._lastSummaryText = tag ? `${tag} | ${data.summary}` : data.summary;
|
||||
}
|
||||
this._setSummaryText(this._lastSummaryText || fallbackText, animate);
|
||||
} catch (error) {
|
||||
@@ -973,6 +970,10 @@ export class IntelHUD {
|
||||
// The names the user sees; feedProvenance keeps the English ids.
|
||||
enabledLayerLabels:
|
||||
layerContext.enabledLayerLabels.map(localizeLayerName),
|
||||
enabledLayers: layerContext.enabledLayers.map((layer) => ({
|
||||
...layer,
|
||||
name: localizeLayerName(layer.name),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ export const HUD_SUMMARY_INSTRUCTIONS = [
|
||||
"Write one concise intelligence-HUD summary for God's Eye View.",
|
||||
'Use only the supplied place, street, nearby-place, enabled-layer labels, and feedProvenance.',
|
||||
'Prefer the clearest named place and include a relevant enabled layer only when useful.',
|
||||
'Name layers only as in enabledLayerLabels; never copy layer names or state words from feedProvenance or its note.',
|
||||
'Set feedState to feedProvenance.overall exactly as given, or none when it is null.',
|
||||
'If feedProvenance.overall is partial, do not present the coverage as complete; if it is any other state but nominal, do not present the view as live or healthy.',
|
||||
'Do not infer from coordinates or invent a place or feed state.',
|
||||
@@ -95,7 +96,6 @@ export function hudSummaryLocale(value) {
|
||||
return normalizeLocale(value) || DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
/** The language a request's summary is written in, named in English. */
|
||||
// Names Intl.DisplayNames gets wrong for the shipped catalogs: pt is the
|
||||
// Brazilian variant, and zh-Hant is the zh-CN wording in Traditional script.
|
||||
const SUMMARY_LANGUAGES = Object.freeze({
|
||||
@@ -107,6 +107,12 @@ const SUMMARY_LANGUAGES = Object.freeze({
|
||||
/** Scripts written without spaces, where five words do not apply. */
|
||||
const UNSPACED_LOCALES = /^(?:ja|zh)(?:-|$)/;
|
||||
|
||||
/** Whether the summary for this locale is capped in characters, not words. */
|
||||
export function hudSummaryIsUnspaced(locale) {
|
||||
return UNSPACED_LOCALES.test(hudSummaryLocale(locale));
|
||||
}
|
||||
|
||||
/** The language a request's summary is written in, named in English. */
|
||||
function summaryLanguage(tag) {
|
||||
if (SUMMARY_LANGUAGES[tag]) return SUMMARY_LANGUAGES[tag];
|
||||
try {
|
||||
@@ -125,7 +131,7 @@ export function hudSummaryInstructions(locale) {
|
||||
const tag = hudSummaryLocale(locale);
|
||||
if (tag === DEFAULT_LOCALE) return HUD_SUMMARY_INSTRUCTIONS;
|
||||
const language = summaryLanguage(tag);
|
||||
const length = UNSPACED_LOCALES.test(tag)
|
||||
const length = hudSummaryIsUnspaced(tag)
|
||||
? ` Instead of five words, write one phrase of at most ${HUD_SUMMARY_MAX_UNSPACED_CHARS} characters.`
|
||||
: '';
|
||||
return `${HUD_SUMMARY_INSTRUCTIONS} Write the summary in ${language} (${tag}), not English, giving places their usual ${language} names and layers the names in enabledLayerLabels; feedState stays the English value.${length}`;
|
||||
@@ -172,7 +178,7 @@ export function hudSummaryLayerContext(layers = [], options) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The feed-state tag for the HUD telemetry suffix when an enabled feed is not
|
||||
* The warning for the HUD telemetry line when an enabled feed is not
|
||||
* nominal.
|
||||
* @param {Array<object>} [layers]
|
||||
* @param {{ now?: number }} [options]
|
||||
|
||||
@@ -164,6 +164,9 @@ test('the installed keyless HUD route stays successful after the voice quota is
|
||||
} finally {
|
||||
if (previousKey === undefined) delete process.env.OPENAI_API_KEY;
|
||||
else process.env.OPENAI_API_KEY = previousKey;
|
||||
if (previousLimit === undefined)
|
||||
delete process.env.GEV_RATELIMIT_OPENAI_PER_MIN;
|
||||
else process.env.GEV_RATELIMIT_OPENAI_PER_MIN = previousLimit;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -263,10 +266,20 @@ test('the HUD sends its UI locale inside the context it signs for dedupe', () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('the state tag leads the one-line summary so clipping cannot hide it', () => {
|
||||
test('the state word leads and the layer names trail the one-line summary', () => {
|
||||
const hud = readFileSync(new URL('./hud.js', import.meta.url), 'utf8');
|
||||
assert.match(hud, /tag \? `\$\{tag\} \| \$\{data\.summary\}`/);
|
||||
assert.match(hud, /provenance \? `\$\{provenance\} \| \$\{line\}`/);
|
||||
assert.match(
|
||||
hud,
|
||||
/return \[state, text, layers\]\.filter\(Boolean\)\.join\(' \| '\);/,
|
||||
);
|
||||
assert.match(hud, /withProvenance\(\s*data\.summary,/);
|
||||
assert.match(hud, /return withProvenance\(line, provenance\);/);
|
||||
// The model sees only the layer names the user sees.
|
||||
assert.match(hud, /name: localizeLayerName\(layer\.name\)/);
|
||||
assert.match(
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
/Name layers only as in enabledLayerLabels/,
|
||||
);
|
||||
});
|
||||
|
||||
test('shipped catalogs get their exact language, and CJK a character limit', () => {
|
||||
@@ -370,6 +383,20 @@ test('a Japanese summary is capped in characters, not words', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
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 five words`, 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' } },
|
||||
|
||||
@@ -1125,7 +1125,6 @@
|
||||
"OCEANIA": "OZEANIEN",
|
||||
"NORTHERN OCEANIC GRID": "NÖRDLICHES OZEANRASTER",
|
||||
"SOUTHERN OCEANIC GRID": "SÜDLICHES OZEANRASTER",
|
||||
"{state} {layers}": "{state} {layers}",
|
||||
"view band|STREET": "STRASSE",
|
||||
"view band|CITY": "STADT",
|
||||
"view band|METRO": "GROSSRAUM",
|
||||
|
||||
@@ -2025,7 +2025,6 @@
|
||||
"Globe field image unavailable": "Imagen del campo en el globo no disponible",
|
||||
"status chip|UNAVAILABLE": "NO DISP.",
|
||||
"Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Contiene referencias a contenido registrado o medios vinculados. Esos archivos externos no se incluyen en un paquete de escena. Sus avisos originales siguen vigentes.",
|
||||
"{state} {layers}": "{state}: {layers}",
|
||||
"Selected Space Mission": "Misión espacial seleccionada",
|
||||
"{duration}s + {hold}s": "{duration} s + {hold} s",
|
||||
"Configured Source": "Fuente configurada",
|
||||
|
||||
@@ -332,7 +332,6 @@
|
||||
"STALE": "OBSOLÈTE",
|
||||
"PARTIAL": "PARTIEL",
|
||||
"FALLBACK": "SECOURS",
|
||||
"{state} {layers}": "{state} : {layers}",
|
||||
"view band|STREET": "RUE",
|
||||
"view band|CITY": "VILLE",
|
||||
"view band|METRO": "MÉTROPOLE",
|
||||
|
||||
@@ -332,7 +332,6 @@
|
||||
"STALE": "OBSOLETO",
|
||||
"PARTIAL": "PARZIALE",
|
||||
"FALLBACK": "RISERVA",
|
||||
"{state} {layers}": "{state} {layers}",
|
||||
"view band|STREET": "STRADA",
|
||||
"view band|CITY": "CITTÀ",
|
||||
"view band|METRO": "METRO",
|
||||
|
||||
@@ -326,7 +326,6 @@
|
||||
"STALE": "更新遅延",
|
||||
"PARTIAL": "部分的",
|
||||
"FALLBACK": "フォールバック",
|
||||
"{state} {layers}": "{state} {layers}",
|
||||
"view band|STREET": "街路",
|
||||
"view band|CITY": "市街",
|
||||
"view band|METRO": "都市圏",
|
||||
|
||||
@@ -332,7 +332,6 @@
|
||||
"STALE": "DESATUALIZADO",
|
||||
"PARTIAL": "PARCIAL",
|
||||
"FALLBACK": "ALTERNATIVO",
|
||||
"{state} {layers}": "{state}: {layers}",
|
||||
"view band|STREET": "RUA",
|
||||
"view band|CITY": "CIDADE",
|
||||
"view band|METRO": "METRÓPOLE",
|
||||
|
||||
@@ -2059,7 +2059,6 @@
|
||||
"Globe field image unavailable": "Изображение поля на глобусе недоступно",
|
||||
"status chip|UNAVAILABLE": "НЕДОСТ.",
|
||||
"Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Используется зарегистрированный контент или связанные медиафайлы. Эти внешние файлы не входят в архив сцены. Их исходные условия использования по-прежнему действуют.",
|
||||
"{state} {layers}": "{state}: {layers}",
|
||||
"Selected Space Mission": "Выбранная космическая миссия",
|
||||
"{duration}s + {hold}s": "{duration} с + {hold} с",
|
||||
"Configured Source": "Настроенный источник",
|
||||
|
||||
@@ -335,7 +335,6 @@
|
||||
"STALE": "ЗАСТАРІЛО",
|
||||
"PARTIAL": "ЧАСТКОВО",
|
||||
"FALLBACK": "РЕЗЕРВ",
|
||||
"{state} {layers}": "{state}: {layers}",
|
||||
"view band|STREET": "ВУЛИЦЯ",
|
||||
"view band|CITY": "МІСТО",
|
||||
"view band|METRO": "АГЛОМЕРАЦІЯ",
|
||||
|
||||
@@ -326,7 +326,6 @@
|
||||
"STALE": "过期",
|
||||
"PARTIAL": "部分",
|
||||
"FALLBACK": "备用",
|
||||
"{state} {layers}": "{state} {layers}",
|
||||
"view band|STREET": "街道",
|
||||
"view band|CITY": "城市",
|
||||
"view band|METRO": "都市圈",
|
||||
|
||||
@@ -326,7 +326,6 @@
|
||||
"STALE": "過期",
|
||||
"PARTIAL": "部分",
|
||||
"FALLBACK": "備用",
|
||||
"{state} {layers}": "{state} {layers}",
|
||||
"view band|STREET": "街道",
|
||||
"view band|CITY": "城市",
|
||||
"view band|METRO": "都市圈",
|
||||
|
||||
@@ -256,14 +256,16 @@ export const getHudCaption = defineTool({
|
||||
const words = response?.data?.summary;
|
||||
if (!response?.ok || typeof words !== 'string' || !words)
|
||||
throw new ToolError('unavailable', 'The caption service did not answer');
|
||||
// As on the HUD: the warning is the app's own state tag, and a reply that
|
||||
// names another state than the app's keeps only the place.
|
||||
// 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
|
||||
? `${text} | ${[tag.state, ...tag.names].join(' ').toUpperCase()}`
|
||||
? [tag.state.toUpperCase(), text, tag.names.join('/').toUpperCase()]
|
||||
.filter(Boolean)
|
||||
.join(' | ')
|
||||
: text;
|
||||
return {
|
||||
summary: caption,
|
||||
|
||||
@@ -2,9 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
|
||||
// The proxy returns the feed state as its own field ({ summary, feedState });
|
||||
// the five words no longer carry STALE, so get_hud_caption adds the app's own
|
||||
// state tag and keeps only the place when the reply names another state.
|
||||
// get_hud_caption shows the app's own feed state, not the model's words.
|
||||
async function staleCaption(reply) {
|
||||
const conditions = {
|
||||
observedAt: '2026-10-01T19:15:00.000Z',
|
||||
@@ -53,7 +51,10 @@ test('get_hud_caption keeps the canonical feed state of a stale view', async ()
|
||||
feedState: 'stale',
|
||||
});
|
||||
assert.equal(data.feedState, 'stale');
|
||||
assert.equal(data.caption, 'Austin weather across the area | STALE WEATHER');
|
||||
assert.equal(
|
||||
data.caption,
|
||||
'STALE | Austin weather across the area | WEATHER',
|
||||
);
|
||||
});
|
||||
|
||||
test('get_hud_caption drops words whose feed state is not the app one', async () => {
|
||||
@@ -62,5 +63,5 @@ test('get_hud_caption drops words whose feed state is not the app one', async ()
|
||||
feedState: 'nominal',
|
||||
});
|
||||
assert.equal(data.feedState, 'stale');
|
||||
assert.equal(data.caption, 'Austin, Texas | STALE WEATHER');
|
||||
assert.equal(data.caption, 'STALE | Austin, Texas | WEATHER');
|
||||
});
|
||||
Reference in new issue
Block a user