mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
* feat: CJK entity extraction for Chinese/Japanese/Korean names - Add hasCJK() / cjkCharCount() detection helpers - Lower min name length for CJK entities from 4 to 2 chars - Fix tokenizeTitle() to handle pure CJK titles as single tokens (was returning [] for CJK-only titles, excluding them from gazetteer) - Add CJK substring matching pass in findMentionedEntities - NER extraction works without schema pack (plain mentions fallback) Verified: gbrain extract links --by-mention creates 27 links from 456 pages with 3 CJK entity pages in gazetteer. * feat: Chinese link type inference + timeline date formats Link types: - CN_FOUNDED_RE: 创立/创办/成立/创建 → founded - CN_INVESTED_RE: 投资/入股/融资 → invested_in - CN_ADVISES_RE: 顾问/咨询/指导 → advises - CN_WORKS_AT_RE: 任职/就职/担任 → works_at - CN_CITED_RE: 引用/提到/提及 → cited Timeline: - TIMELINE_LINE_RE_CN: YYYY年M月D日 | event - Auto-normalizes to YYYY-MM-DD format - Falls through to English format if CN doesn't match * fix: CJK tokenizer uses char-level tokens (reviewer feedback) Addresses all 4 concerns from review of PR #1637: 1. tokenizeForScan now emits CJK characters as individual tokens — normal scan path reaches CJK gazetteer entries naturally, eliminating the separate O(P×C×N) substring fallback pass. 2. tokenizeTitle splits pure CJK titles into individual chars — e.g. '纳瓦尔' → ['纳','瓦','尔'], matching body-level CJK tokens. 3. Removed O(P×C×N) CJK substring pass — no longer needed. Performance now O(P × N_tokens) for both ASCII and CJK. 4. Renamed CN_*_RE → ZH_*_RE in link-extraction.ts with a comment clarifying these are Chinese-only (entity NAME extraction in by-mention.ts covers CJK scripts, link TYPE extraction is zh only). Added 12 CJK-specific tests (10 pure + 2 engine integration). All 51 existing + new tests pass. * review-repair(#1637): scope CN timeline regex to 年月日, revert off-scope extract-ner no-pack change, cosmetics - TIMELINE_LINE_RE_CN required only [年-] separators, so non-bold ASCII dates (- 2020-01-02 - text) started parsing as timeline entries — an English-default regression. Now requires the 年/月 markers. - Dropped the dead 'm = cm as any' assignment. - src/core/extract-ner.ts reverted to origin/master: the no-pack → plain-mentions walk was off-scope for a CJK PR, duplicated the existing --by-mention pass, and hardcoded pack_unavailable:false (breaking the CLI hint). - by-mention.ts: fixed stray indentation + restored EOF newline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Time Attakc <89218912+time-attack@users.noreply.github.com>
525 lines
22 KiB
TypeScript
525 lines
22 KiB
TypeScript
/**
|
|
* Unit tests for src/core/by-mention.ts.
|
|
*
|
|
* Pure-function coverage of `findMentionedEntities` + `buildGazetteer`.
|
|
* Hermetic via PGLite for buildGazetteer (needs engine); pure-fn cases
|
|
* for findMentionedEntities (no engine needed).
|
|
*
|
|
* Covers all 20 cases enumerated in the v0.42.0.0 plan:
|
|
* 1. Single-token title match
|
|
* 2. Multi-word phrase pass ("Acme Corp" matches "Acme Corp" not "Acme")
|
|
* 3. Case folding
|
|
* 4. Whole-word boundary
|
|
* 5. Possessive form
|
|
* 6. Code-block stripping
|
|
* 7. Min-length filter
|
|
* 8. Ignore-list at gazetteer build (Apple suppressed when no page)
|
|
* 9. Ignore-list inverse (Apple matches when page exists)
|
|
* 10. First-mention-only cap
|
|
* 11. Empty gazetteer
|
|
* 12. Empty text
|
|
* 13. All entity pages soft-deleted → empty gazetteer
|
|
* 14. Multi-word shared first token (longest-match wins)
|
|
* 15. Determinism across 10 calls
|
|
* 16. Self-link guard (D13)
|
|
* 17. Cross-source guard
|
|
* 18. Hardcoded type filter (meeting NOT in gazetteer)
|
|
* 19. Min-length + ignore-list interaction
|
|
* 20. Code-block + token interaction
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import {
|
|
buildGazetteer,
|
|
findMentionedEntities,
|
|
LINKABLE_ENTITY_TYPES,
|
|
type Gazetteer,
|
|
type GazetteerEntry,
|
|
} from '../src/core/by-mention.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await engine.executeRaw('DELETE FROM links');
|
|
await engine.executeRaw('DELETE FROM pages');
|
|
});
|
|
|
|
// Tiny gazetteer builder for pure-fn cases that don't need engine.
|
|
function gazetteerFromEntries(entries: Omit<GazetteerEntry, 'tokens'>[]): Gazetteer {
|
|
const TOKEN_RE = /[a-zA-Z0-9]+/g;
|
|
const isCJK = (s: string): boolean => {
|
|
const cp = s.codePointAt(0) ?? 0;
|
|
return (cp >= 0x4e00 && cp <= 0x9fff) || (cp >= 0x3400 && cp <= 0x4dbf) ||
|
|
(cp >= 0x3040 && cp <= 0x309f) || (cp >= 0x30a0 && cp <= 0x30ff) ||
|
|
(cp >= 0xac00 && cp <= 0xd7af);
|
|
};
|
|
const hasCJKTitle = (s: string): boolean => [...s].some(isCJK);
|
|
const tokenize = (s: string): string[] => {
|
|
TOKEN_RE.lastIndex = 0;
|
|
if (!hasCJKTitle(s)) {
|
|
const out: string[] = [];
|
|
let m: RegExpExecArray | null;
|
|
while ((m = TOKEN_RE.exec(s)) !== null) out.push(m[0].toLowerCase());
|
|
return out;
|
|
}
|
|
// CJK: split into individual characters, lowercased.
|
|
return [...s].map(c => isCJK(c) ? c.toLowerCase() : '').filter(Boolean);
|
|
};
|
|
const g: Gazetteer = new Map();
|
|
for (const raw of entries) {
|
|
const tokens = tokenize(raw.title);
|
|
if (tokens.length === 0) continue;
|
|
const key = tokens[0]!;
|
|
const entry: GazetteerEntry = { ...raw, tokens };
|
|
const bucket = g.get(key);
|
|
if (bucket) bucket.push(entry);
|
|
else g.set(key, [entry]);
|
|
}
|
|
for (const bucket of g.values()) bucket.sort((a, b) => b.tokens.length - a.tokens.length);
|
|
return g;
|
|
}
|
|
|
|
// ============================================================
|
|
// findMentionedEntities — pure unit tests
|
|
// ============================================================
|
|
|
|
describe('findMentionedEntities — pure cases', () => {
|
|
test('1. single-token title match', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
|
|
]);
|
|
const mentions = findMentionedEntities('Acme launched today.', g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'default',
|
|
});
|
|
expect(mentions).toHaveLength(1);
|
|
expect(mentions[0]!.slug).toBe('companies/acme');
|
|
expect(mentions[0]!.name).toBe('Acme');
|
|
expect(mentions[0]!.offset).toBe(0);
|
|
});
|
|
|
|
test('2. multi-word phrase pass — "Acme Corp" matches multi-word, not single', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
|
|
{ slug: 'companies/acme-corp', source_id: 'default', title: 'Acme Corp' },
|
|
]);
|
|
const mentions = findMentionedEntities('We met with Acme Corp last week.', g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'default',
|
|
});
|
|
// longest-match wins → only the multi-word target
|
|
expect(mentions).toHaveLength(1);
|
|
expect(mentions[0]!.slug).toBe('companies/acme-corp');
|
|
});
|
|
|
|
test('3. case folding — "iOS Engineer" title matches "ios engineer" in body', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'people/ios-engineer', source_id: 'default', title: 'iOS Engineer' },
|
|
]);
|
|
const mentions = findMentionedEntities('Looking to hire an ios engineer.', g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'default',
|
|
});
|
|
expect(mentions).toHaveLength(1);
|
|
expect(mentions[0]!.slug).toBe('people/ios-engineer');
|
|
});
|
|
|
|
test('4. whole-word boundary — "Acme" matches "Acme." but NOT "Acmecorp"', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
|
|
]);
|
|
// Should match (sentence-ending dot is a token break)
|
|
const m1 = findMentionedEntities('We bought Acme.', g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'default',
|
|
});
|
|
expect(m1).toHaveLength(1);
|
|
// Should NOT match — "Acmecorp" tokenizes as single token "acmecorp"
|
|
const m2 = findMentionedEntities('Acmecorp is unrelated.', g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'default',
|
|
});
|
|
expect(m2).toHaveLength(0);
|
|
});
|
|
|
|
test('5. possessive form — "Acme\'s growth" → Acme matches', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
|
|
]);
|
|
const mentions = findMentionedEntities("Acme's growth is impressive.", g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'default',
|
|
});
|
|
expect(mentions).toHaveLength(1);
|
|
expect(mentions[0]!.slug).toBe('companies/acme');
|
|
});
|
|
|
|
test('6. code-block stripping — mentions inside ``` blocks ignored', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
|
|
]);
|
|
const body = '```\nAcme code\n```\nNothing here.';
|
|
const mentions = findMentionedEntities(body, g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'default',
|
|
});
|
|
expect(mentions).toHaveLength(0);
|
|
});
|
|
|
|
test('10. first-mention-only cap — 5 body mentions of same entity → 1 link', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
|
|
]);
|
|
const body = 'Acme one. Acme two. Acme three. Acme four. Acme five.';
|
|
const mentions = findMentionedEntities(body, g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'default',
|
|
});
|
|
expect(mentions).toHaveLength(1);
|
|
});
|
|
|
|
test('11. empty gazetteer → empty result', () => {
|
|
const g: Gazetteer = new Map();
|
|
const mentions = findMentionedEntities('Anything goes here.', g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'default',
|
|
});
|
|
expect(mentions).toEqual([]);
|
|
});
|
|
|
|
test('12. empty text → empty result', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
|
|
]);
|
|
const mentions = findMentionedEntities('', g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'default',
|
|
});
|
|
expect(mentions).toEqual([]);
|
|
});
|
|
|
|
test('14. multi-word shared first token — longest-match wins', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
|
|
{ slug: 'companies/acme-corp', source_id: 'default', title: 'Acme Corp' },
|
|
{ slug: 'companies/acme-foundation', source_id: 'default', title: 'Acme Foundation' },
|
|
]);
|
|
const body = 'Acme Foundation announced. Then Acme Corp. Then plain Acme.';
|
|
const mentions = findMentionedEntities(body, g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'default',
|
|
});
|
|
// First offset: "Acme Foundation" — longest match wins.
|
|
// Second occurrence of "Acme": multi-word "Acme Corp" matches → multi-word wins.
|
|
// Third: plain "Acme" alone — single-word match.
|
|
const slugs = mentions.map(m => m.slug);
|
|
expect(slugs).toContain('companies/acme-foundation');
|
|
expect(slugs).toContain('companies/acme-corp');
|
|
expect(slugs).toContain('companies/acme');
|
|
});
|
|
|
|
test('15. determinism — same body + same gazetteer → identical output across 10 calls', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
|
|
{ slug: 'companies/acme-corp', source_id: 'default', title: 'Acme Corp' },
|
|
{ slug: 'people/alice', source_id: 'default', title: 'Alice Smith' },
|
|
]);
|
|
const body = 'Acme Corp and Alice Smith and Acme met. Then Alice Smith again.';
|
|
const refs = new Set<string>();
|
|
for (let i = 0; i < 10; i++) {
|
|
const mentions = findMentionedEntities(body, g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'default',
|
|
});
|
|
refs.add(JSON.stringify(mentions));
|
|
}
|
|
expect(refs.size).toBe(1);
|
|
});
|
|
|
|
test('16. self-link guard (D13) — entity page mentioning own title skips', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
|
|
]);
|
|
// Page IS the Acme page; body mentions "Acme" → self-link guard skips.
|
|
const mentions = findMentionedEntities('Acme has 500 customers.', g, {
|
|
fromSlug: 'companies/acme', fromSourceId: 'default',
|
|
});
|
|
expect(mentions).toEqual([]);
|
|
});
|
|
|
|
test('17. cross-source guard — page in source A mentions entity in source B → no link', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'companies/acme', source_id: 'team-b', title: 'Acme' },
|
|
]);
|
|
const mentions = findMentionedEntities('We met Acme today.', g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'team-a', // different source
|
|
});
|
|
expect(mentions).toEqual([]);
|
|
});
|
|
|
|
test('20. code-block + token interaction — body text outside block linked, inside skipped', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
|
|
]);
|
|
// Single backtick inline-code blocks the inner mention; outer mention fires.
|
|
const body = 'Outside: Acme works. Inline `Acme inside` should skip. After.';
|
|
const mentions = findMentionedEntities(body, g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'default',
|
|
});
|
|
expect(mentions).toHaveLength(1); // first-mention-only cap
|
|
expect(mentions[0]!.slug).toBe('companies/acme');
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// CJK — entity extraction tests
|
|
// ============================================================
|
|
|
|
describe('findMentionedEntities — CJK cases', () => {
|
|
test('CJK single-name match — "纳瓦尔" in body → matched', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'people/naval', source_id: 'default', title: '纳瓦尔' },
|
|
]);
|
|
const mentions = findMentionedEntities('我最近读了纳瓦尔的书。', g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'default',
|
|
});
|
|
expect(mentions).toHaveLength(1);
|
|
expect(mentions[0]!.slug).toBe('people/naval');
|
|
expect(mentions[0]!.name).toBe('纳瓦尔');
|
|
});
|
|
|
|
test('CJK multi-name — two different CJK entities in one body', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'people/naval', source_id: 'default', title: '纳瓦尔' },
|
|
{ slug: 'people/shuang-xuetao', source_id: 'default', title: '双雪涛' },
|
|
]);
|
|
const mentions = findMentionedEntities('纳瓦尔和双雪涛都是作家。', g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'default',
|
|
});
|
|
expect(mentions).toHaveLength(2);
|
|
const slugs = mentions.map(m => m.slug);
|
|
expect(slugs).toContain('people/naval');
|
|
expect(slugs).toContain('people/shuang-xuetao');
|
|
});
|
|
|
|
test('CJK first-mention-only — repeated name → single link', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'people/naval', source_id: 'default', title: '纳瓦尔' },
|
|
]);
|
|
const mentions = findMentionedEntities('纳瓦尔说过。然后纳瓦尔又说过。', g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'default',
|
|
});
|
|
expect(mentions).toHaveLength(1);
|
|
});
|
|
|
|
test('CJK self-link guard — entity page mentioning itself is skipped', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'people/naval', source_id: 'default', title: '纳瓦尔' },
|
|
]);
|
|
const mentions = findMentionedEntities('纳瓦尔是一位投资人。', g, {
|
|
fromSlug: 'people/naval', fromSourceId: 'default',
|
|
});
|
|
expect(mentions).toEqual([]);
|
|
});
|
|
|
|
test('CJK cross-source guard — entity in different source skipped', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'people/naval', source_id: 'team-b', title: '纳瓦尔' },
|
|
]);
|
|
const mentions = findMentionedEntities('纳瓦尔写了这本书。', g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'team-a',
|
|
});
|
|
expect(mentions).toEqual([]);
|
|
});
|
|
|
|
test('CJK code-block stripping — CJK name inside ``` is skipped, outside matched', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'people/naval', source_id: 'default', title: '纳瓦尔' },
|
|
]);
|
|
// "纳瓦尔" only appears inside code block → should be skipped.
|
|
const body = '```\n纳瓦尔\n```\n只有代码块里面有。';
|
|
const mentions = findMentionedEntities(body, g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'default',
|
|
});
|
|
expect(mentions).toHaveLength(0);
|
|
});
|
|
|
|
test('CJK determinism — same output across 10 calls', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'people/naval', source_id: 'default', title: '纳瓦尔' },
|
|
{ slug: 'people/shuang-xuetao', source_id: 'default', title: '双雪涛' },
|
|
]);
|
|
const body = '纳瓦尔和双雪涛。纳瓦尔再说一次。';
|
|
const refs = new Set<string>();
|
|
for (let i = 0; i < 10; i++) {
|
|
const mentions = findMentionedEntities(body, g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'default',
|
|
});
|
|
refs.add(JSON.stringify(mentions));
|
|
}
|
|
expect(refs.size).toBe(1);
|
|
});
|
|
|
|
test('CJK mixed body — CJK entity matched in body with ASCII around it', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'people/naval', source_id: 'default', title: '纳瓦尔' },
|
|
{ slug: 'companies/acme', source_id: 'default', title: 'Acme' },
|
|
]);
|
|
const mentions = findMentionedEntities('Acme was founded by 纳瓦尔 in 2020.', g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'default',
|
|
});
|
|
expect(mentions).toHaveLength(2);
|
|
const slugs = mentions.map(m => m.slug);
|
|
expect(slugs).toContain('people/naval');
|
|
expect(slugs).toContain('companies/acme');
|
|
});
|
|
|
|
test('CJK empty gazetteer — no false positives', () => {
|
|
const g: Gazetteer = new Map();
|
|
const mentions = findMentionedEntities('纳瓦尔和双雪涛。', g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'default',
|
|
});
|
|
expect(mentions).toEqual([]);
|
|
});
|
|
|
|
test('CJK empty text → empty result', () => {
|
|
const g = gazetteerFromEntries([
|
|
{ slug: 'people/naval', source_id: 'default', title: '纳瓦尔' },
|
|
]);
|
|
const mentions = findMentionedEntities('', g, {
|
|
fromSlug: 'writing/post-1', fromSourceId: 'default',
|
|
});
|
|
expect(mentions).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// buildGazetteer — engine-backed tests
|
|
// ============================================================
|
|
|
|
describe('buildGazetteer — engine integration', () => {
|
|
test('7. min-length filter — title "AI" (length 2) not in gazetteer', async () => {
|
|
await engine.putPage('companies/ai', {
|
|
type: 'company', title: 'AI', compiled_truth: 'b', timeline: '', frontmatter: {},
|
|
});
|
|
await engine.putPage('companies/acme', {
|
|
type: 'company', title: 'Acme', compiled_truth: 'b', timeline: '', frontmatter: {},
|
|
});
|
|
const g = await buildGazetteer(engine);
|
|
expect(g.has('acme')).toBe(true);
|
|
expect(g.has('ai')).toBe(false);
|
|
});
|
|
|
|
test('8. ignore-list at build — "Apple" suppressed when no companies/apple page', async () => {
|
|
// Seed a different entity page named "Apple" — but importantly NO
|
|
// companies/apple slug exists. Wait — actually the ignore-list keys
|
|
// on TITLE not slug. So even a non-companies slug with title="Apple"
|
|
// would be in the gazetteer because `existingTitles.has("Apple")` is true.
|
|
// The ignore list only fires when NO row has title="Apple". To exercise
|
|
// suppression: seed no entity with title="Apple" — duh, then there's
|
|
// nothing in the gazetteer for Apple anyway. The ignore-list rule is
|
|
// only meaningful if a HYPOTHETICAL entity named "Apple" would otherwise
|
|
// appear; in practice, the ignore-list short-circuits ANY row whose
|
|
// title is in the ignore set AND whose title isn't in existingTitles.
|
|
// For a deterministic test: seed one entity with title="Apple" and
|
|
// verify it IS in the gazetteer (per CK12 inverse rule); seed another
|
|
// run with no Apple entity and verify the ignore-list doesn't add one.
|
|
// Both behaviors covered by the existingTitles vs ignore_set logic.
|
|
await engine.putPage('people/alice', {
|
|
type: 'person', title: 'Alice Example', compiled_truth: 'b', timeline: '', frontmatter: {},
|
|
});
|
|
const g = await buildGazetteer(engine);
|
|
// No Apple entity seeded → 'apple' not a gazetteer key (trivially).
|
|
expect(g.has('apple')).toBe(false);
|
|
// Alice IS in gazetteer.
|
|
expect(g.has('alice')).toBe(true);
|
|
});
|
|
|
|
test('9. ignore-list inverse — title "Apple" matches when companies/apple exists (CK12)', async () => {
|
|
await engine.putPage('companies/apple', {
|
|
type: 'company', title: 'Apple', compiled_truth: 'b', timeline: '', frontmatter: {},
|
|
});
|
|
const g = await buildGazetteer(engine);
|
|
// existingTitles has "Apple" so the ignore-list does NOT suppress;
|
|
// gazetteer presence wins per CK12 rule.
|
|
expect(g.has('apple')).toBe(true);
|
|
expect(g.get('apple')![0]!.slug).toBe('companies/apple');
|
|
});
|
|
|
|
test('13. all entity pages soft-deleted → empty gazetteer', async () => {
|
|
await engine.putPage('people/alice', {
|
|
type: 'person', title: 'Alice Example', compiled_truth: 'b', timeline: '', frontmatter: {},
|
|
});
|
|
await engine.softDeletePage('people/alice');
|
|
const g = await buildGazetteer(engine);
|
|
expect(g.size).toBe(0);
|
|
});
|
|
|
|
test('18. hardcoded type filter — page with type=meeting NOT in gazetteer', async () => {
|
|
await engine.putPage('meetings/2026-01-15', {
|
|
type: 'meeting' as any, title: 'Weekly Sync',
|
|
compiled_truth: 'b', timeline: '', frontmatter: {},
|
|
});
|
|
await engine.putPage('people/bob', {
|
|
type: 'person', title: 'Robert Builder', compiled_truth: 'b', timeline: '', frontmatter: {},
|
|
});
|
|
const g = await buildGazetteer(engine);
|
|
expect(g.has('weekly')).toBe(false); // meeting type filtered out
|
|
expect(g.has('robert')).toBe(true); // person type included
|
|
});
|
|
|
|
test('19. min-length + ignore-list interaction — "YC" (2 chars) filtered by min-length BEFORE ignore-list', async () => {
|
|
// YC isn't in DEFAULT_IGNORE_LIST. But "Box" (3 chars) is. "Box" length
|
|
// = 3 < MIN_NAME_LENGTH (4), so it's filtered by min-length first. The
|
|
// ignore-list never fires. We test the regression that the min-length
|
|
// gate runs BEFORE the ignore-list (so adding Box to ignore-list
|
|
// doesn't accidentally change the filter ordering).
|
|
await engine.putPage('companies/box', {
|
|
type: 'company', title: 'Box', compiled_truth: 'b', timeline: '', frontmatter: {},
|
|
});
|
|
const g = await buildGazetteer(engine);
|
|
// "Box" is 3 chars → min-length filter drops it (whether or not in ignore-list).
|
|
expect(g.has('box')).toBe(false);
|
|
});
|
|
|
|
test('extraIgnore — user-supplied additional ignore tokens', async () => {
|
|
await engine.putPage('people/john', {
|
|
type: 'person', title: 'John', compiled_truth: 'b', timeline: '', frontmatter: {},
|
|
});
|
|
// No companies/john exists, so adding John to extraIgnore should suppress.
|
|
const g1 = await buildGazetteer(engine);
|
|
expect(g1.has('john')).toBe(true); // baseline: in gazetteer
|
|
const g2 = await buildGazetteer(engine, { extraIgnore: ['John'] });
|
|
// But title "John" IS the entity title — existingTitles.has('John') is true.
|
|
// Per CK12 rule, gazetteer presence wins → John IS still in.
|
|
expect(g2.has('john')).toBe(true);
|
|
});
|
|
|
|
test('LINKABLE_ENTITY_TYPES exposes the hardcoded contract', () => {
|
|
// Regression: if anyone changes the hardcoded type list, this test
|
|
// forces a deliberate change (and a corresponding test update).
|
|
expect(LINKABLE_ENTITY_TYPES).toEqual(['person', 'company', 'organization', 'entity']);
|
|
});
|
|
|
|
// CJK — engine-backed tests
|
|
test('CJK entity with 2-char title enters gazetteer with char-level tokens', async () => {
|
|
await engine.putPage('people/naval', {
|
|
type: 'person', title: '纳瓦尔', compiled_truth: 'b', timeline: '', frontmatter: {},
|
|
});
|
|
const g = await buildGazetteer(engine);
|
|
// "纳瓦尔" tokenized as ["纳","瓦","尔"] → key is "纳"
|
|
expect(g.has('纳')).toBe(true);
|
|
const bucket = g.get('纳')!;
|
|
expect(bucket.length).toBe(1);
|
|
expect(bucket[0]!.tokens).toEqual(['纳', '瓦', '尔']);
|
|
expect(bucket[0]!.slug).toBe('people/naval');
|
|
});
|
|
|
|
test('CJK single-char title (cjkCharCount < 2) excluded from gazetteer', async () => {
|
|
await engine.putPage('people/x', {
|
|
type: 'person', title: '谢', compiled_truth: 'b', timeline: '', frontmatter: {},
|
|
});
|
|
const g = await buildGazetteer(engine);
|
|
expect(g.size).toBe(0);
|
|
});
|
|
});
|