memento-mcp-server 1.11.1 → 1.12.0-a1

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 (126) hide show
  1. package/README.en.md +21 -6
  2. package/README.md +38 -7
  3. package/dist/algorithms/hybrid-search-engine.d.ts +34 -1
  4. package/dist/algorithms/hybrid-search-engine.d.ts.map +1 -1
  5. package/dist/algorithms/hybrid-search-engine.js +186 -17
  6. package/dist/algorithms/hybrid-search-engine.js.map +1 -1
  7. package/dist/algorithms/search-ranking.d.ts +15 -1
  8. package/dist/algorithms/search-ranking.d.ts.map +1 -1
  9. package/dist/algorithms/search-ranking.js +41 -4
  10. package/dist/algorithms/search-ranking.js.map +1 -1
  11. package/dist/config/environment.d.ts.map +1 -1
  12. package/dist/config/environment.js +4 -0
  13. package/dist/config/environment.js.map +1 -1
  14. package/dist/config/index.d.ts.map +1 -1
  15. package/dist/config/index.js +6 -0
  16. package/dist/config/index.js.map +1 -1
  17. package/dist/config/ranking-weights-loader.d.ts +37 -0
  18. package/dist/config/ranking-weights-loader.d.ts.map +1 -0
  19. package/dist/config/ranking-weights-loader.js +109 -0
  20. package/dist/config/ranking-weights-loader.js.map +1 -0
  21. package/dist/constants/relation-constants.d.ts +95 -0
  22. package/dist/constants/relation-constants.d.ts.map +1 -0
  23. package/dist/constants/relation-constants.js +95 -0
  24. package/dist/constants/relation-constants.js.map +1 -0
  25. package/dist/database/migration/migrations/005-relation-engine-schema.d.ts +65 -0
  26. package/dist/database/migration/migrations/005-relation-engine-schema.d.ts.map +1 -0
  27. package/dist/database/migration/migrations/005-relation-engine-schema.js +295 -0
  28. package/dist/database/migration/migrations/005-relation-engine-schema.js.map +1 -0
  29. package/dist/database/migration/migrations/005-relation-engine-schema.sql +64 -0
  30. package/dist/server/routes/admin.routes.d.ts.map +1 -1
  31. package/dist/server/routes/admin.routes.js +228 -0
  32. package/dist/server/routes/admin.routes.js.map +1 -1
  33. package/dist/services/anchor/anchor-interfaces.d.ts +1 -0
  34. package/dist/services/anchor/anchor-interfaces.d.ts.map +1 -1
  35. package/dist/services/anchor/anchor-interfaces.js.map +1 -1
  36. package/dist/services/anchor/anchor-search-service.d.ts +16 -0
  37. package/dist/services/anchor/anchor-search-service.d.ts.map +1 -1
  38. package/dist/services/anchor/anchor-search-service.js +136 -17
  39. package/dist/services/anchor/anchor-search-service.js.map +1 -1
  40. package/dist/services/batch-scheduler.d.ts +11 -0
  41. package/dist/services/batch-scheduler.d.ts.map +1 -1
  42. package/dist/services/batch-scheduler.js +99 -0
  43. package/dist/services/batch-scheduler.js.map +1 -1
  44. package/dist/services/llm-based-relation-extractor.d.ts +156 -0
  45. package/dist/services/llm-based-relation-extractor.d.ts.map +1 -0
  46. package/dist/services/llm-based-relation-extractor.js +1350 -0
  47. package/dist/services/llm-based-relation-extractor.js.map +1 -0
  48. package/dist/services/relation-extractor.d.ts +73 -0
  49. package/dist/services/relation-extractor.d.ts.map +1 -0
  50. package/dist/services/relation-extractor.js +231 -0
  51. package/dist/services/relation-extractor.js.map +1 -0
  52. package/dist/services/relation-graph.d.ts +275 -0
  53. package/dist/services/relation-graph.d.ts.map +1 -0
  54. package/dist/services/relation-graph.js +869 -0
  55. package/dist/services/relation-graph.js.map +1 -0
  56. package/dist/services/relation-quality-validator.d.ts +211 -0
  57. package/dist/services/relation-quality-validator.d.ts.map +1 -0
  58. package/dist/services/relation-quality-validator.js +415 -0
  59. package/dist/services/relation-quality-validator.js.map +1 -0
  60. package/dist/services/rule-based-relation-extractor.d.ts +66 -0
  61. package/dist/services/rule-based-relation-extractor.d.ts.map +1 -0
  62. package/dist/services/rule-based-relation-extractor.js +258 -0
  63. package/dist/services/rule-based-relation-extractor.js.map +1 -0
  64. package/dist/tools/add-relation-tool.d.ts +34 -0
  65. package/dist/tools/add-relation-tool.d.ts.map +1 -0
  66. package/dist/tools/add-relation-tool.js +163 -0
  67. package/dist/tools/add-relation-tool.js.map +1 -0
  68. package/dist/tools/extract-relations-tool.d.ts +28 -0
  69. package/dist/tools/extract-relations-tool.d.ts.map +1 -0
  70. package/dist/tools/extract-relations-tool.js +159 -0
  71. package/dist/tools/extract-relations-tool.js.map +1 -0
  72. package/dist/tools/get-relations-tool.d.ts +34 -0
  73. package/dist/tools/get-relations-tool.d.ts.map +1 -0
  74. package/dist/tools/get-relations-tool.js +155 -0
  75. package/dist/tools/get-relations-tool.js.map +1 -0
  76. package/dist/tools/index.d.ts.map +1 -1
  77. package/dist/tools/index.js +11 -3
  78. package/dist/tools/index.js.map +1 -1
  79. package/dist/tools/remember-tool.d.ts +17 -0
  80. package/dist/tools/remember-tool.d.ts.map +1 -1
  81. package/dist/tools/remember-tool.js +195 -26
  82. package/dist/tools/remember-tool.js.map +1 -1
  83. package/dist/tools/remove-relation-tool.d.ts +45 -0
  84. package/dist/tools/remove-relation-tool.d.ts.map +1 -0
  85. package/dist/tools/remove-relation-tool.js +142 -0
  86. package/dist/tools/remove-relation-tool.js.map +1 -0
  87. package/dist/tools/search-local-tool.d.ts.map +1 -1
  88. package/dist/tools/search-local-tool.js +10 -3
  89. package/dist/tools/search-local-tool.js.map +1 -1
  90. package/dist/tools/types.d.ts +2 -0
  91. package/dist/tools/types.d.ts.map +1 -1
  92. package/dist/tools/types.js.map +1 -1
  93. package/dist/tools/visualize-relations-tool.d.ts +46 -0
  94. package/dist/tools/visualize-relations-tool.d.ts.map +1 -0
  95. package/dist/tools/visualize-relations-tool.js +157 -0
  96. package/dist/tools/visualize-relations-tool.js.map +1 -0
  97. package/dist/types/index.d.ts +8 -0
  98. package/dist/types/index.d.ts.map +1 -1
  99. package/dist/types/index.js +1 -0
  100. package/dist/types/index.js.map +1 -1
  101. package/dist/types/relation-graph.d.ts +215 -0
  102. package/dist/types/relation-graph.d.ts.map +1 -0
  103. package/dist/types/relation-graph.js +6 -0
  104. package/dist/types/relation-graph.js.map +1 -0
  105. package/dist/types/relation.d.ts +112 -0
  106. package/dist/types/relation.d.ts.map +1 -0
  107. package/dist/types/relation.js +67 -0
  108. package/dist/types/relation.js.map +1 -0
  109. package/dist/utils/cache-key-generator.d.ts +63 -0
  110. package/dist/utils/cache-key-generator.d.ts.map +1 -0
  111. package/dist/utils/cache-key-generator.js +76 -0
  112. package/dist/utils/cache-key-generator.js.map +1 -0
  113. package/dist/utils/database.d.ts.map +1 -1
  114. package/dist/utils/database.js +37 -17
  115. package/dist/utils/database.js.map +1 -1
  116. package/dist/utils/relation-visualizer.d.ts +81 -0
  117. package/dist/utils/relation-visualizer.d.ts.map +1 -0
  118. package/dist/utils/relation-visualizer.js +239 -0
  119. package/dist/utils/relation-visualizer.js.map +1 -0
  120. package/dist/utils/type-guards.d.ts +100 -0
  121. package/dist/utils/type-guards.d.ts.map +1 -0
  122. package/dist/utils/type-guards.js +144 -0
  123. package/dist/utils/type-guards.js.map +1 -0
  124. package/package.json +7 -2
  125. package/scripts/generate-relation-report.ts +481 -0
  126. package/scripts/weekly-relation-validation.ts +423 -0
