evalvault 1.74.0__py3-none-any.whl → 1.76.0__py3-none-any.whl

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 (59) hide show
  1. evalvault/adapters/inbound/api/adapter.py +127 -80
  2. evalvault/adapters/inbound/api/routers/calibration.py +9 -9
  3. evalvault/adapters/inbound/api/routers/chat.py +303 -17
  4. evalvault/adapters/inbound/api/routers/config.py +3 -1
  5. evalvault/adapters/inbound/api/routers/domain.py +10 -5
  6. evalvault/adapters/inbound/api/routers/pipeline.py +3 -3
  7. evalvault/adapters/inbound/api/routers/runs.py +23 -4
  8. evalvault/adapters/inbound/cli/commands/analyze.py +10 -12
  9. evalvault/adapters/inbound/cli/commands/benchmark.py +10 -8
  10. evalvault/adapters/inbound/cli/commands/calibrate.py +2 -7
  11. evalvault/adapters/inbound/cli/commands/calibrate_judge.py +2 -7
  12. evalvault/adapters/inbound/cli/commands/compare.py +2 -7
  13. evalvault/adapters/inbound/cli/commands/debug.py +3 -2
  14. evalvault/adapters/inbound/cli/commands/domain.py +12 -12
  15. evalvault/adapters/inbound/cli/commands/experiment.py +9 -8
  16. evalvault/adapters/inbound/cli/commands/gate.py +3 -2
  17. evalvault/adapters/inbound/cli/commands/graph_rag.py +2 -2
  18. evalvault/adapters/inbound/cli/commands/history.py +3 -12
  19. evalvault/adapters/inbound/cli/commands/method.py +3 -4
  20. evalvault/adapters/inbound/cli/commands/ops.py +2 -2
  21. evalvault/adapters/inbound/cli/commands/pipeline.py +2 -2
  22. evalvault/adapters/inbound/cli/commands/profile_difficulty.py +3 -12
  23. evalvault/adapters/inbound/cli/commands/prompts.py +4 -18
  24. evalvault/adapters/inbound/cli/commands/regress.py +5 -4
  25. evalvault/adapters/inbound/cli/commands/run.py +188 -59
  26. evalvault/adapters/inbound/cli/commands/run_helpers.py +181 -70
  27. evalvault/adapters/inbound/cli/commands/stage.py +6 -25
  28. evalvault/adapters/inbound/cli/utils/options.py +10 -4
  29. evalvault/adapters/inbound/mcp/tools.py +11 -8
  30. evalvault/adapters/outbound/analysis/embedding_analyzer_module.py +17 -1
  31. evalvault/adapters/outbound/analysis/embedding_searcher_module.py +14 -0
  32. evalvault/adapters/outbound/domain_memory/__init__.py +8 -4
  33. evalvault/adapters/outbound/domain_memory/factory.py +68 -0
  34. evalvault/adapters/outbound/domain_memory/postgres_adapter.py +1062 -0
  35. evalvault/adapters/outbound/domain_memory/postgres_domain_memory_schema.sql +177 -0
  36. evalvault/adapters/outbound/llm/factory.py +1 -1
  37. evalvault/adapters/outbound/llm/vllm_adapter.py +23 -0
  38. evalvault/adapters/outbound/nlp/korean/dense_retriever.py +10 -7
  39. evalvault/adapters/outbound/nlp/korean/toolkit.py +15 -4
  40. evalvault/adapters/outbound/phoenix/sync_service.py +99 -0
  41. evalvault/adapters/outbound/retriever/pgvector_store.py +165 -0
  42. evalvault/adapters/outbound/storage/base_sql.py +3 -2
  43. evalvault/adapters/outbound/storage/factory.py +53 -0
  44. evalvault/adapters/outbound/storage/postgres_schema.sql +2 -0
  45. evalvault/adapters/outbound/tracker/mlflow_adapter.py +209 -54
  46. evalvault/adapters/outbound/tracker/phoenix_adapter.py +158 -9
  47. evalvault/config/instrumentation.py +8 -6
  48. evalvault/config/phoenix_support.py +5 -0
  49. evalvault/config/settings.py +71 -11
  50. evalvault/domain/services/domain_learning_hook.py +2 -1
  51. evalvault/domain/services/evaluator.py +2 -0
  52. evalvault/ports/inbound/web_port.py +3 -1
  53. evalvault/ports/outbound/storage_port.py +2 -0
  54. evalvault-1.76.0.dist-info/METADATA +221 -0
  55. {evalvault-1.74.0.dist-info → evalvault-1.76.0.dist-info}/RECORD +58 -53
  56. evalvault-1.74.0.dist-info/METADATA +0 -585
  57. {evalvault-1.74.0.dist-info → evalvault-1.76.0.dist-info}/WHEEL +0 -0
  58. {evalvault-1.74.0.dist-info → evalvault-1.76.0.dist-info}/entry_points.txt +0 -0
  59. {evalvault-1.74.0.dist-info → evalvault-1.76.0.dist-info}/licenses/LICENSE.md +0 -0
