144 lines
4.6 KiB
TypeScript
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');
|
|
}
|
|
}
|