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 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 12:44:50 -06:00
parent 1f97e6de40
commit 64cb5c1edd
12 changed files with 516 additions and 549 deletions

View File

@@ -921,28 +921,35 @@ model Personality {
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
// Identity // Identity
name String name String // unique identifier slug
displayName String @map("display_name")
description String? @db.Text description String? @db.Text
// Personality traits // System prompt
tone String systemPrompt String @map("system_prompt") @db.Text
formalityLevel FormalityLevel @map("formality_level")
// System prompt template // LLM configuration
systemPromptTemplate String @map("system_prompt_template") @db.Text 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 // Status
isDefault Boolean @default(false) @map("is_default") isDefault Boolean @default(false) @map("is_default")
isActive Boolean @default(true) @map("is_active") isEnabled Boolean @default(true) @map("is_enabled")
// Audit // Audit
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_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([id, workspaceId])
@@unique([workspaceId, name])
@@index([workspaceId]) @@index([workspaceId])
@@index([workspaceId, isDefault]) @@index([workspaceId, isDefault])
@@index([workspaceId, isActive]) @@index([workspaceId, isEnabled])
@@index([llmProviderInstanceId])
@@map("personalities") @@map("personalities")
} }
@@ -962,7 +969,8 @@ model LlmProviderInstance {
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
// Relations // 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([userId])
@@index([providerType]) @@index([providerType])

View File

@@ -1,37 +1,53 @@
import { IsString, IsIn, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator"; import {
IsString,
export const FORMALITY_LEVELS = [ IsOptional,
"VERY_CASUAL", IsBoolean,
"CASUAL", IsNumber,
"NEUTRAL", IsInt,
"FORMAL", IsUUID,
"VERY_FORMAL", MinLength,
] as const; MaxLength,
Min,
export type FormalityLevel = (typeof FORMALITY_LEVELS)[number]; Max,
} from "class-validator";
/**
* DTO for creating a new personality/assistant configuration
*/
export class CreatePersonalityDto { export class CreatePersonalityDto {
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@MaxLength(100) @MaxLength(100)
name!: string; name!: string; // unique identifier slug
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@MaxLength(50) @MaxLength(200)
tone!: string; displayName!: string; // human-readable name
@IsIn(FORMALITY_LEVELS) @IsOptional()
formalityLevel!: FormalityLevel; @IsString()
@MaxLength(1000)
description?: string;
@IsString() @IsString()
@MinLength(10) @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() @IsOptional()
@IsBoolean() @IsBoolean()
@@ -39,5 +55,5 @@ export class CreatePersonalityDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
isActive?: boolean; isEnabled?: boolean;
} }

View File

@@ -1,32 +1,56 @@
import { IsString, IsOptional, IsBoolean, MinLength, MaxLength, IsIn } from "class-validator"; import {
import { FORMALITY_LEVELS, FormalityLevel } from "./create-personality.dto"; IsString,
IsOptional,
IsBoolean,
IsNumber,
IsInt,
IsUUID,
MinLength,
MaxLength,
Min,
Max,
} from "class-validator";
/**
* DTO for updating an existing personality/assistant configuration
*/
export class UpdatePersonalityDto { export class UpdatePersonalityDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@MaxLength(100) @MaxLength(100)
name?: string; name?: string; // unique identifier slug
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@MaxLength(50) @MaxLength(200)
tone?: string; displayName?: string; // human-readable name
@IsOptional() @IsOptional()
@IsIn(FORMALITY_LEVELS) @IsString()
formalityLevel?: FormalityLevel; @MaxLength(1000)
description?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MinLength(10) @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() @IsOptional()
@IsBoolean() @IsBoolean()
@@ -34,5 +58,5 @@ export class UpdatePersonalityDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
isActive?: boolean; isEnabled?: boolean;
} }

View File

@@ -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 { export class Personality implements PrismaPersonality {
id!: string; id!: string;
workspaceId!: string; workspaceId!: string;
name!: string; name!: string; // unique identifier slug
displayName!: string; // human-readable name
description!: string | null; description!: string | null;
tone!: string; systemPrompt!: string;
formalityLevel!: FormalityLevel; temperature!: number | null; // null = use provider default
systemPromptTemplate!: string; maxTokens!: number | null; // null = use provider default
llmProviderInstanceId!: string | null; // FK to LlmProviderInstance
isDefault!: boolean; isDefault!: boolean;
isActive!: boolean; isEnabled!: boolean;
createdAt!: Date; createdAt!: Date;
updatedAt!: Date; updatedAt!: Date;
} }

