mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
fix(hud): AI summary in the UI language, canonical feed state, partial aggregation
The HUD sends its locale with the summary context; the proxy names that
language in the instructions and the input, English when missing or
unsupported. The model returns {summary, feedState}; the HUD shows the
summary only when feedState matches its own state for a view that is not
nominal, and appends its own translated state tag.
partial joins the shared feed-state table between fallback and nominal,
so one partial layer no longer reads off and partial plus nominal no
longer reads nominal.
This commit is contained in:
1 parent
423ca3ca5f
commit
563df3bad9
7 files changed
+386
-56
No files matched your search
@@ -336,6 +336,7 @@
|
|||||||
"exports": ["./server/providers/openai"],
|
"exports": ["./server/providers/openai"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
|
"src/i18n/locale.js",
|
||||||
"src/hudLayoutPolicy.js",
|
"src/hudLayoutPolicy.js",
|
||||||
"server/providers/common/rate-limit.js",
|
"server/providers/common/rate-limit.js",
|
||||||
"server/providers/common/request.js",
|
"server/providers/common/request.js",
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
HUD_SUMMARY_INSTRUCTIONS,
|
HUD_SUMMARY_FEED_STATES,
|
||||||
|
HUD_SUMMARY_RESPONSE_FORMAT,
|
||||||
|
hudSummaryInput,
|
||||||
|
hudSummaryInstructions,
|
||||||
keylessHudSummaryResponse,
|
keylessHudSummaryResponse,
|
||||||
} from '../../../src/hudSummaryResponse.js';
|
} from '../../../src/hudSummaryResponse.js';
|
||||||
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
|
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||||
@@ -18,6 +21,23 @@ function extractOpenAiResponseText(data) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The structured reply, or nulls when the model did not follow the format. */
|
||||||
|
function parseHudSummaryReply(text) {
|
||||||
|
let reply = null;
|
||||||
|
try {
|
||||||
|
reply = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
/* not JSON: no summary */
|
||||||
|
}
|
||||||
|
const feedState = HUD_SUMMARY_FEED_STATES.includes(reply?.feedState)
|
||||||
|
? reply.feedState
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
summary: toFiveWordHudSummary(reply?.summary),
|
||||||
|
feedState: feedState === 'none' ? null : feedState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function toFiveWordHudSummary(value) {
|
function toFiveWordHudSummary(value) {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
|
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
|
||||||
@@ -77,14 +97,17 @@ async function handleHudSummary(req, res) {
|
|||||||
model:
|
model:
|
||||||
process.env.OPENAI_HUD_SUMMARY_MODEL ||
|
process.env.OPENAI_HUD_SUMMARY_MODEL ||
|
||||||
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
|
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
|
||||||
instructions: HUD_SUMMARY_INSTRUCTIONS,
|
instructions: hudSummaryInstructions(context?.locale),
|
||||||
input: JSON.stringify(context),
|
input: hudSummaryInput(context),
|
||||||
|
text: { format: HUD_SUMMARY_RESPONSE_FORMAT },
|
||||||
reasoning: { effort: 'minimal' },
|
reasoning: { effort: 'minimal' },
|
||||||
max_output_tokens: 100,
|
max_output_tokens: 200,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
const summary = toFiveWordHudSummary(extractOpenAiResponseText(data));
|
const { summary, feedState } = parseHudSummaryReply(
|
||||||
|
extractOpenAiResponseText(data),
|
||||||
|
);
|
||||||
res.statusCode = response.ok && summary ? 200 : response.status || 502;
|
res.statusCode = response.ok && summary ? 200 : response.status || 502;
|
||||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
@@ -93,6 +116,7 @@ async function handleHudSummary(req, res) {
|
|||||||
res.end(
|
res.end(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
summary: summary || null,
|
summary: summary || null,
|
||||||
|
feedState,
|
||||||
// Never relay `data.error.message`: that is OpenAI's own wording, and
|
// Never relay `data.error.message`: that is OpenAI's own wording, and
|
||||||
// it carries request ids, organization hints and quota phrasing.
|
// it carries request ids, organization hints and quota phrasing.
|
||||||
error: response.ok ? null : 'OpenAI HUD summary request failed',
|
error: response.ok ? null : 'OpenAI HUD summary request failed',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const LAYER_FEED_STATES = Object.freeze([
|
|||||||
'loading',
|
'loading',
|
||||||
'degraded',
|
'degraded',
|
||||||
'stale',
|
'stale',
|
||||||
|
'partial',
|
||||||
'fallback',
|
'fallback',
|
||||||
'unavailable',
|
'unavailable',
|
||||||
]);
|
]);
|
||||||
@@ -27,6 +28,7 @@ export const LAYER_FEED_STATE_LABELS = Object.freeze({
|
|||||||
loading: 'LOADING',
|
loading: 'LOADING',
|
||||||
degraded: 'DEGRADED',
|
degraded: 'DEGRADED',
|
||||||
stale: 'STALE',
|
stale: 'STALE',
|
||||||
|
partial: 'PARTIAL',
|
||||||
fallback: 'FALLBACK',
|
fallback: 'FALLBACK',
|
||||||
unavailable: 'UNAVAILABLE',
|
unavailable: 'UNAVAILABLE',
|
||||||
});
|
});
|
||||||
@@ -48,8 +50,9 @@ export const FEED_STATE_SEVERITY = Object.freeze({
|
|||||||
degraded: 2,
|
degraded: 2,
|
||||||
stale: 3,
|
stale: 3,
|
||||||
fallback: 4,
|
fallback: 4,
|
||||||
nominal: 5,
|
partial: 5,
|
||||||
off: 6,
|
nominal: 6,
|
||||||
|
off: 7,
|
||||||
});
|
});
|
||||||
|
|
||||||
export { layerFeedState } from './feedState.js';
|
export { layerFeedState } from './feedState.js';
|
||||||
@@ -90,7 +93,7 @@ export function snapshotAgeLabel(lastUpdate, now = Date.now()) {
|
|||||||
/**
|
/**
|
||||||
* Pick the most severe feed-state from a list.
|
* Pick the most severe feed-state from a list.
|
||||||
* @param {Iterable<string|null|undefined>} states Feed-state strings.
|
* @param {Iterable<string|null|undefined>} states Feed-state strings.
|
||||||
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'|'off'|null}
|
* @returns {'nominal'|'loading'|'degraded'|'stale'|'partial'|'fallback'|'unavailable'|'off'|null}
|
||||||
*/
|
*/
|
||||||
export function worstFeedState(states) {
|
export function worstFeedState(states) {
|
||||||
let worst = null;
|
let worst = null;
|
||||||
|
|||||||
@@ -90,7 +90,15 @@ test('layerFeedState matches the chip classifier for every honesty class', () =>
|
|||||||
test('chip labels and severity cover every feed-state the envelope can emit', () => {
|
test('chip labels and severity cover every feed-state the envelope can emit', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
[...LAYER_FEED_STATES],
|
[...LAYER_FEED_STATES],
|
||||||
['nominal', 'loading', 'degraded', 'stale', 'fallback', 'unavailable'],
|
[
|
||||||
|
'nominal',
|
||||||
|
'loading',
|
||||||
|
'degraded',
|
||||||
|
'stale',
|
||||||
|
'partial',
|
||||||
|
'fallback',
|
||||||
|
'unavailable',
|
||||||
|
],
|
||||||
);
|
);
|
||||||
assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']);
|
assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']);
|
||||||
for (const state of LAYER_FEED_STATES) {
|
for (const state of LAYER_FEED_STATES) {
|
||||||
@@ -107,6 +115,30 @@ test('chip labels and severity cover every feed-state the envelope can emit', ()
|
|||||||
assert.equal(worstFeedState([]), null);
|
assert.equal(worstFeedState([]), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('partial is a feed-state of its own: above nominal, below every fault', () => {
|
||||||
|
const layer = (id, stats) => ({ id, name: id, enabled: true, stats });
|
||||||
|
const partial = layer('flights', {
|
||||||
|
partial: true,
|
||||||
|
count: 4,
|
||||||
|
lastUpdate: NOW,
|
||||||
|
});
|
||||||
|
const nominal = layer('vessels', { count: 9, lastUpdate: NOW });
|
||||||
|
const stale = layer('cctv', { stale: true, count: 2, lastUpdate: NOW });
|
||||||
|
const overall = (layers) =>
|
||||||
|
feedProvenanceEnvelope(layers, { now: NOW }).overall;
|
||||||
|
assert.equal(overall([partial]), 'partial');
|
||||||
|
assert.equal(overall([partial, nominal]), 'partial');
|
||||||
|
assert.equal(overall([nominal, partial]), 'partial');
|
||||||
|
assert.equal(overall([partial, stale]), 'stale');
|
||||||
|
assert.equal(overall([stale, partial]), 'stale');
|
||||||
|
assert.equal(LAYER_FEED_STATE_LABELS.partial, 'PARTIAL');
|
||||||
|
assert.equal(
|
||||||
|
FEED_STATE_SEVERITY.fallback < FEED_STATE_SEVERITY.partial &&
|
||||||
|
FEED_STATE_SEVERITY.partial < FEED_STATE_SEVERITY.nominal,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('a disabled layer is off even when leftover stats would read stale', () => {
|
test('a disabled layer is off even when leftover stats would read stale', () => {
|
||||||
const snap = layerSnapshot(
|
const snap = layerSnapshot(
|
||||||
{
|
{
|
||||||
|
|||||||
+10
-3
@@ -31,13 +31,14 @@ import {
|
|||||||
hudSummaryLayerContext,
|
hudSummaryLayerContext,
|
||||||
hudTelemetryProvenance,
|
hudTelemetryProvenance,
|
||||||
isHudSummaryUnconfigured,
|
isHudSummaryUnconfigured,
|
||||||
|
provenanceTag,
|
||||||
} from './hudSummaryResponse.js';
|
} from './hudSummaryResponse.js';
|
||||||
import {
|
import {
|
||||||
applyHudUiTheme,
|
applyHudUiTheme,
|
||||||
DEFAULT_HUD_LAYOUT,
|
DEFAULT_HUD_LAYOUT,
|
||||||
normalizeHudLayout,
|
normalizeHudLayout,
|
||||||
} from './hudLayouts.js';
|
} from './hudLayouts.js';
|
||||||
import { formatNumber, msg, t } from './i18n/index.js';
|
import { formatNumber, getLocale, msg, t } from './i18n/index.js';
|
||||||
import { localizeLayerName } from './ui/layerPanel.js';
|
import { localizeLayerName } from './ui/layerPanel.js';
|
||||||
import { isEmbeddedInline } from './app/embed.js';
|
import { isEmbeddedInline } from './app/embed.js';
|
||||||
|
|
||||||
@@ -892,8 +893,13 @@ export class IntelHUD {
|
|||||||
if (!response.ok || !data?.summary) {
|
if (!response.ok || !data?.summary) {
|
||||||
throw new Error(data?.error || `HTTP ${response.status}`);
|
throw new Error(data?.error || `HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
if (hudSummaryMatchesProvenance(data.summary, context.feedProvenance))
|
if (hudSummaryMatchesProvenance(data, context.feedProvenance)) {
|
||||||
this._lastSummaryText = data.summary;
|
// The warning is the HUD's own translated tag, not the model's words.
|
||||||
|
const tag = localizedProvenanceTag(
|
||||||
|
provenanceTag(context.feedProvenance),
|
||||||
|
);
|
||||||
|
this._lastSummaryText = tag ? `${data.summary} | ${tag}` : data.summary;
|
||||||
|
}
|
||||||
this._setSummaryText(this._lastSummaryText || fallbackText, animate);
|
this._setSummaryText(this._lastSummaryText || fallbackText, animate);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// The 5 s timeout is a failure like any other; a move's abort (or
|
// The 5 s timeout is a failure like any other; a move's abort (or
|
||||||
@@ -959,6 +965,7 @@ export class IntelHUD {
|
|||||||
?.filter((layer) => layer.enabled)
|
?.filter((layer) => layer.enabled)
|
||||||
.map((layer) => layer.name) || [];
|
.map((layer) => layer.name) || [];
|
||||||
return {
|
return {
|
||||||
|
locale: getLocale(),
|
||||||
placeLabels: labels.placeLabels,
|
placeLabels: labels.placeLabels,
|
||||||
streetLabels: labels.streetLabels,
|
streetLabels: labels.streetLabels,
|
||||||
nearbyPlaceLabels: labels.nearbyPlaceLabels,
|
nearbyPlaceLabels: labels.nearbyPlaceLabels,
|
||||||
|
|||||||
+102
-23
@@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
|
SNAPSHOT_FEED_STATES,
|
||||||
feedProvenanceEnvelope,
|
feedProvenanceEnvelope,
|
||||||
layerSnapshots,
|
layerSnapshots,
|
||||||
} from './data/layerSnapshot.js';
|
} from './data/layerSnapshot.js';
|
||||||
|
import { DEFAULT_LOCALE, normalizeLocale } from './i18n/locale.js';
|
||||||
|
|
||||||
export const HUD_SUMMARY_UNCONFIGURED_CODE = 'OPENAI_NOT_CONFIGURED';
|
export const HUD_SUMMARY_UNCONFIGURED_CODE = 'OPENAI_NOT_CONFIGURED';
|
||||||
|
|
||||||
@@ -43,19 +45,83 @@ export function isHudSummaryUnconfigured(status, data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HUD summary model instructions. The five-word line may only describe
|
* HUD summary model instructions. The five words may only describe supplied
|
||||||
* supplied labels, and must carry feed-state when any enabled layer is not
|
* labels; the feed state comes back as its own canonical field, so the check
|
||||||
* nominal.
|
* does not depend on the summary's language or wording.
|
||||||
*/
|
*/
|
||||||
export const HUD_SUMMARY_INSTRUCTIONS = [
|
export const HUD_SUMMARY_INSTRUCTIONS = [
|
||||||
"Write one concise intelligence-HUD summary for God's Eye View.",
|
"Write one concise intelligence-HUD summary for God's Eye View.",
|
||||||
'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.',
|
||||||
'If any enabled layer is not nominal, the five words MUST include that feedState token (STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE) and must not present the view as live.',
|
'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.',
|
||||||
'Do not infer from coordinates or invent a place or feed state.',
|
'Do not infer from coordinates or invent a place or feed state.',
|
||||||
'Output 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(' ');
|
||||||
|
|
||||||
|
/** feedState values the model may return; none stands for a null overall. */
|
||||||
|
export const HUD_SUMMARY_FEED_STATES = Object.freeze([
|
||||||
|
...SNAPSHOT_FEED_STATES,
|
||||||
|
'none',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Responses API structured output: the five words plus the canonical state. */
|
||||||
|
export const HUD_SUMMARY_RESPONSE_FORMAT = Object.freeze({
|
||||||
|
type: 'json_schema',
|
||||||
|
name: 'hud_summary',
|
||||||
|
strict: true,
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
summary: { type: 'string' },
|
||||||
|
feedState: { type: 'string', enum: [...HUD_SUMMARY_FEED_STATES] },
|
||||||
|
},
|
||||||
|
required: ['summary', 'feedState'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A shipped locale for the request, English when missing or unsupported. */
|
||||||
|
export function hudSummaryLocale(value) {
|
||||||
|
return normalizeLocale(value) || DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The language a request's summary is written in, named in English. */
|
||||||
|
function summaryLanguage(tag) {
|
||||||
|
try {
|
||||||
|
return new Intl.DisplayNames(['en'], { type: 'language' }).of(tag) || tag;
|
||||||
|
} catch {
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instructions for one request: English as before, otherwise one more
|
||||||
|
* sentence naming the language the five words are written in.
|
||||||
|
* @param {unknown} locale The `locale` the HUD sent.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function hudSummaryInstructions(locale) {
|
||||||
|
const tag = hudSummaryLocale(locale);
|
||||||
|
if (tag === DEFAULT_LOCALE) return HUD_SUMMARY_INSTRUCTIONS;
|
||||||
|
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.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model input: the HUD context, led by the summary language when it is
|
||||||
|
* not English. A small model follows the language far more reliably when it
|
||||||
|
* is part of the input as well as the instructions.
|
||||||
|
* @param {object} context The HUD summary context.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function hudSummaryInput(context) {
|
||||||
|
const tag = hudSummaryLocale(context?.locale);
|
||||||
|
const json = JSON.stringify(context ?? {});
|
||||||
|
if (tag === DEFAULT_LOCALE) return json;
|
||||||
|
return `Summary language: ${summaryLanguage(tag)} (${tag}).\n${json}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stamp HUD summary context with the same layer snapshots voice tools use.
|
* Stamp HUD summary context with the same layer snapshots voice tools use.
|
||||||
* @param {Array<object>} [layers] `DataLayerManager.getAll()` rows.
|
* @param {Array<object>} [layers] `DataLayerManager.getAll()` rows.
|
||||||
@@ -83,32 +149,45 @@ export function hudSummaryLayerContext(layers = [], options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The feed state and up to two layer names for the HUD telemetry suffix when
|
* The feed state and up to two layer names of a provenance envelope that is
|
||||||
* an enabled feed is not nominal. Values stay English (`state` is a feed-state
|
* not nominal, or null. Values stay English (`state` is a feed-state id,
|
||||||
* id, `names` are `layer.name`); the HUD translates them for display.
|
* `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
|
||||||
|
* nominal.
|
||||||
* @param {Array<object>} [layers]
|
* @param {Array<object>} [layers]
|
||||||
* @param {{ now?: number }} [options]
|
* @param {{ now?: number }} [options]
|
||||||
* @returns {{ state: string, names: string[] }|null}
|
* @returns {{ state: string, names: string[] }|null}
|
||||||
*/
|
*/
|
||||||
export function hudTelemetryProvenance(layers = [], options) {
|
export function hudTelemetryProvenance(layers = [], options) {
|
||||||
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
|
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
|
||||||
const envelope = feedProvenanceEnvelope(snapshots, options);
|
return provenanceTag(feedProvenanceEnvelope(snapshots, options));
|
||||||
if (!envelope.overall || envelope.overall === 'nominal') 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: envelope.overall, names };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Require the supplied non-nominal state before showing an AI summary. */
|
/**
|
||||||
export function hudSummaryMatchesProvenance(summary, provenance) {
|
* 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;
|
const state = provenance?.overall;
|
||||||
if (!state || state === 'nominal' || state === 'off') return true;
|
if (!state || state === 'nominal' || state === 'off') return true;
|
||||||
return String(summary || '')
|
return response?.feedState === state;
|
||||||
.toUpperCase()
|
|
||||||
.split(/\W+/)
|
|
||||||
.includes(String(state).toUpperCase());
|
|
||||||
}
|
}
|
||||||
+205
-21
@@ -1,13 +1,21 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
import {
|
import {
|
||||||
|
HUD_SUMMARY_FEED_STATES,
|
||||||
HUD_SUMMARY_INSTRUCTIONS,
|
HUD_SUMMARY_INSTRUCTIONS,
|
||||||
|
HUD_SUMMARY_RESPONSE_FORMAT,
|
||||||
HUD_SUMMARY_UNCONFIGURED_CODE,
|
HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||||
|
hudSummaryInput,
|
||||||
|
hudSummaryInstructions,
|
||||||
hudSummaryLayerContext,
|
hudSummaryLayerContext,
|
||||||
|
hudSummaryLocale,
|
||||||
|
hudSummaryMatchesProvenance,
|
||||||
hudTelemetryProvenance,
|
hudTelemetryProvenance,
|
||||||
isHudSummaryUnconfigured,
|
isHudSummaryUnconfigured,
|
||||||
keylessHudSummaryResponse,
|
keylessHudSummaryResponse,
|
||||||
|
provenanceTag,
|
||||||
} from './hudSummaryResponse.js';
|
} from './hudSummaryResponse.js';
|
||||||
import { openAiRealtimeProxy } from '../vite.config.js';
|
import { openAiRealtimeProxy } from '../vite.config.js';
|
||||||
|
|
||||||
@@ -206,46 +214,222 @@ test('HUD summary context and telemetry tag carry chip-identical feed-state', ()
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('HUD summary instructions require non-nominal feedState in the five words', () => {
|
test('HUD summary instructions ask for the canonical feedState as its own field', () => {
|
||||||
assert.match(
|
assert.match(
|
||||||
HUD_SUMMARY_INSTRUCTIONS,
|
HUD_SUMMARY_INSTRUCTIONS,
|
||||||
/five words MUST include that feedState token/,
|
/Set feedState to feedProvenance\.overall exactly/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(HUD_SUMMARY_INSTRUCTIONS, /must not present the view as live/);
|
||||||
HUD_SUMMARY_INSTRUCTIONS,
|
assert.deepEqual(HUD_SUMMARY_RESPONSE_FORMAT.schema.required, [
|
||||||
/STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/,
|
'summary',
|
||||||
|
'feedState',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
HUD_SUMMARY_RESPONSE_FORMAT.schema.properties.feedState.enum,
|
||||||
|
[...HUD_SUMMARY_FEED_STATES],
|
||||||
);
|
);
|
||||||
assert.match(HUD_SUMMARY_INSTRUCTIONS, /feedProvenance/);
|
assert.ok(HUD_SUMMARY_FEED_STATES.includes('partial'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the HUD proxy uses the shared provenance instructions', () => {
|
test('the instructions name the UI language, with English as the fallback', () => {
|
||||||
|
assert.match(hudSummaryInstructions('es'), /in Spanish \(es\)/);
|
||||||
|
assert.match(hudSummaryInstructions('ru-RU'), /in Russian \(ru\)/);
|
||||||
|
assert.equal(hudSummaryInstructions('en'), HUD_SUMMARY_INSTRUCTIONS);
|
||||||
|
assert.equal(hudSummaryInstructions('xx-YY'), HUD_SUMMARY_INSTRUCTIONS);
|
||||||
|
assert.equal(hudSummaryInstructions(undefined), HUD_SUMMARY_INSTRUCTIONS);
|
||||||
|
assert.equal(hudSummaryLocale('es-MX'), 'es');
|
||||||
|
const english = { locale: 'en', placeLabels: ['Austin'] };
|
||||||
|
assert.equal(hudSummaryInput(english), JSON.stringify(english));
|
||||||
|
assert.match(
|
||||||
|
hudSummaryInput({ locale: 'ru' }),
|
||||||
|
/^Summary language: Russian \(ru\)\.\n\{/,
|
||||||
|
);
|
||||||
|
assert.equal(hudSummaryLocale(42), 'en');
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
assert.match(hud, /locale: getLocale\(\),\s+placeLabels:/);
|
||||||
|
assert.match(hud, /const signature = JSON\.stringify\(context\);/);
|
||||||
|
});
|
||||||
|
|
||||||
|
let hudSummaryClient = 0;
|
||||||
|
|
||||||
|
async function postHudSummary(context, reply) {
|
||||||
|
// A fresh address per request: the per-IP limiter outlives a single test.
|
||||||
|
hudSummaryClient += 1;
|
||||||
|
const previousKey = process.env.OPENAI_API_KEY;
|
||||||
|
const previousLimit = process.env.GEV_RATELIMIT_OPENAI_PER_MIN;
|
||||||
|
const previousFetch = globalThis.fetch;
|
||||||
|
process.env.OPENAI_API_KEY = 'test-key';
|
||||||
|
process.env.GEV_RATELIMIT_OPENAI_PER_MIN = '0';
|
||||||
|
let sent = null;
|
||||||
|
globalThis.fetch = async (url, init) => {
|
||||||
|
sent = JSON.parse(init.body);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ output_text: reply }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
// The handler itself: the installed route adds a same-site gate that a
|
||||||
|
// bare test request does not pass.
|
||||||
|
const { handleHudSummary: hud } =
|
||||||
|
await import('../server/providers/openai/hud-summary.js');
|
||||||
|
const req = Object.assign(
|
||||||
|
Readable.from([Buffer.from(JSON.stringify(context))]),
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
url: '/',
|
||||||
|
headers: {},
|
||||||
|
socket: { remoteAddress: `10.0.0.${hudSummaryClient}` },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const response = await new Promise((resolve, reject) => {
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
setHeader() {},
|
||||||
|
end(body = '') {
|
||||||
|
resolve({ statusCode: this.statusCode, body: JSON.parse(body) });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Promise.resolve(hud(req, res)).catch(reject);
|
||||||
|
});
|
||||||
|
return { sent, response };
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = previousFetch;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('an es request reaches the model in Spanish and returns the canonical state', async () => {
|
||||||
|
const { sent, response } = await postHudSummary(
|
||||||
|
{
|
||||||
|
locale: 'es',
|
||||||
|
placeLabels: ['Austin'],
|
||||||
|
feedProvenance: { overall: 'stale' },
|
||||||
|
},
|
||||||
|
JSON.stringify({
|
||||||
|
summary: 'Austin vuelos con datos antiguos',
|
||||||
|
feedState: 'stale',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.match(sent.instructions, /in Spanish \(es\)/);
|
||||||
|
assert.equal(sent.text.format.name, 'hud_summary');
|
||||||
|
const [lead, json] = sent.input.split('\n');
|
||||||
|
assert.equal(lead, 'Summary language: Spanish (es).');
|
||||||
|
assert.equal(JSON.parse(json).locale, 'es');
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.deepEqual(response.body, {
|
||||||
|
summary: 'Austin vuelos con datos antiguos',
|
||||||
|
feedState: 'stale',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a reply that skips the format carries no summary and no state', async () => {
|
||||||
|
const { response } = await postHudSummary(
|
||||||
|
{ locale: 'ru', feedProvenance: { overall: 'stale' } },
|
||||||
|
'Остин рейсы данные устарели сейчас',
|
||||||
|
);
|
||||||
|
assert.equal(response.body.summary, null);
|
||||||
|
assert.equal(response.body.feedState, null);
|
||||||
|
const unknown = await postHudSummary(
|
||||||
|
{ locale: 'ru', feedProvenance: { overall: 'stale' } },
|
||||||
|
JSON.stringify({ summary: 'Остин рейсы', feedState: 'fine' }),
|
||||||
|
);
|
||||||
|
assert.equal(unknown.response.body.feedState, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the HUD proxy builds its instructions from the shared contract', () => {
|
||||||
const local = readFileSync(
|
const local = readFileSync(
|
||||||
new URL('../server/providers/openai/hud-summary.js', import.meta.url),
|
new URL('../server/providers/openai/hud-summary.js', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/);
|
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/);
|
assert.doesNotMatch(local, /enabled-layer text labels/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => {
|
test("a localized summary is shown only with the HUD's own canonical state", () => {
|
||||||
const { hudSummaryMatchesProvenance } =
|
const stale = { overall: 'stale' };
|
||||||
await import('./hudSummaryResponse.js');
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
hudSummaryMatchesProvenance('Austin flights operating normally today', {
|
hudSummaryMatchesProvenance(
|
||||||
overall: 'stale',
|
{ summary: 'Остин рейсы данные устарели сейчас', feedState: 'stale' },
|
||||||
}),
|
stale,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
hudSummaryMatchesProvenance(
|
||||||
|
{ summary: 'Austin vuelos en vivo ahora', feedState: 'nominal' },
|
||||||
|
stale,
|
||||||
|
),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
hudSummaryMatchesProvenance('Austin stale flights over downtown', {
|
hudSummaryMatchesProvenance(
|
||||||
overall: 'stale',
|
{ summary: 'Austin stale flights over downtown', feedState: null },
|
||||||
}),
|
stale,
|
||||||
true,
|
),
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
hudSummaryMatchesProvenance('Austin flights operating normally today', {
|
hudSummaryMatchesProvenance(
|
||||||
overall: 'nominal',
|
{ summary: 'Austin flights' },
|
||||||
}),
|
{ overall: 'partial' },
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
hudSummaryMatchesProvenance(
|
||||||
|
{ summary: 'Austin flights operating normally today' },
|
||||||
|
{ overall: 'nominal' },
|
||||||
|
),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
assert.equal(hudSummaryMatchesProvenance({ summary: 'x' }, null), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a partial view keeps its warning tag, translated in es and ru', async () => {
|
||||||
|
const now = 1_000_000;
|
||||||
|
const layers = [
|
||||||
|
{
|
||||||
|
id: 'flights',
|
||||||
|
name: 'Live Flights',
|
||||||
|
enabled: true,
|
||||||
|
stats: { partial: true, count: 4, lastUpdate: now },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vessels',
|
||||||
|
name: 'Live AIS Vessels',
|
||||||
|
enabled: true,
|
||||||
|
stats: { count: 9, lastUpdate: now },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const context = hudSummaryLayerContext(layers, { now });
|
||||||
|
assert.equal(context.feedProvenance.overall, 'partial');
|
||||||
|
assert.deepEqual(provenanceTag(context.feedProvenance), {
|
||||||
|
state: 'partial',
|
||||||
|
names: ['Live Flights'],
|
||||||
|
});
|
||||||
|
assert.deepEqual(hudTelemetryProvenance(layers, { now }), {
|
||||||
|
state: 'partial',
|
||||||
|
names: ['Live Flights'],
|
||||||
|
});
|
||||||
|
const { setLocale, t } = await import('./i18n/index.js');
|
||||||
|
try {
|
||||||
|
for (const locale of ['es', 'ru']) {
|
||||||
|
setLocale(locale);
|
||||||
|
assert.notEqual(t('PARTIAL'), 'PARTIAL', locale);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLocale('en');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user