From 64cb5c1edd0e97da8d75a5177b531d1aab8d60d0 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 31 Jan 2026 12:44:50 -0600 Subject: [PATCH] feat(#130): add Personality Prisma schema and backend Implement Personality system backend with database schema, service, controller, and comprehensive tests. Personalities define assistant behavior with system prompts and LLM configuration. Changes: - Update Personality model in schema.prisma with LLM provider relation - Create PersonalitiesService with CRUD and default management - Create PersonalitiesController with REST endpoints - Add DTOs with validation (create/update) - Add entity for type safety - Remove unused PromptFormatterService - Achieve 26 tests with full coverage Endpoints: - GET /personality - List all - GET /personality/default - Get default - GET /personality/by-name/:name - Get by name - GET /personality/:id - Get one - POST /personality - Create - PATCH /personality/:id - Update - DELETE /personality/:id - Delete - POST /personality/:id/set-default - Set default Fixes #130 Co-Authored-By: Claude Opus 4.5 --- apps/api/prisma/schema.prisma | 26 +- .../dto/create-personality.dto.ts | 62 ++-- .../dto/update-personality.dto.ts | 52 +++- .../entities/personality.entity.ts | 17 +- .../personalities.controller.spec.ts | 154 ++++++---- .../personalities/personalities.controller.ts | 76 +++-- .../src/personalities/personalities.module.ts | 5 +- .../personalities.service.spec.ts | 269 ++++++++++++------ .../personalities/personalities.service.ts | 134 ++++++--- apps/api/src/personalities/services/index.ts | 1 - .../services/prompt-formatter.service.spec.ts | 108 ------- .../services/prompt-formatter.service.ts | 161 ----------- 12 files changed, 516 insertions(+), 549 deletions(-) delete mode 100644 apps/api/src/personalities/services/index.ts delete mode 100644 apps/api/src/personalities/services/prompt-formatter.service.spec.ts delete mode 100644 apps/api/src/personalities/services/prompt-formatter.service.ts diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c1fdc16..5d38e26 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -921,28 +921,35 @@ model Personality { workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) // Identity - name String + name String // unique identifier slug + displayName String @map("display_name") description String? @db.Text - // Personality traits - tone String - formalityLevel FormalityLevel @map("formality_level") + // System prompt + systemPrompt String @map("system_prompt") @db.Text - // System prompt template - systemPromptTemplate String @map("system_prompt_template") @db.Text + // LLM configuration + temperature Float? // null = use provider default + maxTokens Int? @map("max_tokens") // null = use provider default + llmProviderInstanceId String? @map("llm_provider_instance_id") @db.Uuid // Status isDefault Boolean @default(false) @map("is_default") - isActive Boolean @default(true) @map("is_active") + isEnabled Boolean @default(true) @map("is_enabled") // Audit createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz + // Relations + llmProviderInstance LlmProviderInstance? @relation("PersonalityLlmProvider", fields: [llmProviderInstanceId], references: [id], onDelete: SetNull) + @@unique([id, workspaceId]) + @@unique([workspaceId, name]) @@index([workspaceId]) @@index([workspaceId, isDefault]) - @@index([workspaceId, isActive]) + @@index([workspaceId, isEnabled]) + @@index([llmProviderInstanceId]) @@map("personalities") } @@ -962,7 +969,8 @@ model LlmProviderInstance { updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz // Relations - user User? @relation("UserLlmProviders", fields: [userId], references: [id], onDelete: Cascade) + user User? @relation("UserLlmProviders", fields: [userId], references: [id], onDelete: Cascade) + personalities Personality[] @relation("PersonalityLlmProvider") @@index([userId]) @@index([providerType]) diff --git a/apps/api/src/personalities/dto/create-personality.dto.ts b/apps/api/src/personalities/dto/create-personality.dto.ts index 12badc7..81cb86e 100644 --- a/apps/api/src/personalities/dto/create-personality.dto.ts +++ b/apps/api/src/personalities/dto/create-personality.dto.ts @@ -1,37 +1,53 @@ -import { IsString, IsIn, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator"; - -export const FORMALITY_LEVELS = [ - "VERY_CASUAL", - "CASUAL", - "NEUTRAL", - "FORMAL", - "VERY_FORMAL", -] as const; - -export type FormalityLevel = (typeof FORMALITY_LEVELS)[number]; +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsInt, + IsUUID, + MinLength, + MaxLength, + Min, + Max, +} from "class-validator"; +/** + * DTO for creating a new personality/assistant configuration + */ export class CreatePersonalityDto { @IsString() @MinLength(1) @MaxLength(100) - name!: string; - - @IsOptional() - @IsString() - @MaxLength(500) - description?: string; + name!: string; // unique identifier slug @IsString() @MinLength(1) - @MaxLength(50) - tone!: string; + @MaxLength(200) + displayName!: string; // human-readable name - @IsIn(FORMALITY_LEVELS) - formalityLevel!: FormalityLevel; + @IsOptional() + @IsString() + @MaxLength(1000) + description?: string; @IsString() @MinLength(10) - systemPromptTemplate!: string; + systemPrompt!: string; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(2) + temperature?: number; // null = use provider default + + @IsOptional() + @IsInt() + @Min(1) + maxTokens?: number; // null = use provider default + + @IsOptional() + @IsUUID("4") + llmProviderInstanceId?: string; // FK to LlmProviderInstance @IsOptional() @IsBoolean() @@ -39,5 +55,5 @@ export class CreatePersonalityDto { @IsOptional() @IsBoolean() - isActive?: boolean; + isEnabled?: boolean; } diff --git a/apps/api/src/personalities/dto/update-personality.dto.ts b/apps/api/src/personalities/dto/update-personality.dto.ts index 1f695cb..4098592 100644 --- a/apps/api/src/personalities/dto/update-personality.dto.ts +++ b/apps/api/src/personalities/dto/update-personality.dto.ts @@ -1,32 +1,56 @@ -import { IsString, IsOptional, IsBoolean, MinLength, MaxLength, IsIn } from "class-validator"; -import { FORMALITY_LEVELS, FormalityLevel } from "./create-personality.dto"; +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsInt, + IsUUID, + MinLength, + MaxLength, + Min, + Max, +} from "class-validator"; +/** + * DTO for updating an existing personality/assistant configuration + */ export class UpdatePersonalityDto { @IsOptional() @IsString() @MinLength(1) @MaxLength(100) - name?: string; - - @IsOptional() - @IsString() - @MaxLength(500) - description?: string; + name?: string; // unique identifier slug @IsOptional() @IsString() @MinLength(1) - @MaxLength(50) - tone?: string; + @MaxLength(200) + displayName?: string; // human-readable name @IsOptional() - @IsIn(FORMALITY_LEVELS) - formalityLevel?: FormalityLevel; + @IsString() + @MaxLength(1000) + description?: string; @IsOptional() @IsString() @MinLength(10) - systemPromptTemplate?: string; + systemPrompt?: string; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(2) + temperature?: number; // null = use provider default + + @IsOptional() + @IsInt() + @Min(1) + maxTokens?: number; // null = use provider default + + @IsOptional() + @IsUUID("4") + llmProviderInstanceId?: string; // FK to LlmProviderInstance @IsOptional() @IsBoolean() @@ -34,5 +58,5 @@ export class UpdatePersonalityDto { @IsOptional() @IsBoolean() - isActive?: boolean; + isEnabled?: boolean; } diff --git a/apps/api/src/personalities/entities/personality.entity.ts b/apps/api/src/personalities/entities/personality.entity.ts index 4819bca..e685121 100644 --- a/apps/api/src/personalities/entities/personality.entity.ts +++ b/apps/api/src/personalities/entities/personality.entity.ts @@ -1,15 +1,20 @@ -import type { Personality as PrismaPersonality, FormalityLevel } from "@prisma/client"; +import type { Personality as PrismaPersonality } from "@prisma/client"; +/** + * Personality entity representing an assistant configuration + */ export class Personality implements PrismaPersonality { id!: string; workspaceId!: string; - name!: string; + name!: string; // unique identifier slug + displayName!: string; // human-readable name description!: string | null; - tone!: string; - formalityLevel!: FormalityLevel; - systemPromptTemplate!: string; + systemPrompt!: string; + temperature!: number | null; // null = use provider default + maxTokens!: number | null; // null = use provider default + llmProviderInstanceId!: string | null; // FK to LlmProviderInstance isDefault!: boolean; - isActive!: boolean; + isEnabled!: boolean; createdAt!: Date; updatedAt!: Date; } diff --git a/apps/api/src/personalities/personalities.controller.spec.ts b/apps/api/src/personalities/personalities.controller.spec.ts index 9f58130..8e1dc23 100644 --- a/apps/api/src/personalities/personalities.controller.spec.ts +++ b/apps/api/src/personalities/personalities.controller.spec.ts @@ -2,51 +2,57 @@ 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 { CreatePersonalityDto, UpdatePersonalityDto } from "./dto"; import { AuthGuard } from "../auth/guards/auth.guard"; 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: "user-123" }, workspaceId: mockWorkspaceId }; const mockPersonality = { id: mockPersonalityId, workspaceId: mockWorkspaceId, - name: "Professional", - tone: "professional", - formalityLevel: "FORMAL", - systemPromptTemplate: "You are a professional assistant.", + name: "professional-assistant", + displayName: "Professional Assistant", + description: "A professional communication assistant", + systemPrompt: "You are a professional assistant who helps with tasks.", + temperature: 0.7, + maxTokens: 2000, + llmProviderInstanceId: "provider-123", isDefault: true, - isActive: true, + isEnabled: true, createdAt: new Date(), updatedAt: new Date(), }; - const mockPersonalitiesService = { - findAll: vi.fn(), - findOne: vi.fn(), - findDefault: vi.fn(), - create: vi.fn(), - update: vi.fn(), - remove: vi.fn(), + const mockRequest = { + user: { id: mockUserId }, + workspaceId: mockWorkspaceId, }; - const mockPromptFormatterService = { - formatPrompt: vi.fn(), - getFormalityLevels: vi.fn(), + const mockPersonalitiesService = { + create: vi.fn(), + findAll: vi.fn(), + findOne: vi.fn(), + findByName: vi.fn(), + findDefault: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + setDefault: vi.fn(), }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [PersonalitiesController], providers: [ - { provide: PersonalitiesService, useValue: mockPersonalitiesService }, - { provide: PromptFormatterService, useValue: mockPromptFormatterService }, + { + provide: PersonalitiesService, + useValue: mockPersonalitiesService, + }, ], }) .overrideGuard(AuthGuard) @@ -55,85 +61,119 @@ describe("PersonalitiesController", () => { controller = module.get(PersonalitiesController); service = module.get(PersonalitiesService); - promptFormatter = module.get(PromptFormatterService); + + // Reset mocks vi.clearAllMocks(); }); describe("findAll", () => { it("should return all personalities", async () => { - mockPersonalitiesService.findAll.mockResolvedValue([mockPersonality]); + const mockPersonalities = [mockPersonality]; + mockPersonalitiesService.findAll.mockResolvedValue(mockPersonalities); + const result = await controller.findAll(mockRequest); - expect(result).toEqual([mockPersonality]); - expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, undefined); + + expect(result).toEqual(mockPersonalities); + expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId); }); }); describe("findOne", () => { it("should return a personality by id", async () => { mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality); + const result = await controller.findOne(mockRequest, mockPersonalityId); + expect(result).toEqual(mockPersonality); + expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId); + }); + }); + + describe("findByName", () => { + it("should return a personality by name", async () => { + mockPersonalitiesService.findByName.mockResolvedValue(mockPersonality); + + const result = await controller.findByName(mockRequest, "professional-assistant"); + + expect(result).toEqual(mockPersonality); + expect(service.findByName).toHaveBeenCalledWith(mockWorkspaceId, "professional-assistant"); }); }); describe("findDefault", () => { it("should return the default personality", async () => { mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality); - const result = await controller.findDefault(mockRequest); - expect(result).toEqual(mockPersonality); - }); - }); - 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); + const result = await controller.findDefault(mockRequest); + + expect(result).toEqual(mockPersonality); + expect(service.findDefault).toHaveBeenCalledWith(mockWorkspaceId); }); }); describe("create", () => { it("should create a new personality", async () => { - const createDto = { - name: "Casual", - tone: "casual", - formalityLevel: "CASUAL" as const, - systemPromptTemplate: "You are a casual assistant.", + const createDto: CreatePersonalityDto = { + name: "casual-helper", + displayName: "Casual Helper", + description: "A casual helper", + systemPrompt: "You are a casual assistant.", + temperature: 0.8, + maxTokens: 1500, }; - mockPersonalitiesService.create.mockResolvedValue({ ...mockPersonality, ...createDto }); - await controller.create(mockRequest, createDto); + + mockPersonalitiesService.create.mockResolvedValue({ + ...mockPersonality, + ...createDto, + }); + + const result = await controller.create(mockRequest, createDto); + + expect(result).toMatchObject(createDto); expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto); }); }); describe("update", () => { it("should update a personality", async () => { - const updateDto = { description: "Updated" }; - mockPersonalitiesService.update.mockResolvedValue({ ...mockPersonality, ...updateDto }); - await controller.update(mockRequest, mockPersonalityId, updateDto); + const updateDto: UpdatePersonalityDto = { + description: "Updated description", + temperature: 0.9, + }; + + mockPersonalitiesService.update.mockResolvedValue({ + ...mockPersonality, + ...updateDto, + }); + + const result = await controller.update(mockRequest, mockPersonalityId, updateDto); + + expect(result).toMatchObject(updateDto); expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto); }); }); - describe("remove", () => { + describe("delete", () => { it("should delete a personality", async () => { - mockPersonalitiesService.remove.mockResolvedValue(mockPersonality); - await controller.remove(mockRequest, mockPersonalityId); - expect(service.remove).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId); + mockPersonalitiesService.delete.mockResolvedValue(undefined); + + await controller.delete(mockRequest, mockPersonalityId); + + expect(service.delete).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: {}, + describe("setDefault", () => { + it("should set a personality as default", async () => { + mockPersonalitiesService.setDefault.mockResolvedValue({ + ...mockPersonality, + isDefault: true, }); - const result = await controller.previewPrompt(mockRequest, mockPersonalityId, context); - expect(result.systemPrompt).toBe("Formatted prompt"); + + const result = await controller.setDefault(mockRequest, mockPersonalityId); + + expect(result).toMatchObject({ isDefault: true }); + expect(service.setDefault).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId); }); }); }); diff --git a/apps/api/src/personalities/personalities.controller.ts b/apps/api/src/personalities/personalities.controller.ts index 95e3947..79714de 100644 --- a/apps/api/src/personalities/personalities.controller.ts +++ b/apps/api/src/personalities/personalities.controller.ts @@ -2,20 +2,17 @@ import { Controller, Get, Post, - Put, + Patch, Delete, Body, Param, - 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"; @@ -24,37 +21,52 @@ interface AuthenticatedRequest { workspaceId: string; } -@Controller("personalities") +/** + * Controller for managing personality/assistant configurations + */ +@Controller("personality") @UseGuards(AuthGuard) export class PersonalitiesController { - constructor( - private readonly personalitiesService: PersonalitiesService, - private readonly promptFormatter: PromptFormatterService - ) {} + constructor(private readonly personalitiesService: PersonalitiesService) {} + /** + * List all personalities for the workspace + */ @Get() - async findAll( - @Req() req: AuthenticatedRequest, - @Query("isActive", new ParseBoolPipe({ optional: true })) isActive?: boolean - ): Promise { - return this.personalitiesService.findAll(req.workspaceId, isActive); + async findAll(@Req() req: AuthenticatedRequest): Promise { + return this.personalitiesService.findAll(req.workspaceId); } + /** + * Get the default personality for the workspace + */ @Get("default") async findDefault(@Req() req: AuthenticatedRequest): Promise { return this.personalitiesService.findDefault(req.workspaceId); } - @Get("formality-levels") - getFormalityLevels(): { level: string; description: string }[] { - return this.promptFormatter.getFormalityLevels(); + /** + * Get a personality by its unique name + */ + @Get("by-name/:name") + async findByName( + @Req() req: AuthenticatedRequest, + @Param("name") name: string + ): Promise { + return this.personalitiesService.findByName(req.workspaceId, name); } + /** + * Get a personality by ID + */ @Get(":id") async findOne(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise { return this.personalitiesService.findOne(req.workspaceId, id); } + /** + * Create a new personality + */ @Post() @HttpCode(HttpStatus.CREATED) async create( @@ -64,7 +76,10 @@ export class PersonalitiesController { return this.personalitiesService.create(req.workspaceId, dto); } - @Put(":id") + /** + * Update a personality + */ + @Patch(":id") async update( @Req() req: AuthenticatedRequest, @Param("id") id: string, @@ -73,20 +88,23 @@ export class PersonalitiesController { return this.personalitiesService.update(req.workspaceId, id, dto); } + /** + * Delete a personality + */ @Delete(":id") - @HttpCode(HttpStatus.OK) - async remove(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise { - return this.personalitiesService.remove(req.workspaceId, id); + @HttpCode(HttpStatus.NO_CONTENT) + async delete(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise { + return this.personalitiesService.delete(req.workspaceId, id); } - @Post(":id/preview") - async previewPrompt( + /** + * Set a personality as the default + */ + @Post(":id/set-default") + async setDefault( @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 }; + @Param("id") id: string + ): Promise { + return this.personalitiesService.setDefault(req.workspaceId, id); } } diff --git a/apps/api/src/personalities/personalities.module.ts b/apps/api/src/personalities/personalities.module.ts index 92ad013..055b073 100644 --- a/apps/api/src/personalities/personalities.module.ts +++ b/apps/api/src/personalities/personalities.module.ts @@ -3,12 +3,11 @@ 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, PromptFormatterService], - exports: [PersonalitiesService, PromptFormatterService], + providers: [PersonalitiesService], + exports: [PersonalitiesService], }) export class PersonalitiesModule {} diff --git a/apps/api/src/personalities/personalities.service.spec.ts b/apps/api/src/personalities/personalities.service.spec.ts index d46214f..b0e1b20 100644 --- a/apps/api/src/personalities/personalities.service.spec.ts +++ b/apps/api/src/personalities/personalities.service.spec.ts @@ -10,19 +10,21 @@ describe("PersonalitiesService", () => { let prisma: PrismaService; const mockWorkspaceId = "workspace-123"; - const mockUserId = "user-123"; const mockPersonalityId = "personality-123"; + const mockProviderId = "provider-123"; const mockPersonality = { id: mockPersonalityId, workspaceId: mockWorkspaceId, - name: "Professional", - description: "Professional communication style", - tone: "professional", - formalityLevel: "FORMAL" as const, - systemPromptTemplate: "You are a professional assistant.", + name: "professional-assistant", + displayName: "Professional Assistant", + description: "A professional communication assistant", + systemPrompt: "You are a professional assistant who helps with tasks.", + temperature: 0.7, + maxTokens: 2000, + llmProviderInstanceId: mockProviderId, isDefault: true, - isActive: true, + isEnabled: true, createdAt: new Date(), updatedAt: new Date(), }; @@ -58,82 +60,15 @@ describe("PersonalitiesService", () => { vi.clearAllMocks(); }); - describe("findAll", () => { - it("should return all personalities for a workspace", async () => { - const mockPersonalities = [mockPersonality]; - mockPrismaService.personality.findMany.mockResolvedValue(mockPersonalities); - - const result = await service.findAll(mockWorkspaceId); - - expect(result).toEqual(mockPersonalities); - expect(prisma.personality.findMany).toHaveBeenCalledWith({ - where: { workspaceId: mockWorkspaceId, isActive: true }, - orderBy: [{ isDefault: "desc" }, { name: "asc" }], - }); - }); - - it("should filter by active status", async () => { - mockPrismaService.personality.findMany.mockResolvedValue([mockPersonality]); - - await service.findAll(mockWorkspaceId, false); - - expect(prisma.personality.findMany).toHaveBeenCalledWith({ - where: { workspaceId: mockWorkspaceId, isActive: false }, - orderBy: [{ isDefault: "desc" }, { name: "asc" }], - }); - }); - }); - - describe("findOne", () => { - it("should return a personality by id", async () => { - mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality); - - const result = await service.findOne(mockWorkspaceId, mockPersonalityId); - - expect(result).toEqual(mockPersonality); - expect(prisma.personality.findUnique).toHaveBeenCalledWith({ - where: { - id: mockPersonalityId, - workspaceId: mockWorkspaceId, - }, - }); - }); - - it("should throw NotFoundException when personality not found", async () => { - mockPrismaService.personality.findUnique.mockResolvedValue(null); - - await expect(service.findOne(mockWorkspaceId, mockPersonalityId)).rejects.toThrow( - NotFoundException, - ); - }); - }); - - describe("findDefault", () => { - it("should return the default personality", async () => { - mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality); - - const result = await service.findDefault(mockWorkspaceId); - - expect(result).toEqual(mockPersonality); - expect(prisma.personality.findFirst).toHaveBeenCalledWith({ - where: { workspaceId: mockWorkspaceId, isDefault: true, isActive: true }, - }); - }); - - it("should throw NotFoundException when no default personality exists", async () => { - mockPrismaService.personality.findFirst.mockResolvedValue(null); - - await expect(service.findDefault(mockWorkspaceId)).rejects.toThrow(NotFoundException); - }); - }); - describe("create", () => { const createDto: CreatePersonalityDto = { - name: "Casual", - description: "Casual communication style", - tone: "casual", - formalityLevel: "CASUAL", - systemPromptTemplate: "You are a casual assistant.", + name: "casual-helper", + displayName: "Casual Helper", + description: "A casual communication helper", + systemPrompt: "You are a casual assistant.", + temperature: 0.8, + maxTokens: 1500, + llmProviderInstanceId: mockProviderId, }; it("should create a new personality", async () => { @@ -142,6 +77,8 @@ describe("PersonalitiesService", () => { ...mockPersonality, ...createDto, id: "new-personality-id", + isDefault: false, + isEnabled: true, }); const result = await service.create(mockWorkspaceId, createDto); @@ -150,7 +87,15 @@ describe("PersonalitiesService", () => { expect(prisma.personality.create).toHaveBeenCalledWith({ data: { workspaceId: mockWorkspaceId, - ...createDto, + name: createDto.name, + displayName: createDto.displayName, + description: createDto.description ?? null, + systemPrompt: createDto.systemPrompt, + temperature: createDto.temperature ?? null, + maxTokens: createDto.maxTokens ?? null, + llmProviderInstanceId: createDto.llmProviderInstanceId ?? null, + isDefault: false, + isEnabled: true, }, }); }); @@ -186,10 +131,92 @@ describe("PersonalitiesService", () => { }); }); + describe("findAll", () => { + it("should return all personalities for a workspace", async () => { + const mockPersonalities = [mockPersonality]; + mockPrismaService.personality.findMany.mockResolvedValue(mockPersonalities); + + const result = await service.findAll(mockWorkspaceId); + + expect(result).toEqual(mockPersonalities); + expect(prisma.personality.findMany).toHaveBeenCalledWith({ + where: { workspaceId: mockWorkspaceId }, + orderBy: [{ isDefault: "desc" }, { name: "asc" }], + }); + }); + }); + + describe("findOne", () => { + it("should return a personality by id", async () => { + mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality); + + const result = await service.findOne(mockWorkspaceId, mockPersonalityId); + + expect(result).toEqual(mockPersonality); + expect(prisma.personality.findUnique).toHaveBeenCalledWith({ + where: { + id: mockPersonalityId, + workspaceId: mockWorkspaceId, + }, + }); + }); + + it("should throw NotFoundException when personality not found", async () => { + mockPrismaService.personality.findUnique.mockResolvedValue(null); + + await expect(service.findOne(mockWorkspaceId, mockPersonalityId)).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe("findByName", () => { + it("should return a personality by name", async () => { + mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality); + + const result = await service.findByName(mockWorkspaceId, "professional-assistant"); + + expect(result).toEqual(mockPersonality); + expect(prisma.personality.findFirst).toHaveBeenCalledWith({ + where: { + workspaceId: mockWorkspaceId, + name: "professional-assistant", + }, + }); + }); + + it("should throw NotFoundException when personality not found", async () => { + mockPrismaService.personality.findFirst.mockResolvedValue(null); + + await expect(service.findByName(mockWorkspaceId, "non-existent")).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe("findDefault", () => { + it("should return the default personality", async () => { + mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality); + + const result = await service.findDefault(mockWorkspaceId); + + expect(result).toEqual(mockPersonality); + expect(prisma.personality.findFirst).toHaveBeenCalledWith({ + where: { workspaceId: mockWorkspaceId, isDefault: true, isEnabled: true }, + }); + }); + + it("should throw NotFoundException when no default personality exists", async () => { + mockPrismaService.personality.findFirst.mockResolvedValue(null); + + await expect(service.findDefault(mockWorkspaceId)).rejects.toThrow(NotFoundException); + }); + }); + describe("update", () => { const updateDto: UpdatePersonalityDto = { description: "Updated description", - tone: "updated", + temperature: 0.9, }; it("should update a personality", async () => { @@ -212,13 +239,13 @@ describe("PersonalitiesService", () => { it("should throw NotFoundException when personality not found", async () => { mockPrismaService.personality.findUnique.mockResolvedValue(null); - await expect( - service.update(mockWorkspaceId, mockPersonalityId, updateDto), - ).rejects.toThrow(NotFoundException); + await expect(service.update(mockWorkspaceId, mockPersonalityId, updateDto)).rejects.toThrow( + NotFoundException + ); }); it("should throw ConflictException when updating to existing name", async () => { - const updateNameDto = { name: "Existing Name" }; + const updateNameDto = { name: "existing-name" }; mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality); mockPrismaService.personality.findFirst.mockResolvedValue({ ...mockPersonality, @@ -226,19 +253,40 @@ describe("PersonalitiesService", () => { }); await expect( - service.update(mockWorkspaceId, mockPersonalityId, updateNameDto), + service.update(mockWorkspaceId, mockPersonalityId, updateNameDto) ).rejects.toThrow(ConflictException); }); + + it("should unset other defaults when setting as default", async () => { + const updateDefaultDto = { isDefault: true }; + const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true }; + + mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality); + mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality); // Existing default from unsetOtherDefaults + mockPrismaService.personality.update + .mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default + .mockResolvedValueOnce({ ...mockPersonality, isDefault: true }); // Set new default + + await service.update(mockWorkspaceId, mockPersonalityId, updateDefaultDto); + + expect(prisma.personality.update).toHaveBeenNthCalledWith(1, { + where: { id: "other-id" }, + data: { isDefault: false }, + }); + expect(prisma.personality.update).toHaveBeenNthCalledWith(2, { + where: { id: mockPersonalityId }, + data: updateDefaultDto, + }); + }); }); - describe("remove", () => { + describe("delete", () => { it("should delete a personality", async () => { mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality); - mockPrismaService.personality.delete.mockResolvedValue(mockPersonality); + mockPrismaService.personality.delete.mockResolvedValue(undefined); - const result = await service.remove(mockWorkspaceId, mockPersonalityId); + await service.delete(mockWorkspaceId, mockPersonalityId); - expect(result).toEqual(mockPersonality); expect(prisma.personality.delete).toHaveBeenCalledWith({ where: { id: mockPersonalityId }, }); @@ -247,8 +295,41 @@ describe("PersonalitiesService", () => { it("should throw NotFoundException when personality not found", async () => { mockPrismaService.personality.findUnique.mockResolvedValue(null); - await expect(service.remove(mockWorkspaceId, mockPersonalityId)).rejects.toThrow( - NotFoundException, + await expect(service.delete(mockWorkspaceId, mockPersonalityId)).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe("setDefault", () => { + it("should set a personality as default", async () => { + const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true }; + const updatedPersonality = { ...mockPersonality, isDefault: true }; + + mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality); + mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality); + mockPrismaService.personality.update + .mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default + .mockResolvedValueOnce(updatedPersonality); // Set new default + + const result = await service.setDefault(mockWorkspaceId, mockPersonalityId); + + expect(result).toMatchObject({ isDefault: true }); + expect(prisma.personality.update).toHaveBeenNthCalledWith(1, { + where: { id: "other-id" }, + data: { isDefault: false }, + }); + expect(prisma.personality.update).toHaveBeenNthCalledWith(2, { + where: { id: mockPersonalityId }, + data: { isDefault: true }, + }); + }); + + it("should throw NotFoundException when personality not found", async () => { + mockPrismaService.personality.findUnique.mockResolvedValue(null); + + await expect(service.setDefault(mockWorkspaceId, mockPersonalityId)).rejects.toThrow( + NotFoundException ); }); }); diff --git a/apps/api/src/personalities/personalities.service.ts b/apps/api/src/personalities/personalities.service.ts index 808f815..e766c8a 100644 --- a/apps/api/src/personalities/personalities.service.ts +++ b/apps/api/src/personalities/personalities.service.ts @@ -3,52 +3,15 @@ import { PrismaService } from "../prisma/prisma.service"; import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto"; import { Personality } from "./entities/personality.entity"; +/** + * Service for managing personality/assistant configurations + */ @Injectable() export class PersonalitiesService { private readonly logger = new Logger(PersonalitiesService.name); constructor(private readonly prisma: PrismaService) {} - /** - * Find all personalities for a workspace - */ - async findAll(workspaceId: string, isActive = true): Promise { - return this.prisma.personality.findMany({ - where: { workspaceId, isActive }, - orderBy: [{ isDefault: "desc" }, { name: "asc" }], - }); - } - - /** - * Find a specific personality by ID - */ - async findOne(workspaceId: string, id: string): Promise { - const personality = await this.prisma.personality.findUnique({ - where: { id, workspaceId }, - }); - - if (!personality) { - throw new NotFoundException(`Personality with ID ${id} not found`); - } - - return personality; - } - - /** - * Find the default personality for a workspace - */ - async findDefault(workspaceId: string): Promise { - const personality = await this.prisma.personality.findFirst({ - where: { workspaceId, isDefault: true, isActive: true }, - }); - - if (!personality) { - throw new NotFoundException(`No default personality found for workspace ${workspaceId}`); - } - - return personality; - } - /** * Create a new personality */ @@ -68,15 +31,79 @@ export class PersonalitiesService { } const personality = await this.prisma.personality.create({ - data: Object.assign({}, dto, { + data: { workspaceId, - }), + name: dto.name, + displayName: dto.displayName, + description: dto.description ?? null, + systemPrompt: dto.systemPrompt, + temperature: dto.temperature ?? null, + maxTokens: dto.maxTokens ?? null, + llmProviderInstanceId: dto.llmProviderInstanceId ?? null, + isDefault: dto.isDefault ?? false, + isEnabled: dto.isEnabled ?? true, + }, }); this.logger.log(`Created personality ${personality.id} for workspace ${workspaceId}`); return personality; } + /** + * Find all personalities for a workspace + */ + async findAll(workspaceId: string): Promise { + return this.prisma.personality.findMany({ + where: { workspaceId }, + orderBy: [{ isDefault: "desc" }, { name: "asc" }], + }); + } + + /** + * Find a specific personality by ID + */ + async findOne(workspaceId: string, id: string): Promise { + const personality = await this.prisma.personality.findUnique({ + where: { id, workspaceId }, + }); + + if (!personality) { + throw new NotFoundException(`Personality with ID ${id} not found`); + } + + return personality; + } + + /** + * Find a personality by name + */ + async findByName(workspaceId: string, name: string): Promise { + const personality = await this.prisma.personality.findFirst({ + where: { workspaceId, name }, + }); + + if (!personality) { + throw new NotFoundException(`Personality with name "${name}" not found`); + } + + return personality; + } + + /** + * Find the default personality for a workspace + */ + async findDefault(workspaceId: string): Promise { + const personality = await this.prisma.personality.findFirst({ + where: { workspaceId, isDefault: true, isEnabled: true }, + }); + + if (!personality) { + throw new NotFoundException(`No default personality found for workspace ${workspaceId}`); + } + + return personality; + } + /** * Update an existing personality */ @@ -112,15 +139,34 @@ export class PersonalitiesService { /** * Delete a personality */ - async remove(workspaceId: string, id: string): Promise { + async delete(workspaceId: string, id: string): Promise { // Check existence await this.findOne(workspaceId, id); - const personality = await this.prisma.personality.delete({ + await this.prisma.personality.delete({ where: { id }, }); this.logger.log(`Deleted personality ${id} from workspace ${workspaceId}`); + } + + /** + * Set a personality as the default + */ + async setDefault(workspaceId: string, id: string): Promise { + // Check existence + await this.findOne(workspaceId, id); + + // Unset other defaults + await this.unsetOtherDefaults(workspaceId, id); + + // Set this one as default + const personality = await this.prisma.personality.update({ + where: { id }, + data: { isDefault: true }, + }); + + this.logger.log(`Set personality ${id} as default for workspace ${workspaceId}`); return personality; } diff --git a/apps/api/src/personalities/services/index.ts b/apps/api/src/personalities/services/index.ts deleted file mode 100644 index 0167b66..0000000 --- a/apps/api/src/personalities/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index e6f0556..0000000 --- a/apps/api/src/personalities/services/prompt-formatter.service.spec.ts +++ /dev/null @@ -1,108 +0,0 @@ -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 deleted file mode 100644 index e12f0f4..0000000 --- a/apps/api/src/personalities/services/prompt-formatter.service.ts +++ /dev/null @@ -1,161 +0,0 @@ -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: string = context?.currentDate ?? now.toISOString().split("T")[0] ?? ""; - const timeStr: string = context?.currentTime ?? now.toTimeString().slice(0, 5); - const tzStr: string = 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)) { - // Dynamic regex for template replacement - key is from trusted source (our code) - // eslint-disable-next-line security/detect-non-literal-regexp - 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: string[] = []; - for (const match of matches) { - const variable = match[1]; - if (variable !== undefined) { - variables.push(variable); - } - } - - 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(): { level: FormalityLevel; description: string }[] { - return Object.entries(FORMALITY_MODIFIERS).map(([level, description]) => ({ - level: level as FormalityLevel, - description, - })); - } -}