fix(extract): preserve facts on parse warnings (#3494)

This commit is contained in:
cybernaut6404
2026-07-28 11:44:26 -07:00
committed by GitHub
parent a104f98dca
commit e58abd652c
2 changed files with 49 additions and 5 deletions
+11 -5
View File
@@ -17,11 +17,12 @@
* DB rows need cleanup (#1781 — the unconditional wipe-and-reinsert
* made every cycle non-idempotent, re-appending duplicate rows).
*
* After the phase, the DB index for every affected page matches the
* fence's canonical (claim, source) row set (modulo embeddings +
* runtime-derived fields). Pages with no fence wipe DB rows for that
* page coordinate only; legacy NULL-source_markdown_slug rows survive
* because deleteFactsForPage targets source_markdown_slug = slug only.
* After the phase, the DB index for every cleanly parsed affected page
* matches the fence's canonical (claim, source) row set (modulo embeddings
* + runtime-derived fields). Warning-bearing parses are non-authoritative
* and preserve that page's existing index. Pages with no fence wipe DB rows
* for that page coordinate only; legacy NULL-source_markdown_slug rows
* survive because deleteFactsForPage targets source_markdown_slug = slug only.
*
* Empty-fence guard (Codex R2-#7; #2484; #2646): the phase refuses to do
* its destructive reconciliation pass when genuinely-backfillable legacy
@@ -333,6 +334,11 @@ export async function runExtractFacts(
result.warnings.push(
...parsed.warnings.map(w => `${slug}: ${w}`),
);
// The parser deliberately skips malformed rows and returns any rows it
// could still recover. That partial result is not authoritative: using
// it for reconciliation would interpret skipped rows as deletions.
// Preserve this page's existing index and continue with other pages.
continue;
}
if (parsed.facts.length > 0) result.pagesWithFacts += 1;
+38
View File
@@ -231,6 +231,44 @@ describe('runExtractFacts — happy path', () => {
expect(rows.rows[0].fact).toBe('A');
});
test('malformed fence rows make the page non-authoritative and preserve its indexed facts', async () => {
await putPage('people/alice', FACT_FENCE(
`| 1 | A | fact | 1.0 | world | medium | 2026-01-01 | | s | |
| 2 | B | fact | 1.0 | world | medium | 2026-01-01 | | s | |`,
));
await runExtractFacts(engine, { slugs: ['people/alice'] });
// A hand edit corrupts row 2. The parser can still recover row 1, but
// that partial result is not an authoritative replacement for the page.
await putPage('people/alice', FACT_FENCE(
`| 1 | A | fact | 1.0 | world | medium | 2026-01-01 | | s | |
| 2 | B | bogus | 1.0 | world | medium | 2026-01-01 | | s | |`,
));
await putPage('people/bob', FACT_FENCE(
`| 1 | Clean | fact | 1.0 | world | medium | 2026-01-01 | | s | |`,
));
const r = await runExtractFacts(engine, { slugs: ['people/alice', 'people/bob'] });
expect(r.warnings.some(w => w.includes('FACTS_TABLE_MALFORMED'))).toBe(true);
expect(r.pagesScanned).toBe(2);
expect(r.factsInserted).toBe(1);
expect(r.factsDeleted).toBe(0);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rows = await (engine as any).db.query(
`SELECT fact FROM facts WHERE source_markdown_slug = 'people/alice' ORDER BY row_num`,
);
expect(rows.rows.map((row: { fact: string }) => row.fact)).toEqual(['A', 'B']);
// A warning is page-local: clean pages in the same cycle still reconcile.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cleanRows = await (engine as any).db.query(
`SELECT fact FROM facts WHERE source_markdown_slug = 'people/bob'`,
);
expect(cleanRows.rows.map((row: { fact: string }) => row.fact)).toEqual(['Clean']);
});
test('page with no facts fence → DB facts for that page wiped (empty fence reconciles to empty index)', async () => {
await putPage('people/alice', FACT_FENCE(
`| 1 | seeded | fact | 1.0 | world | medium | 2026-01-01 | | s | |`,