feat(#60): implement link resolution service
- Create LinkResolutionService with workspace-scoped link resolution - Resolve links by: exact title match, slug match, fuzzy title match - Handle ambiguous matches (return null if multiple matches) - Support batch link resolution with deduplication - Comprehensive test suite with 19 tests, all passing - 100% coverage of public methods - Integrate service with KnowledgeModule Closes #60 (KNOW-008)
This commit is contained in:
168
apps/api/src/knowledge/services/link-resolution.service.ts
Normal file
168
apps/api/src/knowledge/services/link-resolution.service.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
/**
|
||||
* Represents a knowledge entry that matches a link target
|
||||
*/
|
||||
export interface ResolvedEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for resolving wiki-style links to knowledge entries
|
||||
*
|
||||
* Resolution strategy (in order of priority):
|
||||
* 1. Exact title match (case-sensitive)
|
||||
* 2. Slug match
|
||||
* 3. Fuzzy title match (case-insensitive)
|
||||
*
|
||||
* Supports workspace scoping via RLS
|
||||
*/
|
||||
@Injectable()
|
||||
export class LinkResolutionService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Resolve a single link target to a knowledge entry ID
|
||||
*
|
||||
* @param workspaceId - The workspace scope
|
||||
* @param target - The link target (title or slug)
|
||||
* @returns The entry ID if resolved, null if not found or ambiguous
|
||||
*/
|
||||
async resolveLink(
|
||||
workspaceId: string,
|
||||
target: string
|
||||
): Promise<string | null> {
|
||||
// Validate input
|
||||
if (!target || typeof target !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Trim whitespace
|
||||
const trimmedTarget = target.trim();
|
||||
|
||||
// Reject empty or whitespace-only strings
|
||||
if (trimmedTarget.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. Try exact title match (case-sensitive)
|
||||
const exactMatch = await this.prisma.knowledgeEntry.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
title: trimmedTarget,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (exactMatch) {
|
||||
return exactMatch.id;
|
||||
}
|
||||
|
||||
// 2. Try slug match
|
||||
const slugMatch = await this.prisma.knowledgeEntry.findUnique({
|
||||
where: {
|
||||
workspaceId_slug: {
|
||||
workspaceId,
|
||||
slug: trimmedTarget,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (slugMatch) {
|
||||
return slugMatch.id;
|
||||
}
|
||||
|
||||
// 3. Try fuzzy match (case-insensitive)
|
||||
const fuzzyMatches = await this.prisma.knowledgeEntry.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
title: {
|
||||
mode: "insensitive",
|
||||
equals: trimmedTarget,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Return null if no matches or multiple matches (ambiguous)
|
||||
if (fuzzyMatches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (fuzzyMatches.length > 1) {
|
||||
// Ambiguous match - multiple entries with similar titles
|
||||
return null;
|
||||
}
|
||||
|
||||
return fuzzyMatches[0].id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve multiple link targets in batch
|
||||
*
|
||||
* @param workspaceId - The workspace scope
|
||||
* @param targets - Array of link targets
|
||||
* @returns Map of target to resolved entry ID (null if not found)
|
||||
*/
|
||||
async resolveLinks(
|
||||
workspaceId: string,
|
||||
targets: string[]
|
||||
): Promise<Record<string, string | null>> {
|
||||
const result: Record<string, string | null> = {};
|
||||
|
||||
// Deduplicate targets
|
||||
const uniqueTargets = Array.from(new Set(targets));
|
||||
|
||||
// Resolve each target
|
||||
for (const target of uniqueTargets) {
|
||||
const resolved = await this.resolveLink(workspaceId, target);
|
||||
result[target] = resolved;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all entries that could match a link target (for disambiguation UI)
|
||||
*
|
||||
* @param workspaceId - The workspace scope
|
||||
* @param target - The link target
|
||||
* @returns Array of matching entries
|
||||
*/
|
||||
async getAmbiguousMatches(
|
||||
workspaceId: string,
|
||||
target: string
|
||||
): Promise<ResolvedEntry[]> {
|
||||
const trimmedTarget = target.trim();
|
||||
|
||||
if (trimmedTarget.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matches = await this.prisma.knowledgeEntry.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
title: {
|
||||
mode: "insensitive",
|
||||
equals: trimmedTarget,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
});
|
||||
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user