aps-agent/tests/golden/test_planner_rush_natural_l...

289 lines
11 KiB
Python
Raw Normal View History

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