Skip to content

Commit 43cc7f3

Browse files
authored
feat(catalog): compact catalog index for large apps to shrink the cold prompt (#294) (#306)
generatePromptCatalog() switches to a compact index (id + title + one-line summary + map layer ids) above catalog_index_threshold datasets (default 8), instead of front-loading every dataset's full description + read_parquet paths + provider + about-url. Those all arrive via get_schema(dataset_id) — a call the model already makes before every query — so the cold prompt shrinks with zero added round-trips. Small apps keep the full front-load (byte cost is negligible there). Measured on the real geo-agent-template catalog (27 datasets loaded): catalog block ~9.8k -> ~2.0k tokens (79.5% smaller). Headless A/B on nimbus qwen, CD-16 question: turn-1 cold prompt 29.5k -> 20.3k tok (-31%), total prefill 526k -> 140k tok (3.8x), 77s -> 40s (1.9x). The compact run navigated correctly via list_datasets -> get_schema -> query, confirming that dropping front-loaded paths is safe because get_schema supplies them. Part 1 of #294 (compact catalog index). Part 2 (gate map-tool schemas to relevant turns) is separate. Configurable via catalog_index_threshold. Refs #294
1 parent f5bf345 commit 43cc7f3

4 files changed

Lines changed: 158 additions & 4 deletions

File tree

app/dataset-catalog.js

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -635,10 +635,29 @@ export class DatasetCatalog {
635635
/**
636636
* Generate a text summary of all datasets for injection into the LLM system prompt.
637637
*
638-
* Includes paths and map layer IDs — the "table of contents" for the app.
639-
* Column schemas are NOT included here; the model calls get_schema for those.
638+
* Two renderings (#294): a **full** front-load (title + Collection ID + full
639+
* description + provider + `read_parquet` paths + map layers) for small apps,
640+
* and a **compact index** (id + title + one-line summary + layer ids) above a
641+
* dataset-count threshold. The compact form drops the description, paths,
642+
* provider, and about-url — all of which arrive via `get_schema(dataset_id)`,
643+
* a call the model already makes before every query — so a large catalog's
644+
* ~34k-token cold prompt shrinks with zero added round-trips. Column schemas
645+
* are never front-loaded in either mode (they come from get_schema).
646+
*
647+
* @param {Object} [options]
648+
* @param {number} [options.compactAbove=8] - Use the compact index when the
649+
* catalog has more than this many datasets. Small apps stay on the full
650+
* front-load where the byte cost is negligible.
640651
*/
641-
generatePromptCatalog() {
652+
generatePromptCatalog(options = {}) {
653+
const compactAbove = options.compactAbove ?? 8;
654+
return this.datasets.size > compactAbove
655+
? this._renderCompactCatalog()
656+
: this._renderFullCatalog();
657+
}
658+
659+
/** Full front-load: every dataset's description, paths, provider, map layers. @private */
660+
_renderFullCatalog() {
642661
const preamble = 'The following datasets are pre-loaded for this app. Paths are shown below — use them directly in SQL. Call `get_schema(dataset_id)` before your first SQL query against a dataset to get column names and coded values.\n';
643662
const sections = [preamble];
644663

@@ -694,6 +713,62 @@ export class DatasetCatalog {
694713
return sections.join('\n---\n\n');
695714
}
696715

716+
/**
717+
* Compact index for large catalogs (#294): one line per dataset —
718+
* `id — title — one-line summary` (+ map layer ids for map datasets). The
719+
* full description, `read_parquet` paths, provider, and about-url are
720+
* intentionally omitted; they arrive via `get_schema(dataset_id)`, which the
721+
* model calls before querying. This reinforces the get-schema-first flow
722+
* (AGENTS.md: never guess S3 paths) while cutting the cold-prompt bytes.
723+
* @private
724+
*/
725+
_renderCompactCatalog() {
726+
const preamble = 'The datasets pre-loaded for this app are indexed below as `id` — title — summary. '
727+
+ 'Before querying a dataset, call `get_schema(dataset_id)`: it returns the `read_parquet()` path, '
728+
+ 'column names, types, coded values, and the full description. '
729+
+ 'Use `browse_stac_catalog` / `get_stac_details` only for datasets not in this list.\n';
730+
const lines = [preamble];
731+
732+
for (const ds of this.datasets.values()) {
733+
const isParentContainer = ds.columns.length === 0 && ds.childIds.length > 0;
734+
if (isParentContainer) {
735+
const listed = ds.childIds.slice(0, 20).join(', ');
736+
const more = ds.childIds.length > 20
737+
? ` (+${ds.childIds.length - 20} more via get_stac_details("${ds.id}"))`
738+
: '';
739+
lines.push(`- \`${ds.id}\` — **${ds.title}** — container; sub-datasets: ${listed}${more}`);
740+
continue;
741+
}
742+
743+
const summary = this._oneLine(ds.description);
744+
let line = `- \`${ds.id}\` — **${ds.title}**${summary ? ` — ${summary}` : ''}`;
745+
if (ds.mapLayers.length > 0) {
746+
const layers = ds.mapLayers
747+
.map(ml => `\`${ds.id}/${ml.assetId}\` (${ml.layerType})`)
748+
.join(', ');
749+
line += `\n map layers: ${layers}`;
750+
}
751+
lines.push(line);
752+
}
753+
754+
return lines.join('\n');
755+
}
756+
757+
/**
758+
* A compact one-line stand-in for a dataset description: the first sentence
759+
* if it's a reasonable length, else the collapsed text, capped. Full text is
760+
* available via get_schema, so this only needs to help the model *select* a
761+
* dataset. @private
762+
*/
763+
_oneLine(text, cap = 160) {
764+
if (!text) return '';
765+
const collapsed = text.replace(/\s+/g, ' ').trim();
766+
const sentence = collapsed.match(/^(.{12,}?[.!?])(\s|$)/);
767+
let s = sentence ? sentence[1] : collapsed;
768+
if (s.length > cap) s = s.slice(0, cap - 1).trimEnd() + '…';
769+
return s;
770+
}
771+
697772
/**
698773
* Render SQL asset paths only (no columns) for the system prompt.
699774
* Uses the client-direct parquetAssets extracted during load().

app/main.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,9 @@ async function main() {
360360

361361
/* ── 6. Build system prompt ────────────────────────────────────────── */
362362
const basePrompt = await basePromptP; // fetch was kicked off at step 1c
363-
const catalogText = catalog.generatePromptCatalog();
363+
// Large catalogs (#294) switch to a compact index to shrink the cold prompt;
364+
// tune or disable the threshold with `catalog_index_threshold` (Infinity = always full).
365+
const catalogText = catalog.generatePromptCatalog({ compactAbove: appConfig.catalog_index_threshold ?? 8 });
364366
let systemPrompt = basePrompt + '\n\n' + catalogText;
365367

366368
// Read server-provided prompt (if any)

docs/guide/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Client apps configure GLEN via `layers-input.json`. All fields except `catalog`
1818
| `auto_approve` | No | Start with remote tool calls auto-approved (no confirmation prompt). Default: `true`. |
1919
| `max_tool_calls` | No | Remote queries in auto-approve mode before the agent pauses at a checkpoint. Default: `15`. |
2020
| `max_tool_calls_manual` | No | Remote queries in manual mode before a checkpoint. Default: `100`. |
21+
| `catalog_index_threshold` | No | Dataset count above which the front-loaded catalog switches to a compact index (id + title + one-line summary + layer ids) to shrink the cold prompt; full descriptions/paths then arrive on demand via `get_schema`. Default: `8`. Set very high (e.g. `9999`) to always front-load the full catalog. |
2122
| `links` | No | Optional links shown in the chat UI — see below. |
2223

2324
## View

test/dataset-catalog.test.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,82 @@ describe('DatasetCatalog.generatePromptCatalog', () => {
220220
});
221221
});
222222

223+
describe('DatasetCatalog.generatePromptCatalog compact index (#294)', () => {
224+
// Register `n` realistic leaf datasets (long description, paths, a map layer).
225+
const bigCatalog = (n) => {
226+
const cat = new DatasetCatalog();
227+
for (let i = 0; i < n; i++) {
228+
cat.datasets.set(`ds${i}`, {
229+
id: `ds${i}`,
230+
title: `Dataset ${i}`,
231+
description: `Land cover classification for region ${i}. Derived from Sentinel-2 imagery `
232+
+ `at 10m resolution, updated annually. Includes forest, cropland, wetland, and urban `
233+
+ `classes with per-pixel confidence scores and a detailed methodology appendix.`,
234+
provider: `Provider ${i}`,
235+
columns: [{ name: 'h3', type: 'string' }],
236+
childIds: [],
237+
mapLayers: [{ assetId: 'pmtiles', title: `Layer ${i}`, layerType: 'vector' }],
238+
parquetAssets: [{ title: `Parquet ${i}`, s3Path: `s3://bucket/ds${i}/data.parquet` }],
239+
aboutUrl: `https://example.org/ds${i}`,
240+
});
241+
}
242+
return cat;
243+
};
244+
245+
it('stays full at/below the threshold and switches to compact above it', () => {
246+
expect(bigCatalog(8).generatePromptCatalog()).toContain('**Collection ID:**'); // full
247+
expect(bigCatalog(9).generatePromptCatalog()).not.toContain('**Collection ID:**'); // compact
248+
});
249+
250+
it('compact index keeps id/title/summary/layer but drops description, paths, provider, about-url', () => {
251+
const out = bigCatalog(9).generatePromptCatalog();
252+
expect(out).toContain('`ds0`');
253+
expect(out).toContain('**Dataset 0**');
254+
expect(out).toContain('Land cover classification for region 0.'); // one-line summary (first sentence)
255+
expect(out).toContain('`ds0/pmtiles` (vector)'); // layer id + type for map tools
256+
expect(out).toContain('get_schema(dataset_id)'); // preamble points to the deferred call
257+
// Dropped — all available via get_schema:
258+
expect(out).not.toContain('s3://bucket'); // the read_parquet path itself
259+
expect(out).not.toContain('**Provider:**');
260+
expect(out).not.toContain('per-pixel confidence scores'); // full description tail is gone
261+
expect(out).not.toContain('https://example.org');
262+
});
263+
264+
it('honors an explicit compactAbove override', () => {
265+
expect(bigCatalog(4).generatePromptCatalog({ compactAbove: 3 })).not.toContain('**Collection ID:**');
266+
expect(bigCatalog(40).generatePromptCatalog({ compactAbove: Infinity })).toContain('**Collection ID:**');
267+
});
268+
269+
it('renders parent containers compactly (directory line, no full description)', () => {
270+
const cat = bigCatalog(9);
271+
cat.datasets.set('parent', {
272+
id: 'parent', title: 'Watersheds', description: 'A very long container description that should not appear.',
273+
provider: 'L', columns: [], childIds: ['a', 'b', 'c'], mapLayers: [], parquetAssets: [],
274+
});
275+
const out = cat.generatePromptCatalog();
276+
expect(out).toContain('`parent` — **Watersheds** — container; sub-datasets: a, b, c');
277+
expect(out).not.toContain('should not appear');
278+
});
279+
280+
it('is dramatically smaller than the full front-load for the same catalog', () => {
281+
const cat = bigCatalog(40);
282+
const full = cat.generatePromptCatalog({ compactAbove: Infinity });
283+
const compact = cat.generatePromptCatalog({ compactAbove: 8 });
284+
// The whole point of #294: a large catalog's front-load shrinks by a lot.
285+
expect(compact.length).toBeLessThan(full.length * 0.4);
286+
});
287+
288+
it('_oneLine picks the first sentence and caps long single sentences', () => {
289+
const cat = new DatasetCatalog();
290+
expect(cat._oneLine('First sentence here. Second sentence.')).toBe('First sentence here.');
291+
expect(cat._oneLine('')).toBe('');
292+
const long = 'x'.repeat(300);
293+
const out = cat._oneLine(long);
294+
expect(out.length).toBeLessThanOrEqual(160);
295+
expect(out.endsWith('…')).toBe(true);
296+
});
297+
});
298+
223299
describe('DatasetCatalog.load (mocked fetch)', () => {
224300
let originalFetch;
225301
beforeEach(() => {

0 commit comments

Comments
 (0)