refactor(evolution): use knowledge-service API for system_experiences

Follow proper microservices architecture:
- knowledge-service owns system_experiences table
- evolution-service uses KnowledgeClient API to save experiences
- Deleted SystemExperienceORM from evolution-service
- Added internal API endpoints in knowledge-service
- Disabled synchronize in all services for safety

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-24 20:20:30 -08:00
parent e1bcd0145e
commit c2b4fe19cc
8 changed files with 277 additions and 169 deletions

View File

@ -25,7 +25,8 @@ import { HealthModule } from './health/health.module';
password: config.get('POSTGRES_PASSWORD'), password: config.get('POSTGRES_PASSWORD'),
database: config.get('POSTGRES_DB', 'iconsulting'), database: config.get('POSTGRES_DB', 'iconsulting'),
autoLoadEntities: true, autoLoadEntities: true,
synchronize: config.get('NODE_ENV') !== 'production', // 禁用synchronize使用init-db.sql初始化schema
synchronize: false,
logging: config.get('NODE_ENV') === 'development', logging: config.get('NODE_ENV') === 'development',
}), }),
}), }),

View File

@ -1,22 +1,19 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EvolutionController } from './evolution.controller'; import { EvolutionController } from './evolution.controller';
import { EvolutionService } from './evolution.service'; import { EvolutionService } from './evolution.service';
import { ExperienceExtractorService } from '../infrastructure/claude/experience-extractor.service'; import { ExperienceExtractorService } from '../infrastructure/claude/experience-extractor.service';
import { ConversationClient } from '../infrastructure/clients/conversation.client'; import { ConversationClient } from '../infrastructure/clients/conversation.client';
import { SystemExperienceORM } from '../infrastructure/database/entities/system-experience.orm'; import { KnowledgeClient } from '../infrastructure/clients/knowledge.client';
@Module({ @Module({
imports: [ imports: [],
// 只保留自己 Bounded Context 的实体
TypeOrmModule.forFeature([SystemExperienceORM]),
],
controllers: [EvolutionController], controllers: [EvolutionController],
providers: [ providers: [
EvolutionService, EvolutionService,
ExperienceExtractorService, ExperienceExtractorService,
// 使用 API 客户端访问其他服务的数据 // 使用 API 客户端访问其他服务的数据
ConversationClient, ConversationClient,
KnowledgeClient,
], ],
exports: [EvolutionService], exports: [EvolutionService],
}) })

View File

