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.
Files changed (43) hide show
  1. hegelion/__init__.py +45 -0
  2. hegelion/core/__init__.py +29 -0
  3. hegelion/core/agent.py +166 -0
  4. hegelion/core/autocoding_state.py +293 -0
  5. hegelion/core/backends.py +442 -0
  6. hegelion/core/cache.py +92 -0
  7. hegelion/core/config.py +276 -0
  8. hegelion/core/core.py +649 -0
  9. hegelion/core/engine.py +865 -0
  10. hegelion/core/logging_utils.py +67 -0
  11. hegelion/core/models.py +293 -0
  12. hegelion/core/parsing.py +271 -0
  13. hegelion/core/personas.py +81 -0
  14. hegelion/core/prompt_autocoding.py +353 -0
  15. hegelion/core/prompt_dialectic.py +414 -0
  16. hegelion/core/prompts.py +127 -0
  17. hegelion/core/schema.py +67 -0
  18. hegelion/core/validation.py +68 -0
  19. hegelion/council.py +254 -0
  20. hegelion/examples_data/__init__.py +6 -0
  21. hegelion/examples_data/glm4_6_examples.jsonl +2 -0
  22. hegelion/judge.py +230 -0
  23. hegelion/mcp/__init__.py +3 -0
  24. hegelion/mcp/server.py +918 -0
  25. hegelion/scripts/hegelion_agent_cli.py +90 -0
  26. hegelion/scripts/hegelion_bench.py +117 -0
  27. hegelion/scripts/hegelion_cli.py +497 -0
  28. hegelion/scripts/hegelion_dataset.py +99 -0
  29. hegelion/scripts/hegelion_eval.py +137 -0
  30. hegelion/scripts/mcp_setup.py +150 -0
  31. hegelion/search_providers.py +151 -0
  32. hegelion/training/__init__.py +7 -0
  33. hegelion/training/datasets.py +123 -0
  34. hegelion/training/generator.py +232 -0
  35. hegelion/training/mlx_scu_trainer.py +379 -0
  36. hegelion/training/mlx_trainer.py +181 -0
  37. hegelion/training/unsloth_trainer.py +136 -0
  38. hegelion-0.4.0.dist-info/METADATA +295 -0
  39. hegelion-0.4.0.dist-info/RECORD +43 -0
  40. hegelion-0.4.0.dist-info/WHEEL +5 -0
  41. hegelion-0.4.0.dist-info/entry_points.txt +8 -0
  42. hegelion-0.4.0.dist-info/licenses/LICENSE +21 -0
  43. 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]