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:
parent
7ee2151f17
commit
9e3a7206b9
@ -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",
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
36
backend/src/lib/llm.ts
Normal 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();
|
||||
}
|
||||
@ -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;
|
||||
|
||||
293
backend/src/modules/note-prompts/note-prompts.test.ts
Normal file
293
backend/src/modules/note-prompts/note-prompts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
157
backend/src/modules/note-prompts/repository.ts
Normal file
157
backend/src/modules/note-prompts/repository.ts
Normal 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);
|
||||
}
|
||||
124
backend/src/modules/note-prompts/routes.ts
Normal file
124
backend/src/modules/note-prompts/routes.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
75
backend/src/modules/note-prompts/runner.ts
Normal file
75
backend/src/modules/note-prompts/runner.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
246
backend/src/modules/note-prompts/seed.ts
Normal file
246
backend/src/modules/note-prompts/seed.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
99
backend/src/modules/note-prompts/types.ts
Normal file
99
backend/src/modules/note-prompts/types.ts
Normal 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>;
|
||||
@ -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
25
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user