298 lines
8.3 KiB
TypeScript
298 lines
8.3 KiB
TypeScript
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
|
||
import { ConfigService } from '@nestjs/config';
|
||
import { CircuitBreaker } from '../common/circuit-breaker';
|
||
|
||
/**
|
||
* RAG 检索结果
|
||
*/
|
||
export interface RAGResult {
|
||
content: string;
|
||
sources: Array<{
|
||
articleId: string;
|
||
title: string;
|
||
similarity: number;
|
||
}>;
|
||
userMemories?: string[];
|
||
systemExperiences?: string[];
|
||
}
|
||
|
||
/**
|
||
* 离题检测结果
|
||
*/
|
||
export interface OffTopicResult {
|
||
isOffTopic: boolean;
|
||
confidence: number;
|
||
reason?: string;
|
||
}
|
||
|
||
/**
|
||
* 用户记忆
|
||
*/
|
||
export interface UserMemory {
|
||
id: string;
|
||
userId: string;
|
||
memoryType: 'FACT' | 'PREFERENCE' | 'INTENT';
|
||
content: string;
|
||
importance: number;
|
||
createdAt: string;
|
||
}
|
||
|
||
/**
|
||
* 系统经验
|
||
*/
|
||
export interface SystemExperience {
|
||
id: string;
|
||
experienceType: string;
|
||
content: string;
|
||
scenario: string;
|
||
confidence: number;
|
||
relatedCategory?: string;
|
||
}
|
||
|
||
/**
|
||
* API 响应包装
|
||
*/
|
||
interface ApiResponse<T> {
|
||
success: boolean;
|
||
data: T;
|
||
message?: string;
|
||
}
|
||
|
||
/**
|
||
* Knowledge Service 客户端
|
||
* 封装对 knowledge-service 的 HTTP 调用
|
||
*/
|
||
@Injectable()
|
||
export class KnowledgeClientService implements OnModuleInit {
|
||
private readonly logger = new Logger(KnowledgeClientService.name);
|
||
private baseUrl: string;
|
||
private readonly circuitBreaker: CircuitBreaker;
|
||
|
||
constructor(private configService: ConfigService) {
|
||
this.circuitBreaker = new CircuitBreaker({
|
||
name: 'knowledge-service',
|
||
failureThreshold: 5,
|
||
resetTimeoutMs: 60_000,
|
||
});
|
||
}
|
||
|
||
onModuleInit() {
|
||
this.baseUrl = this.configService.get<string>('KNOWLEDGE_SERVICE_URL') || 'http://knowledge-service:3003';
|
||
this.logger.log(`Initialized with base URL: ${this.baseUrl}`);
|
||
}
|
||
|
||
/**
|
||
* 受熔断器保护的 fetch — 连续 5 次失败后熔断 60s
|
||
* 熔断期间直接返回 null,不发起网络请求
|
||
*/
|
||
private protectedFetch(url: string, init?: RequestInit): Promise<Response | null> {
|
||
return this.circuitBreaker.execute(
|
||
async () => {
|
||
const resp = await fetch(url, init);
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
return resp;
|
||
},
|
||
null,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* RAG 知识检索
|
||
*/
|
||
async retrieveKnowledge(params: {
|
||
query: string;
|
||
userId?: string;
|
||
category?: string;
|
||
includeMemories?: boolean;
|
||
includeExperiences?: boolean;
|
||
}): Promise<RAGResult | null> {
|
||
const resp = await this.protectedFetch(`${this.baseUrl}/api/v1/knowledge/retrieve`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(params),
|
||
});
|
||
if (!resp) return null;
|
||
const data = (await resp.json()) as ApiResponse<RAGResult>;
|
||
return data.success ? data.data : null;
|
||
}
|
||
|
||
/**
|
||
* RAG 检索并格式化为提示词上下文
|
||
*/
|
||
async retrieveForPrompt(params: {
|
||
query: string;
|
||
userId?: string;
|
||
category?: string;
|
||
}): Promise<string | null> {
|
||
const resp = await this.protectedFetch(`${this.baseUrl}/api/v1/knowledge/retrieve/prompt`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(params),
|
||
});
|
||
if (!resp) return null;
|
||
const data = (await resp.json()) as ApiResponse<{ context: string }>;
|
||
return data.success ? data.data.context : null;
|
||
}
|
||
|
||
/**
|
||
* 检查是否离题
|
||
*/
|
||
async checkOffTopic(query: string): Promise<OffTopicResult> {
|
||
const resp = await this.protectedFetch(`${this.baseUrl}/api/v1/knowledge/check-off-topic`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ query }),
|
||
});
|
||
if (!resp) return { isOffTopic: false, confidence: 0 };
|
||
const data = (await resp.json()) as ApiResponse<OffTopicResult>;
|
||
return data.success ? data.data : { isOffTopic: false, confidence: 0 };
|
||
}
|
||
|
||
/**
|
||
* 保存用户记忆
|
||
*/
|
||
async saveUserMemory(params: {
|
||
userId: string;
|
||
memoryType: 'FACT' | 'PREFERENCE' | 'INTENT';
|
||
content: string;
|
||
importance?: number;
|
||
sourceConversationId?: string;
|
||
relatedCategory?: string;
|
||
}): Promise<UserMemory | null> {
|
||
const resp = await this.protectedFetch(`${this.baseUrl}/api/v1/memory/user`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(params),
|
||
});
|
||
if (!resp) return null;
|
||
const data = (await resp.json()) as ApiResponse<UserMemory>;
|
||
return data.success ? data.data : null;
|
||
}
|
||
|
||
/**
|
||
* 搜索用户相关记忆
|
||
*/
|
||
async searchUserMemories(params: {
|
||
userId: string;
|
||
query: string;
|
||
limit?: number;
|
||
}): Promise<UserMemory[]> {
|
||
const resp = await this.protectedFetch(`${this.baseUrl}/api/v1/memory/user/search`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(params),
|
||
});
|
||
if (!resp) return [];
|
||
const data = (await resp.json()) as ApiResponse<UserMemory[]>;
|
||
return data.success ? data.data : [];
|
||
}
|
||
|
||
/**
|
||
* 获取用户最重要的记忆
|
||
*/
|
||
async getUserTopMemories(userId: string, limit = 5): Promise<UserMemory[]> {
|
||
const resp = await this.protectedFetch(`${this.baseUrl}/api/v1/memory/user/${userId}/top?limit=${limit}`);
|
||
if (!resp) return [];
|
||
const data = (await resp.json()) as ApiResponse<UserMemory[]>;
|
||
return data.success ? data.data : [];
|
||
}
|
||
|
||
/**
|
||
* 搜索相关系统经验
|
||
*/
|
||
async searchExperiences(params: {
|
||
query: string;
|
||
experienceType?: string;
|
||
category?: string;
|
||
activeOnly?: boolean;
|
||
limit?: number;
|
||
}): Promise<SystemExperience[]> {
|
||
const resp = await this.protectedFetch(`${this.baseUrl}/api/v1/memory/experience/search`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(params),
|
||
});
|
||
if (!resp) return [];
|
||
const data = (await resp.json()) as ApiResponse<SystemExperience[]>;
|
||
return data.success ? data.data : [];
|
||
}
|
||
|
||
/**
|
||
* 初始化用户节点(Neo4j)
|
||
*/
|
||
async initializeUser(userId: string): Promise<boolean> {
|
||
const resp = await this.protectedFetch(`${this.baseUrl}/api/v1/memory/user`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
userId,
|
||
memoryType: 'FACT',
|
||
content: '用户首次访问系统',
|
||
importance: 10,
|
||
}),
|
||
});
|
||
return resp !== null;
|
||
}
|
||
|
||
/**
|
||
* 保存系统经验
|
||
* 用于将评估门控的失败教训沉淀为全局经验,影响未来所有对话
|
||
*/
|
||
async saveExperience(params: {
|
||
experienceType: string;
|
||
content: string;
|
||
scenario: string;
|
||
sourceConversationId: string;
|
||
confidence?: number;
|
||
relatedCategory?: string;
|
||
}): Promise<SystemExperience | null> {
|
||
const resp = await this.protectedFetch(`${this.baseUrl}/api/v1/memory/experience`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(params),
|
||
});
|
||
if (!resp) return null;
|
||
const data = (await resp.json()) as ApiResponse<SystemExperience>;
|
||
return data.success ? data.data : null;
|
||
}
|
||
|
||
// ============================================================
|
||
// Convenience Methods (for Specialist Agents)
|
||
// ============================================================
|
||
|
||
/**
|
||
* 简便搜索方法 — 供专家 Agent 使用
|
||
* 封装 retrieveForPrompt
|
||
*/
|
||
async search(query: string, category?: string): Promise<string> {
|
||
const result = await this.retrieveForPrompt({ query, category });
|
||
return result || '';
|
||
}
|
||
|
||
/**
|
||
* 简便获取用户上下文方法 — 供专家 Agent 使用
|
||
* 如果提供 userId 则搜索用户记忆,否则做通用知识检索
|
||
*/
|
||
async getUserContext(query: string, userId?: string): Promise<string> {
|
||
if (!userId) {
|
||
// 无 userId 时做通用检索
|
||
return await this.search(query);
|
||
}
|
||
const memories = await this.searchUserMemories({ userId, query, limit: 5 });
|
||
if (memories.length === 0) return '';
|
||
return memories.map(m => `[${m.memoryType}] (重要度:${m.importance}) ${m.content}`).join('\n');
|
||
}
|
||
|
||
/**
|
||
* 获取熔断器状态 — 供 Admin API 使用
|
||
*/
|
||
getCircuitBreakerStatus() {
|
||
return {
|
||
name: 'knowledge-service',
|
||
state: this.circuitBreaker.getState(),
|
||
failureCount: this.circuitBreaker.getFailureCount(),
|
||
config: { failureThreshold: 5, resetTimeoutMs: 60_000 },
|
||
};
|
||
}
|
||
}
|