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.
Files changed (124) 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/services/anchor/anchor-interfaces.d.ts +1 -0
  31. package/dist/services/anchor/anchor-interfaces.d.ts.map +1 -1
  32. package/dist/services/anchor/anchor-interfaces.js.map +1 -1
  33. package/dist/services/anchor/anchor-search-service.d.ts +16 -0
  34. package/dist/services/anchor/anchor-search-service.d.ts.map +1 -1
  35. package/dist/services/anchor/anchor-search-service.js +136 -17
  36. package/dist/services/anchor/anchor-search-service.js.map +1 -1
  37. package/dist/services/batch-scheduler.d.ts +11 -0
  38. package/dist/services/batch-scheduler.d.ts.map +1 -1
  39. package/dist/services/batch-scheduler.js +99 -0
  40. package/dist/services/batch-scheduler.js.map +1 -1
  41. package/dist/services/llm-based-relation-extractor.d.ts +156 -0
  42. package/dist/services/llm-based-relation-extractor.d.ts.map +1 -0
  43. package/dist/services/llm-based-relation-extractor.js +1350 -0
  44. package/dist/services/llm-based-relation-extractor.js.map +1 -0
  45. package/dist/services/relation-extractor.d.ts +73 -0
  46. package/dist/services/relation-extractor.d.ts.map +1 -0
  47. package/dist/services/relation-extractor.js +231 -0
  48. package/dist/services/relation-extractor.js.map +1 -0
  49. package/dist/services/relation-graph.d.ts +275 -0
  50. package/dist/services/relation-graph.d.ts.map +1 -0
  51. package/dist/services/relation-graph.js +869 -0
  52. package/dist/services/relation-graph.js.map +1 -0
  53. package/dist/services/relation-quality-validator.d.ts +211 -0
  54. package/dist/services/relation-quality-validator.d.ts.map +1 -0
  55. package/dist/services/relation-quality-validator.js +415 -0
  56. package/dist/services/relation-quality-validator.js.map +1 -0
  57. package/dist/services/rule-based-relation-extractor.d.ts +66 -0
  58. package/dist/services/rule-based-relation-extractor.d.ts.map +1 -0
  59. package/dist/services/rule-based-relation-extractor.js +258 -0
  60. package/dist/services/rule-based-relation-extractor.js.map +1 -0
  61. package/dist/tools/add-relation-tool.d.ts +34 -0
  62. package/dist/tools/add-relation-tool.d.ts.map +1 -0
  63. package/dist/tools/add-relation-tool.js +163 -0
  64. package/dist/tools/add-relation-tool.js.map +1 -0
  65. package/dist/tools/extract-relations-tool.d.ts +28 -0
  66. package/dist/tools/extract-relations-tool.d.ts.map +1 -0
  67. package/dist/tools/extract-relations-tool.js +159 -0
  68. package/dist/tools/extract-relations-tool.js.map +1 -0
  69. package/dist/tools/get-relations-tool.d.ts +34 -0
  70. package/dist/tools/get-relations-tool.d.ts.map +1 -0
  71. package/dist/tools/get-relations-tool.js +155 -0
  72. package/dist/tools/get-relations-tool.js.map +1 -0
  73. package/dist/tools/index.d.ts +6 -1
  74. package/dist/tools/index.d.ts.map +1 -1
  75. package/dist/tools/index.js +16 -2
  76. package/dist/tools/index.js.map +1 -1
  77. package/dist/tools/remember-tool.d.ts +17 -0
  78. package/dist/tools/remember-tool.d.ts.map +1 -1
  79. package/dist/tools/remember-tool.js +195 -26
  80. package/dist/tools/remember-tool.js.map +1 -1
  81. package/dist/tools/remove-relation-tool.d.ts +45 -0
  82. package/dist/tools/remove-relation-tool.d.ts.map +1 -0
  83. package/dist/tools/remove-relation-tool.js +142 -0
  84. package/dist/tools/remove-relation-tool.js.map +1 -0
  85. package/dist/tools/search-local-tool.d.ts.map +1 -1
  86. package/dist/tools/search-local-tool.js +10 -3
  87. package/dist/tools/search-local-tool.js.map +1 -1
  88. package/dist/tools/types.d.ts +2 -0
  89. package/dist/tools/types.d.ts.map +1 -1
  90. package/dist/tools/types.js.map +1 -1
  91. package/dist/tools/visualize-relations-tool.d.ts +46 -0
  92. package/dist/tools/visualize-relations-tool.d.ts.map +1 -0
  93. package/dist/tools/visualize-relations-tool.js +157 -0
  94. package/dist/tools/visualize-relations-tool.js.map +1 -0
  95. package/dist/types/index.d.ts +8 -0
  96. package/dist/types/index.d.ts.map +1 -1
  97. package/dist/types/index.js +1 -0
  98. package/dist/types/index.js.map +1 -1
  99. package/dist/types/relation-graph.d.ts +215 -0
  100. package/dist/types/relation-graph.d.ts.map +1 -0
  101. package/dist/types/relation-graph.js +6 -0
  102. package/dist/types/relation-graph.js.map +1 -0
  103. package/dist/types/relation.d.ts +112 -0
  104. package/dist/types/relation.d.ts.map +1 -0
  105. package/dist/types/relation.js +67 -0
  106. package/dist/types/relation.js.map +1 -0
  107. package/dist/utils/cache-key-generator.d.ts +63 -0
  108. package/dist/utils/cache-key-generator.d.ts.map +1 -0
  109. package/dist/utils/cache-key-generator.js +76 -0
  110. package/dist/utils/cache-key-generator.js.map +1 -0
  111. package/dist/utils/database.d.ts.map +1 -1
  112. package/dist/utils/database.js +37 -17
  113. package/dist/utils/database.js.map +1 -1
  114. package/dist/utils/relation-visualizer.d.ts +81 -0
  115. package/dist/utils/relation-visualizer.d.ts.map +1 -0
  116. package/dist/utils/relation-visualizer.js +239 -0
  117. package/dist/utils/relation-visualizer.js.map +1 -0
  118. package/dist/utils/type-guards.d.ts +100 -0
  119. package/dist/utils/type-guards.d.ts.map +1 -0
  120. package/dist/utils/type-guards.js +144 -0
  121. package/dist/utils/type-guards.js.map +1 -0
  122. package/package.json +7 -2
  123. package/scripts/generate-relation-report.ts +481 -0
  124. package/scripts/weekly-relation-validation.ts +423 -0
