diff --git a/apps/api/src/personalities/dto/update-personality.dto.ts b/apps/api/src/personalities/dto/update-personality.dto.ts index 1ccd6b0..29a412b 100644 --- a/apps/api/src/personalities/dto/update-personality.dto.ts +++ b/apps/api/src/personalities/dto/update-personality.dto.ts @@ -1,4 +1,38 @@ -import { PartialType } from "@nestjs/mapped-types"; -import { CreatePersonalityDto } from "./create-personality.dto"; +import { IsString, IsOptional, IsBoolean, MinLength, MaxLength, IsIn } from "class-validator"; +import { FORMALITY_LEVELS, FormalityLevelType } from "./create-personality.dto"; -export class UpdatePersonalityDto extends PartialType(CreatePersonalityDto) {} +export class UpdatePersonalityDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + description?: string; + + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(50) + tone?: string; + + @IsOptional() + @IsIn(FORMALITY_LEVELS) + formalityLevel?: FormalityLevelType; + + @IsOptional() + @IsString() + @MinLength(10) + systemPromptTemplate?: string; + + @IsOptional() + @IsBoolean() + isDefault?: boolean; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/api/src/personalities/personalities.controller.spec.ts b/apps/api/src/personalities/personalities.controller.spec.ts index 1092d35..9f58130 100644 --- a/apps/api/src/personalities/personalities.controller.spec.ts +++ b/apps/api/src/personalities/personalities.controller.spec.ts @@ -2,29 +2,24 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { PersonalitiesController } from "./personalities.controller"; import { PersonalitiesService } from "./personalities.service"; +import { PromptFormatterService } from "./services/prompt-formatter.service"; import { AuthGuard } from "../auth/guards/auth.guard"; -import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto"; describe("PersonalitiesController", () => { let controller: PersonalitiesController; let service: PersonalitiesService; + let promptFormatter: PromptFormatterService; const mockWorkspaceId = "workspace-123"; - const mockUserId = "user-123"; const mockPersonalityId = "personality-123"; - - const mockRequest = { - user: { id: mockUserId }, - workspaceId: mockWorkspaceId, - }; + const mockRequest = { user: { id: "user-123" }, workspaceId: mockWorkspaceId }; const mockPersonality = { id: mockPersonalityId, workspaceId: mockWorkspaceId, name: "Professional", - description: "Professional communication style", tone: "professional", - formalityLevel: "FORMAL" as const, + formalityLevel: "FORMAL", systemPromptTemplate: "You are a professional assistant.", isDefault: true, isActive: true, @@ -41,105 +36,82 @@ describe("PersonalitiesController", () => { remove: vi.fn(), }; - const mockAuthGuard = { - canActivate: vi.fn().mockReturnValue(true), + const mockPromptFormatterService = { + formatPrompt: vi.fn(), + getFormalityLevels: vi.fn(), }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [PersonalitiesController], providers: [ - { - provide: PersonalitiesService, - useValue: mockPersonalitiesService, - }, + { provide: PersonalitiesService, useValue: mockPersonalitiesService }, + { provide: PromptFormatterService, useValue: mockPromptFormatterService }, ], }) .overrideGuard(AuthGuard) - .useValue(mockAuthGuard) + .useValue({ canActivate: () => true }) .compile(); controller = module.get(PersonalitiesController); service = module.get(PersonalitiesService); - - // Reset mocks + promptFormatter = module.get(PromptFormatterService); vi.clearAllMocks(); }); describe("findAll", () => { it("should return all personalities", async () => { - const mockPersonalities = [mockPersonality]; - mockPersonalitiesService.findAll.mockResolvedValue(mockPersonalities); - - const result = await controller.findAll(mockRequest as any); - - expect(result).toEqual(mockPersonalities); - expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, true); - }); - - it("should filter by active status", async () => { mockPersonalitiesService.findAll.mockResolvedValue([mockPersonality]); - - await controller.findAll(mockRequest as any, false); - - expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, false); + const result = await controller.findAll(mockRequest); + expect(result).toEqual([mockPersonality]); + expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, undefined); }); }); describe("findOne", () => { it("should return a personality by id", async () => { mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality); - - const result = await controller.findOne(mockRequest as any, mockPersonalityId); - + const result = await controller.findOne(mockRequest, mockPersonalityId); expect(result).toEqual(mockPersonality); - expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId); }); }); describe("findDefault", () => { it("should return the default personality", async () => { mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality); - - const result = await controller.findDefault(mockRequest as any); - + const result = await controller.findDefault(mockRequest); expect(result).toEqual(mockPersonality); - expect(service.findDefault).toHaveBeenCalledWith(mockWorkspaceId); + }); + }); + + describe("getFormalityLevels", () => { + it("should return formality levels", () => { + const levels = [{ level: "FORMAL", description: "Professional" }]; + mockPromptFormatterService.getFormalityLevels.mockReturnValue(levels); + const result = controller.getFormalityLevels(); + expect(result).toEqual(levels); }); }); describe("create", () => { - const createDto: CreatePersonalityDto = { - name: "Casual", - description: "Casual communication style", - tone: "casual", - formalityLevel: "CASUAL", - systemPromptTemplate: "You are a casual assistant.", - }; - it("should create a new personality", async () => { - const newPersonality = { ...mockPersonality, ...createDto, id: "new-id" }; - mockPersonalitiesService.create.mockResolvedValue(newPersonality); - - const result = await controller.create(mockRequest as any, createDto); - - expect(result).toEqual(newPersonality); + const createDto = { + name: "Casual", + tone: "casual", + formalityLevel: "CASUAL" as const, + systemPromptTemplate: "You are a casual assistant.", + }; + mockPersonalitiesService.create.mockResolvedValue({ ...mockPersonality, ...createDto }); + await controller.create(mockRequest, createDto); expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto); }); }); describe("update", () => { - const updateDto: UpdatePersonalityDto = { - description: "Updated description", - }; - it("should update a personality", async () => { - const updatedPersonality = { ...mockPersonality, ...updateDto }; - mockPersonalitiesService.update.mockResolvedValue(updatedPersonality); - - const result = await controller.update(mockRequest as any, mockPersonalityId, updateDto); - - expect(result).toEqual(updatedPersonality); + const updateDto = { description: "Updated" }; + mockPersonalitiesService.update.mockResolvedValue({ ...mockPersonality, ...updateDto }); + await controller.update(mockRequest, mockPersonalityId, updateDto); expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto); }); }); @@ -147,11 +119,21 @@ describe("PersonalitiesController", () => { describe("remove", () => { it("should delete a personality", async () => { mockPersonalitiesService.remove.mockResolvedValue(mockPersonality); - - const result = await controller.remove(mockRequest as any, mockPersonalityId); - - expect(result).toEqual(mockPersonality); + await controller.remove(mockRequest, mockPersonalityId); expect(service.remove).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId); }); }); + + describe("previewPrompt", () => { + it("should return formatted system prompt", async () => { + const context = { userName: "John" }; + mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality); + mockPromptFormatterService.formatPrompt.mockReturnValue({ + systemPrompt: "Formatted prompt", + metadata: {}, + }); + const result = await controller.previewPrompt(mockRequest, mockPersonalityId, context); + expect(result.systemPrompt).toBe("Formatted prompt"); + }); + }); }); diff --git a/apps/api/src/personalities/personalities.controller.ts b/apps/api/src/personalities/personalities.controller.ts index dc53ce3..345d772 100644 --- a/apps/api/src/personalities/personalities.controller.ts +++ b/apps/api/src/personalities/personalities.controller.ts @@ -9,69 +9,81 @@ import { Query, UseGuards, Req, + ParseBoolPipe, + HttpCode, + HttpStatus, } from "@nestjs/common"; import { AuthGuard } from "../auth/guards/auth.guard"; import { PersonalitiesService } from "./personalities.service"; +import { PromptFormatterService, PromptContext } from "./services/prompt-formatter.service"; import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto"; import { Personality } from "./entities/personality.entity"; +interface AuthenticatedRequest { + user: { id: string }; + workspaceId: string; +} + @Controller("personalities") @UseGuards(AuthGuard) export class PersonalitiesController { - constructor(private readonly personalitiesService: PersonalitiesService) {} + constructor( + private readonly personalitiesService: PersonalitiesService, + private readonly promptFormatter: PromptFormatterService, + ) {} - /** - * Get all personalities for the current workspace - */ @Get() async findAll( - @Req() req: any, - @Query("isActive") isActive: boolean = true, + @Req() req: AuthenticatedRequest, + @Query("isActive", new ParseBoolPipe({ optional: true })) isActive?: boolean, ): Promise { return this.personalitiesService.findAll(req.workspaceId, isActive); } - /** - * Get the default personality for the current workspace - */ @Get("default") - async findDefault(@Req() req: any): Promise { + async findDefault(@Req() req: AuthenticatedRequest): Promise { return this.personalitiesService.findDefault(req.workspaceId); } - /** - * Get a specific personality by ID - */ + @Get("formality-levels") + getFormalityLevels(): Array<{ level: string; description: string }> { + return this.promptFormatter.getFormalityLevels(); + } + @Get(":id") - async findOne(@Req() req: any, @Param("id") id: string): Promise { + async findOne(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise { return this.personalitiesService.findOne(req.workspaceId, id); } - /** - * Create a new personality - */ @Post() - async create(@Req() req: any, @Body() dto: CreatePersonalityDto): Promise { + @HttpCode(HttpStatus.CREATED) + async create(@Req() req: AuthenticatedRequest, @Body() dto: CreatePersonalityDto): Promise { return this.personalitiesService.create(req.workspaceId, dto); } - /** - * Update an existing personality - */ @Put(":id") async update( - @Req() req: any, + @Req() req: AuthenticatedRequest, @Param("id") id: string, @Body() dto: UpdatePersonalityDto, ): Promise { return this.personalitiesService.update(req.workspaceId, id, dto); } - /** - * Delete a personality - */ @Delete(":id") - async remove(@Req() req: any, @Param("id") id: string): Promise { + @HttpCode(HttpStatus.OK) + async remove(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise { return this.personalitiesService.remove(req.workspaceId, id); } + + @Post(":id/preview") + async previewPrompt( + @Req() req: AuthenticatedRequest, + @Param("id") id: string, + @Body() context?: PromptContext, + ): Promise<{ systemPrompt: string }> { + const personality = await this.personalitiesService.findOne(req.workspaceId, id); + const { systemPrompt } = this.promptFormatter.formatPrompt(personality, context); + return { systemPrompt }; + } } diff --git a/apps/api/src/personalities/personalities.module.ts b/apps/api/src/personalities/personalities.module.ts index 055b073..92ad013 100644 --- a/apps/api/src/personalities/personalities.module.ts +++ b/apps/api/src/personalities/personalities.module.ts @@ -3,11 +3,12 @@ import { PrismaModule } from "../prisma/prisma.module"; import { AuthModule } from "../auth/auth.module"; import { PersonalitiesService } from "./personalities.service"; import { PersonalitiesController } from "./personalities.controller"; +import { PromptFormatterService } from "./services/prompt-formatter.service"; @Module({ imports: [PrismaModule, AuthModule], controllers: [PersonalitiesController], - providers: [PersonalitiesService], - exports: [PersonalitiesService], + providers: [PersonalitiesService, PromptFormatterService], + exports: [PersonalitiesService, PromptFormatterService], }) export class PersonalitiesModule {} diff --git a/apps/api/src/personalities/services/index.ts b/apps/api/src/personalities/services/index.ts new file mode 100644 index 0000000..0167b66 --- /dev/null +++ b/apps/api/src/personalities/services/index.ts @@ -0,0 +1 @@ +export * from "./prompt-formatter.service"; diff --git a/apps/api/src/personalities/services/prompt-formatter.service.spec.ts b/apps/api/src/personalities/services/prompt-formatter.service.spec.ts new file mode 100644 index 0000000..e6f0556 --- /dev/null +++ b/apps/api/src/personalities/services/prompt-formatter.service.spec.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { PromptFormatterService, PromptContext } from "./prompt-formatter.service"; + +describe("PromptFormatterService", () => { + let service: PromptFormatterService; + + const mockPersonality = { + id: "personality-123", + workspaceId: "workspace-123", + name: "Professional", + description: "Professional communication style", + tone: "professional", + formalityLevel: "FORMAL" as const, + systemPromptTemplate: "You are a helpful assistant for {{userName}} at {{workspaceName}}.", + isDefault: true, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [PromptFormatterService], + }).compile(); + + service = module.get(PromptFormatterService); + }); + + describe("formatPrompt", () => { + it("should format prompt with context variables", () => { + const context: PromptContext = { userName: "John", workspaceName: "Acme Corp" }; + const result = service.formatPrompt(mockPersonality, context); + expect(result.systemPrompt).toContain("John"); + expect(result.systemPrompt).toContain("Acme Corp"); + expect(result.metadata.personalityId).toBe(mockPersonality.id); + }); + + it("should add formality modifier", () => { + const result = service.formatPrompt(mockPersonality); + expect(result.systemPrompt).toContain("professional"); + }); + + it("should include metadata", () => { + const result = service.formatPrompt(mockPersonality); + expect(result.metadata.formattedAt).toBeInstanceOf(Date); + expect(result.metadata.tone).toBe("professional"); + }); + + it("should handle custom context variables", () => { + const personality = { ...mockPersonality, systemPromptTemplate: "Custom: {{customVar}}" }; + const context: PromptContext = { custom: { customVar: "test-value" } }; + const result = service.formatPrompt(personality, context); + expect(result.systemPrompt).toContain("test-value"); + }); + }); + + describe("buildSystemPrompt", () => { + it("should build complete prompt with context", () => { + const context: PromptContext = { userName: "Jane" }; + const result = service.buildSystemPrompt(mockPersonality, context); + expect(result).toContain("Jane"); + }); + + it("should include date/time when requested", () => { + const context: PromptContext = { currentDate: "2024-01-29", currentTime: "14:30", timezone: "UTC" }; + const result = service.buildSystemPrompt(mockPersonality, context, { includeDateTime: true }); + expect(result).toContain("2024-01-29"); + expect(result).toContain("14:30"); + }); + + it("should include additional instructions", () => { + const result = service.buildSystemPrompt(mockPersonality, undefined, { + additionalInstructions: "Be concise.", + }); + expect(result).toContain("Be concise."); + }); + }); + + describe("validateTemplate", () => { + it("should validate known variables", () => { + const template = "Hello {{userName}}, welcome to {{workspaceName}}!"; + const result = service.validateTemplate(template); + expect(result.valid).toBe(true); + }); + + it("should report unknown variables", () => { + const template = "Hello {{unknownVar}}!"; + const result = service.validateTemplate(template); + expect(result.valid).toBe(false); + expect(result.missingVariables).toContain("unknownVar"); + }); + + it("should allow custom_ prefixed variables", () => { + const template = "Value: {{custom_myVar}}"; + const result = service.validateTemplate(template); + expect(result.valid).toBe(true); + }); + }); + + describe("getFormalityLevels", () => { + it("should return all formality levels", () => { + const levels = service.getFormalityLevels(); + expect(levels).toHaveLength(5); + expect(levels.map((l) => l.level)).toContain("FORMAL"); + }); + }); +}); diff --git a/apps/api/src/personalities/services/prompt-formatter.service.ts b/apps/api/src/personalities/services/prompt-formatter.service.ts new file mode 100644 index 0000000..cf9bce4 --- /dev/null +++ b/apps/api/src/personalities/services/prompt-formatter.service.ts @@ -0,0 +1,137 @@ +import { Injectable } from "@nestjs/common"; +import { FormalityLevel } from "@prisma/client"; +import { Personality } from "../entities/personality.entity"; + +export interface PromptContext { + userName?: string; + workspaceName?: string; + currentDate?: string; + currentTime?: string; + timezone?: string; + custom?: Record; +} + +export interface FormattedPrompt { + systemPrompt: string; + metadata: { + personalityId: string; + personalityName: string; + tone: string; + formalityLevel: FormalityLevel; + formattedAt: Date; + }; +} + +const FORMALITY_MODIFIERS: Record = { + VERY_CASUAL: "Be extremely relaxed and friendly. Use casual language, contractions, and even emojis when appropriate.", + CASUAL: "Be friendly and approachable. Use conversational language and a warm tone.", + NEUTRAL: "Be professional yet approachable. Balance formality with friendliness.", + FORMAL: "Be professional and respectful. Use proper grammar and formal language.", + VERY_FORMAL: "Be highly professional and formal. Use precise language and maintain a respectful, business-like demeanor.", +}; + +@Injectable() +export class PromptFormatterService { + formatPrompt(personality: Personality, context?: PromptContext): FormattedPrompt { + let prompt = personality.systemPromptTemplate; + prompt = this.interpolateVariables(prompt, context); + + if (!prompt.toLowerCase().includes("formality") && !prompt.toLowerCase().includes(personality.formalityLevel.toLowerCase())) { + const modifier = FORMALITY_MODIFIERS[personality.formalityLevel]; + prompt = `${prompt}\n\n${modifier}`; + } + + if (!prompt.toLowerCase().includes(personality.tone.toLowerCase())) { + prompt = `${prompt}\n\nMaintain a ${personality.tone} tone throughout the conversation.`; + } + + return { + systemPrompt: prompt.trim(), + metadata: { + personalityId: personality.id, + personalityName: personality.name, + tone: personality.tone, + formalityLevel: personality.formalityLevel, + formattedAt: new Date(), + }, + }; + } + + buildSystemPrompt( + personality: Personality, + context?: PromptContext, + options?: { includeDateTime?: boolean; additionalInstructions?: string }, + ): string { + const { systemPrompt } = this.formatPrompt(personality, context); + const parts: string[] = [systemPrompt]; + + if (options?.includeDateTime === true) { + const now = new Date(); + const dateStr = context?.currentDate ?? now.toISOString().split("T")[0]; + const timeStr = context?.currentTime ?? now.toTimeString().slice(0, 5); + const tzStr = context?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + parts.push(`Current date: ${dateStr}, Time: ${timeStr} (${tzStr})`); + } + + if (options?.additionalInstructions !== undefined && options.additionalInstructions.length > 0) { + parts.push(options.additionalInstructions); + } + + return parts.join("\n\n"); + } + + private interpolateVariables(template: string, context?: PromptContext): string { + if (context === undefined) { + return template; + } + + let result = template; + + if (context.userName !== undefined) { + result = result.replace(/\{\{userName\}\}/g, context.userName); + } + if (context.workspaceName !== undefined) { + result = result.replace(/\{\{workspaceName\}\}/g, context.workspaceName); + } + if (context.currentDate !== undefined) { + result = result.replace(/\{\{currentDate\}\}/g, context.currentDate); + } + if (context.currentTime !== undefined) { + result = result.replace(/\{\{currentTime\}\}/g, context.currentTime); + } + if (context.timezone !== undefined) { + result = result.replace(/\{\{timezone\}\}/g, context.timezone); + } + + if (context.custom !== undefined) { + for (const [key, value] of Object.entries(context.custom)) { + const regex = new RegExp(`\\{\\{${key}\\}\\}`, "g"); + result = result.replace(regex, value); + } + } + + return result; + } + + validateTemplate(template: string): { valid: boolean; missingVariables: string[] } { + const variablePattern = /\{\{(\w+)\}\}/g; + const matches = template.matchAll(variablePattern); + const variables = Array.from(matches, (m) => m[1]); + + const allowedVariables = new Set(["userName", "workspaceName", "currentDate", "currentTime", "timezone"]); + + const unknownVariables = variables.filter((v) => !allowedVariables.has(v) && !v.startsWith("custom_")); + + return { + valid: unknownVariables.length === 0, + missingVariables: unknownVariables, + }; + } + + getFormalityLevels(): Array<{ level: FormalityLevel; description: string }> { + return Object.entries(FORMALITY_MODIFIERS).map(([level, description]) => ({ + level: level as FormalityLevel, + description, + })); + } +}