feat(#82): implement Personality Module

- Add Personality model to Prisma schema with FormalityLevel enum
- Create migration and seed with 6 default personalities
- Implement CRUD API with TDD approach (97.67% coverage)
  * PersonalitiesService: findAll, findOne, findDefault, create, update, remove
  * PersonalitiesController: REST endpoints with auth guards
  * Comprehensive test coverage (21 passing tests)
- Add Personality types to shared package
- Create frontend components:
  * PersonalitySelector: dropdown for choosing personality
  * PersonalityPreview: preview personality style and system prompt
  * PersonalityForm: create/edit personalities with validation
  * Settings page: manage personalities with CRUD operations
- Integrate with Ollama API:
  * Support personalityId in chat endpoint
  * Auto-inject system prompt from personality
  * Fall back to default personality if not specified
- API client for frontend personality management

All tests passing with 97.67% backend coverage (exceeds 85% requirement)
This commit is contained in:
Jason Woltje
2026-01-29 17:57:54 -06:00
parent 95833fb4ea
commit 5dd46c85af
43 changed files with 4782 additions and 2 deletions

View File

@@ -15,6 +15,7 @@ import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { Workspace, Permission, RequirePermission } from "../common/decorators";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
import { LinkSyncService } from "./services/link-sync.service";
/**
* Controller for knowledge entry endpoints
@@ -24,7 +25,10 @@ import { CurrentUser } from "../auth/decorators/current-user.decorator";
@Controller("knowledge/entries")
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
export class KnowledgeController {
constructor(private readonly knowledgeService: KnowledgeService) {}
constructor(
private readonly knowledgeService: KnowledgeService,
private readonly linkSync: LinkSyncService
) {}
/**
* GET /api/knowledge/entries
@@ -100,4 +104,32 @@ export class KnowledgeController {
await this.knowledgeService.remove(workspaceId, slug, user.id);
return { message: "Entry archived successfully" };
}
/**
* GET /api/knowledge/entries/:slug/backlinks
* Get all backlinks for an entry
* Requires: Any workspace member
*/
@Get(":slug/backlinks")
@RequirePermission(Permission.WORKSPACE_ANY)
async getBacklinks(
@Workspace() workspaceId: string,
@Param("slug") slug: string
) {
// First find the entry to get its ID
const entry = await this.knowledgeService.findOne(workspaceId, slug);
// Get backlinks
const backlinks = await this.linkSync.getBacklinks(entry.id);
return {
entry: {
id: entry.id,
slug: entry.slug,
title: entry.title,
},
backlinks,
count: backlinks.length,
};
}
}

View File

