feat: 添加 Web UI 管理界面

- 基于 stdlib http.server + 内联 HTML/JS,无需额外依赖
- 功能:规则列表、创建规则、规则测试、冲突检测
- AI 生成代码按钮(调用 LLM 编译器)
- 启动命令:python3 -c "from rule_engine.web import *"

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
root 2026-05-11 23:12:08 +08:00
parent 4f60151b86
commit 30055e30c1
2 changed files with 532 additions and 3 deletions

View File

@ -26,11 +26,32 @@ pip install -e .
### 启动服务 ### 启动服务
#### REST API
```bash ```bash
python -m rule_engine.api python3 -c "
# 默认 http://0.0.0.0:8000 import sys; sys.path.insert(0, 'src')
from rule_engine.api import create_app
from rule_engine.store import RuleStore
srv = create_app(RuleStore('rules.db'))
print('API on http://0.0.0.0:8000')
srv.serve_forever()
"
``` ```
#### Web UI推荐
```bash
python3 -c "
import sys; sys.path.insert(0, 'src')
from rule_engine.web import create_web_app
from rule_engine.store import RuleStore
srv = create_web_app(RuleStore('rules.db'))
print('Web UI on http://0.0.0.0:8080')
srv.serve_forever()
"
```
访问 http://localhost:8080
### API 接口 ### API 接口
#### 创建规则 #### 创建规则
@ -84,7 +105,8 @@ src/rule_engine/
├── executor.py # 沙箱执行器 ├── executor.py # 沙箱执行器
├── matcher.py # 规则匹配 ├── matcher.py # 规则匹配
├── models.py # 数据模型 ├── models.py # 数据模型
└── store.py # SQLite 存储 ├── store.py # SQLite 存储
└── web.py # Web UI可选
``` ```
## 配置 ## 配置

507
src/rule_engine/web.py Normal file
View File