View File

@@ -2,51 +2,57 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { PersonalitiesController } from "./personalities.controller"; import { PersonalitiesController } from "./personalities.controller";
import { PersonalitiesService } from "./personalities.service"; import { PersonalitiesService } from "./personalities.service";
import { PromptFormatterService } from "./services/prompt-formatter.service"; import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
import { AuthGuard } from "../auth/guards/auth.guard"; import { AuthGuard } from "../auth/guards/auth.guard";
describe("PersonalitiesController", () => { describe("PersonalitiesController", () => {
let controller: PersonalitiesController; let controller: PersonalitiesController;
let service: PersonalitiesService; let service: PersonalitiesService;
let promptFormatter: PromptFormatterService;
const mockWorkspaceId = "workspace-123"; const mockWorkspaceId = "workspace-123";
const mockUserId = "user-123";
const mockPersonalityId = "personality-123"; const mockPersonalityId = "personality-123";
const mockRequest = { user: { id: "user-123" }, workspaceId: mockWorkspaceId };
const mockPersonality = { const mockPersonality = {
id: mockPersonalityId, id: mockPersonalityId,
workspaceId: mockWorkspaceId, workspaceId: mockWorkspaceId,
name: "Professional", name: "professional-assistant",
tone: "professional", displayName: "Professional Assistant",
formalityLevel: "FORMAL", description: "A professional communication assistant",
systemPromptTemplate: "You are a professional assistant.", systemPrompt: "You are a professional assistant who helps with tasks.",
temperature: 0.7,
maxTokens: 2000,
llmProviderInstanceId: "provider-123",
isDefault: true, isDefault: true,
isActive: true, isEnabled: true,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}; };
const mockPersonalitiesService = { const mockRequest = {
findAll: vi.fn(), user: { id: mockUserId },
findOne: vi.fn(), workspaceId: mockWorkspaceId,
findDefault: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
}; };
const mockPromptFormatterService = { const mockPersonalitiesService = {
formatPrompt: vi.fn(), create: vi.fn(),
getFormalityLevels: 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 () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [PersonalitiesController], controllers: [PersonalitiesController],
providers: [ providers: [
{ provide: PersonalitiesService, useValue: mockPersonalitiesService }, {
{ provide: PromptFormatterService, useValue: mockPromptFormatterService }, provide: PersonalitiesService,
useValue: mockPersonalitiesService,
},
], ],
}) })
.overrideGuard(AuthGuard) .overrideGuard(AuthGuard)
@@ -55,85 +61,119 @@ describe("PersonalitiesController", () => {
controller = module.get<PersonalitiesController>(PersonalitiesController); controller = module.get<PersonalitiesController>(PersonalitiesController);
service = module.get<PersonalitiesService>(PersonalitiesService); service = module.get<PersonalitiesService>(PersonalitiesService);
promptFormatter = module.get<PromptFormatterService>(PromptFormatterService);
// Reset mocks
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe("findAll", () => { describe("findAll", () => {
it("should return all personalities", async () => { it("should return all personalities", async () => {
mockPersonalitiesService.findAll.mockResolvedValue([mockPersonality]); const mockPersonalities = [mockPersonality];
mockPersonalitiesService.findAll.mockResolvedValue(mockPersonalities);
const result = await controller.findAll(mockRequest); 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", () => { describe("findOne", () => {
it("should return a personality by id", async () => { it("should return a personality by id", async () => {
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality); mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
const result = await controller.findOne(mockRequest, mockPersonalityId); const result = await controller.findOne(mockRequest, mockPersonalityId);
expect(result).toEqual(mockPersonality); 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", () => { describe("findDefault", () => {
it("should return the default personality", async () => { it("should return the default personality", async () => {
mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality); mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality);
const result = await controller.findDefault(mockRequest);
expect(result).toEqual(mockPersonality);
});
});
describe("getFormalityLevels", () => { const result = await controller.findDefault(mockRequest);
it("should return formality levels", () => {
const levels = [{ level: "FORMAL", description: "Professional" }]; expect(result).toEqual(mockPersonality);
mockPromptFormatterService.getFormalityLevels.mockReturnValue(levels); expect(service.findDefault).toHaveBeenCalledWith(mockWorkspaceId);
const result = controller.getFormalityLevels();
expect(result).toEqual(levels);
}); });
}); });
describe("create", () => { describe("create", () => {
it("should create a new personality", async () => { it("should create a new personality", async () => {
const createDto = { const createDto: CreatePersonalityDto = {
name: "Casual", name: "casual-helper",
tone: "casual", displayName: "Casual Helper",
formalityLevel: "CASUAL" as const, description: "A casual helper",
systemPromptTemplate: "You are a casual assistant.", 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); expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto);
}); });
}); });
describe("update", () => { describe("update", () => {
it("should update a personality", async () => { it("should update a personality", async () => {
const updateDto = { description: "Updated" }; const updateDto: UpdatePersonalityDto = {
mockPersonalitiesService.update.mockResolvedValue({ ...mockPersonality, ...updateDto }); description: "Updated description",
await controller.update(mockRequest, mockPersonalityId, updateDto); 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); expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto);
}); });
}); });
describe("remove", () => { describe("delete", () => {
it("should delete a personality", async () => { it("should delete a personality", async () => {
mockPersonalitiesService.remove.mockResolvedValue(mockPersonality); mockPersonalitiesService.delete.mockResolvedValue(undefined);
await controller.remove(mockRequest, mockPersonalityId);
expect(service.remove).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId); await controller.delete(mockRequest, mockPersonalityId);
expect(service.delete).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
}); });
}); });
describe("previewPrompt", () => { describe("setDefault", () => {
it("should return formatted system prompt", async () => { it("should set a personality as default", async () => {
const context = { userName: "John" }; mockPersonalitiesService.setDefault.mockResolvedValue({
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality); ...mockPersonality,
mockPromptFormatterService.formatPrompt.mockReturnValue({ isDefault: true,
systemPrompt: "Formatted prompt",
metadata: {},
}); });
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);
}); });
}); });
}); });

View File

@@ -2,20 +2,17 @@ import {
Controller, Controller,
Get, Get,
Post, Post,
Put, Patch,
Delete, Delete,
Body, Body,
Param, Param,
Query,
UseGuards, UseGuards,
Req, Req,
ParseBoolPipe,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
} from "@nestjs/common"; } from "@nestjs/common";
import { AuthGuard } from "../auth/guards/auth.guard"; import { AuthGuard } from "../auth/guards/auth.guard";
import { PersonalitiesService } from "./personalities.service"; import { PersonalitiesService } from "./personalities.service";
import { PromptFormatterService, PromptContext } from "./services/prompt-formatter.service";
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto"; import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
import { Personality } from "./entities/personality.entity"; import { Personality } from "./entities/personality.entity";
@@ -24,37 +21,52 @@ interface AuthenticatedRequest {
workspaceId: string; workspaceId: string;
} }
@Controller("personalities") /**
* Controller for managing personality/assistant configurations
*/
@Controller("personality")
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
export class PersonalitiesController { export class PersonalitiesController {
constructor( constructor(private readonly personalitiesService: PersonalitiesService) {}
private readonly personalitiesService: PersonalitiesService,
private readonly promptFormatter: PromptFormatterService
) {}
/**
* List all personalities for the workspace
*/
@Get() @Get()
async findAll( async findAll(@Req() req: AuthenticatedRequest): Promise<Personality[]> {
@Req() req: AuthenticatedRequest, return this.personalitiesService.findAll(req.workspaceId);
@Query("isActive", new ParseBoolPipe({ optional: true })) isActive?: boolean
): Promise<Personality[]> {
return this.personalitiesService.findAll(req.workspaceId, isActive);
} }
/**
* Get the default personality for the workspace
*/
@Get("default") @Get("default")
async findDefault(@Req() req: AuthenticatedRequest): Promise<Personality> { async findDefault(@Req() req: AuthenticatedRequest): Promise<Personality> {
return this.personalitiesService.findDefault(req.workspaceId); return this.personalitiesService.findDefault(req.workspaceId);
} }
@Get("formality-levels") /**
getFormalityLevels(): { level: string; description: string }[] { * Get a personality by its unique name
return this.promptFormatter.getFormalityLevels(); */
@Get("by-name/:name")
async findByName(
@Req() req: AuthenticatedRequest,
@Param("name") name: string
): Promise<Personality> {
return this.personalitiesService.findByName(req.workspaceId, name);
} }
/**
* Get a personality by ID
*/
@Get(":id") @Get(":id")
async findOne(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<Personality> { async findOne(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<Personality> {
return this.personalitiesService.findOne(req.workspaceId, id); return this.personalitiesService.findOne(req.workspaceId, id);
} }
/**
* Create a new personality
*/
@Post() @Post()
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
async create( async create(
@@ -64,7 +76,10 @@ export class PersonalitiesController {
return this.personalitiesService.create(req.workspaceId, dto); return this.personalitiesService.create(req.workspaceId, dto);
} }
@Put(":id") /**
* Update a personality
*/
@Patch(":id")
async update( async update(
@Req() req: AuthenticatedRequest, @Req() req: AuthenticatedRequest,
@Param("id") id: string, @Param("id") id: string,
@@ -73,20 +88,23 @@ export class PersonalitiesController {
return this.personalitiesService.update(req.workspaceId, id, dto); return this.personalitiesService.update(req.workspaceId, id, dto);
} }
/**
* Delete a personality
*/
@Delete(":id") @Delete(":id")
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.NO_CONTENT)
async remove(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<Personality> { async delete(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<void> {
return this.personalitiesService.remove(req.workspaceId, id); 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, @Req() req: AuthenticatedRequest,
@Param("id") id: string, @Param("id") id: string
@Body() context?: PromptContext ): Promise<Personality> {
): Promise<{ systemPrompt: string }> { return this.personalitiesService.setDefault(req.workspaceId, id);
const personality = await this.personalitiesService.findOne(req.workspaceId, id);
const { systemPrompt } = this.promptFormatter.formatPrompt(personality, context);
return { systemPrompt };
} }
} }

View File

@@ -3,12 +3,11 @@ import { PrismaModule } from "../prisma/prisma.module";
import { AuthModule } from "../auth/auth.module"; import { AuthModule } from "../auth/auth.module";
import { PersonalitiesService } from "./personalities.service"; import { PersonalitiesService } from "./personalities.service";
import { PersonalitiesController } from "./personalities.controller"; import { PersonalitiesController } from "./personalities.controller";
import { PromptFormatterService } from "./services/prompt-formatter.service";
@Module({ @Module({
imports: [PrismaModule, AuthModule], imports: [PrismaModule, AuthModule],
controllers: [PersonalitiesController], controllers: [PersonalitiesController],
providers: [PersonalitiesService, PromptFormatterService], providers: [PersonalitiesService],
exports: [PersonalitiesService, PromptFormatterService], exports: [PersonalitiesService],
}) })
export class PersonalitiesModule {} export class PersonalitiesModule {}

View File

@@ -10,19 +10,21 @@ describe("PersonalitiesService", () => {
let prisma: PrismaService; let prisma: PrismaService;
const mockWorkspaceId = "workspace-123"; const mockWorkspaceId = "workspace-123";
const mockUserId = "user-123";
const mockPersonalityId = "personality-123"; const mockPersonalityId = "personality-123";
const mockProviderId = "provider-123";
const mockPersonality = { const mockPersonality = {
id: mockPersonalityId, id: mockPersonalityId,
workspaceId: mockWorkspaceId, workspaceId: mockWorkspaceId,
name: "Professional", name: "professional-assistant",
description: "Professional communication style", displayName: "Professional Assistant",
tone: "professional", description: "A professional communication assistant",
formalityLevel: "FORMAL" as const, systemPrompt: "You are a professional assistant who helps with tasks.",
systemPromptTemplate: "You are a professional assistant.", temperature: 0.7,
maxTokens: 2000,
llmProviderInstanceId: mockProviderId,
isDefault: true, isDefault: true,
isActive: true, isEnabled: true,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}; };
@@ -58,82 +60,15 @@ describe("PersonalitiesService", () => {
vi.clearAllMocks(); 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", () => { describe("create", () => {
const createDto: CreatePersonalityDto = { const createDto: CreatePersonalityDto = {
name: "Casual", name: "casual-helper",
description: "Casual communication style", displayName: "Casual Helper",
tone: "casual", description: "A casual communication helper",
formalityLevel: "CASUAL", systemPrompt: "You are a casual assistant.",
systemPromptTemplate: "You are a casual assistant.", temperature: 0.8,
maxTokens: 1500,
llmProviderInstanceId: mockProviderId,
}; };
it("should create a new personality", async () => { it("should create a new personality", async () => {
@@ -142,6 +77,8 @@ describe("PersonalitiesService", () => {
...mockPersonality, ...mockPersonality,
...createDto, ...createDto,
id: "new-personality-id", id: "new-personality-id",
isDefault: false,
isEnabled: true,
}); });
const result = await service.create(mockWorkspaceId, createDto); const result = await service.create(mockWorkspaceId, createDto);
@@ -150,7 +87,15 @@ describe("PersonalitiesService", () => {
expect(prisma.personality.create).toHaveBeenCalledWith({ expect(prisma.personality.create).toHaveBeenCalledWith({
data: { data: {
workspaceId: mockWorkspaceId, 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", () => { describe("update", () => {
const updateDto: UpdatePersonalityDto = { const updateDto: UpdatePersonalityDto = {
description: "Updated description", description: "Updated description",
tone: "updated", temperature: 0.9,
}; };
it("should update a personality", async () => { it("should update a personality", async () => {
@@ -212,13 +239,13 @@ describe("PersonalitiesService", () => {
it("should throw NotFoundException when personality not found", async () => { it("should throw NotFoundException when personality not found", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(null); mockPrismaService.personality.findUnique.mockResolvedValue(null);
await expect( await expect(service.update(mockWorkspaceId, mockPersonalityId, updateDto)).rejects.toThrow(
service.update(mockWorkspaceId, mockPersonalityId, updateDto), NotFoundException
).rejects.toThrow(NotFoundException); );
}); });
it("should throw ConflictException when updating to existing name", async () => { 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.findUnique.mockResolvedValue(mockPersonality);
mockPrismaService.personality.findFirst.mockResolvedValue({ mockPrismaService.personality.findFirst.mockResolvedValue({
...mockPersonality, ...mockPersonality,
@@ -226,19 +253,40 @@ describe("PersonalitiesService", () => {
}); });
await expect( await expect(
service.update(mockWorkspaceId, mockPersonalityId, updateNameDto), service.update(mockWorkspaceId, mockPersonalityId, updateNameDto)
).rejects.toThrow(ConflictException); ).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 () => { it("should delete a personality", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality); 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({ expect(prisma.personality.delete).toHaveBeenCalledWith({
where: { id: mockPersonalityId }, where: { id: mockPersonalityId },
}); });
@@ -247,8 +295,41 @@ describe("PersonalitiesService", () => {
it("should throw NotFoundException when personality not found", async () => { it("should throw NotFoundException when personality not found", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(null); mockPrismaService.personality.findUnique.mockResolvedValue(null);
await expect(service.remove(mockWorkspaceId, mockPersonalityId)).rejects.toThrow( await expect(service.delete(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
NotFoundException, 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
); );
}); });
}); });

View File

@@ -3,52 +3,15 @@ import { PrismaService } from "../prisma/prisma.service";
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto"; import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
import { Personality } from "./entities/personality.entity"; import { Personality } from "./entities/personality.entity";
/**
* Service for managing personality/assistant configurations
*/
@Injectable() @Injectable()
export class PersonalitiesService { export class PersonalitiesService {
private readonly logger = new Logger(PersonalitiesService.name); private readonly logger = new Logger(PersonalitiesService.name);
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
/**
* Find all personalities for a workspace
*/
async findAll(workspaceId: string, isActive = true): Promise<Personality[]> {
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<Personality> {
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<Personality> {
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 * Create a new personality
*/ */
@@ -68,15 +31,79 @@ export class PersonalitiesService {
} }
const personality = await this.prisma.personality.create({ const personality = await this.prisma.personality.create({
data: Object.assign({}, dto, { data: {
workspaceId, 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}`); this.logger.log(`Created personality ${personality.id} for workspace ${workspaceId}`);
return personality; return personality;
} }
/**
* Find all personalities for a workspace
*/
async findAll(workspaceId: string): Promise<Personality[]> {
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<Personality> {
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<Personality> {
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<Personality> {
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 * Update an existing personality
*/ */
@@ -112,15 +139,34 @@ export class PersonalitiesService {
/** /**
* Delete a personality * Delete a personality
*/ */
async remove(workspaceId: string, id: string): Promise<Personality> { async delete(workspaceId: string, id: string): Promise<void> {
// Check existence // Check existence
await this.findOne(workspaceId, id); await this.findOne(workspaceId, id);
const personality = await this.prisma.personality.delete({ await this.prisma.personality.delete({
where: { id }, where: { id },
}); });
this.logger.log(`Deleted personality ${id} from workspace ${workspaceId}`); this.logger.log(`Deleted personality ${id} from workspace ${workspaceId}`);
}
/**
* Set a personality as the default
*/
async setDefault(workspaceId: string, id: string): Promise<Personality> {
// 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; return personality;
} }

View File

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

View File

@@ -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>(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

@@ -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<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: 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,
}));
}
}