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:
parent
4f60151b86
commit
30055e30c1
28
README.md
28
README.md
@ -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
507
src/rule_engine/web.py
Normal 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): if facts.get('subscription') == 'premium': return {'action': 'discount', 'rate': 0.8} 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
|
||||||
Loading…
x
Reference in New Issue
Block a user