diff --git a/graph-ui/src/provider/compact-rows.search-footers.test.ts b/graph-ui/src/provider/compact-rows.search-footers.test.ts new file mode 100644 index 000000000..2e763e20e --- /dev/null +++ b/graph-ui/src/provider/compact-rows.search-footers.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; + +import { parseSearchResults } from './compact-rows'; + +/* + * Handtest 04.10.: In cbm meldete die Galaxy-Suche bei vielen Begriffen + * "Index search unavailable". Der Server schreibt unter eine Suchantwort mehr + * Fusszeilen, als der Parser kannte (bm25_emit_metadata_tree in src/mcp/mcp.c): + * next_offset und truncation_reason schon bei einer vollen Seite, + * candidate_window_saturated bei sehr vielen Kandidaten, und bei einem zu + * kleinen Ausgabebudget max_output_bytes, continuation_requires_higher_budget, + * output_budget_floor_exceeded und hint. Jede davon liess die Suche scheitern. + */ + +/** Wie der Server auf search_graph {project: cbm, query: working, limit: 3} antwortet. */ +const PAGED = [ + 'results: 3 (cols: qn label file lines rank)', + ' cbm.graph-ui.tools.freshclone-check.workingTreeChanges Function graph-ui/tools/freshclone-check.mjs 112-138 -15.29', + ' cbm.__branch__.working-tree Branch {} - -14.24', + ' cbm.tests.test_cypher.cypher_with_alias_stays_in_scope_issue1919 Function tests/test_cypher.c 2151-2164 -12.2', + 'total: 14', + 'total_relation: eq', + 'search_mode: bm25', + 'returned: 3', + 'has_more: true', + 'next_offset: 3', + 'truncated: true', + 'truncation_reason: page_limit', + '', +].join('\n'); + +describe('parseSearchResults reads every footer the server writes', () => { + it('reads a full page with next_offset and truncation_reason', () => { + const parsed = parseSearchResults(PAGED); + expect(parsed.rows).toHaveLength(3); + expect(parsed.total).toBe(14); + expect(parsed.hasMore).toBe(true); + expect(parsed.nextOffset).toBe(3); + expect(parsed.truncated).toBe(true); + expect(parsed.truncationReason).toBe('page_limit'); + }); + + it('reads a saturated candidate window', () => { + const saturated = [ + 'results: 1 (cols: qn label)', + ' a.b.c Function', + 'total: 1', + 'total_relation: gte', + 'candidate_window_saturated: true', + 'search_mode: bm25', + 'returned: 1', + 'has_more: false', + 'truncated: true', + 'truncation_reason: candidate_window', + '', + ].join('\n'); + const parsed = parseSearchResults(saturated); + expect(parsed.totalRelation).toBe('gte'); + expect(parsed.candidateWindowSaturated).toBe(true); + expect(parsed.truncationReason).toBe('candidate_window'); + expect(parsed.rows).toHaveLength(1); + }); + + it('reads the lines of a too small output budget', () => { + const budget = [ + 'results: 0 (cols: qn label)', + 'total: 5', + 'total_relation: eq', + 'search_mode: bm25', + 'returned: 0', + 'has_more: true', + 'continuation_requires_higher_budget: true', + 'truncated: true', + 'truncation_reason: output_budget', + 'max_output_bytes: 200', + 'output_budget_floor_exceeded: true', + 'hint: "first identity row exceeds max_output_bytes; raise max_output_bytes"', + '', + ].join('\n'); + const parsed = parseSearchResults(budget); + expect(parsed.rows).toHaveLength(0); + expect(parsed.truncationReason).toBe('output_budget'); + expect(parsed.maxOutputBytes).toBe(200); + expect(parsed.continuationRequiresHigherBudget).toBe(true); + expect(parsed.outputBudgetFloorExceeded).toBe(true); + expect(parsed.hint).toContain('max_output_bytes'); + }); + + it('still rejects a line it does not know', () => { + expect(() => parseSearchResults(PAGED.replace('truncation_reason: page_limit', 'surprise: 1'))).toThrow(/unbekannte Zeile/); + }); +}); diff --git a/graph-ui/src/provider/compact-rows.ts b/graph-ui/src/provider/compact-rows.ts index f9d649743..6db915f7a 100644 --- a/graph-ui/src/provider/compact-rows.ts +++ b/graph-ui/src/provider/compact-rows.ts @@ -63,8 +63,23 @@ export interface SearchResults { returned?: number; totalRelation?: string; truncated?: boolean; + /** Was bm25_emit_metadata_tree (src/mcp/mcp.c) ausserdem schreibt: die naechste Seite, + * warum gekappt wurde, ein gesaettigtes Kandidatenfenster und ein zu kleines Budget. */ + nextOffset?: number; + truncationReason?: string; + candidateWindowSaturated?: boolean; + continuationRequiresHigherBudget?: boolean; + maxOutputBytes?: number; + outputBudgetFloorExceeded?: boolean; + hint?: string; } +/** Fusszeilen der Suchantwort mit Zahl, Wahrheitswert oder Text (Handtest 04.10.: jede + * unbekannte liess die Suche mit "Index search unavailable" scheitern). */ +const SEARCH_NUMBER_LINE = /^(next_offset|max_output_bytes):\s*(\d+)\s*$/; +const SEARCH_FLAG_LINE = /^(candidate_window_saturated|continuation_requires_higher_budget|output_budget_floor_exceeded):\s*(true|false)\s*$/; +const SEARCH_TEXT_LINE = /^(truncation_reason|hint):\s*(.*)$/; + const ROWS_HEAD = /^rows:\s*(\d+)\s*\(cols:\s*([^)]*)\)\s*$/; const RESULTS_HEAD = /^results:\s*(\d+)\s*\(cols:\s*([^)]*)\)\s*$/; const TOTAL_LINE = /^total:\s*(\d+)\s*$/; @@ -341,6 +356,7 @@ export function parseSearchResults(text: string): SearchResults { let totalRelation: string | undefined; let truncated: boolean | undefined; let refs: ReadonlyMap | undefined; + const footers: Pick = {}; let i = 0; while (i < lines.length) { @@ -394,6 +410,26 @@ export function parseSearchResults(text: string): SearchResults { i += 1; continue; } + const numberMatch = SEARCH_NUMBER_LINE.exec(line); + if (numberMatch !== null) { + footers[numberMatch[1] === 'next_offset' ? 'nextOffset' : 'maxOutputBytes'] = Number.parseInt(numberMatch[2], 10); + i += 1; + continue; + } + const flagMatch = SEARCH_FLAG_LINE.exec(line); + if (flagMatch !== null) { + const key = flagMatch[1] === 'candidate_window_saturated' ? 'candidateWindowSaturated' + : flagMatch[1] === 'continuation_requires_higher_budget' ? 'continuationRequiresHigherBudget' : 'outputBudgetFloorExceeded'; + footers[key] = flagMatch[2] === 'true'; + i += 1; + continue; + } + const textMatch = SEARCH_TEXT_LINE.exec(line); + if (textMatch !== null) { + footers[textMatch[1] === 'truncation_reason' ? 'truncationReason' : 'hint'] = unquote(textMatch[2].trim()); + i += 1; + continue; + } const head = RESULTS_HEAD.exec(line); if (head !== null) { declared = Number.parseInt(head[1], 10); @@ -426,7 +462,7 @@ export function parseSearchResults(text: string): SearchResults { throw new Error('Suchantwort ohne has_more-Zeile'); } - const out: SearchResults = { total, mode, columns, rows, hasMore }; + const out: SearchResults = { total, mode, columns, rows, hasMore, ...footers }; if (returned !== undefined) { out.returned = returned; }