mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
fix: report stale and partial data in more tool answers
A sweep for the same pattern found more answers presented as current and complete: launches and satellite orbits served from a stale proxy cache, a regional aircraft fallback read as worldwide, license plate cameras trimmed or partly loaded, and a radio directory of popular stations read as every station. The launch and satellite sources now report the proxies' stale markers, and each answer says what it covers.
This commit is contained in:
1 parent
6a45a8f6a2
commit
e779432925
10 files changed
+179
-19
No files matched your search
@@ -2,8 +2,8 @@
|
|||||||
export function createLaunchSource({
|
export function createLaunchSource({
|
||||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
return {
|
/** The launch payload, and whether the proxy served it from a stale cache. */
|
||||||
async getLaunches({ signal } = {}) {
|
async function getLaunchSnapshot({ signal } = {}) {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const response = await fetchImpl('/api/launches', { signal });
|
const response = await fetchImpl('/api/launches', { signal });
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
@@ -11,7 +11,15 @@ export function createLaunchSource({
|
|||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
if (!Array.isArray(payload) && !Array.isArray(payload?.results))
|
if (!Array.isArray(payload) && !Array.isArray(payload?.results))
|
||||||
throw new Error('Malformed launch snapshot');
|
throw new Error('Malformed launch snapshot');
|
||||||
return payload;
|
return {
|
||||||
|
payload,
|
||||||
|
stale: response.headers?.get?.('x-gev-cache') === 'STALE-ERROR',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
getLaunchSnapshot,
|
||||||
|
async getLaunches(options) {
|
||||||
|
return (await getLaunchSnapshot(options)).payload;
|
||||||
},
|
},
|
||||||
async getActiveTle({ signal } = {}) {
|
async getActiveTle({ signal } = {}) {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ export function createSatelliteSource({
|
|||||||
const response = await fetchImpl(`/api/celestrak/${group}`, { signal });
|
const response = await fetchImpl(`/api/celestrak/${group}`, { signal });
|
||||||
const text = response.ok ? await response.text() : '';
|
const text = response.ok ? await response.text() : '';
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
return { ok: response.ok, status: response.status, text };
|
// The proxy serves its last copy, marked STALE-ERROR, when CelesTrak
|
||||||
|
// is down.
|
||||||
|
const stale = response.headers?.get?.('x-tle-cache') === 'STALE-ERROR';
|
||||||
|
return { ok: response.ok, status: response.status, text, stale };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -118,10 +118,17 @@ export const aircraftInArea = defineTool({
|
|||||||
.map((record) => aircraftRow(record, center))
|
.map((record) => aircraftRow(record, center))
|
||||||
.sort((a, b) => a.distance_km - b.distance_km);
|
.sort((a, b) => a.distance_km - b.distance_km);
|
||||||
const kind = args.military ? 'military aircraft' : 'aircraft';
|
const kind = args.military ? 'military aircraft' : 'aircraft';
|
||||||
|
// A regional fallback feed covers only part of the world around the
|
||||||
|
// area's center; say so rather than imply a worldwide answer.
|
||||||
|
const regional = /regional/i.test(snapshot.coverage || '');
|
||||||
|
const notes = [
|
||||||
|
...(regional ? [`regional feed: ${snapshot.coverage}`] : []),
|
||||||
|
...(snapshot.freshness === 'stale' ? ['data may be stale'] : []),
|
||||||
|
];
|
||||||
return {
|
return {
|
||||||
summary:
|
summary:
|
||||||
`${rows.length} ${kind} in ${area.label}` +
|
`${rows.length} ${kind} in ${area.label}` +
|
||||||
(snapshot.freshness === 'stale' ? ' (data may be stale).' : '.'),
|
(notes.length ? ` (${notes.join('; ')}).` : '.'),
|
||||||
data: { ...capRows(rows, args.limit), ...snapshotInfo(snapshot) },
|
data: { ...capRows(rows, args.limit), ...snapshotInfo(snapshot) },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -337,3 +337,17 @@ test('aircraft searches keep the feeds that answered and name the others', async
|
|||||||
error.code === 'unavailable' && error.message === 'OpenSky HTTP 503',
|
error.code === 'unavailable' && error.message === 'OpenSky HTTP 503',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a regional fallback feed is named in the answer', async () => {
|
||||||
|
const regional = feed([record('abc123', 37.6, -122.3)], {
|
||||||
|
source: 'adsb.lol',
|
||||||
|
coverage: '250nm regional fallback',
|
||||||
|
});
|
||||||
|
const result = await composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: { aircraft: regional },
|
||||||
|
}).call('aircraft_in_area', {
|
||||||
|
area: { lat: 37.6, lon: -122.3, radius_km: 20 },
|
||||||
|
});
|
||||||
|
assert.match(result.summary, /\(regional feed: 250nm regional fallback\)\.$/);
|
||||||
|
});
|
||||||
@@ -116,7 +116,8 @@ export const findRadioStations = defineTool({
|
|||||||
name: 'find_radio_stations',
|
name: 'find_radio_stations',
|
||||||
title: 'Radio stations',
|
title: 'Radio stations',
|
||||||
description:
|
description:
|
||||||
'Internet radio stations from the Radio Browser directory, by area and/or ' +
|
'Internet radio stations from a directory of popular Radio Browser ' +
|
||||||
|
'stations, not every station, by area and/or ' +
|
||||||
'a search term matched against name, tags, language and country. Each ' +
|
'a search term matched against name, tags, language and country. Each ' +
|
||||||
"result includes the broadcaster's public stream URL.",
|
"result includes the broadcaster's public stream URL.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
@@ -186,9 +187,20 @@ export const findRadioStations = defineTool({
|
|||||||
if (center) rows.sort((a, b) => a.distance_km - b.distance_km);
|
if (center) rows.sort((a, b) => a.distance_km - b.distance_km);
|
||||||
const where = area ? ` in ${area.label}` : '';
|
const where = area ? ` in ${area.label}` : '';
|
||||||
const what = args.query ? ` matching "${args.query}"` : '';
|
const what = args.query ? ` matching "${args.query}"` : '';
|
||||||
|
// The directory is a selection of popular stations, not every station.
|
||||||
|
const notes = [
|
||||||
|
`from a directory of ${directory.stations.length} popular stations`,
|
||||||
|
...(directory.stale ? ['the directory may be stale'] : []),
|
||||||
|
...(directory.degraded ? ['the directory is incomplete right now'] : []),
|
||||||
|
];
|
||||||
return {
|
return {
|
||||||
summary: `${countNoun(rows.length, 'radio station')}${what}${where}.`,
|
summary: `${countNoun(rows.length, 'radio station')}${what}${where} (${notes.join('; ')}).`,
|
||||||
data: capRows(rows, args.limit),
|
data: {
|
||||||
|
...capRows(rows, args.limit),
|
||||||
|
directory_size: directory.stations.length,
|
||||||
|
stale: directory.stale === true,
|
||||||
|
degraded: directory.degraded === true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -185,7 +185,12 @@ test('radio stations match area and every search term', async () => {
|
|||||||
const jazz = await catalog.call('find_radio_stations', {
|
const jazz = await catalog.call('find_radio_stations', {
|
||||||
query: 'jazz norwegian',
|
query: 'jazz norwegian',
|
||||||
});
|
});
|
||||||
assert.equal(jazz.summary, '1 radio station matching "jazz norwegian".');
|
assert.equal(
|
||||||
|
jazz.summary,
|
||||||
|
'1 radio station matching "jazz norwegian" (from a directory of 3 popular stations).',
|
||||||
|
);
|
||||||
|
assert.equal(jazz.data.directory_size, 3);
|
||||||
|
assert.equal(jazz.data.stale, false);
|
||||||
assert.equal(jazz.data.rows[0].distance_km, undefined);
|
assert.equal(jazz.data.rows[0].distance_km, undefined);
|
||||||
const classicalTexas = await catalog.call('find_radio_stations', {
|
const classicalTexas = await catalog.call('find_radio_stations', {
|
||||||
query: 'classical texas',
|
query: 'classical texas',
|
||||||
@@ -220,3 +225,24 @@ test('malformed image results are a programming error', async () => {
|
|||||||
/malformed images/,
|
/malformed images/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a stale or degraded radio directory is reported', async () => {
|
||||||
|
const catalog = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: {
|
||||||
|
radio: {
|
||||||
|
getDirectory: async () => ({
|
||||||
|
stations: [],
|
||||||
|
stale: true,
|
||||||
|
degraded: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await catalog.call('find_radio_stations', { query: 'jazz' });
|
||||||
|
assert.match(
|
||||||
|
result.summary,
|
||||||
|
/the directory may be stale; the directory is incomplete right now\)\.$/,
|
||||||
|
);
|
||||||
|
assert.equal(result.data.degraded, true);
|
||||||
|
});
|
||||||
@@ -25,7 +25,12 @@ export const getRecentLaunches = defineTool({
|
|||||||
},
|
},
|
||||||
requires: ['launches'],
|
requires: ['launches'],
|
||||||
async run(args, { services, signal }) {
|
async run(args, { services, signal }) {
|
||||||
const payload = await services.launches.getLaunches({ signal });
|
const { payload, stale } = services.launches.getLaunchSnapshot
|
||||||
|
? await services.launches.getLaunchSnapshot({ signal })
|
||||||
|
: {
|
||||||
|
payload: await services.launches.getLaunches({ signal }),
|
||||||
|
stale: false,
|
||||||
|
};
|
||||||
const launches = Array.isArray(payload) ? payload : payload.results;
|
const launches = Array.isArray(payload) ? payload : payload.results;
|
||||||
if (!Array.isArray(launches))
|
if (!Array.isArray(launches))
|
||||||
throw new ToolError('malformed', 'The launch feed returned no launches');
|
throw new ToolError('malformed', 'The launch feed returned no launches');
|
||||||
@@ -49,8 +54,10 @@ export const getRecentLaunches = defineTool({
|
|||||||
.sort((a, b) => String(b.time).localeCompare(String(a.time)));
|
.sort((a, b) => String(b.time).localeCompare(String(a.time)));
|
||||||
const result = capRows(rows, args.limit);
|
const result = capRows(rows, args.limit);
|
||||||
return {
|
return {
|
||||||
summary: `${countNoun(rows.length, 'launch', 'launches')} in the last 30 days${rows[0] ? `; latest ${rows[0].name}` : ''}.`,
|
summary:
|
||||||
data: result,
|
`${countNoun(rows.length, 'launch', 'launches')} in the last 30 days${rows[0] ? `; latest ${rows[0].name}` : ''}` +
|
||||||
|
(stale ? ' (data may be stale).' : '.'),
|
||||||
|
data: { ...result, stale },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -103,7 +110,7 @@ async function readCatalog(services, group, signal) {
|
|||||||
'unavailable',
|
'unavailable',
|
||||||
`The ${group} satellite catalog is unavailable (HTTP ${result.status})`,
|
`The ${group} satellite catalog is unavailable (HTTP ${result.status})`,
|
||||||
);
|
);
|
||||||
return parseTleText(result.text).flatMap((entry) => {
|
const entries = parseTleText(result.text).flatMap((entry) => {
|
||||||
try {
|
try {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -116,6 +123,9 @@ async function readCatalog(services, group, signal) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// The proxy serves its last copy when CelesTrak is down; old orbital
|
||||||
|
// elements make predictions drift.
|
||||||
|
return { entries, stale: result.stale === true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchSatellite(entries, wanted) {
|
function matchSatellite(entries, wanted) {
|
||||||
@@ -164,7 +174,8 @@ export const nextSatellitePass = defineTool({
|
|||||||
async run(args, { services, signal }) {
|
async run(args, { services, signal }) {
|
||||||
const point = await resolvePoint(args.location, { services, signal });
|
const point = await resolvePoint(args.location, { services, signal });
|
||||||
const group = args.group ?? 'stations';
|
const group = args.group ?? 'stations';
|
||||||
const entries = await readCatalog(services, group, signal);
|
const { entries, stale } = await readCatalog(services, group, signal);
|
||||||
|
const staleNote = stale ? ' (orbit data may be stale)' : '';
|
||||||
const satellite = matchSatellite(entries, args.satellite);
|
const satellite = matchSatellite(entries, args.satellite);
|
||||||
if (!satellite)
|
if (!satellite)
|
||||||
throw new ToolError(
|
throw new ToolError(
|
||||||
@@ -184,8 +195,9 @@ export const nextSatellitePass = defineTool({
|
|||||||
const name = satellite.name;
|
const name = satellite.name;
|
||||||
if (!pass)
|
if (!pass)
|
||||||
return {
|
return {
|
||||||
summary: `${name} has no ${args.visible_only ? 'visible ' : ''}pass over ${point.label} in the next ${hours} hours.`,
|
summary: `${name} has no ${args.visible_only ? 'visible ' : ''}pass over ${point.label} in the next ${hours} hours${staleNote}.`,
|
||||||
data: {
|
data: {
|
||||||
|
stale,
|
||||||
location: point,
|
location: point,
|
||||||
satellite: name,
|
satellite: name,
|
||||||
norad: satellite.norad,
|
norad: satellite.norad,
|
||||||
@@ -196,8 +208,9 @@ export const nextSatellitePass = defineTool({
|
|||||||
summary:
|
summary:
|
||||||
`${name} next rises over ${point.label} at ${isoTime(pass.riseMs)} in the ${compass(pass.riseAzDeg)}, ` +
|
`${name} next rises over ${point.label} at ${isoTime(pass.riseMs)} in the ${compass(pass.riseAzDeg)}, ` +
|
||||||
`peaking at ${Math.round(pass.maxElevDeg)}°` +
|
`peaking at ${Math.round(pass.maxElevDeg)}°` +
|
||||||
`${pass.visible ? '; visible to the naked eye' : ''}.`,
|
`${pass.visible ? '; visible to the naked eye' : ''}${staleNote}.`,
|
||||||
data: {
|
data: {
|
||||||
|
stale,
|
||||||
location: point,
|
location: point,
|
||||||
satellite: name,
|
satellite: name,
|
||||||
norad: satellite.norad,
|
norad: satellite.norad,
|
||||||
@@ -238,7 +251,8 @@ export const satellitesOverhead = defineTool({
|
|||||||
const group = args.group ?? 'stations';
|
const group = args.group ?? 'stations';
|
||||||
const minimum = args.min_elevation_deg ?? 10;
|
const minimum = args.min_elevation_deg ?? 10;
|
||||||
const at = nowMs(services);
|
const at = nowMs(services);
|
||||||
const rows = (await readCatalog(services, group, signal))
|
const { entries, stale } = await readCatalog(services, group, signal);
|
||||||
|
const rows = entries
|
||||||
.flatMap((entry) => {
|
.flatMap((entry) => {
|
||||||
const look = lookAnglesAt(entry.satrec, at, point.lat, point.lon);
|
const look = lookAnglesAt(entry.satrec, at, point.lat, point.lon);
|
||||||
return look && look.elevDeg >= minimum
|
return look && look.elevDeg >= minimum
|
||||||
@@ -255,9 +269,12 @@ export const satellitesOverhead = defineTool({
|
|||||||
})
|
})
|
||||||
.sort((a, b) => b.elevation_deg - a.elevation_deg);
|
.sort((a, b) => b.elevation_deg - a.elevation_deg);
|
||||||
return {
|
return {
|
||||||
summary: `${countNoun(rows.length, 'satellite')} from the ${group} group ${rows.length === 1 ? 'is' : 'are'} at least ${minimum}° above ${point.label} at ${isoTime(at)}.`,
|
summary:
|
||||||
|
`${countNoun(rows.length, 'satellite')} from the ${group} group ${rows.length === 1 ? 'is' : 'are'} at least ${minimum}° above ${point.label} at ${isoTime(at)}` +
|
||||||
|
(stale ? ' (orbit data may be stale).' : '.'),
|
||||||
data: {
|
data: {
|
||||||
...capRows(rows, args.limit),
|
...capRows(rows, args.limit),
|
||||||
|
stale,
|
||||||
location: point,
|
location: point,
|
||||||
group,
|
group,
|
||||||
at: isoTime(at),
|
at: isoTime(at),
|
||||||
|
|||||||
@@ -150,3 +150,49 @@ test('satellites overhead are those above the elevation, highest first', async (
|
|||||||
/^0 satellites from the stations group are at least 90° above/,
|
/^0 satellites from the stations group are at least 90° above/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stale orbit and launch data are reported', async () => {
|
||||||
|
const stale = {
|
||||||
|
readGroup: async () => ({ ok: true, status: 200, text: TLE, stale: true }),
|
||||||
|
};
|
||||||
|
const catalog = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: {
|
||||||
|
satellites: stale,
|
||||||
|
clock: CLOCK,
|
||||||
|
launches: {
|
||||||
|
getLaunchSnapshot: async () => ({ payload: [], stale: true }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const overhead = await catalog.call('satellites_overhead', {
|
||||||
|
location: { lat: 0, lon: 0 },
|
||||||
|
});
|
||||||
|
assert.equal(overhead.data.stale, true);
|
||||||
|
assert.match(overhead.summary, /\(orbit data may be stale\)\.$/);
|
||||||
|
const launches = await catalog.call('get_recent_launches', {});
|
||||||
|
assert.equal(launches.data.stale, true);
|
||||||
|
assert.match(launches.summary, /\(data may be stale\)\.$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('satellite and launch sources read the proxies stale markers', async () => {
|
||||||
|
const { createSatelliteSource } =
|
||||||
|
await import('../../layers/satellites/source.js');
|
||||||
|
const { createLaunchSource } =
|
||||||
|
await import('../../layers/launches/source.js');
|
||||||
|
const satellites = createSatelliteSource({
|
||||||
|
fetchImpl: async () =>
|
||||||
|
new Response(TLE, { headers: { 'x-tle-cache': 'STALE-ERROR' } }),
|
||||||
|
});
|
||||||
|
assert.equal((await satellites.readGroup('stations')).stale, true);
|
||||||
|
const launches = createLaunchSource({
|
||||||
|
fetchImpl: async () =>
|
||||||
|
Response.json([], { headers: { 'X-GEV-Cache': 'STALE-ERROR' } }),
|
||||||
|
});
|
||||||
|
assert.deepEqual(await launches.getLaunchSnapshot(), {
|
||||||
|
payload: [],
|
||||||
|
stale: true,
|
||||||
|
});
|
||||||
|
const { getLaunches } = launches;
|
||||||
|
assert.deepEqual(await getLaunches(), []);
|
||||||
|
});
|
||||||
@@ -77,12 +77,21 @@ export const findAlprCameras = defineTool({
|
|||||||
distance_km: round(distanceKm(center, point), 2),
|
distance_km: round(distanceKm(center, point), 2),
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.distance_km - b.distance_km);
|
.sort((a, b) => a.distance_km - b.distance_km);
|
||||||
|
const notes = [
|
||||||
|
...(result.saturated
|
||||||
|
? ['partial: some cameras were not loaded; use a smaller area']
|
||||||
|
: []),
|
||||||
|
...(result.stale ? ['data may be stale'] : []),
|
||||||
|
];
|
||||||
return {
|
return {
|
||||||
summary:
|
summary:
|
||||||
`${countNoun(rows.length, 'license plate reader camera')} mapped in ${area.label}` +
|
`${countNoun(rows.length, 'license plate reader camera')} mapped in ${area.label}` +
|
||||||
(result.stale ? ' (data may be stale).' : '.'),
|
(notes.length ? ` (${notes.join('; ')}).` : '.'),
|
||||||
data: {
|
data: {
|
||||||
...capRows(rows, args.limit),
|
...capRows(rows, args.limit),
|
||||||
|
// Some tiles failed, or a dense area was trimmed to the source limit.
|
||||||
|
complete: !result.saturated,
|
||||||
|
stale: result.stale === true,
|
||||||
source: services.alpr.label ?? null,
|
source: services.alpr.label ?? null,
|
||||||
attribution: services.alpr.attribution ?? null,
|
attribution: services.alpr.attribution ?? null,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -95,3 +95,21 @@ test('coverage, size and zoom limits are reported', async () => {
|
|||||||
/smaller, city-sized area/,
|
/smaller, city-sized area/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a saturated camera fetch is reported as partial', async () => {
|
||||||
|
const catalog = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: {
|
||||||
|
alpr: alpr({
|
||||||
|
records: [camera('alpr:1', 30.3, -97.7)],
|
||||||
|
stale: false,
|
||||||
|
saturated: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await catalog.call('find_alpr_cameras', {
|
||||||
|
area: { lat: 30.3, lon: -97.7, radius_km: 5 },
|
||||||
|
});
|
||||||
|
assert.equal(result.data.complete, false);
|
||||||
|
assert.match(result.summary, /partial: some cameras were not loaded/);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user