hegelion 0.4.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.
- hegelion/__init__.py +45 -0
- hegelion/core/__init__.py +29 -0
- hegelion/core/agent.py +166 -0
- hegelion/core/autocoding_state.py +293 -0
- hegelion/core/backends.py +442 -0
- hegelion/core/cache.py +92 -0
- hegelion/core/config.py +276 -0
- hegelion/core/core.py +649 -0
- hegelion/core/engine.py +865 -0
- hegelion/core/logging_utils.py +67 -0
- hegelion/core/models.py +293 -0
- hegelion/core/parsing.py +271 -0
- hegelion/core/personas.py +81 -0
- hegelion/core/prompt_autocoding.py +353 -0
- hegelion/core/prompt_dialectic.py +414 -0
- hegelion/core/prompts.py +127 -0
- hegelion/core/schema.py +67 -0
- hegelion/core/validation.py +68 -0
- hegelion/council.py +254 -0
- hegelion/examples_data/__init__.py +6 -0
- hegelion/examples_data/glm4_6_examples.jsonl +2 -0
- hegelion/judge.py +230 -0
- hegelion/mcp/__init__.py +3 -0
- hegelion/mcp/server.py +918 -0
- hegelion/scripts/hegelion_agent_cli.py +90 -0
- hegelion/scripts/hegelion_bench.py +117 -0
- hegelion/scripts/hegelion_cli.py +497 -0
- hegelion/scripts/hegelion_dataset.py +99 -0
- hegelion/scripts/hegelion_eval.py +137 -0
- hegelion/scripts/mcp_setup.py +150 -0
- hegelion/search_providers.py +151 -0
- hegelion/training/__init__.py +7 -0
- hegelion/training/datasets.py +123 -0
- hegelion/training/generator.py +232 -0
- hegelion/training/mlx_scu_trainer.py +379 -0
- hegelion/training/mlx_trainer.py +181 -0
- hegelion/training/unsloth_trainer.py +136 -0
- hegelion-0.4.0.dist-info/METADATA +295 -0
- hegelion-0.4.0.dist-info/RECORD +43 -0
- hegelion-0.4.0.dist-info/WHEEL +5 -0
- hegelion-0.4.0.dist-info/entry_points.txt +8 -0
- hegelion-0.4.0.dist-info/licenses/LICENSE +21 -0
- hegelion-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""Backend abstractions for calling different LLM providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Dict, Optional, Protocol
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import inspect
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from openai import AsyncOpenAI
|
|
15
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
16
|
+
AsyncOpenAI = None # type: ignore
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from anthropic import AsyncAnthropic
|
|
20
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
21
|
+
AsyncAnthropic = None # type: ignore
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
import google.generativeai as genai
|
|
25
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
26
|
+
genai = None # type: ignore
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BackendNotAvailableError(RuntimeError):
|
|
30
|
+
"""Raised when a backend cannot be initialized."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LLMBackend(Protocol):
|
|
34
|
+
"""Minimal interface required by the dialectical engine."""
|
|
35
|
+
|
|
36
|
+
async def generate(
|
|
37
|
+
self,
|
|
38
|
+
prompt: str,
|
|
39
|
+
max_tokens: int = 1_000,
|
|
40
|
+
temperature: float = 0.7,
|
|
41
|
+
system_prompt: Optional[str] = None,
|
|
42
|
+
) -> str: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class OpenAILLMBackend:
|
|
47
|
+
"""Backend that targets OpenAI-compatible chat completion APIs."""
|
|
48
|
+
|
|
49
|
+
model: str
|
|
50
|
+
api_key: str
|
|
51
|
+
base_url: Optional[str] = None
|
|
52
|
+
organization: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
def __post_init__(self) -> None:
|
|
55
|
+
if AsyncOpenAI is None: # pragma: no cover - import guard
|
|
56
|
+
raise BackendNotAvailableError(
|
|
57
|
+
"openai package is not installed but OpenAI backend was requested."
|
|
58
|
+
)
|
|
59
|
+
self.client = AsyncOpenAI(
|
|
60
|
+
api_key=self.api_key,
|
|
61
|
+
base_url=self.base_url,
|
|
62
|
+
organization=self.organization,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
async def generate(
|
|
66
|
+
self,
|
|
67
|
+
prompt: str,
|
|
68
|
+
max_tokens: int = 1_000,
|
|
69
|
+
temperature: float = 0.7,
|
|
70
|
+
system_prompt: Optional[str] = None,
|
|
71
|
+
) -> str:
|
|
72
|
+
messages = []
|
|
73
|
+
if system_prompt:
|
|
74
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
75
|
+
messages.append({"role": "user", "content": prompt})
|
|
76
|
+
maybe_response = self.client.chat.completions.create(
|
|
77
|
+
model=self.model,
|
|
78
|
+
messages=messages,
|
|
79
|
+
max_tokens=max_tokens,
|
|
80
|
+
temperature=temperature,
|
|
81
|
+
)
|
|
82
|
+
response = await maybe_response if inspect.isawaitable(maybe_response) else maybe_response
|
|
83
|
+
content = response.choices[0].message.content
|
|
84
|
+
return content.strip() if content else ""
|
|
85
|
+
|
|
86
|
+
async def stream_generate(
|
|
87
|
+
self,
|
|
88
|
+
prompt: str,
|
|
89
|
+
max_tokens: int = 1_000,
|
|
90
|
+
temperature: float = 0.7,
|
|
91
|
+
system_prompt: Optional[str] = None,
|
|
92
|
+
):
|
|
93
|
+
messages = []
|
|
94
|
+
if system_prompt:
|
|
95
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
96
|
+
messages.append({"role": "user", "content": prompt})
|
|
97
|
+
|
|
98
|
+
maybe_stream = self.client.chat.completions.create(
|
|
99
|
+
model=self.model,
|
|
100
|
+
messages=messages,
|
|
101
|
+
max_tokens=max_tokens,
|
|
102
|
+
temperature=temperature,
|
|
103
|
+
stream=True,
|
|
104
|
+
)
|
|
105
|
+
stream = await maybe_stream if inspect.isawaitable(maybe_stream) else maybe_stream
|
|
106
|
+
async for chunk in stream:
|
|
107
|
+
choices = getattr(chunk, "choices", None)
|
|
108
|
+
if not choices:
|
|
109
|
+
continue
|
|
110
|
+
delta = choices[0].delta
|
|
111
|
+
content = getattr(delta, "content", None)
|
|
112
|
+
if content:
|
|
113
|
+
yield content
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _missing_client(): # pragma: no cover - fallback for optional dependency
|
|
117
|
+
async def _raise(*_: Any, **__: Any):
|
|
118
|
+
raise BackendNotAvailableError(
|
|
119
|
+
"openai package is not installed but OpenAI backend was requested."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
class _Completions:
|
|
123
|
+
create = _raise
|
|
124
|
+
|
|
125
|
+
class _Chat:
|
|
126
|
+
completions = _Completions()
|
|
127
|
+
|
|
128
|
+
class _Client:
|
|
129
|
+
chat = _Chat()
|
|
130
|
+
|
|
131
|
+
return _Client()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class AnthropicLLMBackend:
|
|
136
|
+
"""Backend targeting Anthropic Claude models."""
|
|
137
|
+
|
|
138
|
+
model: str
|
|
139
|
+
api_key: str
|
|
140
|
+
base_url: Optional[str] = None
|
|
141
|
+
|
|
142
|
+
def __post_init__(self) -> None:
|
|
143
|
+
if AsyncAnthropic is None: # pragma: no cover - import guard
|
|
144
|
+
raise BackendNotAvailableError(
|
|
145
|
+
"anthropic package is not installed but Anthropic backend was requested."
|
|
146
|
+
)
|
|
147
|
+
self.client = AsyncAnthropic(api_key=self.api_key, base_url=self.base_url)
|
|
148
|
+
|
|
149
|
+
async def generate(
|
|
150
|
+
self,
|
|
151
|
+
prompt: str,
|
|
152
|
+
max_tokens: int = 1_000,
|
|
153
|
+
temperature: float = 0.7,
|
|
154
|
+
system_prompt: Optional[str] = None,
|
|
155
|
+
) -> str:
|
|
156
|
+
response = await self.client.messages.create(
|
|
157
|
+
model=self.model,
|
|
158
|
+
max_tokens=max_tokens,
|
|
159
|
+
temperature=temperature,
|
|
160
|
+
system=system_prompt,
|
|
161
|
+
messages=[{"role": "user", "content": prompt}],
|
|
162
|
+
)
|
|
163
|
+
text_chunks = [
|
|
164
|
+
block.text for block in response.content if getattr(block, "type", None) == "text"
|
|
165
|
+
]
|
|
166
|
+
return "\n".join(text_chunks).strip()
|
|
167
|
+
|
|
168
|
+
async def stream_generate(
|
|
169
|
+
self,
|
|
170
|
+
prompt: str,
|
|
171
|
+
max_tokens: int = 1_000,
|
|
172
|
+
temperature: float = 0.7,
|
|
173
|
+
system_prompt: Optional[str] = None,
|
|
174
|
+
):
|
|
175
|
+
maybe_stream = self.client.messages.create(
|
|
176
|
+
model=self.model,
|
|
177
|
+
max_tokens=max_tokens,
|
|
178
|
+
temperature=temperature,
|
|
179
|
+
system=system_prompt,
|
|
180
|
+
messages=[{"role": "user", "content": prompt}],
|
|
181
|
+
stream=True,
|
|
182
|
+
)
|
|
183
|
+
stream = await maybe_stream if inspect.isawaitable(maybe_stream) else maybe_stream
|
|
184
|
+
async for event in stream:
|
|
185
|
+
if getattr(event, "type", None) == "content_block_delta":
|
|
186
|
+
delta = getattr(event, "delta", None)
|
|
187
|
+
if delta and getattr(delta, "text", None):
|
|
188
|
+
yield delta.text
|
|
189
|
+
elif getattr(event, "type", None) == "content_block_start":
|
|
190
|
+
block = getattr(event, "content_block", None)
|
|
191
|
+
if block and getattr(block, "text", None):
|
|
192
|
+
yield block.text
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass
|
|
196
|
+
class OllamaLLMBackend:
|
|
197
|
+
"""Backend for local Ollama servers using the HTTP API."""
|
|
198
|
+
|
|
199
|
+
model: str
|
|
200
|
+
base_url: str = "http://localhost:11434"
|
|
201
|
+
timeout: float = 60.0
|
|
202
|
+
|
|
203
|
+
async def generate(
|
|
204
|
+
self,
|
|
205
|
+
prompt: str,
|
|
206
|
+
max_tokens: int = 1_000,
|
|
207
|
+
temperature: float = 0.7,
|
|
208
|
+
system_prompt: Optional[str] = None,
|
|
209
|
+
) -> str:
|
|
210
|
+
payload = {
|
|
211
|
+
"model": self.model,
|
|
212
|
+
"prompt": prompt,
|
|
213
|
+
"system": system_prompt,
|
|
214
|
+
"options": {
|
|
215
|
+
"temperature": temperature,
|
|
216
|
+
"num_predict": max_tokens,
|
|
217
|
+
},
|
|
218
|
+
"stream": False,
|
|
219
|
+
}
|
|
220
|
+
url = f"{self.base_url.rstrip('/')}/api/generate"
|
|
221
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
222
|
+
response = await client.post(url, json=payload)
|
|
223
|
+
response.raise_for_status()
|
|
224
|
+
data = response.json()
|
|
225
|
+
text = data.get("response") or data.get("data") or ""
|
|
226
|
+
return str(text).strip()
|
|
227
|
+
|
|
228
|
+
async def stream_generate(
|
|
229
|
+
self,
|
|
230
|
+
prompt: str,
|
|
231
|
+
max_tokens: int = 1_000,
|
|
232
|
+
temperature: float = 0.7,
|
|
233
|
+
system_prompt: Optional[str] = None,
|
|
234
|
+
):
|
|
235
|
+
payload = {
|
|
236
|
+
"model": self.model,
|
|
237
|
+
"prompt": prompt,
|
|
238
|
+
"system": system_prompt,
|
|
239
|
+
"options": {
|
|
240
|
+
"temperature": temperature,
|
|
241
|
+
"num_predict": max_tokens,
|
|
242
|
+
},
|
|
243
|
+
"stream": True,
|
|
244
|
+
}
|
|
245
|
+
url = f"{self.base_url.rstrip('/')}/api/generate"
|
|
246
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
247
|
+
stream_ctx = client.stream("POST", url, json=payload)
|
|
248
|
+
stream_ctx = await stream_ctx if inspect.isawaitable(stream_ctx) else stream_ctx
|
|
249
|
+
async with stream_ctx as response:
|
|
250
|
+
response.raise_for_status()
|
|
251
|
+
lines_iter = response.aiter_lines()
|
|
252
|
+
lines_iter = await lines_iter if inspect.isawaitable(lines_iter) else lines_iter
|
|
253
|
+
async for line in lines_iter:
|
|
254
|
+
if not line:
|
|
255
|
+
continue
|
|
256
|
+
try:
|
|
257
|
+
data = json.loads(line)
|
|
258
|
+
except Exception:
|
|
259
|
+
continue
|
|
260
|
+
chunk = data.get("response") or data.get("data")
|
|
261
|
+
if chunk:
|
|
262
|
+
yield str(chunk)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass
|
|
266
|
+
class CustomHTTPLLMBackend:
|
|
267
|
+
"""Lightweight backend for generic JSON HTTP APIs."""
|
|
268
|
+
|
|
269
|
+
model: str
|
|
270
|
+
api_base_url: str
|
|
271
|
+
api_key: Optional[str] = None
|
|
272
|
+
timeout: float = 60.0
|
|
273
|
+
|
|
274
|
+
async def generate(
|
|
275
|
+
self,
|
|
276
|
+
prompt: str,
|
|
277
|
+
max_tokens: int = 1_000,
|
|
278
|
+
temperature: float = 0.7,
|
|
279
|
+
system_prompt: Optional[str] = None,
|
|
280
|
+
) -> str:
|
|
281
|
+
payload: Dict[str, Any] = {
|
|
282
|
+
"model": self.model,
|
|
283
|
+
"prompt": prompt,
|
|
284
|
+
"max_tokens": max_tokens,
|
|
285
|
+
"temperature": temperature,
|
|
286
|
+
}
|
|
287
|
+
if system_prompt:
|
|
288
|
+
payload["system_prompt"] = system_prompt
|
|
289
|
+
|
|
290
|
+
headers = {"Content-Type": "application/json"}
|
|
291
|
+
if self.api_key:
|
|
292
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
293
|
+
|
|
294
|
+
url = self.api_base_url
|
|
295
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
296
|
+
response = await client.post(url, headers=headers, json=payload)
|
|
297
|
+
response.raise_for_status()
|
|
298
|
+
data: Dict[str, Any] = response.json()
|
|
299
|
+
|
|
300
|
+
for key in ("text", "completion", "result", "output"):
|
|
301
|
+
value = data.get(key)
|
|
302
|
+
if isinstance(value, str):
|
|
303
|
+
return value.strip()
|
|
304
|
+
|
|
305
|
+
choices = data.get("choices")
|
|
306
|
+
if isinstance(choices, list) and choices:
|
|
307
|
+
first = choices[0]
|
|
308
|
+
if isinstance(first, dict):
|
|
309
|
+
for key in ("text", "content"):
|
|
310
|
+
value = first.get(key)
|
|
311
|
+
if isinstance(value, str):
|
|
312
|
+
return value.strip()
|
|
313
|
+
message = first.get("message")
|
|
314
|
+
if isinstance(message, dict):
|
|
315
|
+
content = message.get("content")
|
|
316
|
+
if isinstance(content, str):
|
|
317
|
+
return content.strip()
|
|
318
|
+
|
|
319
|
+
# Fallback: return the entire JSON payload as a string for inspection
|
|
320
|
+
return str(data)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class DummyLLMBackend:
|
|
324
|
+
"""Deterministic backend for tests and demos."""
|
|
325
|
+
|
|
326
|
+
async def generate(
|
|
327
|
+
self,
|
|
328
|
+
prompt: str,
|
|
329
|
+
max_tokens: int = 1_000,
|
|
330
|
+
temperature: float = 0.7,
|
|
331
|
+
system_prompt: Optional[str] = None,
|
|
332
|
+
) -> str:
|
|
333
|
+
del max_tokens, temperature, system_prompt # unused
|
|
334
|
+
lower_prompt = prompt.lower()
|
|
335
|
+
# Order matters: 'synthesis' and 'antithesis' contain 'thesis'
|
|
336
|
+
if "synthesis phase" in lower_prompt:
|
|
337
|
+
return (
|
|
338
|
+
"Paris' symbolic capital status coexists with distributed governance realities. "
|
|
339
|
+
"Treat the capital as a network of institutions rather than a single geography.\n"
|
|
340
|
+
"RESEARCH_PROPOSAL: Compare civic outcomes between centralized and distributed capitals.\n"
|
|
341
|
+
"TESTABLE_PREDICTION: Nations with distributed campuses show higher bureaucratic resilience."
|
|
342
|
+
)
|
|
343
|
+
if "antithesis phase" in lower_prompt:
|
|
344
|
+
return (
|
|
345
|
+
"CONTRADICTION: Thesis ignores shifting historical capitals.\n"
|
|
346
|
+
"EVIDENCE: During the Vichy regime, the functional capital moved away from Paris.\n"
|
|
347
|
+
"CONTRADICTION: Thesis presumes a monocentric governance model.\n"
|
|
348
|
+
"EVIDENCE: The European Union dilutes exclusive national control of capital cities."
|
|
349
|
+
)
|
|
350
|
+
if "thesis phase" in lower_prompt:
|
|
351
|
+
return (
|
|
352
|
+
"Paris is the capital of France. It has been the capital since 508 CE; "
|
|
353
|
+
"the city functions as France's cultural and political hub."
|
|
354
|
+
)
|
|
355
|
+
# fallback
|
|
356
|
+
return "This is a deterministic dummy response for testing."
|
|
357
|
+
|
|
358
|
+
# Legacy compatibility: some older tests expect a `query` coroutine.
|
|
359
|
+
async def query(self, *args, **kwargs): # pragma: no cover - compatibility shim
|
|
360
|
+
return await self.generate(*args, **kwargs)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@dataclass
|
|
364
|
+
class GoogleLLMBackend:
|
|
365
|
+
"""Backend targeting Google Gemini models via google-generativeai."""
|
|
366
|
+
|
|
367
|
+
model: str
|
|
368
|
+
api_key: str
|
|
369
|
+
base_url: Optional[str] = None
|
|
370
|
+
|
|
371
|
+
def __post_init__(self) -> None:
|
|
372
|
+
if genai is None: # pragma: no cover - import guard
|
|
373
|
+
raise BackendNotAvailableError(
|
|
374
|
+
"google-generativeai package is not installed but Google backend was requested."
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
client_options = {"api_endpoint": self.base_url} if self.base_url else None
|
|
378
|
+
configure_kwargs = {"api_key": self.api_key}
|
|
379
|
+
if client_options:
|
|
380
|
+
configure_kwargs["client_options"] = client_options
|
|
381
|
+
|
|
382
|
+
genai.configure(**configure_kwargs)
|
|
383
|
+
|
|
384
|
+
if not hasattr(genai, "AsyncGenerativeModel"):
|
|
385
|
+
raise BackendNotAvailableError(
|
|
386
|
+
"google-generativeai.AsyncGenerativeModel is unavailable; update the dependency."
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
self.client = genai.AsyncGenerativeModel(self.model)
|
|
390
|
+
|
|
391
|
+
async def generate(
|
|
392
|
+
self,
|
|
393
|
+
prompt: str,
|
|
394
|
+
max_tokens: int = 1_000,
|
|
395
|
+
temperature: float = 0.7,
|
|
396
|
+
system_prompt: Optional[str] = None,
|
|
397
|
+
) -> str:
|
|
398
|
+
kwargs: Dict[str, Any] = {
|
|
399
|
+
"generation_config": {
|
|
400
|
+
"max_output_tokens": max_tokens,
|
|
401
|
+
"temperature": temperature,
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if system_prompt:
|
|
405
|
+
kwargs["system_instruction"] = system_prompt
|
|
406
|
+
|
|
407
|
+
response = await self.client.generate_content(prompt, **kwargs)
|
|
408
|
+
|
|
409
|
+
text = getattr(response, "text", None)
|
|
410
|
+
if isinstance(text, str):
|
|
411
|
+
return text.strip()
|
|
412
|
+
|
|
413
|
+
candidates = getattr(response, "candidates", None)
|
|
414
|
+
if isinstance(candidates, list) and candidates:
|
|
415
|
+
candidate = candidates[0]
|
|
416
|
+
if hasattr(candidate, "content"):
|
|
417
|
+
content = getattr(candidate, "content")
|
|
418
|
+
parts = getattr(content, "parts", None)
|
|
419
|
+
if isinstance(parts, list):
|
|
420
|
+
texts = [getattr(part, "text") for part in parts if hasattr(part, "text")]
|
|
421
|
+
joined = "\n".join(filter(None, (t for t in texts if isinstance(t, str))))
|
|
422
|
+
if joined:
|
|
423
|
+
return joined.strip()
|
|
424
|
+
|
|
425
|
+
return ""
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
__all__ = [
|
|
429
|
+
"LLMBackend",
|
|
430
|
+
"OpenAILLMBackend",
|
|
431
|
+
"AnthropicLLMBackend",
|
|
432
|
+
"OllamaLLMBackend",
|
|
433
|
+
"CustomHTTPLLMBackend",
|
|
434
|
+
"DummyLLMBackend",
|
|
435
|
+
"GoogleLLMBackend",
|
|
436
|
+
"BackendNotAvailableError",
|
|
437
|
+
"MockBackend",
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# Backwards compatibility alias
|
|
442
|
+
MockBackend = DummyLLMBackend
|
hegelion/core/cache.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Simple file-based result caching for Hegelion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
from .models import HegelionResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class CacheConfig:
|
|
18
|
+
"""Configuration for result caching."""
|
|
19
|
+
|
|
20
|
+
cache_dir: Path
|
|
21
|
+
ttl_seconds: Optional[int] = None
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_env(
|
|
25
|
+
cls,
|
|
26
|
+
cache_dir: Optional[str] = None,
|
|
27
|
+
ttl_seconds: Optional[int] = None,
|
|
28
|
+
) -> "CacheConfig":
|
|
29
|
+
resolved_dir = Path(cache_dir or os.path.expanduser("~/.cache/hegelion"))
|
|
30
|
+
return cls(cache_dir=resolved_dir, ttl_seconds=ttl_seconds)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ResultCache:
|
|
34
|
+
"""Tiny JSONL cache that stores complete HegelionResult payloads on disk."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, config: CacheConfig) -> None:
|
|
37
|
+
self.config = config
|
|
38
|
+
self.config.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
def _path_for_key(self, key: str) -> Path:
|
|
41
|
+
return self.config.cache_dir / f"{key}.json"
|
|
42
|
+
|
|
43
|
+
def load(self, key: str) -> Optional[Dict[str, Any]]:
|
|
44
|
+
"""Load a cached result if it exists and is fresh."""
|
|
45
|
+
path = self._path_for_key(key)
|
|
46
|
+
if not path.exists():
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
if self.config.ttl_seconds is not None:
|
|
50
|
+
age = time.time() - path.stat().st_mtime
|
|
51
|
+
if age > self.config.ttl_seconds:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
56
|
+
return json.load(handle)
|
|
57
|
+
except Exception:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
def save(self, key: str, result: HegelionResult) -> None:
|
|
61
|
+
"""Persist a result atomically."""
|
|
62
|
+
data = result.to_dict()
|
|
63
|
+
path = self._path_for_key(key)
|
|
64
|
+
tmp_path = path.with_suffix(".tmp")
|
|
65
|
+
with tmp_path.open("w", encoding="utf-8") as handle:
|
|
66
|
+
json.dump(data, handle, ensure_ascii=False)
|
|
67
|
+
tmp_path.replace(path)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def compute_cache_key(
|
|
71
|
+
query: str,
|
|
72
|
+
model: str,
|
|
73
|
+
backend_provider: str,
|
|
74
|
+
*,
|
|
75
|
+
version: str,
|
|
76
|
+
max_tokens_per_phase: int,
|
|
77
|
+
debug: bool,
|
|
78
|
+
) -> str:
|
|
79
|
+
"""Generate a stable cache key for a dialectic request."""
|
|
80
|
+
canonical = json.dumps(
|
|
81
|
+
{
|
|
82
|
+
"query": query,
|
|
83
|
+
"model": model,
|
|
84
|
+
"backend": backend_provider,
|
|
85
|
+
"max_tokens": max_tokens_per_phase,
|
|
86
|
+
"debug": debug,
|
|
87
|
+
"version": version,
|
|
88
|
+
},
|
|
89
|
+
sort_keys=True,
|
|
90
|
+
ensure_ascii=False,
|
|
91
|
+
)
|
|
92
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:24]
|