mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
parseRowCells now splits on unescaped pipes only and decodes escaped pipes while preserving ordinary backslashes and empty cells, so facts whose text contains literal | survive the fence->DB reconcile instead of being silently deleted. Fixes #2726.
This commit is contained in:
+21
-10
@@ -27,17 +27,30 @@
|
||||
* or has no second pipe). On a match, returns the cells with surrounding
|
||||
* whitespace trimmed, with the outer pipes already stripped.
|
||||
*
|
||||
* NOTE: does NOT unescape `\|` back to `|`. Round-trip-on-pipes is a
|
||||
* separate concern callers handle if their domain text legitimately
|
||||
* contains pipes (currently neither takes nor facts do at the LLM-extract
|
||||
* layer; if a hand-edit introduces one, escape-on-write at render time
|
||||
* protects the table shape).
|
||||
* Escaped pipes (`\|`) stay inside their cell and are decoded back to `|`.
|
||||
* Other backslashes are preserved verbatim so existing fence text such as
|
||||
* Windows paths remains byte-stable across a render/parse cycle.
|
||||
*/
|
||||
export function parseRowCells(line: string): string[] | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('|') || !trimmed.includes('|', 1)) return null;
|
||||
const inner = trimmed.replace(/^\|/, '').replace(/\|$/, '');
|
||||
return inner.split('|').map(c => c.trim());
|
||||
const cells: string[] = [];
|
||||
let cell = '';
|
||||
for (let i = 0; i < inner.length; i++) {
|
||||
const char = inner[i];
|
||||
if (char === '\\' && inner[i + 1] === '|') {
|
||||
cell += '|';
|
||||
i += 1;
|
||||
} else if (char === '|') {
|
||||
cells.push(cell.trim());
|
||||
cell = '';
|
||||
} else {
|
||||
cell += char;
|
||||
}
|
||||
}
|
||||
cells.push(cell.trim());
|
||||
return cells;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,10 +90,8 @@ export function parseStringCell(raw: string): string | undefined {
|
||||
|
||||
/**
|
||||
* Escape a value for safe placement inside a pipe-separated cell. Replaces
|
||||
* any literal `|` with `\|` so the table layout stays intact. Inverse is
|
||||
* not needed at parse time today (see parseRowCells note); a future
|
||||
* `unescapeFenceCell` helper can land alongside any domain that needs to
|
||||
* read pipes back out of cell text.
|
||||
* any literal `|` with `\|` so the table layout stays intact. `parseRowCells`
|
||||
* is the inverse and decodes the escape after identifying cell boundaries.
|
||||
*/
|
||||
export function escapeFenceCell(s: string): string {
|
||||
return s.replace(/\|/g, '\\|');
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { PostgresEngine } from '../../src/core/postgres-engine.ts';
|
||||
import { runExtractFacts } from '../../src/core/cycle/extract-facts.ts';
|
||||
import { parseFactsFence, renderFactsTable, type ParsedFact } from '../../src/core/facts-fence.ts';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const skip = !databaseUrl;
|
||||
|
||||
if (skip) test.skip('facts-fence Postgres reconciliation skipped (DATABASE_URL unset)', () => {});
|
||||
|
||||
describe.skipIf(skip)('facts-fence escaped-pipe reconciliation on Postgres', () => {
|
||||
const slug = 'people/facts-pipe-roundtrip-example';
|
||||
let engine: PostgresEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PostgresEngine();
|
||||
await engine.connect({ database_url: databaseUrl! });
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (engine) {
|
||||
await engine.executeRaw('DELETE FROM pages WHERE slug = $1', [slug]);
|
||||
await engine.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('render → parse → reconcile preserves pipes, backslashes, empty cells, and adjacent rows', async () => {
|
||||
const facts: ParsedFact[] = [
|
||||
{
|
||||
rowNum: 1,
|
||||
claim: 'scores correct|incorrect|partial',
|
||||
kind: 'fact',
|
||||
confidence: 1,
|
||||
visibility: 'world',
|
||||
notability: 'high',
|
||||
validFrom: '2026-07-10',
|
||||
source: String.raw`consumer\facts|review`,
|
||||
context: String.raw`left|right\tail`,
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
rowNum: 2,
|
||||
claim: 'ordinary adjacent fact',
|
||||
kind: 'fact',
|
||||
confidence: 0.8,
|
||||
visibility: 'private',
|
||||
notability: 'medium',
|
||||
active: true,
|
||||
},
|
||||
];
|
||||
const rendered = renderFactsTable(facts);
|
||||
expect(parseFactsFence(rendered)).toMatchObject({ warnings: [], facts });
|
||||
|
||||
await engine.putPage(slug, {
|
||||
title: 'Facts Pipe Roundtrip Example',
|
||||
type: 'person',
|
||||
compiled_truth: rendered,
|
||||
frontmatter: {},
|
||||
timeline: '',
|
||||
});
|
||||
const result = await runExtractFacts(engine, { slugs: [slug] });
|
||||
const rows = await engine.executeRaw<{ fact: string; row_num: number; source: string; context: string | null }>(
|
||||
'SELECT fact, row_num, source, context FROM facts WHERE source_markdown_slug = $1 ORDER BY row_num',
|
||||
[slug],
|
||||
);
|
||||
|
||||
expect(result.warnings.some(w => w.includes('FACTS_TABLE_MALFORMED'))).toBe(false);
|
||||
expect(result.factsInserted).toBe(2);
|
||||
expect(Array.from(rows)).toEqual([
|
||||
{ fact: facts[0].claim, row_num: 1, source: facts[0].source!, context: facts[0].context! },
|
||||
{ fact: facts[1].claim, row_num: 2, source: 'fence:reconcile', context: null },
|
||||
]);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -350,6 +350,47 @@ describe('renderFactsTable', () => {
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('round-trip: render then parse returns equivalent rows', () => {
|
||||
test('preserves escaped pipes, backslashes, empty cells, and adjacent ordinary rows', () => {
|
||||
const originals: ParsedFact[] = [
|
||||
minimalFact(1, {
|
||||
claim: 'scores correct|incorrect|partial',
|
||||
validFrom: '2026-07-10',
|
||||
validUntil: undefined,
|
||||
source: String.raw`consumer\facts|review`,
|
||||
context: String.raw`left|right\tail`,
|
||||
}),
|
||||
minimalFact(2, {
|
||||
claim: 'ordinary adjacent fact',
|
||||
validFrom: undefined,
|
||||
validUntil: undefined,
|
||||
source: undefined,
|
||||
context: undefined,
|
||||
}),
|
||||
];
|
||||
|
||||
const rendered = renderFactsTable(originals);
|
||||
expect(rendered).toContain(String.raw`scores correct\|incorrect\|partial`);
|
||||
expect(rendered).toContain(String.raw`consumer\facts\|review`);
|
||||
|
||||
const reparsed = parseFactsFence(rendered);
|
||||
expect(reparsed.warnings).toEqual([]);
|
||||
expect(reparsed.facts).toHaveLength(2);
|
||||
expect(reparsed.facts[0]).toMatchObject({
|
||||
claim: originals[0].claim,
|
||||
validFrom: originals[0].validFrom,
|
||||
validUntil: undefined,
|
||||
source: originals[0].source,
|
||||
context: originals[0].context,
|
||||
});
|
||||
expect(reparsed.facts[1]).toMatchObject({
|
||||
claim: originals[1].claim,
|
||||
validFrom: undefined,
|
||||
validUntil: undefined,
|
||||
source: undefined,
|
||||
context: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('canonical row survives render+parse with all fields intact', () => {
|
||||
const original: ParsedFact = minimalFact(1, {
|
||||
claim: 'Founded Acme in 2017',
|
||||
|
||||
Reference in New Issue
Block a user