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