memento-mcp-server 1.11.1 → 1.12.0-a
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/README.en.md +21 -6
- package/README.md +38 -7
- package/dist/algorithms/hybrid-search-engine.d.ts +34 -1
- package/dist/algorithms/hybrid-search-engine.d.ts.map +1 -1
- package/dist/algorithms/hybrid-search-engine.js +186 -17
- package/dist/algorithms/hybrid-search-engine.js.map +1 -1
- package/dist/algorithms/search-ranking.d.ts +15 -1
- package/dist/algorithms/search-ranking.d.ts.map +1 -1
- package/dist/algorithms/search-ranking.js +41 -4
- package/dist/algorithms/search-ranking.js.map +1 -1
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/config/environment.js +4 -0
- package/dist/config/environment.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +6 -0
- package/dist/config/index.js.map +1 -1
- package/dist/config/ranking-weights-loader.d.ts +37 -0
- package/dist/config/ranking-weights-loader.d.ts.map +1 -0
- package/dist/config/ranking-weights-loader.js +109 -0
- package/dist/config/ranking-weights-loader.js.map +1 -0
- package/dist/constants/relation-constants.d.ts +95 -0
- package/dist/constants/relation-constants.d.ts.map +1 -0
- package/dist/constants/relation-constants.js +95 -0
- package/dist/constants/relation-constants.js.map +1 -0
- package/dist/database/migration/migrations/005-relation-engine-schema.d.ts +65 -0
- package/dist/database/migration/migrations/005-relation-engine-schema.d.ts.map +1 -0
- package/dist/database/migration/migrations/005-relation-engine-schema.js +295 -0
- package/dist/database/migration/migrations/005-relation-engine-schema.js.map +1 -0
- package/dist/database/migration/migrations/005-relation-engine-schema.sql +64 -0
- package/dist/services/anchor/anchor-interfaces.d.ts +1 -0
- package/dist/services/anchor/anchor-interfaces.d.ts.map +1 -1
- package/dist/services/anchor/anchor-interfaces.js.map +1 -1
- package/dist/services/anchor/anchor-search-service.d.ts +16 -0
- package/dist/services/anchor/anchor-search-service.d.ts.map +1 -1
- package/dist/services/anchor/anchor-search-service.js +136 -17
- package/dist/services/anchor/anchor-search-service.js.map +1 -1
- package/dist/services/batch-scheduler.d.ts +11 -0
- package/dist/services/batch-scheduler.d.ts.map +1 -1
- package/dist/services/batch-scheduler.js +99 -0
- package/dist/services/batch-scheduler.js.map +1 -1
- package/dist/services/llm-based-relation-extractor.d.ts +156 -0
- package/dist/services/llm-based-relation-extractor.d.ts.map +1 -0
- package/dist/services/llm-based-relation-extractor.js +1350 -0
- package/dist/services/llm-based-relation-extractor.js.map +1 -0
- package/dist/services/relation-extractor.d.ts +73 -0
- package/dist/services/relation-extractor.d.ts.map +1 -0
- package/dist/services/relation-extractor.js +231 -0
- package/dist/services/relation-extractor.js.map +1 -0
- package/dist/services/relation-graph.d.ts +275 -0
- package/dist/services/relation-graph.d.ts.map +1 -0
- package/dist/services/relation-graph.js +869 -0
- package/dist/services/relation-graph.js.map +1 -0
- package/dist/services/relation-quality-validator.d.ts +211 -0
- package/dist/services/relation-quality-validator.d.ts.map +1 -0
- package/dist/services/relation-quality-validator.js +415 -0
- package/dist/services/relation-quality-validator.js.map +1 -0
- package/dist/services/rule-based-relation-extractor.d.ts +66 -0
- package/dist/services/rule-based-relation-extractor.d.ts.map +1 -0
- package/dist/services/rule-based-relation-extractor.js +258 -0
- package/dist/services/rule-based-relation-extractor.js.map +1 -0
- package/dist/tools/add-relation-tool.d.ts +34 -0
- package/dist/tools/add-relation-tool.d.ts.map +1 -0
- package/dist/tools/add-relation-tool.js +163 -0
- package/dist/tools/add-relation-tool.js.map +1 -0
- package/dist/tools/extract-relations-tool.d.ts +28 -0
- package/dist/tools/extract-relations-tool.d.ts.map +1 -0
- package/dist/tools/extract-relations-tool.js +159 -0
- package/dist/tools/extract-relations-tool.js.map +1 -0
- package/dist/tools/get-relations-tool.d.ts +34 -0
- package/dist/tools/get-relations-tool.d.ts.map +1 -0
- package/dist/tools/get-relations-tool.js +155 -0
- package/dist/tools/get-relations-tool.js.map +1 -0
- package/dist/tools/index.d.ts +6 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +16 -2
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/remember-tool.d.ts +17 -0
- package/dist/tools/remember-tool.d.ts.map +1 -1
- package/dist/tools/remember-tool.js +195 -26
- package/dist/tools/remember-tool.js.map +1 -1
- package/dist/tools/remove-relation-tool.d.ts +45 -0
- package/dist/tools/remove-relation-tool.d.ts.map +1 -0
- package/dist/tools/remove-relation-tool.js +142 -0
- package/dist/tools/remove-relation-tool.js.map +1 -0
- package/dist/tools/search-local-tool.d.ts.map +1 -1
- package/dist/tools/search-local-tool.js +10 -3
- package/dist/tools/search-local-tool.js.map +1 -1
- package/dist/tools/types.d.ts +2 -0
- package/dist/tools/types.d.ts.map +1 -1
- package/dist/tools/types.js.map +1 -1
- package/dist/tools/visualize-relations-tool.d.ts +46 -0
- package/dist/tools/visualize-relations-tool.d.ts.map +1 -0
- package/dist/tools/visualize-relations-tool.js +157 -0
- package/dist/tools/visualize-relations-tool.js.map +1 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/relation-graph.d.ts +215 -0
- package/dist/types/relation-graph.d.ts.map +1 -0
- package/dist/types/relation-graph.js +6 -0
- package/dist/types/relation-graph.js.map +1 -0
- package/dist/types/relation.d.ts +112 -0
- package/dist/types/relation.d.ts.map +1 -0
- package/dist/types/relation.js +67 -0
- package/dist/types/relation.js.map +1 -0
- package/dist/utils/cache-key-generator.d.ts +63 -0
- package/dist/utils/cache-key-generator.d.ts.map +1 -0
- package/dist/utils/cache-key-generator.js +76 -0
- package/dist/utils/cache-key-generator.js.map +1 -0
- package/dist/utils/database.d.ts.map +1 -1
- package/dist/utils/database.js +37 -17
- package/dist/utils/database.js.map +1 -1
- package/dist/utils/relation-visualizer.d.ts +81 -0
- package/dist/utils/relation-visualizer.d.ts.map +1 -0
- package/dist/utils/relation-visualizer.js +239 -0
- package/dist/utils/relation-visualizer.js.map +1 -0
- package/dist/utils/type-guards.d.ts +100 -0
- package/dist/utils/type-guards.d.ts.map +1 -0
- package/dist/utils/type-guards.js +144 -0
- package/dist/utils/type-guards.js.map +1 -0
- package/package.json +7 -2
- package/scripts/generate-relation-report.ts +481 -0
- 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
|