mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
ab8691383b
commit
03c168d7c9
6 files changed
+93
-117
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
+15
-15
@@ -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 | <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.
|
||||
*/
|
||||
/** 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(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+12
-23
@@ -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 ? `(?<!${before})` : ''}${needle}${after ? `(?!${after})` : ''}`,
|
||||
'u',
|
||||
).test(haystack);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Readable } from 'node:stream';
|
||||
import {
|
||||
HUD_SUMMARY_FEED_STATES,
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
HUD_SUMMARY_MAX_UNSPACED_CHARS,
|
||||
HUD_SUMMARY_RESPONSE_FORMAT,
|
||||
@@ -11,12 +10,16 @@ import {
|
||||
hudSummaryInstructions,
|
||||
hudSummaryLayerContext,
|
||||
hudSummaryLocale,
|
||||
hudSummaryMatchesProvenance,
|
||||
hudTelemetryProvenance,
|
||||
isHudSummaryUnconfigured,
|
||||
keylessHudSummaryResponse,
|
||||
provenanceTag,
|
||||
} 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';
|
||||
|
||||
const UNCONFIGURED_PAYLOAD = {
|
||||
@@ -233,10 +236,10 @@ test('HUD summary instructions ask for the canonical feedState as its own field'
|
||||
]);
|
||||
assert.deepEqual(
|
||||
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(HUD_SUMMARY_FEED_STATES.includes('partial'));
|
||||
assert.ok(!SNAPSHOT_FEED_STATES.includes('none'));
|
||||
assert.ok(SNAPSHOT_FEED_STATES.includes('partial'));
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// 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' } },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user