Files
gbrain/test/frontmatter-inference.test.ts
T
17c3c43783 v0.22.15 feat: frontmatter inference — zero-friction ingest for files without YAML headers (#506)
* feat: frontmatter inference — zero-friction ingest for files without YAML headers

The platonic ideal of agentic retrieval is: you throw stuff in and it
becomes knowledge. No manual schema, no frontmatter templates, no YAML
ceremony. This PR makes that real.

## The Problem

9,655 files in a real 81K-page brain have no YAML frontmatter. They import
fine (gray-matter is forgiving), but with minimal metadata:
- type defaults to 'concept' for everything
- title is the slugified filename ('2010 04 13 Apr 13 Founders Mtg')
- No date, no source, no tags, no folder-aware typing

These pages exist in the DB but are poorly classified, which degrades
search ranking, type-filtered queries, and entity resolution.

## The Fix

### 1. Directory-aware inference engine (src/core/frontmatter-inference.ts)

A rules table maps path patterns to rich metadata:

  Apple Notes/*          → type: apple-note, date from filename, source: apple-notes
  Apple Notes/YC/*       → adds tag: yc
  Apple Notes/Politics/* → adds tag: politics
  daily/calendar/*       → type: calendar-index, source: calendar
  people/*               → type: person, title from # heading
  personal/therapy/*     → type: therapy-session, date from filename
  personal/reflections/* → type: reflection, title from # heading
  writing/essays/*       → type: essay, date from filename
  companies/*            → type: company, title from # heading
  events/*               → type: event, date from filename
  (catch-all)            → type: note, title from # heading

Each rule specifies:
- type: page type for brain schema
- datePattern: 'filename' (YYYY-MM-DD prefix), 'dirname', or 'none'
- titleStrategy: 'filename' (strip date), 'heading' (first #), 'filename-full'
- source: optional source tag
- tags: optional additional tags

Title extraction cleans up filenames (strips date prefix, converts dashes
to spaces, preserves existing capitalization). Heading extraction looks at
the first 20 lines for a # heading.

Fully deterministic. No LLM calls. No network. Same file → same frontmatter.

### 2. Inline inference in import pipeline (src/core/import-file.ts)

importFromFile() now runs inference automatically when a file has no
frontmatter. The synthesized frontmatter is applied to the in-memory
content before parseMarkdown runs, so the downstream pipeline sees
well-formed YAML. The file on disk is NOT modified — inference is
DB-only unless you explicitly run `gbrain frontmatter generate --fix`.

### 3. CLI command: gbrain frontmatter generate (src/commands/frontmatter.ts)

  gbrain frontmatter generate /path/to/brain         # dry-run preview
  gbrain frontmatter generate /path/to/brain --fix   # write to files
  gbrain frontmatter generate /path/to/brain --json  # machine output

The dry-run output shows:
- Total scanned / already have frontmatter / would generate
- Breakdown by inferred type
- First 10 examples with inferred metadata and matched rule

### 4. Tests (test/frontmatter-inference.test.ts)

35 tests covering:
- Date extraction from various filename patterns
- Title extraction from filenames and headings
- Inference for every directory rule (Apple Notes, people, therapy, etc.)
- Serialization with YAML-safe quoting
- Integration: applyInference prepends frontmatter correctly
- Rules: ordering, catch-all, specificity

## What this enables

1. `gbrain sync` now imports bare markdown with rich metadata automatically
2. `gbrain frontmatter generate --fix` writes frontmatter to 9,655 files
3. Future: sync can optionally write-back inferred frontmatter to git
4. Future: rules table is extensible — new directory conventions = one rule

## Adding new directory conventions

Edit DIRECTORY_RULES in src/core/frontmatter-inference.ts:

  { pathPrefix: 'recipes/', type: 'recipe', titleStrategy: 'heading' }

Rules are matched first-to-last, most specific prefix wins. The catch-all
(empty prefix) is always last.

## Real-world test output

  Scanned: 81,479 files
  Already have frontmatter: 71,824
  Would generate: 9,655

  By type:
    apple-note: 5,861
    calendar-index: 3,201
    person: 56
    therapy-session: 60
    reflection: 12
    essay: 33
    ...

All 35 tests pass.

* fix: import basename in frontmatter generate dynamic path import

src/commands/frontmatter.ts:437 calls basename(rootPath) as a fallback
when relative(brainRoot, rootPath) returns the empty string, but the
dynamic import a few lines above only destructures { resolve, relative,
join } from 'path'. typecheck failed with TS2304: Cannot find name
'basename', and any user invocation of `gbrain frontmatter generate
<single-file>` would have crashed at runtime with a ReferenceError.

The unit tests cover frontmatter-inference module's pure functions; no
test exercises the CLI single-file branch, so the bug slipped through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump VERSION 0.22.8 → 0.22.15 + CHANGELOG entry

Slot v0.22.15 per the queue allocator (other PRs claim v0.22.9–v0.22.14).
CHANGELOG entry written above v0.22.8 per the never-touch-shipped-entries
rule. bun.lock and llms-full.txt are unchanged (CLAUDE.md untouched, the
inference module and CLI command come in via the feature commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Wintermute <wintermute@garrytan.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:26:25 -07:00

284 lines
10 KiB
TypeScript

/**
* Tests for frontmatter-inference.ts — the zero-friction ingest pipeline.
*
* Validates that files without frontmatter get correct type, title, date,
* source, and tags inferred from their filesystem path and content.
*/
import { describe, test, expect } from 'bun:test';
import {
inferFrontmatter,
extractDateFromFilename,
extractTitleFromFilename,
extractTitleFromHeading,
serializeFrontmatter,
applyInference,
DIRECTORY_RULES,
} from '../src/core/frontmatter-inference.ts';
// ── Date extraction ──────────────────────────────────────────────────
describe('extractDateFromFilename', () => {
test('extracts YYYY-MM-DD from date-prefixed filename', () => {
expect(extractDateFromFilename('2010-04-13 Apr 13 founders mtg.md')).toBe('2010-04-13');
});
test('extracts date with dash separator', () => {
expect(extractDateFromFilename('2024-01-30-therapy-session.md')).toBe('2024-01-30');
});
test('extracts date with underscore separator', () => {
expect(extractDateFromFilename('2023-06-15_meeting-notes.md')).toBe('2023-06-15');
});
test('returns null for no-date filename', () => {
expect(extractDateFromFilename('README.md')).toBe(null);
});
test('returns null for filename with numbers but no date', () => {
expect(extractDateFromFilename('chapter-1-intro.md')).toBe(null);
});
});
// ── Title extraction ─────────────────────────────────────────────────
describe('extractTitleFromFilename', () => {
test('strips date prefix and cleans up', () => {
expect(extractTitleFromFilename('2010-04-13 Apr 13 founders mtg.md')).toBe('Apr 13 founders mtg');
});
test('strips YYYY-MM-DD- prefix', () => {
expect(extractTitleFromFilename('2024-01-30-therapy-session.md')).toBe('Therapy Session');
});
test('handles filename without date', () => {
expect(extractTitleFromFilename('cognitive-distortions.md')).toBe('Cognitive Distortions');
});
test('preserves mixed case', () => {
expect(extractTitleFromFilename('YC presidency.md')).toBe('YC presidency');
});
test('returns Untitled for empty result', () => {
expect(extractTitleFromFilename('.md')).toBe('Untitled');
});
});
describe('extractTitleFromHeading', () => {
test('extracts first # heading', () => {
expect(extractTitleFromHeading('# Dhravya Shah\n\n> Founder of Supermemory')).toBe('Dhravya Shah');
});
test('ignores ## headings', () => {
expect(extractTitleFromHeading('Some text\n## Not this\n# This one')).toBe('This one');
});
test('returns null when no heading found', () => {
expect(extractTitleFromHeading('Just some text\nwithout headings')).toBe(null);
});
test('looks within first 20 lines only', () => {
const lines = Array(25).fill('text').join('\n') + '\n# Too Late';
expect(extractTitleFromHeading(lines)).toBe(null);
});
});
// ── Core inference ───────────────────────────────────────────────────
describe('inferFrontmatter', () => {
test('skips files that already have frontmatter', () => {
const result = inferFrontmatter('people/alice.md', '---\ntitle: Alice\n---\n# Alice');
expect(result.skipped).toBe(true);
});
test('Apple Notes: infers type, date, title, source', () => {
const result = inferFrontmatter(
'Apple Notes/2010-04-13 Apr 13 founders mtg.md',
'<span style="color:#000ff;">Top priority</span>',
);
expect(result.type).toBe('apple-note');
expect(result.date).toBe('2010-04-13');
expect(result.title).toBe('Apr 13 founders mtg');
expect(result.source).toBe('apple-notes');
});
test('Apple Notes/YC: adds yc tag', () => {
const result = inferFrontmatter(
'Apple Notes/YC/2022-08-04 Project 1783Y.md',
'Some content',
);
expect(result.type).toBe('apple-note');
expect(result.tags).toContain('yc');
expect(result.date).toBe('2022-08-04');
});
test('Apple Notes/Politics: adds politics tag', () => {
const result = inferFrontmatter(
'Apple Notes/Politics/2023-11-15 DA race notes.md',
'Some content',
);
expect(result.tags).toContain('politics');
});
test('people/ directory: type person, title from heading', () => {
const result = inferFrontmatter(
'people/dhravya-shah.md',
'# Dhravya Shah\n\n> Founder of Supermemory',
);
expect(result.type).toBe('person');
expect(result.title).toBe('Dhravya Shah');
});
test('people/ directory: falls back to filename when no heading', () => {
const result = inferFrontmatter(
'people/john-doe.md',
'Some text without a heading',
);
expect(result.type).toBe('person');
expect(result.title).toBe('John Doe');
});
test('personal/therapy: infers therapy-session type with date', () => {
const result = inferFrontmatter(
'personal/therapy/jan/2024-01-30.md',
'Session notes...',
);
expect(result.type).toBe('therapy-session');
expect(result.date).toBe('2024-01-30');
expect(result.source).toBe('therapy');
});
test('personal/reflections: infers reflection type, title from heading', () => {
const result = inferFrontmatter(
'personal/reflections/cognitive-distortions.md',
'# Cognitive Distortions\n\nA list of common...',
);
expect(result.type).toBe('reflection');
expect(result.title).toBe('Cognitive Distortions');
});
test('writing/essays: infers essay type', () => {
const result = inferFrontmatter(
'writing/essays/2024-03-15-on-being-remembered.md',
'# On Being Remembered Forever\n\nSome thoughts...',
);
expect(result.type).toBe('essay');
expect(result.title).toBe('On Being Remembered Forever');
expect(result.date).toBe('2024-03-15');
});
test('daily/calendar: infers calendar-index type', () => {
const result = inferFrontmatter(
'daily/calendar/2026-01-15-yc-office-hours.md',
'# Calendar Index\nSome calendar data',
);
expect(result.type).toBe('calendar-index');
expect(result.source).toBe('calendar');
});
test('companies/ directory: type company', () => {
const result = inferFrontmatter(
'companies/stripe.md',
'# Stripe\n\n> Online payments infrastructure',
);
expect(result.type).toBe('company');
expect(result.title).toBe('Stripe');
});
test('unknown directory: defaults to note type with heading title', () => {
const result = inferFrontmatter(
'random/some-file.md',
'# My Random Notes\n\nStuff here',
);
expect(result.type).toBe('note');
expect(result.title).toBe('My Random Notes');
});
test('handles empty content', () => {
const result = inferFrontmatter('notes/empty.md', '');
expect(result.type).toBe('note');
expect(result.title).toBe('Empty');
});
});
// ── Serialization ────────────────────────────────────────────────────
describe('serializeFrontmatter', () => {
test('generates valid YAML frontmatter', () => {
const fm = serializeFrontmatter({
title: 'Apr 13 founders mtg',
type: 'apple-note',
date: '2010-04-13',
source: 'apple-notes',
tags: ['yc'],
});
expect(fm).toContain('---');
expect(fm).toContain('title: Apr 13 founders mtg');
expect(fm).toContain('type: apple-note');
expect(fm).toContain('date: "2010-04-13"');
expect(fm).toContain('source: apple-notes');
expect(fm).toContain('tags: ["yc"]');
});
test('quotes title with special chars', () => {
const fm = serializeFrontmatter({
title: 'What\'s the deal: a "primer"',
type: 'note',
});
expect(fm).toContain('title: "What\'s the deal: a \\"primer\\""');
});
test('returns empty string for skipped files', () => {
expect(serializeFrontmatter({ title: '', type: '', skipped: true })).toBe('');
});
test('omits optional fields when absent', () => {
const fm = serializeFrontmatter({ title: 'Test', type: 'note' });
expect(fm).not.toContain('date');
expect(fm).not.toContain('source');
expect(fm).not.toContain('tags');
});
});
// ── Integration ──────────────────────────────────────────────────────
describe('applyInference', () => {
test('prepends frontmatter to content without it', () => {
const { content, inferred } = applyInference(
'people/alice-smith.md',
'# Alice Smith\n\n> Founder of FooBar',
);
expect(content).toMatch(/^---\n/);
expect(content).toContain('type: person');
expect(content).toContain('title: Alice Smith');
expect(content).toContain('# Alice Smith');
expect(inferred.skipped).toBeUndefined();
});
test('returns original content for files with frontmatter', () => {
const original = '---\ntitle: Bob\n---\n# Bob';
const { content, inferred } = applyInference('people/bob.md', original);
expect(content).toBe(original);
expect(inferred.skipped).toBe(true);
});
});
// ── Rules coverage ───────────────────────────────────────────────────
describe('DIRECTORY_RULES', () => {
test('has a catch-all rule with empty prefix', () => {
const catchAll = DIRECTORY_RULES.find(r => r.pathPrefix === '');
expect(catchAll).toBeDefined();
expect(catchAll!.type).toBe('note');
});
test('Apple Notes rules are more specific than the catch-all', () => {
const appleRules = DIRECTORY_RULES.filter(r => r.pathPrefix.startsWith('apple notes/'));
expect(appleRules.length).toBeGreaterThan(1); // subfolder rules + catch-all
// Subfolder rules should come before the generic apple notes/ rule
const ycIdx = DIRECTORY_RULES.findIndex(r => r.pathPrefix === 'apple notes/yc/');
const genericIdx = DIRECTORY_RULES.findIndex(r => r.pathPrefix === 'apple notes/');
expect(ycIdx).toBeLessThan(genericIdx);
});
});