@@ -0,0 +1,1350 @@
1
+ /**
2
+ * LLM 기반 관계 추출기
3
+ * LLM을 사용하여 기억 간의 관계를 추출합니다.
4
+ * 규칙 기반이 실패하거나 신뢰도가 낮은 경우 사용됩니다.
5
+ *
6
+ * 비용 최적화 전략:
7
+ * - Embedding 기반 후보 제한 (cosine similarity 상위 N개)
8
+ * - Rate limit (토큰 버킷 알고리즘, 초당 1회)
9
+ * - 프롬프트 압축 (최대 500 토큰)
10
+ * - 캐싱 (7일 TTL)
11
+ * - 배치 처리 (최대 10개)
12
+ * - 비용 모니터링
13
+ */
14
+ import OpenAI from 'openai';
15
+ import { GoogleGenerativeAI } from '@google/generative-ai';
16
+ import { mementoConfig } from '../config/index.js';
17
+ import { UnifiedEmbeddingService } from './unified-embedding-service.js';
18
+ import { CacheService } from './cache-service.js';
19
+ import { ALL_RELATION_TYPES } from '../types/relation.js';
20
+ import { isApplicableRelationType, MEMORY_TYPE_RELATION_MAP } from '../types/relation.js';
21
+ import { logger } from '../utils/logger.js';
22
+ import { CacheKeyGenerator } from '../utils/cache-key-generator.js';
23
+ import { CONFIDENCE, LIMITS, CACHE, LLM_COST, RATE_LIMITER, TIME } from '../constants/relation-constants.js';
24
+ /**
25
+ * 토큰 버킷 Rate Limiter
26
+ *
27
+ * 경쟁 조건을 방지하기 위해 락 메커니즘을 사용합니다.
28
+ * 동시에 여러 요청이 들어와도 토큰 계산이 정확하게 이루어집니다.
29
+ */
30
+ class TokenBucketRateLimiter {
31
+ tokens;
32
+ capacity;
33
+ refillRate; // tokens per second
34
+ lastRefill;
35
+ lock = Promise.resolve(); // 락을 위한 Promise 체인
36
+ constructor(capacity = 1, refillRate = 1) {
37
+ this.capacity = capacity;
38
+ this.refillRate = refillRate;
39
+ this.tokens = capacity;
40
+ this.lastRefill = Date.now();
41
+ }
42
+ /**
43
+ * 토큰을 소비하고 사용 가능 여부를 반환
44
+ *
45
+ * 락 메커니즘을 사용하여 동시 요청 시 경쟁 조건을 방지합니다.
46
+ * refill()과 토큰 소비를 원자적으로 처리합니다.
47
+ */
48
+ async consume() {
49
+ // 락을 획득하여 순차적으로 처리
50
+ return await new Promise((resolve) => {
51
+ this.lock = this.lock.then(async () => {
52
+ // 토큰 리필 (락 내에서 실행)
53
+ this.refill();
54
+ if (this.tokens >= 1) {
55
+ this.tokens -= 1;
56
+ resolve(true);
57
+ return;
58
+ }
59
+ // 토큰이 부족한 경우, 다음 토큰이 채워질 때까지 대기
60
+ const waitTime = (1 - this.tokens) / this.refillRate * TIME.SECOND_MS;
61
+ await new Promise(resolve => setTimeout(resolve, waitTime));
62
+ // 대기 후 다시 리필 및 확인 (락 내에서 실행)
63
+ this.refill();
64
+ if (this.tokens >= 1) {
65
+ this.tokens -= 1;
66
+ resolve(true);
67
+ }
68
+ else {
69
+ resolve(false);
70
+ }
71
+ });
72
+ });
73
+ }
74
+ /**
75
+ * 토큰 버킷 리필
76
+ *
77
+ * 락 내에서만 호출되어야 하므로 private으로 유지합니다.
78
+ */
79
+ refill() {
80
+ const now = Date.now();
81
+ const elapsed = (now - this.lastRefill) / TIME.SECOND_MS;
82
+ const tokensToAdd = elapsed * this.refillRate;
83
+ this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
84
+ this.lastRefill = now;
85
+ }
86
+ }
87
+ /**
88
+ * LLM 기반 관계 추출기
89
+ */
90
+ export class LLMBasedRelationExtractor {
91
+ openaiClient = null;
92
+ geminiClient = null;
93
+ preferredProvider;
94
+ embeddingService;
95
+ cache; // 7일 TTL
96
+ rateLimiter;
97
+ costMetrics;
98
+ constructor() {
99
+ this.preferredProvider = this.initializeClients();
100
+ this.embeddingService = new UnifiedEmbeddingService();
101
+ this.cache = new CacheService(CACHE.EXTRACTION_SIZE, CACHE.EXTRACTION_TTL_MS);
102
+ this.rateLimiter = new TokenBucketRateLimiter(RATE_LIMITER.CAPACITY, RATE_LIMITER.REFILL_RATE);
103
+ this.costMetrics = {
104
+ totalCalls: 0,
105
+ totalTokens: 0,
106
+ totalCost: 0,
107
+ lastReset: Date.now()
108
+ };
109
+ }
110
+ /**
111
+ * LLM 클라이언트 초기화
112
+ * 환경 변수 LLM_PROVIDER에 따라 프로바이더 선택
113
+ * - 'openai': OpenAI 우선 시도, 실패 시 Gemini/Ollama fallback
114
+ * - 'gemini': Gemini 우선 시도, 실패 시 OpenAI/Ollama fallback
115
+ * - 'ollama': Ollama 우선 시도, 실패 시 OpenAI/Gemini fallback
116
+ * - 'auto': 사용 가능한 것 자동 선택 (OpenAI -> Gemini -> Ollama 순서)
117
+ */
118
+ initializeClients() {
119
+ const preferredProvider = mementoConfig.llmProvider || 'auto';
120
+ // OpenAI 클라이언트 초기화 함수
121
+ const initOpenAI = () => {
122
+ if (!mementoConfig.openaiApiKey) {
123
+ return null;
124
+ }
125
+ try {
126
+ this.openaiClient = new OpenAI({ apiKey: mementoConfig.openaiApiKey });
127
+ logger.info('OpenAI 클라이언트 초기화 완료');
128
+ return 'openai';
129
+ }
130
+ catch (error) {
131
+ logger.warn('OpenAI 초기화 실패', {
132
+ error: error instanceof Error ? error.message : String(error)
133
+ });
134
+ return null;
135
+ }
136
+ };
137
+ // Gemini 클라이언트 초기화 함수
138
+ const initGemini = () => {
139
+ if (!mementoConfig.geminiApiKey) {
140
+ return null;
141
+ }
142
+ try {
143
+ this.geminiClient = new GoogleGenerativeAI(mementoConfig.geminiApiKey);
144
+ logger.info('Gemini 클라이언트 초기화 완료');
145
+ return 'gemini';
146
+ }
147
+ catch (error) {
148
+ logger.warn('Gemini 초기화 실패', {
149
+ error: error instanceof Error ? error.message : String(error)
150
+ });
151
+ return null;
152
+ }
153
+ };
154
+ // Ollama 클라이언트 초기화 함수 (연결 테스트)
155
+ const initOllama = async () => {
156
+ try {
157
+ const baseUrl = mementoConfig.ollamaBaseUrl || 'http://localhost:11434';
158
+ const model = mementoConfig.ollamaModel || 'llama3';
159
+ // Ollama 서버 연결 테스트
160
+ const response = await fetch(`${baseUrl}/api/tags`, {
161
+ method: 'GET',
162
+ signal: AbortSignal.timeout(3000) // 3초 타임아웃
163
+ });
164
+ if (!response.ok) {
165
+ logger.warn('Ollama 서버 연결 실패', {
166
+ status: response.status,
167
+ baseUrl
168
+ });
169
+ return null;
170
+ }
171
+ logger.info('Ollama 클라이언트 초기화 완료', { baseUrl, model });
172
+ return 'ollama';
173
+ }
174
+ catch (error) {
175
+ logger.warn('Ollama 초기화 실패', {
176
+ error: error instanceof Error ? error.message : String(error),
177
+ baseUrl: mementoConfig.ollamaBaseUrl
178
+ });
179
+ return null;
180
+ }
181
+ };
182
+ // 프로바이더 선택 로직
183
+ if (preferredProvider === 'openai') {
184
+ // OpenAI 우선 시도
185
+ const result = initOpenAI();
186
+ if (result)
187
+ return result;
188
+ // 실패 시 Gemini fallback
189
+ const geminiResult = initGemini();
190
+ if (geminiResult)
191
+ return geminiResult;
192
+ // 실패 시 Ollama fallback (비동기이므로 null 반환)
193
+ return null;
194
+ }
195
+ else if (preferredProvider === 'gemini') {
196
+ // Gemini 우선 시도
197
+ const result = initGemini();
198
+ if (result)
199
+ return result;
200
+ // 실패 시 OpenAI fallback
201
+ const openaiResult = initOpenAI();
202
+ if (openaiResult)
203
+ return openaiResult;
204
+ // 실패 시 Ollama fallback (비동기이므로 null 반환)
205
+ return null;
206
+ }
207
+ else if (preferredProvider === 'ollama') {
208
+ // Ollama는 비동기 초기화이므로 나중에 확인
209
+ // 여기서는 null을 반환하고, extractRelations에서 확인
210
+ return null;
211
+ }
212
+ else {
213
+ // 'auto': 기존 로직 (OpenAI -> Gemini -> Ollama 순서)
214
+ const result = initOpenAI();
215
+ if (result)
216
+ return result;
217
+ const geminiResult = initGemini();
218
+ if (geminiResult)
219
+ return geminiResult;
220
+ // Ollama는 비동기이므로 null 반환
221
+ return null;
222
+ }
223
+ }
224
+ /**
225
+ * LLM 서비스 사용 가능 여부 확인
226
+ */
227
+ isAvailable() {
228
+ // Ollama는 비동기 초기화이므로 설정만 확인
229
+ if (mementoConfig.llmProvider === 'ollama') {
230
+ return true; // Ollama는 extractRelations에서 실제 연결 확인
231
+ }
232
+ return this.preferredProvider !== null;
233
+ }
234
+ /**
235
+ * 관계 추출 프롬프트 템플릿 생성
236
+ */
237
+ buildPrompt(newMemory, existingMemories, applicableTypes) {
238
+ const relationTypesList = applicableTypes.join(', ');
239
+ const memoryList = existingMemories
240
+ .map((mem, idx) => `[${idx + 1}] ID: ${mem.id}\n 내용: ${mem.content}`)
241
+ .join('\n\n');
242
+ return `다음은 새로운 기억과 기존 기억 목록입니다. 새로운 기억과 기존 기억들 간의 의미적 관계를 분석해주세요.
243
+
244
+ 새로운 기억:
245
+ ID: ${newMemory.id}
246
+ 타입: ${newMemory.type}
247
+ 내용: ${newMemory.content}
248
+
249
+ 기존 기억 목록:
250
+ ${memoryList}
251
+
252
+ 관계 유형: ${relationTypesList}
253
+
254
+ 각 기존 기억에 대해 새로운 기억과의 관계가 있다면, 다음 JSON 형식으로 반환해주세요:
255
+ {
256
+ "relations": [
257
+ {
258
+ "target_id": "기억_ID",
259
+ "relation_type": "CAUSES | DEPENDS_ON | FOLLOWS | CONTRASTS_WITH | REFERENCES | BELONGS_TO",
260
+ "confidence": 0.0~1.0,
261
+ "reasoning": "관계 추론 근거 (선택적)"
262
+ }
263
+ ]
264
+ }
265
+
266
+ 관계가 없는 경우 빈 배열을 반환하세요. JSON 형식만 반환하고 다른 설명은 포함하지 마세요.`;
267
+ }
268
+ /**
269
+ * Embedding 기반 후보 필터링
270
+ * cosine similarity 상위 N개만 LLM 비교 대상으로 선정
271
+ */
272
+ async filterCandidatesByEmbedding(newMemory, existingMemories, limit = LIMITS.LLM_CANDIDATE_DEFAULT) {
273
+ if (existingMemories.length <= limit) {
274
+ return existingMemories;
275
+ }
276
+ try {
277
+ // 새로운 기억의 임베딩 생성
278
+ const newEmbedding = await this.embeddingService.generateEmbedding(newMemory.content);
279
+ if (!newEmbedding || !newEmbedding.embedding) {
280
+ // 임베딩 생성 실패 시 단순 제한
281
+ return existingMemories.slice(0, limit);
282
+ }
283
+ // 기존 기억들의 임베딩 데이터 준비
284
+ const embeddingData = [];
285
+ for (const memory of existingMemories) {
286
+ if (memory.embedding && memory.embedding.length > 0) {
287
+ embeddingData.push({
288
+ id: memory.id,
289
+ content: memory.content,
290
+ embedding: memory.embedding
291
+ });
292
+ }
293
+ }
294
+ // 임베딩이 없는 기억은 제외하고 유사도 검색
295
+ if (embeddingData.length === 0) {
296
+ return existingMemories.slice(0, limit);
297
+ }
298
+ // 유사도 검색
299
+ const similarMemories = await this.embeddingService.searchSimilar(newMemory.content, embeddingData, limit, 0.0 // threshold 없이 상위 N개만
300
+ );
301
+ // 유사도 순으로 정렬된 기억 ID 목록
302
+ const similarIds = new Set(similarMemories.map(r => r.id));
303
+ // 원본 순서를 유지하면서 유사한 기억을 우선 배치
304
+ const result = [];
305
+ const added = new Set();
306
+ // 유사한 기억 먼저 추가
307
+ for (const memory of existingMemories) {
308
+ if (similarIds.has(memory.id) && result.length < limit) {
309
+ result.push(memory);
310
+ added.add(memory.id);
311
+ }
312
+ }
313
+ // 나머지 기억 추가 (임베딩이 없는 경우 포함)
314
+ for (const memory of existingMemories) {
315
+ if (!added.has(memory.id) && result.length < limit) {
316
+ result.push(memory);
317
+ }
318
+ }
319
+ return result;
320
+ }
321
+ catch (error) {
322
+ logger.warn('Embedding 기반 필터링 실패, 기본 제한 사용', {
323
+ error: error instanceof Error ? error.message : String(error)
324
+ });
325
+ return existingMemories.slice(0, limit);
326
+ }
327
+ }
328
+ /**
329
+ * 기존 기억 목록을 요약하여 프롬프트 크기 축소
330
+ * 간단한 요약: 내용을 최대 길이로 제한
331
+ */
332
+ compressMemories(memories, maxTokens = LIMITS.MAX_PROMPT_TOKENS) {
333
+ const avgTokensPerMemory = 50; // 대략적인 토큰 수
334
+ const maxMemories = Math.floor(maxTokens / avgTokensPerMemory);
335
+ const limited = memories.slice(0, maxMemories);
336
+ // 각 기억의 내용을 요약 (간단한 버전: 최대 200자로 제한)
337
+ return limited.map(memory => ({
338
+ ...memory,
339
+ content: memory.content.length > 200
340
+ ? memory.content.substring(0, 200) + '...'
341
+ : memory.content
342
+ }));
343
+ }
344
+ /**
345
+ * 캐시 키 생성
346
+ * 공통 유틸리티를 사용하여 일관된 캐시 키를 생성합니다.
347
+ */
348
+ generateCacheKey(newMemoryId, existingMemoryIds) {
349
+ return CacheKeyGenerator.generateLLMRelationExtractionKey(newMemoryId, existingMemoryIds);
350
+ }
351
+ /**
352
+ * 비용 계산 및 모니터링
353
+ * 비용 계산과 모니터링 로그를 통합하여 중복을 제거합니다.
354
+ *
355
+ * @param provider LLM 제공자
356
+ * @param promptTokens 프롬프트 토큰 수
357
+ * @param completionTokens 완료 토큰 수
358
+ * @returns 계산된 비용 (USD)
359
+ */
360
+ calculateAndLogCost(provider, promptTokens, completionTokens) {
361
+ // Given: 토큰 수와 제공자 정보
362
+ // When: 비용 계산
363
+ let cost = 0;
364
+ if (provider === 'openai') {
365
+ // OpenAI 가격 (gpt-4o-mini 기준, 2025년 1월)
366
+ const inputCost = (promptTokens / 1000) * LLM_COST.OPENAI_INPUT;
367
+ const outputCost = (completionTokens / 1000) * LLM_COST.OPENAI_OUTPUT;
368
+ cost = inputCost + outputCost;
369
+ }
370
+ else if (provider === 'gemini') {
371
+ // Gemini 가격 (gemini-1.5-flash 기준)
372
+ const inputCost = (promptTokens / 1000) * LLM_COST.GEMINI_INPUT;
373
+ const outputCost = (completionTokens / 1000) * LLM_COST.GEMINI_OUTPUT;
374
+ cost = inputCost + outputCost;
375
+ }
376
+ else if (provider === 'ollama') {
377
+ // Ollama는 로컬 실행이므로 비용 0
378
+ cost = 0;
379
+ }
380
+ // Then: 비용 메트릭 업데이트 및 로깅
381
+ this.costMetrics.totalCalls++;
382
+ this.costMetrics.totalTokens += promptTokens + completionTokens;
383
+ this.costMetrics.totalCost += cost;
384
+ // 주기적으로 로그 출력
385
+ if (this.costMetrics.totalCalls % LIMITS.COST_LOG_INTERVAL === 0) {
386
+ logger.info('LLM 비용 통계', {
387
+ totalCalls: this.costMetrics.totalCalls,
388
+ totalTokens: this.costMetrics.totalTokens,
389
+ totalCost: this.costMetrics.totalCost.toFixed(4),
390
+ provider
391
+ });
392
+ }
393
+ return cost;
394
+ }
395
+ /**
396
+ * OpenAI를 사용하여 관계 추출
397
+ */
398
+ async extractWithOpenAI(prompt) {
399
+ if (!this.openaiClient) {
400
+ throw new Error('OpenAI 클라이언트가 초기화되지 않았습니다.');
401
+ }
402
+ // Rate limit 확인
403
+ await this.rateLimiter.consume();
404
+ try {
405
+ const model = mementoConfig.openaiLlmModel || 'gpt-4o-mini';
406
+ const response = await this.openaiClient.chat.completions.create({
407
+ model,
408
+ messages: [
409
+ {
410
+ role: 'system',
411
+ content: 'You are a semantic relation analyzer. Analyze relationships between memories and return JSON format only.'
412
+ },
413
+ {
414
+ role: 'user',
415
+ content: prompt
416
+ }
417
+ ],
418
+ temperature: 0.3, // 일관성을 위해 낮은 temperature
419
+ max_tokens: LIMITS.MAX_RESPONSE_TOKENS,
420
+ response_format: { type: 'json_object' } // JSON 모드 강제
421
+ });
422
+ const content = response.choices[0]?.message?.content;
423
+ if (!content) {
424
+ throw new Error('OpenAI 응답이 비어있습니다.');
425
+ }
426
+ // 비용 모니터링
427
+ const promptTokens = response.usage?.prompt_tokens || 0;
428
+ const completionTokens = response.usage?.completion_tokens || 0;
429
+ this.calculateAndLogCost('openai', promptTokens, completionTokens);
430
+ const parseResult = this.parseLLMResponse(content);
431
+ if (!parseResult.success) {
432
+ // 파싱 실패 시 예외를 던져 호출자가 실패를 인지할 수 있도록 함
433
+ throw new Error(`LLM 응답 파싱 실패: ${parseResult.error}`);
434
+ }
435
+ return parseResult;
436
+ }
437
+ catch (error) {
438
+ logger.error('OpenAI 호출 실패', {
439
+ error: error instanceof Error ? error.message : String(error)
440
+ });
441
+ throw error;
442
+ }
443
+ }
444
+ /**
445
+ * Ollama 모델 존재 여부 확인
446
+ */
447
+ async checkOllamaModel(baseUrl, model) {
448
+ try {
449
+ const response = await fetch(`${baseUrl}/api/tags`, {
450
+ method: 'GET',
451
+ signal: AbortSignal.timeout(3000)
452
+ });
453
+ if (!response.ok) {
454
+ return false;
455
+ }
456
+ const data = await response.json();
457
+ const models = data.models || [];
458
+ return models.some((m) => m.name === model || m.name.startsWith(`${model}:`));
459
+ }
460
+ catch (error) {
461
+ logger.warn('Ollama 모델 확인 실패', {
462
+ error: error instanceof Error ? error.message : String(error),
463
+ baseUrl,
464
+ model
465
+ });
466
+ return false;
467
+ }
468
+ }
469
+ /**
470
+ * Ollama를 사용하여 관계 추출
471
+ */
472
+ async extractWithOllama(prompt) {
473
+ // Rate limit 확인
474
+ await this.rateLimiter.consume();
475
+ const baseUrl = mementoConfig.ollamaBaseUrl || 'http://localhost:11434';
476
+ const model = mementoConfig.ollamaModel || 'llama3';
477
+ // Ollama API 요청 준비 (에러 로깅을 위해 함수 스코프 밖에 선언)
478
+ const requestBody = {
479
+ model,
480
+ messages: [
481
+ {
482
+ role: 'system',
483
+ content: 'You are a semantic relation analyzer. Analyze relationships between memories and return JSON format only.'
484
+ },
485
+ {
486
+ role: 'user',
487
+ content: prompt
488
+ }
489
+ ],
490
+ options: {
491
+ temperature: 0.3,
492
+ num_predict: LIMITS.MAX_RESPONSE_TOKENS
493
+ },
494
+ format: 'json' // JSON 형식 강제
495
+ };
496
+ try {
497
+ // 모델 존재 여부 확인
498
+ const modelExists = await this.checkOllamaModel(baseUrl, model);
499
+ if (!modelExists) {
500
+ throw new Error(`Ollama 모델 '${model}'이 설치되지 않았습니다. ` +
501
+ `다음 명령어로 모델을 설치하세요: ollama pull ${model}`);
502
+ }
503
+ // Ollama API 호출
504
+ const apiUrl = `${baseUrl}/api/chat`;
505
+ let response;
506
+ try {
507
+ response = await fetch(apiUrl, {
508
+ method: 'POST',
509
+ headers: {
510
+ 'Content-Type': 'application/json'
511
+ },
512
+ body: JSON.stringify(requestBody),
513
+ signal: AbortSignal.timeout(60000) // 60초 타임아웃
514
+ });
515
+ }
516
+ catch (fetchError) {
517
+ // 에러 발생 시에만 요청 정보 로깅
518
+ logger.error('Ollama API fetch 실패', {
519
+ error: fetchError instanceof Error ? fetchError.message : String(fetchError),
520
+ url: apiUrl,
521
+ baseUrl,
522
+ model,
523
+ requestBody: {
524
+ ...requestBody,
525
+ messages: requestBody.messages.map((msg) => ({
526
+ role: msg.role,
527
+ contentLength: msg.content.length,
528
+ contentPreview: msg.content.substring(0, 500),
529
+ contentFull: msg.content.length < 2000 ? msg.content : msg.content.substring(0, 1000) + '...' + msg.content.substring(msg.content.length - 1000)
530
+ }))
531
+ },
532
+ promptLength: prompt.length,
533
+ promptPreview: prompt.substring(0, 500),
534
+ promptFull: prompt.length < 2000 ? prompt : prompt.substring(0, 1000) + '...' + prompt.substring(prompt.length - 1000)
535
+ });
536
+ throw fetchError;
537
+ }
538
+ if (!response.ok) {
539
+ let errorText = '';
540
+ try {
541
+ errorText = await response.text();
542
+ logger.error('Ollama API 에러 응답', {
543
+ status: response.status,
544
+ statusText: response.statusText,
545
+ errorText,
546
+ url: apiUrl
547
+ });
548
+ }
549
+ catch (textError) {
550
+ logger.error('Ollama API 에러 응답 텍스트 읽기 실패', {
551
+ status: response.status,
552
+ statusText: response.statusText,
553
+ textError: textError instanceof Error ? textError.message : String(textError)
554
+ });
555
+ }
556
+ let errorMessage = `Ollama API 호출 실패: ${response.status} ${response.statusText}`;
557
+ if (response.status === 404) {
558
+ errorMessage = `Ollama 모델 '${model}'을 찾을 수 없습니다. 모델이 설치되어 있는지 확인하세요: ollama pull ${model}`;
559
+ }
560
+ else if (errorText) {
561
+ try {
562
+ const errorData = JSON.parse(errorText);
563
+ errorMessage = errorData.error || errorMessage;
564
+ }
565
+ catch {
566
+ // JSON 파싱 실패 시 원본 에러 메시지 사용
567
+ errorMessage = errorText || errorMessage;
568
+ }
569
+ }
570
+ throw new Error(errorMessage);
571
+ }
572
+ // 응답 본문 파싱
573
+ // Ollama는 NDJSON (Newline Delimited JSON) 형식으로 응답할 수 있습니다
574
+ const contentType = response.headers.get('content-type') || '';
575
+ const isNDJSON = contentType.includes('application/x-ndjson') || contentType.includes('ndjson');
576
+ let data;
577
+ let content = '';
578
+ let responseText = '';
579
+ try {
580
+ responseText = await response.text();
581
+ if (isNDJSON) {
582
+ // NDJSON 형식 처리: 각 줄을 개별 JSON 객체로 파싱
583
+ const lines = responseText.trim().split('\n').filter((line) => line.trim().length > 0);
584
+ let lastData = null;
585
+ const contentParts = [];
586
+ for (let i = 0; i < lines.length; i++) {
587
+ const line = lines[i];
588
+ if (!line)
589
+ continue;
590
+ try {
591
+ const lineData = JSON.parse(line);
592
+ lastData = lineData; // 마지막 줄의 메타데이터 사용
593
+ // message.content가 있으면 합치기
594
+ if (lineData.message?.content) {
595
+ contentParts.push(lineData.message.content);
596
+ }
597
+ // done이 true이면 완료
598
+ if (lineData.done === true) {
599
+ break;
600
+ }
601
+ }
602
+ catch (lineParseError) {
603
+ // 에러 발생 시에만 로깅
604
+ logger.warn('Ollama NDJSON 라인 파싱 실패', {
605
+ lineIndex: i,
606
+ linePreview: line.substring(0, 200),
607
+ error: lineParseError instanceof Error ? lineParseError.message : String(lineParseError),
608
+ responseTextLength: responseText.length,
609
+ responseTextPreview: responseText.substring(0, 500),
610
+ responseTextFull: responseText
611
+ });
612
+ // 계속 진행
613
+ }
614
+ }
615
+ // content 합치기
616
+ content = contentParts.join('');
617
+ // 마지막 데이터를 메인 데이터로 사용 (메타데이터 포함)
618
+ data = lastData || {};
619
+ // content를 message에 설정
620
+ if (data.message) {
621
+ data.message.content = content;
622
+ }
623
+ else {
624
+ data.message = { role: 'assistant', content };
625
+ }
626
+ }
627
+ else {
628
+ // 일반 JSON 형식 처리
629
+ try {
630
+ data = JSON.parse(responseText);
631
+ content = data.message?.content || '';
632
+ }
633
+ catch (parseError) {
634
+ // 에러 발생 시에만 상세 로깅
635
+ logger.error('Ollama API 응답 JSON 파싱 실패', {
636
+ parseError: parseError instanceof Error ? parseError.message : String(parseError),
637
+ contentType,
638
+ isNDJSON,
639
+ responseTextLength: responseText.length,
640
+ responseTextPreview: responseText.substring(0, 500),
641
+ responseTextFull: responseText,
642
+ status: response.status,
643
+ statusText: response.statusText,
644
+ headers: Object.fromEntries(response.headers.entries())
645
+ });
646
+ throw new Error(`Ollama 응답 JSON 파싱 실패: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
647
+ }
648
+ }
649
+ }
650
+ catch (textError) {
651
+ // 에러 발생 시에만 상세 로깅
652
+ logger.error('Ollama API 응답 본문 읽기 실패', {
653
+ textError: textError instanceof Error ? textError.message : String(textError),
654
+ status: response.status,
655
+ statusText: response.statusText,
656
+ headers: Object.fromEntries(response.headers.entries()),
657
+ contentType,
658
+ isNDJSON,
659
+ responseTextLength: responseText.length,
660
+ responseTextPreview: responseText.substring(0, 500),
661
+ responseTextFull: responseText
662
+ });
663
+ throw textError;
664
+ }
665
+ // content가 없으면 data.message?.content에서 가져오기
666
+ if (!content && data.message?.content) {
667
+ content = data.message.content;
668
+ }
669
+ if (!content) {
670
+ // 에러 발생 시에만 상세 로깅
671
+ logger.error('Ollama 응답이 비어있습니다', {
672
+ status: response.status,
673
+ statusText: response.statusText,
674
+ headers: Object.fromEntries(response.headers.entries()),
675
+ contentType,
676
+ isNDJSON,
677
+ responseTextLength: responseText.length,
678
+ responseTextPreview: responseText.substring(0, 500),
679
+ responseTextFull: responseText,
680
+ fullResponse: data
681
+ });
682
+ throw new Error('Ollama 응답이 비어있습니다.');
683
+ }
684
+ // 비용 모니터링 (Ollama는 로컬이므로 비용 0)
685
+ const promptTokens = data.prompt_eval_count || 0;
686
+ const completionTokens = data.eval_count || 0;
687
+ this.calculateAndLogCost('ollama', promptTokens, completionTokens);
688
+ // Given: Ollama 응답 내용 (JSON 형식이어야 하지만 추가 텍스트가 포함될 수 있음)
689
+ // When: JSON 파싱 시도
690
+ // Ollama는 format: 'json' 옵션을 사용하더라도 일부 모델은 JSON 뒤에 추가 텍스트를 포함할 수 있습니다
691
+ // 따라서 먼저 extractJSON으로 정리한 후 파싱을 시도합니다
692
+ let cleanedContent = content;
693
+ const extractedJson = this.extractJSON(content);
694
+ if (extractedJson) {
695
+ cleanedContent = extractedJson;
696
+ }
697
+ else {
698
+ // extractJSON이 실패한 경우, 수동으로 첫 번째 '{'부터 마지막 '}'까지 추출
699
+ const firstBrace = content.indexOf('{');
700
+ const lastBrace = content.lastIndexOf('}');
701
+ if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
702
+ cleanedContent = content.substring(firstBrace, lastBrace + 1).trim();
703
+ }
704
+ }
705
+ // JSON 파싱을 시도하기 전에 한 번 더 정리
706
+ // "Unexpected non-whitespace character after JSON" 에러를 방지하기 위해
707
+ // 첫 번째 '{'부터 마지막 '}'까지만 추출하고, 그 사이의 모든 텍스트를 제거
708
+ let finalJson = cleanedContent;
709
+ const firstBraceFinal = finalJson.indexOf('{');
710
+ const lastBraceFinal = finalJson.lastIndexOf('}');
711
+ if (firstBraceFinal !== -1 && lastBraceFinal !== -1 && lastBraceFinal > firstBraceFinal) {
712
+ finalJson = finalJson.substring(firstBraceFinal, lastBraceFinal + 1).trim();
713
+ // JSON.parse()가 성공할 때까지 끝 부분을 점진적으로 제거
714
+ let validJson = null;
715
+ for (let i = finalJson.length; i > 0; i--) {
716
+ const testJson = finalJson.substring(0, i).trim();
717
+ if (testJson.endsWith('}')) {
718
+ try {
719
+ JSON.parse(testJson);
720
+ validJson = testJson;
721
+ break;
722
+ }
723
+ catch {
724
+ // 계속 시도
725
+ }
726
+ }
727
+ }
728
+ if (validJson) {
729
+ finalJson = validJson;
730
+ }
731
+ }
732
+ const parseResult = this.parseLLMResponse(finalJson);
733
+ if (!parseResult.success) {
734
+ // Then: 파싱 실패 시 상세한 에러 정보와 함께 예외 발생
735
+ logger.error('Ollama 응답 파싱 실패', {
736
+ error: parseResult.error,
737
+ contentLength: content.length,
738
+ cleanedLength: cleanedContent.length,
739
+ finalLength: finalJson.length,
740
+ contentPreview: content.substring(0, 500),
741
+ cleanedPreview: cleanedContent.substring(0, 500),
742
+ finalPreview: finalJson.substring(0, 500),
743
+ contentFull: content.length < 2000 ? content : content.substring(0, 1000) + '...' + content.substring(content.length - 1000),
744
+ model: mementoConfig.ollamaModel,
745
+ baseUrl: mementoConfig.ollamaBaseUrl,
746
+ requestBody: {
747
+ ...requestBody,
748
+ messages: requestBody.messages.map((msg) => ({
749
+ role: msg.role,
750
+ contentLength: msg.content.length,
751
+ contentPreview: msg.content.substring(0, 500),
752
+ contentFull: msg.content.length < 2000 ? msg.content : msg.content.substring(0, 1000) + '...' + msg.content.substring(msg.content.length - 1000)
753
+ }))
754
+ },
755
+ promptLength: prompt.length,
756
+ promptPreview: prompt.substring(0, 500),
757
+ promptFull: prompt.length < 2000 ? prompt : prompt.substring(0, 1000) + '...' + prompt.substring(prompt.length - 1000),
758
+ responseTextLength: responseText.length,
759
+ responseTextPreview: responseText.substring(0, 500),
760
+ responseTextFull: responseText,
761
+ contentType,
762
+ isNDJSON,
763
+ status: response.status,
764
+ statusText: response.statusText,
765
+ headers: Object.fromEntries(response.headers.entries())
766
+ });
767
+ throw new Error(`LLM 응답 파싱 실패: ${parseResult.error}`);
768
+ }
769
+ return parseResult;
770
+ }
771
+ catch (error) {
772
+ // 에러 발생 시에만 상세 로깅
773
+ logger.error('Ollama 호출 실패', {
774
+ error: error instanceof Error ? error.message : String(error),
775
+ baseUrl: mementoConfig.ollamaBaseUrl,
776
+ model: mementoConfig.ollamaModel,
777
+ requestBody: {
778
+ ...requestBody,
779
+ messages: requestBody.messages.map(msg => ({
780
+ role: msg.role,
781
+ contentLength: msg.content.length,
782
+ contentPreview: msg.content.substring(0, 500),
783
+ contentFull: msg.content.length < 2000 ? msg.content : msg.content.substring(0, 1000) + '...' + msg.content.substring(msg.content.length - 1000)
784
+ }))
785
+ },
786
+ promptLength: prompt.length,
787
+ promptPreview: prompt.substring(0, 500),
788
+ promptFull: prompt.length < 2000 ? prompt : prompt.substring(0, 1000) + '...' + prompt.substring(prompt.length - 1000)
789
+ });
790
+ throw error;
791
+ }
792
+ }
793
+ /**
794
+ * Gemini를 사용하여 관계 추출
795
+ */
796
+ async extractWithGemini(prompt) {
797
+ if (!this.geminiClient) {
798
+ throw new Error('Gemini 클라이언트가 초기화되지 않았습니다.');
799
+ }
800
+ // Rate limit 확인
801
+ await this.rateLimiter.consume();
802
+ try {
803
+ const modelName = mementoConfig.geminiModel || 'gemini-1.5-flash';
804
+ const model = this.geminiClient.getGenerativeModel({ model: modelName });
805
+ const result = await model.generateContent({
806
+ contents: [
807
+ {
808
+ role: 'user',
809
+ parts: [{ text: prompt }]
810
+ }
811
+ ],
812
+ generationConfig: {
813
+ temperature: 0.3,
814
+ maxOutputTokens: LIMITS.MAX_RESPONSE_TOKENS,
815
+ responseMimeType: 'application/json'
816
+ }
817
+ });
818
+ const response = result.response;
819
+ const text = response.text();
820
+ if (!text) {
821
+ throw new Error('Gemini 응답이 비어있습니다.');
822
+ }
823
+ // 비용 모니터링 (Gemini는 usage 정보를 직접 제공하지 않으므로 대략적 추정)
824
+ const estimatedPromptTokens = Math.ceil(prompt.length / 4); // 대략적 추정
825
+ const estimatedCompletionTokens = Math.ceil(text.length / 4);
826
+ this.calculateAndLogCost('gemini', estimatedPromptTokens, estimatedCompletionTokens);
827
+ const parseResult = this.parseLLMResponse(text);
828
+ if (!parseResult.success) {
829
+ // 파싱 실패 시 예외를 던져 호출자가 실패를 인지할 수 있도록 함
830
+ throw new Error(`LLM 응답 파싱 실패: ${parseResult.error}`);
831
+ }
832
+ return parseResult;
833
+ }
834
+ catch (error) {
835
+ logger.error('Gemini 호출 실패', {
836
+ error: error instanceof Error ? error.message : String(error)
837
+ });
838
+ throw error;
839
+ }
840
+ }
841
+ /**
842
+ * LLM 응답에서 JSON 객체 추출
843
+ * JSON 뒤에 추가 텍스트가 있어도 첫 번째 유효한 JSON만 추출
844
+ *
845
+ * Given: LLM 응답 텍스트 (JSON 형식이어야 하지만 추가 텍스트가 포함될 수 있음)
846
+ * When: JSON 객체 추출 시도
847
+ * Then: 유효한 JSON 객체만 반환 (추가 텍스트 제거)
848
+ */
849
+ extractJSON(text) {
850
+ if (!text || typeof text !== 'string') {
851
+ return null;
852
+ }
853
+ let jsonText = text.trim();
854
+ // 마크다운 코드 블록 제거
855
+ if (jsonText.startsWith('```json')) {
856
+ jsonText = jsonText.replace(/^```json\s*/, '').replace(/\s*```.*$/s, '');
857
+ }
858
+ else if (jsonText.startsWith('```')) {
859
+ jsonText = jsonText.replace(/^```\s*/, '').replace(/\s*```.*$/s, '');
860
+ }
861
+ // 첫 번째 '{'부터 시작하는 JSON 객체 찾기
862
+ const firstBrace = jsonText.indexOf('{');
863
+ if (firstBrace === -1) {
864
+ logger.warn('JSON 객체 시작 문자({)를 찾을 수 없습니다', {
865
+ textLength: jsonText.length,
866
+ textPreview: jsonText.substring(0, 200)
867
+ });
868
+ return null;
869
+ }
870
+ // 중괄호 매칭하여 JSON 객체 끝 찾기
871
+ // 이 방법은 JSON 뒤에 추가 텍스트가 있어도 정확하게 JSON 객체만 추출할 수 있습니다
872
+ let braceCount = 0;
873
+ let inString = false;
874
+ let escapeNext = false;
875
+ let jsonEnd = -1;
876
+ for (let i = firstBrace; i < jsonText.length; i++) {
877
+ const char = jsonText[i];
878
+ if (escapeNext) {
879
+ escapeNext = false;
880
+ continue;
881
+ }
882
+ if (char === '\\') {
883
+ escapeNext = true;
884
+ continue;
885
+ }
886
+ if (char === '"' && !escapeNext) {
887
+ inString = !inString;
888
+ continue;
889
+ }
890
+ if (!inString) {
891
+ if (char === '{') {
892
+ braceCount++;
893
+ }
894
+ else if (char === '}') {
895
+ braceCount--;
896
+ if (braceCount === 0) {
897
+ // JSON 객체 끝 찾음
898
+ jsonEnd = i + 1;
899
+ break;
900
+ }
901
+ }
902
+ }
903
+ }
904
+ if (jsonEnd === -1) {
905
+ // 중괄호가 닫히지 않음 - 경고 로그
906
+ logger.warn('JSON 객체가 완전히 닫히지 않았습니다', {
907
+ braceCount,
908
+ textLength: jsonText.length,
909
+ extractedPreview: jsonText.substring(firstBrace, Math.min(firstBrace + 200, jsonText.length))
910
+ });
911
+ // 그래도 시도해보기 (마지막 '}'까지 추출)
912
+ const lastBrace = jsonText.lastIndexOf('}');
913
+ if (lastBrace !== -1 && lastBrace > firstBrace) {
914
+ return jsonText.substring(firstBrace, lastBrace + 1);
915
+ }
916
+ return jsonText.substring(firstBrace);
917
+ }
918
+ // JSON 객체만 추출 (추가 텍스트 제거)
919
+ const extracted = jsonText.substring(firstBrace, jsonEnd).trim();
920
+ // 추출된 JSON이 유효한지 빠르게 확인
921
+ // 이 검증은 JSON.parse()가 실패하지 않도록 보장합니다
922
+ try {
923
+ const parsed = JSON.parse(extracted);
924
+ // 파싱 성공 시 유효한 JSON 반환
925
+ return extracted;
926
+ }
927
+ catch (error) {
928
+ // 유효하지 않은 JSON인 경우, 에러 타입에 따라 처리
929
+ const errorMsg = error instanceof Error ? error.message : String(error);
930
+ const isTrailingTextError = errorMsg.includes('Unexpected non-whitespace character after JSON');
931
+ if (isTrailingTextError) {
932
+ // JSON 뒤에 추가 텍스트가 있는 경우, 더 정확하게 추출 시도
933
+ // 중괄호 매칭이 정확했지만, JSON.parse()가 여전히 추가 텍스트를 감지
934
+ // 이는 JSON 내부에 문제가 있거나, 추출 범위가 정확하지 않을 수 있음
935
+ // 점진적으로 JSON 끝을 조정하여 유효한 JSON 찾기
936
+ let validJson = null;
937
+ for (let i = extracted.length; i > 0; i--) {
938
+ const testJson = extracted.substring(0, i).trim();
939
+ if (testJson.endsWith('}')) {
940
+ try {
941
+ JSON.parse(testJson);
942
+ validJson = testJson;
943
+ logger.debug('JSON 점진적 추출 성공 (extractJSON 내부)', {
944
+ originalLength: extracted.length,
945
+ validLength: validJson.length,
946
+ removedChars: extracted.length - validJson.length
947
+ });
948
+ break;
949
+ }
950
+ catch {
951
+ // 계속 시도
952
+ }
953
+ }
954
+ }
955
+ if (validJson) {
956
+ return validJson;
957
+ }
958
+ }
959
+ // 유효한 JSON을 찾지 못한 경우, 로그를 남기고 추출된 JSON 반환
960
+ // parseLLMResponse에서 추가 정리 시도
961
+ logger.warn('추출된 JSON이 유효하지 않습니다', {
962
+ error: errorMsg,
963
+ extractedLength: extracted.length,
964
+ extractedPreview: extracted.substring(0, 200),
965
+ originalPreview: jsonText.substring(0, 300)
966
+ });
967
+ // 그래도 반환 (parseLLMResponse에서 추가 정리 시도)
968
+ return extracted;
969
+ }
970
+ }
971
+ /**
972
+ * LLM 응답을 파싱하여 관계 후보 추출
973
+ *
974
+ * @param responseText LLM 응답 텍스트
975
+ * @returns 파싱 결과 (성공 여부와 관계 목록 포함)
976
+ * @throws 파싱 실패 시 예외를 던지지 않고 ParseResult에 실패 정보를 포함하여 반환
977
+ */
978
+ parseLLMResponse(responseText) {
979
+ try {
980
+ // Given: LLM 응답 텍스트 (JSON 형식이어야 함)
981
+ // When: JSON 추출 및 파싱 시도
982
+ // JSON 추출 (마크다운 코드 블록 및 추가 텍스트 제거)
983
+ let jsonText = this.extractJSON(responseText);
984
+ if (!jsonText) {
985
+ // JSON 추출 실패 시 원본 텍스트에서 직접 시도
986
+ logger.warn('JSON 추출 실패, 원본 텍스트에서 직접 파싱 시도', {
987
+ responseLength: responseText.length,
988
+ responsePreview: responseText.substring(0, 200)
989
+ });
990
+ jsonText = responseText.trim();
991
+ }
992
+ // JSON 파싱 시도 (여러 방법)
993
+ let parsed;
994
+ try {
995
+ // 첫 번째 시도: extractJSON으로 추출한 JSON 파싱
996
+ // extractJSON이 이미 유효한 JSON만 반환하도록 보장하지만,
997
+ // 일부 모델은 JSON 뒤에 추가 텍스트를 포함할 수 있으므로 추가 정리 필요
998
+ // JSON.parse()가 실패할 수 있으므로, 먼저 정리된 JSON인지 확인
999
+ let trimmedJson = jsonText.trim();
1000
+ // JSON 뒤에 추가 텍스트가 있을 수 있으므로, 첫 번째 '{'부터 마지막 '}'까지만 추출
1001
+ const firstBrace = trimmedJson.indexOf('{');
1002
+ const lastBrace = trimmedJson.lastIndexOf('}');
1003
+ if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
1004
+ trimmedJson = trimmedJson.substring(firstBrace, lastBrace + 1).trim();
1005
+ }
1006
+ parsed = JSON.parse(trimmedJson);
1007
+ logger.debug('JSON 파싱 성공 (첫 번째 시도)');
1008
+ }
1009
+ catch (parseError) {
1010
+ // 첫 번째 시도 실패 시, 더 공격적인 정리 시도
1011
+ const firstError = parseError instanceof Error ? parseError.message : String(parseError);
1012
+ // JSON 파싱 에러가 "Unexpected non-whitespace character after JSON"인 경우
1013
+ // JSON 뒤에 추가 텍스트가 있다는 의미이므로, extractJSON을 다시 사용하거나
1014
+ // 더 정확한 JSON 추출 시도
1015
+ const isTrailingTextError = firstError.includes('Unexpected non-whitespace character after JSON');
1016
+ logger.warn('JSON 파싱 실패, 추가 정리 후 재시도', {
1017
+ error: firstError,
1018
+ isTrailingTextError,
1019
+ jsonLength: jsonText.length,
1020
+ jsonPreview: jsonText.substring(0, 300),
1021
+ jsonFull: jsonText.length < 1000 ? jsonText : jsonText.substring(0, 500) + '...' + jsonText.substring(jsonText.length - 500)
1022
+ });
1023
+ // 추가 정리: 첫 번째 '{'부터 마지막 '}'까지 추출
1024
+ // extractJSON이 이미 이를 수행했지만, 다시 시도하여 더 정확하게 추출
1025
+ let cleanedJson = jsonText.trim();
1026
+ // extractJSON을 다시 호출하여 더 정확한 추출 시도
1027
+ if (isTrailingTextError) {
1028
+ // "Unexpected non-whitespace character after JSON" 에러는 JSON 뒤에 추가 텍스트가 있다는 의미
1029
+ // extractJSON이 이미 이를 처리했지만, 여전히 문제가 있을 수 있으므로 더 정확하게 추출
1030
+ const reExtracted = this.extractJSON(responseText);
1031
+ if (reExtracted && reExtracted !== jsonText) {
1032
+ cleanedJson = reExtracted.trim();
1033
+ logger.debug('JSON 재추출 완료 (trailing text 제거)', {
1034
+ originalLength: jsonText.length,
1035
+ cleanedLength: cleanedJson.length
1036
+ });
1037
+ }
1038
+ else {
1039
+ // extractJSON이 실패한 경우, 수동으로 첫 번째 '{'부터 마지막 '}'까지 추출
1040
+ // 그리고 JSON.parse()가 성공할 때까지 끝 부분을 점진적으로 제거
1041
+ let firstBrace = cleanedJson.indexOf('{');
1042
+ let lastBrace = cleanedJson.lastIndexOf('}');
1043
+ if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
1044
+ // 먼저 첫 번째 '{'부터 마지막 '}'까지 추출
1045
+ cleanedJson = cleanedJson.substring(firstBrace, lastBrace + 1).trim();
1046
+ // JSON.parse()가 성공할 때까지 끝 부분을 점진적으로 제거
1047
+ let attemptJson = cleanedJson;
1048
+ let foundValidJson = false;
1049
+ for (let i = attemptJson.length; i > 0 && !foundValidJson; i--) {
1050
+ const testJson = attemptJson.substring(0, i);
1051
+ // 마지막 문자가 '}'인지 확인
1052
+ if (testJson.endsWith('}')) {
1053
+ try {
1054
+ JSON.parse(testJson);
1055
+ cleanedJson = testJson;
1056
+ foundValidJson = true;
1057
+ logger.debug('JSON 점진적 추출 성공', {
1058
+ originalLength: jsonText.length,
1059
+ cleanedLength: cleanedJson.length,
1060
+ removedChars: attemptJson.length - cleanedJson.length
1061
+ });
1062
+ }
1063
+ catch {
1064
+ // 계속 시도
1065
+ }
1066
+ }
1067
+ }
1068
+ if (!foundValidJson) {
1069
+ logger.debug('JSON 수동 정리 완료 (점진적 추출 실패)', {
1070
+ originalLength: jsonText.length,
1071
+ cleanedLength: cleanedJson.length,
1072
+ cleanedPreview: cleanedJson.substring(0, 300)
1073
+ });
1074
+ }
1075
+ }
1076
+ }
1077
+ }
1078
+ else {
1079
+ // 다른 종류의 에러인 경우, 기본 정리 시도
1080
+ const firstBrace = cleanedJson.indexOf('{');
1081
+ const lastBrace = cleanedJson.lastIndexOf('}');
1082
+ if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
1083
+ cleanedJson = cleanedJson.substring(firstBrace, lastBrace + 1);
1084
+ logger.debug('JSON 정리 완료', {
1085
+ originalLength: jsonText.length,
1086
+ cleanedLength: cleanedJson.length,
1087
+ cleanedPreview: cleanedJson.substring(0, 300)
1088
+ });
1089
+ }
1090
+ }
1091
+ // 두 번째 시도: 정리된 JSON 파싱
1092
+ try {
1093
+ parsed = JSON.parse(cleanedJson);
1094
+ logger.debug('JSON 파싱 성공 (두 번째 시도)');
1095
+ }
1096
+ catch (secondError) {
1097
+ // 두 번째 시도도 실패 - 원본에서 직접 추출 시도
1098
+ logger.warn('정리된 JSON 파싱도 실패, 원본에서 직접 추출 시도', {
1099
+ secondError: secondError instanceof Error ? secondError.message : String(secondError),
1100
+ cleanedLength: cleanedJson.length,
1101
+ cleanedPreview: cleanedJson.substring(0, 300)
1102
+ });
1103
+ // 원본 텍스트에서 다시 추출
1104
+ const reExtracted = this.extractJSON(responseText);
1105
+ if (reExtracted && reExtracted !== jsonText && reExtracted !== cleanedJson) {
1106
+ try {
1107
+ parsed = JSON.parse(reExtracted);
1108
+ logger.debug('JSON 파싱 성공 (재추출 시도)');
1109
+ }
1110
+ catch (thirdError) {
1111
+ // 최종 실패
1112
+ logger.error('JSON 파싱 최종 실패', {
1113
+ firstError,
1114
+ secondError: secondError instanceof Error ? secondError.message : String(secondError),
1115
+ thirdError: thirdError instanceof Error ? thirdError.message : String(thirdError),
1116
+ originalLength: responseText.length,
1117
+ originalPreview: responseText.substring(0, 500),
1118
+ extractedLength: reExtracted?.length || 0,
1119
+ extractedPreview: reExtracted?.substring(0, 500) || 'null'
1120
+ });
1121
+ return {
1122
+ success: false,
1123
+ relations: [],
1124
+ error: `JSON 파싱 실패: ${thirdError instanceof Error ? thirdError.message : String(thirdError)}`
1125
+ };
1126
+ }
1127
+ }
1128
+ else {
1129
+ // 최종 실패
1130
+ logger.error('JSON 파싱 최종 실패', {
1131
+ firstError,
1132
+ secondError: secondError instanceof Error ? secondError.message : String(secondError),
1133
+ originalLength: responseText.length,
1134
+ originalPreview: responseText.substring(0, 500),
1135
+ cleanedLength: cleanedJson.length,
1136
+ cleanedPreview: cleanedJson.substring(0, 500)
1137
+ });
1138
+ return {
1139
+ success: false,
1140
+ relations: [],
1141
+ error: `JSON 파싱 실패: ${secondError instanceof Error ? secondError.message : String(secondError)}`
1142
+ };
1143
+ }
1144
+ }
1145
+ }
1146
+ // 응답 구조 검증
1147
+ if (!parsed.relations || !Array.isArray(parsed.relations)) {
1148
+ return {
1149
+ success: false,
1150
+ relations: [],
1151
+ error: '응답 구조가 올바르지 않습니다: relations 배열이 없거나 배열이 아닙니다.'
1152
+ };
1153
+ }
1154
+ // 관계 유형 및 신뢰도 검증
1155
+ const validRelations = parsed.relations
1156
+ .filter(rel => {
1157
+ // 관계 유형 검증
1158
+ if (!ALL_RELATION_TYPES.includes(rel.relation_type)) {
1159
+ return false;
1160
+ }
1161
+ // 신뢰도 범위 검증
1162
+ if (typeof rel.confidence !== 'number' || rel.confidence < 0 || rel.confidence > 1) {
1163
+ return false;
1164
+ }
1165
+ return true;
1166
+ })
1167
+ .map(rel => ({
1168
+ target_id: rel.target_id,
1169
+ relation_type: rel.relation_type,
1170
+ confidence: Math.max(0, Math.min(1, rel.confidence)), // 0~1 범위로 클램핑
1171
+ reasoning: rel.reasoning
1172
+ }));
1173
+ return {
1174
+ success: true,
1175
+ relations: validRelations
1176
+ };
1177
+ }
1178
+ catch (error) {
1179
+ const errorMessage = error instanceof Error ? error.message : String(error);
1180
+ logger.error('LLM 응답 파싱 실패', {
1181
+ error: errorMessage,
1182
+ responseText: responseText.substring(0, 500) // 처음 500자만 로깅
1183
+ });
1184
+ return {
1185
+ success: false,
1186
+ relations: [],
1187
+ error: `JSON 파싱 실패: ${errorMessage}`
1188
+ };
1189
+ }
1190
+ }
1191
+ /**
1192
+ * 새로운 기억과 기존 기억들 간의 관계를 추출합니다.
1193
+ *
1194
+ * @param newMemory 새로운 기억
1195
+ * @param existingMemories 기존 기억 목록
1196
+ * @param options 추출 옵션
1197
+ * @returns 관계 후보 목록
1198
+ */
1199
+ async extractRelations(newMemory, existingMemories, options) {
1200
+ if (!this.isAvailable()) {
1201
+ throw new Error('LLM 서비스가 사용 불가능합니다. API 키를 설정해주세요.');
1202
+ }
1203
+ if (existingMemories.length === 0) {
1204
+ return [];
1205
+ }
1206
+ // 캐시 확인
1207
+ const existingMemoryIds = existingMemories.map(m => m.id);
1208
+ const cacheKey = this.generateCacheKey(newMemory.id, existingMemoryIds);
1209
+ const cached = this.cache.get(cacheKey);
1210
+ if (cached) {
1211
+ logger.debug('LLM 관계 추출 캐시 히트', { cacheKey });
1212
+ return cached;
1213
+ }
1214
+ // 적용 가능한 관계 유형 필터링
1215
+ const allowedTypes = options?.relationTypes;
1216
+ const memoryType = newMemory.type;
1217
+ const applicableTypes = allowedTypes
1218
+ ? allowedTypes.filter(type => isApplicableRelationType(memoryType, type))
1219
+ : MEMORY_TYPE_RELATION_MAP[memoryType];
1220
+ if (applicableTypes.length === 0) {
1221
+ return [];
1222
+ }
1223
+ // Embedding 기반 후보 제한 (cosine similarity 상위 N개)
1224
+ const candidateLimit = options?.candidateLimit ?? LIMITS.LLM_CANDIDATE_DEFAULT;
1225
+ const filteredMemories = await this.filterCandidatesByEmbedding(newMemory, existingMemories, candidateLimit);
1226
+ // 프롬프트 압축
1227
+ const compressedMemories = this.compressMemories(filteredMemories, LIMITS.MAX_PROMPT_TOKENS);
1228
+ // 프롬프트 생성
1229
+ const prompt = this.buildPrompt(newMemory, compressedMemories, applicableTypes);
1230
+ // LLM 호출
1231
+ let parsedResponse;
1232
+ try {
1233
+ if (this.preferredProvider === 'openai') {
1234
+ parsedResponse = await this.extractWithOpenAI(prompt);
1235
+ }
1236
+ else if (this.preferredProvider === 'gemini') {
1237
+ parsedResponse = await this.extractWithGemini(prompt);
1238
+ }
1239
+ else if (this.preferredProvider === 'ollama' || (this.preferredProvider === null && mementoConfig.llmProvider === 'ollama')) {
1240
+ parsedResponse = await this.extractWithOllama(prompt);
1241
+ }
1242
+ else if (this.preferredProvider === null && mementoConfig.llmProvider === 'auto') {
1243
+ // auto 모드에서 모든 프로바이더 시도
1244
+ try {
1245
+ parsedResponse = await this.extractWithOpenAI(prompt);
1246
+ }
1247
+ catch (openaiError) {
1248
+ try {
1249
+ parsedResponse = await this.extractWithGemini(prompt);
1250
+ }
1251
+ catch (geminiError) {
1252
+ parsedResponse = await this.extractWithOllama(prompt);
1253
+ }
1254
+ }
1255
+ }
1256
+ else {
1257
+ throw new Error('사용 가능한 LLM 서비스가 없습니다.');
1258
+ }
1259
+ }
1260
+ catch (error) {
1261
+ // LLM 호출 실패 시 명확한 에러 메시지와 함께 예외를 던짐
1262
+ // 호출자가 실패를 인지하고 적절한 fallback 전략을 사용할 수 있도록 함
1263
+ const errorMessage = error instanceof Error ? error.message : String(error);
1264
+ // provider 정보를 정확하게 추출
1265
+ let actualProvider = this.preferredProvider;
1266
+ if (actualProvider === null) {
1267
+ // preferredProvider가 null인 경우, 환경 변수에서 확인
1268
+ actualProvider = mementoConfig.llmProvider || 'auto';
1269
+ }
1270
+ const errorDetails = {
1271
+ error: errorMessage,
1272
+ memoryId: newMemory.id,
1273
+ provider: actualProvider,
1274
+ preferredProvider: this.preferredProvider,
1275
+ llmProviderConfig: mementoConfig.llmProvider,
1276
+ suggestion: '규칙 기반 추출을 사용하거나 네트워크 연결을 확인하세요.'
1277
+ };
1278
+ logger.error('LLM 호출 실패', errorDetails);
1279
+ // 네트워크 오류와 실제 관계가 없는 경우를 구분하기 위해 예외를 던짐
1280
+ // 호출자는 이 예외를 catch하여 fallback 전략을 사용할 수 있음
1281
+ throw new Error(`LLM 기반 관계 추출 실패: ${errorMessage}. ${errorDetails.suggestion}`);
1282
+ }
1283
+ // 최소 신뢰도 필터링
1284
+ const minConfidence = options?.minConfidence ?? CONFIDENCE.MIN_LLM_BASED;
1285
+ // RelationCandidate로 변환
1286
+ const candidates = parsedResponse.relations
1287
+ .filter(rel => rel.confidence >= minConfidence)
1288
+ .map(rel => ({
1289
+ source_id: newMemory.id,
1290
+ target_id: rel.target_id,
1291
+ relation_type: rel.relation_type,
1292
+ confidence: rel.confidence,
1293
+ method: 'llm',
1294
+ evidence: rel.reasoning || 'LLM 분석 결과'
1295
+ }));
1296
+ // 신뢰도 내림차순 정렬
1297
+ candidates.sort((a, b) => b.confidence - a.confidence);
1298
+ // 캐시 저장 (7일 TTL)
1299
+ this.cache.set(cacheKey, candidates);
1300
+ return candidates;
1301
+ }
1302
+ /**
1303
+ * 배치 관계 추출 (여러 기억을 묶어서 처리)
1304
+ *
1305
+ * @param newMemories 새로운 기억 목록
1306
+ * @param existingMemories 기존 기억 목록
1307
+ * @param options 추출 옵션 (batchSize 포함 가능)
1308
+ * @returns 각 새로운 기억별 관계 후보 맵
1309
+ */
1310
+ async extractRelationsBatch(newMemories, existingMemories, options) {
1311
+ const results = new Map();
1312
+ // 배치 크기를 옵션에서 가져오거나, 환경 변수에서 가져오거나, 기본값 사용
1313
+ const batchSize = options?.batchSize ??
1314
+ (process.env.RELATION_EXTRACT_BATCH_SIZE ? parseInt(process.env.RELATION_EXTRACT_BATCH_SIZE, 10) : LIMITS.BATCH_SIZE_DEFAULT);
1315
+ const batches = [];
1316
+ // 배치로 나누기
1317
+ for (let i = 0; i < newMemories.length; i += batchSize) {
1318
+ batches.push(newMemories.slice(i, i + batchSize));
1319
+ }
1320
+ // 각 배치 처리
1321
+ for (const batch of batches) {
1322
+ const promises = batch.map(memory => this.extractRelations(memory, existingMemories, options));
1323
+ const batchResults = await Promise.all(promises);
1324
+ for (let i = 0; i < batch.length && i < batchResults.length; i++) {
1325
+ const memory = batch[i];
1326
+ const result = batchResults[i];
1327
+ if (memory && result !== undefined) {
1328
+ results.set(memory.id, result);
1329
+ }
1330
+ }
1331
+ }
1332
+ return results;
1333
+ }
1334
+ /**
1335
+ * 비용 통계 조회
1336
+ */
1337
+ getCostMetrics() {
1338
+ return { ...this.costMetrics };
1339
+ }
1340
+ /**
1341
+ * 비용 통계 초기화
1342
+ */
1343
+ resetCostMetrics() {
1344
+ this.costMetrics.totalCalls = 0;
1345
+ this.costMetrics.totalTokens = 0;
1346
+ this.costMetrics.totalCost = 0;
1347
+ this.costMetrics.lastReset = Date.now();
1348
+ }
1349
+ }
1350
+ //# sourceMappingURL=llm-based-relation-extractor.js.map