Merge develop to resolve conflicts

This commit is contained in:
Jason Woltje
2026-01-29 19:45:29 -06:00
31 changed files with 1330 additions and 146 deletions

View File

@@ -15,7 +15,7 @@ import { LayoutsModule } from "./layouts/layouts.module";
import { KnowledgeModule } from "./knowledge/knowledge.module";
import { UsersModule } from "./users/users.module";
import { WebSocketModule } from "./websocket/websocket.module";
import { OllamaModule } from "./ollama/ollama.module";
import { LlmModule } from "./llm/llm.module";
import { BrainModule } from "./brain/brain.module";
@Module({
@@ -34,7 +34,7 @@ import { BrainModule } from "./brain/brain.module";
KnowledgeModule,
UsersModule,
WebSocketModule,
OllamaModule,
LlmModule,
BrainModule,
],
controllers: [AppController],

View File

@@ -1,2 +1,6 @@
export { LinkResolutionService } from "./link-resolution.service";
export type { ResolvedEntry } from "./link-resolution.service";
export type {
ResolvedEntry,
ResolvedLink,
Backlink,
} from "./link-resolution.service";

View File

@@ -63,6 +63,9 @@ describe("LinkResolutionService", () => {
findFirst: vi.fn(),
findMany: vi.fn(),
},
knowledgeLink: {
findMany: vi.fn(),
},
};
beforeEach(async () => {
@@ -403,4 +406,186 @@ describe("LinkResolutionService", () => {
expect(result).toHaveLength(1);
});
});
describe("resolveLinksFromContent", () => {
it("should parse and resolve wiki links from content", async () => {
const content =
"Check out [[TypeScript Guide]] and [[React Hooks]] for more info.";
// Mock resolveLink for each target
mockPrismaService.knowledgeEntry.findFirst
.mockResolvedValueOnce({ id: "entry-1" }) // TypeScript Guide
.mockResolvedValueOnce({ id: "entry-2" }); // React Hooks
const result = await service.resolveLinksFromContent(content, workspaceId);
expect(result).toHaveLength(2);
expect(result[0].link.target).toBe("TypeScript Guide");
expect(result[0].entryId).toBe("entry-1");
expect(result[1].link.target).toBe("React Hooks");
expect(result[1].entryId).toBe("entry-2");
});
it("should return null entryId for unresolved links", async () => {
const content = "See [[Non-existent Page]] for details.";
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
const result = await service.resolveLinksFromContent(content, workspaceId);
expect(result).toHaveLength(1);
expect(result[0].link.target).toBe("Non-existent Page");
expect(result[0].entryId).toBeNull();
});
it("should return empty array for content with no wiki links", async () => {
const content = "This content has no wiki links.";
const result = await service.resolveLinksFromContent(content, workspaceId);
expect(result).toEqual([]);
expect(mockPrismaService.knowledgeEntry.findFirst).not.toHaveBeenCalled();
});
it("should handle content with display text syntax", async () => {
const content = "Read the [[typescript-guide|TS Guide]] first.";
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce({
id: "entry-1",
});
const result = await service.resolveLinksFromContent(content, workspaceId);
expect(result).toHaveLength(1);
expect(result[0].link.target).toBe("typescript-guide");
expect(result[0].link.displayText).toBe("TS Guide");
expect(result[0].entryId).toBe("entry-1");
});
it("should preserve link position information", async () => {
const content = "Start [[Page One]] middle [[Page Two]] end.";
mockPrismaService.knowledgeEntry.findFirst
.mockResolvedValueOnce({ id: "entry-1" })
.mockResolvedValueOnce({ id: "entry-2" });
const result = await service.resolveLinksFromContent(content, workspaceId);
expect(result).toHaveLength(2);
expect(result[0].link.start).toBe(6);
expect(result[0].link.end).toBe(18);
expect(result[1].link.start).toBe(26);
expect(result[1].link.end).toBe(38);
});
});
describe("getBacklinks", () => {
it("should return all entries that link to the target entry", async () => {
const targetEntryId = "entry-target";
const mockBacklinks = [
{
id: "link-1",
sourceId: "entry-1",
targetId: targetEntryId,
linkText: "Target Page",
displayText: "Target Page",
positionStart: 10,
positionEnd: 25,
resolved: true,
context: null,
createdAt: new Date(),
source: {
id: "entry-1",
title: "TypeScript Guide",
slug: "typescript-guide",
},
},
{
id: "link-2",
sourceId: "entry-2",
targetId: targetEntryId,
linkText: "Target Page",
displayText: "See Target",
positionStart: 50,
positionEnd: 70,
resolved: true,
context: null,
createdAt: new Date(),
source: {
id: "entry-2",
title: "React Hooks",
slug: "react-hooks",
},
},
];
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce(
mockBacklinks
);
const result = await service.getBacklinks(targetEntryId);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
sourceId: "entry-1",
sourceTitle: "TypeScript Guide",
sourceSlug: "typescript-guide",
linkText: "Target Page",
displayText: "Target Page",
});
expect(result[1]).toEqual({
sourceId: "entry-2",
sourceTitle: "React Hooks",
sourceSlug: "react-hooks",
linkText: "Target Page",
displayText: "See Target",
});
expect(mockPrismaService.knowledgeLink.findMany).toHaveBeenCalledWith({
where: {
targetId: targetEntryId,
resolved: true,
},
include: {
source: {
select: {
id: true,
title: true,
slug: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
});
it("should return empty array when no backlinks exist", async () => {
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce([]);
const result = await service.getBacklinks("entry-with-no-backlinks");
expect(result).toEqual([]);
});
it("should only return resolved backlinks", async () => {
const targetEntryId = "entry-target";
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce([]);
await service.getBacklinks(targetEntryId);
expect(mockPrismaService.knowledgeLink.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
resolved: true,
}),
})
);
});
});
});

