mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
fix(hud): lead with the state tag, keep feedState in get_hud_caption
Review fixes for the HUD summary language change: - The translated feed-state tag now leads the one-line summary and the fallback line, so the readout's ellipsis cannot hide it. - get_hud_caption checks the reply's feedState, returns it and appends the state tag; a reply naming another state keeps only the place. - The model gets the layer names the user sees (localizeLayerName). - Japanese and Chinese summaries are capped at 20 characters; pt, zh-CN and zh-Hant get explicit language names. - A partial view is narrated as incomplete coverage, not as not live. - provenanceTag and hudSummaryMatchesProvenance move to layerSnapshot.js so the tools package can use them without browser globals. - docs/CURRENT-STATE.md, changelog and comments updated; redundant tests trimmed.
This commit is contained in:
1 parent
54750a01e5
commit
f967218a04
10 files changed
+232
-80
No files matched your search
@@ -1,5 +1,3 @@
|
|||||||
- Write the AI HUD summary in the interface language. The model now returns
|
- Write the AI HUD summary in the interface language, led by the HUD's own
|
||||||
the feed state as its own field, and the HUD checks it against its own and
|
translated feed-state warning. A partial layer no longer reads as off or
|
||||||
adds its translated state tag, so a stale or partial view is never shown as
|
nominal in the HUD, voice and analyst answers (German, @gekh, #1488).
|
||||||
live. A partial layer no longer reads as off or nominal in the HUD, voice
|
|
||||||
and analyst provenance (German, @gekh, #1488).
|
|
||||||
@@ -5255,8 +5255,11 @@ and unreachable upstream (502/504) separately from road geometry.
|
|||||||
### AI HUD Summary (June 2026)
|
### AI HUD Summary (June 2026)
|
||||||
|
|
||||||
- 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).
|
- 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 model is instructed not to infer from coordinates.
|
- 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.
|
||||||
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
|
- 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.
|
||||||
|
- `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.
|
- 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.
|
||||||
|
|
||||||
### Place-search providers
|
### Place-search providers
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
HUD_SUMMARY_FEED_STATES,
|
HUD_SUMMARY_FEED_STATES,
|
||||||
|
HUD_SUMMARY_MAX_UNSPACED_CHARS,
|
||||||
HUD_SUMMARY_RESPONSE_FORMAT,
|
HUD_SUMMARY_RESPONSE_FORMAT,
|
||||||
hudSummaryInput,
|
hudSummaryInput,
|
||||||
hudSummaryInstructions,
|
hudSummaryInstructions,
|
||||||
@@ -21,7 +22,7 @@ function extractOpenAiResponseText(data) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The structured reply, or nulls when the model did not follow the format. */
|
/** Parse the summary and normalize the canonical feed state. */
|
||||||
function parseHudSummaryReply(text) {
|
function parseHudSummaryReply(text) {
|
||||||
let reply = null;
|
let reply = null;
|
||||||
try {
|
try {
|
||||||
@@ -39,13 +40,22 @@ function parseHudSummaryReply(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toFiveWordHudSummary(value) {
|
function toFiveWordHudSummary(value) {
|
||||||
return String(value || '')
|
const words = String(value || '')
|
||||||
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
|
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
|
||||||
.trim()
|
.trim()
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.join(' ');
|
.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;
|
||||||
|
const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
||||||
|
return [...graphemes.segment(words)]
|
||||||
|
.slice(0, HUD_SUMMARY_MAX_UNSPACED_CHARS)
|
||||||
|
.map(({ segment }) => segment)
|
||||||
|
.join('')
|
||||||
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleHudSummary(req, res) {
|
async function handleHudSummary(req, res) {
|
||||||
|
|||||||
@@ -208,6 +208,8 @@ export function feedProvenanceNote(layers = [], overall = null) {
|
|||||||
return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`;
|
return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`;
|
||||||
});
|
});
|
||||||
const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`;
|
const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`;
|
||||||
|
if (overall === 'partial')
|
||||||
|
return `${body}. Narrate that coverage is incomplete with any count; do not claim full coverage.`;
|
||||||
return `${body}. Narrate that provenance with any count; do not describe this as live.`;
|
return `${body}. Narrate that provenance with any count; do not describe this as live.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,3 +256,35 @@ export function viewStateLayerRow(layer, options) {
|
|||||||
lastUpdate: snap.lastUpdate,
|
lastUpdate: snap.lastUpdate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The feed state and up to two layer names of a provenance envelope that is
|
||||||
|
* not nominal, or null. Values stay English (`state` is a feed-state id,
|
||||||
|
* `names` are `layer.name`); the HUD translates them for display.
|
||||||
|
* @param {{ overall?: string|null, layers?: Array<object> }|null} envelope
|
||||||
|
* @returns {{ state: string, names: string[] }|null}
|
||||||
|
*/
|
||||||
|
export function provenanceTag(envelope) {
|
||||||
|
const overall = envelope?.overall;
|
||||||
|
if (!overall || overall === 'nominal' || overall === 'off') return null;
|
||||||
|
const names = (envelope.layers || [])
|
||||||
|
.filter(
|
||||||
|
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
|
||||||
|
)
|
||||||
|
.map((s) => String(s.name || s.id || 'LAYER'))
|
||||||
|
.slice(0, 2);
|
||||||
|
return { state: overall, names };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require a matching feedState for warning states: a view that is not
|
||||||
|
* nominal falls back on a missing or different state.
|
||||||
|
* @param {{ feedState?: string|null }|null} response The proxy's reply.
|
||||||
|
* @param {{ overall?: string|null }|null} provenance The context's envelope.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function hudSummaryMatchesProvenance(response, provenance) {
|
||||||
|
const state = provenance?.overall;
|
||||||
|
if (!state || state === 'nominal' || state === 'off') return true;
|
||||||
|
return response?.feedState === state;
|
||||||
|
}
|
||||||
@@ -132,6 +132,9 @@ test('partial is a feed-state of its own: above nominal, below every fault', ()
|
|||||||
assert.equal(overall([partial, stale]), 'stale');
|
assert.equal(overall([partial, stale]), 'stale');
|
||||||
assert.equal(overall([stale, partial]), 'stale');
|
assert.equal(overall([stale, partial]), 'stale');
|
||||||
assert.equal(LAYER_FEED_STATE_LABELS.partial, 'PARTIAL');
|
assert.equal(LAYER_FEED_STATE_LABELS.partial, 'PARTIAL');
|
||||||
|
const note = feedProvenanceEnvelope([partial], { now: NOW }).note;
|
||||||
|
assert.match(note, /coverage is incomplete/);
|
||||||
|
assert.doesNotMatch(note, /do not describe this as live/);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
FEED_STATE_SEVERITY.fallback < FEED_STATE_SEVERITY.partial &&
|
FEED_STATE_SEVERITY.fallback < FEED_STATE_SEVERITY.partial &&
|
||||||
FEED_STATE_SEVERITY.partial < FEED_STATE_SEVERITY.nominal,
|
FEED_STATE_SEVERITY.partial < FEED_STATE_SEVERITY.nominal,
|
||||||
|
|||||||
+12
-10
@@ -782,7 +782,8 @@ export class IntelHUD {
|
|||||||
zone: localTag,
|
zone: localTag,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return provenance ? `${line} | ${provenance}` : line;
|
// The feed state leads: the one-line readout clips its end.
|
||||||
|
return provenance ? `${provenance} | ${line}` : line;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop a summary still typing out, leaving what it has typed so far. */
|
/** Stop a summary still typing out, leaving what it has typed so far. */
|
||||||
@@ -894,11 +895,12 @@ export class IntelHUD {
|
|||||||
throw new Error(data?.error || `HTTP ${response.status}`);
|
throw new Error(data?.error || `HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
if (hudSummaryMatchesProvenance(data, context.feedProvenance)) {
|
if (hudSummaryMatchesProvenance(data, context.feedProvenance)) {
|
||||||
// The warning is the HUD's own translated tag, not the model's words.
|
// 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(
|
const tag = localizedProvenanceTag(
|
||||||
provenanceTag(context.feedProvenance),
|
provenanceTag(context.feedProvenance),
|
||||||
);
|
);
|
||||||
this._lastSummaryText = tag ? `${data.summary} | ${tag}` : data.summary;
|
this._lastSummaryText = tag ? `${tag} | ${data.summary}` : data.summary;
|
||||||
}
|
}
|
||||||
this._setSummaryText(this._lastSummaryText || fallbackText, animate);
|
this._setSummaryText(this._lastSummaryText || fallbackText, animate);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -959,18 +961,18 @@ export class IntelHUD {
|
|||||||
// A slower build for a view the camera has left must not replace the
|
// A slower build for a view the camera has left must not replace the
|
||||||
// newer view's lookup.
|
// newer view's lookup.
|
||||||
if (revision === this._summaryRevision) this._placeLabels = labels;
|
if (revision === this._summaryRevision) this._placeLabels = labels;
|
||||||
const enabledLayers =
|
const layerContext = hudSummaryLayerContext(
|
||||||
this._dataManager
|
this._dataManager?.getAll?.() || [],
|
||||||
?.getAll?.()
|
);
|
||||||
?.filter((layer) => layer.enabled)
|
|
||||||
.map((layer) => layer.name) || [];
|
|
||||||
return {
|
return {
|
||||||
locale: getLocale(),
|
locale: getLocale(),
|
||||||
placeLabels: labels.placeLabels,
|
placeLabels: labels.placeLabels,
|
||||||
streetLabels: labels.streetLabels,
|
streetLabels: labels.streetLabels,
|
||||||
nearbyPlaceLabels: labels.nearbyPlaceLabels,
|
nearbyPlaceLabels: labels.nearbyPlaceLabels,
|
||||||
enabledLayerLabels: enabledLayers,
|
...layerContext,
|
||||||
...hudSummaryLayerContext(this._dataManager?.getAll?.() || []),
|
// The names the user sees; feedProvenance keeps the English ids.
|
||||||
|
enabledLayerLabels:
|
||||||
|
layerContext.enabledLayerLabels.map(localizeLayerName),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-36
@@ -2,9 +2,15 @@ import {
|
|||||||
SNAPSHOT_FEED_STATES,
|
SNAPSHOT_FEED_STATES,
|
||||||
feedProvenanceEnvelope,
|
feedProvenanceEnvelope,
|
||||||
layerSnapshots,
|
layerSnapshots,
|
||||||
|
provenanceTag,
|
||||||
} 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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,11 +60,14 @@ export const HUD_SUMMARY_INSTRUCTIONS = [
|
|||||||
'Use only the supplied place, street, nearby-place, enabled-layer labels, and feedProvenance.',
|
'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.',
|
'Prefer the clearest named place and include a relevant enabled layer only when useful.',
|
||||||
'Set feedState to feedProvenance.overall exactly as given, or none when it is null.',
|
'Set feedState to feedProvenance.overall exactly as given, or none when it is null.',
|
||||||
'If feedProvenance.overall is not nominal, the summary must not present the view as live or healthy.',
|
'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.',
|
'Do not infer from coordinates or invent a place or feed state.',
|
||||||
'The summary is exactly five words with no title, punctuation, markdown, or introductory phrase.',
|
'The summary is exactly five words with no title, punctuation, markdown, or introductory phrase.',
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
|
/** Longest summary, in characters, for a script written without spaces. */
|
||||||
|
export const HUD_SUMMARY_MAX_UNSPACED_CHARS = 20;
|
||||||
|
|
||||||
/** feedState values the model may return; none stands for a null overall. */
|
/** feedState values the model may return; none stands for a null overall. */
|
||||||
export const HUD_SUMMARY_FEED_STATES = Object.freeze([
|
export const HUD_SUMMARY_FEED_STATES = Object.freeze([
|
||||||
...SNAPSHOT_FEED_STATES,
|
...SNAPSHOT_FEED_STATES,
|
||||||
@@ -87,7 +96,19 @@ export function hudSummaryLocale(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The language a request's summary is written in, named in English. */
|
/** 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({
|
||||||
|
pt: 'Brazilian Portuguese',
|
||||||
|
'zh-CN': 'Simplified Chinese',
|
||||||
|
'zh-Hant': 'Chinese in Traditional characters (mainland wording)',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Scripts written without spaces, where five words do not apply. */
|
||||||
|
const UNSPACED_LOCALES = /^(?:ja|zh)(?:-|$)/;
|
||||||
|
|
||||||
function summaryLanguage(tag) {
|
function summaryLanguage(tag) {
|
||||||
|
if (SUMMARY_LANGUAGES[tag]) return SUMMARY_LANGUAGES[tag];
|
||||||
try {
|
try {
|
||||||
return new Intl.DisplayNames(['en'], { type: 'language' }).of(tag) || tag;
|
return new Intl.DisplayNames(['en'], { type: 'language' }).of(tag) || tag;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -96,8 +117,7 @@ function summaryLanguage(tag) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The instructions for one request: English as before, otherwise one more
|
* Build the summary instructions for the requested locale.
|
||||||
* sentence naming the language the five words are written in.
|
|
||||||
* @param {unknown} locale The `locale` the HUD sent.
|
* @param {unknown} locale The `locale` the HUD sent.
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
@@ -105,7 +125,10 @@ export function hudSummaryInstructions(locale) {
|
|||||||
const tag = hudSummaryLocale(locale);
|
const tag = hudSummaryLocale(locale);
|
||||||
if (tag === DEFAULT_LOCALE) return HUD_SUMMARY_INSTRUCTIONS;
|
if (tag === DEFAULT_LOCALE) return HUD_SUMMARY_INSTRUCTIONS;
|
||||||
const language = summaryLanguage(tag);
|
const language = summaryLanguage(tag);
|
||||||
return `${HUD_SUMMARY_INSTRUCTIONS} Write the summary in ${language} (${tag}), not English, giving places and layers their usual ${language} names; feedState stays the English value.`;
|
const length = UNSPACED_LOCALES.test(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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,25 +171,6 @@ export function hudSummaryLayerContext(layers = [], options) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The feed state and up to two layer names of a provenance envelope that is
|
|
||||||
* not nominal, or null. Values stay English (`state` is a feed-state id,
|
|
||||||
* `names` are `layer.name`); the HUD translates them for display.
|
|
||||||
* @param {{ overall?: string|null, layers?: Array<object> }|null} envelope
|
|
||||||
* @returns {{ state: string, names: string[] }|null}
|
|
||||||
*/
|
|
||||||
export function provenanceTag(envelope) {
|
|
||||||
const overall = envelope?.overall;
|
|
||||||
if (!overall || overall === 'nominal' || overall === 'off') return null;
|
|
||||||
const names = (envelope.layers || [])
|
|
||||||
.filter(
|
|
||||||
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
|
|
||||||
)
|
|
||||||
.map((s) => String(s.name || s.id || 'LAYER'))
|
|
||||||
.slice(0, 2);
|
|
||||||
return { state: overall, names };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The feed-state tag for the HUD telemetry suffix when an enabled feed is not
|
* The feed-state tag for the HUD telemetry suffix when an enabled feed is not
|
||||||
* nominal.
|
* nominal.
|
||||||
@@ -178,16 +182,3 @@ export function hudTelemetryProvenance(layers = [], options) {
|
|||||||
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
|
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
|
||||||
return provenanceTag(feedProvenanceEnvelope(snapshots, options));
|
return provenanceTag(feedProvenanceEnvelope(snapshots, options));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Show an AI summary only when its canonical feedState is the HUD's own: a
|
|
||||||
* view that is not nominal falls back on a missing or different state.
|
|
||||||
* @param {{ feedState?: string|null }|null} response The proxy's reply.
|
|
||||||
* @param {{ overall?: string|null }|null} provenance The context's envelope.
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
export function hudSummaryMatchesProvenance(response, provenance) {
|
|
||||||
const state = provenance?.overall;
|
|
||||||
if (!state || state === 'nominal' || state === 'off') return true;
|
|
||||||
return response?.feedState === state;
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ import { Readable } from 'node:stream';
|
|||||||
import {
|
import {
|
||||||
HUD_SUMMARY_FEED_STATES,
|
HUD_SUMMARY_FEED_STATES,
|
||||||
HUD_SUMMARY_INSTRUCTIONS,
|
HUD_SUMMARY_INSTRUCTIONS,
|
||||||
|
HUD_SUMMARY_MAX_UNSPACED_CHARS,
|
||||||
HUD_SUMMARY_RESPONSE_FORMAT,
|
HUD_SUMMARY_RESPONSE_FORMAT,
|
||||||
HUD_SUMMARY_UNCONFIGURED_CODE,
|
HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||||
hudSummaryInput,
|
hudSummaryInput,
|
||||||
@@ -163,9 +164,6 @@ test('the installed keyless HUD route stays successful after the voice quota is
|
|||||||
} finally {
|
} finally {
|
||||||
if (previousKey === undefined) delete process.env.OPENAI_API_KEY;
|
if (previousKey === undefined) delete process.env.OPENAI_API_KEY;
|
||||||
else process.env.OPENAI_API_KEY = previousKey;
|
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;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,7 +217,14 @@ test('HUD summary instructions ask for the canonical feedState as its own field'
|
|||||||
HUD_SUMMARY_INSTRUCTIONS,
|
HUD_SUMMARY_INSTRUCTIONS,
|
||||||
/Set feedState to feedProvenance\.overall exactly/,
|
/Set feedState to feedProvenance\.overall exactly/,
|
||||||
);
|
);
|
||||||
assert.match(HUD_SUMMARY_INSTRUCTIONS, /must not present the view as live/);
|
assert.match(
|
||||||
|
HUD_SUMMARY_INSTRUCTIONS,
|
||||||
|
/do not present the view as live or healthy/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
HUD_SUMMARY_INSTRUCTIONS,
|
||||||
|
/partial, do not present the coverage as complete/,
|
||||||
|
);
|
||||||
assert.deepEqual(HUD_SUMMARY_RESPONSE_FORMAT.schema.required, [
|
assert.deepEqual(HUD_SUMMARY_RESPONSE_FORMAT.schema.required, [
|
||||||
'summary',
|
'summary',
|
||||||
'feedState',
|
'feedState',
|
||||||
@@ -251,6 +256,32 @@ test('the HUD sends its UI locale inside the context it signs for dedupe', () =>
|
|||||||
const hud = readFileSync(new URL('./hud.js', import.meta.url), 'utf8');
|
const hud = readFileSync(new URL('./hud.js', import.meta.url), 'utf8');
|
||||||
assert.match(hud, /locale: getLocale\(\),\s+placeLabels:/);
|
assert.match(hud, /locale: getLocale\(\),\s+placeLabels:/);
|
||||||
assert.match(hud, /const signature = JSON\.stringify\(context\);/);
|
assert.match(hud, /const signature = JSON\.stringify\(context\);/);
|
||||||
|
// The model gets the layer names the user sees.
|
||||||
|
assert.match(
|
||||||
|
hud,
|
||||||
|
/enabledLayerLabels:\s+layerContext\.enabledLayerLabels\.map\(localizeLayerName\)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the state tag leads the one-line summary so clipping cannot hide it', () => {
|
||||||
|
const hud = readFileSync(new URL('./hud.js', import.meta.url), 'utf8');
|
||||||
|
assert.match(hud, /tag \? `\$\{tag\} \| \$\{data\.summary\}`/);
|
||||||
|
assert.match(hud, /provenance \? `\$\{provenance\} \| \$\{line\}`/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shipped catalogs get their exact language, and CJK a character limit', () => {
|
||||||
|
assert.match(hudSummaryInstructions('pt'), /in Brazilian Portuguese \(pt\)/);
|
||||||
|
assert.match(
|
||||||
|
hudSummaryInstructions('zh-Hant'),
|
||||||
|
/in Chinese in Traditional characters \(mainland wording\) \(zh-Hant\)/,
|
||||||
|
);
|
||||||
|
assert.match(hudSummaryInstructions('zh-CN'), /in Simplified Chinese/);
|
||||||
|
assert.match(
|
||||||
|
hudSummaryInstructions('ja'),
|
||||||
|
new RegExp(`at most ${HUD_SUMMARY_MAX_UNSPACED_CHARS} characters`),
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(hudSummaryInstructions('es'), /at most/);
|
||||||
|
assert.match(hudSummaryInstructions('es'), /names in enabledLayerLabels/);
|
||||||
});
|
});
|
||||||
|
|
||||||
let hudSummaryClient = 0;
|
let hudSummaryClient = 0;
|
||||||
@@ -259,10 +290,8 @@ async function postHudSummary(context, reply) {
|
|||||||
// A fresh address per request: the per-IP limiter outlives a single test.
|
// A fresh address per request: the per-IP limiter outlives a single test.
|
||||||
hudSummaryClient += 1;
|
hudSummaryClient += 1;
|
||||||
const previousKey = process.env.OPENAI_API_KEY;
|
const previousKey = process.env.OPENAI_API_KEY;
|
||||||
const previousLimit = process.env.GEV_RATELIMIT_OPENAI_PER_MIN;
|
|
||||||
const previousFetch = globalThis.fetch;
|
const previousFetch = globalThis.fetch;
|
||||||
process.env.OPENAI_API_KEY = 'test-key';
|
process.env.OPENAI_API_KEY = 'test-key';
|
||||||
process.env.GEV_RATELIMIT_OPENAI_PER_MIN = '0';
|
|
||||||
let sent = null;
|
let sent = null;
|
||||||
globalThis.fetch = async (url, init) => {
|
globalThis.fetch = async (url, init) => {
|
||||||
sent = JSON.parse(init.body);
|
sent = JSON.parse(init.body);
|
||||||
@@ -301,9 +330,6 @@ async function postHudSummary(context, reply) {
|
|||||||
globalThis.fetch = previousFetch;
|
globalThis.fetch = previousFetch;
|
||||||
if (previousKey === undefined) delete process.env.OPENAI_API_KEY;
|
if (previousKey === undefined) delete process.env.OPENAI_API_KEY;
|
||||||
else process.env.OPENAI_API_KEY = previousKey;
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,6 +358,18 @@ test('an es request reaches the model in Spanish and returns the canonical state
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a Japanese summary is capped in characters, not words', async () => {
|
||||||
|
const long = 'テキサス州議会議事堂周辺の航空機データは古くなっています';
|
||||||
|
const { response } = await postHudSummary(
|
||||||
|
{ locale: 'ja', feedProvenance: { overall: 'stale' } },
|
||||||
|
JSON.stringify({ summary: long, feedState: 'stale' }),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
response.body.summary,
|
||||||
|
[...long].slice(0, HUD_SUMMARY_MAX_UNSPACED_CHARS).join(''),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
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' } },
|
||||||
@@ -346,17 +384,6 @@ test('a reply that skips the format carries no summary and no state', async () =
|
|||||||
assert.equal(unknown.response.body.feedState, null);
|
assert.equal(unknown.response.body.feedState, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the HUD proxy builds its instructions from the shared contract', () => {
|
|
||||||
const local = readFileSync(
|
|
||||||
new URL('../server/providers/openai/hud-summary.js', import.meta.url),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
assert.match(local, /hudSummaryInstructions\(context\?\.locale\)/);
|
|
||||||
assert.match(local, /hudSummaryInput\(context\)/);
|
|
||||||
assert.match(local, /HUD_SUMMARY_RESPONSE_FORMAT/);
|
|
||||||
assert.doesNotMatch(local, /enabled-layer text labels/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a localized summary is shown only with the HUD's own canonical state", () => {
|
test("a localized summary is shown only with the HUD's own canonical state", () => {
|
||||||
const stale = { overall: 'stale' };
|
const stale = { overall: 'stale' };
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
/** Composite queries that combine other tools' answers for one area. */
|
/** Composite queries that combine other tools' answers for one area. */
|
||||||
|
|
||||||
import { suggestView } from '../views.js';
|
import { suggestView } from '../views.js';
|
||||||
import { feedProvenanceEnvelope } from '../../data/layerSnapshot.js';
|
import {
|
||||||
|
feedProvenanceEnvelope,
|
||||||
|
hudSummaryMatchesProvenance,
|
||||||
|
provenanceTag,
|
||||||
|
} from '../../data/layerSnapshot.js';
|
||||||
import { defineTool, ToolError } from '../catalog.js';
|
import { defineTool, ToolError } from '../catalog.js';
|
||||||
import {
|
import {
|
||||||
AREA_SCHEMA,
|
AREA_SCHEMA,
|
||||||
@@ -232,6 +236,7 @@ export const getHudCaption = defineTool({
|
|||||||
feedState: sectionFeedState(brief[key]),
|
feedState: sectionFeedState(brief[key]),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const feedProvenance = feedProvenanceEnvelope(layers);
|
||||||
const response = await context.services.summary.summarize(
|
const response = await context.services.summary.summarize(
|
||||||
{
|
{
|
||||||
placeLabels: [area.label],
|
placeLabels: [area.label],
|
||||||
@@ -244,16 +249,29 @@ export const getHudCaption = defineTool({
|
|||||||
name,
|
name,
|
||||||
feedState,
|
feedState,
|
||||||
})),
|
})),
|
||||||
feedProvenance: feedProvenanceEnvelope(layers),
|
feedProvenance,
|
||||||
},
|
},
|
||||||
{ signal: context.signal },
|
{ signal: context.signal },
|
||||||
);
|
);
|
||||||
const caption = response?.data?.summary;
|
const words = response?.data?.summary;
|
||||||
if (!response?.ok || typeof caption !== 'string' || !caption)
|
if (!response?.ok || typeof words !== 'string' || !words)
|
||||||
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 warning is the app's own state tag, 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()}`
|
||||||
|
: text;
|
||||||
return {
|
return {
|
||||||
summary: caption,
|
summary: caption,
|
||||||
data: { area: area.label, caption },
|
data: {
|
||||||
|
area: area.label,
|
||||||
|
caption,
|
||||||
|
feedState: feedProvenance.overall,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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.
|
||||||
|
async function staleCaption(reply) {
|
||||||
|
const conditions = {
|
||||||
|
observedAt: '2026-10-01T19:15:00.000Z',
|
||||||
|
temperatureC: 26.3,
|
||||||
|
apparentTemperatureC: 32,
|
||||||
|
precipitationMm: 0,
|
||||||
|
cloudCoverPct: 100,
|
||||||
|
windKph: 5.4,
|
||||||
|
windDirectionDeg: 318,
|
||||||
|
visibilityM: 15400,
|
||||||
|
weatherCode: 3,
|
||||||
|
};
|
||||||
|
const places = {
|
||||||
|
resolve: async (name) =>
|
||||||
|
name === 'Austin'
|
||||||
|
? {
|
||||||
|
name: 'Austin, Texas',
|
||||||
|
bounds: { west: -97.94, south: 30.1, east: -97.56, north: 30.52 },
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
const staleWeather = {
|
||||||
|
getConditions: async () => ({ status: 'stale', weather: conditions }),
|
||||||
|
};
|
||||||
|
let sent = null;
|
||||||
|
const summary = {
|
||||||
|
async summarize(context) {
|
||||||
|
sent = context;
|
||||||
|
return { ok: true, status: 200, data: { error: null, ...reply } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const catalog = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: { weather: staleWeather, summary, places },
|
||||||
|
});
|
||||||
|
const result = await catalog.call('get_hud_caption', {
|
||||||
|
area: { place: 'Austin' },
|
||||||
|
});
|
||||||
|
assert.equal(sent.feedProvenance.overall, 'stale');
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('get_hud_caption keeps the canonical feed state of a stale view', async () => {
|
||||||
|
const data = await staleCaption({
|
||||||
|
summary: 'Austin weather across the area',
|
||||||
|
feedState: 'stale',
|
||||||
|
});
|
||||||
|
assert.equal(data.feedState, 'stale');
|
||||||
|
assert.equal(data.caption, 'Austin weather across the area | STALE WEATHER');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get_hud_caption drops words whose feed state is not the app one', async () => {
|
||||||
|
const data = await staleCaption({
|
||||||
|
summary: 'Austin live weather looks fine',
|
||||||
|
feedState: 'nominal',
|
||||||
|
});
|
||||||
|
assert.equal(data.feedState, 'stale');
|
||||||
|
assert.equal(data.caption, 'Austin, Texas | STALE WEATHER');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user