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.
- evalvault/adapters/inbound/api/adapter.py +127 -80
- evalvault/adapters/inbound/api/routers/calibration.py +9 -9
- evalvault/adapters/inbound/api/routers/chat.py +303 -17
- evalvault/adapters/inbound/api/routers/config.py +3 -1
- evalvault/adapters/inbound/api/routers/domain.py +10 -5
- evalvault/adapters/inbound/api/routers/pipeline.py +3 -3
- evalvault/adapters/inbound/api/routers/runs.py +23 -4
- evalvault/adapters/inbound/cli/commands/analyze.py +10 -12
- evalvault/adapters/inbound/cli/commands/benchmark.py +10 -8
- evalvault/adapters/inbound/cli/commands/calibrate.py +2 -7
- evalvault/adapters/inbound/cli/commands/calibrate_judge.py +2 -7
- evalvault/adapters/inbound/cli/commands/compare.py +2 -7
- evalvault/adapters/inbound/cli/commands/debug.py +3 -2
- evalvault/adapters/inbound/cli/commands/domain.py +12 -12
- evalvault/adapters/inbound/cli/commands/experiment.py +9 -8
- evalvault/adapters/inbound/cli/commands/gate.py +3 -2
- evalvault/adapters/inbound/cli/commands/graph_rag.py +2 -2
- evalvault/adapters/inbound/cli/commands/history.py +3 -12
- evalvault/adapters/inbound/cli/commands/method.py +3 -4
- evalvault/adapters/inbound/cli/commands/ops.py +2 -2
- evalvault/adapters/inbound/cli/commands/pipeline.py +2 -2
- evalvault/adapters/inbound/cli/commands/profile_difficulty.py +3 -12
- evalvault/adapters/inbound/cli/commands/prompts.py +4 -18
- evalvault/adapters/inbound/cli/commands/regress.py +5 -4
- evalvault/adapters/inbound/cli/commands/run.py +188 -59
- evalvault/adapters/inbound/cli/commands/run_helpers.py +181 -70
- evalvault/adapters/inbound/cli/commands/stage.py +6 -25
- evalvault/adapters/inbound/cli/utils/options.py +10 -4
- evalvault/adapters/inbound/mcp/tools.py +11 -8
- evalvault/adapters/outbound/analysis/embedding_analyzer_module.py +17 -1
- evalvault/adapters/outbound/analysis/embedding_searcher_module.py +14 -0
- evalvault/adapters/outbound/domain_memory/__init__.py +8 -4
- evalvault/adapters/outbound/domain_memory/factory.py +68 -0
- evalvault/adapters/outbound/domain_memory/postgres_adapter.py +1062 -0
- evalvault/adapters/outbound/domain_memory/postgres_domain_memory_schema.sql +177 -0
- evalvault/adapters/outbound/llm/factory.py +1 -1
- evalvault/adapters/outbound/llm/vllm_adapter.py +23 -0
- evalvault/adapters/outbound/nlp/korean/dense_retriever.py +10 -7
- evalvault/adapters/outbound/nlp/korean/toolkit.py +15 -4
- evalvault/adapters/outbound/phoenix/sync_service.py +99 -0
- evalvault/adapters/outbound/retriever/pgvector_store.py +165 -0
- evalvault/adapters/outbound/storage/base_sql.py +3 -2
- evalvault/adapters/outbound/storage/factory.py +53 -0
- evalvault/adapters/outbound/storage/postgres_schema.sql +2 -0
- evalvault/adapters/outbound/tracker/mlflow_adapter.py +209 -54
- evalvault/adapters/outbound/tracker/phoenix_adapter.py +158 -9
- evalvault/config/instrumentation.py +8 -6
- evalvault/config/phoenix_support.py +5 -0
- evalvault/config/settings.py +71 -11
- evalvault/domain/services/domain_learning_hook.py +2 -1
- evalvault/domain/services/evaluator.py +2 -0
- evalvault/ports/inbound/web_port.py +3 -1
- evalvault/ports/outbound/storage_port.py +2 -0
- evalvault-1.76.0.dist-info/METADATA +221 -0
- {evalvault-1.74.0.dist-info → evalvault-1.76.0.dist-info}/RECORD +58 -53
- evalvault-1.74.0.dist-info/METADATA +0 -585
- {evalvault-1.74.0.dist-info → evalvault-1.76.0.dist-info}/WHEEL +0 -0
- {evalvault-1.74.0.dist-info → evalvault-1.76.0.dist-info}/entry_points.txt +0 -0
- {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
|
+
}
|