@ -1,9 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ExperienceExtractorService } from '../infrastructure/claude/experience-extractor.service'; import { ExperienceExtractorService } from '../infrastructure/claude/experience-extractor.service';
import { ConversationClient, ConversationDto, MessageDto } from '../infrastructure/clients/conversation.client'; import { ConversationClient } from '../infrastructure/clients/conversation.client';
import { SystemExperienceORM } from '../infrastructure/database/entities/system-experience.orm'; import { KnowledgeClient } from '../infrastructure/clients/knowledge.client';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
/** /**
@ -21,13 +19,17 @@ export interface EvolutionTaskResult {
/** /**
* *
* *
*
*
* - ConversationClient conversation-service API
* - KnowledgeClient knowledge-service API
* - 访
*/ */
@Injectable() @Injectable()
export class EvolutionService { export class EvolutionService {
constructor( constructor(
private conversationClient: ConversationClient, private conversationClient: ConversationClient,
@InjectRepository(SystemExperienceORM) private knowledgeClient: KnowledgeClient,
private experienceRepo: Repository<SystemExperienceORM>,
private experienceExtractor: ExperienceExtractorService, private experienceExtractor: ExperienceExtractorService,
) {} ) {}
@ -86,17 +88,22 @@ export class EvolutionService {
rating: conversation.rating, rating: conversation.rating,
}); });
// 保存提取的经验 // 通过 API 保存提取的经验到 knowledge-service
for (const exp of analysis.experiences) { for (const exp of analysis.experiences) {
await this.saveExperience({ try {
experienceType: exp.type, await this.knowledgeClient.saveExperience({
content: exp.content, experienceType: exp.type,
scenario: exp.scenario, content: exp.content,
confidence: exp.confidence, scenario: exp.scenario,
relatedCategory: exp.relatedCategory, confidence: exp.confidence,
sourceConversationId: conversation.id, relatedCategory: exp.relatedCategory,
}); sourceConversationId: conversation.id,
result.experiencesExtracted++; });
result.experiencesExtracted++;
} catch (saveError) {
console.error(`[Evolution] Failed to save experience:`, saveError);
result.errors.push(`Save experience: ${(saveError as Error).message}`);
}
} }
// 收集知识缺口 // 收集知识缺口
@ -127,65 +134,6 @@ export class EvolutionService {
} }
} }
/**
*
*/
private async saveExperience(params: {
experienceType: string;
content: string;
scenario: string;
confidence: number;
relatedCategory?: string;
sourceConversationId: string;
}): Promise<void> {
// 查找相似经验
const existingExperiences = await this.experienceRepo.find({
where: {
experienceType: params.experienceType,
relatedCategory: params.relatedCategory,
},
});
// 简单的相似度检查(实际应该用向量相似度)
const similar = existingExperiences.find(
exp => this.simpleSimilarity(exp.content, params.content) > 0.8,
);
if (similar) {
// 合并到现有经验
if (!similar.sourceConversationIds.includes(params.sourceConversationId)) {
similar.sourceConversationIds.push(params.sourceConversationId);
similar.confidence = Math.min(100, similar.confidence + 5);
await this.experienceRepo.save(similar);
}
} else {
// 创建新经验
const newExperience = this.experienceRepo.create({
id: uuidv4(),
experienceType: params.experienceType,
content: params.content,
scenario: params.scenario,
confidence: params.confidence,
relatedCategory: params.relatedCategory,
sourceConversationIds: [params.sourceConversationId],
verificationStatus: 'PENDING',
isActive: false,
});
await this.experienceRepo.save(newExperience);
}
}
/**
*
*/
private simpleSimilarity(a: string, b: string): number {
const aWords = new Set(a.toLowerCase().split(/\s+/));
const bWords = new Set(b.toLowerCase().split(/\s+/));
const intersection = [...aWords].filter(x => bWords.has(x)).length;
const union = new Set([...aWords, ...bWords]).size;
return intersection / union;
}
/** /**
* *
*/ */
@ -197,12 +145,8 @@ export class EvolutionService {
recentConversationsAnalyzed: number; recentConversationsAnalyzed: number;
topExperienceTypes: Array<{ type: string; count: number }>; topExperienceTypes: Array<{ type: string; count: number }>;
}> { }> {
const [total, pending, approved, active] = await Promise.all([ // 通过 API 获取经验统计
this.experienceRepo.count(), const stats = await this.knowledgeClient.getStatistics();
this.experienceRepo.count({ where: { verificationStatus: 'PENDING' } }),
this.experienceRepo.count({ where: { verificationStatus: 'APPROVED' } }),
this.experienceRepo.count({ where: { isActive: true } }),
]);
// 通过 API 获取最近分析的对话数过去7天 // 通过 API 获取最近分析的对话数过去7天
const recentConversations = await this.conversationClient.countConversations({ const recentConversations = await this.conversationClient.countConversations({
@ -210,26 +154,19 @@ export class EvolutionService {
daysBack: 7, daysBack: 7,
}); });
// 获取经验类型分布 // 转换类型分布为数组格式
const typeDistribution = await this.experienceRepo const topExperienceTypes = Object.entries(stats.byType)
.createQueryBuilder('exp') .map(([type, count]) => ({ type, count: count as number }))
.select('exp.experienceType', 'type') .sort((a, b) => b.count - a.count)
.addSelect('COUNT(*)', 'count') .slice(0, 5);
.groupBy('exp.experienceType')
.orderBy('count', 'DESC')
.limit(5)
.getRawMany();
return { return {
totalExperiences: total, totalExperiences: stats.total,
pendingExperiences: pending, pendingExperiences: stats.byStatus['PENDING'] || 0,
approvedExperiences: approved, approvedExperiences: stats.byStatus['APPROVED'] || 0,
activeExperiences: active, activeExperiences: stats.byStatus['ACTIVE'] || 0,
recentConversationsAnalyzed: recentConversations, recentConversationsAnalyzed: recentConversations,
topExperienceTypes: typeDistribution.map(t => ({ topExperienceTypes,
type: t.type,
count: parseInt(t.count),
})),
}; };
} }

