diff --git a/graph-ui/src/provider/compact-rows.test.ts b/graph-ui/src/provider/compact-rows.test.ts index 25cd86467..da553800f 100644 --- a/graph-ui/src/provider/compact-rows.test.ts +++ b/graph-ui/src/provider/compact-rows.test.ts @@ -120,6 +120,28 @@ describe('parseCompactRows', () => { expect(() => parseCompactRows(broken)).toThrow(/rows: 2 an, geliefert wurden 1/); }); + it('liest die Zeilenform des schlanken Vertrags mit ihren Fusszeilen', () => { + const lean = [ + 'rows: 2 (cols: n.name n.file_path)', + ' loadConfig src/config.ts', + ' isProduction src/config.ts', + 'returned: 2', + 'total: 2', + 'total_relation: eq', + 'has_more: false', + 'truncated: false', + '', + ].join('\n'); + const parsed = parseCompactRows(lean); + expect(parsed.rows).toEqual([['loadConfig', 'src/config.ts'], ['isProduction', 'src/config.ts']]); + expect(parsed.total).toBe(2); + expect(parsed.returned).toBe(2); + expect(parsed.totalRelation).toBe('eq'); + expect(parsed.hasMore).toBe(false); + expect(parsed.truncated).toBe(false); + expect(parsed.hint).toBeUndefined(); + }); + it('wirft ohne total-Zeile, statt total zu erfinden', () => { const broken = ['rows: 1 (cols: b.name)', ' validateUser', ''].join('\n'); expect(() => parseCompactRows(broken)).toThrow(/ohne total-Zeile/); @@ -162,6 +184,35 @@ describe('parseSearchResults', () => { it('nimmt die Zeilenform nicht als Suchform an', () => { expect(() => parseSearchResults(CALLS_RESPONSE)).toThrow(/unbekannte Zeile|unbekannter Kopf/); }); + + /* + * Der schlanke Ausgabevertrag (#1597) haengt an beide Formen weitere + * Fusszeilen: returned, total_relation, truncated (und bei der Suche + * steht search_mode jetzt nach dem Kopf). Woertlich, was der Server auf + * main am 2026-09-06 fuer fixtures/atlas-sample geantwortet hat; ein + * Parser, der eine dieser Zeilen nicht kennt, lieferte vorher gar nichts. + */ + it('liest die Suchantwort des schlanken Vertrags mit ihren Fusszeilen', () => { + const lean = [ + 'results: 1 (cols: qn label file lines rank)', + ' atlas-sample.src.services.userService.createUser Function src/services/userService.ts 23-36 -15.26', + 'total: 1', + 'total_relation: eq', + 'search_mode: bm25', + 'returned: 1', + 'has_more: false', + 'truncated: false', + '', + ].join('\n'); + const parsed = parseSearchResults(lean); + expect(parsed.rows).toHaveLength(1); + expect(parsed.total).toBe(1); + expect(parsed.mode).toBe('bm25'); + expect(parsed.hasMore).toBe(false); + expect(parsed.returned).toBe(1); + expect(parsed.totalRelation).toBe('eq'); + expect(parsed.truncated).toBe(false); + }); }); describe('Hilfsfunktionen', () => { diff --git a/graph-ui/src/provider/compact-rows.ts b/graph-ui/src/provider/compact-rows.ts index 40a0fe0d1..ddf1433b6 100644 --- a/graph-ui/src/provider/compact-rows.ts +++ b/graph-ui/src/provider/compact-rows.ts @@ -29,6 +29,17 @@ export interface CompactRows { total: number; /** Der `hint:`-Text, den der Server bei leeren Ergebnissen mitschickt. */ hint?: string; + /** + * Die Fusszeilen, die der Server seit dem schlanken Ausgabevertrag + * (#1597) mitschickt: wie viele Zeilen geliefert wurden, ob `total` exakt + * oder eine Untergrenze ist (`eq` / `gte`), ob mehr da waere und ob + * gekappt wurde. Optional, weil die Aufzeichnungen von vor dem Vertrag + * sie nicht tragen; wo sie da sind, sind sie die ehrlichere Auskunft. + */ + returned?: number; + totalRelation?: string; + hasMore?: boolean; + truncated?: boolean; } /** Eine geparste search_graph-Antwort. */ @@ -43,6 +54,10 @@ export interface SearchResults { rows: string[][]; /** True, wenn der Server abgeschnitten hat und mehr da waere. */ hasMore: boolean; + /** Die weiteren Fusszeilen des schlanken Vertrags (#1597), siehe CompactRows. */ + returned?: number; + totalRelation?: string; + truncated?: boolean; } const ROWS_HEAD = /^rows:\s*(\d+)\s*\(cols:\s*([^)]*)\)\s*$/; @@ -51,6 +66,9 @@ const TOTAL_LINE = /^total:\s*(\d+)\s*$/; const HINT_LINE = /^hint:\s*(.*)$/; const MODE_LINE = /^search_mode:\s*(\S+)\s*$/; const HAS_MORE_LINE = /^has_more:\s*(true|false)\s*$/; +const RETURNED_LINE = /^returned:\s*(\d+)\s*$/; +const TOTAL_RELATION_LINE = /^total_relation:\s*(\S+)\s*$/; +const TRUNCATED_LINE = /^truncated:\s*(true|false)\s*$/; const DATA_LINE = /^ {2}\S/; /** Zerlegt eine Datenzeile in Zellen und nimmt Anfuehrungszeichen weg. */ @@ -177,6 +195,10 @@ export function parseCompactRows(text: string): CompactRows { let total: number | undefined; let hint: string | undefined; + let returned: number | undefined; + let totalRelation: string | undefined; + let hasMore: boolean | undefined; + let truncated: boolean | undefined; for (; i < lines.length; i += 1) { const line = lines[i]; if (line.trim().length === 0) { @@ -192,6 +214,26 @@ export function parseCompactRows(text: string): CompactRows { hint = unquote(hintMatch[1]); continue; } + const returnedMatch = RETURNED_LINE.exec(line); + if (returnedMatch !== null) { + returned = Number.parseInt(returnedMatch[1], 10); + continue; + } + const relationMatch = TOTAL_RELATION_LINE.exec(line); + if (relationMatch !== null) { + totalRelation = relationMatch[1]; + continue; + } + const hasMoreMatch = HAS_MORE_LINE.exec(line); + if (hasMoreMatch !== null) { + hasMore = hasMoreMatch[1] === 'true'; + continue; + } + const truncatedMatch = TRUNCATED_LINE.exec(line); + if (truncatedMatch !== null) { + truncated = truncatedMatch[1] === 'true'; + continue; + } throw new Error(`unbekannte Fusszeile einer kompakten Antwort: ${line.trim()}`); } @@ -203,6 +245,18 @@ export function parseCompactRows(text: string): CompactRows { if (hint !== undefined) { out.hint = hint; } + if (returned !== undefined) { + out.returned = returned; + } + if (totalRelation !== undefined) { + out.totalRelation = totalRelation; + } + if (hasMore !== undefined) { + out.hasMore = hasMore; + } + if (truncated !== undefined) { + out.truncated = truncated; + } return out; } @@ -218,6 +272,9 @@ export function parseSearchResults(text: string): SearchResults { let declared: number | undefined; let rows: string[][] | undefined; let hasMore: boolean | undefined; + let returned: number | undefined; + let totalRelation: string | undefined; + let truncated: boolean | undefined; let i = 0; while (i < lines.length) { @@ -245,6 +302,24 @@ export function parseSearchResults(text: string): SearchResults { i += 1; continue; } + const returnedMatch = RETURNED_LINE.exec(line); + if (returnedMatch !== null) { + returned = Number.parseInt(returnedMatch[1], 10); + i += 1; + continue; + } + const relationMatch = TOTAL_RELATION_LINE.exec(line); + if (relationMatch !== null) { + totalRelation = relationMatch[1]; + i += 1; + continue; + } + const truncatedMatch = TRUNCATED_LINE.exec(line); + if (truncatedMatch !== null) { + truncated = truncatedMatch[1] === 'true'; + i += 1; + continue; + } const head = RESULTS_HEAD.exec(line); if (head !== null) { declared = Number.parseInt(head[1], 10); @@ -277,7 +352,17 @@ export function parseSearchResults(text: string): SearchResults { throw new Error('Suchantwort ohne has_more-Zeile'); } - return { total, mode, columns, rows, hasMore }; + const out: SearchResults = { total, mode, columns, rows, hasMore }; + if (returned !== undefined) { + out.returned = returned; + } + if (totalRelation !== undefined) { + out.totalRelation = totalRelation; + } + if (truncated !== undefined) { + out.truncated = truncated; + } + return out; } /** Verbindet Spaltennamen und Zellen zu einem Objekt je Zeile. */