feat(backend): add note-prompts module with Smart Actions LLM integration

- Add @bytelyst/llm dependency (file: ref) + llm.ts singleton wrapper
- Add LLM env vars to config (LLM_PROVIDER, LLM_DEFAULT_MODEL, LLM_VISION_MODEL, LLM_EMBEDDING_MODEL)
- Create note-prompts module: types, repository, runner, routes, seed (20 built-in templates)
- Built-in templates: 8 transform, 3 extract, 3 generate, 2 analyze, 2 vision, 2 export
- Prompt runner supports text, image, and text+image inputs via @bytelyst/llm vision
- Upgrade copilot-transform.ts to use @bytelyst/llm directly (with local heuristic fallback)
- Add reading-time endpoint (GET /api/notes/:id/reading-time)
- Extend agent-action types with smart_action and auto_enrich
- Add note_prompts Cosmos container to cosmos-init
- Register notePromptRoutes in server.ts
- 15 new tests (CRUD, run, slug resolution, seed validation, reading-time)
This commit is contained in:
saravanakumardb1 2026-04-06 08:01:12 -07:00
parent 7ee2151f17
commit 9e3a7206b9
14 changed files with 1101 additions and 26 deletions

View File

@ -28,6 +28,7 @@
"@bytelyst/fastify-auth": "^0.1.0",
"@bytelyst/fastify-core": "^0.1.0",
"@bytelyst/field-encrypt": "^0.1.0",
"@bytelyst/llm": "file:../../learning_ai_common_plat/packages/llm",
"@bytelyst/logger": "^0.1.0",
"fastify": "5.7.4",
"jose": "^6.0.8",

View File

