memento-mcp-server 1.14.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/domains/memory/tools/convert-episodic-to-semantic-tool.d.ts +18 -0
- package/dist/domains/memory/tools/convert-episodic-to-semantic-tool.d.ts.map +1 -0
- package/dist/domains/memory/tools/convert-episodic-to-semantic-tool.js +346 -0
- package/dist/domains/memory/tools/convert-episodic-to-semantic-tool.js.map +1 -0
- package/dist/domains/memory/tools/remember-tool.d.ts.map +1 -1
- package/dist/domains/memory/tools/remember-tool.js +182 -1
- package/dist/domains/memory/tools/remember-tool.js.map +1 -1
- package/dist/infrastructure/database/database/migration/migrations/005-relation-engine-schema.sql +7 -7
- package/dist/infrastructure/database/database/migration/migrations/008-arigraph-schema-expansion.d.ts +65 -0
- package/dist/infrastructure/database/database/migration/migrations/008-arigraph-schema-expansion.d.ts.map +1 -0
- package/dist/infrastructure/database/database/migration/migrations/008-arigraph-schema-expansion.js +250 -0
- package/dist/infrastructure/database/database/migration/migrations/008-arigraph-schema-expansion.js.map +1 -0
- package/dist/infrastructure/database/database/migration/migrations/008-arigraph-schema-expansion.sql +86 -0
- package/dist/infrastructure/logging/triple-extraction-logger.d.ts +92 -0
- package/dist/infrastructure/logging/triple-extraction-logger.d.ts.map +1 -0
- package/dist/infrastructure/logging/triple-extraction-logger.js +194 -0
- package/dist/infrastructure/logging/triple-extraction-logger.js.map +1 -0
- package/dist/infrastructure/scheduler/batch-scheduler.d.ts +57 -0
- package/dist/infrastructure/scheduler/batch-scheduler.d.ts.map +1 -1
- package/dist/infrastructure/scheduler/batch-scheduler.js +220 -0
- package/dist/infrastructure/scheduler/batch-scheduler.js.map +1 -1
- package/dist/infrastructure/scheduler/jobs/triple-extraction-batch-job.d.ts +194 -0
- package/dist/infrastructure/scheduler/jobs/triple-extraction-batch-job.d.ts.map +1 -0
- package/dist/infrastructure/scheduler/jobs/triple-extraction-batch-job.js +639 -0
- package/dist/infrastructure/scheduler/jobs/triple-extraction-batch-job.js.map +1 -0
- package/dist/services/semantic-memory/semantic-memory-statistics.d.ts +64 -0
- package/dist/services/semantic-memory/semantic-memory-statistics.d.ts.map +1 -0
- package/dist/services/semantic-memory/semantic-memory-statistics.js +113 -0
- package/dist/services/semantic-memory/semantic-memory-statistics.js.map +1 -0
- package/dist/services/semantic-memory/semantic-memory-update-service.d.ts +257 -0
- package/dist/services/semantic-memory/semantic-memory-update-service.d.ts.map +1 -0
- package/dist/services/semantic-memory/semantic-memory-update-service.js +696 -0
- package/dist/services/semantic-memory/semantic-memory-update-service.js.map +1 -0
- package/dist/services/triple-extraction/entity-linker.d.ts +55 -0
- package/dist/services/triple-extraction/entity-linker.d.ts.map +1 -0
- package/dist/services/triple-extraction/entity-linker.js +154 -0
- package/dist/services/triple-extraction/entity-linker.js.map +1 -0
- package/dist/services/triple-extraction/predicate-canonicalizer.d.ts +63 -0
- package/dist/services/triple-extraction/predicate-canonicalizer.d.ts.map +1 -0
- package/dist/services/triple-extraction/predicate-canonicalizer.js +166 -0
- package/dist/services/triple-extraction/predicate-canonicalizer.js.map +1 -0
- package/dist/services/triple-extraction/triple-extraction-service.d.ts +181 -0
- package/dist/services/triple-extraction/triple-extraction-service.d.ts.map +1 -0
- package/dist/services/triple-extraction/triple-extraction-service.js +907 -0
- package/dist/services/triple-extraction/triple-extraction-service.js.map +1 -0
- package/dist/services/triple-extraction/triple-extraction-statistics.d.ts +74 -0
- package/dist/services/triple-extraction/triple-extraction-statistics.d.ts.map +1 -0
- package/dist/services/triple-extraction/triple-extraction-statistics.js +146 -0
- package/dist/services/triple-extraction/triple-extraction-statistics.js.map +1 -0
- package/dist/shared/types/index.d.ts +1 -0
- package/dist/shared/types/index.d.ts.map +1 -1
- package/dist/shared/types/index.js.map +1 -1
- package/dist/shared/types/triple-extraction.d.ts +99 -0
- package/dist/shared/types/triple-extraction.d.ts.map +1 -0
- package/dist/shared/types/triple-extraction.js +6 -0
- package/dist/shared/types/triple-extraction.js.map +1 -0
- package/dist/shared/utils/pii-masker.d.ts +67 -0
- package/dist/shared/utils/pii-masker.d.ts.map +1 -0
- package/dist/shared/utils/pii-masker.js +205 -0
- package/dist/shared/utils/pii-masker.js.map +1 -0
- package/dist/shared/utils/prompt-template-loader.d.ts +42 -0
- package/dist/shared/utils/prompt-template-loader.d.ts.map +1 -0
- package/dist/shared/utils/prompt-template-loader.js +92 -0
- package/dist/shared/utils/prompt-template-loader.js.map +1 -0
- package/dist/shared/utils/triple-cache.d.ts +90 -0
- package/dist/shared/utils/triple-cache.d.ts.map +1 -0
- package/dist/shared/utils/triple-cache.js +124 -0
- package/dist/shared/utils/triple-cache.js.map +1 -0
- package/dist/tools/index.d.ts +2 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +3 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/types.d.ts +1 -0
- package/dist/tools/types.d.ts.map +1 -1
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Triple 추출 서비스
|
|
3
|
+
* LLM을 사용하여 Episodic Memory의 observation에서 (subject, predicate, object) 형태의 지식 그래프 트리플을 추출합니다.
|
|
4
|
+
*
|
|
5
|
+
* AriGraph 파이프라인의 핵심 컴포넌트로, Episodic Memory에서 Semantic Memory로의 자동 학습을 지원합니다.
|
|
6
|
+
*
|
|
7
|
+
* 비용 최적화 전략:
|
|
8
|
+
* - Rate limit (토큰 버킷 알고리즘)
|
|
9
|
+
* - 캐싱 (TTL 기반)
|
|
10
|
+
* - 비용 모니터링
|
|
11
|
+
* - 에러 처리 및 폴백
|
|
12
|
+
*/
|
|
13
|
+
import OpenAI from 'openai';
|
|
14
|
+
import { GoogleGenerativeAI } from '@google/generative-ai';
|
|
15
|
+
import { mementoConfig } from '../../shared/config/index.js';
|
|
16
|
+
import { CacheService } from '../../infrastructure/cache/cache-service.js';
|
|
17
|
+
import { PromptTemplateLoader } from '../../shared/utils/prompt-template-loader.js';
|
|
18
|
+
import { PredicateCanonicalizer } from './predicate-canonicalizer.js';
|
|
19
|
+
import { EntityLinker } from './entity-linker.js';
|
|
20
|
+
import { tripleExtractionLogger } from '../../infrastructure/logging/triple-extraction-logger.js';
|
|
21
|
+
import { TripleCacheService } from '../../shared/utils/triple-cache.js';
|
|
22
|
+
import { TripleExtractionStatisticsService } from './triple-extraction-statistics.js';
|
|
23
|
+
import { logger } from '../../shared/utils/logger.js';
|
|
24
|
+
/**
|
|
25
|
+
* 토큰 버킷 Rate Limiter
|
|
26
|
+
* LLM 호출 빈도를 제한하여 비용을 절감합니다.
|
|
27
|
+
*/
|
|
28
|
+
class TokenBucketRateLimiter {
|
|
29
|
+
tokens;
|
|
30
|
+
capacity;
|
|
31
|
+
refillRate; // tokens per second
|
|
32
|
+
lastRefill;
|
|
33
|
+
lock = Promise.resolve();
|
|
34
|
+
constructor(capacity = 1, refillRate = 1) {
|
|
35
|
+
this.capacity = capacity;
|
|
36
|
+
this.refillRate = refillRate;
|
|
37
|
+
this.tokens = capacity;
|
|
38
|
+
this.lastRefill = Date.now();
|
|
39
|
+
}
|
|
40
|
+
async consume() {
|
|
41
|
+
return await new Promise((resolve) => {
|
|
42
|
+
this.lock = this.lock.then(async () => {
|
|
43
|
+
this.refill();
|
|
44
|
+
if (this.tokens >= 1) {
|
|
45
|
+
this.tokens -= 1;
|
|
46
|
+
resolve(true);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const waitTime = (1 - this.tokens) / this.refillRate * 1000;
|
|
50
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
51
|
+
this.refill();
|
|
52
|
+
if (this.tokens >= 1) {
|
|
53
|
+
this.tokens -= 1;
|
|
54
|
+
resolve(true);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
resolve(false);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
refill() {
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
const elapsed = (now - this.lastRefill) / 1000;
|
|
65
|
+
const tokensToAdd = elapsed * this.refillRate;
|
|
66
|
+
this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
|
|
67
|
+
this.lastRefill = now;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Triple 추출 서비스
|
|
72
|
+
*/
|
|
73
|
+
export class TripleExtractionService {
|
|
74
|
+
openaiClient = null;
|
|
75
|
+
geminiClient = null;
|
|
76
|
+
preferredProvider;
|
|
77
|
+
cache; // PRD 6.11: Triple 추출 결과 캐싱 구현
|
|
78
|
+
rateLimiter;
|
|
79
|
+
costMetrics;
|
|
80
|
+
canonicalizer;
|
|
81
|
+
entityLinker;
|
|
82
|
+
statistics; // PRD 8.1: Triple 추출 통계 수집
|
|
83
|
+
// 기본 설정
|
|
84
|
+
DEFAULT_TEMPERATURE = 0.3;
|
|
85
|
+
DEFAULT_MAX_TOKENS = 2000;
|
|
86
|
+
CACHE_SIZE = 100; // PRD 7.3: 캐시 크기 100개 항목
|
|
87
|
+
CACHE_TTL_MS = 6 * 60 * 60 * 1000; // PRD 7.3: 캐싱 TTL 6시간
|
|
88
|
+
RATE_LIMITER_CAPACITY = 1;
|
|
89
|
+
RATE_LIMITER_REFILL_RATE = 1; // 초당 1회
|
|
90
|
+
// 로깅 설정
|
|
91
|
+
SUCCESS_SAMPLING_RATE = 0.1; // 성공 케이스 10% 샘플링
|
|
92
|
+
constructor() {
|
|
93
|
+
this.preferredProvider = this.initializeClients();
|
|
94
|
+
// PRD 6.11: Triple 추출 결과 캐싱 구현
|
|
95
|
+
// PRD 6.12: 캐시 키 생성 로직 구현 (content_hash 기반)
|
|
96
|
+
// PRD 6.13: 캐시 TTL 기반 자동 무효화 구현
|
|
97
|
+
this.cache = new TripleCacheService(this.CACHE_SIZE, this.CACHE_TTL_MS);
|
|
98
|
+
this.rateLimiter = new TokenBucketRateLimiter(this.RATE_LIMITER_CAPACITY, this.RATE_LIMITER_REFILL_RATE);
|
|
99
|
+
this.costMetrics = {
|
|
100
|
+
totalCalls: 0,
|
|
101
|
+
totalTokens: 0,
|
|
102
|
+
totalCost: 0,
|
|
103
|
+
lastReset: Date.now()
|
|
104
|
+
};
|
|
105
|
+
this.canonicalizer = new PredicateCanonicalizer();
|
|
106
|
+
this.entityLinker = new EntityLinker();
|
|
107
|
+
// PRD 8.1: Triple 추출 통계 수집
|
|
108
|
+
this.statistics = new TripleExtractionStatisticsService();
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* LLM 클라이언트 초기화
|
|
112
|
+
* 환경 변수 LLM_PROVIDER에 따라 프로바이더 선택
|
|
113
|
+
*/
|
|
114
|
+
initializeClients() {
|
|
115
|
+
const preferredProvider = mementoConfig.llmProvider || 'auto';
|
|
116
|
+
const initOpenAI = () => {
|
|
117
|
+
if (!mementoConfig.openaiApiKey) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
this.openaiClient = new OpenAI({ apiKey: mementoConfig.openaiApiKey });
|
|
122
|
+
logger.info('TripleExtractionService: OpenAI 클라이언트 초기화 완료');
|
|
123
|
+
return 'openai';
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
logger.warn('TripleExtractionService: OpenAI 초기화 실패', {
|
|
127
|
+
error: error instanceof Error ? error.message : String(error)
|
|
128
|
+
});
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const initGemini = () => {
|
|
133
|
+
if (!mementoConfig.geminiApiKey) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
this.geminiClient = new GoogleGenerativeAI(mementoConfig.geminiApiKey);
|
|
138
|
+
logger.info('TripleExtractionService: Gemini 클라이언트 초기화 완료');
|
|
139
|
+
return 'gemini';
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
logger.warn('TripleExtractionService: Gemini 초기화 실패', {
|
|
143
|
+
error: error instanceof Error ? error.message : String(error)
|
|
144
|
+
});
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
// 프로바이더 선택 로직
|
|
149
|
+
if (preferredProvider === 'openai') {
|
|
150
|
+
const result = initOpenAI();
|
|
151
|
+
if (result)
|
|
152
|
+
return result;
|
|
153
|
+
return initGemini();
|
|
154
|
+
}
|
|
155
|
+
else if (preferredProvider === 'gemini') {
|
|
156
|
+
const result = initGemini();
|
|
157
|
+
if (result)
|
|
158
|
+
return result;
|
|
159
|
+
return initOpenAI();
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
// 'auto' 또는 'ollama': OpenAI -> Gemini 순서
|
|
163
|
+
const result = initOpenAI();
|
|
164
|
+
if (result)
|
|
165
|
+
return result;
|
|
166
|
+
return initGemini();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* LLM 서비스 사용 가능 여부 확인
|
|
171
|
+
*/
|
|
172
|
+
isAvailable() {
|
|
173
|
+
return this.preferredProvider !== null;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Observation 텍스트에서 Triple 추출
|
|
177
|
+
*
|
|
178
|
+
* @param observation Episodic Memory의 content (observation 텍스트)
|
|
179
|
+
* @param options 추출 옵션
|
|
180
|
+
* @param memoryId Episodic Memory ID (로깅용, 선택사항)
|
|
181
|
+
* @returns Triple 추출 결과
|
|
182
|
+
*/
|
|
183
|
+
async extractTriples(observation, options = {}, memoryId) {
|
|
184
|
+
if (!observation || observation.trim().length === 0) {
|
|
185
|
+
const result = this.createFailureResult('no_triple', 'Observation이 비어있습니다.');
|
|
186
|
+
// 실패 케이스는 항상 로깅
|
|
187
|
+
this.logExtractionResult(result, memoryId, observation, 'Observation이 비어있습니다.').catch(err => {
|
|
188
|
+
logger.error('TripleExtractionService: 로깅 실패', { error: err });
|
|
189
|
+
});
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
// PRD 6.14: TripleExtractionService에 캐싱 통합
|
|
193
|
+
// 캐시 히트 시 LLM 호출 생략
|
|
194
|
+
const extractionStartTime = Date.now();
|
|
195
|
+
const cached = this.cache.get(observation);
|
|
196
|
+
if (cached) {
|
|
197
|
+
logger.debug('TripleExtractionService: 캐시 히트', {
|
|
198
|
+
contentLength: observation.length,
|
|
199
|
+
tripleCount: cached.triples.length
|
|
200
|
+
});
|
|
201
|
+
// PRD 8.1: Triple 추출 통계 수집 - 캐시 히트 기록
|
|
202
|
+
const extractionTime = Date.now() - extractionStartTime;
|
|
203
|
+
this.statistics.recordExtraction(cached, extractionTime, true, 0, 0, 0);
|
|
204
|
+
return cached;
|
|
205
|
+
}
|
|
206
|
+
// Rate limit 확인
|
|
207
|
+
await this.rateLimiter.consume();
|
|
208
|
+
try {
|
|
209
|
+
const provider = options.provider || this.preferredProvider || 'auto';
|
|
210
|
+
const { result, rawLLMOutput } = await this.extractWithLLM(observation, provider, options);
|
|
211
|
+
// PRD 6.14: TripleExtractionService에 캐싱 통합
|
|
212
|
+
// 성공한 Triple 추출 결과만 캐시에 저장
|
|
213
|
+
// TripleCacheService.set() 내부에서 triples.length > 0 체크
|
|
214
|
+
this.cache.set(observation, result);
|
|
215
|
+
// PRD 8.1: Triple 추출 통계 수집
|
|
216
|
+
const extractionTime = Date.now() - extractionStartTime;
|
|
217
|
+
const costMetrics = this.getCostMetrics();
|
|
218
|
+
// 이번 호출의 토큰과 비용은 정확히 측정하기 어려우므로, 전체 누적값 사용 (추정)
|
|
219
|
+
const llmCalls = 1; // 이번 호출
|
|
220
|
+
const tokens = costMetrics.totalTokens; // 누적 토큰 (추정)
|
|
221
|
+
const cost = costMetrics.totalCost; // 누적 비용 (추정)
|
|
222
|
+
this.statistics.recordExtraction(result, extractionTime, false, llmCalls, tokens, cost);
|
|
223
|
+
// rawLLMOutput 저장 정책 적용 (비동기, 블로킹하지 않음)
|
|
224
|
+
this.logExtractionResult(result, memoryId, observation, rawLLMOutput).catch(err => {
|
|
225
|
+
logger.error('TripleExtractionService: 로깅 실패', { error: err });
|
|
226
|
+
});
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
// 에러 타입 분류
|
|
231
|
+
const errorType = this.classifyErrorType(error);
|
|
232
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
233
|
+
// 상세 에러 로깅
|
|
234
|
+
logger.error('TripleExtractionService: Triple 추출 실패', {
|
|
235
|
+
error: errorMessage,
|
|
236
|
+
errorType,
|
|
237
|
+
observation: observation.substring(0, 100), // 로그용 일부만
|
|
238
|
+
retryable: errorType === 'network' || errorType === 'rate_limit' || errorType === 'timeout',
|
|
239
|
+
// API 키 오류는 재시도 불가 (즉시 실패)
|
|
240
|
+
immediateFailure: errorType === 'api_key'
|
|
241
|
+
});
|
|
242
|
+
// 실패 결과 생성
|
|
243
|
+
// 에러 타입에 따라 더 구체적인 실패 사유 제공
|
|
244
|
+
const failureReason = 'llm_api_error';
|
|
245
|
+
const result = this.createFailureResult(failureReason, errorMessage);
|
|
246
|
+
// 실패 케이스는 항상 로깅
|
|
247
|
+
// 에러가 발생해도 Episodic Memory는 정상 저장되도록 보장 (remember tool에서 처리)
|
|
248
|
+
this.logExtractionResult(result, memoryId, observation, errorMessage).catch(err => {
|
|
249
|
+
logger.error('TripleExtractionService: 로깅 실패', { error: err });
|
|
250
|
+
});
|
|
251
|
+
// 항상 TripleExtractionResult 반환 보장
|
|
252
|
+
// 에러가 발생해도 메인 플로우는 계속 진행
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* LLM을 사용하여 Triple 추출
|
|
258
|
+
*
|
|
259
|
+
* @param observation Observation 텍스트
|
|
260
|
+
* @param provider LLM Provider
|
|
261
|
+
* @param options 추출 옵션
|
|
262
|
+
* @returns Triple 추출 결과와 rawLLMOutput
|
|
263
|
+
*/
|
|
264
|
+
async extractWithLLM(observation, provider, options) {
|
|
265
|
+
// 프롬프트 템플릿 로드 및 렌더링
|
|
266
|
+
const prompt = PromptTemplateLoader.loadAndRender('triple-extraction', {
|
|
267
|
+
observation
|
|
268
|
+
});
|
|
269
|
+
// Provider 선택 및 호출
|
|
270
|
+
const actualProvider = provider === 'auto'
|
|
271
|
+
? (this.preferredProvider || 'openai')
|
|
272
|
+
: provider;
|
|
273
|
+
let rawLLMOutput;
|
|
274
|
+
let triples = [];
|
|
275
|
+
try {
|
|
276
|
+
switch (actualProvider) {
|
|
277
|
+
case 'openai':
|
|
278
|
+
rawLLMOutput = await this.extractWithOpenAI(prompt, options);
|
|
279
|
+
break;
|
|
280
|
+
case 'gemini':
|
|
281
|
+
rawLLMOutput = await this.extractWithGemini(prompt, options);
|
|
282
|
+
break;
|
|
283
|
+
case 'ollama':
|
|
284
|
+
rawLLMOutput = await this.extractWithOllama(prompt, options);
|
|
285
|
+
break;
|
|
286
|
+
default:
|
|
287
|
+
throw new Error(`지원하지 않는 LLM Provider: ${actualProvider}`);
|
|
288
|
+
}
|
|
289
|
+
// JSON 파싱 및 Triple 추출
|
|
290
|
+
const parseResult = this.parseLLMResponse(rawLLMOutput);
|
|
291
|
+
if (parseResult.success) {
|
|
292
|
+
triples = parseResult.triples;
|
|
293
|
+
// Triple이 추출되지 않은 경우 no_triple로 분류
|
|
294
|
+
if (triples.length === 0) {
|
|
295
|
+
return {
|
|
296
|
+
result: this.createFailureResult('no_triple', rawLLMOutput),
|
|
297
|
+
rawLLMOutput
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
// 구조가 모호한 경우 (일부만 유효) - 유효한 triple은 반환하되 경고
|
|
301
|
+
if (parseResult.errorType === 'structure') {
|
|
302
|
+
logger.warn('TripleExtractionService: 일부 triple이 유효하지 않음', {
|
|
303
|
+
validTriples: triples.length,
|
|
304
|
+
error: parseResult.error
|
|
305
|
+
});
|
|
306
|
+
// 유효한 triple은 반환하되, ambiguous_structure는 후처리에서 처리 가능하도록 정보 제공
|
|
307
|
+
// 현재는 유효한 것만 반환 (성공으로 처리)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
// 파싱 실패 시 실패 사유에 따라 분류
|
|
312
|
+
const failureReason = this.classifyFailureReason(parseResult.error, rawLLMOutput, parseResult.errorType);
|
|
313
|
+
return {
|
|
314
|
+
result: this.createFailureResult(failureReason, rawLLMOutput),
|
|
315
|
+
rawLLMOutput
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
// 에러 타입 분류 및 상세 로깅
|
|
321
|
+
const errorType = this.classifyErrorType(error);
|
|
322
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
323
|
+
logger.error('TripleExtractionService: LLM 호출 실패', {
|
|
324
|
+
error: errorMessage,
|
|
325
|
+
errorType,
|
|
326
|
+
provider: actualProvider,
|
|
327
|
+
retryable: errorType === 'network' || errorType === 'rate_limit' || errorType === 'timeout'
|
|
328
|
+
});
|
|
329
|
+
throw error; // 상위로 전파하여 llm_api_error로 처리
|
|
330
|
+
}
|
|
331
|
+
// Triple 정규화 및 steps 추적
|
|
332
|
+
const steps = this.trackExtractionSteps(triples);
|
|
333
|
+
// 성공 결과 생성
|
|
334
|
+
// rawLLMOutput은 로그 파일에만 저장하므로 extractionInfo에는 포함하지 않음
|
|
335
|
+
// (DB에 저장하지 않음)
|
|
336
|
+
const extractionInfo = {
|
|
337
|
+
steps
|
|
338
|
+
// rawLLMOutput은 로그 파일에만 저장 (DB 저장 안 함)
|
|
339
|
+
};
|
|
340
|
+
return {
|
|
341
|
+
result: {
|
|
342
|
+
triples,
|
|
343
|
+
extractionInfo
|
|
344
|
+
},
|
|
345
|
+
rawLLMOutput
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* OpenAI를 사용하여 Triple 추출
|
|
350
|
+
*/
|
|
351
|
+
async extractWithOpenAI(prompt, options) {
|
|
352
|
+
if (!this.openaiClient) {
|
|
353
|
+
throw new Error('OpenAI 클라이언트가 초기화되지 않았습니다.');
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
const model = mementoConfig.openaiLlmModel || 'gpt-4o-mini';
|
|
357
|
+
const temperature = options.temperature ?? this.DEFAULT_TEMPERATURE;
|
|
358
|
+
const maxTokens = options.maxTokens ?? this.DEFAULT_MAX_TOKENS;
|
|
359
|
+
const response = await this.openaiClient.chat.completions.create({
|
|
360
|
+
model,
|
|
361
|
+
messages: [
|
|
362
|
+
{
|
|
363
|
+
role: 'system',
|
|
364
|
+
content: 'You are a knowledge graph extractor. Extract triples (subject, predicate, object) from observations and return JSON format only.'
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
role: 'user',
|
|
368
|
+
content: prompt
|
|
369
|
+
}
|
|
370
|
+
],
|
|
371
|
+
temperature,
|
|
372
|
+
max_tokens: maxTokens,
|
|
373
|
+
response_format: { type: 'json_object' } // JSON 모드 강제
|
|
374
|
+
});
|
|
375
|
+
const content = response.choices[0]?.message?.content;
|
|
376
|
+
if (!content) {
|
|
377
|
+
throw new Error('OpenAI 응답이 비어있습니다.');
|
|
378
|
+
}
|
|
379
|
+
// 비용 모니터링
|
|
380
|
+
const promptTokens = response.usage?.prompt_tokens || 0;
|
|
381
|
+
const completionTokens = response.usage?.completion_tokens || 0;
|
|
382
|
+
this.updateCostMetrics('openai', promptTokens, completionTokens);
|
|
383
|
+
return content;
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
logger.error('TripleExtractionService: OpenAI 호출 실패', {
|
|
387
|
+
error: error instanceof Error ? error.message : String(error)
|
|
388
|
+
});
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Gemini를 사용하여 Triple 추출
|
|
394
|
+
*/
|
|
395
|
+
async extractWithGemini(prompt, options) {
|
|
396
|
+
if (!this.geminiClient) {
|
|
397
|
+
throw new Error('Gemini 클라이언트가 초기화되지 않았습니다.');
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
const modelName = mementoConfig.geminiModel || 'gemini-1.5-flash';
|
|
401
|
+
const model = this.geminiClient.getGenerativeModel({ model: modelName });
|
|
402
|
+
const temperature = options.temperature ?? this.DEFAULT_TEMPERATURE;
|
|
403
|
+
const maxTokens = options.maxTokens ?? this.DEFAULT_MAX_TOKENS;
|
|
404
|
+
const result = await model.generateContent({
|
|
405
|
+
contents: [
|
|
406
|
+
{
|
|
407
|
+
role: 'user',
|
|
408
|
+
parts: [{ text: prompt }]
|
|
409
|
+
}
|
|
410
|
+
],
|
|
411
|
+
generationConfig: {
|
|
412
|
+
temperature,
|
|
413
|
+
maxOutputTokens: maxTokens,
|
|
414
|
+
responseMimeType: 'application/json'
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
const response = result.response;
|
|
418
|
+
const text = response.text();
|
|
419
|
+
if (!text) {
|
|
420
|
+
throw new Error('Gemini 응답이 비어있습니다.');
|
|
421
|
+
}
|
|
422
|
+
// 비용 모니터링 (Gemini는 usage 정보를 직접 제공하지 않으므로 대략적 추정)
|
|
423
|
+
const estimatedPromptTokens = Math.ceil(prompt.length / 4);
|
|
424
|
+
const estimatedCompletionTokens = Math.ceil(text.length / 4);
|
|
425
|
+
this.updateCostMetrics('gemini', estimatedPromptTokens, estimatedCompletionTokens);
|
|
426
|
+
return text;
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
logger.error('TripleExtractionService: Gemini 호출 실패', {
|
|
430
|
+
error: error instanceof Error ? error.message : String(error)
|
|
431
|
+
});
|
|
432
|
+
throw error;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Ollama를 사용하여 Triple 추출
|
|
437
|
+
*/
|
|
438
|
+
async extractWithOllama(prompt, options) {
|
|
439
|
+
const baseUrl = mementoConfig.ollamaBaseUrl || 'http://localhost:11434';
|
|
440
|
+
const model = mementoConfig.ollamaModel || 'llama3';
|
|
441
|
+
const temperature = options.temperature ?? this.DEFAULT_TEMPERATURE;
|
|
442
|
+
const maxTokens = options.maxTokens ?? this.DEFAULT_MAX_TOKENS;
|
|
443
|
+
const requestBody = {
|
|
444
|
+
model,
|
|
445
|
+
messages: [
|
|
446
|
+
{
|
|
447
|
+
role: 'system',
|
|
448
|
+
content: 'You are a knowledge graph extractor. Extract triples (subject, predicate, object) from observations and return JSON format only.'
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
role: 'user',
|
|
452
|
+
content: prompt
|
|
453
|
+
}
|
|
454
|
+
],
|
|
455
|
+
options: {
|
|
456
|
+
temperature,
|
|
457
|
+
num_predict: maxTokens
|
|
458
|
+
},
|
|
459
|
+
format: 'json'
|
|
460
|
+
};
|
|
461
|
+
try {
|
|
462
|
+
// Ollama 모델 존재 여부 확인
|
|
463
|
+
const modelExists = await this.checkOllamaModel(baseUrl, model);
|
|
464
|
+
if (!modelExists) {
|
|
465
|
+
throw new Error(`Ollama 모델 '${model}'이 설치되지 않았습니다. ` +
|
|
466
|
+
`다음 명령어로 모델을 설치하세요: ollama pull ${model}`);
|
|
467
|
+
}
|
|
468
|
+
const apiUrl = `${baseUrl}/api/chat`;
|
|
469
|
+
const response = await fetch(apiUrl, {
|
|
470
|
+
method: 'POST',
|
|
471
|
+
headers: {
|
|
472
|
+
'Content-Type': 'application/json'
|
|
473
|
+
},
|
|
474
|
+
body: JSON.stringify(requestBody),
|
|
475
|
+
signal: AbortSignal.timeout(60000) // 60초 타임아웃
|
|
476
|
+
});
|
|
477
|
+
if (!response.ok) {
|
|
478
|
+
const errorText = await response.text().catch(() => '');
|
|
479
|
+
throw new Error(`Ollama API 호출 실패: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ''}`);
|
|
480
|
+
}
|
|
481
|
+
// NDJSON 형식 처리
|
|
482
|
+
const contentType = response.headers.get('content-type') || '';
|
|
483
|
+
const isNDJSON = contentType.includes('application/x-ndjson') || contentType.includes('ndjson');
|
|
484
|
+
let content = '';
|
|
485
|
+
const responseText = await response.text();
|
|
486
|
+
if (isNDJSON) {
|
|
487
|
+
const lines = responseText.trim().split('\n').filter(line => line.trim().length > 0);
|
|
488
|
+
const contentParts = [];
|
|
489
|
+
for (const line of lines) {
|
|
490
|
+
try {
|
|
491
|
+
const lineData = JSON.parse(line);
|
|
492
|
+
if (lineData.message?.content) {
|
|
493
|
+
contentParts.push(lineData.message.content);
|
|
494
|
+
}
|
|
495
|
+
if (lineData.done === true) {
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
// 라인 파싱 실패 시 무시
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
content = contentParts.join('');
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
const data = JSON.parse(responseText);
|
|
507
|
+
content = data.message?.content || '';
|
|
508
|
+
}
|
|
509
|
+
if (!content) {
|
|
510
|
+
throw new Error('Ollama 응답이 비어있습니다.');
|
|
511
|
+
}
|
|
512
|
+
// 비용 모니터링 (Ollama는 로컬이므로 비용 0, 토큰 수만 추적)
|
|
513
|
+
const estimatedPromptTokens = Math.ceil(prompt.length / 4);
|
|
514
|
+
const estimatedCompletionTokens = Math.ceil(content.length / 4);
|
|
515
|
+
this.updateCostMetrics('ollama', estimatedPromptTokens, estimatedCompletionTokens);
|
|
516
|
+
return content;
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
logger.error('TripleExtractionService: Ollama 호출 실패', {
|
|
520
|
+
error: error instanceof Error ? error.message : String(error),
|
|
521
|
+
baseUrl,
|
|
522
|
+
model
|
|
523
|
+
});
|
|
524
|
+
throw error;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Ollama 모델 존재 여부 확인
|
|
529
|
+
*/
|
|
530
|
+
async checkOllamaModel(baseUrl, model) {
|
|
531
|
+
try {
|
|
532
|
+
const response = await fetch(`${baseUrl}/api/tags`, {
|
|
533
|
+
method: 'GET',
|
|
534
|
+
signal: AbortSignal.timeout(3000)
|
|
535
|
+
});
|
|
536
|
+
if (!response.ok) {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
const data = await response.json();
|
|
540
|
+
const models = data.models || [];
|
|
541
|
+
return models.some((m) => m.name === model || m.name.startsWith(`${model}:`));
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* LLM 응답을 파싱하여 Triple 배열 추출
|
|
549
|
+
*
|
|
550
|
+
* @param responseText LLM 원본 응답 텍스트
|
|
551
|
+
* @returns 파싱 결과 (success, triples, error)
|
|
552
|
+
*/
|
|
553
|
+
parseLLMResponse(responseText) {
|
|
554
|
+
try {
|
|
555
|
+
// JSON 추출 (마크다운 코드 블록 제거)
|
|
556
|
+
let jsonText = this.extractJSON(responseText);
|
|
557
|
+
if (!jsonText) {
|
|
558
|
+
jsonText = responseText.trim();
|
|
559
|
+
}
|
|
560
|
+
// JSON 파싱
|
|
561
|
+
const parsed = JSON.parse(jsonText);
|
|
562
|
+
// triples 배열 추출
|
|
563
|
+
if (!parsed.triples || !Array.isArray(parsed.triples)) {
|
|
564
|
+
return {
|
|
565
|
+
success: false,
|
|
566
|
+
triples: [],
|
|
567
|
+
error: 'triples 배열이 없거나 유효하지 않습니다.',
|
|
568
|
+
errorType: 'parse'
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
// Triple 유효성 검증
|
|
572
|
+
const validTriples = [];
|
|
573
|
+
for (const triple of parsed.triples) {
|
|
574
|
+
if (this.isValidTriple(triple)) {
|
|
575
|
+
validTriples.push({
|
|
576
|
+
subject: String(triple.subject).trim(),
|
|
577
|
+
predicate: String(triple.predicate).trim(),
|
|
578
|
+
object: String(triple.object).trim()
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
// 모든 triple이 유효하지 않은 경우
|
|
583
|
+
if (validTriples.length === 0 && parsed.triples.length > 0) {
|
|
584
|
+
return {
|
|
585
|
+
success: false,
|
|
586
|
+
triples: [],
|
|
587
|
+
error: '모든 triple이 유효하지 않습니다.',
|
|
588
|
+
errorType: 'no_triple'
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
// 일부 triple만 유효한 경우 (유효한 것만 반환, ambiguous_structure는 별도 감지)
|
|
592
|
+
// 대부분이 유효하지 않으면 ambiguous_structure로 분류
|
|
593
|
+
const invalidRatio = (parsed.triples.length - validTriples.length) / parsed.triples.length;
|
|
594
|
+
if (invalidRatio > 0.5 && parsed.triples.length > 1) {
|
|
595
|
+
// 유효한 triple은 반환하되, 구조가 모호함을 표시
|
|
596
|
+
return {
|
|
597
|
+
success: true,
|
|
598
|
+
triples: validTriples,
|
|
599
|
+
error: `일부 triple이 유효하지 않습니다. (유효: ${validTriples.length}/${parsed.triples.length})`,
|
|
600
|
+
errorType: 'structure'
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
return {
|
|
604
|
+
success: true,
|
|
605
|
+
triples: validTriples
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
return {
|
|
610
|
+
success: false,
|
|
611
|
+
triples: [],
|
|
612
|
+
error: error instanceof Error ? error.message : 'JSON 파싱 실패',
|
|
613
|
+
errorType: 'parse'
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* JSON 텍스트에서 JSON 객체 추출
|
|
619
|
+
*/
|
|
620
|
+
extractJSON(text) {
|
|
621
|
+
if (!text || typeof text !== 'string') {
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
let jsonText = text.trim();
|
|
625
|
+
// 마크다운 코드 블록 제거
|
|
626
|
+
if (jsonText.startsWith('```json')) {
|
|
627
|
+
jsonText = jsonText.replace(/^```json\s*/, '').replace(/\s*```.*$/s, '');
|
|
628
|
+
}
|
|
629
|
+
else if (jsonText.startsWith('```')) {
|
|
630
|
+
jsonText = jsonText.replace(/^```\s*/, '').replace(/\s*```.*$/s, '');
|
|
631
|
+
}
|
|
632
|
+
// 첫 번째 '{'부터 마지막 '}'까지 추출
|
|
633
|
+
const firstBrace = jsonText.indexOf('{');
|
|
634
|
+
const lastBrace = jsonText.lastIndexOf('}');
|
|
635
|
+
if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) {
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
return jsonText.substring(firstBrace, lastBrace + 1).trim();
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Triple 유효성 검증
|
|
642
|
+
*/
|
|
643
|
+
isValidTriple(triple) {
|
|
644
|
+
return (triple &&
|
|
645
|
+
typeof triple === 'object' &&
|
|
646
|
+
typeof triple.subject === 'string' &&
|
|
647
|
+
typeof triple.predicate === 'string' &&
|
|
648
|
+
typeof triple.object === 'string' &&
|
|
649
|
+
triple.subject.trim().length > 0 &&
|
|
650
|
+
triple.predicate.trim().length > 0 &&
|
|
651
|
+
triple.object.trim().length > 0);
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* 비용 메트릭 업데이트
|
|
655
|
+
*/
|
|
656
|
+
updateCostMetrics(provider, promptTokens, completionTokens) {
|
|
657
|
+
this.costMetrics.totalCalls += 1;
|
|
658
|
+
this.costMetrics.totalTokens += promptTokens + completionTokens;
|
|
659
|
+
// 비용 계산 (간단한 추정)
|
|
660
|
+
let cost = 0;
|
|
661
|
+
if (provider === 'openai') {
|
|
662
|
+
// GPT-4o-mini 기준: $0.15 / 1M input tokens, $0.60 / 1M output tokens
|
|
663
|
+
cost = (promptTokens / 1_000_000) * 0.15 + (completionTokens / 1_000_000) * 0.60;
|
|
664
|
+
}
|
|
665
|
+
else if (provider === 'gemini') {
|
|
666
|
+
// Gemini 1.5 Flash 기준: $0.075 / 1M input tokens, $0.30 / 1M output tokens
|
|
667
|
+
cost = (promptTokens / 1_000_000) * 0.075 + (completionTokens / 1_000_000) * 0.30;
|
|
668
|
+
}
|
|
669
|
+
// Ollama는 로컬이므로 비용 0
|
|
670
|
+
this.costMetrics.totalCost += cost;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* 실패 사유 분류
|
|
674
|
+
* 에러 타입과 컨텍스트를 기반으로 적절한 실패 사유를 반환합니다.
|
|
675
|
+
*
|
|
676
|
+
* @param error 에러 메시지 또는 파싱 결과의 error
|
|
677
|
+
* @param rawLLMOutput 원본 LLM 응답 (선택사항)
|
|
678
|
+
* @param errorType 파싱 결과의 에러 타입 (선택사항)
|
|
679
|
+
* @returns 분류된 실패 사유
|
|
680
|
+
*/
|
|
681
|
+
classifyFailureReason(error, rawLLMOutput, errorType) {
|
|
682
|
+
// errorType이 명시된 경우 우선 사용
|
|
683
|
+
if (errorType === 'parse') {
|
|
684
|
+
return 'llm_parse_fail';
|
|
685
|
+
}
|
|
686
|
+
if (errorType === 'structure') {
|
|
687
|
+
return 'ambiguous_structure';
|
|
688
|
+
}
|
|
689
|
+
if (errorType === 'no_triple') {
|
|
690
|
+
return 'no_triple';
|
|
691
|
+
}
|
|
692
|
+
// errorType이 없으면 에러 메시지 기반으로 분류
|
|
693
|
+
if (!error) {
|
|
694
|
+
return 'no_triple';
|
|
695
|
+
}
|
|
696
|
+
const errorLower = error.toLowerCase();
|
|
697
|
+
// JSON 파싱 관련 에러
|
|
698
|
+
if (errorLower.includes('json') ||
|
|
699
|
+
errorLower.includes('parse') ||
|
|
700
|
+
errorLower.includes('syntax') ||
|
|
701
|
+
errorLower.includes('triples 배열이 없거나')) {
|
|
702
|
+
return 'llm_parse_fail';
|
|
703
|
+
}
|
|
704
|
+
// 구조 관련 에러 (모호한 구조)
|
|
705
|
+
if (errorLower.includes('구조') ||
|
|
706
|
+
errorLower.includes('structure') ||
|
|
707
|
+
errorLower.includes('ambiguous') ||
|
|
708
|
+
errorLower.includes('유효하지 않습니다')) {
|
|
709
|
+
return 'ambiguous_structure';
|
|
710
|
+
}
|
|
711
|
+
// Triple이 없는 경우
|
|
712
|
+
if (errorLower.includes('triple') &&
|
|
713
|
+
(errorLower.includes('없') || errorLower.includes('empty') || errorLower.includes('no'))) {
|
|
714
|
+
return 'no_triple';
|
|
715
|
+
}
|
|
716
|
+
// 기본값: 파싱 실패로 간주
|
|
717
|
+
return 'llm_parse_fail';
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Triple 추출 결과 로깅
|
|
721
|
+
* rawLLMOutput 저장 정책에 따라 로그 파일에 저장합니다.
|
|
722
|
+
*
|
|
723
|
+
* 저장 정책:
|
|
724
|
+
* - 성공 케이스: 10% 샘플링
|
|
725
|
+
* - 실패 케이스: 100% 저장
|
|
726
|
+
* - PII 마스킹은 로거에서 처리
|
|
727
|
+
*
|
|
728
|
+
* @param result Triple 추출 결과
|
|
729
|
+
* @param memoryId Episodic Memory ID (선택사항)
|
|
730
|
+
* @param observation Observation 텍스트 (선택사항)
|
|
731
|
+
* @param rawLLMOutput 원본 LLM 응답 (로깅용)
|
|
732
|
+
*/
|
|
733
|
+
async logExtractionResult(result, memoryId, observation, rawLLMOutput) {
|
|
734
|
+
const isSuccess = result.triples.length > 0;
|
|
735
|
+
const isFailure = !isSuccess || result.extractionInfo.failureReason !== undefined;
|
|
736
|
+
// 저장 정책 적용
|
|
737
|
+
let shouldLog = false;
|
|
738
|
+
if (isFailure) {
|
|
739
|
+
// 실패 케이스: 100% 저장
|
|
740
|
+
shouldLog = true;
|
|
741
|
+
}
|
|
742
|
+
else if (isSuccess) {
|
|
743
|
+
// 성공 케이스: 10% 샘플링
|
|
744
|
+
shouldLog = Math.random() < this.SUCCESS_SAMPLING_RATE;
|
|
745
|
+
}
|
|
746
|
+
if (!shouldLog) {
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
// rawLLMOutput을 포함하여 로깅
|
|
750
|
+
// 로거에서 PII 마스킹을 처리하므로 원본 rawLLMOutput 전달
|
|
751
|
+
const resultWithRawOutput = {
|
|
752
|
+
...result,
|
|
753
|
+
extractionInfo: {
|
|
754
|
+
...result.extractionInfo,
|
|
755
|
+
rawLLMOutput // 로거에서 마스킹 처리
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
await tripleExtractionLogger.logExtraction(resultWithRawOutput, memoryId, observation);
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* 에러 타입 분류
|
|
762
|
+
* 에러의 특성에 따라 분류하여 적절한 처리 전략을 수립합니다.
|
|
763
|
+
*
|
|
764
|
+
* @param error 에러 객체
|
|
765
|
+
* @returns 에러 타입
|
|
766
|
+
*/
|
|
767
|
+
classifyErrorType(error) {
|
|
768
|
+
if (!(error instanceof Error)) {
|
|
769
|
+
return 'unknown';
|
|
770
|
+
}
|
|
771
|
+
const errorMessage = error.message.toLowerCase();
|
|
772
|
+
const errorName = error.name.toLowerCase();
|
|
773
|
+
// 네트워크 오류
|
|
774
|
+
if (errorMessage.includes('network') ||
|
|
775
|
+
errorMessage.includes('econnrefused') ||
|
|
776
|
+
errorMessage.includes('enotfound') ||
|
|
777
|
+
errorMessage.includes('timeout') ||
|
|
778
|
+
errorName.includes('network')) {
|
|
779
|
+
return 'network';
|
|
780
|
+
}
|
|
781
|
+
// API 키 오류
|
|
782
|
+
if (errorMessage.includes('api key') ||
|
|
783
|
+
errorMessage.includes('apikey') ||
|
|
784
|
+
errorMessage.includes('unauthorized') ||
|
|
785
|
+
errorMessage.includes('authentication') ||
|
|
786
|
+
errorMessage.includes('invalid api key') ||
|
|
787
|
+
errorMessage.includes('api key not found')) {
|
|
788
|
+
return 'api_key';
|
|
789
|
+
}
|
|
790
|
+
// Rate Limit 오류
|
|
791
|
+
if (errorMessage.includes('rate limit') ||
|
|
792
|
+
errorMessage.includes('ratelimit') ||
|
|
793
|
+
errorMessage.includes('too many requests') ||
|
|
794
|
+
errorMessage.includes('429')) {
|
|
795
|
+
return 'rate_limit';
|
|
796
|
+
}
|
|
797
|
+
// 타임아웃 오류
|
|
798
|
+
if (errorMessage.includes('timeout') ||
|
|
799
|
+
errorMessage.includes('timed out') ||
|
|
800
|
+
errorName.includes('timeout')) {
|
|
801
|
+
return 'timeout';
|
|
802
|
+
}
|
|
803
|
+
return 'unknown';
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Triple 추출 단계별 성공 여부 추적
|
|
807
|
+
* 각 triple에 대해 canonicalization과 entityLinking을 수행하여 steps를 생성합니다.
|
|
808
|
+
*
|
|
809
|
+
* @param triples 추출된 triple 배열
|
|
810
|
+
* @returns 추적된 steps 정보
|
|
811
|
+
*/
|
|
812
|
+
trackExtractionSteps(triples) {
|
|
813
|
+
let canonicalizationSuccess = false;
|
|
814
|
+
let entityLinkingSuccess = false;
|
|
815
|
+
// Triple이 없으면 모두 false 반환
|
|
816
|
+
if (triples.length === 0) {
|
|
817
|
+
return {
|
|
818
|
+
canonicalization: false,
|
|
819
|
+
entityLinking: false
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
// 각 triple에 대해 정규화 수행
|
|
823
|
+
for (const triple of triples) {
|
|
824
|
+
// Predicate 정규화 (Canonicalization)
|
|
825
|
+
if (!canonicalizationSuccess) {
|
|
826
|
+
const canonicalResult = this.canonicalizer.canonicalize(triple.predicate);
|
|
827
|
+
if (canonicalResult.success) {
|
|
828
|
+
canonicalizationSuccess = true;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
// Subject Entity Linking
|
|
832
|
+
if (!entityLinkingSuccess) {
|
|
833
|
+
const subjectResult = this.entityLinker.link(triple.subject);
|
|
834
|
+
if (subjectResult.success) {
|
|
835
|
+
entityLinkingSuccess = true;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
// Object Entity Linking
|
|
839
|
+
if (!entityLinkingSuccess) {
|
|
840
|
+
const objectResult = this.entityLinker.link(triple.object);
|
|
841
|
+
if (objectResult.success) {
|
|
842
|
+
entityLinkingSuccess = true;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
// 둘 다 성공했으면 조기 종료
|
|
846
|
+
if (canonicalizationSuccess && entityLinkingSuccess) {
|
|
847
|
+
break;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return {
|
|
851
|
+
canonicalization: canonicalizationSuccess,
|
|
852
|
+
entityLinking: entityLinkingSuccess
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* 실패 결과 생성
|
|
857
|
+
*
|
|
858
|
+
* @param failureReason 실패 사유
|
|
859
|
+
* @param rawLLMOutput 원본 LLM 응답 또는 에러 메시지 (선택사항)
|
|
860
|
+
* @returns 실패 결과
|
|
861
|
+
*/
|
|
862
|
+
createFailureResult(failureReason, rawLLMOutput) {
|
|
863
|
+
const extractionInfo = {
|
|
864
|
+
failureReason,
|
|
865
|
+
steps: {
|
|
866
|
+
canonicalization: false,
|
|
867
|
+
entityLinking: false
|
|
868
|
+
},
|
|
869
|
+
rawLLMOutput: rawLLMOutput
|
|
870
|
+
};
|
|
871
|
+
return {
|
|
872
|
+
triples: [],
|
|
873
|
+
extractionInfo
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* 비용 통계 조회
|
|
878
|
+
*/
|
|
879
|
+
getCostMetrics() {
|
|
880
|
+
return { ...this.costMetrics };
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Triple 추출 통계 조회
|
|
884
|
+
*
|
|
885
|
+
* PRD 8.1: Triple 추출 통계
|
|
886
|
+
* - 성공률: 성공한 Triple 추출 수 / 전체 시도 수
|
|
887
|
+
* - 평균 추출 시간
|
|
888
|
+
* - LLM 호출 횟수 및 비용
|
|
889
|
+
* - 실패 사유별 통계
|
|
890
|
+
*
|
|
891
|
+
* @returns Triple 추출 통계
|
|
892
|
+
*/
|
|
893
|
+
getStatistics() {
|
|
894
|
+
return this.statistics.getStatistics();
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* 실패 사유별 통계 조회
|
|
898
|
+
*
|
|
899
|
+
* PRD 8.1: 실패 사유별 통계
|
|
900
|
+
*
|
|
901
|
+
* @returns 실패 사유별 통계
|
|
902
|
+
*/
|
|
903
|
+
getFailureReasonStatistics() {
|
|
904
|
+
return this.statistics.getFailureReasonStatistics();
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
//# sourceMappingURL=triple-extraction-service.js.map
|