diff --git a/backend/package.json b/backend/package.json index 6288321..d26e545 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/lib/config.ts b/backend/src/lib/config.ts index 1b517e7..ad3479a 100644 --- a/backend/src/lib/config.ts +++ b/backend/src/lib/config.ts @@ -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'), diff --git a/backend/src/lib/copilot-transform.ts b/backend/src/lib/copilot-transform.ts index 6d70fb2..4f1ff83 100644 --- a/backend/src/lib/copilot-transform.ts +++ b/backend/src/lib/copilot-transform.ts @@ -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 = { + 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 { - 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 { 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 } diff --git a/backend/src/lib/cosmos-init.ts b/backend/src/lib/cosmos-init.ts index d4fa9d3..c2dab41 100644 --- a/backend/src/lib/cosmos-init.ts +++ b/backend/src/lib/cosmos-init.ts @@ -10,6 +10,7 @@ const CONTAINER_DEFS: Record = { note_artifacts: { partitionKeyPath: '/workspaceId' }, note_agent_actions: { partitionKeyPath: '/workspaceId' }, saved_views: { partitionKeyPath: '/userId' }, + note_prompts: { partitionKeyPath: '/userId' }, }; export async function initCosmosIfNeeded(): Promise { diff --git a/backend/src/lib/llm.ts b/backend/src/lib/llm.ts new file mode 100644 index 0000000..5d23ccb --- /dev/null +++ b/backend/src/lib/llm.ts @@ -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(); +} diff --git a/backend/src/modules/note-agent-actions/types.ts b/backend/src/modules/note-agent-actions/types.ts index 33ab913..3325e17 100644 --- a/backend/src/modules/note-agent-actions/types.ts +++ b/backend/src/modules/note-agent-actions/types.ts @@ -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; diff --git a/backend/src/modules/note-prompts/note-prompts.test.ts b/backend/src/modules/note-prompts/note-prompts.test.ts new file mode 100644 index 0000000..5477f44 --- /dev/null +++ b/backend/src/modules/note-prompts/note-prompts.test.ts @@ -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); + }); +}); diff --git a/backend/src/modules/note-prompts/repository.ts b/backend/src/modules/note-prompts/repository.ts new file mode 100644 index 0000000..8fa64cc --- /dev/null +++ b/backend/src/modules/note-prompts/repository.ts @@ -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(COLLECTION, PARTITION_KEY); +} + +export async function createPromptTemplate( + userId: string, + input: CreatePromptTemplateInput, +): Promise { + 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 { + return col().findById(id, userId); +} + +export async function getPromptTemplateBySlug( + slug: string, + userId: string, +): Promise { + // 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 { + 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 { + 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, +): Promise { + const now = new Date().toISOString(); + const doc: PromptTemplateDoc = { + ...template, + createdAt: now, + updatedAt: now, + }; + return col().upsert(doc); +} diff --git a/backend/src/modules/note-prompts/routes.ts b/backend/src/modules/note-prompts/routes.ts new file mode 100644 index 0000000..73a33c7 --- /dev/null +++ b/backend/src/modules/note-prompts/routes.ts @@ -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 { + // ── 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 }; + }); +} diff --git a/backend/src/modules/note-prompts/runner.ts b/backend/src/modules/note-prompts/runner.ts new file mode 100644 index 0000000..067c1f8 --- /dev/null +++ b/backend/src/modules/note-prompts/runner.ts @@ -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 { + 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 { + const provider = llm(); + + // Build variables map + const vars: Record = { + ...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, + }; +} diff --git a/backend/src/modules/note-prompts/seed.ts b/backend/src/modules/note-prompts/seed.ts new file mode 100644 index 0000000..fffaeed --- /dev/null +++ b/backend/src/modules/note-prompts/seed.ts @@ -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; + +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[] { + return TEMPLATES.map((t) => ({ + ...t, + id: `builtin-${t.slug}`, + productId: PRODUCT_ID, + userId: BUILTIN_USER, + isBuiltin: true, + })); +} diff --git a/backend/src/modules/note-prompts/types.ts b/backend/src/modules/note-prompts/types.ts new file mode 100644 index 0000000..f2de512 --- /dev/null +++ b/backend/src/modules/note-prompts/types.ts @@ -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; + +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; + +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; + +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; diff --git a/backend/src/server.ts b/backend/src/server.ts index b57ec69..51aca15 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8595cb..ae0e7d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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