@ -14,6 +14,11 @@ const envSchema = baseBackendConfigSchema.extend({
MCP_SERVER_URL: z.string().default('http://localhost:4007'),
TELEMETRY_ENABLED: z.coerce.boolean().default(false),
FEATURE_FLAGS_ENABLED: z.coerce.boolean().default(false),
// ── LLM (@bytelyst/llm) ──
LLM_PROVIDER: z.enum(['azure', 'openai', 'mock']).default('mock'),
LLM_DEFAULT_MODEL: z.string().default('gpt-4o-mini'),
LLM_VISION_MODEL: z.string().default('gpt-4o'),
LLM_EMBEDDING_MODEL: z.string().default('text-embedding-3-small'),
// ── Field Encryption (@bytelyst/field-encrypt) ──
FIELD_ENCRYPT_ENABLED: z.coerce.boolean().default(true),
FIELD_ENCRYPT_KEY_PROVIDER: z.enum(['akv', 'env', 'memory']).default('memory'),

View File

@ -1,7 +1,20 @@
import { extractFromText } from './extraction-client.js';
/**
* Copilot text transforms powered by @bytelyst/llm.
*
* Falls back to local heuristics if LLM is unavailable.
*/
import { llm } from './llm.js';
export type CopilotAction = 'shorten' | 'expand' | 'bulletize' | 'grammar';
const SYSTEM_PROMPTS: Record<CopilotAction, string> = {
shorten: 'Condense the text to about half its length while preserving key points. Return only the shortened text.',
expand: 'Expand the text with more detail and examples. Return only the expanded text.',
bulletize: 'Convert the text into concise bullet points. Return only the bullet points.',
grammar: 'Fix grammar, spelling, and punctuation. Preserve original meaning and tone. Return only the corrected text.',
};
function fallbackTransform(action: CopilotAction, text: string): string {
const lines = text.split(/\n/).map((l) => l.trim()).filter(Boolean);
switch (action) {
@ -21,30 +34,46 @@ function fallbackTransform(action: CopilotAction, text: string): string {
}
export async function runCopilotTransform(action: CopilotAction, text: string): Promise<string> {
const prompt = `Transform the following text with action "${action}". Return only the transformed text, no preamble.\n\n---\n${text}`;
const provider = llm();
if (!provider.isConfigured()) {
return fallbackTransform(action, text);
}
try {
const result = await extractFromText(prompt, 'copilot_transform');
const out = result.summary?.trim();
if (out && out.length > 0) {
return out;
}
const result = await provider.chatCompletion({
messages: [
{ role: 'system', content: SYSTEM_PROMPTS[action] },
{ role: 'user', content: text },
],
temperature: 0.3,
maxTokens: 4096,
});
const out = result.content.trim();
if (out.length > 0) return out;
} catch {
// fall through
// fall through to local heuristics
}
return fallbackTransform(action, text);
}
export async function suggestTitleFromBody(body: string): Promise<string> {
const plain = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
const provider = llm();
if (!provider.isConfigured()) {
return plain.split(/[.!?]/)[0]?.trim().slice(0, 80) || 'Untitled note';
}
try {
const result = await extractFromText(
`Propose a short note title (max 8 words) for this content. Reply with the title only.\n\n${plain.slice(0, 4000)}`,
'title_suggestion',
);
const t = result.summary?.trim();
if (t && t.length > 0 && t.length < 500) {
return t;
}
const result = await provider.chatCompletion({
messages: [
{ role: 'system', content: 'Suggest a concise, descriptive title (max 8 words). Return only the title, no quotes.' },
{ role: 'user', content: plain.slice(0, 4000) },
],
temperature: 0.6,
maxTokens: 64,
});
const t = result.content.trim();
if (t.length > 0 && t.length < 500) return t;
} catch {
// fall through
}

View File

@ -10,6 +10,7 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
note_artifacts: { partitionKeyPath: '/workspaceId' },
note_agent_actions: { partitionKeyPath: '/workspaceId' },
saved_views: { partitionKeyPath: '/userId' },
note_prompts: { partitionKeyPath: '/userId' },
};
export async function initCosmosIfNeeded(): Promise<void> {

36
backend/src/lib/llm.ts Normal file
View File

@ -0,0 +1,36 @@
/**
* LLM singleton for NoteLett backend.
*
* Wraps @bytelyst/llm with lazy initialization.
* Provider is auto-detected from env vars (LLM_PROVIDER, OPENAI_API_KEY, etc.).
*/
import { getLLM, createLLMProvider, setLLM } from '@bytelyst/llm';
import type { LLMProvider } from '@bytelyst/llm';
let initialized = false;
/**
* Initialize the LLM provider singleton.
* Safe to call multiple times only initializes once.
*/
export function initLLM(): LLMProvider {
if (!initialized) {
const providerType = (process.env.LLM_PROVIDER || 'mock') as 'azure' | 'openai' | 'mock';
const provider = createLLMProvider(providerType);
setLLM(provider);
initialized = true;
}
return getLLM();
}
/**
* Get the initialized LLM provider.
* Calls initLLM() if not yet initialized.
*/
export function llm(): LLMProvider {
if (!initialized) {
return initLLM();
}
return getLLM();
}

View File

@ -1,6 +1,6 @@
import { z } from 'zod';
export const NOTE_AGENT_ACTION_TYPES = ['create', 'update', 'summarize', 'extract_tasks', 'attach_citation'] as const;
export const NOTE_AGENT_ACTION_TYPES = ['create', 'update', 'summarize', 'extract_tasks', 'attach_citation', 'smart_action', 'auto_enrich'] as const;
export type NoteAgentActionType = (typeof NOTE_AGENT_ACTION_TYPES)[number];
export const NOTE_AGENT_ACTION_STATES = ['draft', 'proposed', 'approved', 'rejected', 'applied'] as const;

View File

@ -0,0 +1,293 @@
/**
* Tests for note-prompts module CRUD + run + seed + reading-time.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { FastifyInstance } from 'fastify';
const { extractAuthMock } = vi.hoisted(() => ({
extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access', role: 'editor' })),
}));
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock }));
vi.mock('../../lib/product-config.js', () => ({
PRODUCT_ID: 'notelett',
DISPLAY_NAME: 'NoteLett',
productConfig: { productId: 'notelett', displayName: 'NoteLett' },
}));
vi.mock('../../lib/request-context.js', () => ({
getUserId: vi.fn(() => 'user_1'),
getRequestProductId: vi.fn(() => 'notelett'),
}));
vi.mock('../../lib/telemetry.js', () => ({ trackEvent: vi.fn() }));
vi.mock('../../lib/feature-flags.js', () => ({ isFeatureEnabled: vi.fn(() => true) }));
vi.mock('../../lib/field-encrypt.js', () => ({
initEncryption: vi.fn(),
getEncryptor: vi.fn(() => ({
encrypt: vi.fn(async (v: unknown) => v),
decrypt: vi.fn(async (v: unknown) => v),
})),
}));
vi.mock('../../lib/llm.js', () => ({
llm: vi.fn(() => ({
isConfigured: () => true,
chatCompletion: vi.fn(async () => ({
content: 'Mock LLM response',
model: 'mock-model',
usage: { promptTokens: 10, completionTokens: 10, totalTokens: 20 },
finishReason: 'stop',
})),
})),
initLLM: vi.fn(),
}));
vi.mock('@bytelyst/llm', () => ({
getLLM: vi.fn(),
setLLM: vi.fn(),
createLLMProvider: vi.fn(),
buildVisionMessage: vi.fn((text: string, url: string) => ({
role: 'user',
content: [{ type: 'text', text }, { type: 'image_url', image_url: { url, detail: 'auto' } }],
})),
hasVisionContent: vi.fn(() => false),
isVisionMessage: vi.fn(() => false),
getMessageText: vi.fn((msg: { content: string }) => typeof msg.content === 'string' ? msg.content : ''),
}));
import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js';
import { notePromptRoutes } from './routes.js';
import { noteRoutes } from '../notes/routes.js';
import { getBuiltinTemplates } from './seed.js';
import { upsertBuiltinTemplate } from './repository.js';
let app: FastifyInstance;
beforeAll(async () => {
app = await buildTestApp(async (fastify) => {
await noteRoutes(fastify);
await notePromptRoutes(fastify);
});
});
afterAll(async () => {
await app.close();
});
async function seedBuiltins() {
for (const t of getBuiltinTemplates()) {
await upsertBuiltinTemplate(t);
}
}
describe('note-prompts CRUD', () => {
beforeEach(() => {
resetMemoryDatastore();
});
it('GET /note-prompts — lists builtins', async () => {
await seedBuiltins();
const res = await app.inject({ method: 'GET', url: '/api/note-prompts' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.items.length).toBeGreaterThan(0);
expect(body.items.some((t: { isBuiltin: boolean }) => t.isBuiltin)).toBe(true);
});
it('GET /note-prompts?category=transform — filters by category', async () => {
await seedBuiltins();
const res = await app.inject({ method: 'GET', url: '/api/note-prompts?category=transform' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.items.every((t: { category: string }) => t.category === 'transform')).toBe(true);
});
it('POST /note-prompts — creates custom template', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/note-prompts',
payload: {
slug: 'my-custom-prompt',
name: 'My Custom Prompt',
systemPrompt: 'You are a helpful assistant.',
userPromptTemplate: 'Do something with: {{noteBody}}',
category: 'transform',
inputType: 'text',
outputType: 'replace',
},
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.slug).toBe('my-custom-prompt');
expect(body.isBuiltin).toBe(false);
expect(body.productId).toBe('notelett');
});
it('PATCH + DELETE custom template lifecycle', async () => {
// create
const createRes = await app.inject({
method: 'POST',
url: '/api/note-prompts',
payload: {
slug: 'lifecycle-test',
name: 'Lifecycle',
systemPrompt: 'sys',
userPromptTemplate: '{{noteBody}}',
},
});
const id = createRes.json().id;
// update
const patchRes = await app.inject({
method: 'PATCH',
url: `/api/note-prompts/${id}`,
payload: { name: 'Updated Name' },
});
expect(patchRes.statusCode).toBe(200);
expect(patchRes.json().name).toBe('Updated Name');
// delete
const delRes = await app.inject({ method: 'DELETE', url: `/api/note-prompts/${id}` });
expect(delRes.statusCode).toBe(204);
});
it('PATCH builtin returns 404', async () => {
await seedBuiltins();
const res = await app.inject({
method: 'PATCH',
url: '/api/note-prompts/builtin-summarize',
payload: { name: 'Hacked' },
});
expect(res.statusCode).toBe(404);
});
it('DELETE builtin returns 404', async () => {
await seedBuiltins();
const res = await app.inject({ method: 'DELETE', url: '/api/note-prompts/builtin-summarize' });
expect(res.statusCode).toBe(404);
});
});
describe('note-prompts run', () => {
beforeEach(() => {
resetMemoryDatastore();
});
it('POST /note-prompts/run — runs builtin template', async () => {
await seedBuiltins();
// Create a note first
const noteRes = await app.inject({
method: 'POST',
url: '/api/notes',
payload: { id: 'n1', workspaceId: 'ws1', title: 'Test', body: 'Test content about AI.' },
});
expect(noteRes.statusCode).toBe(201);
const res = await app.inject({
method: 'POST',
url: '/api/note-prompts/run',
payload: { templateId: 'builtin-summarize', noteId: 'n1', workspaceId: 'ws1' },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.content).toBeTruthy();
expect(body.templateSlug).toBe('summarize');
expect(body.outputType).toBe('new_note');
expect(body.usage).toBeDefined();
});
it('POST /note-prompts/run — resolves by slug', async () => {
await seedBuiltins();
await app.inject({
method: 'POST',
url: '/api/notes',
payload: { id: 'n2', workspaceId: 'ws2', title: 'Test', body: 'Bullet content.' },
});
const res = await app.inject({
method: 'POST',
url: '/api/note-prompts/run',
payload: { templateId: 'bulletize', noteId: 'n2', workspaceId: 'ws2' },
});
expect(res.statusCode).toBe(200);
expect(res.json().templateSlug).toBe('bulletize');
});
it('POST /note-prompts/run — 404 for unknown template', async () => {
await app.inject({
method: 'POST',
url: '/api/notes',
payload: { id: 'n3', workspaceId: 'ws3', title: 'T', body: 'B' },
});
const res = await app.inject({
method: 'POST',
url: '/api/note-prompts/run',
payload: { templateId: 'nonexistent', noteId: 'n3', workspaceId: 'ws3' },
});
expect(res.statusCode).toBe(404);
});
it('POST /note-prompts/run — 404 for unknown note', async () => {
await seedBuiltins();
const res = await app.inject({
method: 'POST',
url: '/api/note-prompts/run',
payload: { templateId: 'builtin-summarize', noteId: 'nonexistent', workspaceId: 'ws-x' },
});
expect(res.statusCode).toBe(404);
});
});
describe('reading-time', () => {
beforeEach(() => {
resetMemoryDatastore();
});
it('GET /notes/:id/reading-time — returns word count and reading time', async () => {
await app.inject({
method: 'POST',
url: '/api/notes',
payload: { id: 'rt1', workspaceId: 'ws-rt', title: 'Long', body: Array(500).fill('word').join(' ') },
});
const res = await app.inject({
method: 'GET',
url: '/api/notes/rt1/reading-time?workspaceId=ws-rt',
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.wordCount).toBe(500);
expect(body.readingTimeMinutes).toBe(3); // ceil(500/238)
});
it('GET /notes/:id/reading-time — 400 without workspaceId', async () => {
await app.inject({
method: 'POST',
url: '/api/notes',
payload: { id: 'rt2', workspaceId: 'ws-rt2', title: 'X', body: 'Y' },
});
const res = await app.inject({ method: 'GET', url: '/api/notes/rt2/reading-time' });
expect(res.statusCode).toBe(400);
});
});
describe('seed', () => {
it('getBuiltinTemplates returns 20 templates', () => {
const templates = getBuiltinTemplates();
expect(templates.length).toBe(20);
expect(templates.every((t) => t.isBuiltin)).toBe(true);
expect(templates.every((t) => t.id.startsWith('builtin-'))).toBe(true);
});
it('all slugs are unique', () => {
const templates = getBuiltinTemplates();
const slugs = templates.map((t) => t.slug);
expect(new Set(slugs).size).toBe(slugs.length);
});
it('all categories are valid', () => {
const validCategories = ['transform', 'extract', 'generate', 'analyze', 'export'];
const templates = getBuiltinTemplates();
expect(templates.every((t) => validCategories.includes(t.category))).toBe(true);
});
});

View File

@ -0,0 +1,157 @@
/**
* Note-prompts repository CRUD for PromptTemplateDoc.
*
* Uses @bytelyst/datastore DocumentCollection API:
* findById, findMany, create, upsert, delete, count
*/
import { getCollection } from '../../lib/datastore.js';
import { PRODUCT_ID } from '../../lib/product-config.js';
import type { FilterMap } from '@bytelyst/datastore';
import type {
PromptTemplateDoc,
CreatePromptTemplateInput,
UpdatePromptTemplateInput,
ListPromptTemplatesQuery,
} from './types.js';
const COLLECTION = 'note_prompts';
const PARTITION_KEY = '/userId';
function col() {
return getCollection<PromptTemplateDoc>(COLLECTION, PARTITION_KEY);
}
export async function createPromptTemplate(
userId: string,
input: CreatePromptTemplateInput,
): Promise<PromptTemplateDoc> {
const now = new Date().toISOString();
const doc: PromptTemplateDoc = {
id: `${input.slug}-${userId}-${Date.now()}`,
productId: PRODUCT_ID,
userId,
slug: input.slug,
name: input.name,
description: input.description ?? '',
systemPrompt: input.systemPrompt,
userPromptTemplate: input.userPromptTemplate,
inputType: input.inputType ?? 'text',
outputType: input.outputType ?? 'new_note',
category: input.category ?? 'transform',
isBuiltin: false,
model: input.model,
temperature: input.temperature,
maxTokens: input.maxTokens,
createdAt: now,
updatedAt: now,
};
return col().create(doc);
}
export async function getPromptTemplate(
id: string,
userId: string,
): Promise<PromptTemplateDoc | null> {
return col().findById(id, userId);
}
export async function getPromptTemplateBySlug(
slug: string,
userId: string,
): Promise<PromptTemplateDoc | null> {
// Find by slug — check user templates first, then builtins
const userResults = await col().findMany({
filter: { slug, userId, productId: PRODUCT_ID } as FilterMap,
limit: 1,
});
if (userResults.length > 0) return userResults[0];
// Check builtins (userId = '__builtin__')
const builtinResults = await col().findMany({
filter: { slug, userId: '__builtin__', productId: PRODUCT_ID, isBuiltin: true } as FilterMap,
limit: 1,
});
return builtinResults[0] ?? null;
}
export async function updatePromptTemplate(
id: string,
userId: string,
input: UpdatePromptTemplateInput,
): Promise<PromptTemplateDoc | null> {
const existing = await col().findById(id, userId);
if (!existing || existing.isBuiltin) return null;
const updated: PromptTemplateDoc = {
...existing,
...input,
updatedAt: new Date().toISOString(),
};
return col().upsert(updated);
}
export async function deletePromptTemplate(
id: string,
userId: string,
): Promise<boolean> {
const existing = await col().findById(id, userId);
if (!existing || existing.isBuiltin) return false;
await col().delete(id, userId);
return true;
}
export async function listPromptTemplates(
userId: string,
query: ListPromptTemplatesQuery,
): Promise<{ items: PromptTemplateDoc[]; total: number }> {
// Get user templates
const userFilter: FilterMap = { userId, productId: PRODUCT_ID };
if (query.category) userFilter.category = query.category;
const userItems = await col().findMany({
filter: userFilter,
sort: { name: 1 },
limit: query.limit + query.offset,
});
// Get builtins if requested
let builtinItems: PromptTemplateDoc[] = [];
if (query.includeBuiltin) {
const builtinFilter: FilterMap = {
userId: '__builtin__',
productId: PRODUCT_ID,
isBuiltin: true,
};
if (query.category) builtinFilter.category = query.category;
builtinItems = await col().findMany({
filter: builtinFilter,
sort: { name: 1 },
limit: 100,
});
}
// Merge: builtins first, then user
const all = [...builtinItems, ...userItems];
return {
items: all.slice(query.offset, query.offset + query.limit),
total: all.length,
};
}
/**
* Upsert a built-in prompt template (used by seed).
* Uses slug as the id for built-ins so they're idempotent.
*/
export async function upsertBuiltinTemplate(
template: Omit<PromptTemplateDoc, 'createdAt' | 'updatedAt' | '_ts' | '_etag'>,
): Promise<PromptTemplateDoc> {
const now = new Date().toISOString();
const doc: PromptTemplateDoc = {
...template,
createdAt: now,
updatedAt: now,
};
return col().upsert(doc);
}

View File

@ -0,0 +1,124 @@
/**
* Note-prompts routes CRUD + run prompt templates.
*/
import type { FastifyInstance } from 'fastify';
import { getUserId, getRequestProductId } from '../../lib/request-context.js';
import { BadRequestError, NotFoundError } from '@bytelyst/errors';
import {
CreatePromptTemplateSchema,
UpdatePromptTemplateSchema,
ListPromptTemplatesQuerySchema,
RunPromptSchema,
} from './types.js';
import * as repo from './repository.js';
import * as noteRepo from '../notes/repository.js';
import { executePrompt } from './runner.js';
export async function notePromptRoutes(app: FastifyInstance): Promise<void> {
// ── List prompt templates ───────────────────────────────────────
app.get('/note-prompts', async (req) => {
const userId = getUserId(req);
const query = ListPromptTemplatesQuerySchema.parse(req.query);
return repo.listPromptTemplates(userId, query);
});
// ── Get single prompt template ──────────────────────────────────
app.get('/note-prompts/:id', async (req) => {
const userId = getUserId(req);
const { id } = req.params as { id: string };
const template = await repo.getPromptTemplate(id, userId);
if (!template) {
// Try builtin partition
const builtin = await repo.getPromptTemplate(id, '__builtin__');
if (!builtin) throw new NotFoundError('Prompt template not found');
return builtin;
}
return template;
});
// ── Create custom prompt template ───────────────────────────────
app.post('/note-prompts', async (req, reply) => {
const userId = getUserId(req);
const input = CreatePromptTemplateSchema.parse(req.body);
const created = await repo.createPromptTemplate(userId, input);
reply.code(201);
return created;
});
// ── Update custom prompt template ───────────────────────────────
app.patch('/note-prompts/:id', async (req) => {
const userId = getUserId(req);
const { id } = req.params as { id: string };
const input = UpdatePromptTemplateSchema.parse(req.body);
const updated = await repo.updatePromptTemplate(id, userId, input);
if (!updated) throw new NotFoundError('Prompt template not found or is built-in');
return updated;
});
// ── Delete custom prompt template ───────────────────────────────
app.delete('/note-prompts/:id', async (req, reply) => {
const userId = getUserId(req);
const { id } = req.params as { id: string };
const deleted = await repo.deletePromptTemplate(id, userId);
if (!deleted) throw new NotFoundError('Prompt template not found or is built-in');
reply.code(204);
});
// ── Run a prompt template against a note ────────────────────────
app.post('/note-prompts/run', async (req) => {
const userId = getUserId(req);
const productId = getRequestProductId(req);
const input = RunPromptSchema.parse(req.body);
// Resolve template — by id or slug
let template = await repo.getPromptTemplate(input.templateId, userId);
if (!template) {
template = await repo.getPromptTemplate(input.templateId, '__builtin__');
}
if (!template) {
template = await repo.getPromptTemplateBySlug(input.templateId, userId);
}
if (!template) throw new NotFoundError('Prompt template not found');
// Validate image requirement
if (
(template.inputType === 'image' || template.inputType === 'text+image') &&
!input.imageUrl
) {
throw new BadRequestError('This prompt requires an image URL');
}
// Get note body
const note = await noteRepo.getNote(input.noteId, input.workspaceId);
if (!note || note.userId !== userId || note.productId !== productId) {
throw new NotFoundError('Note not found');
}
const noteBody = note.body?.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim() ?? '';
const result = await executePrompt(template, input, noteBody);
return result;
});
// ── Reading time estimate ───────────────────────────────────────
app.get('/notes/:id/reading-time', async (req) => {
const userId = getUserId(req);
const productId = getRequestProductId(req);
const { id } = req.params as { id: string };
const { workspaceId } = req.query as { workspaceId: string };
if (!workspaceId) throw new BadRequestError('workspaceId query param required');
const note = await noteRepo.getNote(id, workspaceId);
if (!note || note.userId !== userId || note.productId !== productId) {
throw new NotFoundError('Note not found');
}
const plainText = (note.body ?? '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
const wordCount = plainText.split(/\s+/).filter(Boolean).length;
const readingTimeMinutes = Math.max(1, Math.ceil(wordCount / 238));
return { wordCount, readingTimeMinutes };
});
}

View File

@ -0,0 +1,75 @@
/**
* Prompt runner executes a PromptTemplate against note content via @bytelyst/llm.
*/
import { llm } from '../../lib/llm.js';
import { config } from '../../lib/config.js';
import {
buildVisionMessage,
hasVisionContent,
type ChatMessage,
} from '@bytelyst/llm';
import type { PromptTemplateDoc, RunPromptInput, RunPromptOutput } from './types.js';
/**
* Interpolate {{variable}} placeholders in a template string.
*/
function interpolate(template: string, vars: Record<string, string>): string {
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
}
/**
* Run a prompt template against provided input.
* Handles text-only, image-only, and text+image inputs.
*/
export async function executePrompt(
template: PromptTemplateDoc,
input: RunPromptInput,
noteBody: string,
): Promise<RunPromptOutput> {
const provider = llm();
// Build variables map
const vars: Record<string, string> = {
...input.variables,
noteBody,
noteId: input.noteId,
workspaceId: input.workspaceId,
};
if (input.inputText) vars.inputText = input.inputText;
const userPrompt = interpolate(template.userPromptTemplate, vars);
// Build messages
const messages: ChatMessage[] = [
{ role: 'system', content: template.systemPrompt },
];
if (input.imageUrl && (template.inputType === 'image' || template.inputType === 'text+image')) {
messages.push(buildVisionMessage(userPrompt, input.imageUrl));
} else {
messages.push({ role: 'user', content: userPrompt });
}
// Select model: vision model for image content, custom model, or default
const req = { messages };
let model = template.model || config.LLM_DEFAULT_MODEL;
if (hasVisionContent(req)) {
model = config.LLM_VISION_MODEL;
}
const result = await provider.chatCompletion({
messages,
model,
temperature: template.temperature ?? 0.7,
maxTokens: template.maxTokens ?? 4096,
});
return {
content: result.content,
model: result.model,
usage: result.usage,
templateSlug: template.slug,
outputType: template.outputType,
};
}

View File

@ -0,0 +1,246 @@
/**
* Built-in prompt templates seeded on startup.
*
* These are available to all users and cannot be edited or deleted.
* userId = '__builtin__' is a sentinel for system-owned templates.
*/
import { PRODUCT_ID } from '../../lib/product-config.js';
import type { PromptTemplateDoc } from './types.js';
const BUILTIN_USER = '__builtin__';
type SeedTemplate = Omit<PromptTemplateDoc, 'id' | 'productId' | 'userId' | 'isBuiltin' | 'createdAt' | 'updatedAt' | '_ts' | '_etag'>;
const TEMPLATES: SeedTemplate[] = [
// ── Transform ───────────────────────────────────
{
slug: 'summarize',
name: 'Summarize',
description: 'Create a concise summary of the note',
category: 'transform',
inputType: 'text',
outputType: 'new_note',
systemPrompt: 'You are a concise summarizer. Output a clear, structured summary.',
userPromptTemplate: 'Summarize the following note:\n\n{{noteBody}}',
},
{
slug: 'shorten',
name: 'Shorten',
description: 'Condense the note while keeping key points',
category: 'transform',
inputType: 'text',
outputType: 'replace',
systemPrompt: 'You condense text while preserving meaning. Return only the shortened text.',
userPromptTemplate: 'Shorten this text to about half its length, keeping key points:\n\n{{noteBody}}',
},
{
slug: 'expand',
name: 'Expand',
description: 'Expand the note with more detail',
category: 'transform',
inputType: 'text',
outputType: 'replace',
systemPrompt: 'You expand text with relevant detail and examples. Return the expanded text.',
userPromptTemplate: 'Expand this text with more detail and examples:\n\n{{noteBody}}',
},
{
slug: 'bulletize',
name: 'Bullet Points',
description: 'Convert the note into bullet points',
category: 'transform',
inputType: 'text',
outputType: 'replace',
systemPrompt: 'Convert text into clean bullet points. Return only bullet points.',
userPromptTemplate: 'Convert the following text into concise bullet points:\n\n{{noteBody}}',
},
{
slug: 'fix-grammar',
name: 'Fix Grammar',
description: 'Fix grammar, spelling, and punctuation',
category: 'transform',
inputType: 'text',
outputType: 'replace',
systemPrompt: 'Fix grammar, spelling, and punctuation. Preserve original meaning and tone. Return only the corrected text.',
userPromptTemplate: '{{noteBody}}',
},
{
slug: 'change-tone-formal',
name: 'Make Formal',
description: 'Rewrite in a formal, professional tone',
category: 'transform',
inputType: 'text',
outputType: 'replace',
systemPrompt: 'Rewrite text in a formal, professional tone. Return only the rewritten text.',
userPromptTemplate: 'Rewrite this in a formal, professional tone:\n\n{{noteBody}}',
},
{
slug: 'change-tone-casual',
name: 'Make Casual',
description: 'Rewrite in a casual, friendly tone',
category: 'transform',
inputType: 'text',
outputType: 'replace',
systemPrompt: 'Rewrite text in a casual, friendly tone. Return only the rewritten text.',
userPromptTemplate: 'Rewrite this in a casual, friendly tone:\n\n{{noteBody}}',
},
{
slug: 'translate-spanish',
name: 'Translate to Spanish',
description: 'Translate the note into Spanish',
category: 'transform',
inputType: 'text',
outputType: 'new_note',
systemPrompt: 'You are a translator. Translate to Spanish accurately, preserving formatting.',
userPromptTemplate: 'Translate to Spanish:\n\n{{noteBody}}',
},
// ── Extract ─────────────────────────────────────
{
slug: 'extract-action-items',
name: 'Extract Action Items',
description: 'Extract tasks and action items from the note',
category: 'extract',
inputType: 'text',
outputType: 'artifact',
systemPrompt: 'Extract action items and tasks. Return as a numbered list. Each item should be actionable.',
userPromptTemplate: 'Extract all action items and tasks from this note:\n\n{{noteBody}}',
},
{
slug: 'extract-key-facts',
name: 'Extract Key Facts',
description: 'Extract key facts and data points',
category: 'extract',
inputType: 'text',
outputType: 'artifact',
systemPrompt: 'Extract key facts, statistics, dates, and important data points. Return as a structured list.',
userPromptTemplate: 'Extract key facts and data points from:\n\n{{noteBody}}',
},
{
slug: 'extract-questions',
name: 'Extract Open Questions',
description: 'Identify unanswered questions in the note',
category: 'extract',
inputType: 'text',
outputType: 'artifact',
systemPrompt: 'Identify questions that are raised but not answered. Return as a numbered list.',
userPromptTemplate: 'Identify unanswered questions in this note:\n\n{{noteBody}}',
},
// ── Generate ────────────────────────────────────
{
slug: 'continue-writing',
name: 'Continue Writing',
description: 'Continue writing from where the note ends',
category: 'generate',
inputType: 'text',
outputType: 'inline',
systemPrompt: 'Continue writing in the same style and tone. Output only the continuation, not the original text.',
userPromptTemplate: 'Continue writing from where this text ends:\n\n{{noteBody}}',
temperature: 0.8,
},
{
slug: 'suggest-title',
name: 'Suggest Title',
description: 'Suggest a title for the note',
category: 'generate',
inputType: 'text',
outputType: 'inline',
systemPrompt: 'Suggest a concise, descriptive title (max 8 words). Return only the title, no quotes.',
userPromptTemplate: 'Suggest a title for this note:\n\n{{noteBody}}',
temperature: 0.6,
maxTokens: 64,
},
{
slug: 'generate-outline',
name: 'Generate Outline',
description: 'Create a structured outline from the note',
category: 'generate',
inputType: 'text',
outputType: 'new_note',
systemPrompt: 'Create a structured outline with headers and sub-points. Use markdown formatting.',
userPromptTemplate: 'Create a structured outline from this content:\n\n{{noteBody}}',
},
// ── Analyze ─────────────────────────────────────
{
slug: 'analyze-sentiment',
name: 'Analyze Sentiment',
description: 'Analyze the sentiment and tone of the note',
category: 'analyze',
inputType: 'text',
outputType: 'artifact',
systemPrompt: 'Analyze sentiment and tone. Report: overall sentiment (positive/negative/neutral), confidence, key emotional indicators, tone description.',
userPromptTemplate: 'Analyze the sentiment and tone of this text:\n\n{{noteBody}}',
maxTokens: 512,
},
{
slug: 'reading-level',
name: 'Reading Level',
description: 'Assess the reading difficulty level',
category: 'analyze',
inputType: 'text',
outputType: 'artifact',
systemPrompt: 'Assess reading level. Report: grade level, Flesch-Kincaid estimate, vocabulary complexity, sentence complexity, suggestions to simplify.',
userPromptTemplate: 'Assess the reading level of this text:\n\n{{noteBody}}',
maxTokens: 512,
},
// ── Vision ──────────────────────────────────────
{
slug: 'describe-image',
name: 'Describe Image',
description: 'Generate a text description of an image',
category: 'extract',
inputType: 'image',
outputType: 'new_note',
systemPrompt: 'Describe the image in detail. Include key visual elements, text visible in the image, and overall composition.',
userPromptTemplate: 'Describe this image in detail.',
},
{
slug: 'extract-text-from-image',
name: 'Extract Text from Image',
description: 'OCR — extract visible text from an image',
category: 'extract',
inputType: 'image',
outputType: 'new_note',
systemPrompt: 'Extract all visible text from the image. Preserve formatting where possible. Return only the extracted text.',
userPromptTemplate: 'Extract all text visible in this image.',
},
// ── Export ──────────────────────────────────────
{
slug: 'to-email-draft',
name: 'Draft Email',
description: 'Convert the note into an email draft',
category: 'export',
inputType: 'text',
outputType: 'new_note',
systemPrompt: 'Convert the note into a professional email draft with subject line, greeting, body, and sign-off.',
userPromptTemplate: 'Convert this note into an email draft:\n\n{{noteBody}}',
},
{
slug: 'to-social-post',
name: 'Draft Social Post',
description: 'Convert the note into a social media post',
category: 'export',
inputType: 'text',
outputType: 'new_note',
systemPrompt: 'Convert the note into an engaging social media post. Keep it concise and impactful. Include relevant hashtags.',
userPromptTemplate: 'Convert this note into a social media post:\n\n{{noteBody}}',
maxTokens: 512,
},
];
/**
* Build full PromptTemplateDoc objects from seed data.
*/
export function getBuiltinTemplates(): Omit<PromptTemplateDoc, 'createdAt' | 'updatedAt' | '_ts' | '_etag'>[] {
return TEMPLATES.map((t) => ({
...t,
id: `builtin-${t.slug}`,
productId: PRODUCT_ID,
userId: BUILTIN_USER,
isBuiltin: true,
}));
}

View File

@ -0,0 +1,99 @@
import { z } from 'zod';
// ── Prompt Template ───────────────────────────────────────────────
export const PROMPT_CATEGORIES = ['transform', 'extract', 'generate', 'analyze', 'export'] as const;
export type PromptCategory = (typeof PROMPT_CATEGORIES)[number];
export const PROMPT_INPUT_TYPES = ['text', 'image', 'text+image'] as const;
export type PromptInputType = (typeof PROMPT_INPUT_TYPES)[number];
export const PROMPT_OUTPUT_TYPES = ['replace', 'new_note', 'artifact', 'inline'] as const;
export type PromptOutputType = (typeof PROMPT_OUTPUT_TYPES)[number];
export interface PromptTemplateDoc {
id: string;
productId: string;
userId: string;
slug: string;
name: string;
description: string;
systemPrompt: string;
userPromptTemplate: string;
inputType: PromptInputType;
outputType: PromptOutputType;
category: PromptCategory;
isBuiltin: boolean;
model?: string;
temperature?: number;
maxTokens?: number;
createdAt: string;
updatedAt: string;
_ts?: number;
_etag?: string;
}
// ── Run Prompt ────────────────────────────────────────────────────
export const RunPromptSchema = z.object({
templateId: z.string().min(1).max(128),
noteId: z.string().min(1).max(128),
workspaceId: z.string().min(1).max(128),
inputText: z.string().max(100_000).optional(),
imageUrl: z.string().url().max(4096).optional(),
variables: z.record(z.string()).optional(),
});
export type RunPromptInput = z.infer<typeof RunPromptSchema>;
export interface RunPromptOutput {
content: string;
model: string;
usage: { promptTokens: number; completionTokens: number; totalTokens: number };
templateSlug: string;
outputType: PromptOutputType;
createdNoteId?: string;
createdArtifactId?: string;
}
// ── CRUD Schemas ──────────────────────────────────────────────────
export const CreatePromptTemplateSchema = z.object({
slug: z.string().min(1).max(64).regex(/^[a-z0-9-]+$/),
name: z.string().min(1).max(128),
description: z.string().max(1000).default(''),
systemPrompt: z.string().max(8000),
userPromptTemplate: z.string().max(8000),
inputType: z.enum(PROMPT_INPUT_TYPES).default('text'),
outputType: z.enum(PROMPT_OUTPUT_TYPES).default('new_note'),
category: z.enum(PROMPT_CATEGORIES).default('transform'),
model: z.string().max(128).optional(),
temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().int().min(1).max(128_000).optional(),
});
export type CreatePromptTemplateInput = z.infer<typeof CreatePromptTemplateSchema>;
export const UpdatePromptTemplateSchema = z.object({
name: z.string().min(1).max(128).optional(),
description: z.string().max(1000).optional(),
systemPrompt: z.string().max(8000).optional(),
userPromptTemplate: z.string().max(8000).optional(),
inputType: z.enum(PROMPT_INPUT_TYPES).optional(),
outputType: z.enum(PROMPT_OUTPUT_TYPES).optional(),
category: z.enum(PROMPT_CATEGORIES).optional(),
model: z.string().max(128).optional(),
temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().int().min(1).max(128_000).optional(),
});
export type UpdatePromptTemplateInput = z.infer<typeof UpdatePromptTemplateSchema>;
export const ListPromptTemplatesQuerySchema = z.object({
category: z.enum(PROMPT_CATEGORIES).optional(),
includeBuiltin: z.coerce.boolean().default(true),
limit: z.coerce.number().int().min(1).max(100).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
export type ListPromptTemplatesQuery = z.infer<typeof ListPromptTemplatesQuerySchema>;

View File

@ -9,6 +9,7 @@ import { noteRelationshipRoutes } from './modules/note-relationships/routes.js';
import { noteTaskRoutes } from './modules/note-tasks/routes.js';
import { savedViewRoutes } from './modules/saved-views/routes.js';
import { workspaceRoutes } from './modules/workspaces/routes.js';
import { notePromptRoutes } from './modules/note-prompts/routes.js';
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
import { initEncryption } from './lib/field-encrypt.js';
import { initDatastore } from './lib/datastore.js';
@ -61,6 +62,7 @@ await registerApiPlugin(noteRelationshipRoutes);
await registerApiPlugin(noteTaskRoutes);
await registerApiPlugin(savedViewRoutes);
await registerApiPlugin(workspaceRoutes);
await registerApiPlugin(notePromptRoutes);
// ── Public read-only share (no auth) ───────────────────────────────
app.get('/api/public/note-shares/:token', async (req, reply) => {

25
pnpm-lock.yaml generated
View File

@ -42,8 +42,8 @@ importers:
specifier: ^0.1.0
version: 0.1.0
'@bytelyst/events':
specifier: file:/tmp/bytelyst-events-0.1.0.tgz
version: file:../../../../../tmp/bytelyst-events-0.1.0.tgz(zod@3.25.76)
specifier: ^0.1.0
version: 0.1.0(zod@3.25.76)
'@bytelyst/fastify-auth':
specifier: ^0.1.0
version: 0.1.0(fastify@5.7.4)(jose@6.2.2)
@ -53,6 +53,9 @@ importers:
'@bytelyst/field-encrypt':
specifier: ^0.1.0
version: 0.1.0(@azure/keyvault-keys@4.10.0(@azure/core-client@1.10.1))(zod@3.25.76)
'@bytelyst/llm':
specifier: file:../../learning_ai_common_plat/packages/llm
version: file:../learning_ai_common_plat/packages/llm
'@bytelyst/logger':
specifier: ^0.1.0
version: 0.1.0
@ -972,9 +975,8 @@ packages:
'@bytelyst/errors@0.1.0':
resolution: {integrity: sha512-hE4sHwmQUDGZYDdo3w7VuRdVfuaXgEcG2f0KD0ZLJF+EgfRmDV3IevD1ubPsJIIZxMu8brK8zZOvPohhsMsYdw==, tarball: http://localhost:3300/api/packages/bytelyst/npm/%40bytelyst%2Ferrors/-/0.1.0/errors-0.1.0.tgz}
'@bytelyst/events@file:../../../../../tmp/bytelyst-events-0.1.0.tgz':
resolution: {integrity: sha512-9HmjfrDMmR63UHVbuaruIPEbALWwApcdH/lfeOiF6W+pFpd6FV3yrnLh04gKMzxBOWyGzysH+vrGI7Xnt4PpnQ==, tarball: file:../../../../../tmp/bytelyst-events-0.1.0.tgz}
version: 0.1.0
'@bytelyst/events@0.1.0':
resolution: {integrity: sha512-iiBXWPCoSlzYnvOBdhkLnpOtyp+oz0uIPmeB5UvMBqn6oiZ35NbwqUQnh8xfmsEbOUT6ESPipuwG8k/R5r4Kkw==, tarball: http://localhost:3300/api/packages/bytelyst/npm/%40bytelyst%2Fevents/-/0.1.0/events-0.1.0.tgz}
peerDependencies:
zod: ^3.0.0
@ -1025,6 +1027,9 @@ packages:
'@bytelyst/kill-switch-client@0.1.0':
resolution: {integrity: sha512-XVXltkFVFrE7pbR9J4tVGQA1o+0Jr3YPOz1KiUu7oU9Pkwfty0pmVElIgoChAaNStmhAfhkdMw9ftJDU4WqzJQ==, tarball: http://localhost:3300/api/packages/bytelyst/npm/%40bytelyst%2Fkill-switch-client/-/0.1.0/kill-switch-client-0.1.0.tgz}
'@bytelyst/llm@file:../learning_ai_common_plat/packages/llm':
resolution: {directory: ../learning_ai_common_plat/packages/llm, type: directory}
'@bytelyst/logger@0.1.0':
resolution: {integrity: sha512-Ow7svVI+w5nxaRfS1f0tTRypu+auAznSAtpgTUeLOa60Px162xYk6UGT4DhGTf2ReLZcLrcU5n0qa/FolidzZw==, tarball: http://localhost:3300/api/packages/bytelyst/npm/%40bytelyst%2Flogger/-/0.1.0/logger-0.1.0.tgz}
@ -7171,7 +7176,7 @@ snapshots:
'@bytelyst/errors@0.1.0': {}
'@bytelyst/events@file:../../../../../tmp/bytelyst-events-0.1.0.tgz(zod@3.25.76)':
'@bytelyst/events@0.1.0(zod@3.25.76)':
dependencies:
'@bytelyst/queue': 0.1.0
zod: 3.25.76
@ -7208,6 +7213,8 @@ snapshots:
'@bytelyst/kill-switch-client@0.1.0': {}
'@bytelyst/llm@file:../learning_ai_common_plat/packages/llm': {}
'@bytelyst/logger@0.1.0': {}
'@bytelyst/offline-queue@0.1.0': {}
@ -10119,7 +10126,7 @@ snapshots:
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1))
@ -10152,7 +10159,7 @@ snapshots:
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
@ -10230,7 +10237,7 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9