memento-mcp-server 1.15.0 → 1.16.0-b

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 (61) hide show
  1. package/dist/domains/relation/tools/visualize-relations-tool.d.ts +2 -2
  2. package/dist/infrastructure/database/database/migration/migrations/009-quality-assurance-schema.d.ts +60 -0
  3. package/dist/infrastructure/database/database/migration/migrations/009-quality-assurance-schema.d.ts.map +1 -0
  4. package/dist/infrastructure/database/database/migration/migrations/009-quality-assurance-schema.js +276 -0
  5. package/dist/infrastructure/database/database/migration/migrations/009-quality-assurance-schema.js.map +1 -0
  6. package/dist/infrastructure/database/database/migration/migrations/009-quality-assurance-schema.sql +128 -0
  7. package/dist/infrastructure/scheduler/batch-scheduler.d.ts +17 -0
  8. package/dist/infrastructure/scheduler/batch-scheduler.d.ts.map +1 -1
  9. package/dist/infrastructure/scheduler/batch-scheduler.js +130 -0
  10. package/dist/infrastructure/scheduler/batch-scheduler.js.map +1 -1
  11. package/dist/infrastructure/scheduler/jobs/quality-measurement-batch-job.d.ts +108 -0
  12. package/dist/infrastructure/scheduler/jobs/quality-measurement-batch-job.d.ts.map +1 -0
  13. package/dist/infrastructure/scheduler/jobs/quality-measurement-batch-job.js +184 -0
  14. package/dist/infrastructure/scheduler/jobs/quality-measurement-batch-job.js.map +1 -0
  15. package/dist/server/http-server.d.ts.map +1 -1
  16. package/dist/server/http-server.js +3 -0
  17. package/dist/server/http-server.js.map +1 -1
  18. package/dist/server/routes/quality.routes.d.ts +14 -0
  19. package/dist/server/routes/quality.routes.d.ts.map +1 -0
  20. package/dist/server/routes/quality.routes.js +460 -0
  21. package/dist/server/routes/quality.routes.js.map +1 -0
  22. package/dist/services/quality-assurance/quality-assurance-service.d.ts +207 -0
  23. package/dist/services/quality-assurance/quality-assurance-service.d.ts.map +1 -0
  24. package/dist/services/quality-assurance/quality-assurance-service.js +247 -0
  25. package/dist/services/quality-assurance/quality-assurance-service.js.map +1 -0
  26. package/dist/services/quality-assurance/quality-evaluator.d.ts +163 -0
  27. package/dist/services/quality-assurance/quality-evaluator.d.ts.map +1 -0
  28. package/dist/services/quality-assurance/quality-evaluator.js +256 -0
  29. package/dist/services/quality-assurance/quality-evaluator.js.map +1 -0
  30. package/dist/services/quality-assurance/quality-metrics-collector.d.ts +221 -0
  31. package/dist/services/quality-assurance/quality-metrics-collector.d.ts.map +1 -0
  32. package/dist/services/quality-assurance/quality-metrics-collector.js +796 -0
  33. package/dist/services/quality-assurance/quality-metrics-collector.js.map +1 -0
  34. package/dist/services/quality-assurance/quality-recorder.d.ts +108 -0
  35. package/dist/services/quality-assurance/quality-recorder.d.ts.map +1 -0
  36. package/dist/services/quality-assurance/quality-recorder.js +281 -0
  37. package/dist/services/quality-assurance/quality-recorder.js.map +1 -0
  38. package/dist/services/quality-assurance/quality-reporter.d.ts +189 -0
  39. package/dist/services/quality-assurance/quality-reporter.d.ts.map +1 -0
  40. package/dist/services/quality-assurance/quality-reporter.js +558 -0
  41. package/dist/services/quality-assurance/quality-reporter.js.map +1 -0
  42. package/dist/services/quality-assurance/quality-threshold-manager.d.ts +102 -0
  43. package/dist/services/quality-assurance/quality-threshold-manager.d.ts.map +1 -0
  44. package/dist/services/quality-assurance/quality-threshold-manager.js +252 -0
  45. package/dist/services/quality-assurance/quality-threshold-manager.js.map +1 -0
  46. package/dist/shared/utils/database.d.ts +7 -0
  47. package/dist/shared/utils/database.d.ts.map +1 -1
  48. package/dist/shared/utils/database.js +24 -0
  49. package/dist/shared/utils/database.js.map +1 -1
  50. package/dist/test/helpers/search-quality-metrics.d.ts +96 -0
  51. package/dist/test/helpers/search-quality-metrics.d.ts.map +1 -0
  52. package/dist/test/helpers/search-quality-metrics.js +185 -0
  53. package/dist/test/helpers/search-quality-metrics.js.map +1 -0
  54. package/dist/test/helpers/vector-search-quality-metrics.d.ts +1287 -0
  55. package/dist/test/helpers/vector-search-quality-metrics.d.ts.map +1 -0
  56. package/dist/test/helpers/vector-search-quality-metrics.js +2214 -0
  57. package/dist/test/helpers/vector-search-quality-metrics.js.map +1 -0
  58. package/package.json +5 -1
  59. package/scripts/generate-ground-truth.ts +353 -0
  60. package/scripts/quality-report.ts +166 -0
  61. package/scripts/quality-thresholds.ts +279 -0