View File

@ -0,0 +1,133 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
/**
*
*/
export interface ExperienceDto {
id: string;
experienceType: string;
content: string;
scenario: string;
confidence: number;
relatedCategory?: string;
sourceConversationIds: string[];
verificationStatus: string;
isActive: boolean;
usageCount: number;
positiveCount: number;
negativeCount: number;
createdAt: Date;
updatedAt: Date;
}
/**
*
*/
export interface ExperienceStatisticsDto {
total: number;
byStatus: Record<string, number>;
byType: Record<string, number>;
}
/**
* Knowledge Service
* knowledge-service API
*/
@Injectable()
export class KnowledgeClient {
private readonly baseUrl: string;
constructor(private configService: ConfigService) {
this.baseUrl = this.configService.get<string>(
'KNOWLEDGE_SERVICE_URL',
'http://knowledge-service:3005',
);
}
/**
*
*/
async saveExperience(params: {
experienceType: string;
content: string;
scenario: string;
confidence: number;
relatedCategory?: string;
sourceConversationId: string;
}): Promise<ExperienceDto> {
const url = `${this.baseUrl}/api/internal/experiences`;
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
});
const data = await response.json();
if (!data.success) {
throw new Error('Failed to save experience');
}
return data.data;
} catch (error) {
console.error('[KnowledgeClient] Error saving experience:', error);
throw error;
}
}
/**
*
*/
async getStatistics(): Promise<ExperienceStatisticsDto> {
const url = `${this.baseUrl}/api/internal/experiences/statistics`;
try {
const response = await fetch(url);
const data = await response.json();
if (!data.success) {
throw new Error('Failed to get statistics');
}
return data.data;
} catch (error) {
console.error('[KnowledgeClient] Error getting statistics:', error);
throw error;
}
}
/**
*
*/
async countExperiences(options: {
status?: string;
isActive?: boolean;
}): Promise<number> {
const params = new URLSearchParams();
if (options.status) params.append('status', options.status);
if (options.isActive !== undefined) {
params.append('isActive', options.isActive.toString());
}
const url = `${this.baseUrl}/api/internal/experiences/count?${params.toString()}`;
try {
const response = await fetch(url);
const data = await response.json();
if (!data.success) {
throw new Error('Failed to count experiences');
}
return data.data.count;
} catch (error) {
console.error('[KnowledgeClient] Error counting experiences:', error);
throw error;
}
}
}

View File

