boolean-algebra-engine 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.
- api/__init__.py +0 -0
- api/routes.py +386 -0
- boolean_algebra_engine-0.1.0.dist-info/METADATA +213 -0
- boolean_algebra_engine-0.1.0.dist-info/RECORD +19 -0
- boolean_algebra_engine-0.1.0.dist-info/WHEEL +5 -0
- boolean_algebra_engine-0.1.0.dist-info/entry_points.txt +2 -0
- boolean_algebra_engine-0.1.0.dist-info/licenses/LICENSE +674 -0
- boolean_algebra_engine-0.1.0.dist-info/top_level.txt +5 -0
- cli/__init__.py +0 -0
- cli/main.py +411 -0
- core/__init__.py +0 -0
- core/evaluator.py +86 -0
- core/models.py +96 -0
- core/parser.py +73 -0
- core/synthesizer.py +167 -0
- mcp_server/__init__.py +0 -0
- mcp_server/server.py +247 -0
- nl/__init__.py +0 -0
- nl/nl.py +449 -0
nl/nl.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"""
|
|
2
|
+
nl/nl.py — Natural Language layer for the boolean algebra engine.
|
|
3
|
+
|
|
4
|
+
Pipeline:
|
|
5
|
+
plain English → LLM (parse) → boolean expression
|
|
6
|
+
→ core engine → truth table + minimal form
|
|
7
|
+
→ LLM (explain) → plain English result
|
|
8
|
+
|
|
9
|
+
Supports any LLM via the Provider protocol. Built-in providers:
|
|
10
|
+
AnthropicProvider — Claude (default)
|
|
11
|
+
OpenAIProvider — GPT-4o, GPT-4, etc.
|
|
12
|
+
OllamaProvider — local models via Ollama (llama3, mistral, etc.)
|
|
13
|
+
OpenAICompatProvider — any OpenAI-compatible endpoint (Groq, Together, etc.)
|
|
14
|
+
|
|
15
|
+
Public API:
|
|
16
|
+
ask(sentence, provider) → NLResult
|
|
17
|
+
check_rules(rules, provider) → dict
|
|
18
|
+
|
|
19
|
+
Quick start:
|
|
20
|
+
from nl.nl import ask, AnthropicProvider
|
|
21
|
+
result = ask("lights on when door open or motion detected but not both",
|
|
22
|
+
provider=AnthropicProvider()) # uses ANTHROPIC_API_KEY env var
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
from abc import ABC, abstractmethod
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
from core.evaluator import evaluate
|
|
32
|
+
from core.synthesizer import synthesize
|
|
33
|
+
from core.parser import validate
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Prompts — shared across all providers
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
_PARSE_SYSTEM = """\
|
|
41
|
+
You are a boolean algebra parser. Your job is to convert a natural language
|
|
42
|
+
description of a logical rule or condition into a boolean expression.
|
|
43
|
+
|
|
44
|
+
Rules:
|
|
45
|
+
- Variables must be single uppercase letters: A, B, C, D, ...
|
|
46
|
+
- Operators: ! (NOT), . (AND), ^ (XOR), + (OR)
|
|
47
|
+
- Parentheses for grouping
|
|
48
|
+
- Return ONLY valid JSON, no markdown, no explanation
|
|
49
|
+
|
|
50
|
+
Output format:
|
|
51
|
+
{
|
|
52
|
+
"expression": "<boolean expression>",
|
|
53
|
+
"variables": {
|
|
54
|
+
"A": "<what A represents>",
|
|
55
|
+
"B": "<what B represents>",
|
|
56
|
+
...
|
|
57
|
+
},
|
|
58
|
+
"assumptions": "<any assumptions you made parsing ambiguous language>"
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
Examples:
|
|
62
|
+
Input: "lights on when door open or motion detected but not both"
|
|
63
|
+
Output: {"expression": "D^M", "variables": {"D": "door is open", "M": "motion detected"}, "assumptions": "interpreted 'but not both' as XOR"}
|
|
64
|
+
|
|
65
|
+
Input: "access granted if user is admin and authenticated, or if read-only and authenticated"
|
|
66
|
+
Output: {"expression": "A.(B+C)", "variables": {"A": "user is authenticated", "B": "user is admin", "C": "request is read-only"}, "assumptions": "factored out authentication as common condition"}
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
_EXPLAIN_SYSTEM = """\
|
|
70
|
+
You are a boolean logic explainer. You are given the result of evaluating a
|
|
71
|
+
boolean expression and must explain it in plain English clearly and concisely.
|
|
72
|
+
|
|
73
|
+
Be direct. Lead with the most important finding (contradiction, tautology, or
|
|
74
|
+
key insight). Then explain what the minimal form means. Keep it under 150 words.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Provider protocol
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
class Provider(ABC):
|
|
83
|
+
"""
|
|
84
|
+
Abstract base for LLM providers. Implement complete() to add a new model.
|
|
85
|
+
|
|
86
|
+
The NL layer calls complete() twice per request — once to parse the sentence
|
|
87
|
+
into a boolean expression, once to explain the result. Both are plain
|
|
88
|
+
text-in, text-out with a system prompt and a user message.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
@abstractmethod
|
|
92
|
+
def complete(self, system: str, user: str, max_tokens: int = 512) -> str:
|
|
93
|
+
"""
|
|
94
|
+
Call the LLM and return the response text.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
system: System prompt.
|
|
98
|
+
user: User message.
|
|
99
|
+
max_tokens: Maximum tokens in the response.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Response text as a plain string.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Built-in providers
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
class AnthropicProvider(Provider):
|
|
111
|
+
"""
|
|
112
|
+
Claude via Anthropic SDK.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
api_key: Anthropic API key. Falls back to ANTHROPIC_API_KEY env var.
|
|
116
|
+
model: Model ID. Defaults to claude-sonnet-4-6.
|
|
117
|
+
|
|
118
|
+
Install: pip install anthropic
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
api_key: str | None = None,
|
|
124
|
+
model: str = "claude-sonnet-4-6",
|
|
125
|
+
):
|
|
126
|
+
try:
|
|
127
|
+
import anthropic as _anthropic
|
|
128
|
+
except ImportError:
|
|
129
|
+
raise ImportError("pip install anthropic")
|
|
130
|
+
self._client = _anthropic.Anthropic(
|
|
131
|
+
api_key=api_key or os.environ.get("ANTHROPIC_API_KEY")
|
|
132
|
+
)
|
|
133
|
+
self.model = model
|
|
134
|
+
|
|
135
|
+
def complete(self, system: str, user: str, max_tokens: int = 512) -> str:
|
|
136
|
+
response = self._client.messages.create(
|
|
137
|
+
model=self.model,
|
|
138
|
+
max_tokens=max_tokens,
|
|
139
|
+
system=system,
|
|
140
|
+
messages=[{"role": "user", "content": user}],
|
|
141
|
+
)
|
|
142
|
+
return response.content[0].text.strip()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class OpenAIProvider(Provider):
|
|
146
|
+
"""
|
|
147
|
+
GPT-4o, GPT-4, GPT-3.5, or any OpenAI model.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
api_key: OpenAI API key. Falls back to OPENAI_API_KEY env var.
|
|
151
|
+
model: Model ID. Defaults to gpt-4o.
|
|
152
|
+
|
|
153
|
+
Install: pip install openai
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
def __init__(
|
|
157
|
+
self,
|
|
158
|
+
api_key: str | None = None,
|
|
159
|
+
model: str = "gpt-4o",
|
|
160
|
+
):
|
|
161
|
+
try:
|
|
162
|
+
from openai import OpenAI
|
|
163
|
+
except ImportError:
|
|
164
|
+
raise ImportError("pip install openai")
|
|
165
|
+
self._client = OpenAI(
|
|
166
|
+
api_key=api_key or os.environ.get("OPENAI_API_KEY")
|
|
167
|
+
)
|
|
168
|
+
self.model = model
|
|
169
|
+
|
|
170
|
+
def complete(self, system: str, user: str, max_tokens: int = 512) -> str:
|
|
171
|
+
response = self._client.chat.completions.create(
|
|
172
|
+
model=self.model,
|
|
173
|
+
max_tokens=max_tokens,
|
|
174
|
+
messages=[
|
|
175
|
+
{"role": "system", "content": system},
|
|
176
|
+
{"role": "user", "content": user},
|
|
177
|
+
],
|
|
178
|
+
)
|
|
179
|
+
return response.choices[0].message.content.strip()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class OllamaProvider(Provider):
|
|
183
|
+
"""
|
|
184
|
+
Local models via Ollama (llama3, mistral, phi3, gemma, etc.).
|
|
185
|
+
Ollama must be running locally: https://ollama.com
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
model: Ollama model name. Defaults to llama3.
|
|
189
|
+
base_url: Ollama API base URL. Defaults to http://localhost:11434.
|
|
190
|
+
|
|
191
|
+
Install: pip install ollama (or use OllamaProvider via openai SDK)
|
|
192
|
+
No API key needed — runs fully locally.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
def __init__(
|
|
196
|
+
self,
|
|
197
|
+
model: str = "llama3",
|
|
198
|
+
base_url: str = "http://localhost:11434",
|
|
199
|
+
):
|
|
200
|
+
self.model = model
|
|
201
|
+
self.base_url = base_url.rstrip("/")
|
|
202
|
+
|
|
203
|
+
def complete(self, system: str, user: str, max_tokens: int = 512) -> str:
|
|
204
|
+
import urllib.request
|
|
205
|
+
payload = json.dumps({
|
|
206
|
+
"model": self.model,
|
|
207
|
+
"messages": [
|
|
208
|
+
{"role": "system", "content": system},
|
|
209
|
+
{"role": "user", "content": user},
|
|
210
|
+
],
|
|
211
|
+
"stream": False,
|
|
212
|
+
"options": {"num_predict": max_tokens},
|
|
213
|
+
}).encode()
|
|
214
|
+
req = urllib.request.Request(
|
|
215
|
+
f"{self.base_url}/api/chat",
|
|
216
|
+
data=payload,
|
|
217
|
+
headers={"Content-Type": "application/json"},
|
|
218
|
+
)
|
|
219
|
+
with urllib.request.urlopen(req) as resp:
|
|
220
|
+
data = json.loads(resp.read())
|
|
221
|
+
return data["message"]["content"].strip()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class OpenAICompatProvider(Provider):
|
|
225
|
+
"""
|
|
226
|
+
Any OpenAI-compatible endpoint: Groq, Together AI, Fireworks, Mistral API,
|
|
227
|
+
LM Studio, vLLM, etc.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
api_key: API key for the provider.
|
|
231
|
+
base_url: Base URL of the OpenAI-compatible API.
|
|
232
|
+
model: Model ID as accepted by the provider.
|
|
233
|
+
|
|
234
|
+
Install: pip install openai
|
|
235
|
+
|
|
236
|
+
Examples:
|
|
237
|
+
# Groq
|
|
238
|
+
OpenAICompatProvider(
|
|
239
|
+
api_key=os.environ["GROQ_API_KEY"],
|
|
240
|
+
base_url="https://api.groq.com/openai/v1",
|
|
241
|
+
model="llama3-8b-8192",
|
|
242
|
+
)
|
|
243
|
+
# Together AI
|
|
244
|
+
OpenAICompatProvider(
|
|
245
|
+
api_key=os.environ["TOGETHER_API_KEY"],
|
|
246
|
+
base_url="https://api.together.xyz/v1",
|
|
247
|
+
model="mistralai/Mixtral-8x7B-Instruct-v0.1",
|
|
248
|
+
)
|
|
249
|
+
# Local LM Studio
|
|
250
|
+
OpenAICompatProvider(
|
|
251
|
+
api_key="lm-studio",
|
|
252
|
+
base_url="http://localhost:1234/v1",
|
|
253
|
+
model="local-model",
|
|
254
|
+
)
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
def __init__(self, api_key: str, base_url: str, model: str):
|
|
258
|
+
try:
|
|
259
|
+
from openai import OpenAI
|
|
260
|
+
except ImportError:
|
|
261
|
+
raise ImportError("pip install openai")
|
|
262
|
+
self._client = OpenAI(api_key=api_key, base_url=base_url)
|
|
263
|
+
self.model = model
|
|
264
|
+
|
|
265
|
+
def complete(self, system: str, user: str, max_tokens: int = 512) -> str:
|
|
266
|
+
response = self._client.chat.completions.create(
|
|
267
|
+
model=self.model,
|
|
268
|
+
max_tokens=max_tokens,
|
|
269
|
+
messages=[
|
|
270
|
+
{"role": "system", "content": system},
|
|
271
|
+
{"role": "user", "content": user},
|
|
272
|
+
],
|
|
273
|
+
)
|
|
274
|
+
return response.choices[0].message.content.strip()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _default_provider() -> Provider:
|
|
278
|
+
"""Return AnthropicProvider if key is set, else raise a clear error."""
|
|
279
|
+
if os.environ.get("ANTHROPIC_API_KEY"):
|
|
280
|
+
return AnthropicProvider()
|
|
281
|
+
raise ValueError(
|
|
282
|
+
"No provider specified and ANTHROPIC_API_KEY is not set.\n"
|
|
283
|
+
"Pass a provider explicitly:\n"
|
|
284
|
+
" ask(sentence, provider=AnthropicProvider(api_key='...'))\n"
|
|
285
|
+
" ask(sentence, provider=OpenAIProvider(api_key='...'))\n"
|
|
286
|
+
" ask(sentence, provider=OllamaProvider(model='llama3'))"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
# Result type
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
@dataclass
|
|
295
|
+
class NLResult:
|
|
296
|
+
input_sentence: str
|
|
297
|
+
expression: str
|
|
298
|
+
variables: dict[str, str]
|
|
299
|
+
minimal: str
|
|
300
|
+
satisfiable: bool
|
|
301
|
+
tautology: bool
|
|
302
|
+
contradiction: bool
|
|
303
|
+
minterms: list[int]
|
|
304
|
+
maxterms: list[int]
|
|
305
|
+
explanation: str
|
|
306
|
+
rows: list[dict]
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
# Public API
|
|
311
|
+
# ---------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
def ask(sentence: str, provider: Provider | None = None) -> NLResult:
|
|
314
|
+
"""
|
|
315
|
+
Convert a plain English logical statement into a verified boolean result.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
sentence: Plain English description of a logical rule or condition.
|
|
319
|
+
provider: LLM provider to use. Defaults to AnthropicProvider() if
|
|
320
|
+
ANTHROPIC_API_KEY is set, otherwise raises ValueError.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
NLResult with expression, truth table, minimal form, and explanation.
|
|
324
|
+
|
|
325
|
+
Raises:
|
|
326
|
+
ValueError: If the LLM cannot parse the sentence into a valid expression.
|
|
327
|
+
|
|
328
|
+
Examples:
|
|
329
|
+
from nl.nl import ask, AnthropicProvider, OpenAIProvider, OllamaProvider
|
|
330
|
+
|
|
331
|
+
# Claude
|
|
332
|
+
result = ask("door opens when key valid and not locked",
|
|
333
|
+
provider=AnthropicProvider())
|
|
334
|
+
|
|
335
|
+
# GPT-4o
|
|
336
|
+
result = ask("...", provider=OpenAIProvider())
|
|
337
|
+
|
|
338
|
+
# Local llama3 via Ollama (no API key, free)
|
|
339
|
+
result = ask("...", provider=OllamaProvider(model="llama3"))
|
|
340
|
+
|
|
341
|
+
# Groq (fast, cheap)
|
|
342
|
+
result = ask("...", provider=OpenAICompatProvider(
|
|
343
|
+
api_key=os.environ["GROQ_API_KEY"],
|
|
344
|
+
base_url="https://api.groq.com/openai/v1",
|
|
345
|
+
model="llama3-8b-8192",
|
|
346
|
+
))
|
|
347
|
+
"""
|
|
348
|
+
llm = provider or _default_provider()
|
|
349
|
+
|
|
350
|
+
# Step 1: NL → expression
|
|
351
|
+
raw = llm.complete(_PARSE_SYSTEM, sentence, max_tokens=512)
|
|
352
|
+
try:
|
|
353
|
+
parsed = json.loads(raw)
|
|
354
|
+
except json.JSONDecodeError:
|
|
355
|
+
raise ValueError(f"LLM returned non-JSON: {raw}")
|
|
356
|
+
|
|
357
|
+
expression = parsed.get("expression", "")
|
|
358
|
+
variables = parsed.get("variables", {})
|
|
359
|
+
assumptions = parsed.get("assumptions", "")
|
|
360
|
+
|
|
361
|
+
error = validate(expression)
|
|
362
|
+
if error:
|
|
363
|
+
raise ValueError(
|
|
364
|
+
f"LLM produced invalid expression '{expression}': {error}\n"
|
|
365
|
+
f"Assumptions: {assumptions}"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# Step 2: evaluate + synthesize
|
|
369
|
+
table, _ = evaluate(expression)
|
|
370
|
+
minimal, _ = synthesize(table)
|
|
371
|
+
|
|
372
|
+
# Step 3: result → explanation
|
|
373
|
+
explain_prompt = (
|
|
374
|
+
f"Sentence: {sentence}\n"
|
|
375
|
+
f"Parsed as: {expression}\n"
|
|
376
|
+
f"Variables: {json.dumps(variables)}\n"
|
|
377
|
+
f"Minimal form: {minimal}\n"
|
|
378
|
+
f"Satisfiable: {table.satisfiable}\n"
|
|
379
|
+
f"Tautology: {table.tautology}\n"
|
|
380
|
+
f"Contradiction: {not table.satisfiable}\n"
|
|
381
|
+
f"Minterms (rows where true): {table.minterms}\n"
|
|
382
|
+
f"Total rows: {len(table.rows)}\n"
|
|
383
|
+
f"Assumptions made: {assumptions}"
|
|
384
|
+
)
|
|
385
|
+
explanation = llm.complete(_EXPLAIN_SYSTEM, explain_prompt, max_tokens=300)
|
|
386
|
+
|
|
387
|
+
return NLResult(
|
|
388
|
+
input_sentence=sentence,
|
|
389
|
+
expression=expression,
|
|
390
|
+
variables=variables,
|
|
391
|
+
minimal=minimal,
|
|
392
|
+
satisfiable=table.satisfiable,
|
|
393
|
+
tautology=table.tautology,
|
|
394
|
+
contradiction=not table.satisfiable,
|
|
395
|
+
minterms=table.minterms,
|
|
396
|
+
maxterms=table.maxterms,
|
|
397
|
+
explanation=explanation,
|
|
398
|
+
rows=[{**row.inputs, "output": row.output} for row in table.rows],
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def check_rules(rules: list[str], provider: Provider | None = None) -> dict:
|
|
403
|
+
"""
|
|
404
|
+
Parse and verify a list of plain English rules for contradictions,
|
|
405
|
+
tautologies, and pairwise conflicts.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
rules: List of plain English rule descriptions.
|
|
409
|
+
provider: LLM provider to use. Defaults to AnthropicProvider().
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
Dictionary with per-rule analysis and pairwise conflict/equivalence checks.
|
|
413
|
+
"""
|
|
414
|
+
from mcp_server.server import check_prompt_logic as _check
|
|
415
|
+
|
|
416
|
+
llm = provider or _default_provider()
|
|
417
|
+
expressions = []
|
|
418
|
+
variable_maps = []
|
|
419
|
+
errors = []
|
|
420
|
+
|
|
421
|
+
for rule in rules:
|
|
422
|
+
try:
|
|
423
|
+
raw = llm.complete(_PARSE_SYSTEM, rule, max_tokens=512)
|
|
424
|
+
parsed = json.loads(raw)
|
|
425
|
+
expr = parsed.get("expression", "")
|
|
426
|
+
error = validate(expr)
|
|
427
|
+
if error:
|
|
428
|
+
errors.append({"rule": rule, "error": error})
|
|
429
|
+
else:
|
|
430
|
+
expressions.append(expr)
|
|
431
|
+
variable_maps.append({
|
|
432
|
+
"rule": rule,
|
|
433
|
+
"expression": expr,
|
|
434
|
+
"variables": parsed.get("variables", {}),
|
|
435
|
+
})
|
|
436
|
+
except Exception as e:
|
|
437
|
+
errors.append({"rule": rule, "error": str(e)})
|
|
438
|
+
|
|
439
|
+
engine_result = _check(expressions) if expressions else {
|
|
440
|
+
"rules": [], "pairwise": [], "summary": {}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
for i, item in enumerate(engine_result.get("rules", [])):
|
|
444
|
+
if i < len(variable_maps):
|
|
445
|
+
item["original_rule"] = variable_maps[i]["rule"]
|
|
446
|
+
item["variables"] = variable_maps[i]["variables"]
|
|
447
|
+
|
|
448
|
+
engine_result["parse_errors"] = errors
|
|
449
|
+
return engine_result
|