@@ -0,0 +1,796 @@
1
+ /**
2
+ * Quality Metrics Collector
3
+ *
4
+ * 품질 지표 수집 서비스
5
+ *
6
+ * 주요 기능:
7
+ * - 기존 품질 검증 시스템들을 호출하여 지표 수집
8
+ * - namespace 단위 수집 메서드 제공 (search, relation, consolidation, storage)
9
+ * - 수집된 지표를 구조화된 형태로 반환
10
+ *
11
+ * PRD FR-1.1: Collector 역할 - 품질 지표 수집 (기존 검증 시스템 호출)
12
+ */
13
+ import Database from 'better-sqlite3';
14
+ import { logger } from '../../shared/utils/logger.js';
15
+ import { calculatePrecisionAtK, calculateRecallAtK, calculateNDCGAtK } from '../../test/helpers/search-quality-metrics.js';
16
+ import { calculateKendallTau, generateOrderPreservationReport, loadGroundTruth } from '../../test/helpers/vector-search-quality-metrics.js';
17
+ import { RelationQualityValidator } from '../../domains/relation/services/relation-quality-validator.js';
18
+ import { HybridSearchFactory } from '../../domains/search/factories/hybrid-search.factory.js';
19
+ /**
20
+ * Quality Metrics Collector
21
+ *
22
+ * PRD FR-1.1: Collector 역할 - 품질 지표 수집 (기존 검증 시스템 호출)
23
+ */
24
+ export class QualityMetricsCollector {
25
+ db;
26
+ constructor(db) {
27
+ this.db = db;
28
+ if (!db) {
29
+ throw new Error('Database instance is required');
30
+ }
31
+ }
32
+ /**
33
+ * MRR (Mean Reciprocal Rank) 계산
34
+ * 첫 번째 관련 결과의 역순위의 평균
35
+ *
36
+ * @param queryResults 쿼리별 검색 결과
37
+ * @param groundTruths 쿼리별 Ground Truth
38
+ * @returns MRR (0-1)
39
+ */
40
+ calculateMRR(queryResults, groundTruths) {
41
+ if (groundTruths.length === 0)
42
+ return 0;
43
+ let sumReciprocalRank = 0;
44
+ let validQueries = 0;
45
+ for (const groundTruth of groundTruths) {
46
+ const results = queryResults.get(groundTruth.queryId);
47
+ if (!results || results.length === 0)
48
+ continue;
49
+ const relevantSet = new Set(groundTruth.relevantIds);
50
+ let firstRelevantRank = -1;
51
+ for (let i = 0; i < results.length; i++) {
52
+ const result = results[i];
53
+ if (result && relevantSet.has(result.id)) {
54
+ firstRelevantRank = i + 1; // 1-based rank
55
+ break;
56
+ }
57
+ }
58
+ if (firstRelevantRank > 0) {
59
+ sumReciprocalRank += 1 / firstRelevantRank;
60
+ validQueries++;
61
+ }
62
+ }
63
+ return validQueries > 0 ? sumReciprocalRank / groundTruths.length : 0;
64
+ }
65
+ /**
66
+ * 검색 품질 지표 수집
67
+ *
68
+ * PRD FR-2.1: 검색 품질 지표 정의
69
+ * - Precision@K, Recall@K, NDCG@K, MRR, Kendall's Tau 등
70
+ *
71
+ * @param context - 컨텍스트 (기본값: 'default')
72
+ * @param options - 옵션 (Ground Truth 데이터, 검색 결과 등)
73
+ * @returns 검색 품질 지표
74
+ *
75
+ * Note: Ground Truth 데이터가 제공되면 실제 측정을 수행하고,
76
+ * 없으면 기본값(0)을 반환합니다.
77
+ */
78
+ async collectSearchMetrics(context = 'default', options) {
79
+ const metrics = {};
80
+ // Ground Truth 자동 로드 (옵션에 없으면 파일에서 로드 시도)
81
+ let groundTruths = options?.groundTruths;
82
+ if (!groundTruths || groundTruths.length === 0) {
83
+ try {
84
+ const loaded = loadGroundTruth();
85
+ if (loaded && loaded.length > 0) {
86
+ groundTruths = loaded;
87
+ logger.info('Ground Truth 자동 로드 완료', {
88
+ context,
89
+ count: groundTruths.length
90
+ });
91
+ }
92
+ }
93
+ catch (error) {
94
+ logger.warn('Ground Truth 파일 로드 실패', {
95
+ context,
96
+ error: error instanceof Error ? error.message : String(error)
97
+ });
98
+ }
99
+ }
100
+ // 검색 결과 자동 생성 (옵션에 없고 Ground Truth가 있으면 실제 검색 수행)
101
+ let queryResults = options?.queryResults;
102
+ if (!queryResults && groundTruths && groundTruths.length > 0) {
103
+ try {
104
+ queryResults = new Map();
105
+ const searchEngine = HybridSearchFactory.createDefaultEngine(this.db);
106
+ for (const groundTruth of groundTruths) {
107
+ try {
108
+ const searchQuery = {
109
+ query: groundTruth.queryId,
110
+ limit: 20 // Ground Truth 비교를 위해 충분한 결과 수 확보
111
+ };
112
+ const searchResult = await searchEngine.search(this.db, searchQuery);
113
+ // HybridSearchResult를 SearchResult로 변환
114
+ const results = searchResult.items.map(item => ({
115
+ id: item.id,
116
+ score: item.finalScore
117
+ }));
118
+ queryResults.set(groundTruth.queryId, results);
119
+ logger.debug('검색 수행 완료', {
120
+ context,
121
+ query: groundTruth.queryId,
122
+ resultCount: results.length
123
+ });
124
+ }
125
+ catch (error) {
126
+ logger.warn('검색 수행 실패', {
127
+ context,
128
+ query: groundTruth.queryId,
129
+ error: error instanceof Error ? error.message : String(error)
130
+ });
131
+ // 검색 실패 시 빈 결과 추가
132
+ queryResults.set(groundTruth.queryId, []);
133
+ }
134
+ }
135
+ logger.info('검색 결과 자동 생성 완료', {
136
+ context,
137
+ queryCount: queryResults.size
138
+ });
139
+ }
140
+ catch (error) {
141
+ logger.warn('검색 엔진 초기화 또는 검색 수행 실패', {
142
+ context,
143
+ error: error instanceof Error ? error.message : String(error)
144
+ });
145
+ queryResults = new Map();
146
+ }
147
+ }
148
+ // Ground Truth와 검색 결과가 제공된 경우 실제 측정 수행
149
+ if (groundTruths && queryResults && groundTruths.length > 0) {
150
+ // groundTruths와 queryResults는 위에서 로드/생성됨
151
+ // Precision@K, Recall@K, NDCG@K 계산
152
+ const kValues = [5, 10];
153
+ for (const k of kValues) {
154
+ let sumPrecision = 0;
155
+ let sumRecall = 0;
156
+ let sumNDCG = 0;
157
+ let validQueries = 0;
158
+ for (const groundTruth of groundTruths) {
159
+ const results = queryResults.get(groundTruth.queryId);
160
+ if (!results || results.length === 0)
161
+ continue;
162
+ const precision = calculatePrecisionAtK(results, groundTruth.relevantIds, k);
163
+ const recall = calculateRecallAtK(results, groundTruth.relevantIds, k);
164
+ const ndcg = calculateNDCGAtK(results, groundTruth.relevantIds, k);
165
+ sumPrecision += precision;
166
+ sumRecall += recall;
167
+ sumNDCG += ndcg;
168
+ validQueries++;
169
+ }
170
+ if (validQueries > 0) {
171
+ metrics[`precision_at_${k}`] = sumPrecision / validQueries;
172
+ metrics[`recall_at_${k}`] = sumRecall / validQueries;
173
+ metrics[`ndcg_at_${k}`] = sumNDCG / validQueries;
174
+ }
175
+ else {
176
+ metrics[`precision_at_${k}`] = 0;
177
+ metrics[`recall_at_${k}`] = 0;
178
+ metrics[`ndcg_at_${k}`] = 0;
179
+ }
180
+ }
181
+ // MRR 계산
182
+ metrics.mrr = this.calculateMRR(queryResults, groundTruths);
183
+ // Kendall's Tau 및 순서 보존 지표 계산
184
+ if (options?.searchResultPairs && options.searchResultPairs.length > 0) {
185
+ let sumKendallTau = 0;
186
+ let sumTop5Retention = 0;
187
+ let sumTop10Retention = 0;
188
+ let validPairs = 0;
189
+ for (const pair of options.searchResultPairs) {
190
+ const vectorIds = pair.vectorOnly.map(r => r.id);
191
+ const consolidationIds = pair.withConsolidation.map(r => r.id);
192
+ const kendallTau = calculateKendallTau(vectorIds, consolidationIds);
193
+ sumKendallTau += kendallTau;
194
+ // Top-K 유지율 계산
195
+ const top5Vector = new Set(vectorIds.slice(0, 5));
196
+ const top10Vector = new Set(vectorIds.slice(0, 10));
197
+ const top5Consolidation = new Set(consolidationIds.slice(0, 5));
198
+ const top10Consolidation = new Set(consolidationIds.slice(0, 10));
199
+ const top5Retention = Array.from(top5Vector).filter(id => top5Consolidation.has(id)).length / 5;
200
+ const top10Retention = Array.from(top10Vector).filter(id => top10Consolidation.has(id)).length / 10;
201
+ sumTop5Retention += top5Retention;
202
+ sumTop10Retention += top10Retention;
203
+ validPairs++;
204
+ }
205
+ if (validPairs > 0) {
206
+ metrics.kendalls_tau = sumKendallTau / validPairs;
207
+ metrics.top_5_retention = sumTop5Retention / validPairs;
208
+ metrics.top_10_retention = sumTop10Retention / validPairs;
209
+ }
210
+ else {
211
+ metrics.kendalls_tau = 0;
212
+ metrics.top_5_retention = 0;
213
+ metrics.top_10_retention = 0;
214
+ }
215
+ }
216
+ else {
217
+ metrics.kendalls_tau = 0;
218
+ metrics.top_5_retention = 0;
219
+ metrics.top_10_retention = 0;
220
+ }
221
+ logger.info('검색 품질 지표 수집 완료', {
222
+ context,
223
+ metrics_count: Object.keys(metrics).length,
224
+ ground_truth_count: groundTruths.length
225
+ });
226
+ }
227
+ else {
228
+ // Ground Truth가 없으면 기본값 반환
229
+ metrics.precision_at_5 = 0;
230
+ metrics.precision_at_10 = 0;
231
+ metrics.recall_at_5 = 0;
232
+ metrics.recall_at_10 = 0;
233
+ metrics.ndcg_at_5 = 0;
234
+ metrics.ndcg_at_10 = 0;
235
+ metrics.mrr = 0;
236
+ metrics.kendalls_tau = 0;
237
+ metrics.top_5_retention = 0;
238
+ metrics.top_10_retention = 0;
239
+ logger.info('검색 품질 지표 수집 완료 (기본값)', {
240
+ context,
241
+ note: 'Ground Truth 데이터가 없어 기본값을 반환했습니다. 실제 측정을 위해서는 Ground Truth 데이터가 필요합니다.'
242
+ });
243
+ }
244
+ return {
245
+ namespace: 'search',
246
+ context,
247
+ measured_at: new Date().toISOString(),
248
+ metrics,
249
+ metadata: {
250
+ has_ground_truth: groundTruths !== undefined && (groundTruths.length > 0),
251
+ ground_truth_count: groundTruths?.length || 0,
252
+ auto_loaded: !options?.groundTruths && groundTruths !== undefined,
253
+ auto_searched: !options?.queryResults && queryResults !== undefined
254
+ }
255
+ };
256
+ }
257
+ /**
258
+ * 관계 추출 품질 지표 수집
259
+ *
260
+ * PRD FR-2.4: 관계 추출 품질 지표 정의
261
+ * - Precision, Recall, F1-Score, 관계 유형별 정확도
262
+ *
263
+ * @param context - 컨텍스트 (기본값: 'default')
264
+ * @param options - 옵션 (예상 관계, 추출된 관계 등)
265
+ * @returns 관계 추출 품질 지표
266
+ *
267
+ * Note: 예상 관계와 추출된 관계가 제공되면 실제 측정을 수행하고,
268
+ * 없으면 기본값(0)을 반환합니다.
269
+ */
270
+ async collectRelationMetrics(context = 'default', options) {
271
+ const metrics = {};
272
+ // 예상 관계와 추출된 관계가 제공된 경우 실제 측정 수행
273
+ if (options?.expectedRelations && options?.extractedRelations) {
274
+ const { expectedRelations, extractedRelations } = options;
275
+ const validator = new RelationQualityValidator();
276
+ // 전체 품질 메트릭 계산
277
+ const qualityMetrics = validator.calculateQualityMetrics(expectedRelations, extractedRelations);
278
+ // 전체 메트릭
279
+ metrics.precision = qualityMetrics.precision;
280
+ metrics.recall = qualityMetrics.recall;
281
+ metrics.f1_score = qualityMetrics.f1Score;
282
+ metrics.true_positives = qualityMetrics.truePositives;
283
+ metrics.false_positives = qualityMetrics.falsePositives;
284
+ metrics.false_negatives = qualityMetrics.falseNegatives;
285
+ metrics.confidence_compliance_rate = qualityMetrics.confidenceComplianceRate;
286
+ // 관계 유형별 정확도 (Precision, Recall, F1-Score)
287
+ const typePrecision = {};
288
+ const typeRecall = {};
289
+ const typeF1Score = {};
290
+ for (const [relationType, typeMetric] of Object.entries(qualityMetrics.typeMetrics)) {
291
+ typePrecision[relationType] = typeMetric.precision;
292
+ typeRecall[relationType] = typeMetric.recall;
293
+ typeF1Score[relationType] = typeMetric.f1Score;
294
+ }
295
+ // 메타데이터에 관계 유형별 정확도 포함
296
+ const metadata = {
297
+ has_ground_truth: true,
298
+ expected_relations_count: expectedRelations.length,
299
+ extracted_relations_count: extractedRelations.length,
300
+ type_precision: typePrecision,
301
+ type_recall: typeRecall,
302
+ type_f1_score: typeF1Score
303
+ };
304
+ logger.info('관계 추출 품질 지표 수집 완료', {
305
+ context,
306
+ precision: qualityMetrics.precision,
307
+ recall: qualityMetrics.recall,
308
+ f1_score: qualityMetrics.f1Score,
309
+ expected_count: expectedRelations.length,
310
+ extracted_count: extractedRelations.length
311
+ });
312
+ return {
313
+ namespace: 'relation',
314
+ context,
315
+ measured_at: new Date().toISOString(),
316
+ metrics,
317
+ metadata
318
+ };
319
+ }
320
+ else {
321
+ // 예상 관계나 추출된 관계가 없으면 기본값 반환
322
+ metrics.precision = 0;
323
+ metrics.recall = 0;
324
+ metrics.f1_score = 0;
325
+ metrics.true_positives = 0;
326
+ metrics.false_positives = 0;
327
+ metrics.false_negatives = 0;
328
+ metrics.confidence_compliance_rate = 0;
329
+ logger.info('관계 추출 품질 지표 수집 완료 (기본값)', {
330
+ context,
331
+ note: '예상 관계나 추출된 관계가 없어 기본값을 반환했습니다. 실제 측정을 위해서는 예상 관계와 추출된 관계가 필요합니다.'
332
+ });
333
+ return {
334
+ namespace: 'relation',
335
+ context,
336
+ measured_at: new Date().toISOString(),
337
+ metrics,
338
+ metadata: {
339
+ has_ground_truth: false,
340
+ note: '예상 관계나 추출된 관계가 없어 기본값을 반환했습니다. 실제 측정을 위해서는 예상 관계와 추출된 관계가 필요합니다.'
341
+ }
342
+ };
343
+ }
344
+ }
345
+ /**
346
+ * Consolidation 점수 품질 지표 수집
347
+ *
348
+ * PRD FR-2.5: Consolidation 점수 안정성 지표 정의
349
+ * - 점수 분포, 순서 보존 검증
350
+ *
351
+ * @param context - 컨텍스트 (기본값: 'default')
352
+ * @param options - 옵션 (검색 결과 쌍, 점수 샘플 등)
353
+ * @returns Consolidation 점수 품질 지표
354
+ *
355
+ * Note: 검색 결과 쌍이나 점수 샘플이 제공되면 실제 측정을 수행하고,
356
+ * 없으면 기본값(0)을 반환합니다.
357
+ */
358
+ async collectConsolidationMetrics(context = 'default', options) {
359
+ const metrics = {};
360
+ // 검색 결과 쌍이 제공된 경우 순서 보존 지표 계산
361
+ if (options?.searchResultPairs && options.searchResultPairs.length > 0) {
362
+ const { searchResultPairs } = options;
363
+ let sumKendallTau = 0;
364
+ let sumTop5Retention = 0;
365
+ let sumTop10Retention = 0;
366
+ let validPairs = 0;
367
+ for (const pair of searchResultPairs) {
368
+ // 순서 보존 리포트 생성
369
+ const report = generateOrderPreservationReport(pair);
370
+ sumKendallTau += report.metrics.kendallTau;
371
+ sumTop5Retention += report.metrics.top5Retention;
372
+ sumTop10Retention += report.metrics.top10Retention;
373
+ validPairs++;
374
+ }
375
+ if (validPairs > 0) {
376
+ metrics.kendalls_tau = sumKendallTau / validPairs;
377
+ metrics.order_preservation = (metrics.kendalls_tau + 1) / 2; // -1~1을 0~1로 정규화
378
+ metrics.top_5_retention = sumTop5Retention / validPairs;
379
+ metrics.top_10_retention = sumTop10Retention / validPairs;
380
+ }
381
+ else {
382
+ metrics.kendalls_tau = 0;
383
+ metrics.order_preservation = 0;
384
+ metrics.top_5_retention = 0;
385
+ metrics.top_10_retention = 0;
386
+ }
387
+ }
388
+ else {
389
+ metrics.kendalls_tau = 0;
390
+ metrics.order_preservation = 0;
391
+ metrics.top_5_retention = 0;
392
+ metrics.top_10_retention = 0;
393
+ }
394
+ // Consolidation 점수 샘플이 제공된 경우 점수 분포 계산
395
+ if (options?.consolidationScores && options.consolidationScores.length > 0) {
396
+ const scores = options.consolidationScores.filter(s => s !== null && s !== undefined && !isNaN(s));
397
+ if (scores.length > 0) {
398
+ // 평균 계산
399
+ const mean = scores.reduce((sum, score) => sum + score, 0) / scores.length;
400
+ metrics.score_mean = mean;
401
+ // 표준편차 계산
402
+ const variance = scores.reduce((sum, score) => sum + Math.pow(score - mean, 2), 0) / scores.length;
403
+ const std = Math.sqrt(variance);
404
+ metrics.score_std = std;
405
+ // 점수 안정성 계산 (분산의 역수, 0~1 범위로 정규화)
406
+ // 분산이 작을수록 안정적이므로, 1 / (1 + variance)로 계산
407
+ // 최대 분산은 0.25 (0~1 범위에서)이므로 이를 기준으로 정규화
408
+ const maxVariance = 0.25; // 0~1 범위에서 최대 분산
409
+ const normalizedVariance = Math.min(variance / maxVariance, 1.0);
410
+ metrics.score_stability = 1.0 - normalizedVariance;
411
+ }
412
+ else {
413
+ metrics.score_mean = 0;
414
+ metrics.score_std = 0;
415
+ metrics.score_stability = 0;
416
+ }
417
+ }
418
+ else {
419
+ // 데이터베이스에서 Consolidation 점수 조회 시도
420
+ try {
421
+ const scoresResult = this.db.prepare(`
422
+ SELECT consolidation_score
423
+ FROM memory_item
424
+ WHERE consolidation_score IS NOT NULL
425
+ LIMIT 1000
426
+ `).all();
427
+ const scores = scoresResult
428
+ .map(r => r.consolidation_score)
429
+ .filter((s) => s !== null && s !== undefined && !isNaN(s));
430
+ if (scores.length > 0) {
431
+ // 평균 계산
432
+ const mean = scores.reduce((sum, score) => sum + score, 0) / scores.length;
433
+ metrics.score_mean = mean;
434
+ // 표준편차 계산
435
+ const variance = scores.reduce((sum, score) => sum + Math.pow(score - mean, 2), 0) / scores.length;
436
+ const std = Math.sqrt(variance);
437
+ metrics.score_std = std;
438
+ // 점수 안정성 계산
439
+ const maxVariance = 0.25;
440
+ const normalizedVariance = Math.min(variance / maxVariance, 1.0);
441
+ metrics.score_stability = 1.0 - normalizedVariance;
442
+ }
443
+ else {
444
+ metrics.score_mean = 0;
445
+ metrics.score_std = 0;
446
+ metrics.score_stability = 0;
447
+ }
448
+ }
449
+ catch (error) {
450
+ // 데이터베이스 조회 실패 시 기본값
451
+ logger.warn('Consolidation 점수 조회 실패', { error: error instanceof Error ? error.message : String(error) });
452
+ metrics.score_mean = 0;
453
+ metrics.score_std = 0;
454
+ metrics.score_stability = 0;
455
+ }
456
+ }
457
+ const hasData = (options?.searchResultPairs && options.searchResultPairs.length > 0) ||
458
+ (options?.consolidationScores && options.consolidationScores.length > 0) ||
459
+ metrics.score_mean !== undefined;
460
+ if (hasData) {
461
+ logger.info('Consolidation 점수 품질 지표 수집 완료', {
462
+ context,
463
+ metrics_count: Object.keys(metrics).length,
464
+ has_order_preservation: options?.searchResultPairs !== undefined,
465
+ has_score_distribution: metrics.score_mean !== undefined
466
+ });
467
+ }
468
+ else {
469
+ logger.info('Consolidation 점수 품질 지표 수집 완료 (기본값)', {
470
+ context,
471
+ note: '검색 결과 쌍이나 점수 샘플이 없어 기본값을 반환했습니다. 실제 측정을 위해서는 검색 결과 쌍이나 점수 샘플이 필요합니다.'
472
+ });
473
+ }
474
+ return {
475
+ namespace: 'consolidation',
476
+ context,
477
+ measured_at: new Date().toISOString(),
478
+ metrics,
479
+ metadata: {
480
+ has_search_result_pairs: options?.searchResultPairs !== undefined && (options.searchResultPairs.length > 0),
481
+ has_score_samples: options?.consolidationScores !== undefined && (options.consolidationScores.length > 0),
482
+ search_result_pairs_count: options?.searchResultPairs?.length || 0,
483
+ score_samples_count: options?.consolidationScores?.length || 0
484
+ }
485
+ };
486
+ }
487
+ /**
488
+ * 저장 품질 지표 수집
489
+ *
490
+ * PRD FR-2.5: 기억 저장 품질 지표 정의
491
+ * - 중복 비율, 데이터 무결성, 스키마 준수율, 데이터 손실률
492
+ *
493
+ * @param context - 컨텍스트 (기본값: 'default')
494
+ * @returns 저장 품질 지표
495
+ */
496
+ async collectStorageMetrics(context = 'default') {
497
+ const metrics = {};
498
+ try {
499
+ // 1. 중복 비율 계산 (memory_link 테이블에서 duplicates 관계 비율)
500
+ const totalMemoryItems = this.db.prepare(`
501
+ SELECT COUNT(*) as count FROM memory_item
502
+ `).get();
503
+ const duplicateLinks = this.db.prepare(`
504
+ SELECT COUNT(*) as count FROM memory_link
505
+ WHERE relation_type = 'duplicates'
506
+ `).get();
507
+ // 중복 비율 = (중복 관계 수 * 2) / (전체 메모리 아이템 수 * 2)
508
+ // 각 중복 관계는 2개의 메모리를 연결하므로, 중복된 메모리 수는 관계 수 * 2
509
+ // 전체 메모리 아이템이 0인 경우 0으로 처리
510
+ if (totalMemoryItems.count > 0) {
511
+ metrics.duplication_rate = Math.min((duplicateLinks.count * 2) / totalMemoryItems.count, 1.0);
512
+ }
513
+ else {
514
+ metrics.duplication_rate = 0;
515
+ }
516
+ // 2. 데이터 무결성 검증
517
+ let integrityScore = 1.0;
518
+ let integrityChecks = 0;
519
+ let integrityPassed = 0;
520
+ // 2.1 PRAGMA integrity_check
521
+ try {
522
+ const integrityResult = this.db.prepare('PRAGMA integrity_check').get();
523
+ integrityChecks++;
524
+ if (integrityResult.integrity_check === 'ok') {
525
+ integrityPassed++;
526
+ }
527
+ else {
528
+ integrityScore -= 0.3; // 무결성 검사 실패 시 큰 패널티
529
+ }
530
+ }
531
+ catch (error) {
532
+ integrityChecks++;
533
+ integrityScore -= 0.3;
534
+ }
535
+ // 2.2 외래키 제약 조건 검증
536
+ // memory_item_tag의 외래키 검증
537
+ try {
538
+ const orphanedTags = this.db.prepare(`
539
+ SELECT COUNT(*) as count
540
+ FROM memory_item_tag mit
541
+ LEFT JOIN memory_item mi ON mit.memory_id = mi.id
542
+ LEFT JOIN memory_tag mt ON mit.tag_id = mt.id
543
+ WHERE mi.id IS NULL OR mt.id IS NULL
544
+ `).get();
545
+ integrityChecks++;
546
+ if (orphanedTags.count === 0) {
547
+ integrityPassed++;
548
+ }
549
+ else {
550
+ integrityScore -= 0.2;
551
+ }
552
+ }
553
+ catch (error) {
554
+ integrityChecks++;
555
+ integrityScore -= 0.1;
556
+ }
557
+ // memory_link의 외래키 검증
558
+ try {
559
+ const orphanedLinks = this.db.prepare(`
560
+ SELECT COUNT(*) as count
561
+ FROM memory_link ml
562
+ LEFT JOIN memory_item mi1 ON ml.source_id = mi1.id
563
+ LEFT JOIN memory_item mi2 ON ml.target_id = mi2.id
564
+ WHERE mi1.id IS NULL OR mi2.id IS NULL
565
+ `).get();
566
+ integrityChecks++;
567
+ if (orphanedLinks.count === 0) {
568
+ integrityPassed++;
569
+ }
570
+ else {
571
+ integrityScore -= 0.2;
572
+ }
573
+ }
574
+ catch (error) {
575
+ integrityChecks++;
576
+ integrityScore -= 0.1;
577
+ }
578
+ // feedback_event의 외래키 검증
579
+ try {
580
+ const orphanedFeedback = this.db.prepare(`
581
+ SELECT COUNT(*) as count
582
+ FROM feedback_event fe
583
+ LEFT JOIN memory_item mi ON fe.memory_id = mi.id
584
+ WHERE mi.id IS NULL
585
+ `).get();
586
+ integrityChecks++;
587
+ if (orphanedFeedback.count === 0) {
588
+ integrityPassed++;
589
+ }
590
+ else {
591
+ integrityScore -= 0.1;
592
+ }
593
+ }
594
+ catch (error) {
595
+ integrityChecks++;
596
+ integrityScore -= 0.05;
597
+ }
598
+ // memory_embedding의 외래키 검증
599
+ try {
600
+ const orphanedEmbeddings = this.db.prepare(`
601
+ SELECT COUNT(*) as count
602
+ FROM memory_embedding me
603
+ LEFT JOIN memory_item mi ON me.memory_id = mi.id
604
+ WHERE mi.id IS NULL
605
+ `).get();
606
+ integrityChecks++;
607
+ if (orphanedEmbeddings.count === 0) {
608
+ integrityPassed++;
609
+ }
610
+ else {
611
+ integrityScore -= 0.2;
612
+ }
613
+ }
614
+ catch (error) {
615
+ integrityChecks++;
616
+ integrityScore -= 0.1;
617
+ }
618
+ // 무결성 점수는 0 이상 1 이하로 정규화
619
+ metrics.data_integrity = Math.max(0, Math.min(integrityScore, 1.0));
620
+ // 3. 스키마 준수율 계산
621
+ let schemaComplianceScore = 1.0;
622
+ let schemaChecks = 0;
623
+ let schemaPassed = 0;
624
+ // 3.1 필수 필드 존재 여부 (id, type, content)
625
+ try {
626
+ const missingRequiredFields = this.db.prepare(`
627
+ SELECT COUNT(*) as count
628
+ FROM memory_item
629
+ WHERE id IS NULL OR id = '' OR
630
+ type IS NULL OR type = '' OR
631
+ content IS NULL OR content = ''
632
+ `).get();
633
+ schemaChecks++;
634
+ if (missingRequiredFields.count === 0) {
635
+ schemaPassed++;
636
+ }
637
+ else {
638
+ schemaComplianceScore -= 0.3;
639
+ }
640
+ }
641
+ catch (error) {
642
+ schemaChecks++;
643
+ schemaComplianceScore -= 0.1;
644
+ }
645
+ // 3.2 타입 검증 (type은 enum 값)
646
+ try {
647
+ const invalidTypes = this.db.prepare(`
648
+ SELECT COUNT(*) as count
649
+ FROM memory_item
650
+ WHERE type NOT IN ('working', 'episodic', 'semantic', 'procedural', 'core', 'vault')
651
+ `).get();
652
+ schemaChecks++;
653
+ if (invalidTypes.count === 0) {
654
+ schemaPassed++;
655
+ }
656
+ else {
657
+ schemaComplianceScore -= 0.2;
658
+ }
659
+ }
660
+ catch (error) {
661
+ schemaChecks++;
662
+ schemaComplianceScore -= 0.1;
663
+ }
664
+ // 3.3 importance 범위 검증 (0-1)
665
+ try {
666
+ const invalidImportance = this.db.prepare(`
667
+ SELECT COUNT(*) as count
668
+ FROM memory_item
669
+ WHERE importance IS NOT NULL AND (importance < 0 OR importance > 1)
670
+ `).get();
671
+ schemaChecks++;
672
+ if (invalidImportance.count === 0) {
673
+ schemaPassed++;
674
+ }
675
+ else {
676
+ schemaComplianceScore -= 0.1;
677
+ }
678
+ }
679
+ catch (error) {
680
+ schemaChecks++;
681
+ schemaComplianceScore -= 0.05;
682
+ }
683
+ // 3.4 privacy_scope enum 검증
684
+ try {
685
+ const invalidPrivacyScope = this.db.prepare(`
686
+ SELECT COUNT(*) as count
687
+ FROM memory_item
688
+ WHERE privacy_scope IS NOT NULL AND
689
+ privacy_scope NOT IN ('private', 'team', 'public')
690
+ `).get();
691
+ schemaChecks++;
692
+ if (invalidPrivacyScope.count === 0) {
693
+ schemaPassed++;
694
+ }
695
+ else {
696
+ schemaComplianceScore -= 0.1;
697
+ }
698
+ }
699
+ catch (error) {
700
+ schemaChecks++;
701
+ schemaComplianceScore -= 0.05;
702
+ }
703
+ // 스키마 준수율은 0 이상 1 이하로 정규화
704
+ metrics.schema_compliance = Math.max(0, Math.min(schemaComplianceScore, 1.0));
705
+ // 4. 데이터 손실률 계산
706
+ // memory_embedding 테이블에 embedding이 없는 memory_item 비율
707
+ try {
708
+ const totalItems = totalMemoryItems.count;
709
+ if (totalItems > 0) {
710
+ const itemsWithoutEmbedding = this.db.prepare(`
711
+ SELECT COUNT(*) as count
712
+ FROM memory_item mi
713
+ LEFT JOIN memory_embedding me ON mi.id = me.memory_id
714
+ WHERE me.memory_id IS NULL
715
+ `).get();
716
+ metrics.data_loss_rate = itemsWithoutEmbedding.count / totalItems;
717
+ }
718
+ else {
719
+ metrics.data_loss_rate = 0;
720
+ }
721
+ }
722
+ catch (error) {
723
+ metrics.data_loss_rate = 0;
724
+ }
725
+ logger.info('저장 품질 지표 수집 완료', {
726
+ context,
727
+ metrics_count: Object.keys(metrics).length,
728
+ duplication_rate: metrics.duplication_rate,
729
+ data_integrity: metrics.data_integrity,
730
+ schema_compliance: metrics.schema_compliance,
731
+ data_loss_rate: metrics.data_loss_rate,
732
+ integrity_checks: integrityChecks,
733
+ integrity_passed: integrityPassed,
734
+ schema_checks: schemaChecks,
735
+ schema_passed: schemaPassed
736
+ });
737
+ }
738
+ catch (error) {
739
+ logger.error('저장 품질 지표 수집 중 오류 발생', {
740
+ context,
741
+ error: error instanceof Error ? error.message : String(error)
742
+ });
743
+ // 오류 발생 시 기본값 반환
744
+ metrics.duplication_rate = 0;
745
+ metrics.data_integrity = 0;
746
+ metrics.schema_compliance = 0;
747
+ metrics.data_loss_rate = 0;
748
+ }
749
+ return {
750
+ namespace: 'storage',
751
+ context,
752
+ measured_at: new Date().toISOString(),
753
+ metrics,
754
+ metadata: {
755
+ note: '저장 품질 지표 수집 완료'
756
+ }
757
+ };
758
+ }
759
+ /**
760
+ * 모든 네임스페이스의 품질 지표 수집
761
+ *
762
+ * @param context - 컨텍스트 (기본값: 'default')
763
+ * @returns 모든 네임스페이스의 품질 지표
764
+ */
765
+ async collectAllMetrics(context = 'default') {
766
+ const results = await Promise.all([
767
+ this.collectSearchMetrics(context),
768
+ this.collectRelationMetrics(context),
769
+ this.collectConsolidationMetrics(context),
770
+ this.collectStorageMetrics(context)
771
+ ]);
772
+ return results;
773
+ }
774
+ /**
775
+ * 특정 네임스페이스의 품질 지표 수집
776
+ *
777
+ * @param namespace - 네임스페이스 ('search', 'relation', 'consolidation', 'storage')
778
+ * @param context - 컨텍스트 (기본값: 'default')
779
+ * @returns 품질 지표
780
+ */
781
+ async collectMetricsByNamespace(namespace, context = 'default') {
782
+ switch (namespace) {
783
+ case 'search':
784
+ return this.collectSearchMetrics(context);
785
+ case 'relation':
786
+ return this.collectRelationMetrics(context);
787
+ case 'consolidation':
788
+ return this.collectConsolidationMetrics(context);
789
+ case 'storage':
790
+ return this.collectStorageMetrics(context);
791
+ default:
792
+ throw new Error(`Unknown namespace: ${namespace}`);
793
+ }
794
+ }
795
+ }
796
+ //# sourceMappingURL=quality-metrics-collector.js.map