Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
338 lines
8.7 KiB
TypeScript
338 lines
8.7 KiB
TypeScript
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
|
import { Prisma } from "@prisma/client";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import { EmbeddingService } from "../knowledge/services/embedding.service";
|
|
import type { CreateFindingDto, QueryFindingsDto, SearchFindingsDto } from "./dto";
|
|
|
|
const findingSelect = {
|
|
id: true,
|
|
workspaceId: true,
|
|
taskId: true,
|
|
agentId: true,
|
|
type: true,
|
|
title: true,
|
|
data: true,
|
|
summary: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
} satisfies Prisma.FindingSelect;
|
|
|
|
type FindingRecord = Prisma.FindingGetPayload<{ select: typeof findingSelect }>;
|
|
|
|
interface RawFindingSearchResult {
|
|
id: string;
|
|
workspace_id: string;
|
|
task_id: string | null;
|
|
agent_id: string;
|
|
type: string;
|
|
title: string;
|
|
data: Prisma.JsonValue;
|
|
summary: string;
|
|
created_at: Date;
|
|
updated_at: Date;
|
|
score: number;
|
|
}
|
|
|
|
export interface FindingSearchResult extends FindingRecord {
|
|
score: number;
|
|
}
|
|
|
|
interface PaginatedMeta {
|
|
total: number;
|
|
page: number;
|
|
limit: number;
|
|
totalPages: number;
|
|
}
|
|
|
|
export interface PaginatedFindingsResponse {
|
|
data: FindingRecord[];
|
|
meta: PaginatedMeta;
|
|
}
|
|
|
|
export interface FindingsSearchResponse {
|
|
data: FindingSearchResult[];
|
|
meta: PaginatedMeta;
|
|
query: string;
|
|
similarityThreshold: number;
|
|
}
|
|
|
|
/**
|
|
* Service for managing structured findings with vector search support
|
|
*/
|
|
@Injectable()
|
|
export class FindingsService {
|
|
private readonly logger = new Logger(FindingsService.name);
|
|
private readonly defaultSimilarityThreshold: number;
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly embeddingService: EmbeddingService
|
|
) {
|
|
const parsedThreshold = Number.parseFloat(process.env.FINDINGS_SIMILARITY_THRESHOLD ?? "0.5");
|
|
|
|
this.defaultSimilarityThreshold =
|
|
Number.isFinite(parsedThreshold) && parsedThreshold >= 0 && parsedThreshold <= 1
|
|
? parsedThreshold
|
|
: 0.5;
|
|
}
|
|
|
|
/**
|
|
* Create a finding and generate its embedding from the summary when available
|
|
*/
|
|
async create(workspaceId: string, createFindingDto: CreateFindingDto): Promise<FindingRecord> {
|
|
if (createFindingDto.taskId) {
|
|
const task = await this.prisma.agentTask.findUnique({
|
|
where: {
|
|
id: createFindingDto.taskId,
|
|
workspaceId,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
if (!task) {
|
|
throw new NotFoundException(`Agent task with ID ${createFindingDto.taskId} not found`);
|
|
}
|
|
}
|
|
|
|
const createInput: Prisma.FindingUncheckedCreateInput = {
|
|
workspaceId,
|
|
agentId: createFindingDto.agentId,
|
|
type: createFindingDto.type,
|
|
title: createFindingDto.title,
|
|
data: createFindingDto.data as Prisma.InputJsonValue,
|
|
summary: createFindingDto.summary,
|
|
};
|
|
|
|
if (createFindingDto.taskId) {
|
|
createInput.taskId = createFindingDto.taskId;
|
|
}
|
|
|
|
const finding = await this.prisma.finding.create({
|
|
data: createInput,
|
|
select: findingSelect,
|
|
});
|
|
|
|
await this.generateAndStoreEmbedding(finding.id, workspaceId, finding.summary);
|
|
|
|
if (this.embeddingService.isConfigured()) {
|
|
return this.findOne(finding.id, workspaceId);
|
|
}
|
|
|
|
return finding;
|
|
}
|
|
|
|
/**
|
|
* Get paginated findings with optional filters
|
|
*/
|
|
async findAll(workspaceId: string, query: QueryFindingsDto): Promise<PaginatedFindingsResponse> {
|
|
const page = query.page ?? 1;
|
|
const limit = query.limit ?? 50;
|
|
const skip = (page - 1) * limit;
|
|
|
|
const where: Prisma.FindingWhereInput = {
|
|
workspaceId,
|
|
};
|
|
|
|
if (query.agentId) {
|
|
where.agentId = query.agentId;
|
|
}
|
|
|
|
if (query.type) {
|
|
where.type = query.type;
|
|
}
|
|
|
|
if (query.taskId) {
|
|
where.taskId = query.taskId;
|
|
}
|
|
|
|
const [data, total] = await Promise.all([
|
|
this.prisma.finding.findMany({
|
|
where,
|
|
select: findingSelect,
|
|
orderBy: {
|
|
createdAt: "desc",
|
|
},
|
|
skip,
|
|
take: limit,
|
|
}),
|
|
this.prisma.finding.count({ where }),
|
|
]);
|
|
|
|
return {
|
|
data,
|
|
meta: {
|
|
total,
|
|
page,
|
|
limit,
|
|
totalPages: Math.ceil(total / limit),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get a single finding by ID
|
|
*/
|
|
async findOne(id: string, workspaceId: string): Promise<FindingRecord> {
|
|
const finding = await this.prisma.finding.findUnique({
|
|
where: {
|
|
id,
|
|
workspaceId,
|
|
},
|
|
select: findingSelect,
|
|
});
|
|
|
|
if (!finding) {
|
|
throw new NotFoundException(`Finding with ID ${id} not found`);
|
|
}
|
|
|
|
return finding;
|
|
}
|
|
|
|
/**
|
|
* Semantic search findings using vector similarity
|
|
*/
|
|
async search(workspaceId: string, searchDto: SearchFindingsDto): Promise<FindingsSearchResponse> {
|
|
if (!this.embeddingService.isConfigured()) {
|
|
throw new BadRequestException(
|
|
"Finding vector search requires OPENAI_API_KEY to be configured"
|
|
);
|
|
}
|
|
|
|
const page = searchDto.page ?? 1;
|
|
const limit = searchDto.limit ?? 20;
|
|
const offset = (page - 1) * limit;
|
|
const similarityThreshold = searchDto.similarityThreshold ?? this.defaultSimilarityThreshold;
|
|
const distanceThreshold = 1 - similarityThreshold;
|
|
|
|
const queryEmbedding = await this.embeddingService.generateEmbedding(searchDto.query);
|
|
const embeddingString = `[${queryEmbedding.join(",")}]`;
|
|
|
|
const agentFilter = searchDto.agentId
|
|
? Prisma.sql`AND f.agent_id = ${searchDto.agentId}`
|
|
: Prisma.sql``;
|
|
const typeFilter = searchDto.type ? Prisma.sql`AND f.type = ${searchDto.type}` : Prisma.sql``;
|
|
const taskFilter = searchDto.taskId
|
|
? Prisma.sql`AND f.task_id = ${searchDto.taskId}::uuid`
|
|
: Prisma.sql``;
|
|
|
|
const searchResults = await this.prisma.$queryRaw<RawFindingSearchResult[]>`
|
|
SELECT
|
|
f.id,
|
|
f.workspace_id,
|
|
f.task_id,
|
|
f.agent_id,
|
|
f.type,
|
|
f.title,
|
|
f.data,
|
|
f.summary,
|
|
f.created_at,
|
|
f.updated_at,
|
|
(1 - (f.embedding <=> ${embeddingString}::vector)) AS score
|
|
FROM findings f
|
|
WHERE f.workspace_id = ${workspaceId}::uuid
|
|
AND f.embedding IS NOT NULL
|
|
${agentFilter}
|
|
${typeFilter}
|
|
${taskFilter}
|
|
AND (f.embedding <=> ${embeddingString}::vector) <= ${distanceThreshold}
|
|
ORDER BY f.embedding <=> ${embeddingString}::vector
|
|
LIMIT ${limit}
|
|
OFFSET ${offset}
|
|
`;
|
|
|
|
const countResult = await this.prisma.$queryRaw<[{ count: bigint }]>`
|
|
SELECT COUNT(*) as count
|
|
FROM findings f
|
|
WHERE f.workspace_id = ${workspaceId}::uuid
|
|
AND f.embedding IS NOT NULL
|
|
${agentFilter}
|
|
${typeFilter}
|
|
${taskFilter}
|
|
AND (f.embedding <=> ${embeddingString}::vector) <= ${distanceThreshold}
|
|
`;
|
|
|
|
const total = Number(countResult[0].count);
|
|
|
|
const data: FindingSearchResult[] = searchResults.map((row) => ({
|
|
id: row.id,
|
|
workspaceId: row.workspace_id,
|
|
taskId: row.task_id,
|
|
agentId: row.agent_id,
|
|
type: row.type,
|
|
title: row.title,
|
|
data: row.data,
|
|
summary: row.summary,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
score: row.score,
|
|
}));
|
|
|
|
return {
|
|
data,
|
|
meta: {
|
|
total,
|
|
page,
|
|
limit,
|
|
totalPages: Math.ceil(total / limit),
|
|
},
|
|
query: searchDto.query,
|
|
similarityThreshold,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Delete a finding
|
|
*/
|
|
async remove(id: string, workspaceId: string): Promise<{ message: string }> {
|
|
const existingFinding = await this.prisma.finding.findUnique({
|
|
where: {
|
|
id,
|
|
workspaceId,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
if (!existingFinding) {
|
|
throw new NotFoundException(`Finding with ID ${id} not found`);
|
|
}
|
|
|
|
await this.prisma.finding.delete({
|
|
where: {
|
|
id,
|
|
workspaceId,
|
|
},
|
|
});
|
|
|
|
return { message: "Finding deleted successfully" };
|
|
}
|
|
|
|
/**
|
|
* Generate and persist embedding for a finding summary
|
|
*/
|
|
private async generateAndStoreEmbedding(
|
|
findingId: string,
|
|
workspaceId: string,
|
|
summary: string
|
|
): Promise<void> {
|
|
if (!this.embeddingService.isConfigured()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const embedding = await this.embeddingService.generateEmbedding(summary);
|
|
const embeddingString = `[${embedding.join(",")}]`;
|
|
|
|
await this.prisma.$executeRaw`
|
|
UPDATE findings
|
|
SET embedding = ${embeddingString}::vector,
|
|
updated_at = NOW()
|
|
WHERE id = ${findingId}::uuid
|
|
AND workspace_id = ${workspaceId}::uuid
|
|
`;
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
this.logger.warn(`Failed to generate embedding for finding ${findingId}: ${message}`);
|
|
}
|
|
}
|
|
}
|