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 { 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 { 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 { 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 { 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` 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 { 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}`); } } }