343 lines
13 KiB
Python
343 lines
13 KiB
Python
# ============================================================
|
||
# 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 "",
|
||
"plannerSummary": pack.get("plannerSummary") 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 = []
|
||
planner_bits: list[str] = []
|
||
for e in effects:
|
||
if e["op"] == "constraint.set":
|
||
summary_bits.append(f"{e['id']}→{'开' if e.get('enabled') else '关'}/{e.get('kind', '')}")
|
||
constraint_id = str(e.get("id") or "")
|
||
if constraint_id == "C10_changeover":
|
||
planner_bits.append("将换型顺序纳入排产控制")
|
||
elif constraint_id == "C6_material_kit":
|
||
planner_bits.append("物料不齐套时提示风险,不直接阻断排产草案")
|
||
elif constraint_id != "C13_sop":
|
||
planner_bits.append("将作业标准中的约束条件纳入排产")
|
||
elif e["op"] == "changeover.policy":
|
||
summary_bits.append(f"跨族换型默认{e.get('defaultCrossFamilyMin')}分")
|
||
policy_bits: list[str] = []
|
||
cross_min = e.get("defaultCrossFamilyMin")
|
||
standard_min = e.get("standardSetupMin")
|
||
if cross_min is not None:
|
||
policy_bits.append(f"跨产品族换型预留 {float(cross_min):.0f} 分钟")
|
||
if standard_min is not None:
|
||
policy_bits.append(f"常规换型预留 {float(standard_min):.0f} 分钟")
|
||
if e.get("noChangeoverAfter"):
|
||
policy_bits.append(f"{e['noChangeoverAfter']} 后不安排换型")
|
||
planner_bits.append(";".join(policy_bits) if policy_bits else "按作业标准控制换型时间")
|
||
elif e["op"] == "strategy.suggest":
|
||
summary_bits.append(f"建议策略{e.get('strategy')}")
|
||
planner_bits.append("优先连续生产同一产品族,减少换型次数")
|
||
elif e["op"] == "process.note":
|
||
summary_bits.append("流程提示")
|
||
planner_bits.append("急单插单先进行影响评估,并经主管确认后执行")
|
||
|
||
planner_bits = list(dict.fromkeys(planner_bits))
|
||
|
||
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 "无可应用效果",
|
||
"plannerSummary": (";".join(planner_bits) if planner_bits
|
||
else "当前作业标准暂未识别出可用于排产的约束"),
|
||
"warnings": warnings,
|
||
}
|
||
|
||
|
||
def compile_sop_by_asset(kb_assets: list[dict[str, Any]], asset_id: str | None = None) -> dict[str, Any]:
|
||
"""按 Pi 明确给出的 assetId 精确选择并编译 SOP。"""
|
||
requested = str(asset_id or "").strip()
|
||
sops = [a for a in kb_assets if a.get("kind") == "sop" and a.get("approved", True)]
|
||
available = [
|
||
{"assetId": a.get("assetId"), "title": a.get("title")}
|
||
for a in sops[:12]
|
||
]
|
||
if not requested:
|
||
return {
|
||
"ok": False,
|
||
"error": "请提供明确的 assetId,不能根据原始问句自动选择 SOP。",
|
||
"available": available,
|
||
}
|
||
hit = next(
|
||
(a for a in sops if str(a.get("assetId") or "") == requested),
|
||
None,
|
||
)
|
||
if hit is None:
|
||
return {
|
||
"ok": False,
|
||
"error": f"未找到可编译的 SOP:assetId={requested}。",
|
||
"available": available,
|
||
}
|
||
return {"ok": True, "pack": compile_sop_asset(hit)}
|
||
|
||
|
||
def compile_sop_by_query(kb_assets: list[dict[str, Any]], query: str | None = None) -> dict[str, Any]:
|
||
"""兼容旧调用;禁止用原始问句选择 SOP,调用方必须改用 assetId。"""
|
||
del query
|
||
sops = [a for a in kb_assets if a.get("kind") == "sop" and a.get("approved", True)]
|
||
return {
|
||
"ok": False,
|
||
"error": "请提供明确的 assetId,不能根据原始问句自动选择 SOP。",
|
||
"available": [
|
||
{"assetId": a.get("assetId"), "title": a.get("title")}
|
||
for a in sops[:12]
|
||
],
|
||
}
|
||
|
||
|
||
def confirmation_for_sop_apply(pack: dict[str, Any]) -> tuple[str, list[str]]:
|
||
title = f"应用作业标准:{pack.get('title') or pack.get('packId')}"
|
||
summary = str(pack.get("plannerSummary") or "当前作业标准暂未识别出可用于排产的约束")
|
||
lines = [bit.strip() for bit in summary.split(";") if bit.strip()]
|
||
src = (pack.get("source") or {}).get("title")
|
||
if src:
|
||
lines.append(f"出处:{src}")
|
||
lines.append("批准后应用到后续排产,历史版本保持不变。")
|
||
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"作业标准规则:{pack.get('title')}",
|
||
"reportType": "sop-rule-pack",
|
||
"markdown": _pack_markdown(pack),
|
||
"pack": pack,
|
||
},
|
||
)
|
||
|
||
|
||
def _pack_markdown(pack: dict[str, Any]) -> str:
|
||
summary = str(pack.get("plannerSummary") or "当前作业标准暂未识别出可用于排产的约束")
|
||
lines = [
|
||
f"# 作业标准:{pack.get('title')}",
|
||
"",
|
||
"## 排产规则",
|
||
]
|
||
for bit in summary.split(";"):
|
||
if bit.strip():
|
||
lines.append(f"- {bit.strip()}")
|
||
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')}"]
|
||
return "\n".join(lines)
|