From 758a2d4293aad85fafd405d74ff358186d6c6ff3 Mon Sep 17 00:00:00 2001 From: Ziyang Guo <121015044+RerankerGuo@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:01:29 +0800 Subject: [PATCH] fix(takes): emit JSON for page extraction (#4004) Fixes #3962 Return the structured extraction result for --json callers while preserving the existing human summary. Add a behavior-level regression test that proves stdout is parseable JSON. --- src/commands/takes.ts | 13 ++++++-- test/takes-extract-json.test.ts | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 test/takes-extract-json.test.ts diff --git a/src/commands/takes.ts b/src/commands/takes.ts index 0a3fd4607..db18a975e 100644 --- a/src/commands/takes.ts +++ b/src/commands/takes.ts @@ -624,12 +624,13 @@ async function cmdExtract(engine: BrainEngine, rest: string[]): Promise { const sub = rest[0]; if (sub !== '--from-pages') { process.stderr.write( - 'Usage: gbrain takes extract --from-pages [--yes] [--dry-run] [--source-id ] [--max-pages N (clamped to 1000)] [--include-covered] [--holder ]\n' + + 'Usage: gbrain takes extract --from-pages [--yes] [--dry-run] [--json] [--source-id ] [--max-pages N (clamped to 1000)] [--include-covered] [--holder ]\n' + 'Runs progress: pages that already hold takes are skipped, so repeat runs sweep a large corpus in slices. --include-covered rescans everything (refresh).\n', ); process.exit(1); } const dryRun = rest.includes('--dry-run'); + const json = rest.includes('--json'); const skipConfirm = rest.includes('--yes'); const sourceIdx = rest.indexOf('--source-id'); const sourceIdFilter = sourceIdx >= 0 ? rest[sourceIdx + 1] : undefined; @@ -667,9 +668,17 @@ async function cmdExtract(engine: BrainEngine, rest: string[]): Promise { holder, }); if (result.llm_unavailable) { - process.stderr.write(`[takes extract] chat gateway unavailable (no API key configured).\n`); + if (json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + process.stderr.write(`[takes extract] chat gateway unavailable (no API key configured).\n`); + } process.exit(2); } + if (json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } process.stdout.write( `takes extract --from-pages: ${result.claims_extracted} claim(s) from ${result.pages_scanned} page(s)` + (dryRun ? ' (dry-run)' : '') + '\n', diff --git a/test/takes-extract-json.test.ts b/test/takes-extract-json.test.ts new file mode 100644 index 000000000..69e37f3a9 --- /dev/null +++ b/test/takes-extract-json.test.ts @@ -0,0 +1,56 @@ +/** + * #3962 — `takes extract --from-pages --json` must emit the structured + * extraction result instead of the human summary line. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { runTakes } from '../src/commands/takes.ts'; +import { + configureGateway, + resetGateway, +} from '../src/core/ai/gateway.ts'; +import type { BrainEngine } from '../src/core/engine.ts'; + +const engine = { + getConfig: async (key: string) => key === 'takes.bootstrap_enabled' ? 'true' : null, + executeRaw: async () => [], +} as unknown as BrainEngine; + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const originalWrite = process.stdout.write; + process.stdout.write = ((chunk: string | Uint8Array) => { + chunks.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + try { + await fn(); + } finally { + process.stdout.write = originalWrite; + } + return chunks.join(''); +} + +beforeAll(() => { + configureGateway({ + chat_model: 'openai:gpt-test', + env: { OPENAI_API_KEY: 'sk-test-takes-json' }, + }); +}); + +afterAll(() => { + resetGateway(); +}); + +describe('gbrain takes extract --from-pages --json (#3962)', () => { + test('emits the extraction result as parseable JSON', async () => { + const stdout = await captureStdout(() => + runTakes(engine, ['extract', '--from-pages', '--dry-run', '--json'])); + + expect(JSON.parse(stdout)).toEqual({ + pages_scanned: 0, + claims_extracted: 0, + consent_gate_blocked: false, + llm_unavailable: false, + }); + }); +});