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

289 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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