diff --git a/.changelog/next/fixed-issue-4196.md b/.changelog/next/fixed-issue-4196.md new file mode 100644 index 0000000000..f79c2b3e4c --- /dev/null +++ b/.changelog/next/fixed-issue-4196.md @@ -0,0 +1 @@ +- Brain graph views now load lightweight projections, keeping large song sheets out of graph data while preserving node summaries and tags. diff --git a/server/services/brainGraph.js b/server/services/brainGraph.js index e2465db6bc..f6d9824edb 100644 --- a/server/services/brainGraph.js +++ b/server/services/brainGraph.js @@ -15,9 +15,9 @@ * O(n²) tag explosion can't happen. * * The two edge-bearing views need every record's tags and summary, so they go - * through `loadNodes()` (a full read of each entity store). The search index - * needs only `{ id, label, brainType }`, so it reads the cached field - * projections in `brainSearchIndex` instead — focusing the graph's search box + * through `loadNodes()` (the cached field projections for each entity store). + * The search index needs only `{ id, label, brainType }`, so it reads those + * same projections in `brainSearchIndex` instead — focusing the graph's search box * no longer walks and JSON-parses every record body and Daily Log entry on disk * (issue #3507). */ @@ -87,9 +87,9 @@ const journalDate = (entry) => entry.id || entry.date; // Load all non-archived brain entities as graph nodes (no edges). async function loadNodes() { const nodes = []; - for (const type of ENTITY_TYPES) { - const records = await brainStorage.getAll(type); - for (const record of records) { + const perType = await Promise.all(ENTITY_TYPES.map((type) => getBrainProjections(type, { ranked: false }))); + ENTITY_TYPES.forEach((type, i) => { + for (const record of perType[i]) { if (record.archived) continue; nodes.push({ id: record.id, @@ -101,7 +101,7 @@ async function loadNodes() { status: record.status }); } - } + }); // Goals (identity system): active goals only (completed/abandoned excluded) const goalsData = await getGoals().catch(() => null); diff --git a/server/services/brainGraph.test.js b/server/services/brainGraph.test.js index f081c24b47..24b3b898e9 100644 --- a/server/services/brainGraph.test.js +++ b/server/services/brainGraph.test.js @@ -149,6 +149,11 @@ describe('getBrainGraphSearchIndex', () => { content: 'z'.repeat(5000), segments: [{ text: 'z'.repeat(5000) }] }]; + if (type === 'songs') return [{ + id: 's1', + title: 'Example song', + content: { format: 'chordpro', text: 'x'.repeat(5000) } + }]; return []; }); @@ -158,6 +163,9 @@ describe('getBrainGraphSearchIndex', () => { expect(memory.embedding).toBeUndefined(); expect(memory.attachments).toBeUndefined(); + const [song] = await getBrainProjections('songs', { ranked: false }); + expect(song.content).toBeUndefined(); + // The Daily Log body is the biggest record in the brain — the graph only // needs "is this day non-empty?", so the text must never reach the cache. expect(await getBrainProjections('journals', { ranked: false })).toEqual([ @@ -186,6 +194,25 @@ describe('getBrainGraphOverview', () => { expect(result.nodes.map(n => n.label).sort()).toEqual(['Alice', 'Phoenix']); }); + it('keeps projected summaries, tags, and status for edge-bearing views', async () => { + onlyType('people', [{ + id: 'p1', + name: 'Ada Placeholder', + description: 'Example description', + tags: ['example'], + status: 'active' + }]); + + const result = await getBrainGraphOverview({ limit: 100 }); + + expect(result.nodes).toMatchObject([{ + id: 'p1', + summary: 'Example description', + tags: ['example'], + status: 'active' + }]); + }); + it('summarizes a SongBook node with its artist, never its content object (#4105)', async () => { onlyType('songs', [{ id: 's1', diff --git a/server/services/brainSearchIndex.js b/server/services/brainSearchIndex.js index dfb27e6e05..e4ef0c7c5c 100644 --- a/server/services/brainSearchIndex.js +++ b/server/services/brainSearchIndex.js @@ -47,37 +47,50 @@ import { safeDate } from '../lib/fileUtils.js'; * from `capturedText` / `name` / `context` / `title` / `oneLiner` / `notes` * / `nextAction` / `content` / `mood` / `url` / `description`. * - `getBrainGraphSearchIndex` (server/services/brainGraph.js) derives each - * node's label from `name || title` and drops archived records, so the - * graph entity types also project `name`, `title` and `archived`. + * node's label from `name || title` and drops archived records, while the + * edge-bearing graph views also need tags, status, and summary fields. * `journals` is graph-only (the Daily Log is not a unified-search source), and * projects no body — see `journalHasBody` below. `songs` (SongBook) is - * graph-only too, and projects only its label field: the sheet body lives in - * `content.text` (up to 200k chars of tab/ChordPro per song) and nothing here - * renders it, so it must never reach the cache. + * graph-only too, and its sheet body lives in `content.text` (up to 200k + * chars of tab/ChordPro per song). The graph projection keeps only a string + * `content` value, so the SongBook object never reaches the cache. */ +const GRAPH_PROJECTION_FIELDS = Object.freeze([ + 'name', 'title', 'archived', 'tags', 'status', + 'description', 'context', 'oneLiner', 'artist', 'notes' +]); + const PROJECTED_FIELDS = Object.freeze({ inbox: Object.freeze(['capturedText']), - people: Object.freeze(['name', 'title', 'context', 'archived']), - projects: Object.freeze(['name', 'title', 'notes', 'archived']), - ideas: Object.freeze(['name', 'title', 'oneLiner', 'notes', 'archived']), - admin: Object.freeze(['name', 'title', 'notes', 'nextAction', 'archived']), - memories: Object.freeze(['name', 'title', 'content', 'mood', 'archived']), + people: GRAPH_PROJECTION_FIELDS, + projects: GRAPH_PROJECTION_FIELDS, + ideas: GRAPH_PROJECTION_FIELDS, + admin: Object.freeze([...GRAPH_PROJECTION_FIELDS, 'nextAction']), + memories: Object.freeze([...GRAPH_PROJECTION_FIELDS, 'mood']), links: Object.freeze(['title', 'url', 'description']), journals: Object.freeze(['date']), - songs: Object.freeze(['title', 'archived']), + songs: GRAPH_PROJECTION_FIELDS, }); /** * Is this Daily Log entry non-empty? The graph only asks the question — it * never renders the answer's source — and a journal body is the largest record * in the brain, so the predicate is projected and `content`/`segments` are not. - * Exported so `brainGraph`'s full-record path (which still loads bodies, for - * summaries and tags) applies the identical rule. + * Exported so `brainGraph`'s journal path applies the identical rule. */ export const journalHasBody = (record) => !!(record?.content || record?.segments?.length); +const stringContent = (record) => typeof record?.content === 'string' ? record.content : undefined; +const GRAPH_DERIVED_FIELDS = Object.freeze({ content: stringContent }); + // Fields computed from the record rather than copied off it, per type. const DERIVED_FIELDS = Object.freeze({ + people: GRAPH_DERIVED_FIELDS, + projects: GRAPH_DERIVED_FIELDS, + ideas: GRAPH_DERIVED_FIELDS, + admin: GRAPH_DERIVED_FIELDS, + memories: GRAPH_DERIVED_FIELDS, + songs: GRAPH_DERIVED_FIELDS, journals: Object.freeze({ hasBody: journalHasBody }), }); diff --git a/server/services/brainSearchIndex.test.js b/server/services/brainSearchIndex.test.js index 79d3437a33..c2aef31d7f 100644 --- a/server/services/brainSearchIndex.test.js +++ b/server/services/brainSearchIndex.test.js @@ -53,15 +53,47 @@ describe('brainSearchIndex', () => { it('projects only the fields its consumers read', async () => { getAll.mockResolvedValue([ - { id: 'p1', name: 'Ada Placeholder', context: 'colleague', avatarBlob: 'x'.repeat(50), embedding: [1, 2, 3] } + { + id: 'p1', + name: 'Ada Placeholder', + context: 'colleague', + tags: ['example'], + status: 'active', + avatarBlob: 'x'.repeat(50), + embedding: [1, 2, 3] + } ]) const [projection] = await getBrainProjections('people') - expect(projection).toEqual({ id: 'p1', name: 'Ada Placeholder', context: 'colleague' }) + expect(projection).toMatchObject({ + id: 'p1', + name: 'Ada Placeholder', + context: 'colleague', + tags: ['example'], + status: 'active' + }) expect(projection).not.toHaveProperty('avatarBlob') expect(projection).not.toHaveProperty('embedding') }) + it('keeps string content but drops object-shaped content from graph projections', async () => { + getAll.mockImplementation(async (type) => { + if (type === 'memories') return [{ id: 'm1', title: 'Example memory', content: 'searchable text' }] + if (type === 'songs') return [{ + id: 's1', + title: 'Example song', + content: { format: 'chordpro', text: 'large sheet body' } + }] + return [] + }) + + const [memory] = await getBrainProjections('memories', { ranked: false }) + const [song] = await getBrainProjections('songs', { ranked: false }) + + expect(memory.content).toBe('searchable text') + expect(song.content).toBeUndefined() + }) + it('projects a journal entry as a body predicate, never the body', async () => { getAll.mockResolvedValue([ { id: '2026-01-01', content: 'x'.repeat(500), segments: [{ text: 'x' }] },