@ -0,0 +1,507 @@
"""Web UI for rule engine management."""
import json
import os
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
from typing import Optional, Dict, Any
from rule_engine.store import RuleStore
from rule_engine.models import CreateRuleRequest
from rule_engine.matcher import RuleMatcher
from rule_engine.executor import RuleExecutor
from rule_engine.callback import LLMEallback, MockLLMClient
from rule_engine.compiler import RuleCompiler
from rule_engine.conflict import ConflictDetector
class RuleEngineWebApp:
"""Web 应用处理逻辑。"""
def __init__(self, store: RuleStore, enable_callback: bool = False):
self.store = store
self.executor = RuleExecutor()
self.matcher = RuleMatcher(store, self.executor)
self.conflict_detector = ConflictDetector()
if enable_callback:
self.callback = LLMEallback()
else:
mock_client = MockLLMClient()
compiler = RuleCompiler(llm_client=mock_client)
self.callback = LLMEallback(compiler=compiler)
def handle_request(self, path: str, method: str, body: Optional[Dict] = None) -> tuple:
"""处理请求并返回 (status, content_type, body)。"""
if path == "/" or path == "/index.html":
return 200, "text/html", self._render_index()
elif path == "/api/rules" and method == "GET":
return self._handle_list_rules()
elif path == "/api/rules" and method == "POST":
return self._handle_create_rule(body)
elif path.startswith("/api/rules/") and method == "GET":
rule_id = path.split("/")[-1]
return self._handle_get_rule(rule_id)
elif path.startswith("/api/rules/") and method == "DELETE":
rule_id = path.split("/")[-1]
return self._handle_delete_rule(rule_id)
elif path == "/api/rules/evaluate" and method == "POST":
return self._handle_evaluate(body)
elif path == "/api/rules/compile" and method == "POST":
return self._handle_compile(body)
elif path == "/api/conflicts":
return self._handle_check_conflicts()
else:
return 404, "application/json", json.dumps({"error": "Not found"})
def _render_index(self) -> str:
return HTML_CONTENT
def _handle_list_rules(self) -> tuple:
rules = self.store.list_rules()
return 200, "application/json", json.dumps({
"rules": [r.to_dict() for r in rules]
})
def _handle_create_rule(self, body: Optional[Dict]) -> tuple:
if not body:
return 400, "application/json", json.dumps({"error": "Body required"})
try:
request = CreateRuleRequest(
name=body.get("name", ""),
condition_template=body.get("condition_template", ""),
description=body.get("description"),
priority=body.get("priority", 0)
)
except (TypeError, ValueError) as e:
return 400, "application/json", json.dumps({"error": f"Invalid request: {e}"})
if not request.name or not request.condition_template:
return 400, "application/json", json.dumps({"error": "name and condition_template required"})
code = body.get("code", "def rule(facts):\n return None")
# 检查冲突
existing = [r.to_dict() for r in self.store.list_rules()]
new_rule_dict = {
"id": "new", "name": request.name, "code": code,
"priority": request.priority, "is_active": True
}
conflicts = self.conflict_detector.check_rule_with_existing(new_rule_dict, existing)
rule = self.store.create_rule(
name=request.name,
condition_template=request.condition_template,
code=code,
description=request.description,
priority=request.priority
)
result = rule.to_dict()
if conflicts:
result["conflict_warning"] = conflicts
return 201, "application/json", json.dumps(result)
def _handle_get_rule(self, rule_id: str) -> tuple:
rule = self.store.get_rule(rule_id)
if not rule:
return 404, "application/json", json.dumps({"error": "Not found"})
return 200, "application/json", json.dumps(rule.to_dict())
def _handle_delete_rule(self, rule_id: str) -> tuple:
deleted = self.store.delete_rule(rule_id)
if not deleted:
return 404, "application/json", json.dumps({"error": "Not found"})
return 200, "application/json", json.dumps({"deleted": True})
def _handle_evaluate(self, body: Optional[Dict]) -> tuple:
if not body:
return 400, "application/json", json.dumps({"error": "facts required"})
facts = body.get("facts")
if not facts:
return 400, "application/json", json.dumps({"error": "facts required"})
rule_id = body.get("rule_id")
matched_rule_id, result = self.matcher.find_matching_rule(facts, rule_id)
if matched_rule_id and result is not None:
self.store.record_execution(matched_rule_id, facts, result, True)
return 200, "application/json", json.dumps({
"matched": True, "rule_id": matched_rule_id, "result": result
})
self.store.record_execution(matched_rule_id or "", facts, result, False)
return 200, "application/json", json.dumps({
"matched": False, "rule_id": matched_rule_id, "result": None
})
def _handle_compile(self, body: Optional[Dict]) -> tuple:
if not body:
return 400, "application/json", json.dumps({"error": "condition_template required"})
template = body.get("condition_template")
if not template:
return 400, "application/json", json.dumps({"error": "condition_template required"})
from rule_engine.compiler import build_compile_prompt
prompt = build_compile_prompt(template)
# 使用 mock 返回(真实环境需要配置 LLM API key
response = MockLLMClient().complete(prompt)
from rule_engine.compiler import extract_code_block
code = extract_code_block(response)
if not code:
return 500, "application/json", json.dumps({"error": "Failed to generate code"})
return 200, "application/json", json.dumps({
"condition_template": template,
"generated_code": code
})
def _handle_check_conflicts(self) -> tuple:
rules = [r.to_dict() for r in self.store.list_rules()]
conflicts = self.conflict_detector.detect_conflicts(rules)
return 200, "application/json", json.dumps({"conflicts": conflicts})
class WebUIHandler(BaseHTTPRequestHandler):
app: Optional[RuleEngineWebApp] = None
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path
if path.startswith("/api/") or path == "/":
status, content_type, body = self.app.handle_request(path, "GET")
self._send_response(status, content_type, body)
else:
self._send_response(404, "application/json", json.dumps({"error": "Not found"}))
def do_POST(self):
parsed = urlparse(self.path)
path = parsed.path
if path.startswith("/api/"):
content_length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(content_length)) if content_length > 0 else None
status, content_type, response = self.app.handle_request(path, "POST", body)
self._send_response(status, content_type, response)
else:
self._send_response(404, "application/json", json.dumps({"error": "Not found"}))
def do_DELETE(self):
parsed = urlparse(self.path)
path = parsed.path
if path.startswith("/api/rules/"):
rule_id = path.split("/")[-1]
status, content_type, response = self.app.handle_request(path, "DELETE")
self._send_response(status, content_type, response)
else:
self._send_response(404, "application/json", json.dumps({"error": "Not found"}))
def _send_response(self, status: int, content_type: str, body: str):
self.send_response(status)
self.send_header("Content-Type", f"{content_type}; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body.encode("utf-8"))
def log_message(self, format, *args):
print(f"[{self.log_date_time_string()}] {format % args}")
HTML_CONTENT = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 规则引擎</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; line-height: 1.6; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
header { background: #2c3e50; color: white; padding: 20px 0; margin-bottom: 30px; }
header h1 { text-align: center; }
header h1 span { color: #3498db; }
.tabs { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
.tab { padding: 10px 20px; background: #ecf0f1; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; transition: all 0.2s; }
.tab:hover { background: #bdc3c7; }
.tab.active { background: #3498db; color: white; }
.tab-content { display: none; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.tab-content.active { display: block; }
.card { background: white; padding: 20px; margin-bottom: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.card h3 { margin-bottom: 15px; color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px; }
.form-group { margin-bottom: 15px; }
.form-group label { display: block; margin-bottom: 5px; font-weight: 600; color: #555; }
.form-group input, .form-group textarea, .form-group select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
.form-group textarea { min-height: 80px; resize: vertical; }
.btn { padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; transition: all 0.2s; }
.btn-primary { background: #3498db; color: white; }
.btn-primary:hover { background: #2980b9; }
.btn-danger { background: #e74c3c; color: white; }
.btn-danger:hover { background: #c0392b; }
.btn-success { background: #27ae60; color: white; }
.btn-success:hover { background: #229954; }
.btn-secondary { background: #95a5a6; color: white; }
.btn-secondary:hover { background: #7f8c8d; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }
th { background: #f8f9fa; font-weight: 600; color: #555; }
tr:hover { background: #f8f9fa; }
.badge { display: inline-block; padding: 3px 8px; border-radius: 12px; font-size: 12px; font-weight: 600; }
.badge-success { background: #d4edda; color: #155724; }
.badge-danger { background: #f8d7da; color: #721c24; }
.badge-warning { background: #fff3cd; color: #856404; }
.alert { padding: 15px; margin-bottom: 15px; border-radius: 4px; }
.alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.alert-danger { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.alert-warning { background: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
.json-display { background: #2c3e50; color: #ecf0f1; padding: 15px; border-radius: 4px; font-family: 'Monaco', 'Menlo', monospace; font-size: 13px; overflow-x: auto; white-space: pre-wrap; }
.actions { display: flex; gap: 8px; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
pre { background: #2c3e50; color: #ecf0f1; padding: 15px; border-radius: 4px; overflow-x: auto; }
</style>
</head>
<body>
<header><div class="container"><h1>AI <span>规则引擎</span> 管理后台</h1></div></header>
<div class="container">
<div class="tabs">
<button class="tab active" onclick="showTab('rules')">规则列表</button>
<button class="tab" onclick="showTab('create')">创建规则</button>
<button class="tab" onclick="showTab('test')">规则测试</button>
<button class="tab" onclick="showTab('conflicts')">冲突检测</button>
</div>
<div id="rules" class="tab-content active">
<div class="card">
<h3>规则列表</h3>
<button class="btn btn-secondary" onclick="loadRules()">刷新</button>
<div id="rules-list" style="margin-top: 15px;"></div>
</div>
</div>
<div id="create" class="tab-content">
<div class="card">
<h3>创建规则</h3>
<div id="create-result"></div>
<form onsubmit="createRule(event)">
<div class="form-group">
<label>规则名称 *</label>
<input type="text" id="rule-name" required placeholder="如: premium_discount">
</div>
<div class="form-group">
<label>规则描述</label>
<input type="text" id="rule-desc" placeholder="如: 高级会员8折优惠">
</div>
<div class="form-group">
<label>规则描述模板 *</label>
<textarea id="rule-template" required placeholder="如: 如果用户订阅类型为 premium 且年龄大于 18"></textarea>
</div>
<div class="form-group">
<label>优先级</label>
<input type="number" id="rule-priority" value="0" min="0">
</div>
<div class="form-group">
<label>生成代码</label>
<div style="margin-bottom: 10px;">
<button type="button" class="btn btn-success" onclick="compileTemplate()">AI 生成代码</button>
<button type="button" class="btn btn-secondary" onclick="testCompile()">使用 Mock 测试</button>
</div>
<textarea id="rule-code" placeholder="def rule(facts):&#10; if facts.get('subscription') == 'premium':&#10; return {'action': 'discount', 'rate': 0.8}&#10; return None"></textarea>
</div>
<button type="submit" class="btn btn-primary">创建规则</button>
</form>
</div>
</div>
<div id="test" class="tab-content">
<div class="card">
<h3>规则测试</h3>
<div id="test-result"></div>
<form onsubmit="testRule(event)">
<div class="form-group">
<label>Facts (JSON)</label>
<textarea id="test-facts" placeholder='{"subscription": "premium", "age": 25}'>{}</textarea>
</div>
<div class="form-group">
<label>指定规则 ID可选</label>
<input type="text" id="test-rule-id" placeholder="留空则匹配所有">
</div>
<button type="submit" class="btn btn-primary">测试</button>
</form>
</div>
</div>
<div id="conflicts" class="tab-content">
<div class="card">
<h3>冲突检测</h3>
<button class="btn btn-secondary" onclick="checkConflicts()">检查冲突</button>
<div id="conflicts-list" style="margin-top: 15px;"></div>
</div>
</div>
</div>
<script>
const API = '';
let currentRules = [];
function showTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.querySelector(`.tab[onclick="showTab('${name}')"]`).classList.add('active');
document.getElementById(name).classList.add('active');
}
async function loadRules() {
const res = await fetch(API + '/api/rules');
const data = await res.json();
currentRules = data.rules || [];
const html = currentRules.length ? `
<table><thead><tr><th>名称</th><th>描述</th><th>优先级</th><th>版本</th><th>状态</th><th>操作</th></tr></thead>
<tbody>${currentRules.map(r => `
<tr>
<td><strong>${escapeHtml(r.name)}</strong><br><code style="font-size:11px">${r.id}</code></td>
<td>${escapeHtml(r.description || '-')}</td>
<td>${r.priority}</td>
<td>v${r.version}</td>
<td>${r.is_active ? '<span class="badge badge-success">活跃</span>' : '<span class="badge badge-danger">禁用</span>'}</td>
<td class="actions">
<button class="btn btn-danger" onclick="deleteRule('${r.id}')">删除</button>
</td>
</tr>`).join('')}</tbody></table>
<details style="margin-top:20px"><summary>完整 JSON</summary><pre>${JSON.stringify(currentRules, null, 2)}</pre></details>
` : '<p>暂无规则</p>';
document.getElementById('rules-list').innerHTML = html;
}
async function createRule(e) {
e.preventDefault();
const name = document.getElementById('rule-name').value;
const desc = document.getElementById('rule-desc').value;
const template = document.getElementById('rule-template').value;
const priority = parseInt(document.getElementById('rule-priority').value) || 0;
const code = document.getElementById('rule-code').value || 'def rule(facts):\\n return None';
const resultDiv = document.getElementById('create-result');
try {
const res = await fetch(API + '/api/rules', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, description: desc, condition_template: template, priority, code})
});
const data = await res.json();
if (res.ok) {
let html = '<div class="alert alert-success">规则创建成功!</div>';
if (data.conflict_warning) {
html += `<div class="alert alert-warning"><strong>冲突警告</strong>${JSON.stringify(data.conflict_warning)}</div>`;
}
resultDiv.innerHTML = html;
e.target.reset();
loadRules();
} else {
resultDiv.innerHTML = `<div class="alert alert-danger">错误: ${data.error}</div>`;
}
} catch (err) {
resultDiv.innerHTML = `<div class="alert alert-danger">请求失败: ${err}</div>`;
}
}
async function compileTemplate() {
const template = document.getElementById('rule-template').value;
if (!template) { alert('请先输入规则描述模板'); return; }
try {
const res = await fetch(API + '/api/rules/compile', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({condition_template: template})
});
const data = await res.json();
if (res.ok) {
document.getElementById('rule-code').value = data.generated_code;
} else {
alert('编译失败: ' + data.error);
}
} catch (err) {
alert('请求失败: ' + err);
}
}
function testCompile() {
// Mock 编译结果用于测试
const template = document.getElementById('rule-template').value;
const mockCode = `def rule(facts):
if facts.get("subscription") == "premium":
return {"action": "discount", "rate": 0.8}
return None`;
document.getElementById('rule-code').value = mockCode;
}
async function testRule(e) {
e.preventDefault();
const factsStr = document.getElementById('test-facts').value;
const ruleId = document.getElementById('test-rule-id').value;
const resultDiv = document.getElementById('test-result');
try {
let facts;
try { facts = JSON.parse(factsStr); } catch { facts = {}; }
const body = { facts };
if (ruleId) body.rule_id = ruleId;
const res = await fetch(API + '/api/rules/evaluate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
const data = await res.json();
if (data.matched) {
resultDiv.innerHTML = `<div class="alert alert-success">
<strong>匹配成功</strong><br>
规则ID: ${data.rule_id}<br>
结果: <pre>${JSON.stringify(data.result, null, 2)}</pre>
</div>`;
} else {
resultDiv.innerHTML = `<div class="alert alert-warning">无匹配规则<br>rule_id: ${data.rule_id || 'none'}</div>`;
}
} catch (err) {
resultDiv.innerHTML = `<div class="alert alert-danger">请求失败: ${err}</div>`;
}
}
async function checkConflicts() {
const res = await fetch(API + '/api/conflicts');
const data = await res.json();
const conflicts = data.conflicts || [];
const html = conflicts.length ? conflicts.map(c => `
<div class="alert alert-danger">
<strong>冲突</strong>${c.rule_a_name} vs ${c.rule_b_name}<br>
<small>${c.reason}</small><br>
<button class="btn btn-danger" onclick="deleteRule('${c.rule_a_id}')">删除 ${c.rule_a_name}</button>
<button class="btn btn-danger" onclick="deleteRule('${c.rule_b_id}')">删除 ${c.rule_b_name}</button>
</div>`).join('') : '<div class="alert alert-success">未检测到冲突</div>';
document.getElementById('conflicts-list').innerHTML = html;
}
async function deleteRule(id) {
if (!confirm('确认删除规则 ' + id + '')) return;
await fetch(API + '/api/rules/' + id, {method: 'DELETE'});
loadRules();
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// 初始化
loadRules();
</script>
</body>
</html>
"""
def create_web_app(store: RuleStore, host: str = "0.0.0.0", port: int = 8080, enable_callback: bool = False) -> HTTPServer:
"""创建 Web UI 服务器。"""
WebUIHandler.app = RuleEngineWebApp(store, enable_callback)
server = HTTPServer((host, port), WebUIHandler)
return server