feat(palace): auto-save hooks — extract memories on note create/update (N4)
This commit is contained in:
parent
d9d0cffc8d
commit
0af5f875bb
@ -11,6 +11,7 @@ import { llm } from './llm.js';
|
|||||||
import { trackEvent } from './telemetry.js';
|
import { trackEvent } from './telemetry.js';
|
||||||
import type { NoteDoc } from '../modules/notes/types.js';
|
import type { NoteDoc } from '../modules/notes/types.js';
|
||||||
import { getCollection } from './datastore.js';
|
import { getCollection } from './datastore.js';
|
||||||
|
import { onNoteSavedToPalace } from '../modules/palace/palace-hooks.js';
|
||||||
import type { FastifyBaseLogger } from 'fastify';
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
|
||||||
const MIN_WORDS_FOR_SUMMARY = 300;
|
const MIN_WORDS_FOR_SUMMARY = 300;
|
||||||
@ -22,10 +23,12 @@ const MIN_WORDS_FOR_SUMMARY = 300;
|
|||||||
export function runPostSaveHooks(
|
export function runPostSaveHooks(
|
||||||
note: NoteDoc,
|
note: NoteDoc,
|
||||||
log: FastifyBaseLogger,
|
log: FastifyBaseLogger,
|
||||||
|
workspaceName?: string,
|
||||||
): void {
|
): void {
|
||||||
// Fire-and-forget — errors are logged, never thrown
|
// Fire-and-forget — errors are logged, never thrown
|
||||||
void backgroundEmbed(note, log);
|
void backgroundEmbed(note, log);
|
||||||
void backgroundAutoSummarize(note, log);
|
void backgroundAutoSummarize(note, log);
|
||||||
|
void onNoteSavedToPalace(note, workspaceName ?? 'Untitled', log);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
129
backend/src/modules/palace/palace-hooks.test.ts
Normal file
129
backend/src/modules/palace/palace-hooks.test.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { resetMemoryDatastore, TEST_USER_ID, TEST_PRODUCT_ID } from '../../test-helpers.js';
|
||||||
|
import { onNoteSavedToPalace } from './palace-hooks.js';
|
||||||
|
import { listWings, listRooms, listMemories } from './repository.js';
|
||||||
|
import type { NoteDoc } from '../notes/types.js';
|
||||||
|
|
||||||
|
const USER_A = TEST_USER_ID;
|
||||||
|
const USER_B = 'test-user-2';
|
||||||
|
const PRODUCT = TEST_PRODUCT_ID;
|
||||||
|
|
||||||
|
const mockLog = {
|
||||||
|
info: () => {},
|
||||||
|
warn: () => {},
|
||||||
|
debug: () => {},
|
||||||
|
error: () => {},
|
||||||
|
fatal: () => {},
|
||||||
|
trace: () => {},
|
||||||
|
child: () => mockLog,
|
||||||
|
} as unknown as import('fastify').FastifyBaseLogger;
|
||||||
|
|
||||||
|
function makeNote(overrides: Partial<NoteDoc> = {}): NoteDoc {
|
||||||
|
return {
|
||||||
|
id: 'note-1',
|
||||||
|
productId: PRODUCT,
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
userId: USER_A,
|
||||||
|
title: 'Test Note',
|
||||||
|
body: 'Decision: Use JWT for auth\nFound: Old sessions are deprecated\nPrefer: Short-lived tokens',
|
||||||
|
status: 'active',
|
||||||
|
tags: [],
|
||||||
|
links: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
createdBy: USER_A,
|
||||||
|
updatedBy: USER_A,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Palace Auto-Save Hooks (N4)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetMemoryDatastore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('note create triggers memory extraction and stores memories', async () => {
|
||||||
|
const note = makeNote();
|
||||||
|
await onNoteSavedToPalace(note, 'Work Notes', mockLog);
|
||||||
|
|
||||||
|
const wings = await listWings(USER_A, PRODUCT);
|
||||||
|
expect(wings.length).toBe(1);
|
||||||
|
expect(wings[0].sourceWorkspaceId).toBe('ws-1');
|
||||||
|
|
||||||
|
const rooms = await listRooms(USER_A, PRODUCT, wings[0].id);
|
||||||
|
expect(rooms.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
const memories = await listMemories(USER_A, PRODUCT, { wingId: wings[0].id });
|
||||||
|
expect(memories.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('duplicate memories are skipped on re-save', async () => {
|
||||||
|
const note = makeNote();
|
||||||
|
|
||||||
|
await onNoteSavedToPalace(note, 'Work', mockLog);
|
||||||
|
const firstCount = (await listMemories(USER_A, PRODUCT, {})).length;
|
||||||
|
|
||||||
|
// Save same note again — duplicates should be skipped
|
||||||
|
await onNoteSavedToPalace(note, 'Work', mockLog);
|
||||||
|
const secondCount = (await listMemories(USER_A, PRODUCT, {})).length;
|
||||||
|
|
||||||
|
expect(secondCount).toBe(firstCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extraction failure does not throw', async () => {
|
||||||
|
const note = makeNote({ body: '' }); // empty body → short-circuit
|
||||||
|
// Should not throw
|
||||||
|
await expect(onNoteSavedToPalace(note, 'Work', mockLog)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wing auto-created from workspace on first save', async () => {
|
||||||
|
const note = makeNote({ workspaceId: 'ws-new' });
|
||||||
|
await onNoteSavedToPalace(note, 'New Workspace', mockLog);
|
||||||
|
|
||||||
|
const wings = await listWings(USER_A, PRODUCT);
|
||||||
|
expect(wings.length).toBe(1);
|
||||||
|
expect(wings[0].name).toBe('New Workspace');
|
||||||
|
expect(wings[0].sourceWorkspaceId).toBe('ws-new');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('memories include sourceNoteId back-reference', async () => {
|
||||||
|
const note = makeNote({ id: 'note-ref-test' });
|
||||||
|
await onNoteSavedToPalace(note, 'Work', mockLog);
|
||||||
|
|
||||||
|
const memories = await listMemories(USER_A, PRODUCT, {});
|
||||||
|
for (const mem of memories) {
|
||||||
|
expect(mem.sourceNoteId).toBe('note-ref-test');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cross-user isolation: user A note save never creates memories for user B', async () => {
|
||||||
|
const noteA = makeNote({ userId: USER_A, workspaceId: 'ws-a' });
|
||||||
|
const noteB = makeNote({ userId: USER_B, workspaceId: 'ws-b', id: 'note-b' });
|
||||||
|
|
||||||
|
await onNoteSavedToPalace(noteA, 'A Workspace', mockLog);
|
||||||
|
await onNoteSavedToPalace(noteB, 'B Workspace', mockLog);
|
||||||
|
|
||||||
|
const memoriesA = await listMemories(USER_A, PRODUCT, {});
|
||||||
|
const memoriesB = await listMemories(USER_B, PRODUCT, {});
|
||||||
|
|
||||||
|
// Each user only has their own memories
|
||||||
|
for (const m of memoriesA) expect(m.userId).toBe(USER_A);
|
||||||
|
for (const m of memoriesB) expect(m.userId).toBe(USER_B);
|
||||||
|
|
||||||
|
// And they don't overlap
|
||||||
|
const wingsA = await listWings(USER_A, PRODUCT);
|
||||||
|
const wingsB = await listWings(USER_B, PRODUCT);
|
||||||
|
expect(wingsA.length).toBe(1);
|
||||||
|
expect(wingsB.length).toBe(1);
|
||||||
|
expect(wingsA[0].sourceWorkspaceId).toBe('ws-a');
|
||||||
|
expect(wingsB[0].sourceWorkspaceId).toBe('ws-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips extraction when content is too short', async () => {
|
||||||
|
const note = makeNote({ body: 'Hi there' }); // < 50 chars after strip
|
||||||
|
await onNoteSavedToPalace(note, 'Work', mockLog);
|
||||||
|
|
||||||
|
const memories = await listMemories(USER_A, PRODUCT, {});
|
||||||
|
expect(memories.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
75
backend/src/modules/palace/palace-hooks.ts
Normal file
75
backend/src/modules/palace/palace-hooks.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Palace auto-save hooks — fire-and-forget memory extraction on note save.
|
||||||
|
*
|
||||||
|
* Called from note-hooks.ts after note create/update.
|
||||||
|
* Gated behind PALACE_ENABLED + PALACE_EXTRACTION_ENABLED config.
|
||||||
|
* Failures are logged, never thrown — note save is never delayed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { config } from '../../lib/config.js';
|
||||||
|
import { embedText, stripHtmlForEmbedding } from '../../lib/embeddings.js';
|
||||||
|
import { trackEvent } from '../../lib/telemetry.js';
|
||||||
|
import { ensureWing, ensureRoom, storeMemory, isNearDuplicate } from './repository.js';
|
||||||
|
import { extractMemories } from './extractor.js';
|
||||||
|
import type { NoteDoc } from '../notes/types.js';
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract and store memories from a note (fire-and-forget).
|
||||||
|
*
|
||||||
|
* Called after note create or update. Best-effort — failures are logged.
|
||||||
|
*/
|
||||||
|
export async function onNoteSavedToPalace(
|
||||||
|
note: NoteDoc,
|
||||||
|
workspaceName: string,
|
||||||
|
log: FastifyBaseLogger,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!config.PALACE_ENABLED) return;
|
||||||
|
if (!config.PALACE_EXTRACTION_ENABLED) return;
|
||||||
|
|
||||||
|
const userId = note.userId;
|
||||||
|
const productId = note.productId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const plainText = stripHtmlForEmbedding(note.body ?? '');
|
||||||
|
if (plainText.length < 50) return;
|
||||||
|
|
||||||
|
const wing = await ensureWing(userId, productId, note.workspaceId, workspaceName);
|
||||||
|
const memories = await extractMemories(note.body, note.title, workspaceName);
|
||||||
|
|
||||||
|
if (memories.length === 0) return;
|
||||||
|
|
||||||
|
let stored = 0;
|
||||||
|
let skippedDup = 0;
|
||||||
|
|
||||||
|
for (const mem of memories) {
|
||||||
|
const room = await ensureRoom(userId, productId, wing.id, mem.roomSlug);
|
||||||
|
const embedding = await embedText(mem.content);
|
||||||
|
const isDup = await isNearDuplicate(
|
||||||
|
userId, productId, room.id, mem.hall, mem.content, embedding,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isDup) {
|
||||||
|
await storeMemory(
|
||||||
|
userId, productId, wing.id, room.id,
|
||||||
|
mem.hall, mem.content, note.id, embedding,
|
||||||
|
);
|
||||||
|
stored++;
|
||||||
|
} else {
|
||||||
|
skippedDup++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stored > 0) {
|
||||||
|
trackEvent('palace.memories_extracted', userId, {
|
||||||
|
noteId: note.id,
|
||||||
|
wingId: wing.id,
|
||||||
|
stored: String(stored),
|
||||||
|
skippedDup: String(skippedDup),
|
||||||
|
});
|
||||||
|
log.info({ noteId: note.id, stored, skippedDup }, 'palace: memories extracted from note');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ noteId: note.id, err }, 'palace: memory extraction failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user