From 66b07418bd12a294427bc58fa9d59da700867064 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 15:44:33 -0700 Subject: [PATCH] fix: say when the camera catalog serves only part of a region Camera packs keep their nearest cameras and the catalog cap thins them further, so London answered 250 cameras while TfL offers many more. The catalog now records each pack's full count, tags cameras with their pack, and serves the packs it trimmed; find_cctv_cameras names them. --- server/providers/cctv.js | 2 ++ server/providers/cctv/catalog.js | 19 +++++++++++- server/providers/cctv/normalize.js | 5 +++- src/data/cctvCatalogCap.test.mjs | 20 +++++++++++++ src/tools/queries/media.js | 46 ++++++++++++++++++++++++------ src/tools/queries/media.test.mjs | 36 +++++++++++++++++++++++ 6 files changed, 117 insertions(+), 11 deletions(-) diff --git a/server/providers/cctv.js b/server/providers/cctv.js index a71e2af4d..9cf3b0c47 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -143,8 +143,10 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { if (url.pathname === '/sources') { const body = { + trimmedPacks: getCctvSources.trimmedPacks?.() ?? [], sources: sources.map((source) => ({ id: source.id, + pack: source.pack, name: source.name, city: source.city, cityId: source.cityId, diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index ae0ab9afa..cc258d7f4 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -143,6 +143,9 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) { /** @type {Promise>|null} In-flight refresh, shared by concurrent * callers so a post-TTL burst launches ONE refetch, not one per request. */ let _cctvSourceInflight = null; + /** @type {Array<{pack: string, available: number, served: number}>} Packs + * that serve fewer cameras than they offer. */ + let _cctvTrimmedPacks = []; /** * Assemble and cache the merged CCTV source list. @@ -209,8 +212,11 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) { name, sources: items .filter((item) => item && typeof item === 'object') - .map((item) => normalizeSourceItem(item)) + .map((item) => ({ ...normalizeSourceItem(item), pack: name })) .filter((item) => item.id), + available: Number.isFinite(items.available) + ? items.available + : items.length, }); const packs = [ ...LIVE_PACKS.map((pack, index) => @@ -234,6 +240,14 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) { loadGroundHeights(sourceRoot), ); const trimmed = allocation.packs.filter((pack) => pack.kept < pack.offered); + // Each pack's own nearest-first cap and the shared catalog cap both trim. + const trimmedPacks = packs + .map((pack, index) => ({ + pack: pack.name, + available: Math.max(pack.available, allocation.packs[index].offered), + served: allocation.packs[index].kept, + })) + .filter((pack) => pack.served < pack.available); if (trimmed.length) { const detail = trimmed .map((pack) => `${pack.name} ${pack.kept}/${pack.offered}`) @@ -244,6 +258,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) { } if (capped.length > 0 || _cctvSourceCache.length === 0) { _cctvSourceCache = capped; + _cctvTrimmedPacks = trimmedPacks; } else { // Every source came back empty (all live packs timed out / upstream outage) // but a good catalog is already cached — serve it stale rather than blanking @@ -257,5 +272,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) { return _cctvSourceCache; } + /** Packs serving fewer cameras than they offer, as of the last refresh. */ + getCctvSources.trimmedPacks = () => _cctvTrimmedPacks; return getCctvSources; } diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index b564ef440..99cd54712 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -470,7 +470,10 @@ export function prioritizeSources(cameras, maxCount, anchors) { return a.idx - b.idx; }); - return scored.slice(0, cap).map((entry) => entry.camera); + const kept = scored.slice(0, cap).map((entry) => entry.camera); + // How many cameras the pack offered before trimming, for coverage reports. + Object.defineProperty(kept, 'available', { value: list.length }); + return kept; } /** diff --git a/src/data/cctvCatalogCap.test.mjs b/src/data/cctvCatalogCap.test.mjs index 6f1ff6c59..8e3c08f03 100644 --- a/src/data/cctvCatalogCap.test.mjs +++ b/src/data/cctvCatalogCap.test.mjs @@ -128,6 +128,10 @@ test('the catalog applies the fair cap to configured packs end to end', async () sources.slice(0, 3).map((s) => s.id), ['env-0', 'env-1', 'env-2'], ); + assert.equal(sources[0].pack, 'env'); + assert.deepEqual(getSources.trimmedPacks(), [ + { pack: 'env', available: 12, served: 9 }, + ]); } finally { for (const key of Object.keys(process.env)) { if (!(key in saved)) delete process.env[key]; @@ -135,3 +139,19 @@ test('the catalog applies the fair cap to configured packs end to end', async () Object.assign(process.env, saved); } }); + +test('a pack trimmed to its nearest cameras reports how many it offered', async () => { + const { prioritizeSources } = + await import('../../server/providers/cctv/normalize.js'); + const cameras = Array.from({ length: 5 }, (_, i) => ({ + id: `c${i}`, + lat: i, + lon: 0, + })); + const kept = prioritizeSources(cameras, 2, [{ lat: 0, lon: 0 }]); + assert.deepEqual( + kept.map((camera) => camera.id), + ['c0', 'c1'], + ); + assert.equal(kept.available, 5); +}); diff --git a/src/tools/queries/media.js b/src/tools/queries/media.js index b4d3bf38c..45037ae1b 100644 --- a/src/tools/queries/media.js +++ b/src/tools/queries/media.js @@ -17,12 +17,22 @@ const round = (value, digits) => Number.isFinite(value) ? Number(value.toFixed(digits)) : null; const text = (value) => (typeof value === 'string' && value ? value : null); -async function readCameras(services, signal) { +async function readCatalog(services, signal) { const payload = await services.cctv.getCatalog({ signal }); - return payload.sources.filter( - (camera) => - camera?.id && Number.isFinite(camera.lat) && Number.isFinite(camera.lon), - ); + return { + cameras: payload.sources.filter( + (camera) => + camera?.id && + Number.isFinite(camera.lat) && + Number.isFinite(camera.lon), + ), + // Regional packs the catalog serves only part of, nearest their centers. + trimmed: Array.isArray(payload.trimmedPacks) ? payload.trimmedPacks : [], + }; +} + +async function readCameras(services, signal) { + return (await readCatalog(services, signal)).cameras; } export const findCctvCameras = defineTool({ @@ -41,8 +51,13 @@ export const findCctvCameras = defineTool({ async run(args, { services, signal }) { const area = await resolveArea(args.area, { services, signal }); const center = areaCenter(area); - const rows = (await readCameras(services, signal)) - .filter((camera) => areaContains(area, camera)) + const catalog = await readCatalog(services, signal); + const found = catalog.cameras.filter((camera) => + areaContains(area, camera), + ); + const packs = new Set(found.map((camera) => camera.pack).filter(Boolean)); + const trimmed = catalog.trimmed.filter((entry) => packs.has(entry.pack)); + const rows = found .map((camera) => ({ id: camera.id, name: text(camera.name), @@ -57,8 +72,21 @@ export const findCctvCameras = defineTool({ })) .sort((a, b) => a.distance_km - b.distance_km); return { - summary: `${countNoun(rows.length, 'public camera')} in ${area.label}.`, - data: capRows(rows, args.limit), + summary: + `${countNoun(rows.length, 'public camera')} in ${area.label}` + + (trimmed.length + ? ` (the catalog serves only some cameras here: ${trimmed + .map( + (entry) => + `${entry.served} of ${entry.available} from ${entry.pack}`, + ) + .join(', ')}).` + : '.'), + data: { + ...capRows(rows, args.limit), + complete: trimmed.length === 0, + catalog_trimmed: trimmed, + }, }; }, }); diff --git a/src/tools/queries/media.test.mjs b/src/tools/queries/media.test.mjs index a5c7a4a56..5b5a2e9c4 100644 --- a/src/tools/queries/media.test.mjs +++ b/src/tools/queries/media.test.mjs @@ -246,3 +246,39 @@ test('a stale or degraded radio directory is reported', async () => { ); assert.equal(result.data.degraded, true); }); + +test('cameras from a trimmed catalog pack are reported as partial', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + cctv: { + getCatalog: async () => ({ + trimmedPacks: [ + { pack: 'tfl', available: 900, served: 250 }, + { pack: 'other', available: 10, served: 5 }, + ], + sources: [ + { + id: 'jam-1', + name: 'Strand', + lat: 51.51, + lon: -0.12, + pack: 'tfl', + }, + ], + }), + }, + }, + }); + const result = await catalog.call('find_cctv_cameras', { + area: { lat: 51.51, lon: -0.12, radius_km: 5 }, + }); + assert.equal(result.data.complete, false); + assert.deepEqual(result.data.catalog_trimmed, [ + { pack: 'tfl', available: 900, served: 250 }, + ]); + assert.match( + result.summary, + /\(the catalog serves only some cameras here: 250 of 900 from tfl\)\.$/, + ); +});