feat(#82): Implement personality module #107

Merged
jason.woltje merged 2 commits from feature/82-personality into develop 2026-01-30 01:43:56 +00:00
7 changed files with 374 additions and 99 deletions
Showing only changes of commit 8383a98070 - Show all commits

View File

@@ -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;
}

View File

@@ -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>(PersonalitiesController);
service = module.get<PersonalitiesService>(PersonalitiesService);
// Reset mocks
promptFormatter = module.get<PromptFormatterService>(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");
});
});
});

View File

@@ -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<Personality[]> {
return this.personalitiesService.findAll(req.workspaceId, isActive);
}
/**
* Get the default personality for the current workspace
*/
@Get("default")
async findDefault(@Req() req: any): Promise<Personality> {
async findDefault(@Req() req: AuthenticatedRequest): Promise<Personality> {
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<Personality> {
async findOne(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<Personality> {
return this.personalitiesService.findOne(req.workspaceId, id);
}
/**
* Create a new personality
*/
@Post()
async create(@Req() req: any, @Body() dto: CreatePersonalityDto): Promise<Personality> {
@HttpCode(HttpStatus.CREATED)
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreatePersonalityDto): Promise<Personality> {
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<Personality> {
return this.personalitiesService.update(req.workspaceId, id, dto);
}
/**
* Delete a personality
*/
@Delete(":id")
async remove(@Req() req: any, @Param("id") id: string): Promise<Personality> {
@HttpCode(HttpStatus.OK)
async remove(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<Personality> {
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 };
}
}

View File

@@ -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 {}

View File

@@ -0,0 +1 @@
export * from "./prompt-formatter.service";

View File

@@ -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>(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");
});
});
});

View File

@@ -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<string, string>;
}
export interface FormattedPrompt {
systemPrompt: string;
metadata: {
personalityId: string;
personalityName: string;
tone: string;
formalityLevel: FormalityLevel;
formattedAt: Date;
};
}
const FORMALITY_MODIFIERS: Record<FormalityLevel, string> = {
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,
}));
}
}