mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
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:
co-authored by
Claude Opus 4.8
Time Attakc
parent
44cae62324
commit
e1526bfebe
@@ -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
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user