@@ -0,0 +1,869 @@
1
+ /**
2
+ * 관계 그래프 서비스
3
+ * 기억 간의 관계를 저장하고 관리하는 서비스
4
+ *
5
+ * 주요 기능:
6
+ * - 관계 추가/삭제/조회
7
+ * - 순환 참조 감지 (DFS)
8
+ * - N-hop 관계 탐색 (BFS)
9
+ * - 신뢰도 갱신
10
+ * - 캐싱 계층 (L1: MemoryCache 10분, L2: PersistentCache 7일)
11
+ * - 배치 삽입 최적화
12
+ */
13
+ import Database from 'better-sqlite3';
14
+ import { DatabaseUtils } from '../utils/database.js';
15
+ import { CacheService } from './cache-service.js';
16
+ import { logger } from '../utils/logger.js';
17
+ import { isExistingRelationRow, isMetadataRow, isRelationRow } from '../utils/type-guards.js';
18
+ import { CacheKeyGenerator } from '../utils/cache-key-generator.js';
19
+ import { CONFIDENCE, LIMITS, CACHE } from '../constants/relation-constants.js';
20
+ /**
21
+ * 관계 그래프 서비스
22
+ */
23
+ export class RelationGraph {
24
+ db;
25
+ // L1 캐시: 메모리 캐시 (TTL 10분)
26
+ l1Cache;
27
+ // L2 캐시: 영구 캐시 (TTL 7일)
28
+ l2Cache;
29
+ // 캐시 키 추적: memoryId -> Set<cacheKey>
30
+ // 정확한 캐시 무효화를 위해 사용
31
+ cacheKeyIndex = new Map();
32
+ constructor(db) {
33
+ this.db = db;
34
+ // L1 캐시: 1000개 항목, 10분 TTL
35
+ this.l1Cache = new CacheService(CACHE.L1_SIZE, CACHE.L1_TTL_MS);
36
+ // L2 캐시: 5000개 항목, 7일 TTL
37
+ this.l2Cache = new CacheService(CACHE.L2_SIZE, CACHE.L2_TTL_MS);
38
+ }
39
+ /**
40
+ * 관계 추가
41
+ * UNIQUE 제약 검증 및 순환 참조 감지를 수행합니다.
42
+ *
43
+ * 트랜잭션 내에서 실행하여 순환 참조 감지와 관계 추가를 원자적으로 처리합니다.
44
+ * 이를 통해 경쟁 조건을 방지하고 일관성을 보장합니다.
45
+ *
46
+ * @param sourceId 소스 기억 ID
47
+ * @param targetId 타겟 기억 ID
48
+ * @param relationType 관계 유형
49
+ * @param options 추가 옵션
50
+ * @returns 추가된 관계 ID
51
+ */
52
+ async addRelation(sourceId, targetId, relationType, options) {
53
+ // Given: 입력 검증
54
+ this.validateRelationInput(sourceId, targetId);
55
+ const confidence = options?.confidence ?? CONFIDENCE.DEFAULT;
56
+ const updateOnConflict = options?.updateOnConflict ?? false;
57
+ const allowCyclic = options?.allowCyclic ?? false;
58
+ // When: 트랜잭션 내에서 순환 참조 감지와 관계 추가를 원자적으로 처리
59
+ // BEGIN IMMEDIATE TRANSACTION을 사용하여 배타적 락을 획득하여 경쟁 조건 방지
60
+ // DatabaseUtils.runTransaction이 중첩 트랜잭션을 자동으로 처리하므로
61
+ // 트랜잭션 상태 확인 없이 직접 호출합니다
62
+ return await DatabaseUtils.runTransaction(this.db, async () => {
63
+ // Then: 순환 참조 감지 (트랜잭션 상태 확인)
64
+ if (!allowCyclic) {
65
+ await this.checkForCyclicRelation(sourceId, targetId, relationType);
66
+ }
67
+ // 메타데이터 준비
68
+ const metadata = this.prepareMetadata(options, allowCyclic);
69
+ const metadataJson = JSON.stringify(metadata);
70
+ try {
71
+ return await this.addRelationInternal(sourceId, targetId, relationType, confidence, metadata, metadataJson, updateOnConflict, allowCyclic);
72
+ }
73
+ catch (error) {
74
+ return await this.handleRelationAddError(error, sourceId, targetId, relationType, confidence, metadata, updateOnConflict);
75
+ }
76
+ });
77
+ }
78
+ /**
79
+ * 관계 추가 내부 로직
80
+ * 기존 관계 확인 및 새 관계 추가를 수행합니다.
81
+ *
82
+ * @param sourceId 소스 기억 ID
83
+ * @param targetId 타겟 기억 ID
84
+ * @param relationType 관계 유형
85
+ * @param confidence 신뢰도
86
+ * @param metadata 메타데이터
87
+ * @param metadataJson 메타데이터 JSON 문자열
88
+ * @param updateOnConflict 충돌 시 업데이트 여부
89
+ * @param allowCyclic 순환 참조 허용 여부
90
+ * @returns 관계 ID
91
+ */
92
+ async addRelationInternal(sourceId, targetId, relationType, confidence, metadata, metadataJson, updateOnConflict, allowCyclic) {
93
+ // 기존 관계 확인 및 업데이트
94
+ const existing = this.findExistingRelation(sourceId, targetId, relationType);
95
+ if (existing) {
96
+ return await this.handleExistingRelation(existing, sourceId, targetId, relationType, confidence, metadata, updateOnConflict);
97
+ }
98
+ // 새 관계 추가
99
+ const relationId = await this.insertNewRelation(sourceId, targetId, relationType, confidence, metadataJson, allowCyclic);
100
+ // 캐시 무효화
101
+ this.invalidateCache(sourceId);
102
+ this.invalidateCache(targetId);
103
+ return relationId;
104
+ }
105
+ /**
106
+ * 관계 추가 에러 처리
107
+ * 동시성 문제로 인한 UNIQUE constraint 에러를 처리합니다.
108
+ *
109
+ * @param error 발생한 에러
110
+ * @param sourceId 소스 기억 ID
111
+ * @param targetId 타겟 기억 ID
112
+ * @param relationType 관계 유형
113
+ * @param confidence 신뢰도
114
+ * @param metadata 메타데이터
115
+ * @param updateOnConflict 충돌 시 업데이트 여부
116
+ * @returns 관계 ID 또는 에러를 throw
117
+ */
118
+ async handleRelationAddError(error, sourceId, targetId, relationType, confidence, metadata, updateOnConflict) {
119
+ // 동시성 문제로 인한 UNIQUE constraint 에러 처리
120
+ // findExistingRelation과 insertNewRelation 사이에 다른 요청이 관계를 추가한 경우
121
+ // better-sqlite3는 SQLite 에러 코드를 제공하지 않으므로, 에러 메시지와 타입을 모두 확인
122
+ const isUniqueConstraintError = error instanceof Error &&
123
+ (error.message.includes('UNIQUE constraint') ||
124
+ error.message.includes('UNIQUE constraint failed') ||
125
+ error.code === 'SQLITE_CONSTRAINT_UNIQUE');
126
+ if (isUniqueConstraintError) {
127
+ // 기존 관계를 다시 확인하여 업데이트 처리
128
+ const existing = this.findExistingRelation(sourceId, targetId, relationType);
129
+ if (existing && updateOnConflict) {
130
+ return await this.handleExistingRelation(existing, sourceId, targetId, relationType, confidence, metadata, updateOnConflict);
131
+ }
132
+ throw new Error(`이미 존재하는 관계입니다: ${sourceId} -> ${targetId} (${relationType}). ` +
133
+ `동시성 문제로 인해 관계가 이미 추가되었습니다.`);
134
+ }
135
+ throw error;
136
+ }
137
+ /**
138
+ * 관계 입력 검증
139
+ *
140
+ * @param sourceId 소스 기억 ID
141
+ * @param targetId 타겟 기억 ID
142
+ */
143
+ validateRelationInput(sourceId, targetId) {
144
+ if (sourceId === targetId) {
145
+ throw new Error('자기 자신에 대한 관계는 생성할 수 없습니다.');
146
+ }
147
+ }
148
+ /**
149
+ * 순환 참조 확인
150
+ * 트랜잭션 내에서 호출되는 경우 중첩 트랜잭션을 방지하기 위해
151
+ * 트랜잭션 상태를 확인합니다.
152
+ *
153
+ * @param sourceId 소스 기억 ID
154
+ * @param targetId 타겟 기억 ID
155
+ * @param relationType 관계 유형
156
+ */
157
+ async checkForCyclicRelation(sourceId, targetId, relationType) {
158
+ // 트랜잭션 내에서 실행 중인 경우, detectCycleInternal을 트랜잭션 없이 호출
159
+ // addRelation이 이미 트랜잭션을 시작했으므로,
160
+ // 트랜잭션 없이 실행되어야 합니다
161
+ const isCyclic = await this.detectCycleInternal(sourceId, targetId, relationType);
162
+ if (isCyclic) {
163
+ throw new Error(`순환 참조가 감지되었습니다: ${sourceId} -> ${targetId} (${relationType})`);
164
+ }
165
+ }
166
+ /**
167
+ * 메타데이터 준비
168
+ *
169
+ * @param options 추가 옵션
170
+ * @param allowCyclic 순환 참조 허용 여부
171
+ * @returns 준비된 메타데이터
172
+ */
173
+ prepareMetadata(options, allowCyclic) {
174
+ return {
175
+ method: options?.metadata?.method,
176
+ extracted_at: options?.metadata?.extracted_at || new Date().toISOString(),
177
+ cyclic: allowCyclic ? true : undefined,
178
+ evidence: options?.metadata?.evidence,
179
+ ...options?.metadata
180
+ };
181
+ }
182
+ /**
183
+ * 기존 관계 조회
184
+ *
185
+ * @param sourceId 소스 기억 ID
186
+ * @param targetId 타겟 기억 ID
187
+ * @param relationType 관계 유형
188
+ * @returns 기존 관계 정보 또는 null
189
+ */
190
+ findExistingRelation(sourceId, targetId, relationType) {
191
+ const result = DatabaseUtils.get(this.db, `
192
+ SELECT id, confidence, metadata
193
+ FROM memory_relation
194
+ WHERE source_id = ? AND target_id = ? AND relation_type = ?
195
+ `, [sourceId, targetId, relationType]);
196
+ if (result === null || result === undefined) {
197
+ return null;
198
+ }
199
+ if (isExistingRelationRow(result)) {
200
+ return result;
201
+ }
202
+ // 타입 검증 실패 시 로깅하고 null 반환
203
+ logger.warn('기존 관계 조회 결과 타입 검증 실패', {
204
+ sourceId,
205
+ targetId,
206
+ relationType,
207
+ resultType: typeof result
208
+ });
209
+ return null;
210
+ }
211
+ /**
212
+ * 기존 관계 처리
213
+ *
214
+ * @param existing 기존 관계 정보
215
+ * @param sourceId 소스 기억 ID
216
+ * @param targetId 타겟 기억 ID
217
+ * @param relationType 관계 유형
218
+ * @param confidence 신뢰도
219
+ * @param metadata 메타데이터
220
+ * @param updateOnConflict 충돌 시 업데이트 여부
221
+ * @returns 관계 ID
222
+ */
223
+ async handleExistingRelation(existing, sourceId, targetId, relationType, confidence, metadata, updateOnConflict) {
224
+ if (!updateOnConflict) {
225
+ throw new Error(`이미 존재하는 관계입니다: ${sourceId} -> ${targetId} (${relationType})`);
226
+ }
227
+ // 기존 관계 업데이트
228
+ const oldConfidence = existing.confidence;
229
+ const oldMetadata = existing.metadata ? JSON.parse(existing.metadata) : {};
230
+ // 신뢰도 개선 이력 추가
231
+ const refinementHistory = oldMetadata.refinement_history || [];
232
+ refinementHistory.push({
233
+ timestamp: new Date().toISOString(),
234
+ old_confidence: oldConfidence,
235
+ new_confidence: confidence,
236
+ reason: '관계 추가 시 업데이트'
237
+ });
238
+ const updatedMetadata = {
239
+ ...oldMetadata,
240
+ ...metadata,
241
+ refinement_history: refinementHistory
242
+ };
243
+ DatabaseUtils.run(this.db, `
244
+ UPDATE memory_relation
245
+ SET confidence = ?,
246
+ metadata = ?,
247
+ updated_at = CURRENT_TIMESTAMP
248
+ WHERE id = ?
249
+ `, [confidence, JSON.stringify(updatedMetadata), existing.id]);
250
+ // 캐시 무효화
251
+ this.invalidateCache(sourceId);
252
+ this.invalidateCache(targetId);
253
+ return existing.id;
254
+ }
255
+ /**
256
+ * 새 관계 추가
257
+ *
258
+ * @param sourceId 소스 기억 ID
259
+ * @param targetId 타겟 기억 ID
260
+ * @param relationType 관계 유형
261
+ * @param confidence 신뢰도
262
+ * @param metadataJson 메타데이터 JSON 문자열
263
+ * @param allowCyclic 순환 참조 허용 여부
264
+ * @returns 추가된 관계 ID
265
+ */
266
+ async insertNewRelation(sourceId, targetId, relationType, confidence, metadataJson, allowCyclic) {
267
+ // 새 관계 추가
268
+ const result = DatabaseUtils.run(this.db, `
269
+ INSERT INTO memory_relation (
270
+ source_id, target_id, relation_type, confidence, metadata
271
+ )
272
+ VALUES (?, ?, ?, ?, ?)
273
+ `, [sourceId, targetId, relationType, confidence, metadataJson]);
274
+ const relationId = result.lastInsertRowid;
275
+ // 순환 참조 플래그 업데이트 (필요한 경우)
276
+ if (allowCyclic) {
277
+ await this.updateCyclicFlag(relationId);
278
+ }
279
+ return relationId;
280
+ }
281
+ /**
282
+ * 순환 참조 플래그 업데이트
283
+ *
284
+ * @param relationId 관계 ID
285
+ */
286
+ async updateCyclicFlag(relationId) {
287
+ // 기존 메타데이터 가져오기
288
+ const result = DatabaseUtils.get(this.db, `
289
+ SELECT metadata FROM memory_relation WHERE id = ?
290
+ `, [relationId]);
291
+ if (!result) {
292
+ logger.warn('순환 참조 플래그 업데이트 실패: 관계를 찾을 수 없습니다', { relationId });
293
+ return;
294
+ }
295
+ if (!isMetadataRow(result)) {
296
+ logger.warn('순환 참조 플래그 업데이트 실패: 타입 검증 실패', {
297
+ relationId,
298
+ resultType: typeof result
299
+ });
300
+ return;
301
+ }
302
+ const existing = result;
303
+ let updatedMetadata;
304
+ if (existing.metadata) {
305
+ updatedMetadata = JSON.parse(existing.metadata);
306
+ }
307
+ else {
308
+ updatedMetadata = {};
309
+ }
310
+ updatedMetadata.cyclic = true;
311
+ DatabaseUtils.run(this.db, `
312
+ UPDATE memory_relation
313
+ SET metadata = ?
314
+ WHERE id = ?
315
+ `, [JSON.stringify(updatedMetadata), relationId]);
316
+ }
317
+ /**
318
+ * 관계 조회
319
+ *
320
+ * @param memoryId 기억 ID
321
+ * @param options 조회 옵션
322
+ * @returns 관계 목록
323
+ */
324
+ async getRelations(memoryId, options) {
325
+ const direction = options?.direction ?? 'both';
326
+ const relationTypes = options?.relationTypes;
327
+ const minConfidence = options?.minConfidence;
328
+ const limit = options?.limit;
329
+ const offset = options?.offset ?? 0;
330
+ const bypassCache = options?.bypassCache ?? false;
331
+ // 캐시 우회 옵션이 활성화되지 않은 경우에만 캐시 확인
332
+ if (!bypassCache) {
333
+ // 캐시 키 생성
334
+ const cacheKey = this.generateCacheKey(memoryId, options);
335
+ // L1 캐시 확인
336
+ const l1Cached = this.l1Cache.get(cacheKey);
337
+ if (l1Cached) {
338
+ return l1Cached;
339
+ }
340
+ // L2 캐시 확인
341
+ const l2Cached = this.l2Cache.get(cacheKey);
342
+ if (l2Cached) {
343
+ // L1 캐시에도 저장
344
+ this.l1Cache.set(cacheKey, l2Cached);
345
+ return l2Cached;
346
+ }
347
+ }
348
+ // 데이터베이스에서 조회
349
+ let query = '';
350
+ const params = [];
351
+ if (direction === 'outgoing') {
352
+ query = 'SELECT * FROM memory_relation WHERE source_id = ?';
353
+ params.push(memoryId);
354
+ }
355
+ else if (direction === 'incoming') {
356
+ query = 'SELECT * FROM memory_relation WHERE target_id = ?';
357
+ params.push(memoryId);
358
+ }
359
+ else {
360
+ query = `
361
+ SELECT * FROM memory_relation
362
+ WHERE source_id = ? OR target_id = ?
363
+ `;
364
+ params.push(memoryId, memoryId);
365
+ }
366
+ // 관계 유형 필터
367
+ if (relationTypes && relationTypes.length > 0) {
368
+ const placeholders = relationTypes.map(() => '?').join(',');
369
+ query += ` AND relation_type IN (${placeholders})`;
370
+ params.push(...relationTypes);
371
+ }
372
+ // 최소 신뢰도 필터
373
+ if (minConfidence !== undefined) {
374
+ query += ' AND confidence >= ?';
375
+ params.push(minConfidence);
376
+ }
377
+ // 정렬 및 제한
378
+ query += ' ORDER BY confidence DESC, created_at DESC';
379
+ if (limit) {
380
+ query += ' LIMIT ?';
381
+ params.push(limit);
382
+ if (offset > 0) {
383
+ query += ' OFFSET ?';
384
+ params.push(offset);
385
+ }
386
+ }
387
+ const rows = DatabaseUtils.all(this.db, query, params);
388
+ // 타입 가드를 사용하여 안전하게 필터링
389
+ const validRows = rows.filter((row) => isRelationRow(row));
390
+ if (validRows.length !== rows.length) {
391
+ logger.warn('관계 조회 결과 일부 행의 타입 검증 실패', {
392
+ totalRows: rows.length,
393
+ validRows: validRows.length
394
+ });
395
+ }
396
+ const relations = validRows.map(row => ({
397
+ id: row.id,
398
+ source_id: row.source_id,
399
+ target_id: row.target_id,
400
+ relation_type: row.relation_type,
401
+ confidence: row.confidence,
402
+ created_at: new Date(row.created_at),
403
+ updated_at: new Date(row.updated_at),
404
+ metadata: row.metadata ? JSON.parse(row.metadata) : undefined
405
+ }));
406
+ // 캐시 우회 옵션이 활성화되지 않은 경우에만 캐시 저장
407
+ if (!bypassCache) {
408
+ const cacheKey = this.generateCacheKey(memoryId, options);
409
+ // 캐시 저장
410
+ this.l1Cache.set(cacheKey, relations);
411
+ this.l2Cache.set(cacheKey, relations);
412
+ // 캐시 키 인덱스에 추가
413
+ this.addCacheKeyToIndex(memoryId, cacheKey);
414
+ }
415
+ return relations;
416
+ }
417
+ /**
418
+ * 관련 기억 조회 (N-hop 관계 탐색)
419
+ * BFS 알고리즘을 사용하여 N-hop 관계를 탐색합니다.
420
+ *
421
+ * @param memoryId 시작 기억 ID
422
+ * @param options 탐색 옵션
423
+ * @returns 관련 기억 ID 목록과 hop 거리
424
+ */
425
+ async getRelatedMemories(memoryId, options) {
426
+ const maxHops = options?.maxHops ?? 2;
427
+ const relationTypes = options?.relationTypes;
428
+ const minConfidence = options?.minConfidence;
429
+ const limit = options?.limit;
430
+ const includeCyclic = options?.includeCyclic ?? false;
431
+ // BFS 탐색
432
+ const visited = new Set();
433
+ const queue = [];
434
+ const results = [];
435
+ // 배치 쿼리 최적화: 여러 노드의 관계를 한 번에 조회
436
+ // 노드별 관계를 캐싱하여 중복 쿼리 방지
437
+ const nodeRelationsCache = new Map();
438
+ // 시작 노드
439
+ queue.push({
440
+ memory_id: memoryId,
441
+ hop_distance: 0,
442
+ relation_path: []
443
+ });
444
+ visited.add(memoryId);
445
+ while (queue.length > 0) {
446
+ const current = queue.shift();
447
+ if (current.hop_distance > 0) {
448
+ // 시작 노드가 아닌 경우 결과에 추가
449
+ results.push(current);
450
+ if (limit && results.length >= limit) {
451
+ break;
452
+ }
453
+ }
454
+ if (current.hop_distance >= maxHops) {
455
+ continue;
456
+ }
457
+ // 배치 쿼리 최적화: 현재 레벨의 모든 노드 관계를 한 번에 조회
458
+ // 같은 hop_distance를 가진 노드들의 관계를 배치로 조회하여 I/O 오버헤드 감소
459
+ const currentLevelNodes = queue.filter(n => n.hop_distance === current.hop_distance);
460
+ const nodesToQuery = [current.memory_id, ...currentLevelNodes.map(n => n.memory_id)]
461
+ .filter(id => !nodeRelationsCache.has(id));
462
+ if (nodesToQuery.length > 0) {
463
+ // 배치 쿼리: 여러 노드의 관계를 한 번에 조회
464
+ const placeholders = nodesToQuery.map(() => '?').join(',');
465
+ let batchQuery = `
466
+ SELECT * FROM memory_relation
467
+ WHERE (source_id IN (${placeholders}) OR target_id IN (${placeholders}))
468
+ `;
469
+ const params = [
470
+ ...nodesToQuery,
471
+ ...nodesToQuery
472
+ ];
473
+ // 관계 유형 필터 (경로의 모든 관계에 적용)
474
+ // 주의: relationTypes 필터는 경로의 모든 관계에 적용되므로,
475
+ // 경로에 다른 관계 유형이 포함되면 필터링됩니다
476
+ if (relationTypes && relationTypes.length > 0) {
477
+ const typePlaceholders = relationTypes.map(() => '?').join(',');
478
+ batchQuery += ` AND relation_type IN (${typePlaceholders})`;
479
+ params.push(...relationTypes);
480
+ }
481
+ // 최소 신뢰도 필터
482
+ if (minConfidence !== undefined) {
483
+ batchQuery += ' AND confidence >= ?';
484
+ params.push(minConfidence);
485
+ }
486
+ batchQuery += ' ORDER BY confidence DESC';
487
+ const batchRows = DatabaseUtils.all(this.db, batchQuery, params);
488
+ const validRows = batchRows.filter((row) => isRelationRow(row));
489
+ // 노드별로 관계 분류하여 캐시에 저장
490
+ for (const nodeId of nodesToQuery) {
491
+ const nodeRelations = validRows
492
+ .filter(row => row.source_id === nodeId || row.target_id === nodeId)
493
+ .map(row => ({
494
+ id: row.id,
495
+ source_id: row.source_id,
496
+ target_id: row.target_id,
497
+ relation_type: row.relation_type,
498
+ confidence: row.confidence,
499
+ created_at: new Date(row.created_at),
500
+ updated_at: new Date(row.updated_at),
501
+ metadata: row.metadata ? JSON.parse(row.metadata) : undefined
502
+ }));
503
+ nodeRelationsCache.set(nodeId, nodeRelations);
504
+ }
505
+ }
506
+ // 캐시에서 현재 노드의 관계 가져오기
507
+ const relations = nodeRelationsCache.get(current.memory_id) || [];
508
+ for (const relation of relations) {
509
+ const nextId = relation.source_id === current.memory_id
510
+ ? relation.target_id
511
+ : relation.source_id;
512
+ // 순환 참조 처리
513
+ if (!includeCyclic && relation.metadata?.cyclic) {
514
+ continue;
515
+ }
516
+ // 방문하지 않은 노드만 큐에 추가
517
+ if (!visited.has(nextId)) {
518
+ visited.add(nextId);
519
+ const nextPath = [...current.relation_path];
520
+ if (relation.source_id === current.memory_id) {
521
+ nextPath.push({
522
+ source_id: relation.source_id,
523
+ target_id: relation.target_id,
524
+ relation_type: relation.relation_type
525
+ });
526
+ }
527
+ else {
528
+ nextPath.push({
529
+ source_id: relation.target_id,
530
+ target_id: relation.source_id,
531
+ relation_type: relation.relation_type
532
+ });
533
+ }
534
+ queue.push({
535
+ memory_id: nextId,
536
+ hop_distance: current.hop_distance + 1,
537
+ relation_path: nextPath
538
+ });
539
+ }
540
+ }
541
+ }
542
+ return results;
543
+ }
544
+ /**
545
+ * 관계 삭제
546
+ *
547
+ * @param sourceId 소스 기억 ID
548
+ * @param targetId 타겟 기억 ID
549
+ * @param relationType 관계 유형
550
+ * @returns 삭제 성공 여부
551
+ */
552
+ async removeRelation(sourceId, targetId, relationType) {
553
+ const result = DatabaseUtils.run(this.db, `
554
+ DELETE FROM memory_relation
555
+ WHERE source_id = ? AND target_id = ? AND relation_type = ?
556
+ `, [sourceId, targetId, relationType]);
557
+ if (result.changes > 0) {
558
+ // 캐시 무효화
559
+ this.invalidateCache(sourceId);
560
+ this.invalidateCache(targetId);
561
+ return true;
562
+ }
563
+ return false;
564
+ }
565
+ /**
566
+ * 신뢰도 갱신
567
+ *
568
+ * @param sourceId 소스 기억 ID
569
+ * @param targetId 타겟 기억 ID
570
+ * @param relationType 관계 유형
571
+ * @param newConfidence 새로운 신뢰도
572
+ * @param reason 갱신 이유
573
+ * @returns 갱신 성공 여부
574
+ */
575
+ async updateConfidence(sourceId, targetId, relationType, newConfidence, reason) {
576
+ // 기존 관계 조회
577
+ const queryResult = DatabaseUtils.get(this.db, `
578
+ SELECT id, confidence, metadata
579
+ FROM memory_relation
580
+ WHERE source_id = ? AND target_id = ? AND relation_type = ?
581
+ `, [sourceId, targetId, relationType]);
582
+ if (!queryResult) {
583
+ return false;
584
+ }
585
+ // 타입 가드를 사용하여 안전하게 타입 검증
586
+ if (!isExistingRelationRow(queryResult)) {
587
+ logger.warn('신뢰도 갱신: 타입 검증 실패', {
588
+ sourceId,
589
+ targetId,
590
+ relationType,
591
+ resultType: typeof queryResult
592
+ });
593
+ return false;
594
+ }
595
+ const existing = queryResult;
596
+ const oldConfidence = existing.confidence;
597
+ const oldMetadata = existing.metadata ? JSON.parse(existing.metadata) : {};
598
+ // 신뢰도 개선 이력 추가
599
+ const refinementHistory = oldMetadata.refinement_history || [];
600
+ refinementHistory.push({
601
+ timestamp: new Date().toISOString(),
602
+ old_confidence: oldConfidence,
603
+ new_confidence: newConfidence,
604
+ reason: reason || '신뢰도 갱신'
605
+ });
606
+ const updatedMetadata = {
607
+ ...oldMetadata,
608
+ refinement_history: refinementHistory
609
+ };
610
+ const updateResult = DatabaseUtils.run(this.db, `
611
+ UPDATE memory_relation
612
+ SET confidence = ?,
613
+ metadata = ?,
614
+ updated_at = CURRENT_TIMESTAMP
615
+ WHERE id = ?
616
+ `, [newConfidence, JSON.stringify(updatedMetadata), existing.id]);
617
+ if (updateResult.changes > 0) {
618
+ // 캐시 무효화
619
+ this.invalidateCache(sourceId);
620
+ this.invalidateCache(targetId);
621
+ return true;
622
+ }
623
+ return false;
624
+ }
625
+ /**
626
+ * 순환 참조 감지 (DFS)
627
+ *
628
+ * 트랜잭션 내에서 실행하여 경쟁 조건을 방지합니다.
629
+ * 순환 참조 감지 중에 다른 프로세스/스레드에서 관계가 추가되는 것을 방지하기 위해
630
+ * 트랜잭션 격리 수준을 사용합니다.
631
+ *
632
+ * 성능 최적화:
633
+ * - 최대 탐색 깊이 제한 (기본값: 10)
634
+ * - 대규모 그래프에서 무한 루프 방지
635
+ *
636
+ * @param sourceId 소스 기억 ID
637
+ * @param targetId 타겟 기억 ID
638
+ * @param relationType 관계 유형
639
+ * @param maxDepth 최대 탐색 깊이 (기본값: 10)
640
+ * @returns 순환 참조 여부
641
+ */
642
+ /**
643
+ * 순환 참조 감지 내부 로직 (트랜잭션 없이 실행)
644
+ *
645
+ * @param sourceId 소스 기억 ID
646
+ * @param targetId 타겟 기억 ID
647
+ * @param relationType 관계 유형
648
+ * @param maxDepth 최대 탐색 깊이
649
+ * @returns 순환 참조 여부
650
+ */
651
+ async detectCycleInternal(sourceId, targetId, relationType, maxDepth = LIMITS.MAX_CYCLE_DEPTH) {
652
+ // 자기 자신에 대한 관계는 순환 참조가 아님
653
+ if (sourceId === targetId) {
654
+ return false;
655
+ }
656
+ // DFS로 순환 참조 탐색
657
+ // targetId에서 sourceId로 가는 경로가 있는지 확인
658
+ const visited = new Set();
659
+ // 배치 쿼리 최적화: 여러 노드의 관계를 한 번에 조회
660
+ // 현재 탐색 중인 노드들의 관계를 배치로 조회하여 I/O 오버헤드 감소
661
+ // 노드별 관계를 캐싱하여 중복 쿼리 방지
662
+ const nodeRelations = new Map();
663
+ const dfs = async (currentId, target, depth) => {
664
+ // 최대 탐색 깊이 제한 (무한 루프 방지)
665
+ if (depth > maxDepth) {
666
+ logger.warn('순환 참조 감지: 최대 탐색 깊이 초과', {
667
+ sourceId,
668
+ targetId,
669
+ currentId,
670
+ depth,
671
+ maxDepth
672
+ });
673
+ return false;
674
+ }
675
+ if (currentId === target) {
676
+ return true; // 순환 참조 발견
677
+ }
678
+ if (visited.has(currentId)) {
679
+ return false; // 이미 방문한 노드 (순환 경로가 아님)
680
+ }
681
+ visited.add(currentId);
682
+ // 배치 쿼리 최적화: 캐시에 없는 경우에만 배치로 조회
683
+ let targetIds = [];
684
+ if (nodeRelations.has(currentId)) {
685
+ targetIds = nodeRelations.get(currentId);
686
+ }
687
+ else {
688
+ // 현재 노드에서 나가는 관계 조회 (캐시 우회를 위해 직접 쿼리)
689
+ // 캐시를 사용하면 새로 추가된 관계를 놓칠 수 있으므로 직접 쿼리
690
+ // 트랜잭션 내에서 실행되므로 일관된 스냅샷을 보장
691
+ const rows = DatabaseUtils.all(this.db, `
692
+ SELECT target_id
693
+ FROM memory_relation
694
+ WHERE source_id = ? AND relation_type = ?
695
+ `, [currentId, relationType]);
696
+ // 타입 검증 및 target_id 추출
697
+ for (const row of rows) {
698
+ if (typeof row === 'object' && row !== null && 'target_id' in row) {
699
+ const targetIdValue = row.target_id;
700
+ if (typeof targetIdValue === 'string') {
701
+ targetIds.push(targetIdValue);
702
+ }
703
+ }
704
+ }
705
+ // 캐시에 저장하여 중복 쿼리 방지
706
+ nodeRelations.set(currentId, targetIds);
707
+ }
708
+ // 재귀적으로 다음 노드 탐색
709
+ for (const nextId of targetIds) {
710
+ if (await dfs(nextId, target, depth + 1)) {
711
+ return true;
712
+ }
713
+ }
714
+ return false;
715
+ };
716
+ // targetId에서 sourceId로 가는 경로가 있는지 확인
717
+ return await dfs(targetId, sourceId, 0);
718
+ }
719
+ /**
720
+ * 순환 참조 감지 (공개 메서드)
721
+ * 트랜잭션을 자동으로 관리합니다.
722
+ *
723
+ * @param sourceId 소스 기억 ID
724
+ * @param targetId 타겟 기억 ID
725
+ * @param relationType 관계 유형
726
+ * @param maxDepth 최대 탐색 깊이 (기본값: 10)
727
+ * @returns 순환 참조 여부
728
+ */
729
+ async detectCycle(sourceId, targetId, relationType, maxDepth = LIMITS.MAX_CYCLE_DEPTH) {
730
+ // 트랜잭션 내에서 순환 참조 감지를 수행하여 경쟁 조건 방지
731
+ // BEGIN IMMEDIATE TRANSACTION을 사용하여 배타적 락을 획득
732
+ // 이미 트랜잭션이 시작된 경우 중첩 트랜잭션을 방지하기 위해
733
+ // 트랜잭션 상태를 확인하여 적절히 처리
734
+ if (DatabaseUtils.isInTransaction(this.db)) {
735
+ // 트랜잭션이 이미 시작된 경우 트랜잭션 없이 실행
736
+ return await this.detectCycleInternal(sourceId, targetId, relationType, maxDepth);
737
+ }
738
+ // 트랜잭션이 시작되지 않은 경우 트랜잭션 내에서 실행
739
+ return await DatabaseUtils.runTransaction(this.db, async () => {
740
+ return await this.detectCycleInternal(sourceId, targetId, relationType, maxDepth);
741
+ });
742
+ }
743
+ /**
744
+ * 캐시 키 생성
745
+ * 공통 유틸리티를 사용하여 일관된 캐시 키를 생성합니다.
746
+ *
747
+ * @param memoryId 기억 ID
748
+ * @param options 조회 옵션
749
+ * @returns 캐시 키
750
+ */
751
+ generateCacheKey(memoryId, options) {
752
+ return CacheKeyGenerator.generateRelationGraphKey(memoryId, {
753
+ direction: options?.direction,
754
+ relationTypes: options?.relationTypes,
755
+ minConfidence: options?.minConfidence,
756
+ limit: options?.limit,
757
+ offset: options?.offset
758
+ });
759
+ }
760
+ /**
761
+ * 캐시 키를 인덱스에 추가
762
+ * 정확한 캐시 무효화를 위해 사용
763
+ *
764
+ * @param memoryId 기억 ID
765
+ * @param cacheKey 캐시 키
766
+ */
767
+ addCacheKeyToIndex(memoryId, cacheKey) {
768
+ if (!this.cacheKeyIndex.has(memoryId)) {
769
+ this.cacheKeyIndex.set(memoryId, new Set());
770
+ }
771
+ this.cacheKeyIndex.get(memoryId).add(cacheKey);
772
+ }
773
+ /**
774
+ * 캐시 무효화
775
+ * 특정 메모리 ID와 관련된 캐시를 정확하게 무효화합니다.
776
+ *
777
+ * 최적화 전략:
778
+ * 1. 캐시 키 인덱스를 우선 사용하여 O(1) 접근으로 정확한 캐시 키 삭제
779
+ * 2. 인덱스가 없는 경우에만 패턴 기반 삭제 수행 (fallback)
780
+ *
781
+ * 성능 고려사항:
782
+ * - 인덱스가 있는 경우: O(n) where n = 해당 memoryId의 캐시 키 수
783
+ * - 인덱스가 없는 경우: O(m) where m = 전체 캐시 키 수 (최악의 경우)
784
+ * - 일반적으로 인덱스가 있으므로 효율적
785
+ *
786
+ * @param memoryId 기억 ID
787
+ */
788
+ invalidateCache(memoryId) {
789
+ // 캐시 키 인덱스에서 해당 memoryId의 모든 캐시 키 가져오기
790
+ const cacheKeys = this.cacheKeyIndex.get(memoryId);
791
+ if (cacheKeys && cacheKeys.size > 0) {
792
+ // 인덱스에 등록된 모든 캐시 키 삭제 (효율적)
793
+ for (const cacheKey of cacheKeys) {
794
+ this.l1Cache.delete(cacheKey);
795
+ this.l2Cache.delete(cacheKey);
796
+ }
797
+ // 인덱스에서 memoryId 제거
798
+ this.cacheKeyIndex.delete(memoryId);
799
+ return; // 인덱스가 있으면 패턴 기반 삭제 불필요
800
+ }
801
+ // 인덱스에 없는 경우를 대비한 fallback: 패턴 기반 삭제
802
+ // (이전에 생성된 캐시나 인덱스가 없던 시점의 캐시 처리)
803
+ // 주의: keys() 메서드는 모든 키를 반환하므로 캐시가 많을 경우 성능 저하 가능
804
+ // 하지만 일반적으로 인덱스가 있으므로 이 경로는 거의 실행되지 않음
805
+ const cacheKeyPrefix = `relation_graph:${memoryId}:`;
806
+ const allL1Keys = this.l1Cache.keys();
807
+ const allL2Keys = this.l2Cache.keys();
808
+ for (const key of allL1Keys) {
809
+ if (key.startsWith(cacheKeyPrefix)) {
810
+ this.l1Cache.delete(key);
811
+ }
812
+ }
813
+ for (const key of allL2Keys) {
814
+ if (key.startsWith(cacheKeyPrefix)) {
815
+ this.l2Cache.delete(key);
816
+ }
817
+ }
818
+ }
819
+ /**
820
+ * 배치 관계 추가
821
+ * 여러 관계를 한 번에 추가하여 성능을 최적화합니다.
822
+ *
823
+ * @param relations 추가할 관계 목록
824
+ * @returns 배치 처리 결과 (성공한 관계 ID 목록 및 실패한 관계 정보)
825
+ */
826
+ async addRelationsBatch(relations) {
827
+ const insertedIds = [];
828
+ const failed = [];
829
+ // 트랜잭션으로 배치 처리
830
+ await DatabaseUtils.runTransaction(this.db, async () => {
831
+ for (const relation of relations) {
832
+ try {
833
+ const id = await this.addRelation(relation.source_id, relation.target_id, relation.relation_type, {
834
+ confidence: relation.confidence,
835
+ metadata: relation.metadata,
836
+ updateOnConflict: true,
837
+ allowCyclic: false
838
+ });
839
+ insertedIds.push(id);
840
+ }
841
+ catch (error) {
842
+ // 실패한 관계를 추적하여 결과에 포함
843
+ const errorMessage = error instanceof Error ? error.message : String(error);
844
+ failed.push({
845
+ source_id: relation.source_id,
846
+ target_id: relation.target_id,
847
+ relation_type: relation.relation_type,
848
+ error: errorMessage
849
+ });
850
+ logger.warn('관계 추가 실패', {
851
+ source_id: relation.source_id,
852
+ target_id: relation.target_id,
853
+ relation_type: relation.relation_type,
854
+ error: errorMessage
855
+ });
856
+ }
857
+ }
858
+ return { insertedIds, failed };
859
+ });
860
+ return {
861
+ insertedIds,
862
+ failed,
863
+ total: relations.length,
864
+ success: insertedIds.length,
865
+ failedCount: failed.length
866
+ };
867
+ }
868
+ }
869
+ //# sourceMappingURL=relation-graph.js.map