View File

@@ -1,5 +1,6 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../../prisma/prisma.service";
import { parseWikiLinks, WikiLink } from "../utils/wiki-link-parser";
/**
* Represents a knowledge entry that matches a link target
@@ -9,6 +10,32 @@ export interface ResolvedEntry {
title: string;
}
/**
* Represents a resolved wiki link with entry information
*/
export interface ResolvedLink {
/** The parsed wiki link */
link: WikiLink;
/** The resolved entry ID, or null if not found */
entryId: string | null;
}
/**
* Represents a backlink - an entry that links to a target entry
*/
export interface Backlink {
/** The source entry ID */
sourceId: string;
/** The source entry title */
sourceTitle: string;
/** The source entry slug */
sourceSlug: string;
/** The link text used to reference the target */
linkText: string;
/** The display text shown for the link */
displayText: string;
}
/**
* Service for resolving wiki-style links to knowledge entries
*
@@ -165,4 +192,72 @@ export class LinkResolutionService {
return matches;
}
/**
* Parse wiki links from content and resolve them to knowledge entries
*
* @param content - The markdown content containing wiki links
* @param workspaceId - The workspace scope for resolution
* @returns Array of resolved links with entry IDs (or null if not found)
*/
async resolveLinksFromContent(
content: string,
workspaceId: string
): Promise<ResolvedLink[]> {
// Parse wiki links from content
const parsedLinks = parseWikiLinks(content);
if (parsedLinks.length === 0) {
return [];
}
// Resolve each link
const resolvedLinks: ResolvedLink[] = [];
for (const link of parsedLinks) {
const entryId = await this.resolveLink(workspaceId, link.target);
resolvedLinks.push({
link,
entryId,
});
}
return resolvedLinks;
}
/**
* Get all entries that link TO a specific entry (backlinks)
*
* @param entryId - The target entry ID
* @returns Array of backlinks with source entry information
*/
async getBacklinks(entryId: string): Promise<Backlink[]> {
// Find all links where this entry is the target
const links = await this.prisma.knowledgeLink.findMany({
where: {
targetId: entryId,
resolved: true,
},
include: {
source: {
select: {
id: true,
title: true,
slug: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
return links.map((link) => ({
sourceId: link.source.id,
sourceTitle: link.source.title,
sourceSlug: link.source.slug,
linkText: link.linkText,
displayText: link.displayText,
}));
}
}

View File

@@ -0,0 +1,7 @@
import { IsArray, IsString, IsOptional, IsBoolean, IsNumber, ValidateNested, IsIn } from "class-validator";
import { Type } from "class-transformer";
export type ChatRole = "system" | "user" | "assistant";
export class ChatMessageDto { @IsString() @IsIn(["system", "user", "assistant"]) role!: ChatRole; @IsString() content!: string; }
export class ChatRequestDto { @IsString() model!: string; @IsArray() @ValidateNested({ each: true }) @Type(() => ChatMessageDto) messages!: ChatMessageDto[]; @IsOptional() @IsBoolean() stream?: boolean; @IsOptional() @IsNumber() temperature?: number; @IsOptional() @IsNumber() maxTokens?: number; @IsOptional() @IsString() systemPrompt?: string; }
export interface ChatResponseDto { model: string; message: { role: ChatRole; content: string }; done: boolean; totalDuration?: number; promptEvalCount?: number; evalCount?: number; }
export interface ChatStreamChunkDto { model: string; message: { role: ChatRole; content: string }; done: boolean; }

View File

@@ -0,0 +1,3 @@
import { IsArray, IsString, IsOptional } from "class-validator";
export class EmbedRequestDto { @IsString() model!: string; @IsArray() @IsString({ each: true }) input!: string[]; @IsOptional() @IsString() truncate?: "start" | "end" | "none"; }
export interface EmbedResponseDto { model: string; embeddings: number[][]; totalDuration?: number; }

View File

@@ -0,0 +1,2 @@
export * from "./chat.dto";
export * from "./embed.dto";

View File

@@ -0,0 +1,4 @@
export * from "./llm.module";
export * from "./llm.service";
export * from "./llm.controller";
export * from "./dto";

View File

@@ -0,0 +1,15 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { LlmController } from "./llm.controller";
import { LlmService } from "./llm.service";
import type { ChatRequestDto, EmbedRequestDto } from "./dto";
describe("LlmController", () => {
let controller: LlmController;
const mockService = { checkHealth: vi.fn(), listModels: vi.fn(), chat: vi.fn(), chatStream: vi.fn(), embed: vi.fn() };
beforeEach(async () => { vi.clearAllMocks(); controller = (await Test.createTestingModule({ controllers: [LlmController], providers: [{ provide: LlmService, useValue: mockService }] }).compile()).get(LlmController); });
it("should be defined", () => { expect(controller).toBeDefined(); });
describe("health", () => { it("should return status", async () => { const s = { healthy: true, host: "h" }; mockService.checkHealth.mockResolvedValue(s); expect(await controller.health()).toEqual(s); }); });
describe("listModels", () => { it("should return models", async () => { mockService.listModels.mockResolvedValue(["m1"]); expect(await controller.listModels()).toEqual({ models: ["m1"] }); }); });
describe("chat", () => { const req: ChatRequestDto = { model: "m", messages: [{ role: "user", content: "x" }] }; const res = { setHeader: vi.fn(), write: vi.fn(), end: vi.fn() }; it("should return response", async () => { const r = { model: "m", message: { role: "assistant", content: "y" }, done: true }; mockService.chat.mockResolvedValue(r); expect(await controller.chat(req, res as any)).toEqual(r); }); it("should stream", async () => { mockService.chatStream.mockReturnValue((async function* () { yield { model: "m", message: { role: "a", content: "x" }, done: true }; })()); await controller.chat({ ...req, stream: true }, res as any); expect(res.setHeader).toHaveBeenCalled(); expect(res.end).toHaveBeenCalled(); }); });
describe("embed", () => { it("should return embeddings", async () => { const r = { model: "m", embeddings: [[0.1]] }; mockService.embed.mockResolvedValue(r); expect(await controller.embed({ model: "m", input: ["x"] })).toEqual(r); }); });
});

View File

@@ -0,0 +1,12 @@
import { Controller, Post, Get, Body, Res, HttpCode, HttpStatus } from "@nestjs/common";
import { Response } from "express";
import { LlmService, OllamaHealthStatus } from "./llm.service";
import { ChatRequestDto, ChatResponseDto, EmbedRequestDto, EmbedResponseDto } from "./dto";
@Controller("llm")
export class LlmController {
constructor(private readonly llmService: LlmService) {}
@Get("health") async health(): Promise<OllamaHealthStatus> { return this.llmService.checkHealth(); }
@Get("models") async listModels(): Promise<{ models: string[] }> { return { models: await this.llmService.listModels() }; }
@Post("chat") @HttpCode(HttpStatus.OK) async chat(@Body() req: ChatRequestDto, @Res({ passthrough: true }) res: Response): Promise<ChatResponseDto | void> { if (req.stream === true) { res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); res.setHeader("X-Accel-Buffering", "no"); try { for await (const c of this.llmService.chatStream(req)) res.write("data: " + JSON.stringify(c) + "\n\n"); res.write("data: [DONE]\n\n"); res.end(); } catch (e: unknown) { res.write("data: " + JSON.stringify({ error: e instanceof Error ? e.message : String(e) }) + "\n\n"); res.end(); } return; } return this.llmService.chat(req); }
@Post("embed") @HttpCode(HttpStatus.OK) async embed(@Body() req: EmbedRequestDto): Promise<EmbedResponseDto> { return this.llmService.embed(req); }
}

View File

@@ -0,0 +1,5 @@
import { Module } from "@nestjs/common";
import { LlmController } from "./llm.controller";
import { LlmService } from "./llm.service";
@Module({ controllers: [LlmController], providers: [LlmService], exports: [LlmService] })
export class LlmModule {}

View File

@@ -0,0 +1,19 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { ServiceUnavailableException } from "@nestjs/common";
import { LlmService } from "./llm.service";
import type { ChatRequestDto, EmbedRequestDto } from "./dto";
const mockList = vi.fn(); const mockChat = vi.fn(); const mockEmbed = vi.fn();
vi.mock("ollama", () => ({ Ollama: class { list = mockList; chat = mockChat; embed = mockEmbed; } }));
describe("LlmService", () => {
let service: LlmService;
const originalEnv = { ...process.env };
beforeEach(async () => { process.env = { ...originalEnv, OLLAMA_HOST: "http://test:11434", OLLAMA_TIMEOUT: "60000" }; vi.clearAllMocks(); service = (await Test.createTestingModule({ providers: [LlmService] }).compile()).get(LlmService); });
afterEach(() => { process.env = originalEnv; });
it("should be defined", () => { expect(service).toBeDefined(); });
describe("checkHealth", () => { it("should return healthy", async () => { mockList.mockResolvedValue({ models: [{ name: "llama3.2" }] }); const r = await service.checkHealth(); expect(r.healthy).toBe(true); }); it("should return unhealthy on error", async () => { mockList.mockRejectedValue(new Error("fail")); const r = await service.checkHealth(); expect(r.healthy).toBe(false); }); });
describe("listModels", () => { it("should return models", async () => { mockList.mockResolvedValue({ models: [{ name: "llama3.2" }] }); expect(await service.listModels()).toEqual(["llama3.2"]); }); it("should throw on error", async () => { mockList.mockRejectedValue(new Error("fail")); await expect(service.listModels()).rejects.toThrow(ServiceUnavailableException); }); });
describe("chat", () => { const req: ChatRequestDto = { model: "llama3.2", messages: [{ role: "user", content: "Hi" }] }; it("should return response", async () => { mockChat.mockResolvedValue({ model: "llama3.2", message: { role: "assistant", content: "Hello" }, done: true }); const r = await service.chat(req); expect(r.message.content).toBe("Hello"); }); it("should throw on error", async () => { mockChat.mockRejectedValue(new Error("fail")); await expect(service.chat(req)).rejects.toThrow(ServiceUnavailableException); }); });
describe("chatStream", () => { it("should yield chunks", async () => { mockChat.mockResolvedValue((async function* () { yield { model: "m", message: { role: "a", content: "x" }, done: true }; })()); const chunks = []; for await (const c of service.chatStream({ model: "m", messages: [{ role: "user", content: "x" }], stream: true })) chunks.push(c); expect(chunks.length).toBe(1); }); });
describe("embed", () => { it("should return embeddings", async () => { mockEmbed.mockResolvedValue({ model: "m", embeddings: [[0.1]] }); const r = await service.embed({ model: "m", input: ["x"] }); expect(r.embeddings).toEqual([[0.1]]); }); });
});

View File

@@ -0,0 +1,20 @@
import { Injectable, OnModuleInit, Logger, ServiceUnavailableException } from "@nestjs/common";
import { Ollama, Message } from "ollama";
import type { ChatRequestDto, ChatResponseDto, EmbedRequestDto, EmbedResponseDto, ChatStreamChunkDto } from "./dto";
export interface OllamaConfig { host: string; timeout?: number; }
export interface OllamaHealthStatus { healthy: boolean; host: string; error?: string; models?: string[]; }
@Injectable()
export class LlmService implements OnModuleInit {
private readonly logger = new Logger(LlmService.name);
private client: Ollama;
private readonly config: OllamaConfig;
constructor() { this.config = { host: process.env["OLLAMA_HOST"] ?? "http://localhost:11434", timeout: parseInt(process.env["OLLAMA_TIMEOUT"] ?? "120000", 10) }; this.client = new Ollama({ host: this.config.host }); this.logger.log("Ollama service initialized"); }
async onModuleInit(): Promise<void> { const h = await this.checkHealth(); if (h.healthy) this.logger.log("Ollama healthy"); else this.logger.warn("Ollama unhealthy: " + (h.error ?? "unknown")); }
async checkHealth(): Promise<OllamaHealthStatus> { try { const r = await this.client.list(); return { healthy: true, host: this.config.host, models: r.models.map(m => m.name) }; } catch (e: unknown) { return { healthy: false, host: this.config.host, error: e instanceof Error ? e.message : String(e) }; } }
async listModels(): Promise<string[]> { try { return (await this.client.list()).models.map(m => m.name); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); this.logger.error("Failed to list models: " + msg); throw new ServiceUnavailableException("Failed to list models: " + msg); } }
async chat(request: ChatRequestDto): Promise<ChatResponseDto> { try { const msgs = this.buildMessages(request); const r = await this.client.chat({ model: request.model, messages: msgs, stream: false, options: { temperature: request.temperature, num_predict: request.maxTokens } }); return { model: r.model, message: { role: r.message.role as "assistant", content: r.message.content }, done: r.done, totalDuration: r.total_duration, promptEvalCount: r.prompt_eval_count, evalCount: r.eval_count }; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); this.logger.error("Chat failed: " + msg); throw new ServiceUnavailableException("Chat completion failed: " + msg); } }
async *chatStream(request: ChatRequestDto): AsyncGenerator<ChatStreamChunkDto> { try { const stream = await this.client.chat({ model: request.model, messages: this.buildMessages(request), stream: true, options: { temperature: request.temperature, num_predict: request.maxTokens } }); for await (const c of stream) yield { model: c.model, message: { role: c.message.role as "assistant", content: c.message.content }, done: c.done }; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); this.logger.error("Stream failed: " + msg); throw new ServiceUnavailableException("Streaming failed: " + msg); } }
async embed(request: EmbedRequestDto): Promise<EmbedResponseDto> { try { const r = await this.client.embed({ model: request.model, input: request.input, truncate: request.truncate === "none" ? false : true }); return { model: r.model, embeddings: r.embeddings, totalDuration: r.total_duration }; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); this.logger.error("Embed failed: " + msg); throw new ServiceUnavailableException("Embedding failed: " + msg); } }
private buildMessages(req: ChatRequestDto): Message[] { const msgs: Message[] = []; if (req.systemPrompt && !req.messages.some(m => m.role === "system")) msgs.push({ role: "system", content: req.systemPrompt }); for (const m of req.messages) msgs.push({ role: m.role, content: m.content }); return msgs; }
getConfig(): OllamaConfig { return { ...this.config }; }
}

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

View File

@@ -135,6 +135,15 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
this.logger.debug(`Emitted event:deleted to ${room}`);
}
/**
* Emit project:created event to workspace room
*/
emitProjectCreated(workspaceId: string, project: Project): void {
const room = this.getWorkspaceRoom(workspaceId);
this.server.to(room).emit('project:created', project);
this.logger.debug(`Emitted project:created to ${room}`);
}
/**
* Emit project:updated event to workspace room
*/
@@ -144,6 +153,15 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
this.logger.debug(`Emitted project:updated to ${room}`);
}
/**
* Emit project:deleted event to workspace room
*/
emitProjectDeleted(workspaceId: string, projectId: string): void {
const room = this.getWorkspaceRoom(workspaceId);
this.server.to(room).emit('project:deleted', { id: projectId });
this.logger.debug(`Emitted project:deleted to ${room}`);
}
/**
* Get workspace room name
*/