@@ -12,13 +12,17 @@ import type {
PaginatedEntries,
} from "./entities/knowledge-entry.entity";
import { renderMarkdown } from "./utils/markdown";
import { LinkSyncService } from "./services/link-sync.service";
/**
* Service for managing knowledge entries
*/
@Injectable()
export class KnowledgeService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly linkSync: LinkSyncService
) {}
/**
@@ -225,6 +229,9 @@ export class KnowledgeService {
throw new Error("Failed to create entry");
}
// Sync wiki links after entry creation
await this.linkSync.syncLinks(workspaceId, result.id, createDto.content);
return {
id: result.id,
workspaceId: result.workspaceId,
@@ -374,6 +381,11 @@ export class KnowledgeService {
throw new Error("Failed to update entry");
}
// Sync wiki links after entry update (only if content changed)
if (updateDto.content !== undefined) {
await this.linkSync.syncLinks(workspaceId, result.id, result.content);
}
return {
id: result.id,
workspaceId: result.workspaceId,

View File

@@ -0,0 +1,410 @@
import { Test, TestingModule } from "@nestjs/testing";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { LinkSyncService } from "./link-sync.service";
import { LinkResolutionService } from "./link-resolution.service";
import { PrismaService } from "../../prisma/prisma.service";
import * as wikiLinkParser from "../utils/wiki-link-parser";
// Mock the wiki-link parser
vi.mock("../utils/wiki-link-parser");
const mockParseWikiLinks = vi.mocked(wikiLinkParser.parseWikiLinks);
describe("LinkSyncService", () => {
let service: LinkSyncService;
let prisma: PrismaService;
let linkResolver: LinkResolutionService;
const mockWorkspaceId = "workspace-1";
const mockEntryId = "entry-1";
const mockTargetId = "entry-2";
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
LinkSyncService,
{
provide: PrismaService,
useValue: {
knowledgeLink: {
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
},
$transaction: vi.fn((fn) => fn(prisma)),
},
},
{
provide: LinkResolutionService,
useValue: {
resolveLink: vi.fn(),
resolveLinks: vi.fn(),
},
},
],
}).compile();
service = module.get<LinkSyncService>(LinkSyncService);
prisma = module.get<PrismaService>(PrismaService);
linkResolver = module.get<LinkResolutionService>(LinkResolutionService);
});
afterEach(() => {
vi.clearAllMocks();
});
describe("syncLinks", () => {
it("should be defined", () => {
expect(service).toBeDefined();
});
it("should parse wiki links from content", async () => {
const content = "This is a [[Test Link]] in content";
mockParseWikiLinks.mockReturnValue([
{
raw: "[[Test Link]]",
target: "Test Link",
displayText: "Test Link",
start: 10,
end: 25,
},
]);
vi.spyOn(linkResolver, "resolveLink").mockResolvedValue(mockTargetId);
vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([]);
vi.spyOn(prisma.knowledgeLink, "create").mockResolvedValue({} as any);
await service.syncLinks(mockWorkspaceId, mockEntryId, content);
expect(mockParseWikiLinks).toHaveBeenCalledWith(content);
});
it("should create new links when parsing finds wiki links", async () => {
const content = "This is a [[Test Link]] in content";
mockParseWikiLinks.mockReturnValue([
{
raw: "[[Test Link]]",
target: "Test Link",
displayText: "Test Link",
start: 10,
end: 25,
},
]);
vi.spyOn(linkResolver, "resolveLink").mockResolvedValue(mockTargetId);
vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([]);
vi.spyOn(prisma.knowledgeLink, "create").mockResolvedValue({
id: "link-1",
sourceId: mockEntryId,
targetId: mockTargetId,
linkText: "Test Link",
displayText: "Test Link",
positionStart: 10,
positionEnd: 25,
resolved: true,
context: null,
createdAt: new Date(),
});
await service.syncLinks(mockWorkspaceId, mockEntryId, content);
expect(prisma.knowledgeLink.create).toHaveBeenCalledWith({
data: {
sourceId: mockEntryId,
targetId: mockTargetId,
linkText: "Test Link",
displayText: "Test Link",
positionStart: 10,
positionEnd: 25,
resolved: true,
},
});
});
it("should create unresolved links when target cannot be found", async () => {
const content = "This is a [[Nonexistent Link]] in content";
mockParseWikiLinks.mockReturnValue([
{
raw: "[[Nonexistent Link]]",
target: "Nonexistent Link",
displayText: "Nonexistent Link",
start: 10,
end: 32,
},
]);
vi.spyOn(linkResolver, "resolveLink").mockResolvedValue(null);
vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([]);
vi.spyOn(prisma.knowledgeLink, "create").mockResolvedValue({
id: "link-1",
sourceId: mockEntryId,
targetId: null,
linkText: "Nonexistent Link",
displayText: "Nonexistent Link",
positionStart: 10,
positionEnd: 32,
resolved: false,
context: null,
createdAt: new Date(),
});
await service.syncLinks(mockWorkspaceId, mockEntryId, content);
expect(prisma.knowledgeLink.create).toHaveBeenCalledWith({
data: {
sourceId: mockEntryId,
targetId: null,
linkText: "Nonexistent Link",
displayText: "Nonexistent Link",
positionStart: 10,
positionEnd: 32,
resolved: false,
},
});
});
it("should handle custom display text in links", async () => {
const content = "This is a [[Target|Custom Display]] in content";
mockParseWikiLinks.mockReturnValue([
{
raw: "[[Target|Custom Display]]",
target: "Target",
displayText: "Custom Display",
start: 10,
end: 35,
},
]);
vi.spyOn(linkResolver, "resolveLink").mockResolvedValue(mockTargetId);
vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([]);
vi.spyOn(prisma.knowledgeLink, "create").mockResolvedValue({} as any);
await service.syncLinks(mockWorkspaceId, mockEntryId, content);
expect(prisma.knowledgeLink.create).toHaveBeenCalledWith({
data: {
sourceId: mockEntryId,
targetId: mockTargetId,
linkText: "Target",
displayText: "Custom Display",
positionStart: 10,
positionEnd: 35,
resolved: true,
},
});
});
it("should delete orphaned links not present in updated content", async () => {
const content = "This is a [[New Link]] in content";
mockParseWikiLinks.mockReturnValue([
{
raw: "[[New Link]]",
target: "New Link",
displayText: "New Link",
start: 10,
end: 22,
},
]);
// Mock existing link that should be removed
vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([
{
id: "old-link-1",
sourceId: mockEntryId,
targetId: "old-target",
linkText: "Old Link",
displayText: "Old Link",
positionStart: 5,
positionEnd: 17,
resolved: true,
context: null,
createdAt: new Date(),
},
] as any);
vi.spyOn(linkResolver, "resolveLink").mockResolvedValue(mockTargetId);
vi.spyOn(prisma.knowledgeLink, "create").mockResolvedValue({} as any);
vi.spyOn(prisma.knowledgeLink, "deleteMany").mockResolvedValue({ count: 1 });
await service.syncLinks(mockWorkspaceId, mockEntryId, content);
expect(prisma.knowledgeLink.deleteMany).toHaveBeenCalledWith({
where: {
sourceId: mockEntryId,
id: {
in: ["old-link-1"],
},
},
});
});
it("should handle empty content by removing all links", async () => {
const content = "";
mockParseWikiLinks.mockReturnValue([]);
vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([
{
id: "link-1",
sourceId: mockEntryId,
targetId: mockTargetId,
linkText: "Link",
displayText: "Link",
positionStart: 10,
positionEnd: 18,
resolved: true,
context: null,
createdAt: new Date(),
},
] as any);
vi.spyOn(prisma.knowledgeLink, "deleteMany").mockResolvedValue({ count: 1 });
await service.syncLinks(mockWorkspaceId, mockEntryId, content);
expect(prisma.knowledgeLink.deleteMany).toHaveBeenCalledWith({
where: {
sourceId: mockEntryId,
id: {
in: ["link-1"],
},
},
});
});
it("should handle multiple links in content", async () => {
const content = "Links: [[Link 1]] and [[Link 2]] and [[Link 3]]";
mockParseWikiLinks.mockReturnValue([
{
raw: "[[Link 1]]",
target: "Link 1",
displayText: "Link 1",
start: 7,
end: 17,
},
{
raw: "[[Link 2]]",
target: "Link 2",
displayText: "Link 2",
start: 22,
end: 32,
},
{
raw: "[[Link 3]]",
target: "Link 3",
displayText: "Link 3",
start: 37,
end: 47,
},
]);
vi.spyOn(linkResolver, "resolveLink").mockResolvedValue(mockTargetId);
vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([]);
vi.spyOn(prisma.knowledgeLink, "create").mockResolvedValue({} as any);
await service.syncLinks(mockWorkspaceId, mockEntryId, content);
expect(prisma.knowledgeLink.create).toHaveBeenCalledTimes(3);
});
});
describe("getBacklinks", () => {
it("should return all backlinks for an entry", async () => {
const mockBacklinks = [
{
id: "link-1",
sourceId: "source-1",
targetId: mockEntryId,
linkText: "Link Text",
displayText: "Link Text",
positionStart: 10,
positionEnd: 25,
resolved: true,
context: null,
createdAt: new Date(),
source: {
id: "source-1",
title: "Source Entry",
slug: "source-entry",
},
},
];
vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue(mockBacklinks as any);
const result = await service.getBacklinks(mockEntryId);
expect(prisma.knowledgeLink.findMany).toHaveBeenCalledWith({
where: {
targetId: mockEntryId,
resolved: true,
},
include: {
source: {
select: {
id: true,
title: true,
slug: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
expect(result).toEqual(mockBacklinks);
});
it("should return empty array when no backlinks exist", async () => {
vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([]);
const result = await service.getBacklinks(mockEntryId);
expect(result).toEqual([]);
});
});
describe("getUnresolvedLinks", () => {
it("should return all unresolved links for a workspace", async () => {
const mockUnresolvedLinks = [
{
id: "link-1",
sourceId: mockEntryId,
targetId: null,
linkText: "Unresolved Link",
displayText: "Unresolved Link",
positionStart: 10,
positionEnd: 29,
resolved: false,
context: null,
createdAt: new Date(),
},
];
vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue(mockUnresolvedLinks as any);
const result = await service.getUnresolvedLinks(mockWorkspaceId);
expect(prisma.knowledgeLink.findMany).toHaveBeenCalledWith({
where: {
source: {
workspaceId: mockWorkspaceId,
},
resolved: false,
},
include: {
source: {
select: {
id: true,
title: true,
slug: true,
},
},
},
});
expect(result).toEqual(mockUnresolvedLinks);
});
});
});

View File

@@ -0,0 +1,201 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../../prisma/prisma.service";
import { LinkResolutionService } from "./link-resolution.service";
import { parseWikiLinks, WikiLink } from "../utils/wiki-link-parser";
/**
* Represents a backlink to a knowledge entry
*/
export interface Backlink {
id: string;
sourceId: string;
targetId: string;
linkText: string;
displayText: string;
positionStart: number;
positionEnd: number;
resolved: boolean;
context: string | null;
createdAt: Date;
source: {
id: string;
title: string;
slug: string;
};
}
/**
* Represents an unresolved wiki link
*/
export interface UnresolvedLink {
id: string;
sourceId: string;
targetId: string | null;
linkText: string;
displayText: string;
positionStart: number;
positionEnd: number;
resolved: boolean;
context: string | null;
createdAt: Date;
source: {
id: string;
title: string;
slug: string;
};
}
/**
* Service for synchronizing wiki-style links in knowledge entries
*
* Responsibilities:
* - Parse content for wiki links
* - Resolve links to knowledge entries
* - Store/update link records
* - Handle orphaned links
*/
@Injectable()
export class LinkSyncService {
constructor(
private readonly prisma: PrismaService,
private readonly linkResolver: LinkResolutionService
) {}
/**
* Sync links for a knowledge entry
* Parses content, resolves links, and updates the database
*
* @param workspaceId - The workspace scope
* @param entryId - The entry being updated
* @param content - The markdown content to parse
*/
async syncLinks(
workspaceId: string,
entryId: string,
content: string
): Promise<void> {
// Parse wiki links from content
const parsedLinks = parseWikiLinks(content);
// Get existing links for this entry
const existingLinks = await this.prisma.knowledgeLink.findMany({
where: {
sourceId: entryId,
},
});
// Resolve all parsed links
const linkCreations: Array<{
sourceId: string;
targetId: string | null;
linkText: string;
displayText: string;
positionStart: number;
positionEnd: number;
resolved: boolean;
}> = [];
for (const link of parsedLinks) {
const targetId = await this.linkResolver.resolveLink(
workspaceId,
link.target
);
linkCreations.push({
sourceId: entryId,
targetId: targetId,
linkText: link.target,
displayText: link.displayText,
positionStart: link.start,
positionEnd: link.end,
resolved: targetId !== null,
});
}
// Determine which existing links to keep/delete
// We'll use a simple strategy: delete all existing and recreate
// (In production, you might want to diff and only update changed links)
const existingLinkIds = existingLinks.map((link) => link.id);
// Delete all existing links and create new ones in a transaction
await this.prisma.$transaction(async (tx) => {
// Delete all existing links
if (existingLinkIds.length > 0) {
await tx.knowledgeLink.deleteMany({
where: {
sourceId: entryId,
id: {
in: existingLinkIds,
},
},
});
}
// Create new links
for (const linkData of linkCreations) {
await tx.knowledgeLink.create({
data: linkData,
});
}
});
}
/**
* Get all backlinks for an entry
* Returns entries that link TO this entry
*
* @param entryId - The target entry
* @returns Array of backlinks with source entry information
*/
async getBacklinks(entryId: string): Promise<Backlink[]> {
const backlinks = await this.prisma.knowledgeLink.findMany({
where: {
targetId: entryId,
resolved: true,
},
include: {
source: {
select: {
id: true,
title: true,
slug: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
return backlinks as Backlink[];
}
/**
* Get all unresolved links for a workspace
* Useful for finding broken links or pages that need to be created
*
* @param workspaceId - The workspace scope
* @returns Array of unresolved links
*/
async getUnresolvedLinks(workspaceId: string): Promise<UnresolvedLink[]> {
const unresolvedLinks = await this.prisma.knowledgeLink.findMany({
where: {
source: {
workspaceId,
},
resolved: false,
},
include: {
source: {
select: {
id: true,
title: true,
slug: true,
},
},
},
});
return unresolvedLinks as UnresolvedLink[];
}
}