aps-agent/server/aps_domain/sop_rules.py

306 lines
11 KiB
Python
Raw Normal View History

# ============================================================
# SOP → 约束规则包(moduleId: domain-sop-rules, IND-02 / C13,可重生 ✅)
# 知识库 SOP 编译为可应用的约束/换型策略补丁;预览 P0,应用 P2
# ============================================================
from __future__ import annotations
import re
import uuid
from datetime import datetime
from typing import Any
from server.timeutil import fmt_dt
World = dict[str, Any]
def _extract_minutes(text: str, *patterns: str, default: float | None = None) -> float | None:
for pat in patterns:
m = re.search(pat, text)
if m:
try:
return float(m.group(1))
except (TypeError, ValueError):
continue
return default
def list_compilable_sops(kb_assets: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""列出可编译的 SOP(kind=sop 且已审批)。"""
out = []
for a in kb_assets:
if a.get("kind") != "sop" or not a.get("approved", True):
continue
pack = compile_sop_asset(a)
out.append({
"assetId": a.get("assetId"),
"title": a.get("title"),
"tags": a.get("tags") or [],
"effectCount": len(pack.get("effects") or []),
"compilable": bool(pack.get("effects")),
"summary": pack.get("summary") or "",
})
return out
def compile_sop_asset(asset: dict[str, Any]) -> dict[str, Any]:
"""
将单条 SOP 编译为规则包预览(确定性模板+关键词,非 LLM)。
返回 {packId, title, source, effects[], summary, warnings[]}
"""
title = str(asset.get("title") or "")
content = str(asset.get("content") or "")
tags = [str(t) for t in (asset.get("tags") or [])]
blob = f"{title}\n{content}\n{' '.join(tags)}"
effects: list[dict[str, Any]] = []
warnings: list[str] = []
# —— 换线标准 SOP ——
if re.search(r"换线|换型", blob):
cross = _extract_minutes(
content,
r"跨产品族换线\s*(\d+)\s*分钟",
r"跨族[^\d]{0,6}(\d+)\s*分",
default=60.0,
)
std = _extract_minutes(
content,
r"换线作业标准\s*(\d+)\s*分钟",
r"标准\s*(\d+)\s*分钟",
default=30.0,
)
effects.append({
"op": "constraint.set",
"id": "C10_changeover",
"enabled": True,
"kind": "soft",
"rationale": "SOP 要求顺序相关换型受控",
})
effects.append({
"op": "changeover.policy",
"defaultCrossFamilyMin": cross,
"standardSetupMin": std,
"noChangeoverAfter": "16:00" if re.search(r"16:00|十六点|夜班", content) else None,
"rationale": f"跨族默认 {cross:.0f} 分;标准 {std:.0f} 分"
+ (";16:00 后不安排换线" if re.search(r"16:00", content) else ""),
})
effects.append({
"op": "strategy.suggest",
"strategy": "CHANGEOVER_MIN",
"rationale": "同产品族连续生产优先",
})
# —— 齐套 / 库存 SOP ——
if re.search(r"齐套|缺料|库存", blob) and not re.search(r"换线", title):
effects.append({
"op": "constraint.set",
"id": "C6_material_kit",
"enabled": True,
"kind": "soft",
"rationale": "SOP:缺料须可见,不默认阻断草案",
})
# —— 插单审批 SOP ——
if re.search(r"插单|急单", blob):
effects.append({
"op": "process.note",
"text": "VIP 插单须影响评估 + 主管审批(本系统 rush.apply 已是 P2)",
"rationale": "与门禁对齐,无需改引擎开关",
})
if not effects:
warnings.append("未识别可落地的约束模板;可扩展编译器或改用约束配置中心手工调整")
# C13 标记:有可应用效果时启用
if any(e.get("op") in ("constraint.set", "changeover.policy") for e in effects):
effects.append({
"op": "constraint.set",
"id": "C13_sop",
"enabled": True,
"kind": "soft",
"params": {
"sourceAssetId": asset.get("assetId"),
"sourceTitle": title,
},
"rationale": "标记本剖面含 SOP 编译规则",
})
summary_bits = []
for e in effects:
if e["op"] == "constraint.set":
summary_bits.append(f"{e['id']}→{'开' if e.get('enabled') else '关'}/{e.get('kind', '')}")
elif e["op"] == "changeover.policy":
summary_bits.append(f"跨族换型默认{e.get('defaultCrossFamilyMin')}分")
elif e["op"] == "strategy.suggest":
summary_bits.append(f"建议策略{e.get('strategy')}")
elif e["op"] == "process.note":
summary_bits.append("流程提示")
return {
"packId": f"pack-{asset.get('assetId') or uuid.uuid4().hex[:8]}",
"title": title,
"source": {
"assetId": asset.get("assetId"),
"kind": asset.get("kind"),
"version": asset.get("version"),
"title": title,
},
"effects": effects,
"summary": ";".join(summary_bits) if summary_bits else "无可应用效果",
"warnings": warnings,
}
def compile_sop_by_query(kb_assets: list[dict[str, Any]], query: str | None = None) -> dict[str, Any]:
"""按标题/关键词选 SOP 并编译;缺省取「换线」相关。"""
q = (query or "换线").strip()
sops = [a for a in kb_assets if a.get("kind") == "sop" and a.get("approved", True)]
hit = None
for a in sops:
blob = f"{a.get('title','')} {' '.join(a.get('tags') or [])}"
if q.lower() in blob.lower() or q in str(a.get("content") or ""):
hit = a
break
if hit is None and sops:
# 模糊:换线/齐套/插单
for key in ("换线", "齐套", "插单"):
if key in q:
hit = next((a for a in sops if key in str(a.get("title") or "")), None)
if hit:
break
if hit is None:
return {
"ok": False,
"error": f"未找到可编译的 SOP(查询:{q})",
"available": [{"assetId": a.get("assetId"), "title": a.get("title")} for a in sops[:12]],
}
pack = compile_sop_asset(hit)
return {"ok": True, "pack": pack}
def confirmation_for_sop_apply(pack: dict[str, Any]) -> tuple[str, list[str]]:
title = f"应用 SOP 规则包:{pack.get('title') or pack.get('packId')}"
lines = [pack.get("summary") or "应用编译效果"]
src = (pack.get("source") or {}).get("title")
if src:
lines.append(f"出处:{src}")
for e in pack.get("effects") or []:
if e.get("rationale"):
lines.append(f"· {e['op']}: {e['rationale']}")
lines.append("P2 写主干;执行前自动建档")
for w in pack.get("warnings") or []:
lines.append(f"⚠ {w}")
return title, lines
def apply_sop_pack(world: World, pack: dict[str, Any]) -> dict[str, Any]:
"""把规则包写入世界(约束剖面 / 换型策略 / 规则包登记)。"""
from server.aps_domain.constraints import apply_profile_save
effects = pack.get("effects") or []
constraint_payload: dict[str, Any] = {"constraints": {}}
changeover_policy: dict[str, Any] = dict(world.get("changeoverPolicy") or {})
notes: list[str] = []
applied: list[str] = []
for e in effects:
op = e.get("op")
if op == "constraint.set":
cid = str(e.get("id") or "")
if not cid:
continue
patch: dict[str, Any] = {}
if "enabled" in e:
patch["enabled"] = bool(e["enabled"])
if e.get("kind") in ("hard", "soft"):
patch["kind"] = e["kind"]
if isinstance(e.get("params"), dict):
patch["params"] = e["params"]
constraint_payload["constraints"][cid] = patch
applied.append(f"constraint:{cid}")
elif op == "changeover.policy":
if e.get("defaultCrossFamilyMin") is not None:
changeover_policy["defaultCrossFamilyMin"] = float(e["defaultCrossFamilyMin"])
if e.get("standardSetupMin") is not None:
changeover_policy["standardSetupMin"] = float(e["standardSetupMin"])
if e.get("noChangeoverAfter"):
changeover_policy["noChangeoverAfter"] = str(e["noChangeoverAfter"])
applied.append("changeover.policy")
elif op == "strategy.suggest":
notes.append(f"建议后续试排使用策略 {e.get('strategy')}")
applied.append(f"suggest:{e.get('strategy')}")
elif op == "process.note":
notes.append(str(e.get("text") or ""))
applied.append("process.note")
if constraint_payload["constraints"]:
apply_profile_save(world, constraint_payload)
if any(e.get("op") == "changeover.policy" for e in effects):
world["changeoverPolicy"] = changeover_policy
packs = world.setdefault("rulePacks", [])
rec = {
"packId": pack.get("packId") or f"pack-{uuid.uuid4().hex[:8]}",
"title": pack.get("title"),
"source": pack.get("source"),
"appliedAt": fmt_dt(datetime.now()),
"effects": effects,
"notes": notes,
}
# 同 asset 再应用则替换
src_id = (pack.get("source") or {}).get("assetId")
packs[:] = [p for p in packs if (p.get("source") or {}).get("assetId") != src_id]
packs.append(rec)
return {
"packId": rec["packId"],
"applied": applied,
"notes": notes,
"changeoverPolicy": changeover_policy,
"rulePackCount": len(packs),
}
def get_changeover_policy(world: World) -> dict[str, Any]:
pol = world.get("changeoverPolicy") or {}
return {
"defaultCrossFamilyMin": float(pol.get("defaultCrossFamilyMin") or 30),
"standardSetupMin": float(pol.get("standardSetupMin") or 30),
"noChangeoverAfter": pol.get("noChangeoverAfter"),
}
def sop_as_block(pack: dict[str, Any]) -> Any:
from server.contracts import UIBlock
return UIBlock(
blockId=f"sop-pack-{uuid.uuid4().hex[:8]}",
type="report",
props={
"title": f"SOP 编译:{pack.get('title')}",
"reportType": "sop-rule-pack",
"markdown": _pack_markdown(pack),
"pack": pack,
},
)
def _pack_markdown(pack: dict[str, Any]) -> str:
lines = [
f"# {pack.get('title')}",
"",
f"**摘要**:{pack.get('summary')}",
"",
"## 效果",
]
for e in pack.get("effects") or []:
lines.append(f"- `{e.get('op')}` — {e.get('rationale') or e}")
if pack.get("warnings"):
lines.append("")
lines.append("## 警告")
for w in pack["warnings"]:
lines.append(f"- {w}")
src = pack.get("source") or {}
lines += ["", f"出处:{src.get('title')}({src.get('assetId')})"]
return "\n".join(lines)