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\)\.$/, + ); +});