@@ -0,0 +1,1062 @@
1
+ """PostgreSQL adapter for Domain Memory storage.
2
+
3
+ Based on "Memory in the Age of AI Agents: A Survey" framework:
4
+ - Phase 1: Basic CRUD for Factual, Experiential, Working layers
5
+ - Phase 2: Evolution dynamics (consolidate, forget, decay)
6
+ - Phase 3: Formation dynamics (extraction from evaluations)
7
+ - Phase 5: Forms expansion (Planar/Hierarchical)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import re
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+
18
+ import psycopg
19
+
20
+ from evalvault.domain.entities.memory import (
21
+ BehaviorEntry,
22
+ BehaviorHandbook,
23
+ DomainMemoryContext,
24
+ FactualFact,
25
+ LearningMemory,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ def _coerce_datetime(value: datetime | str) -> datetime:
32
+ if isinstance(value, datetime):
33
+ return value
34
+ return datetime.fromisoformat(value)
35
+
36
+
37
+ class PostgresDomainMemoryAdapter:
38
+ """PostgreSQL 기반 도메인 메모리 저장 어댑터.
39
+
40
+ Implements DomainMemoryPort using PostgreSQL for persistent storage.
41
+ """
42
+
43
+ def __init__(self, connection_string: str | None = None):
44
+ """Initialize PostgreSQL domain memory adapter.
45
+
46
+ Args:
47
+ connection_string: PostgreSQL connection string
48
+ (e.g., "host=localhost port=5432 dbname=evalvault user=postgres password=...")
49
+ """
50
+ self.connection_string = connection_string or (
51
+ "host=localhost port=5432 dbname=evalvault user=postgres password="
52
+ )
53
+ self._init_db()
54
+
55
+ def _init_db(self) -> None:
56
+ """Initialize database schema."""
57
+ schema_path = Path(__file__).parent / "postgres_domain_memory_schema.sql"
58
+ with open(schema_path, encoding="utf-8") as f:
59
+ schema_sql = f.read()
60
+
61
+ try:
62
+ with psycopg.connect(self.connection_string) as conn:
63
+ with conn.cursor() as cur:
64
+ cur.execute(schema_sql)
65
+ conn.commit()
66
+ except psycopg.Error as e:
67
+ logger.error("Failed to initialize PostgreSQL schema: %s", e)
68
+ raise
69
+
70
+ def _get_connection(self) -> psycopg.Connection:
71
+ """Get a database connection."""
72
+ return psycopg.connect(self.connection_string)
73
+
74
+ def _row_to_fact(self, row: tuple) -> FactualFact:
75
+ """Convert database row to FactualFact."""
76
+ fact_id = row[0]
77
+ abstraction_level = row[12] if len(row) > 12 else 0
78
+
79
+ return FactualFact(
80
+ fact_id=fact_id,
81
+ subject=row[1],
82
+ predicate=row[2],
83
+ object=row[3],
84
+ language=row[4],
85
+ domain=row[5],
86
+ fact_type=row[6],
87
+ verification_score=row[7],
88
+ verification_count=row[8],
89
+ source_document_ids=json.loads(row[9]) if row[9] else [],
90
+ created_at=_coerce_datetime(row[10]),
91
+ last_verified=_coerce_datetime(row[11]) if row[11] else None,
92
+ abstraction_level=abstraction_level,
93
+ )
94
+
95
+ def _row_to_learning(self, row: tuple) -> LearningMemory:
96
+ """Convert database row to LearningMemory."""
97
+ return LearningMemory(
98
+ learning_id=row[0],
99
+ run_id=row[1],
100
+ domain=row[2],
101
+ language=row[3],
102
+ entity_type_reliability=json.loads(row[4]) if row[4] else {},
103
+ relation_type_reliability=json.loads(row[5]) if row[5] else {},
104
+ failed_patterns=json.loads(row[6]) if row[6] else [],
105
+ successful_patterns=json.loads(row[7]) if row[7] else [],
106
+ faithfulness_by_entity_type=json.loads(row[8]) if row[8] else {},
107
+ timestamp=_coerce_datetime(row[9]),
108
+ )
109
+
110
+ def _row_to_behavior(self, row: tuple) -> BehaviorEntry:
111
+ """Convert database row to BehaviorEntry."""
112
+ return BehaviorEntry(
113
+ behavior_id=row[0],
114
+ description=row[1],
115
+ trigger_pattern=row[2] or "",
116
+ action_sequence=json.loads(row[3]) if row[3] else [],
117
+ success_rate=row[4],
118
+ token_savings=row[5],
119
+ applicable_languages=json.loads(row[6]) if row[6] else ["ko", "en"],
120
+ domain=row[7],
121
+ last_used=_coerce_datetime(row[8]),
122
+ use_count=row[9],
123
+ created_at=_coerce_datetime(row[10]),
124
+ )
125
+
126
+ def save_fact(self, fact: FactualFact) -> str:
127
+ """사실을 저장합니다."""
128
+ conn = self._get_connection()
129
+ try:
130
+ with conn.cursor() as cur:
131
+ cur.execute(
132
+ """
133
+ INSERT INTO factual_facts (
134
+ fact_id, subject, predicate, object, language, domain,
135
+ fact_type, verification_score, verification_count,
136
+ source_document_ids, created_at, last_verified, abstraction_level
137
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
138
+ ON CONFLICT (fact_id) DO UPDATE SET
139
+ subject = EXCLUDED.subject,
140
+ predicate = EXCLUDED.predicate,
141
+ object = EXCLUDED.object,
142
+ language = EXCLUDED.language,
143
+ domain = EXCLUDED.domain,
144
+ fact_type = EXCLUDED.fact_type,
145
+ verification_score = EXCLUDED.verification_score,
146
+ verification_count = EXCLUDED.verification_count,
147
+ source_document_ids = EXCLUDED.source_document_ids,
148
+ last_verified = EXCLUDED.last_verified,
149
+ abstraction_level = EXCLUDED.abstraction_level
150
+ """,
151
+ (
152
+ fact.fact_id,
153
+ fact.subject,
154
+ fact.predicate,
155
+ fact.object,
156
+ fact.language,
157
+ fact.domain,
158
+ fact.fact_type,
159
+ fact.verification_score,
160
+ fact.verification_count,
161
+ json.dumps(fact.source_document_ids),
162
+ fact.created_at.isoformat(),
163
+ fact.last_verified.isoformat() if fact.last_verified else None,
164
+ fact.abstraction_level,
165
+ ),
166
+ )
167
+
168
+ if fact.kg_entity_id:
169
+ cur.execute(
170
+ """
171
+ INSERT INTO fact_kg_bindings (fact_id, kg_entity_id, kg_relation_type)
172
+ VALUES (%s, %s, %s)
173
+ ON CONFLICT (fact_id, kg_entity_id) DO UPDATE SET
174
+ kg_relation_type = EXCLUDED.kg_relation_type
175
+ """,
176
+ (fact.fact_id, fact.kg_entity_id, fact.kg_relation_type),
177
+ )
178
+
179
+ if fact.parent_fact_id:
180
+ cur.execute(
181
+ """
182
+ INSERT INTO fact_hierarchy (parent_fact_id, child_fact_id)
183
+ VALUES (%s, %s)
184
+ ON CONFLICT DO NOTHING
185
+ """,
186
+ (fact.parent_fact_id, fact.fact_id),
187
+ )
188
+
189
+ conn.commit()
190
+ return fact.fact_id
191
+ finally:
192
+ conn.close()
193
+
194
+ def get_fact(self, fact_id: str) -> FactualFact:
195
+ """사실을 조회합니다."""
196
+ conn = self._get_connection()
197
+ try:
198
+ with conn.cursor() as cur:
199
+ cur.execute(
200
+ """
201
+ SELECT fact_id, subject, predicate, object, language, domain,
202
+ fact_type, verification_score, verification_count,
203
+ source_document_ids, created_at, last_verified, abstraction_level
204
+ FROM factual_facts WHERE fact_id = %s
205
+ """,
206
+ (fact_id,),
207
+ )
208
+ row = cur.fetchone()
209
+
210
+ if not row:
211
+ raise KeyError(f"Fact not found: {fact_id}")
212
+
213
+ return self._row_to_fact(row)
214
+ finally:
215
+ conn.close()
216
+
217
+ def list_facts(
218
+ self,
219
+ domain: str | None = None,
220
+ language: str | None = None,
221
+ subject: str | None = None,
222
+ predicate: str | None = None,
223
+ limit: int = 100,
224
+ ) -> list[FactualFact]:
225
+ """사실 목록을 조회합니다."""
226
+ conn = self._get_connection()
227
+ try:
228
+ with conn.cursor() as cur:
229
+ query = """
230
+ SELECT fact_id, subject, predicate, object, language, domain,
231
+ fact_type, verification_score, verification_count,
232
+ source_document_ids, created_at, last_verified, abstraction_level
233
+ FROM factual_facts WHERE 1=1
234
+ """
235
+ params: list = []
236
+
237
+ if domain:
238
+ query += " AND domain = %s"
239
+ params.append(domain)
240
+ if language:
241
+ query += " AND language = %s"
242
+ params.append(language)
243
+ if subject:
244
+ query += " AND subject = %s"
245
+ params.append(subject)
246
+ if predicate:
247
+ query += " AND predicate = %s"
248
+ params.append(predicate)
249
+
250
+ query += " ORDER BY last_verified DESC LIMIT %s"
251
+ params.append(limit)
252
+
253
+ cur.execute(query, params)
254
+ return [self._row_to_fact(row) for row in cur.fetchall()]
255
+ finally:
256
+ conn.close()
257
+
258
+ def update_fact(self, fact: FactualFact) -> None:
259
+ """사실을 업데이트합니다."""
260
+ self.save_fact(fact)
261
+
262
+ def delete_fact(self, fact_id: str) -> bool:
263
+ """사실을 삭제합니다."""
264
+ conn = self._get_connection()
265
+ try:
266
+ with conn.cursor() as cur:
267
+ cur.execute("DELETE FROM factual_facts WHERE fact_id = %s", (fact_id,))
268
+ deleted = cur.rowcount > 0
269
+ conn.commit()
270
+ return deleted
271
+ finally:
272
+ conn.close()
273
+
274
+ def find_fact_by_triple(
275
+ self,
276
+ subject: str,
277
+ predicate: str,
278
+ obj: str,
279
+ domain: str | None = None,
280
+ ) -> FactualFact | None:
281
+ """SPO 트리플로 사실을 검색합니다."""
282
+ conn = self._get_connection()
283
+ try:
284
+ with conn.cursor() as cur:
285
+ query = """
286
+ SELECT fact_id, subject, predicate, object, language, domain,
287
+ fact_type, verification_score, verification_count,
288
+ source_document_ids, created_at, last_verified, abstraction_level
289
+ FROM factual_facts
290
+ WHERE subject = %s AND predicate = %s AND object = %s
291
+ """
292
+ params: list = [subject, predicate, obj]
293
+
294
+ if domain:
295
+ query += " AND domain = %s"
296
+ params.append(domain)
297
+
298
+ cur.execute(query, params)
299
+ row = cur.fetchone()
300
+
301
+ return self._row_to_fact(row) if row else None
302
+ finally:
303
+ conn.close()
304
+
305
+ def save_learning(self, learning: LearningMemory) -> str:
306
+ """학습 메모리를 저장합니다."""
307
+ conn = self._get_connection()
308
+ try:
309
+ with conn.cursor() as cur:
310
+ cur.execute(
311
+ """
312
+ INSERT INTO learning_memories (
313
+ learning_id, run_id, domain, language,
314
+ entity_type_reliability, relation_type_reliability,
315
+ failed_patterns, successful_patterns,
316
+ faithfulness_by_entity_type, timestamp
317
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
318
+ ON CONFLICT (learning_id) DO UPDATE SET
319
+ run_id = EXCLUDED.run_id,
320
+ domain = EXCLUDED.domain,
321
+ language = EXCLUDED.language,
322
+ entity_type_reliability = EXCLUDED.entity_type_reliability,
323
+ relation_type_reliability = EXCLUDED.relation_type_reliability,
324
+ failed_patterns = EXCLUDED.failed_patterns,
325
+ successful_patterns = EXCLUDED.successful_patterns,
326
+ faithfulness_by_entity_type = EXCLUDED.faithfulness_by_entity_type,
327
+ timestamp = EXCLUDED.timestamp
328
+ """,
329
+ (
330
+ learning.learning_id,
331
+ learning.run_id,
332
+ learning.domain,
333
+ learning.language,
334
+ json.dumps(learning.entity_type_reliability),
335
+ json.dumps(learning.relation_type_reliability),
336
+ json.dumps(learning.failed_patterns),
337
+ json.dumps(learning.successful_patterns),
338
+ json.dumps(learning.faithfulness_by_entity_type),
339
+ learning.timestamp.isoformat(),
340
+ ),
341
+ )
342
+ conn.commit()
343
+ return learning.learning_id
344
+ finally:
345
+ conn.close()
346
+
347
+ def get_learning(self, learning_id: str) -> LearningMemory:
348
+ """학습 메모리를 조회합니다."""
349
+ conn = self._get_connection()
350
+ try:
351
+ with conn.cursor() as cur:
352
+ cur.execute(
353
+ """
354
+ SELECT learning_id, run_id, domain, language,
355
+ entity_type_reliability, relation_type_reliability,
356
+ failed_patterns, successful_patterns,
357
+ faithfulness_by_entity_type, timestamp
358
+ FROM learning_memories WHERE learning_id = %s
359
+ """,
360
+ (learning_id,),
361
+ )
362
+ row = cur.fetchone()
363
+
364
+ if not row:
365
+ raise KeyError(f"Learning not found: {learning_id}")
366
+
367
+ return self._row_to_learning(row)
368
+ finally:
369
+ conn.close()
370
+
371
+ def list_learnings(
372
+ self,
373
+ domain: str | None = None,
374
+ language: str | None = None,
375
+ run_id: str | None = None,
376
+ limit: int = 100,
377
+ ) -> list[LearningMemory]:
378
+ """학습 메모리 목록을 조회합니다."""
379
+ conn = self._get_connection()
380
+ try:
381
+ with conn.cursor() as cur:
382
+ query = """
383
+ SELECT learning_id, run_id, domain, language,
384
+ entity_type_reliability, relation_type_reliability,
385
+ failed_patterns, successful_patterns,
386
+ faithfulness_by_entity_type, timestamp
387
+ FROM learning_memories WHERE 1=1
388
+ """
389
+ params: list = []
390
+
391
+ if domain:
392
+ query += " AND domain = %s"
393
+ params.append(domain)
394
+ if language:
395
+ query += " AND language = %s"
396
+ params.append(language)
397
+ if run_id:
398
+ query += " AND run_id = %s"
399
+ params.append(run_id)
400
+
401
+ query += " ORDER BY timestamp DESC LIMIT %s"
402
+ params.append(limit)
403
+
404
+ cur.execute(query, params)
405
+ return [self._row_to_learning(row) for row in cur.fetchall()]
406
+ finally:
407
+ conn.close()
408
+
409
+ def get_aggregated_reliability(
410
+ self,
411
+ domain: str,
412
+ language: str,
413
+ ) -> dict[str, float]:
414
+ """도메인/언어별 집계된 엔티티 타입 신뢰도를 조회합니다."""
415
+ conn = self._get_connection()
416
+ try:
417
+ with conn.cursor() as cur:
418
+ cur.execute(
419
+ """
420
+ SELECT entity_type_reliability
421
+ FROM learning_memories
422
+ WHERE domain = %s AND language = %s
423
+ ORDER BY timestamp DESC
424
+ """,
425
+ (domain, language),
426
+ )
427
+ rows = cur.fetchall()
428
+
429
+ if not rows:
430
+ return {}
431
+
432
+ aggregated: dict[str, list[float]] = {}
433
+ for row in rows:
434
+ reliability = json.loads(row[0]) if row[0] else {}
435
+ for entity_type, score in reliability.items():
436
+ if entity_type not in aggregated:
437
+ aggregated[entity_type] = []
438
+ aggregated[entity_type].append(score)
439
+
440
+ return {
441
+ entity_type: sum(scores) / len(scores)
442
+ for entity_type, scores in aggregated.items()
443
+ }
444
+ finally:
445
+ conn.close()
446
+
447
+ def save_behavior(self, behavior: BehaviorEntry) -> str:
448
+ """행동 엔트리를 저장합니다."""
449
+ conn = self._get_connection()
450
+ try:
451
+ with conn.cursor() as cur:
452
+ cur.execute(
453
+ """
454
+ INSERT INTO behavior_entries (
455
+ behavior_id, description, trigger_pattern, action_sequence,
456
+ success_rate, token_savings, applicable_languages, domain,
457
+ last_used, use_count, created_at
458
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
459
+ ON CONFLICT (behavior_id) DO UPDATE SET
460
+ description = EXCLUDED.description,
461
+ trigger_pattern = EXCLUDED.trigger_pattern,
462
+ action_sequence = EXCLUDED.action_sequence,
463
+ success_rate = EXCLUDED.success_rate,
464
+ token_savings = EXCLUDED.token_savings,
465
+ applicable_languages = EXCLUDED.applicable_languages,
466
+ domain = EXCLUDED.domain,
467
+ last_used = EXCLUDED.last_used,
468
+ use_count = EXCLUDED.use_count,
469
+ created_at = EXCLUDED.created_at
470
+ """,
471
+ (
472
+ behavior.behavior_id,
473
+ behavior.description,
474
+ behavior.trigger_pattern,
475
+ json.dumps(behavior.action_sequence),
476
+ behavior.success_rate,
477
+ behavior.token_savings,
478
+ json.dumps(behavior.applicable_languages),
479
+ behavior.domain,
480
+ behavior.last_used.isoformat(),
481
+ behavior.use_count,
482
+ behavior.created_at.isoformat(),
483
+ ),
484
+ )
485
+ conn.commit()
486
+ return behavior.behavior_id
487
+ finally:
488
+ conn.close()
489
+
490
+ def get_behavior(self, behavior_id: str) -> BehaviorEntry:
491
+ """행동 엔트리를 조회합니다."""
492
+ conn = self._get_connection()
493
+ try:
494
+ with conn.cursor() as cur:
495
+ cur.execute(
496
+ """
497
+ SELECT behavior_id, description, trigger_pattern, action_sequence,
498
+ success_rate, token_savings, applicable_languages, domain,
499
+ last_used, use_count, created_at
500
+ FROM behavior_entries WHERE behavior_id = %s
501
+ """,
502
+ (behavior_id,),
503
+ )
504
+ row = cur.fetchone()
505
+
506
+ if not row:
507
+ raise KeyError(f"Behavior not found: {behavior_id}")
508
+
509
+ return self._row_to_behavior(row)
510
+ finally:
511
+ conn.close()
512
+
513
+ def list_behaviors(
514
+ self,
515
+ domain: str | None = None,
516
+ language: str | None = None,
517
+ min_success_rate: float = 0.0,
518
+ limit: int = 100,
519
+ ) -> list[BehaviorEntry]:
520
+ """행동 엔트리 목록을 조회합니다."""
521
+ conn = self._get_connection()
522
+ try:
523
+ with conn.cursor() as cur:
524
+ query = """
525
+ SELECT behavior_id, description, trigger_pattern, action_sequence,
526
+ success_rate, token_savings, applicable_languages, domain,
527
+ last_used, use_count, created_at
528
+ FROM behavior_entries
529
+ WHERE success_rate >= %s
530
+ """
531
+ params: list = [min_success_rate]
532
+
533
+ if domain:
534
+ query += " AND domain = %s"
535
+ params.append(domain)
536
+
537
+ query += " ORDER BY success_rate DESC, use_count DESC LIMIT %s"
538
+ params.append(limit)
539
+
540
+ cur.execute(query, params)
541
+ rows = cur.fetchall()
542
+
543
+ behaviors = [self._row_to_behavior(row) for row in rows]
544
+
545
+ if language:
546
+ behaviors = [b for b in behaviors if b.is_applicable(language)]
547
+
548
+ return behaviors
549
+ finally:
550
+ conn.close()
551
+
552
+ def get_handbook(self, domain: str) -> BehaviorHandbook:
553
+ """도메인별 행동 핸드북을 조회합니다."""
554
+ behaviors = self.list_behaviors(domain=domain, limit=1000)
555
+ handbook = BehaviorHandbook(domain=domain, behaviors=behaviors)
556
+ return handbook
557
+
558
+ def update_behavior(self, behavior: BehaviorEntry) -> None:
559
+ """행동 엔트리를 업데이트합니다."""
560
+ self.save_behavior(behavior)
561
+
562
+ def save_context(self, context: DomainMemoryContext) -> str:
563
+ """워킹 메모리 컨텍스트를 저장합니다."""
564
+ conn = self._get_connection()
565
+ try:
566
+ with conn.cursor() as cur:
567
+ cur.execute(
568
+ """
569
+ INSERT INTO memory_contexts (
570
+ session_id, domain, language, active_entities,
571
+ entity_type_distribution, current_quality_metrics,
572
+ started_at, updated_at
573
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
574
+ ON CONFLICT (session_id) DO UPDATE SET
575
+ domain = EXCLUDED.domain,
576
+ language = EXCLUDED.language,
577
+ active_entities = EXCLUDED.active_entities,
578
+ entity_type_distribution = EXCLUDED.entity_type_distribution,
579
+ current_quality_metrics = EXCLUDED.current_quality_metrics,
580
+ updated_at = EXCLUDED.updated_at
581
+ """,
582
+ (
583
+ context.session_id,
584
+ context.domain,
585
+ context.language,
586
+ json.dumps(list(context.active_entities)),
587
+ json.dumps(context.entity_type_distribution),
588
+ json.dumps(context.current_quality_metrics),
589
+ context.started_at.isoformat(),
590
+ context.updated_at.isoformat(),
591
+ ),
592
+ )
593
+ conn.commit()
594
+ return context.session_id
595
+ finally:
596
+ conn.close()
597
+
598
+ def get_context(self, session_id: str) -> DomainMemoryContext:
599
+ """워킹 메모리 컨텍스트를 조회합니다."""
600
+ conn = self._get_connection()
601
+ try:
602
+ with conn.cursor() as cur:
603
+ cur.execute(
604
+ """
605
+ SELECT session_id, domain, language, active_entities,
606
+ entity_type_distribution, current_quality_metrics,
607
+ started_at, updated_at
608
+ FROM memory_contexts WHERE session_id = %s
609
+ """,
610
+ (session_id,),
611
+ )
612
+ row = cur.fetchone()
613
+
614
+ if not row:
615
+ raise KeyError(f"Context not found: {session_id}")
616
+
617
+ return DomainMemoryContext(
618
+ session_id=row[0],
619
+ domain=row[1],
620
+ language=row[2],
621
+ active_entities=set(json.loads(row[3])) if row[3] else set(),
622
+ entity_type_distribution=json.loads(row[4]) if row[4] else {},
623
+ current_quality_metrics=json.loads(row[5]) if row[5] else {},
624
+ started_at=_coerce_datetime(row[6]),
625
+ updated_at=_coerce_datetime(row[7]),
626
+ )
627
+ finally:
628
+ conn.close()
629
+
630
+ def update_context(self, context: DomainMemoryContext) -> None:
631
+ """워킹 메모리 컨텍스트를 업데이트합니다."""
632
+ self.save_context(context)
633
+
634
+ def delete_context(self, session_id: str) -> bool:
635
+ """워킹 메모리 컨텍스트를 삭제합니다."""
636
+ conn = self._get_connection()
637
+ try:
638
+ with conn.cursor() as cur:
639
+ cur.execute("DELETE FROM memory_contexts WHERE session_id = %s", (session_id,))
640
+ deleted = cur.rowcount > 0
641
+ conn.commit()
642
+ return deleted
643
+ finally:
644
+ conn.close()
645
+
646
+ def get_statistics(self, domain: str | None = None) -> dict[str, int]:
647
+ """메모리 통계를 조회합니다."""
648
+ conn = self._get_connection()
649
+ try:
650
+ with conn.cursor() as cur:
651
+ domain_filter = " WHERE domain = %s" if domain else ""
652
+ params = [domain] if domain else []
653
+
654
+ cur.execute(f"SELECT COUNT(*) FROM factual_facts{domain_filter}", params)
655
+ facts_count = cur.fetchone()[0]
656
+
657
+ cur.execute(f"SELECT COUNT(*) FROM learning_memories{domain_filter}", params)
658
+ learnings_count = cur.fetchone()[0]
659
+
660
+ cur.execute(f"SELECT COUNT(*) FROM behavior_entries{domain_filter}", params)
661
+ behaviors_count = cur.fetchone()[0]
662
+
663
+ cur.execute(f"SELECT COUNT(*) FROM memory_contexts{domain_filter}", params)
664
+ contexts_count = cur.fetchone()[0]
665
+
666
+ return {
667
+ "facts": facts_count,
668
+ "learnings": learnings_count,
669
+ "behaviors": behaviors_count,
670
+ "contexts": contexts_count,
671
+ }
672
+ finally:
673
+ conn.close()
674
+
675
+ def consolidate_facts(self, domain: str, language: str) -> int:
676
+ """유사한 사실들을 통합합니다."""
677
+ conn = self._get_connection()
678
+ try:
679
+ with conn.cursor() as cur:
680
+ cur.execute(
681
+ """
682
+ SELECT subject, predicate, object, array_agg(fact_id) as fact_ids,
683
+ COUNT(*) as cnt
684
+ FROM factual_facts
685
+ WHERE domain = %s AND language = %s
686
+ GROUP BY subject, predicate, object
687
+ HAVING COUNT(*) > 1
688
+ """,
689
+ (domain, language),
690
+ )
691
+ duplicates = cur.fetchall()
692
+
693
+ consolidated_count = 0
694
+
695
+ for row in duplicates:
696
+ subject, predicate, obj, fact_ids, count = row
697
+
698
+ cur.execute(
699
+ """
700
+ SELECT fact_id, verification_score, verification_count,
701
+ source_document_ids, created_at, last_verified
702
+ FROM factual_facts
703
+ WHERE fact_id = ANY(%s)
704
+ ORDER BY verification_score DESC, verification_count DESC
705
+ """,
706
+ (fact_ids,),
707
+ )
708
+ facts_data = cur.fetchall()
709
+
710
+ primary_id = facts_data[0][0]
711
+ total_score = sum(f[1] for f in facts_data) / len(facts_data)
712
+ total_count = sum(f[2] for f in facts_data)
713
+
714
+ all_sources: set[str] = set()
715
+ for f in facts_data:
716
+ if f[3]:
717
+ sources = json.loads(f[3])
718
+ all_sources.update(sources)
719
+
720
+ latest_verified = max(f[5] for f in facts_data if f[5])
721
+
722
+ cur.execute(
723
+ """
724
+ UPDATE factual_facts
725
+ SET verification_score = %s,
726
+ verification_count = %s,
727
+ source_document_ids = %s,
728
+ last_verified = %s
729
+ WHERE fact_id = %s
730
+ """,
731
+ (
732
+ min(total_score, 1.0),
733
+ total_count,
734
+ json.dumps(list(all_sources)),
735
+ latest_verified,
736
+ primary_id,
737
+ ),
738
+ )
739
+
740
+ other_ids = [fid for fid in fact_ids if fid != primary_id]
741
+ if other_ids:
742
+ cur.execute(
743
+ "DELETE FROM factual_facts WHERE fact_id = ANY(%s)",
744
+ (other_ids,),
745
+ )
746
+ consolidated_count += len(other_ids)
747
+
748
+ self._log_evolution(
749
+ cur,
750
+ "consolidate",
751
+ "fact",
752
+ primary_id,
753
+ {"merged_ids": other_ids, "new_score": total_score},
754
+ )
755
+
756
+ conn.commit()
757
+ return consolidated_count
758
+ finally:
759
+ conn.close()
760
+
761
+ def _log_evolution(
762
+ self,
763
+ cur: psycopg.cursor.Cursor,
764
+ operation: str,
765
+ target_type: str,
766
+ target_id: str,
767
+ details: dict,
768
+ ) -> None:
769
+ """Evolution 로그를 기록합니다."""
770
+ cur.execute(
771
+ """
772
+ INSERT INTO memory_evolution_log (operation, target_type, target_id, details)
773
+ VALUES (%s, %s, %s, %s)
774
+ """,
775
+ (operation, target_type, target_id, json.dumps(details)),
776
+ )
777
+
778
+ def resolve_conflict(self, fact1: FactualFact, fact2: FactualFact) -> FactualFact:
779
+ """충돌하는 사실을 해결합니다."""
780
+ from math import log
781
+
782
+ def calculate_priority(fact: FactualFact) -> float:
783
+ base_score = fact.verification_score
784
+ count_factor = log(fact.verification_count + 1) + 1
785
+ recency_days = (datetime.now() - (fact.last_verified or fact.created_at)).days
786
+ recency_factor = 1.0 / (1 + recency_days / 30)
787
+ return base_score * count_factor * recency_factor
788
+
789
+ priority1 = calculate_priority(fact1)
790
+ priority2 = calculate_priority(fact2)
791
+
792
+ winner = fact1 if priority1 >= priority2 else fact2
793
+ loser = fact2 if priority1 >= priority2 else fact1
794
+
795
+ conn = self._get_connection()
796
+ try:
797
+ with conn.cursor() as cur:
798
+ cur.execute(
799
+ "UPDATE factual_facts SET fact_type = %s WHERE fact_id = %s",
800
+ ("contradictory", loser.fact_id),
801
+ )
802
+ self._log_evolution(
803
+ cur,
804
+ "resolve_conflict",
805
+ "fact",
806
+ winner.fact_id,
807
+ {
808
+ "loser_id": loser.fact_id,
809
+ "winner_priority": priority1 if priority1 >= priority2 else priority2,
810
+ "loser_priority": priority2 if priority1 >= priority2 else priority1,
811
+ },
812
+ )
813
+ conn.commit()
814
+ finally:
815
+ conn.close()
816
+
817
+ return winner
818
+
819
+ def forget_obsolete(
820
+ self,
821
+ domain: str,
822
+ max_age_days: int = 90,
823
+ min_verification_count: int = 1,
824
+ min_verification_score: float = 0.3,
825
+ ) -> int:
826
+ """오래되거나 신뢰도 낮은 메모리를 삭제합니다."""
827
+ conn = self._get_connection()
828
+ try:
829
+ with conn.cursor() as cur:
830
+ cutoff_date = datetime.now().isoformat()
831
+
832
+ cur.execute(
833
+ """
834
+ SELECT fact_id FROM factual_facts
835
+ WHERE domain = %s
836
+ AND (
837
+ (EXTRACT(DAY FROM %s::timestamp - last_verified) > %s
838
+ AND verification_count < %s)
839
+ OR verification_score < %s
840
+ )
841
+ """,
842
+ (
843
+ domain,
844
+ cutoff_date,
845
+ max_age_days,
846
+ min_verification_count,
847
+ min_verification_score,
848
+ ),
849
+ )
850
+ to_delete = [row[0] for row in cur.fetchall()]
851
+
852
+ if not to_delete:
853
+ return 0
854
+
855
+ for fact_id in to_delete:
856
+ self._log_evolution(
857
+ cur,
858
+ "forget",
859
+ "fact",
860
+ fact_id,
861
+ {
862
+ "max_age_days": max_age_days,
863
+ "min_verification_count": min_verification_count,
864
+ "min_verification_score": min_verification_score,
865
+ },
866
+ )
867
+
868
+ cur.execute(
869
+ """
870
+ DELETE FROM factual_facts
871
+ WHERE domain = %s
872
+ AND (
873
+ (EXTRACT(DAY FROM %s::timestamp - last_verified) > %s
874
+ AND verification_count < %s)
875
+ OR verification_score < %s
876
+ )
877
+ """,
878
+ (
879
+ domain,
880
+ cutoff_date,
881
+ max_age_days,
882
+ min_verification_count,
883
+ min_verification_score,
884
+ ),
885
+ )
886
+ deleted_count = cur.rowcount
887
+ conn.commit()
888
+ return deleted_count
889
+ finally:
890
+ conn.close()
891
+
892
+ def decay_verification_scores(self, domain: str, decay_rate: float = 0.95) -> int:
893
+ """시간에 따라 검증 점수를 감소시킵니다."""
894
+ if not 0.0 <= decay_rate <= 1.0:
895
+ raise ValueError("decay_rate must be between 0.0 and 1.0")
896
+
897
+ conn = self._get_connection()
898
+ try:
899
+ with conn.cursor() as cur:
900
+ min_days_since_verified = 7
901
+ cutoff_date = datetime.now().isoformat()
902
+
903
+ cur.execute(
904
+ """
905
+ SELECT fact_id, verification_score
906
+ FROM factual_facts
907
+ WHERE domain = %s
908
+ AND EXTRACT(DAY FROM %s::timestamp - last_verified) > %s
909
+ AND verification_score > 0.1
910
+ """,
911
+ (domain, cutoff_date, min_days_since_verified),
912
+ )
913
+ to_decay = cur.fetchall()
914
+
915
+ if not to_decay:
916
+ return 0
917
+
918
+ for fact_id, current_score in to_decay:
919
+ new_score = max(current_score * decay_rate, 0.1)
920
+ cur.execute(
921
+ """
922
+ UPDATE factual_facts
923
+ SET verification_score = %s
924
+ WHERE fact_id = %s
925
+ """,
926
+ (new_score, fact_id),
927
+ )
928
+ self._log_evolution(
929
+ cur,
930
+ "decay",
931
+ "fact",
932
+ fact_id,
933
+ {
934
+ "old_score": current_score,
935
+ "new_score": new_score,
936
+ "decay_rate": decay_rate,
937
+ },
938
+ )
939
+
940
+ conn.commit()
941
+ return len(to_decay)
942
+ finally:
943
+ conn.close()
944
+
945
+ def search_facts(
946
+ self,
947
+ query: str,
948
+ domain: str | None = None,
949
+ language: str | None = None,
950
+ limit: int = 10,
951
+ ) -> list[FactualFact]:
952
+ """키워드 기반 사실 검색 (ILIKE)."""
953
+ conn = self._get_connection()
954
+ try:
955
+ with conn.cursor() as cur:
956
+ search_term = f"%{query}%"
957
+
958
+ query_sql = """
959
+ SELECT fact_id, subject, predicate, object, language, domain,
960
+ fact_type, verification_score, verification_count,
961
+ source_document_ids, created_at, last_verified, abstraction_level
962
+ FROM factual_facts
963
+ WHERE (subject ILIKE %s OR predicate ILIKE %s OR object ILIKE %s)
964
+ """
965
+ params: list = [search_term, search_term, search_term]
966
+
967
+ if domain:
968
+ query_sql += " AND domain = %s"
969
+ params.append(domain)
970
+ if language:
971
+ query_sql += " AND language = %s"
972
+ params.append(language)
973
+
974
+ query_sql += " ORDER BY verification_score DESC LIMIT %s"
975
+ params.append(limit)
976
+
977
+ cur.execute(query_sql, params)
978
+ return [self._row_to_fact(row) for row in cur.fetchall()]
979
+ finally:
980
+ conn.close()
981
+
982
+ def search_behaviors(
983
+ self,
984
+ context: str,
985
+ domain: str,
986
+ language: str,
987
+ limit: int = 5,
988
+ ) -> list[BehaviorEntry]:
989
+ """컨텍스트 기반 행동 검색."""
990
+ conn = self._get_connection()
991
+ try:
992
+ with conn.cursor() as cur:
993
+ search_term = f"%{context}%"
994
+
995
+ cur.execute(
996
+ """
997
+ SELECT behavior_id, description, trigger_pattern, action_sequence,
998
+ success_rate, token_savings, applicable_languages, domain,
999
+ last_used, use_count, created_at
1000
+ FROM behavior_entries
1001
+ WHERE domain = %s
1002
+ AND (description ILIKE %s OR trigger_pattern ILIKE %s)
1003
+ ORDER BY success_rate DESC
1004
+ LIMIT %s
1005
+ """,
1006
+ (domain, search_term, search_term, limit * 3),
1007
+ )
1008
+ results = [self._row_to_behavior(row) for row in cur.fetchall()]
1009
+
1010
+ cur.execute(
1011
+ """
1012
+ SELECT behavior_id, description, trigger_pattern, action_sequence,
1013
+ success_rate, token_savings, applicable_languages, domain,
1014
+ last_used, use_count, created_at
1015
+ FROM behavior_entries
1016
+ WHERE domain = %s
1017
+ AND trigger_pattern IS NOT NULL
1018
+ AND trigger_pattern != ''
1019
+ ORDER BY success_rate DESC
1020
+ """,
1021
+ (domain,),
1022
+ )
1023
+ all_behaviors = [self._row_to_behavior(row) for row in cur.fetchall()]
1024
+
1025
+ for behavior in all_behaviors:
1026
+ if behavior in results:
1027
+ continue
1028
+ if not behavior.is_applicable(language):
1029
+ continue
1030
+ try:
1031
+ if re.search(behavior.trigger_pattern, context, re.IGNORECASE):
1032
+ results.append(behavior)
1033
+ except re.error:
1034
+ continue
1035
+
1036
+ results = [b for b in results if b.is_applicable(language)]
1037
+ results = sorted(results, key=lambda b: b.success_rate, reverse=True)
1038
+
1039
+ return results[:limit]
1040
+ finally:
1041
+ conn.close()
1042
+
1043
+ def hybrid_search(
1044
+ self,
1045
+ query: str,
1046
+ domain: str,
1047
+ language: str,
1048
+ fact_weight: float = 0.5,
1049
+ behavior_weight: float = 0.3,
1050
+ learning_weight: float = 0.2,
1051
+ limit: int = 10,
1052
+ ) -> dict[str, list]:
1053
+ """하이브리드 메모리 검색."""
1054
+ facts = self.search_facts(query, domain, language, int(limit * fact_weight))
1055
+ behaviors = self.search_behaviors(query, domain, language, int(limit * behavior_weight))
1056
+ learnings = self.list_learnings(domain, language, limit=int(limit * learning_weight))
1057
+
1058
+ return {
1059
+ "facts": facts,
1060
+ "behaviors": behaviors,
1061
+ "learnings": learnings,
1062
+ }