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

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