import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, ILike } from 'typeorm'; import { TenantContextService } from '@iconsulting/shared'; import { IKnowledgeRepository } from '../../../domain/repositories/knowledge.repository.interface'; import { KnowledgeArticleEntity, KnowledgeSource } from '../../../domain/entities/knowledge-article.entity'; import { KnowledgeChunkEntity, ChunkType } from '../../../domain/entities/knowledge-chunk.entity'; import { KnowledgeArticleORM } from '../../../infrastructure/database/postgres/entities/knowledge-article.orm'; import { KnowledgeChunkORM } from '../../../infrastructure/database/postgres/entities/knowledge-chunk.orm'; @Injectable() export class KnowledgePostgresRepository implements IKnowledgeRepository { constructor( @InjectRepository(KnowledgeArticleORM) private articleRepo: Repository, @InjectRepository(KnowledgeChunkORM) private chunkRepo: Repository, private readonly tenantContext: TenantContextService, ) {} private getTenantId(): string { const id = this.tenantContext.getCurrentTenantId(); if (!id) throw new Error('Tenant context not set'); return id; } // ========== 文章操作 ========== async saveArticle(article: KnowledgeArticleEntity): Promise { const orm = this.toArticleORM(article); orm.tenantId = this.getTenantId(); await this.articleRepo.save(orm); } async findArticleById(id: string): Promise { const orm = await this.articleRepo.findOne({ where: { id, tenantId: this.getTenantId() }, }); return orm ? this.toArticleEntity(orm) : null; } async findArticlesByCategory( category: string, options?: { publishedOnly?: boolean; limit?: number; offset?: number }, ): Promise { const query = this.articleRepo.createQueryBuilder('article') .where('article.tenant_id = :tenantId', { tenantId: this.getTenantId() }) .andWhere('article.category = :category', { category }); if (options?.publishedOnly) { query.andWhere('article.isPublished = true'); } query.orderBy('article.qualityScore', 'DESC') .addOrderBy('article.createdAt', 'DESC'); if (options?.limit) { query.take(options.limit); } if (options?.offset) { query.skip(options.offset); } const orms = await query.getMany(); return orms.map(orm => this.toArticleEntity(orm)); } async searchArticles( queryStr: string, options?: { category?: string; publishedOnly?: boolean; limit?: number }, ): Promise { const query = this.articleRepo.createQueryBuilder('article') .where('article.tenant_id = :tenantId', { tenantId: this.getTenantId() }) .andWhere('(article.title ILIKE :search OR article.content ILIKE :search)', { search: `%${queryStr}%`, }); if (options?.category) { query.andWhere('article.category = :category', { category: options.category }); } if (options?.publishedOnly) { query.andWhere('article.isPublished = true'); } query.orderBy('article.qualityScore', 'DESC') .take(options?.limit || 10); const orms = await query.getMany(); return orms.map(orm => this.toArticleEntity(orm)); } async searchArticlesByVector( embedding: number[], options?: { category?: string; publishedOnly?: boolean; limit?: number; minSimilarity?: number; }, ): Promise> { const tenantId = this.getTenantId(); const embeddingStr = `[${embedding.join(',')}]`; const limit = options?.limit || 5; const minSimilarity = options?.minSimilarity || 0.7; let sql = ` SELECT *, 1 - (embedding <=> '${embeddingStr}'::vector) as similarity FROM knowledge_articles WHERE tenant_id = $1 AND embedding IS NOT NULL `; if (options?.category) { sql += ` AND category = '${options.category}'`; } if (options?.publishedOnly) { sql += ` AND is_published = true`; } sql += ` AND 1 - (embedding <=> '${embeddingStr}'::vector) >= ${minSimilarity} ORDER BY similarity DESC LIMIT ${limit} `; const results = await this.articleRepo.query(sql, [tenantId]); return results.map((row: any) => ({ article: this.toArticleEntityFromRaw(row), similarity: parseFloat(row.similarity), })); } async updateArticle(article: KnowledgeArticleEntity): Promise { const orm = this.toArticleORM(article); orm.tenantId = this.getTenantId(); await this.articleRepo.save(orm); } async deleteArticle(id: string): Promise { await this.articleRepo.delete({ id, tenantId: this.getTenantId() }); } async countArticles(options?: { category?: string; publishedOnly?: boolean }): Promise { const query = this.articleRepo.createQueryBuilder('article') .where('article.tenant_id = :tenantId', { tenantId: this.getTenantId() }); if (options?.category) { query.andWhere('article.category = :category', { category: options.category }); } if (options?.publishedOnly) { query.andWhere('article.isPublished = true'); } return query.getCount(); } // ========== 块操作 ========== async saveChunk(chunk: KnowledgeChunkEntity): Promise { const orm = this.toChunkORM(chunk); orm.tenantId = this.getTenantId(); await this.chunkRepo.save(orm); } async saveChunks(chunks: KnowledgeChunkEntity[]): Promise { const tenantId = this.getTenantId(); const orms = chunks.map(chunk => { const orm = this.toChunkORM(chunk); orm.tenantId = tenantId; return orm; }); await this.chunkRepo.save(orms); } async findChunksByArticleId(articleId: string): Promise { const orms = await this.chunkRepo.find({ where: { articleId, tenantId: this.getTenantId() }, order: { chunkIndex: 'ASC' }, }); return orms.map(orm => this.toChunkEntity(orm)); } async searchChunksByVector( embedding: number[], options?: { category?: string; limit?: number; minSimilarity?: number; }, ): Promise> { const tenantId = this.getTenantId(); const embeddingStr = `[${embedding.join(',')}]`; const limit = options?.limit || 5; const minSimilarity = options?.minSimilarity || 0.7; let sql = ` SELECT c.*, 1 - (c.embedding <=> '${embeddingStr}'::vector) as similarity FROM knowledge_chunks c JOIN knowledge_articles a ON c.article_id = a.id WHERE c.tenant_id = $1 AND c.embedding IS NOT NULL AND a.is_published = true `; if (options?.category) { sql += ` AND a.category = '${options.category}'`; } sql += ` AND 1 - (c.embedding <=> '${embeddingStr}'::vector) >= ${minSimilarity} ORDER BY similarity DESC LIMIT ${limit} `; const results = await this.chunkRepo.query(sql, [tenantId]); return results.map((row: any) => ({ chunk: this.toChunkEntityFromRaw(row), similarity: parseFloat(row.similarity), })); } async deleteChunksByArticleId(articleId: string): Promise { await this.chunkRepo.delete({ articleId, tenantId: this.getTenantId() }); } // ========== 转换方法 ========== private toArticleORM(entity: KnowledgeArticleEntity): KnowledgeArticleORM { const orm = new KnowledgeArticleORM(); orm.id = entity.id; orm.tenantId = this.getTenantId(); orm.title = entity.title; orm.content = entity.content; orm.summary = entity.summary; orm.category = entity.category; orm.tags = entity.tags; orm.source = entity.source; orm.sourceUrl = entity.sourceUrl; orm.embedding = entity.embedding; orm.isPublished = entity.isPublished; orm.citationCount = entity.citationCount; orm.helpfulCount = entity.helpfulCount; orm.unhelpfulCount = entity.unhelpfulCount; orm.qualityScore = entity.qualityScore; orm.createdBy = entity.createdBy; orm.updatedBy = entity.updatedBy; orm.createdAt = entity.createdAt; orm.updatedAt = entity.updatedAt; return orm; } private toArticleEntity(orm: KnowledgeArticleORM): KnowledgeArticleEntity { return KnowledgeArticleEntity.fromPersistence({ id: orm.id, title: orm.title, content: orm.content, summary: orm.summary, category: orm.category, tags: orm.tags, source: orm.source as KnowledgeSource, sourceUrl: orm.sourceUrl, embedding: orm.embedding, isPublished: orm.isPublished, citationCount: orm.citationCount, helpfulCount: orm.helpfulCount, unhelpfulCount: orm.unhelpfulCount, qualityScore: orm.qualityScore, createdBy: orm.createdBy, updatedBy: orm.updatedBy, createdAt: orm.createdAt, updatedAt: orm.updatedAt, }); } private toArticleEntityFromRaw(row: any): KnowledgeArticleEntity { return KnowledgeArticleEntity.fromPersistence({ id: row.id, title: row.title, content: row.content, summary: row.summary, category: row.category, tags: row.tags, source: row.source as KnowledgeSource, sourceUrl: row.source_url, embedding: row.embedding, isPublished: row.is_published, citationCount: row.citation_count, helpfulCount: row.helpful_count, unhelpfulCount: row.unhelpful_count, qualityScore: row.quality_score, createdBy: row.created_by, updatedBy: row.updated_by, createdAt: new Date(row.created_at), updatedAt: new Date(row.updated_at), }); } private toChunkORM(entity: KnowledgeChunkEntity): KnowledgeChunkORM { const orm = new KnowledgeChunkORM(); orm.id = entity.id; orm.tenantId = this.getTenantId(); orm.articleId = entity.articleId; orm.content = entity.content; orm.chunkIndex = entity.chunkIndex; orm.chunkType = entity.chunkType; orm.embedding = entity.embedding; orm.metadata = entity.metadata as Record; orm.tokenCount = entity.tokenCount; orm.createdAt = entity.createdAt; return orm; } private toChunkEntity(orm: KnowledgeChunkORM): KnowledgeChunkEntity { return KnowledgeChunkEntity.fromPersistence({ id: orm.id, articleId: orm.articleId, content: orm.content, chunkIndex: orm.chunkIndex, chunkType: orm.chunkType as ChunkType, embedding: orm.embedding, metadata: orm.metadata, tokenCount: orm.tokenCount, createdAt: orm.createdAt, }); } private toChunkEntityFromRaw(row: any): KnowledgeChunkEntity { return KnowledgeChunkEntity.fromPersistence({ id: row.id, articleId: row.article_id, content: row.content, chunkIndex: row.chunk_index, chunkType: row.chunk_type as ChunkType, embedding: row.embedding, metadata: row.metadata, tokenCount: row.token_count, createdAt: new Date(row.created_at), }); } }