diff --git a/src/tools/queries/brief.js b/src/tools/queries/brief.js index cf6161693..e1900873d 100644 --- a/src/tools/queries/brief.js +++ b/src/tools/queries/brief.js @@ -90,9 +90,11 @@ const AWARENESS_SECTIONS = [ function sectionFeedState(section) { if (section.unavailable) return 'unavailable'; const data = section.data || {}; - return data.freshness === 'stale' || data.stale === true - ? 'stale' - : 'nominal'; + if (data.freshness === 'stale' || data.stale === true) return 'stale'; + // Answered, but some of the feeds or satellites behind it did not. + if (data.missing_sources?.length || data.unavailable_feeds?.length) + return 'degraded'; + return 'nominal'; } /** diff --git a/src/tools/queries/environment.js b/src/tools/queries/environment.js index 141a8e70c..fab605431 100644 --- a/src/tools/queries/environment.js +++ b/src/tools/queries/environment.js @@ -104,9 +104,18 @@ export const getWeather = defineTool({ signal, }); const row = weatherRow(payload?.weather); + // The proxy answers from its cache, marked stale, when a refresh fails. + const stale = payload?.status === 'stale'; return { - summary: `Weather at ${point.label}: ${describeWeather(row)}.`, - data: { location: point, weather: row }, + summary: + `Weather at ${point.label}: ${describeWeather(row)}` + + (stale ? ' (data may be stale).' : '.'), + data: { + location: point, + weather: row, + stale, + retrieved_at: payload?.retrievedAt ?? null, + }, }; }, }); diff --git a/src/tools/queries/environment.test.mjs b/src/tools/queries/environment.test.mjs index b26c615bf..617d156f2 100644 --- a/src/tools/queries/environment.test.mjs +++ b/src/tools/queries/environment.test.mjs @@ -694,3 +694,51 @@ test('a fire perimeter enclosing the whole area is found', async () => { ['big'], ); }); + +test('stale weather and partial fire data reach answers and the HUD caption', async () => { + const sent = []; + const summary = { + async summarize(context) { + sent.push(context); + return { ok: true, status: 200, data: { summary: 'Austin STALE' } }; + }, + }; + const staleWeather = { + getConditions: async () => ({ status: 'stale', weather: conditions }), + }; + const fires = { + getSnapshot: async () => ({ + stale: false, + fetchedAt: Date.UTC(2026, 0, 1), + sources: [ + { source: 'VIIRS_NOAA20_NRT', ok: true }, + { source: 'VIIRS_SNPP_NRT', ok: false }, + ], + fires: [], + }), + }; + const catalog = catalogWith({ + weather: staleWeather, + summary, + places, + fires, + }); + const weatherResult = await catalog.call('get_weather', { + location: { place: 'Austin' }, + }); + assert.equal(weatherResult.data.stale, true); + assert.match(weatherResult.summary, /\(data may be stale\)\.$/); + const fireResult = await catalog.call('get_active_fires', { + area: { place: 'Austin' }, + }); + assert.deepEqual(fireResult.data.missing_sources, ['VIIRS_SNPP_NRT']); + assert.match(fireResult.summary, /\(no data from VIIRS_SNPP_NRT\)\.$/); + await catalog.call('get_hud_caption', { area: { place: 'Austin' } }); + assert.deepEqual( + sent[0].enabledLayers.map((layer) => [layer.id, layer.feedState]), + [ + ['weather', 'stale'], + ['fires', 'degraded'], + ], + ); +}); diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js index 15e80507a..c841d197b 100644 --- a/src/tools/queries/hazards.js +++ b/src/tools/queries/hazards.js @@ -78,9 +78,24 @@ export const getActiveFires = defineTool({ .filter((fire) => areaContains(area, fire)) .sort((a, b) => (b.frp ?? 0) - (a.frp ?? 0)); const result = capRows(fires, args.limit); + // The proxy serves its last good snapshot when a refresh fails, and marks + // satellites whose download failed. + const stale = snapshot.stale === true; + const missing = (snapshot.sources || []) + .filter((source) => source?.ok === false) + .map((source) => source.source); + const notes = [ + ...(stale ? ['data may be stale'] : []), + ...(missing.length ? [`no data from ${missing.join(', ')}`] : []), + ]; return { - summary: `${countNoun(fires.length, 'fire detection')} in ${area.label} in the last 24 hours.`, + summary: + `${countNoun(fires.length, 'fire detection')} in ${area.label} in the last 24 hours` + + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { + stale, + missing_sources: missing, + fetched_at: isoTime(snapshot.fetchedAt), ...result, rows: result.rows.map((fire) => ({ lat: fire.lat,