fix(think): render the Gaps section once instead of twice (#1662)

gbrain think printed "## Gaps" twice: the synthesis prompt asked the model for a Gaps section inside the answer body AND a separate structured gaps array, then both render paths printed both — the CLI human output (src/commands/think.ts) and the --save page (persistSynthesis in src/core/think/index.ts).

Make the structured gaps array the single source. The prompt now routes gaps into the array, not an answer-body section. New exported stripGapsSection(answer) defensively removes any "## Gaps" section a model still emits (any heading level, case-insensitive, bounded by the next same/higher heading); both render sites call it, so the dedup is structural rather than dependent on the model obeying the prompt.

Adds test/think-gaps.test.ts (hermetic): strip helper across heading levels / case / no-section / mid-document / false-match, the one-render-only repro, and the prompt contract.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Time Attakc <89218912+time-attack@users.noreply.github.com>
This commit is contained in:
Om Mishra
2026-07-22 18:46:29 -07:00
committed by GitHub
co-authored by Claude Opus 4.8 Time Attakc
parent 44cae62324
commit e1526bfebe
4 changed files with 133 additions and 9 deletions
+2 -2
View File
@@ -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');
+35 -1
View File
@@ -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/<slugified-question>-<date>.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');
+6 -6
View File
@@ -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": "<markdown body. Inline citations like [slug#row] or [slug]. Sections: Answer, Conflicts (optional), Gaps>",
"answer": "<markdown body. Inline citations like [slug#row] or [slug]. Sections: Answer, Conflicts (optional). Do NOT add a Gaps section here — gaps belong in the gaps array.>",
"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');
}
+90
View File
@@ -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');
});
});