memento-mcp-server 1.14.0 → 1.15.0-c

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.
Files changed (77) hide show
  1. package/dist/domains/memory/tools/convert-episodic-to-semantic-tool.d.ts +18 -0
  2. package/dist/domains/memory/tools/convert-episodic-to-semantic-tool.d.ts.map +1 -0
  3. package/dist/domains/memory/tools/convert-episodic-to-semantic-tool.js +346 -0
  4. package/dist/domains/memory/tools/convert-episodic-to-semantic-tool.js.map +1 -0
  5. package/dist/domains/memory/tools/remember-tool.d.ts.map +1 -1
  6. package/dist/domains/memory/tools/remember-tool.js +182 -1
  7. package/dist/domains/memory/tools/remember-tool.js.map +1 -1
  8. package/dist/infrastructure/database/database/migration/migrations/005-relation-engine-schema.sql +7 -7
  9. package/dist/infrastructure/database/database/migration/migrations/008-arigraph-schema-expansion.d.ts +65 -0
  10. package/dist/infrastructure/database/database/migration/migrations/008-arigraph-schema-expansion.d.ts.map +1 -0
  11. package/dist/infrastructure/database/database/migration/migrations/008-arigraph-schema-expansion.js +250 -0
  12. package/dist/infrastructure/database/database/migration/migrations/008-arigraph-schema-expansion.js.map +1 -0
  13. package/dist/infrastructure/database/database/migration/migrations/008-arigraph-schema-expansion.sql +86 -0
  14. package/dist/infrastructure/logging/triple-extraction-logger.d.ts +92 -0
  15. package/dist/infrastructure/logging/triple-extraction-logger.d.ts.map +1 -0
  16. package/dist/infrastructure/logging/triple-extraction-logger.js +194 -0
  17. package/dist/infrastructure/logging/triple-extraction-logger.js.map +1 -0
  18. package/dist/infrastructure/scheduler/batch-scheduler.d.ts +57 -0
  19. package/dist/infrastructure/scheduler/batch-scheduler.d.ts.map +1 -1
  20. package/dist/infrastructure/scheduler/batch-scheduler.js +220 -0
  21. package/dist/infrastructure/scheduler/batch-scheduler.js.map +1 -1
  22. package/dist/infrastructure/scheduler/jobs/triple-extraction-batch-job.d.ts +194 -0
  23. package/dist/infrastructure/scheduler/jobs/triple-extraction-batch-job.d.ts.map +1 -0
  24. package/dist/infrastructure/scheduler/jobs/triple-extraction-batch-job.js +639 -0
  25. package/dist/infrastructure/scheduler/jobs/triple-extraction-batch-job.js.map +1 -0
  26. package/dist/services/semantic-memory/semantic-memory-statistics.d.ts +64 -0
  27. package/dist/services/semantic-memory/semantic-memory-statistics.d.ts.map +1 -0
  28. package/dist/services/semantic-memory/semantic-memory-statistics.js +113 -0
  29. package/dist/services/semantic-memory/semantic-memory-statistics.js.map +1 -0
  30. package/dist/services/semantic-memory/semantic-memory-update-service.d.ts +257 -0
  31. package/dist/services/semantic-memory/semantic-memory-update-service.d.ts.map +1 -0
  32. package/dist/services/semantic-memory/semantic-memory-update-service.js +696 -0
  33. package/dist/services/semantic-memory/semantic-memory-update-service.js.map +1 -0
  34. package/dist/services/triple-extraction/entity-linker.d.ts +55 -0
  35. package/dist/services/triple-extraction/entity-linker.d.ts.map +1 -0
  36. package/dist/services/triple-extraction/entity-linker.js +154 -0
  37. package/dist/services/triple-extraction/entity-linker.js.map +1 -0
  38. package/dist/services/triple-extraction/predicate-canonicalizer.d.ts +63 -0
  39. package/dist/services/triple-extraction/predicate-canonicalizer.d.ts.map +1 -0
  40. package/dist/services/triple-extraction/predicate-canonicalizer.js +166 -0
  41. package/dist/services/triple-extraction/predicate-canonicalizer.js.map +1 -0
  42. package/dist/services/triple-extraction/triple-extraction-service.d.ts +181 -0
  43. package/dist/services/triple-extraction/triple-extraction-service.d.ts.map +1 -0
  44. package/dist/services/triple-extraction/triple-extraction-service.js +907 -0
  45. package/dist/services/triple-extraction/triple-extraction-service.js.map +1 -0
  46. package/dist/services/triple-extraction/triple-extraction-statistics.d.ts +74 -0
  47. package/dist/services/triple-extraction/triple-extraction-statistics.d.ts.map +1 -0
  48. package/dist/services/triple-extraction/triple-extraction-statistics.js +146 -0
  49. package/dist/services/triple-extraction/triple-extraction-statistics.js.map +1 -0
  50. package/dist/shared/types/index.d.ts +1 -0
  51. package/dist/shared/types/index.d.ts.map +1 -1
  52. package/dist/shared/types/index.js.map +1 -1
  53. package/dist/shared/types/triple-extraction.d.ts +99 -0
  54. package/dist/shared/types/triple-extraction.d.ts.map +1 -0
  55. package/dist/shared/types/triple-extraction.js +6 -0
  56. package/dist/shared/types/triple-extraction.js.map +1 -0
  57. package/dist/shared/utils/pii-masker.d.ts +67 -0
  58. package/dist/shared/utils/pii-masker.d.ts.map +1 -0
  59. package/dist/shared/utils/pii-masker.js +205 -0
  60. package/dist/shared/utils/pii-masker.js.map +1 -0
  61. package/dist/shared/utils/prompt-template-loader.d.ts +42 -0
  62. package/dist/shared/utils/prompt-template-loader.d.ts.map +1 -0
  63. package/dist/shared/utils/prompt-template-loader.js +92 -0
  64. package/dist/shared/utils/prompt-template-loader.js.map +1 -0
  65. package/dist/shared/utils/triple-cache.d.ts +90 -0
  66. package/dist/shared/utils/triple-cache.d.ts.map +1 -0
  67. package/dist/shared/utils/triple-cache.js +124 -0
  68. package/dist/shared/utils/triple-cache.js.map +1 -0
  69. package/dist/tools/index.d.ts +2 -1
  70. package/dist/tools/index.d.ts.map +1 -1
  71. package/dist/tools/index.js +3 -1
  72. package/dist/tools/index.js.map +1 -1
  73. package/dist/tools/types.d.ts +1 -0
  74. package/dist/tools/types.d.ts.map +1 -1
  75. package/dist/tools/types.js +2 -0
  76. package/dist/tools/types.js.map +1 -1
  77. 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