@ -1,62 +0,0 @@
import {
Entity,
Column,
PrimaryColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('system_experiences')
export class SystemExperienceORM {
@PrimaryColumn('uuid')
id: string;
@Column({ name: 'experience_type', length: 30 })
experienceType: string;
@Column('text')
content: string;
@Column({ default: 50 })
confidence: number;
@Column('text')
scenario: string;
@Column({ name: 'related_category', length: 50, nullable: true })
relatedCategory: string;
@Column('uuid', { name: 'source_conversation_ids', array: true, default: '{}' })
sourceConversationIds: string[];
@Column({ name: 'verification_status', length: 20, default: 'PENDING' })
verificationStatus: string;
@Column({ name: 'verified_by', nullable: true })
verifiedBy: string;
@Column({ name: 'verified_at', nullable: true })
verifiedAt: Date;
@Column({ name: 'usage_count', default: 0 })
usageCount: number;
@Column({ name: 'positive_count', default: 0 })
positiveCount: number;
@Column({ name: 'negative_count', default: 0 })
negativeCount: number;
// pgvector VECTOR(1536) - exclude from default selects to avoid type conflicts
@Column({ type: 'text', name: 'embedding', nullable: true, select: false })
embedding: string;
@Column({ name: 'is_active', default: false })
isActive: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -25,7 +25,8 @@ import { HealthModule } from './health/health.module';
password: config.get('POSTGRES_PASSWORD'), password: config.get('POSTGRES_PASSWORD'),
database: config.get('POSTGRES_DB', 'iconsulting'), database: config.get('POSTGRES_DB', 'iconsulting'),
autoLoadEntities: true, autoLoadEntities: true,
synchronize: config.get('NODE_ENV') !== 'production', // 禁用synchronize使用init-db.sql初始化schema
synchronize: false,
logging: config.get('NODE_ENV') === 'development', logging: config.get('NODE_ENV') === 'development',
}), }),
}), }),

View File

@ -0,0 +1,100 @@
import { Controller, Get, Post, Body, Query } from '@nestjs/common';
import { MemoryService } from './memory.service';
import { ExperienceType } from '../domain/entities/system-experience.entity';
/**
* API
* evolution-service
*/
@Controller('internal/experiences')
export class InternalMemoryController {
constructor(private memoryService: MemoryService) {}
/**
* evolution-service
*/
@Post()
async saveExperience(@Body() dto: {
experienceType: string;
content: string;
scenario: string;
confidence: number;
relatedCategory?: string;
sourceConversationId: string;
}) {
const experience = await this.memoryService.extractAndSaveExperience({
experienceType: dto.experienceType as ExperienceType,
content: dto.content,
scenario: dto.scenario,
confidence: dto.confidence,
relatedCategory: dto.relatedCategory,
sourceConversationId: dto.sourceConversationId,
});
return {
success: true,
data: {
id: experience.id,
experienceType: experience.experienceType,
content: experience.content,
scenario: experience.scenario,
confidence: experience.confidence,
},
};
}
/**
*
*/
@Post('find-similar')
async findSimilarExperiences(@Body() dto: {
experienceType: string;
relatedCategory?: string;
}) {
// 返回同类型同类别的经验用于客户端去重
// 实际的相似度检查在客户端进行
const stats = await this.memoryService.getExperienceStatistics();
return {
success: true,
data: {
totalByType: stats.byType,
},
};
}
/**
*
*/
@Get('statistics')
async getStatistics() {
const stats = await this.memoryService.getExperienceStatistics();
return {
success: true,
data: {
total: stats.total,
byStatus: stats.byStatus,
byType: stats.byType,
},
};
}
/**
*
*/
@Get('count')
async countExperiences(
@Query('status') status?: string,
@Query('isActive') isActive?: string,
) {
const stats = await this.memoryService.getExperienceStatistics();
let count = stats.total;
if (status && stats.byStatus[status as keyof typeof stats.byStatus]) {
count = stats.byStatus[status as keyof typeof stats.byStatus];
}
return {
success: true,
data: { count },
};
}
}

View File

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { MemoryController } from './memory.controller'; import { MemoryController } from './memory.controller';
import { InternalMemoryController } from './internal.controller';
import { MemoryService } from './memory.service'; import { MemoryService } from './memory.service';
import { EmbeddingService } from '../infrastructure/embedding/embedding.service'; import { EmbeddingService } from '../infrastructure/embedding/embedding.service';
import { Neo4jService } from '../infrastructure/database/neo4j/neo4j.service'; import { Neo4jService } from '../infrastructure/database/neo4j/neo4j.service';
@ -22,7 +23,7 @@ import {
SystemExperienceORM, SystemExperienceORM,
]), ]),
], ],
controllers: [MemoryController], controllers: [MemoryController, InternalMemoryController],
providers: [ providers: [
MemoryService, MemoryService,
EmbeddingService, EmbeddingService,