289 lines
11 KiB
Python
289 lines
11 KiB
Python
|
|
# ============================================================
|
|||
|
|
# A0050101-00280 外壳 · 紧急插单计划员自然语言验收
|
|||
|
|
#
|
|||
|
|
# 目标:用计划员原话验证 Pi 只负责理解和调用,能否插单、影响哪些订单
|
|||
|
|
# 必须来自真实 RULE 引擎;采用前不得写入订单或生成排产版本。
|
|||
|
|
# ============================================================
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
from datetime import datetime, timedelta
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
from server.agent_core import fallback_lane
|
|||
|
|
from server.aps_domain import workflow as wf
|
|||
|
|
from server.aps_domain.project_analyze import analyze_project_deep
|
|||
|
|
from server.contracts import IntentResult
|
|||
|
|
from server.state.seed import empty_world
|
|||
|
|
from server.timeutil import fmt_date
|
|||
|
|
|
|||
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|||
|
|
DEMO_DIR = REPO_ROOT / "demo-data" / "锐扬APS演示数据"
|
|||
|
|
PRODUCT_CODE = "A0050101-00280"
|
|||
|
|
FIXED_TODAY = datetime(2026, 9, 18) # noqa: DTZ001 - 业务日历统一使用本地无时区时间
|
|||
|
|
|
|||
|
|
|
|||
|
|
class FakeStore:
|
|||
|
|
def __init__(self, world: dict):
|
|||
|
|
self.data = world
|
|||
|
|
self._counters: dict[str, int] = {}
|
|||
|
|
|
|||
|
|
def next_id(self, kind: str) -> int:
|
|||
|
|
self._counters[kind] = self._counters.get(kind, 0) + 1
|
|||
|
|
return self._counters[kind]
|
|||
|
|
|
|||
|
|
def save(self) -> None:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _primary_intent(query: str) -> IntentResult:
|
|||
|
|
return IntentResult(
|
|||
|
|
intent="assistant.reply",
|
|||
|
|
params={"query": query, "_history": [], "_piPrimary": True},
|
|||
|
|
confidence=1.0,
|
|||
|
|
source="LLM",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _planner_world(monkeypatch) -> dict:
|
|||
|
|
"""加载仓库里的锐扬演示资料,并用固定时钟保证测试可复现。"""
|
|||
|
|
monkeypatch.setattr("server.aps_domain.rush.today0", lambda: FIXED_TODAY)
|
|||
|
|
monkeypatch.setattr("server.aps_domain.lns.today0", lambda: FIXED_TODAY)
|
|||
|
|
|
|||
|
|
world = empty_world()
|
|||
|
|
counter = {"n": 0}
|
|||
|
|
|
|||
|
|
def next_id(_kind: str) -> int:
|
|||
|
|
counter["n"] += 1
|
|||
|
|
return counter["n"]
|
|||
|
|
|
|||
|
|
report = analyze_project_deep(
|
|||
|
|
world,
|
|||
|
|
input_path=str(DEMO_DIR),
|
|||
|
|
next_id=next_id,
|
|||
|
|
apply=True,
|
|||
|
|
)
|
|||
|
|
assert report["canSchedule"] is True
|
|||
|
|
assert any(m.get("code") == PRODUCT_CODE for m in world["materials"])
|
|||
|
|
|
|||
|
|
# 演示包的交期是历史固定日期;测试只平移交期,保留产品、BOM、工艺和设备原样。
|
|||
|
|
for offset, order in enumerate(world["salesOrders"]):
|
|||
|
|
order["deliveryDate"] = fmt_date(FIXED_TODAY + timedelta(days=12 + offset))
|
|||
|
|
world["scheduleParams"]["planningHorizonDays"] = 60
|
|||
|
|
return world
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _install_real_tool_runtime(monkeypatch) -> list[IntentResult]:
|
|||
|
|
calls: list[IntentResult] = []
|
|||
|
|
|
|||
|
|
async def fake_run_tool(store, session_id, intent, actor="planner"):
|
|||
|
|
calls.append(intent)
|
|||
|
|
return await wf.handle_intent(store, session_id, intent, actor)
|
|||
|
|
|
|||
|
|
monkeypatch.setattr("server.agent_core.tool_runtime.run_tool_async", fake_run_tool)
|
|||
|
|
return calls
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _pi_runner(captured: dict, tool: str | None, params: dict, model_text: str):
|
|||
|
|
def runner(task: str, work_dir: Path):
|
|||
|
|
captured["task"] = task
|
|||
|
|
if tool is None:
|
|||
|
|
yield {
|
|||
|
|
"type": "message_end",
|
|||
|
|
"message": {
|
|||
|
|
"role": "assistant",
|
|||
|
|
"stopReason": "stop",
|
|||
|
|
"content": [{"type": "text", "text": model_text}],
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
yield {"type": "agent_end", "messages": []}
|
|||
|
|
return
|
|||
|
|
mailbox = work_dir.parent / "outbox" / "chat-tools"
|
|||
|
|
mailbox.mkdir(parents=True, exist_ok=True)
|
|||
|
|
request = mailbox / f"01-{tool.replace('.', '-')}.json"
|
|||
|
|
request.write_text(
|
|||
|
|
json.dumps({"seq": 1, "tool": tool, "params": params}, ensure_ascii=False),
|
|||
|
|
encoding="utf-8",
|
|||
|
|
)
|
|||
|
|
yield {"type": "harness_heartbeat"}
|
|||
|
|
result = request.with_name(request.stem + ".result.json")
|
|||
|
|
captured["tool_result"] = json.loads(result.read_text(encoding="utf-8"))
|
|||
|
|
yield {
|
|||
|
|
"type": "message_end",
|
|||
|
|
"message": {
|
|||
|
|
"role": "assistant",
|
|||
|
|
"stopReason": "stop",
|
|||
|
|
"content": [{"type": "text", "text": model_text}],
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
yield {"type": "agent_end", "messages": []}
|
|||
|
|
|
|||
|
|
return runner
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _assert_planner_language(text: str, summary: list[str] | None = None) -> None:
|
|||
|
|
visible = text + "\n" + "\n".join(summary or [])
|
|||
|
|
for term in ("P1", "P2", "DRAFT", "LNS", "evalId", "APPROVED", "isRush", "沙盒"):
|
|||
|
|
assert term not in visible
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def test_planner_rush_natural_language_uses_engine_and_ignores_pi_guess(
|
|||
|
|
tmp_path, monkeypatch,
|
|||
|
|
):
|
|||
|
|
"""计划员原话进入 Pi 后,必须调用 rush.evaluate,且引擎结论覆盖模型猜测。"""
|
|||
|
|
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
|
|||
|
|
store = FakeStore(_planner_world(monkeypatch))
|
|||
|
|
calls = _install_real_tool_runtime(monkeypatch)
|
|||
|
|
query = (
|
|||
|
|
"湖南工业控制有限公司追加 50 套 A0050101-00280 外壳,10月14日交货,"
|
|||
|
|
"先评估紧急插单影响,不要直接改计划。"
|
|||
|
|
)
|
|||
|
|
params = {
|
|||
|
|
"customerName": "湖南工业控制有限公司",
|
|||
|
|
"customerLevel": "A",
|
|||
|
|
"productCode": PRODUCT_CODE,
|
|||
|
|
"quantity": 50,
|
|||
|
|
"deliveryDate": "2026-10-14",
|
|||
|
|
"priority": 1,
|
|||
|
|
"strategy": "DELIVERY_FIRST",
|
|||
|
|
}
|
|||
|
|
captured: dict = {}
|
|||
|
|
|
|||
|
|
reply = await fallback_lane.propose_reply(
|
|||
|
|
store,
|
|||
|
|
"s-planner-rush",
|
|||
|
|
_primary_intent(query),
|
|||
|
|
runner=_pi_runner(
|
|||
|
|
captured,
|
|||
|
|
"rush.evaluate",
|
|||
|
|
params,
|
|||
|
|
"可以插单,而且不会影响任何订单,直接改计划就行。",
|
|||
|
|
),
|
|||
|
|
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert [call.intent for call in calls] == ["rush.evaluate"]
|
|||
|
|
assert calls[0].params["productCode"] == PRODUCT_CODE
|
|||
|
|
assert calls[0].params["quantity"] == 50
|
|||
|
|
assert calls[0].params["deliveryDate"] == "2026-10-14"
|
|||
|
|
assert "必须先调用 `rush.evaluate`" in captured["task"]
|
|||
|
|
assert reply.text.startswith("插单影响评估完成")
|
|||
|
|
assert "直接改计划就行" not in reply.text
|
|||
|
|
assert "现有计划没有改动" in reply.text
|
|||
|
|
assert {block.props.get("kind") for block in reply.blocks} >= {"rush-impact"}
|
|||
|
|
|
|||
|
|
actual = wf._LAST_RUSH_EVAL
|
|||
|
|
assert actual["rushOrder"]["productCode"] == PRODUCT_CODE
|
|||
|
|
assert actual["rushOrder"]["quantity"] == 50
|
|||
|
|
assert actual["rushOrder"]["deliveryDate"] == "2026-10-14"
|
|||
|
|
assert actual["affectedOrderCount"] == len(actual["affectedOrders"])
|
|||
|
|
assert actual["lns"]["status"] in {"LOCAL", "ESCALATE"}
|
|||
|
|
_assert_planner_language(reply.text)
|
|||
|
|
wf._LAST_RUSH_EVAL.clear()
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def test_planner_rush_missing_quantity_asks_instead_of_guessing(tmp_path, monkeypatch):
|
|||
|
|
"""缺少数量或交期时,Pi 先追问,不拿默认值代替计划员的真实要求。"""
|
|||
|
|
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
|
|||
|
|
store = FakeStore(_planner_world(monkeypatch))
|
|||
|
|
calls = _install_real_tool_runtime(monkeypatch)
|
|||
|
|
captured: dict = {}
|
|||
|
|
question = "请补充这批 A0050101-00280 外壳的交货数量和最晚交货日期,我再按真实产能评估。"
|
|||
|
|
|
|||
|
|
reply = await fallback_lane.propose_reply(
|
|||
|
|
store,
|
|||
|
|
"s-planner-rush-missing",
|
|||
|
|
_primary_intent("客户临时要加一批 A0050101-00280 外壳,先看能不能插进去。"),
|
|||
|
|
runner=_pi_runner(captured, None, {}, question),
|
|||
|
|
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert calls == []
|
|||
|
|
assert "交货数量" in reply.text and "最晚交货日期" in reply.text
|
|||
|
|
assert "缺少“已有订单号,或产品编码/名称 + 数量 + 交期”时先向用户追问" in captured["task"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def test_planner_rush_unknown_order_returns_engine_rejection(tmp_path, monkeypatch):
|
|||
|
|
"""订单号不存在时,Pi 不能改口说可以插,必须转达引擎的真实拒绝原因。"""
|
|||
|
|
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
|
|||
|
|
store = FakeStore(_planner_world(monkeypatch))
|
|||
|
|
calls = _install_real_tool_runtime(monkeypatch)
|
|||
|
|
captured: dict = {}
|
|||
|
|
|
|||
|
|
reply = await fallback_lane.propose_reply(
|
|||
|
|
store,
|
|||
|
|
"s-planner-rush-unknown",
|
|||
|
|
_primary_intent("把 SO-NOT-EXIST 加急,10月14日交,直接告诉我能不能插。"),
|
|||
|
|
runner=_pi_runner(
|
|||
|
|
captured,
|
|||
|
|
"rush.evaluate",
|
|||
|
|
{"orderNo": "SO-NOT-EXIST"},
|
|||
|
|
"这张单可以加急,我已经安排进去了。",
|
|||
|
|
),
|
|||
|
|
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert [call.intent for call in calls] == ["rush.evaluate"]
|
|||
|
|
assert reply.text == "订单 SO-NOT-EXIST 不存在"
|
|||
|
|
assert "已经安排进去" not in reply.text
|
|||
|
|
wf._LAST_RUSH_EVAL.clear()
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def test_planner_rush_apply_waits_for_confirmation(tmp_path, monkeypatch):
|
|||
|
|
"""“采用刚才方案”只生成确认事项;确认前订单和排产版本都不变。"""
|
|||
|
|
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
|
|||
|
|
store = FakeStore(_planner_world(monkeypatch))
|
|||
|
|
payload = {
|
|||
|
|
"customerName": "湖南工业控制有限公司",
|
|||
|
|
"customerLevel": "A",
|
|||
|
|
"productCode": PRODUCT_CODE,
|
|||
|
|
"quantity": 50,
|
|||
|
|
"deliveryDate": "2026-10-14",
|
|||
|
|
"priority": 1,
|
|||
|
|
"strategy": "DELIVERY_FIRST",
|
|||
|
|
}
|
|||
|
|
await wf.handle_intent(
|
|||
|
|
store,
|
|||
|
|
"s-planner-rush-apply",
|
|||
|
|
IntentResult(intent="rush.evaluate", params=payload, confidence=1.0, source="LLM"),
|
|||
|
|
"planner",
|
|||
|
|
)
|
|||
|
|
before_orders = len(store.data["salesOrders"])
|
|||
|
|
before_versions = len(store.data["scheduleVersions"])
|
|||
|
|
calls = _install_real_tool_runtime(monkeypatch)
|
|||
|
|
captured: dict = {}
|
|||
|
|
|
|||
|
|
reply = await fallback_lane.propose_reply(
|
|||
|
|
store,
|
|||
|
|
"s-planner-rush-apply",
|
|||
|
|
_primary_intent("采用刚才的插单方案,但先给我确认。"),
|
|||
|
|
runner=_pi_runner(
|
|||
|
|
captured,
|
|||
|
|
"rush.apply",
|
|||
|
|
{},
|
|||
|
|
"已经采用,订单也写好并发布了。",
|
|||
|
|
),
|
|||
|
|
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert [call.intent for call in calls] == ["rush.apply"]
|
|||
|
|
assert calls[0].params == {}
|
|||
|
|
assert len(store.data["salesOrders"]) == before_orders
|
|||
|
|
assert len(store.data["scheduleVersions"]) == before_versions
|
|||
|
|
assert "已经写好并发布" not in reply.text
|
|||
|
|
cards = [block for block in reply.blocks if block.type == "confirm-card"]
|
|||
|
|
assert len(cards) == 1
|
|||
|
|
_assert_planner_language(reply.text, cards[0].props.get("summary") or [])
|
|||
|
|
|
|||
|
|
message = wf.execute_confirmed(
|
|||
|
|
store,
|
|||
|
|
str(cards[0].props["confirmId"]),
|
|||
|
|
approve=True,
|
|||
|
|
actor="planner",
|
|||
|
|
)
|
|||
|
|
assert len(store.data["salesOrders"]) == before_orders + 1
|
|||
|
|
assert len(store.data["scheduleVersions"]) == before_versions + 1
|
|||
|
|
assert store.data["scheduleVersions"][-1]["status"] == "DRAFT"
|
|||
|
|
_assert_planner_language(message)
|
|||
|
|
wf._LAST_RUSH_EVAL.clear()
|