aps-agent/server/aps_domain/constraint_rules.py

353 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 计划员约束规则库(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"]),
},
}