diff --git a/src/commands/think.ts b/src/commands/think.ts index e3c10882f..9ce9ee099 100644 --- a/src/commands/think.ts +++ b/src/commands/think.ts @@ -6,7 +6,7 @@ * degrades to gather-only output with a warning if missing. */ import type { BrainEngine } from '../core/engine.ts'; -import { runThink, persistSynthesis } from '../core/think/index.ts'; +import { runThink, persistSynthesis, stripGapsSection } from '../core/think/index.ts'; import { loadConfig, isThinClient } from '../core/config.ts'; import { callRemoteTool, unpackToolResult } from '../core/mcp-client.ts'; @@ -157,7 +157,7 @@ prints what would have been the input (exit 0). // Human-readable output console.log(`# ${question}\n`); - console.log(result.answer); + console.log(stripGapsSection(result.answer)); console.log(''); if (result.gaps.length > 0) { console.log('## Gaps'); diff --git a/src/core/think/index.ts b/src/core/think/index.ts index e11d7475f..f06ab59dc 100644 --- a/src/core/think/index.ts +++ b/src/core/think/index.ts @@ -553,6 +553,40 @@ export async function runThink( }; } +/** + * Strip a "## Gaps" section from an answer body. + * + * `think` returns gaps in the structured `gaps` array, which the CLI and the + * persisted synthesis page render exactly once. The system prompt also used to + * ask for a "Gaps" section inside the answer prose, so a model that still emits + * one would make the output show "## Gaps" twice — once from the prose, once + * from the structured array. This removes the prose section so the structured + * array stays the single source of truth. + * + * Matches a heading line `## Gaps` (level 2-6, case-insensitive) and removes it + * through the next heading of the same-or-higher level, or end of string. + * Returns the input unchanged when there is no such section. + */ +export function stripGapsSection(answer: string): string { + if (!answer) return answer; + const lines = answer.split('\n'); + let start = -1; + let level = 0; + for (let i = 0; i < lines.length; i++) { + const m = /^(#{2,6})\s+gaps\s*$/i.exec(lines[i]); + if (m) { start = i; level = m[1].length; break; } + } + if (start === -1) return answer; + let end = lines.length; + for (let i = start + 1; i < lines.length; i++) { + const h = /^(#{1,6})\s+\S/.exec(lines[i]); + if (h && h[1].length <= level) { end = i; break; } + } + const kept = [...lines.slice(0, start), ...lines.slice(end)].join('\n'); + // Drop trailing blank lines left by removing a trailing section. + return kept.replace(/\s+$/, ''); +} + /** * Persist a synthesis page + its evidence. Returns the saved slug. * Synthesis pages are written under `synthesis/-.md`. @@ -582,7 +616,7 @@ export async function persistSynthesis( const body = [ `# ${result.question}`, '', - result.answer, + stripGapsSection(result.answer), '', result.gaps.length > 0 ? '## Gaps\n\n' + result.gaps.map(g => `- ${g}`).join('\n') : '', ].filter(Boolean).join('\n'); diff --git a/src/core/think/prompt.ts b/src/core/think/prompt.ts index 7107ef729..06a16f84a 100644 --- a/src/core/think/prompt.ts +++ b/src/core/think/prompt.ts @@ -52,19 +52,19 @@ Hard rules: rather than asserting it as established. Confidence is part of the data. - If two takes contradict (different holders, opposite claims), surface BOTH in a "Conflicts" section. Never silently pick one. -- If you cannot answer because the brain doesn't contain the relevant data, say so in the - "Gaps" section. List the specific missing pieces. Do not make up answers. +- If the brain doesn't contain data needed to answer, do NOT make it up. Record each + missing piece in the structured "gaps" array (below), not as a section in the answer prose. - Never instruct the user (no "you should" / "I recommend X"). The brain reports; the user decides. - Output MUST be valid JSON matching the schema below. No prose outside JSON. Output schema: { - "answer": "", + "answer": "", "citations": [ {"page_slug": "people/alice-example", "row_num": 3, "citation_index": 1}, {"page_slug": "companies/acme-example", "row_num": null, "citation_index": 2} ], - "gaps": ["specific missing data point 1", "specific missing data point 2"] + "gaps": ["a specific, self-contained missing-or-stale data point, citing the [slug] where relevant", "another specific gap"] } The "row_num" field is required for take citations and MUST be null for page-only citations.`; @@ -83,7 +83,7 @@ export function buildThinkSystemPrompt(opts: ThinkSystemPromptOpts = {}): string lines.push(`\nThis is a temporal question. Order key claims chronologically when it helps the reader.`); } if (opts.willSave) { - lines.push(`\nThis synthesis will be persisted as a brain page. Aim for completeness — cover Answer, Conflicts, and Gaps thoroughly.`); + lines.push(`\nThis synthesis will be persisted as a brain page. Aim for completeness — cover the Answer and any Conflicts thoroughly, and list every missing piece in the structured "gaps" array.`); } if (opts.withCalibration) { lines.push( @@ -92,7 +92,7 @@ export function buildThinkSystemPrompt(opts: ThinkSystemPromptOpts = {}): string lines.push(`- Name both the user's PRIOR (default reasoning) AND the COUNTER-PRIOR from their hedged-domain self.`); lines.push(`- Reference active bias tags by name when relevant ("this fits the over-confident-geography pattern").`); lines.push(`- Do NOT silently substitute the debiased answer. ALWAYS surface both priors transparently.`); - lines.push(`- Track-record sentences belong in a "Calibration" section in the answer body, between Conflicts and Gaps.`); + lines.push(`- Track-record sentences belong in a "Calibration" section in the answer body, after the Conflicts section (if present).`); } return lines.join('\n'); } diff --git a/test/think-gaps.test.ts b/test/think-gaps.test.ts new file mode 100644 index 000000000..3b1660700 --- /dev/null +++ b/test/think-gaps.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from 'bun:test'; +import { stripGapsSection } from '../src/core/think/index.ts'; +import { buildThinkSystemPrompt } from '../src/core/think/prompt.ts'; + +// `gbrain think` returns gaps in the structured `gaps` array, which both the +// CLI (`src/commands/think.ts`) and the persisted synthesis page +// (`persistSynthesis`) render exactly once. Older prompts also asked for a +// "Gaps" section inside the answer prose, so a model that still emits one made +// the output print "## Gaps" twice. `stripGapsSection` removes the prose +// section so the structured array is the single source of truth. + +describe('stripGapsSection', () => { + test('removes a trailing "## Gaps" section', () => { + const answer = 'The answer with a claim [people/alice].\n\n## Gaps\n- no update since 2026-03-22 [projects/acme]\n- pricing not recorded'; + const out = stripGapsSection(answer); + expect(out).not.toContain('## Gaps'); + expect(out).not.toContain('no update since'); + expect(out).toContain('The answer with a claim [people/alice].'); + }); + + test('removes a level-3 "### Gaps" section', () => { + const out = stripGapsSection('Body text.\n\n### Gaps\n- missing thing'); + expect(out).not.toMatch(/#+\s+Gaps/i); + expect(out).toBe('Body text.'); + }); + + test('is case-insensitive', () => { + expect(stripGapsSection('Body.\n\n## GAPS\n- x')).toBe('Body.'); + expect(stripGapsSection('Body.\n\n## gaps\n- x')).toBe('Body.'); + }); + + test('returns the answer unchanged when there is no Gaps section', () => { + const answer = 'Just an answer.\n\n## Conflicts\n- a vs b'; + expect(stripGapsSection(answer)).toBe(answer); + }); + + test('does not match a heading that merely starts with "Gaps"', () => { + const answer = 'Body.\n\n## Gaps in the coverage\n- this is real content'; + expect(stripGapsSection(answer)).toBe(answer); + }); + + test('stops at the next same-or-higher heading (preserves later content)', () => { + const answer = 'Intro.\n\n## Gaps\n- missing x\n\n## Sources\n- [a]'; + const out = stripGapsSection(answer); + expect(out).not.toContain('missing x'); + expect(out).toContain('## Sources'); + expect(out).toContain('- [a]'); + }); + + test('handles empty / falsy input', () => { + expect(stripGapsSection('')).toBe(''); + }); + + test('the bug repro: strip + structured render yields exactly one "## Gaps"', () => { + // Mirrors the render in src/commands/think.ts: print the (stripped) answer, + // then append one "## Gaps" block from the structured `gaps` array. + const answer = 'Answer prose [people/alice].\n\n## Gaps\n- the prose gap, slightly different wording'; + const gaps = ['the structured gap']; + const rendered = + stripGapsSection(answer) + '\n\n## Gaps\n' + gaps.map((g) => `- ${g}`).join('\n'); + expect((rendered.match(/## Gaps/g) ?? []).length).toBe(1); + expect(rendered).toContain('the structured gap'); + }); +}); + +describe('buildThinkSystemPrompt — gaps go in the structured array, not the answer body', () => { + test('the answer schema no longer lists "Gaps" as a body section', () => { + const out = buildThinkSystemPrompt({}); + expect(out).not.toContain('Sections: Answer, Conflicts (optional), Gaps'); + expect(out).toContain('gaps belong in the gaps array'); + }); + + test('still requires the structured "gaps" array', () => { + const out = buildThinkSystemPrompt({}); + expect(out).toContain('"gaps"'); + }); + + test('preserves the Conflicts section and the Hard rules', () => { + const out = buildThinkSystemPrompt({}); + expect(out).toContain('Conflicts'); + expect(out).toContain('Hard rules:'); + expect(out).toContain('Cite EVERY substantive claim'); + }); + + test('willSave mode routes gaps to the structured array (no body Gaps section)', () => { + const out = buildThinkSystemPrompt({ willSave: true }); + expect(out).not.toContain('cover Answer, Conflicts, and Gaps thoroughly'); + expect(out).toContain('structured "gaps" array'); + }); +});