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:
Jason Woltje
2026-01-29 17:50:57 -06:00
parent 566bf1e7c5
commit 3b113f87fd
4 changed files with 579 additions and 2 deletions

View 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;
}
}