learning_ai_notes/backend/src/lib/note-hooks.ts

144 lines
4.6 KiB
TypeScript

/**
* Note lifecycle hooks — background AI enrichment triggered after save.
*
* Runs non-blocking (fire-and-forget) so note save is never delayed.
* Gated behind feature flags.
*/
import { isFeatureEnabled } from './feature-flags.js';
import { embedText, stripHtmlForEmbedding } from './embeddings.js';
import { llm } from './llm.js';
import { trackEvent } from './telemetry.js';
import type { NoteDoc } from '../modules/notes/types.js';
import { getCollection } from './datastore.js';
import { onNoteSavedToPalace } from '../modules/palace/palace-hooks.js';
import type { FastifyBaseLogger } from 'fastify';
const MIN_WORDS_FOR_SUMMARY = 300;
/**
* Run after a note is created or updated.
* Triggers background embedding + auto-summarize if enabled.
*/
export function runPostSaveHooks(
note: NoteDoc,
log: FastifyBaseLogger,
workspaceName?: string,
): void {
// Fire-and-forget — errors are logged, never thrown
void backgroundEmbed(note, log);
void backgroundAutoSummarize(note, log);
void onNoteSavedToPalace(note, workspaceName ?? 'Untitled', log);
}
/**
* Compute and store embedding vector for the note.
*/
async function backgroundEmbed(
note: NoteDoc,
log: FastifyBaseLogger,
): Promise<void> {
if (!isFeatureEnabled('notelett_auto_embed_enabled')) return;
try {
const plainText = stripHtmlForEmbedding(note.body ?? '');
if (plainText.length < 20) return; // Too short to embed meaningfully
const embedding = await embedText(plainText);
if (!embedding) return;
// Update the note document with the embedding (don't overwrite other fields)
const col = getCollection<NoteDoc>('notes', '/workspaceId');
const existing = await col.findById(note.id, note.workspaceId);
if (!existing) return;
await col.upsert({ ...existing, embedding });
log.debug({ noteId: note.id }, 'note embedding computed');
} catch (err) {
log.warn({ noteId: note.id, err }, 'background embed failed');
}
}
/**
* Auto-generate a summary artifact for long notes.
*/
async function backgroundAutoSummarize(
note: NoteDoc,
log: FastifyBaseLogger,
): Promise<void> {
if (!isFeatureEnabled('notelett_auto_summarize_enabled')) return;
try {
const plainText = stripHtmlForEmbedding(note.body ?? '');
const wordCount = plainText.split(/\s+/).filter(Boolean).length;
if (wordCount < MIN_WORDS_FOR_SUMMARY) return;
// Skip if already has a summary
if (note.summaryArtifactId) return;
const provider = llm();
const result = await provider.chatCompletion({
messages: [
{ role: 'system', content: 'Create a concise summary (2-4 sentences) of the following note. Return only the summary.' },
{ role: 'user', content: plainText.slice(0, 8000) },
],
temperature: 0.3,
maxTokens: 512,
});
const summary = result.content.trim();
if (!summary) return;
// Store as artifact
const { createNoteArtifact } = await import('../modules/note-artifacts/repository.js');
const now = new Date().toISOString();
const artifact = await createNoteArtifact({
id: `summary-${note.id}-${Date.now()}`,
productId: note.productId,
workspaceId: note.workspaceId,
userId: note.userId,
noteId: note.id,
artifactType: 'summary',
title: `Auto-summary of ${note.title}`,
description: summary,
createdAt: now,
updatedAt: now,
createdBy: 'system',
updatedBy: 'system',
});
// Link artifact back to note
const col = getCollection<NoteDoc>('notes', '/workspaceId');
const existing = await col.findById(note.id, note.workspaceId);
if (existing) {
await col.upsert({ ...existing, summaryArtifactId: artifact.id });
}
// Record agent action
const { createNoteAgentAction } = await import('../modules/note-agent-actions/repository.js');
await createNoteAgentAction({
id: `auto-summary-${note.id}-${Date.now()}`,
productId: note.productId,
workspaceId: note.workspaceId,
userId: note.userId,
noteId: note.id,
actorId: 'system',
actorType: 'agent',
toolName: 'auto_summarize',
actionType: 'auto_enrich',
state: 'applied',
reason: `Auto-generated summary for note with ${wordCount} words`,
afterSummary: summary.slice(0, 200),
createdAt: now,
updatedAt: now,
createdBy: 'system',
updatedBy: 'system',
});
trackEvent('auto_summarize_triggered', note.userId, { wordCount: String(wordCount), noteId: note.id });
log.info({ noteId: note.id, artifactId: artifact.id }, 'auto-summary generated');
} catch (err) {
log.warn({ noteId: note.id, err }, 'background auto-summarize failed');
}
}