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()
|