mcal-ai 0.1.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.
- mcal/__init__.py +165 -0
- mcal/backends/__init__.py +42 -0
- mcal/backends/base.py +383 -0
- mcal/baselines/__init__.py +1 -0
- mcal/core/__init__.py +101 -0
- mcal/core/embeddings.py +266 -0
- mcal/core/extraction_cache.py +398 -0
- mcal/core/goal_retriever.py +539 -0
- mcal/core/intent_tracker.py +734 -0
- mcal/core/models.py +445 -0
- mcal/core/rate_limiter.py +372 -0
- mcal/core/reasoning_store.py +1061 -0
- mcal/core/retry.py +188 -0
- mcal/core/storage.py +456 -0
- mcal/core/streaming.py +254 -0
- mcal/core/unified_extractor.py +1466 -0
- mcal/core/vector_index.py +206 -0
- mcal/evaluation/__init__.py +1 -0
- mcal/integrations/__init__.py +88 -0
- mcal/integrations/autogen.py +95 -0
- mcal/integrations/crewai.py +92 -0
- mcal/integrations/langchain.py +112 -0
- mcal/integrations/langgraph.py +50 -0
- mcal/mcal.py +1697 -0
- mcal/providers/bedrock.py +217 -0
- mcal/storage/__init__.py +1 -0
- mcal_ai-0.1.0.dist-info/METADATA +319 -0
- mcal_ai-0.1.0.dist-info/RECORD +32 -0
- mcal_ai-0.1.0.dist-info/WHEEL +5 -0
- mcal_ai-0.1.0.dist-info/entry_points.txt +2 -0
- mcal_ai-0.1.0.dist-info/licenses/LICENSE +21 -0
- mcal_ai-0.1.0.dist-info/top_level.txt +1 -0
mcal/core/retry.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Retry utilities for LLM API calls (Issue #38).
|
|
3
|
+
|
|
4
|
+
Provides exponential backoff retry decorators for handling transient
|
|
5
|
+
LLM API failures including rate limits, server errors, and timeouts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import functools
|
|
10
|
+
from typing import Type, Tuple, Callable, Any
|
|
11
|
+
from tenacity import (
|
|
12
|
+
retry,
|
|
13
|
+
stop_after_attempt,
|
|
14
|
+
wait_exponential_jitter,
|
|
15
|
+
retry_if_exception_type,
|
|
16
|
+
before_sleep_log,
|
|
17
|
+
RetryError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# =============================================================================
|
|
24
|
+
# Retryable Exception Types
|
|
25
|
+
# =============================================================================
|
|
26
|
+
|
|
27
|
+
class LLMRetryableError(Exception):
|
|
28
|
+
"""Base class for retryable LLM errors."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LLMRateLimitError(LLMRetryableError):
|
|
33
|
+
"""Rate limit exceeded (HTTP 429)."""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LLMServerError(LLMRetryableError):
|
|
38
|
+
"""Server-side error (HTTP 5xx)."""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class LLMTimeoutError(LLMRetryableError):
|
|
43
|
+
"""Connection or request timeout."""
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class LLMThrottlingError(LLMRetryableError):
|
|
48
|
+
"""AWS/Cloud provider throttling."""
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Default retryable exceptions tuple
|
|
53
|
+
RETRYABLE_EXCEPTIONS: Tuple[Type[Exception], ...] = (
|
|
54
|
+
LLMRateLimitError,
|
|
55
|
+
LLMServerError,
|
|
56
|
+
LLMTimeoutError,
|
|
57
|
+
LLMThrottlingError,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# =============================================================================
|
|
62
|
+
# Retry Decorator Factory
|
|
63
|
+
# =============================================================================
|
|
64
|
+
|
|
65
|
+
def llm_retry(
|
|
66
|
+
max_attempts: int = 3,
|
|
67
|
+
min_wait: float = 1.0,
|
|
68
|
+
max_wait: float = 10.0,
|
|
69
|
+
jitter: float = 1.0,
|
|
70
|
+
retryable_exceptions: Tuple[Type[Exception], ...] = RETRYABLE_EXCEPTIONS,
|
|
71
|
+
):
|
|
72
|
+
"""
|
|
73
|
+
Create a retry decorator for LLM API calls.
|
|
74
|
+
|
|
75
|
+
Uses exponential backoff with jitter to handle transient failures:
|
|
76
|
+
- HTTP 429 (Rate Limited)
|
|
77
|
+
- HTTP 500, 502, 503, 504 (Server Errors)
|
|
78
|
+
- Connection timeouts
|
|
79
|
+
- AWS throttling exceptions
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
max_attempts: Maximum number of retry attempts (default: 3)
|
|
83
|
+
min_wait: Minimum wait time in seconds (default: 1.0)
|
|
84
|
+
max_wait: Maximum wait time in seconds (default: 10.0)
|
|
85
|
+
jitter: Random jitter added to wait time (default: 1.0)
|
|
86
|
+
retryable_exceptions: Tuple of exception types to retry
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Decorator function
|
|
90
|
+
|
|
91
|
+
Example:
|
|
92
|
+
@llm_retry(max_attempts=3)
|
|
93
|
+
async def call_llm(prompt: str) -> str:
|
|
94
|
+
...
|
|
95
|
+
"""
|
|
96
|
+
return retry(
|
|
97
|
+
stop=stop_after_attempt(max_attempts),
|
|
98
|
+
wait=wait_exponential_jitter(initial=min_wait, max=max_wait, jitter=jitter),
|
|
99
|
+
retry=retry_if_exception_type(retryable_exceptions),
|
|
100
|
+
before_sleep=before_sleep_log(logger, logging.WARNING),
|
|
101
|
+
reraise=True,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def llm_retry_sync(
|
|
106
|
+
max_attempts: int = 3,
|
|
107
|
+
min_wait: float = 1.0,
|
|
108
|
+
max_wait: float = 10.0,
|
|
109
|
+
jitter: float = 1.0,
|
|
110
|
+
retryable_exceptions: Tuple[Type[Exception], ...] = RETRYABLE_EXCEPTIONS,
|
|
111
|
+
):
|
|
112
|
+
"""
|
|
113
|
+
Synchronous version of llm_retry for sync functions.
|
|
114
|
+
|
|
115
|
+
Same parameters as llm_retry().
|
|
116
|
+
"""
|
|
117
|
+
return retry(
|
|
118
|
+
stop=stop_after_attempt(max_attempts),
|
|
119
|
+
wait=wait_exponential_jitter(initial=min_wait, max=max_wait, jitter=jitter),
|
|
120
|
+
retry=retry_if_exception_type(retryable_exceptions),
|
|
121
|
+
before_sleep=before_sleep_log(logger, logging.WARNING),
|
|
122
|
+
reraise=True,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# =============================================================================
|
|
127
|
+
# Error Classification Helpers
|
|
128
|
+
# =============================================================================
|
|
129
|
+
|
|
130
|
+
def classify_http_error(status_code: int, message: str = "") -> Exception:
|
|
131
|
+
"""
|
|
132
|
+
Classify an HTTP error into the appropriate exception type.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
status_code: HTTP status code
|
|
136
|
+
message: Error message
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Appropriate exception instance
|
|
140
|
+
"""
|
|
141
|
+
if status_code == 429:
|
|
142
|
+
return LLMRateLimitError(f"Rate limited: {message}")
|
|
143
|
+
elif status_code in (500, 502, 503, 504):
|
|
144
|
+
return LLMServerError(f"Server error ({status_code}): {message}")
|
|
145
|
+
else:
|
|
146
|
+
# Non-retryable client error (4xx except 429)
|
|
147
|
+
return RuntimeError(f"HTTP {status_code}: {message}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def classify_boto_error(error_code: str, message: str = "") -> Exception:
|
|
151
|
+
"""
|
|
152
|
+
Classify a boto3/botocore error into the appropriate exception type.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
error_code: AWS error code (e.g., 'ThrottlingException')
|
|
156
|
+
message: Error message
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Appropriate exception instance
|
|
160
|
+
"""
|
|
161
|
+
throttling_codes = {
|
|
162
|
+
'ThrottlingException',
|
|
163
|
+
'Throttling',
|
|
164
|
+
'TooManyRequestsException',
|
|
165
|
+
'RequestThrottled',
|
|
166
|
+
'ProvisionedThroughputExceededException',
|
|
167
|
+
'ServiceUnavailableException',
|
|
168
|
+
'ModelStreamErrorException',
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
server_error_codes = {
|
|
172
|
+
'InternalServerException',
|
|
173
|
+
'ServiceException',
|
|
174
|
+
'InternalFailure',
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if error_code in throttling_codes:
|
|
178
|
+
return LLMThrottlingError(f"AWS throttling ({error_code}): {message}")
|
|
179
|
+
elif error_code in server_error_codes:
|
|
180
|
+
return LLMServerError(f"AWS server error ({error_code}): {message}")
|
|
181
|
+
else:
|
|
182
|
+
# Non-retryable AWS error
|
|
183
|
+
return RuntimeError(f"AWS error ({error_code}): {message}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def is_retryable_exception(exc: Exception) -> bool:
|
|
187
|
+
"""Check if an exception is retryable."""
|
|
188
|
+
return isinstance(exc, RETRYABLE_EXCEPTIONS)
|
mcal/core/storage.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCAL Storage Layer
|
|
3
|
+
|
|
4
|
+
Provides persistence for intent graphs and decision trails across sessions.
|
|
5
|
+
This enables cross-session reasoning preservation - the core value proposition.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional, Dict, Any, List
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
|
|
14
|
+
from .models import (
|
|
15
|
+
IntentGraph,
|
|
16
|
+
IntentNode,
|
|
17
|
+
IntentEdge,
|
|
18
|
+
IntentType,
|
|
19
|
+
IntentStatus,
|
|
20
|
+
EdgeRelation,
|
|
21
|
+
DecisionTrail,
|
|
22
|
+
Alternative,
|
|
23
|
+
Evidence,
|
|
24
|
+
EvidenceSource,
|
|
25
|
+
TradeOff,
|
|
26
|
+
)
|
|
27
|
+
from .unified_extractor import UnifiedGraph
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _utc_now() -> datetime:
|
|
31
|
+
"""Return current UTC time (timezone-aware)."""
|
|
32
|
+
return datetime.now(timezone.utc)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MCALStorage:
|
|
39
|
+
"""
|
|
40
|
+
Persistent storage for MCAL data structures.
|
|
41
|
+
|
|
42
|
+
Stores:
|
|
43
|
+
- Intent graphs per user (with versioning)
|
|
44
|
+
- Decision trails per user
|
|
45
|
+
- Session metadata
|
|
46
|
+
|
|
47
|
+
File structure:
|
|
48
|
+
~/.mcal/
|
|
49
|
+
users/
|
|
50
|
+
{user_id}/
|
|
51
|
+
intent_graph.json # Legacy 3-Pillar
|
|
52
|
+
decisions.json # Legacy 3-Pillar
|
|
53
|
+
unified_graph.json # Unified Deep (Issue #25)
|
|
54
|
+
sessions.json
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, base_path: Optional[Path] = None):
|
|
58
|
+
"""
|
|
59
|
+
Initialize storage.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
base_path: Base directory for storage (default: ~/.mcal)
|
|
63
|
+
"""
|
|
64
|
+
if base_path is None:
|
|
65
|
+
base_path = Path.home() / ".mcal"
|
|
66
|
+
|
|
67
|
+
self.base_path = Path(base_path)
|
|
68
|
+
self.users_path = self.base_path / "users"
|
|
69
|
+
self.users_path.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
logger.info(f"MCAL storage initialized at {self.base_path}")
|
|
71
|
+
|
|
72
|
+
def _get_user_path(self, user_id: str) -> Path:
|
|
73
|
+
"""Get path for user's data directory."""
|
|
74
|
+
user_path = self.users_path / user_id
|
|
75
|
+
user_path.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
return user_path
|
|
77
|
+
|
|
78
|
+
# =========================================================================
|
|
79
|
+
# Intent Graph Persistence
|
|
80
|
+
# =========================================================================
|
|
81
|
+
|
|
82
|
+
def save_intent_graph(self, user_id: str, graph: IntentGraph) -> None:
|
|
83
|
+
"""
|
|
84
|
+
Save intent graph for user.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
user_id: User identifier
|
|
88
|
+
graph: IntentGraph to save
|
|
89
|
+
"""
|
|
90
|
+
user_path = self._get_user_path(user_id)
|
|
91
|
+
graph_path = user_path / "intent_graph.json"
|
|
92
|
+
|
|
93
|
+
# Serialize graph
|
|
94
|
+
data = self._serialize_intent_graph(graph)
|
|
95
|
+
data["_metadata"] = {
|
|
96
|
+
"user_id": user_id,
|
|
97
|
+
"saved_at": _utc_now().isoformat(),
|
|
98
|
+
"node_count": len(graph.nodes),
|
|
99
|
+
"edge_count": len(graph.edges)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
with open(graph_path, 'w') as f:
|
|
103
|
+
json.dump(data, f, indent=2)
|
|
104
|
+
|
|
105
|
+
logger.info(f"Saved intent graph for {user_id}: {len(graph.nodes)} nodes, {len(graph.edges)} edges")
|
|
106
|
+
|
|
107
|
+
def load_intent_graph(self, user_id: str) -> Optional[IntentGraph]:
|
|
108
|
+
"""
|
|
109
|
+
Load intent graph for user.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
user_id: User identifier
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
IntentGraph or None if not found
|
|
116
|
+
"""
|
|
117
|
+
user_path = self._get_user_path(user_id)
|
|
118
|
+
graph_path = user_path / "intent_graph.json"
|
|
119
|
+
|
|
120
|
+
if not graph_path.exists():
|
|
121
|
+
logger.debug(f"No existing intent graph for {user_id}")
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
with open(graph_path, 'r') as f:
|
|
126
|
+
data = json.load(f)
|
|
127
|
+
|
|
128
|
+
graph = self._deserialize_intent_graph(data)
|
|
129
|
+
logger.info(f"Loaded intent graph for {user_id}: {len(graph.nodes)} nodes")
|
|
130
|
+
return graph
|
|
131
|
+
|
|
132
|
+
except Exception as e:
|
|
133
|
+
logger.error(f"Failed to load intent graph for {user_id}: {e}")
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
def _serialize_intent_graph(self, graph: IntentGraph) -> dict:
|
|
137
|
+
"""Serialize IntentGraph to JSON-compatible dict."""
|
|
138
|
+
return {
|
|
139
|
+
"session_id": graph.session_id,
|
|
140
|
+
"nodes": {
|
|
141
|
+
node_id: {
|
|
142
|
+
"id": node.id,
|
|
143
|
+
"type": node.type.value,
|
|
144
|
+
"content": node.content,
|
|
145
|
+
"status": node.status.value,
|
|
146
|
+
"confidence": node.confidence,
|
|
147
|
+
"evidence": node.evidence,
|
|
148
|
+
"created_at": node.created_at.isoformat() if node.created_at else None,
|
|
149
|
+
"updated_at": node.updated_at.isoformat() if node.updated_at else None,
|
|
150
|
+
}
|
|
151
|
+
for node_id, node in graph.nodes.items()
|
|
152
|
+
},
|
|
153
|
+
"edges": [
|
|
154
|
+
{
|
|
155
|
+
"id": edge.id,
|
|
156
|
+
"source": edge.source,
|
|
157
|
+
"target": edge.target,
|
|
158
|
+
"relation": edge.relation.value,
|
|
159
|
+
}
|
|
160
|
+
for edge in graph.edges
|
|
161
|
+
]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
def _deserialize_intent_graph(self, data: dict) -> IntentGraph:
|
|
165
|
+
"""Deserialize JSON dict to IntentGraph."""
|
|
166
|
+
graph = IntentGraph(session_id=data.get("session_id"))
|
|
167
|
+
|
|
168
|
+
# Reconstruct nodes
|
|
169
|
+
for node_id, node_data in data.get("nodes", {}).items():
|
|
170
|
+
node = IntentNode(
|
|
171
|
+
type=IntentType(node_data["type"]),
|
|
172
|
+
content=node_data["content"],
|
|
173
|
+
status=IntentStatus(node_data["status"]),
|
|
174
|
+
confidence=node_data.get("confidence", 0.8),
|
|
175
|
+
evidence=node_data.get("evidence", [])
|
|
176
|
+
)
|
|
177
|
+
# Override auto-generated ID with stored ID
|
|
178
|
+
node.id = node_data["id"]
|
|
179
|
+
graph.nodes[node.id] = node
|
|
180
|
+
|
|
181
|
+
# Reconstruct edges
|
|
182
|
+
for edge_data in data.get("edges", []):
|
|
183
|
+
edge = IntentEdge(
|
|
184
|
+
source=edge_data["source"],
|
|
185
|
+
target=edge_data["target"],
|
|
186
|
+
relation=EdgeRelation(edge_data["relation"])
|
|
187
|
+
)
|
|
188
|
+
edge.id = edge_data.get("id", edge.id)
|
|
189
|
+
graph.edges.append(edge)
|
|
190
|
+
|
|
191
|
+
return graph
|
|
192
|
+
|
|
193
|
+
# =========================================================================
|
|
194
|
+
# Unified Graph Persistence (Issue #25)
|
|
195
|
+
# =========================================================================
|
|
196
|
+
|
|
197
|
+
def save_unified_graph(self, user_id: str, graph: UnifiedGraph) -> None:
|
|
198
|
+
"""
|
|
199
|
+
Save unified graph for user.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
user_id: User identifier
|
|
203
|
+
graph: UnifiedGraph to save
|
|
204
|
+
"""
|
|
205
|
+
user_path = self._get_user_path(user_id)
|
|
206
|
+
graph_path = user_path / "unified_graph.json"
|
|
207
|
+
|
|
208
|
+
# Use existing to_dict() method
|
|
209
|
+
data = graph.to_dict()
|
|
210
|
+
data["_metadata"] = {
|
|
211
|
+
"user_id": user_id,
|
|
212
|
+
"saved_at": _utc_now().isoformat(),
|
|
213
|
+
"node_count": len(graph.nodes),
|
|
214
|
+
"edge_count": len(graph.edges),
|
|
215
|
+
"version": "unified_deep_v1"
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
with open(graph_path, 'w') as f:
|
|
219
|
+
json.dump(data, f, indent=2)
|
|
220
|
+
|
|
221
|
+
logger.info(f"Saved unified graph for {user_id}: {len(graph.nodes)} nodes, {len(graph.edges)} edges")
|
|
222
|
+
|
|
223
|
+
def load_unified_graph(self, user_id: str) -> Optional[UnifiedGraph]:
|
|
224
|
+
"""
|
|
225
|
+
Load unified graph for user.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
user_id: User identifier
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
UnifiedGraph or None if not found
|
|
232
|
+
"""
|
|
233
|
+
user_path = self._get_user_path(user_id)
|
|
234
|
+
graph_path = user_path / "unified_graph.json"
|
|
235
|
+
|
|
236
|
+
if not graph_path.exists():
|
|
237
|
+
logger.debug(f"No existing unified graph for {user_id}")
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
with open(graph_path, 'r') as f:
|
|
242
|
+
data = json.load(f)
|
|
243
|
+
|
|
244
|
+
# Use existing from_dict() method
|
|
245
|
+
graph = UnifiedGraph.from_dict(data)
|
|
246
|
+
logger.info(f"Loaded unified graph for {user_id}: {len(graph.nodes)} nodes")
|
|
247
|
+
return graph
|
|
248
|
+
|
|
249
|
+
except Exception as e:
|
|
250
|
+
logger.error(f"Failed to load unified graph for {user_id}: {e}")
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
def delete_unified_graph(self, user_id: str) -> bool:
|
|
254
|
+
"""
|
|
255
|
+
Delete unified graph for user.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
user_id: User identifier
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
True if deleted, False if not found
|
|
262
|
+
"""
|
|
263
|
+
user_path = self._get_user_path(user_id)
|
|
264
|
+
graph_path = user_path / "unified_graph.json"
|
|
265
|
+
|
|
266
|
+
if graph_path.exists():
|
|
267
|
+
graph_path.unlink()
|
|
268
|
+
logger.info(f"Deleted unified graph for {user_id}")
|
|
269
|
+
return True
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
# =========================================================================
|
|
273
|
+
# Decision Trail Persistence
|
|
274
|
+
# =========================================================================
|
|
275
|
+
|
|
276
|
+
def save_decisions(self, user_id: str, decisions: List[DecisionTrail]) -> None:
|
|
277
|
+
"""
|
|
278
|
+
Save decision trails for user.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
user_id: User identifier
|
|
282
|
+
decisions: List of DecisionTrail objects
|
|
283
|
+
"""
|
|
284
|
+
user_path = self._get_user_path(user_id)
|
|
285
|
+
decisions_path = user_path / "decisions.json"
|
|
286
|
+
|
|
287
|
+
data = {
|
|
288
|
+
"decisions": [
|
|
289
|
+
self._serialize_decision(decision)
|
|
290
|
+
for decision in decisions
|
|
291
|
+
],
|
|
292
|
+
"_metadata": {
|
|
293
|
+
"user_id": user_id,
|
|
294
|
+
"saved_at": _utc_now().isoformat(),
|
|
295
|
+
"decision_count": len(decisions)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
with open(decisions_path, 'w') as f:
|
|
300
|
+
json.dump(data, f, indent=2)
|
|
301
|
+
|
|
302
|
+
logger.info(f"Saved {len(decisions)} decisions for {user_id}")
|
|
303
|
+
|
|
304
|
+
def load_decisions(self, user_id: str) -> List[DecisionTrail]:
|
|
305
|
+
"""
|
|
306
|
+
Load decision trails for user.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
user_id: User identifier
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
List of DecisionTrail objects
|
|
313
|
+
"""
|
|
314
|
+
user_path = self._get_user_path(user_id)
|
|
315
|
+
decisions_path = user_path / "decisions.json"
|
|
316
|
+
|
|
317
|
+
if not decisions_path.exists():
|
|
318
|
+
logger.debug(f"No existing decisions for {user_id}")
|
|
319
|
+
return []
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
with open(decisions_path, 'r') as f:
|
|
323
|
+
data = json.load(f)
|
|
324
|
+
|
|
325
|
+
decisions = [
|
|
326
|
+
self._deserialize_decision(decision_data)
|
|
327
|
+
for decision_data in data.get("decisions", [])
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
logger.info(f"Loaded {len(decisions)} decisions for {user_id}")
|
|
331
|
+
return decisions
|
|
332
|
+
|
|
333
|
+
except Exception as e:
|
|
334
|
+
logger.error(f"Failed to load decisions for {user_id}: {e}")
|
|
335
|
+
return []
|
|
336
|
+
|
|
337
|
+
def _serialize_decision(self, decision: DecisionTrail) -> dict:
|
|
338
|
+
"""Serialize DecisionTrail to JSON-compatible dict."""
|
|
339
|
+
return {
|
|
340
|
+
"id": decision.id,
|
|
341
|
+
"decision": decision.decision,
|
|
342
|
+
"context": decision.context,
|
|
343
|
+
"rationale": decision.rationale,
|
|
344
|
+
"alternatives": [
|
|
345
|
+
{
|
|
346
|
+
"option": alt.option,
|
|
347
|
+
"pros": alt.pros,
|
|
348
|
+
"cons": alt.cons,
|
|
349
|
+
"rejection_reason": alt.rejection_reason
|
|
350
|
+
}
|
|
351
|
+
for alt in decision.alternatives
|
|
352
|
+
],
|
|
353
|
+
"evidence": [
|
|
354
|
+
{
|
|
355
|
+
"claim": ev.claim,
|
|
356
|
+
"source": ev.source.value,
|
|
357
|
+
"turn_id": ev.turn_id
|
|
358
|
+
}
|
|
359
|
+
for ev in decision.evidence
|
|
360
|
+
],
|
|
361
|
+
"trade_offs": [
|
|
362
|
+
{
|
|
363
|
+
"gained": to.gained,
|
|
364
|
+
"sacrificed": to.sacrificed,
|
|
365
|
+
"justification": to.justification
|
|
366
|
+
}
|
|
367
|
+
for to in decision.trade_offs
|
|
368
|
+
],
|
|
369
|
+
"confidence": decision.confidence,
|
|
370
|
+
"related_goals": decision.related_goals,
|
|
371
|
+
"dependencies": decision.dependencies,
|
|
372
|
+
"created_at": decision.created_at.isoformat() if decision.created_at else None,
|
|
373
|
+
"is_valid": decision.is_valid,
|
|
374
|
+
"invalidated_by": decision.invalidated_by
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
def _deserialize_decision(self, data: dict) -> DecisionTrail:
|
|
378
|
+
"""Deserialize JSON dict to DecisionTrail."""
|
|
379
|
+
# Reconstruct alternatives
|
|
380
|
+
alternatives = [
|
|
381
|
+
Alternative(
|
|
382
|
+
option=alt["option"],
|
|
383
|
+
pros=alt.get("pros", []),
|
|
384
|
+
cons=alt.get("cons", []),
|
|
385
|
+
rejection_reason=alt.get("rejection_reason", "")
|
|
386
|
+
)
|
|
387
|
+
for alt in data.get("alternatives", [])
|
|
388
|
+
]
|
|
389
|
+
|
|
390
|
+
# Reconstruct evidence
|
|
391
|
+
evidence = [
|
|
392
|
+
Evidence(
|
|
393
|
+
claim=ev["claim"],
|
|
394
|
+
source=EvidenceSource(ev.get("source", "inferred")),
|
|
395
|
+
turn_id=ev.get("turn_id")
|
|
396
|
+
)
|
|
397
|
+
for ev in data.get("evidence", [])
|
|
398
|
+
]
|
|
399
|
+
|
|
400
|
+
# Reconstruct trade-offs
|
|
401
|
+
trade_offs = [
|
|
402
|
+
TradeOff(
|
|
403
|
+
gained=to["gained"],
|
|
404
|
+
sacrificed=to["sacrificed"],
|
|
405
|
+
justification=to.get("justification", "")
|
|
406
|
+
)
|
|
407
|
+
for to in data.get("trade_offs", [])
|
|
408
|
+
]
|
|
409
|
+
|
|
410
|
+
# Create decision with all fields
|
|
411
|
+
decision = DecisionTrail(
|
|
412
|
+
id=data["id"],
|
|
413
|
+
decision=data["decision"],
|
|
414
|
+
context=data.get("context", ""),
|
|
415
|
+
rationale=data.get("rationale", ""),
|
|
416
|
+
alternatives=alternatives,
|
|
417
|
+
evidence=evidence,
|
|
418
|
+
trade_offs=trade_offs,
|
|
419
|
+
confidence=data.get("confidence", 0.8),
|
|
420
|
+
related_goals=data.get("related_goals", []),
|
|
421
|
+
dependencies=data.get("dependencies", []),
|
|
422
|
+
invalidated_by=data.get("invalidated_by")
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
return decision
|
|
426
|
+
|
|
427
|
+
# =========================================================================
|
|
428
|
+
# Utility Methods
|
|
429
|
+
# =========================================================================
|
|
430
|
+
|
|
431
|
+
def clear_user_data(self, user_id: str) -> None:
|
|
432
|
+
"""Clear all stored data for a user."""
|
|
433
|
+
import shutil
|
|
434
|
+
user_path = self._get_user_path(user_id)
|
|
435
|
+
if user_path.exists():
|
|
436
|
+
shutil.rmtree(user_path)
|
|
437
|
+
logger.info(f"Cleared all data for {user_id}")
|
|
438
|
+
|
|
439
|
+
def list_users(self) -> list[str]:
|
|
440
|
+
"""List all users with stored data."""
|
|
441
|
+
if not self.users_path.exists():
|
|
442
|
+
return []
|
|
443
|
+
return [d.name for d in self.users_path.iterdir() if d.is_dir()]
|
|
444
|
+
|
|
445
|
+
def get_user_summary(self, user_id: str) -> dict:
|
|
446
|
+
"""Get summary of stored data for a user."""
|
|
447
|
+
graph = self.load_intent_graph(user_id)
|
|
448
|
+
decisions = self.load_decisions(user_id)
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
"user_id": user_id,
|
|
452
|
+
"has_intent_graph": graph is not None,
|
|
453
|
+
"node_count": len(graph.nodes) if graph else 0,
|
|
454
|
+
"decision_count": len(decisions),
|
|
455
|
+
"active_goals": len(graph.get_active_goals()) if graph else 0
|
|
456
|
+
}
|