aps-agent/server/aps_domain/constraint_rules.py

353 lines
15 KiB
Python
Raw Permalink Normal View History

# ============================================================
# 计划员约束规则库(moduleId: domain-constraint-rules, 可重生 ✅)
# 默认通用规则 = constraints.py 的 C1..C13 内建目录(只读基线,本模块不改它)
# 本模块只管理「计划员用自然语言提出、经 P2 确认后扩充的规则」:
# * 按类别归档(沿用 GROUP_LABELS 的 8 类 + 其他)
# * 按范围归档(仅本项目 / 全厂)
# * 没有接入排产引擎的规则如实标注,不假装生效
# ============================================================
from __future__ import annotations
import json
import os
import re
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Any
from server.aps_domain.constraints import (
DEFAULT_CONSTRAINT_DEFS,
GROUP_LABELS,
get_constraint,
get_constraint_profile,
)
World = dict[str, Any]
RULE_CATEGORIES: dict[str, str] = {**GROUP_LABELS, "other": "其他"}
RULE_SCOPES: dict[str, str] = {"project": "仅本项目", "tenant": "全厂"}
RULE_KINDS: dict[str, str] = {"hard": "必须满足", "soft": "尽量满足"}
RULE_OPS: tuple[str, ...] = ("add", "enable", "disable", "delete")
_ID_RE = re.compile(r"^R-(\d+)$")
def _now() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M")
def _safe_tenant(tenant_uuid: str | None) -> str:
raw = (tenant_uuid or "platform").strip() or "platform"
return re.sub(r"[^\w\-]+", "_", raw)[:64] or "platform"
def tenant_rules_path(tenant_uuid: str | None) -> Path:
"""全厂规则落盘位置:与 world.json 同一数据根(尊重 APS_HOME / APS_DATA_DIR)。"""
from server.aps_home import data_dir
tenant = _safe_tenant(tenant_uuid)
if tenant == "platform":
return data_dir() / "constraint-rules.json"
return data_dir() / "tenants" / tenant / "constraint-rules.json"
def load_tenant_rules(tenant_uuid: str | None) -> list[dict[str, Any]]:
"""读取全厂规则(文件不存在或损坏时返回空列表,不抛错)。"""
path = tenant_rules_path(tenant_uuid)
if not path.exists():
return []
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
rules = raw.get("rules") if isinstance(raw, dict) else raw
if not isinstance(rules, list):
return []
return [r for r in rules if isinstance(r, dict)]
def save_tenant_rules(tenant_uuid: str | None, rules: list[dict[str, Any]]) -> str:
"""原子写入全厂规则文件(tmp + os.replace,与 WorldStore 同一写盘策略)。"""
path = tenant_rules_path(tenant_uuid)
path.parent.mkdir(parents=True, exist_ok=True)
body = json.dumps(
{"version": 1, "scope": "tenant", "rules": rules},
ensure_ascii=False, indent=2,
)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".constraint-rules-", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(body + "\n")
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
return str(path)
def project_rules(world: World) -> list[dict[str, Any]]:
raw = world.get("constraintRules")
if not isinstance(raw, list):
return []
return [r for r in raw if isinstance(r, dict)]
def all_rules(world: World, tenant_uuid: str | None = None) -> list[dict[str, Any]]:
"""全厂规则 + 本项目规则,统一补 scope 字段(缺省按所属容器判定)。"""
out: list[dict[str, Any]] = []
for r in load_tenant_rules(tenant_uuid):
out.append({**r, "scope": r.get("scope") or "tenant"})
for r in project_rules(world):
out.append({**r, "scope": r.get("scope") or "project"})
return out
def next_rule_id(rules: list[dict[str, Any]]) -> str:
top = 0
for r in rules:
m = _ID_RE.match(str(r.get("id") or ""))
if m:
top = max(top, int(m.group(1)))
return f"R-{top + 1:03d}"
def rule_effect(world: World, rule: dict[str, Any]) -> dict[str, Any]:
"""如实说明这条规则当前靠谁执行;没有接入引擎就直说。"""
bound = str(rule.get("boundConstraintId") or "").strip()
if not bound:
return {"bound": False, "enforced": False, "text": "已记录,暂未接入排产引擎"}
c = get_constraint(world, bound)
if not c:
return {"bound": True, "enforced": False,
"text": f"绑定的内置规则 {bound} 不在当前约束目录,暂未执行"}
state = "已启用" if c.get("enabled", True) else "已关闭"
kind_label = "硬约束" if c.get("kind") == "hard" else "软约束"
enforced = bool(c.get("enabled", True))
if not enforced:
return {"bound": True, "enforced": False,
"text": f"内置规则《{c['name']}》当前已关闭,本条暂不生效"}
return {"bound": True, "enforced": True,
"text": f"由内置规则《{c['name']}》执行({state}/{kind_label})"}
def _default_ids() -> set[str]:
return {str(d["id"]) for d in DEFAULT_CONSTRAINT_DEFS}
def normalize_rule_payload(payload: dict[str, Any]) -> dict[str, Any]:
"""校验规则库载荷;只接受计划员语言字段,不接受 JSON 片段。"""
raw = payload if isinstance(payload, dict) else {}
op = str(raw.get("op") or "add").strip().lower() or "add"
if op not in RULE_OPS:
raise ValueError("规则操作只能是 新增/启用/停用/删除")
if op != "add":
rule_id = str(raw.get("ruleId") or raw.get("id") or "").strip()
if not rule_id:
raise ValueError("请指定要操作的规则")
return {"op": op, "ruleId": rule_id}
name = str(raw.get("name") or raw.get("text") or "").strip()
if not name:
raise ValueError("规则内容不能为空,请用一句话描述")
if len(name) > 40:
raise ValueError("规则内容请控制在 40 字以内,说清「什么时候、什么不能排」即可")
category = str(raw.get("category") or "other").strip().lower() or "other"
if category not in RULE_CATEGORIES:
raise ValueError(f"类别只能是:{'、'.join(RULE_CATEGORIES.values())}")
scope = str(raw.get("scope") or "project").strip().lower() or "project"
if scope not in RULE_SCOPES:
raise ValueError("适用范围只能是:仅本项目 / 全厂")
kind = str(raw.get("kind") or "soft").strip().lower() or "soft"
if kind not in RULE_KINDS:
raise ValueError("强度只能是:必须满足 / 尽量满足")
bound = str(raw.get("boundConstraintId") or "").strip()
if bound and bound not in _default_ids():
raise ValueError(f"绑定的内置规则 {bound} 不存在,请留空或改用现有内置规则")
out: dict[str, Any] = {
"op": "add", "name": name, "category": category, "scope": scope, "kind": kind,
}
if bound:
out["boundConstraintId"] = bound
source = str(raw.get("sourceText") or "").strip()
if source:
out["sourceText"] = source[:200]
return out
def _bound_apply(world: World, norm: dict[str, Any]) -> tuple[dict[str, Any] | None, str | None]:
"""新增规则绑定内置可配项时,把强度真正落到排产引擎;返回 (补丁, 说明)。"""
bound = str(norm.get("boundConstraintId") or "")
if not bound:
return None, None
c = get_constraint(world, bound)
if not c or not c.get("kindConfigurable"):
return None, None
target = "hard" if norm.get("kind") == "hard" else "soft"
if c.get("kind") == target:
return None, None
label = "硬约束(违反阻止发布)" if target == "hard" else "软约束(只提示风险)"
return {"constraints": {bound: {"kind": target}}}, f"同时把内置规则《{c['name']}》调整为{label}"
def confirmation_for_rule_save(world: World, payload: dict[str, Any],
tenant_uuid: str | None = None) -> tuple[str, list[str]]:
"""P2 确认卡文案:计划员看得懂,写清范围、强度和是否真的接入引擎。"""
norm = normalize_rule_payload(payload)
if norm["op"] == "add":
title = f"登记约束规则:{norm['name']}"
lines = [
f"类别:{RULE_CATEGORIES[norm['category']]};适用范围:{RULE_SCOPES[norm['scope']]}",
f"强度:{RULE_KINDS[norm['kind']]}",
]
patch, note = _bound_apply(world, norm)
if patch:
lines.append(f"接入排产:{note}")
elif norm.get("boundConstraintId"):
c = get_constraint(world, str(norm["boundConstraintId"]))
lines.append(f"接入排产:由内置规则《{(c or {}).get('name') or norm['boundConstraintId']}》执行")
else:
lines.append("接入排产:暂未接入排产引擎,先进入规则库备查")
lines.append("确认后写入规则库并记审计;不影响历史版本")
return title, lines
rules = {str(r.get("id")): r for r in all_rules(world, tenant_uuid)}
rule = rules.get(norm["ruleId"])
if not rule:
raise ValueError(f"规则库中没有 {norm['ruleId']} 这条规则")
name = str(rule.get("name") or norm["ruleId"])
effect = rule_effect(world, rule)["text"]
if norm["op"] == "delete":
return f"删除约束规则:{name}", [
f"原规则:{name}({RULE_CATEGORIES.get(str(rule.get('category')), '其他')})",
f"当前执行:{effect}",
"删除后不再出现在规则库;不影响历史版本与已发布计划",
]
label = "启用" if norm["op"] == "enable" else "停用"
lines = [
f"原规则:{name}({RULE_CATEGORIES.get(str(rule.get('category')), '其他')}/"
f"{RULE_SCOPES.get(str(rule.get('scope')), '仅本项目')})",
f"当前执行:{effect}",
"该操作只调整规则库里的状态,不改动内置约束的开关",
]
return f"{label}约束规则:{name}", lines
def _locate(world: World, tenant_uuid: str | None,
rule_id: str) -> tuple[dict[str, Any] | None, str | None]:
for r in load_tenant_rules(tenant_uuid):
if str(r.get("id")) == rule_id:
return r, "tenant"
for r in project_rules(world):
if str(r.get("id")) == rule_id:
return r, "project"
return None, None
def _write_container(world: World, tenant_uuid: str | None, where: str,
rules: list[dict[str, Any]]) -> None:
if where == "tenant":
save_tenant_rules(tenant_uuid, rules)
else:
world["constraintRules"] = rules
def apply_rule_save(world: World, payload: dict[str, Any],
tenant_uuid: str | None = None) -> dict[str, Any]:
"""落库:新增/启用/停用/删除计划员扩充规则(调用方负责审计与 save)。"""
norm = normalize_rule_payload(payload)
if norm["op"] == "add":
existing = all_rules(world, tenant_uuid)
rule_id = next_rule_id(existing)
rec: dict[str, Any] = {
"id": rule_id,
"name": norm["name"],
"category": norm["category"],
"scope": norm["scope"],
"kind": norm["kind"],
"enabled": True,
"createdAt": _now(),
"updatedAt": _now(),
}
if norm.get("boundConstraintId"):
rec["boundConstraintId"] = norm["boundConstraintId"]
if norm.get("sourceText"):
rec["source"] = {"type": "PLANNER_CHAT", "text": norm["sourceText"]}
patch, note = _bound_apply(world, norm)
if patch:
from server.aps_domain.constraints import apply_profile_save
apply_profile_save(world, patch)
if norm["scope"] == "tenant":
save_tenant_rules(tenant_uuid, load_tenant_rules(tenant_uuid) + [rec])
else:
world["constraintRules"] = project_rules(world) + [rec]
effect = rule_effect(world, rec)["text"]
message = (f"约束规则已登记 ✅ {rule_id} {rec['name']}"
f"({RULE_CATEGORIES[norm['category']]}/{RULE_SCOPES[norm['scope']]})。"
f"\n执行情况:{effect}。" + (f"\n{note}。" if note else ""))
return {"ruleId": rule_id, "op": "add", "scope": norm["scope"],
"rule": rec, "message": message}
rule_id = norm["ruleId"]
rule, where = _locate(world, tenant_uuid, rule_id)
if not rule or not where:
raise ValueError(f"规则库中没有 {rule_id} 这条规则")
container = load_tenant_rules(tenant_uuid) if where == "tenant" else project_rules(world)
name = str(rule.get("name") or rule_id)
if norm["op"] == "delete":
kept = [r for r in container if str(r.get("id")) != rule_id]
_write_container(world, tenant_uuid, where, kept)
return {"ruleId": rule_id, "op": "delete", "scope": where,
"message": f"约束规则已删除 ✅ {rule_id} {name}。"}
enabled = norm["op"] == "enable"
updated = []
for r in container:
if str(r.get("id")) == rule_id:
updated.append({**r, "enabled": enabled, "updatedAt": _now()})
else:
updated.append(r)
_write_container(world, tenant_uuid, where, updated)
label = "启用" if enabled else "停用"
return {"ruleId": rule_id, "op": norm["op"], "scope": where,
"message": f"约束规则已{label} ✅ {rule_id} {name}。"}
def constraint_library(world: World, tenant_uuid: str | None = None) -> dict[str, Any]:
"""设置页数据:默认通用规则概览 + 计划员扩充规则(按类别分组)。"""
builtin = get_constraint_profile(world)["constraints"]
custom: list[dict[str, Any]] = []
for r in all_rules(world, tenant_uuid):
effect = rule_effect(world, r)
category = str(r.get("category") or "other")
custom.append({
**r,
"category": category,
"categoryLabel": RULE_CATEGORIES.get(category, "其他"),
"scopeLabel": RULE_SCOPES.get(str(r.get("scope")), "仅本项目"),
"kindLabel": RULE_KINDS.get(str(r.get("kind")), "尽量满足"),
"effect": effect,
})
groups = []
for gid, label in RULE_CATEGORIES.items():
items = [r for r in custom if r["category"] == gid]
if items:
groups.append({"id": gid, "label": label, "items": items})
return {
"builtin": {
"total": len(builtin),
"enabled": sum(1 for c in builtin if c.get("enabled", True)),
"configurable": sum(1 for c in builtin if c.get("configurable")),
},
"custom": custom,
"groups": groups,
"categories": [{"id": k, "label": v} for k, v in RULE_CATEGORIES.items()],
"scopes": [{"id": k, "label": v} for k, v in RULE_SCOPES.items()],
"summary": {
"builtinTotal": len(builtin),
"customTotal": len(custom),
"engineBound": sum(1 for r in custom if r["effect"]["enforced"]),
"recordOnly": sum(1 for r in custom if not r["effect"]["enforced"]),
},
}