2026-09-08 00:07:26 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-09-14 15:40:10 +08:00
|
|
|
|
import ast
|
|
|
|
|
|
import json
|
2026-09-08 00:07:26 +08:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
2026-09-14 15:40:10 +08:00
|
|
|
|
from server.agent_core import fallback_lane
|
2026-09-08 00:07:26 +08:00
|
|
|
|
from server.contracts import AgentReply, IntentResult, UIBlock
|
|
|
|
|
|
from server.gateway.app import create_app
|
|
|
|
|
|
from server.state.seed import seed_world
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeStore:
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
|
self.data = seed_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, history: list[dict] | None = None) -> IntentResult:
|
|
|
|
|
|
return IntentResult(
|
|
|
|
|
|
intent="assistant.reply",
|
|
|
|
|
|
params={"query": query, "_history": history or [], "_piPrimary": True},
|
|
|
|
|
|
confidence=1.0,
|
|
|
|
|
|
source="LLM",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-14 15:40:10 +08:00
|
|
|
|
def test_legacy_intent_pipeline_modules_are_deleted():
|
|
|
|
|
|
server_root = Path(__file__).resolve().parents[2] / "server"
|
|
|
|
|
|
legacy_paths = {
|
|
|
|
|
|
server_root / "agent_core" / "intent.py",
|
|
|
|
|
|
server_root / "agent_core" / "dialog.py",
|
|
|
|
|
|
server_root / "agent_core" / "assistant.py",
|
|
|
|
|
|
}
|
|
|
|
|
|
assert all(not path.exists() for path in legacy_paths)
|
|
|
|
|
|
|
|
|
|
|
|
legacy_modules = {
|
|
|
|
|
|
"server.agent_core.intent",
|
|
|
|
|
|
"server.agent_core.dialog",
|
|
|
|
|
|
"server.agent_core.assistant",
|
|
|
|
|
|
}
|
|
|
|
|
|
violations: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
for path in server_root.rglob("*.py"):
|
|
|
|
|
|
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
|
|
|
|
|
|
for node in ast.walk(tree):
|
|
|
|
|
|
if isinstance(node, ast.Import):
|
|
|
|
|
|
imported = {alias.name for alias in node.names}
|
|
|
|
|
|
if imported & legacy_modules:
|
|
|
|
|
|
violations.append(f"{path.relative_to(server_root)}:{node.lineno}")
|
|
|
|
|
|
elif isinstance(node, ast.ImportFrom):
|
|
|
|
|
|
imported = {alias.name for alias in node.names}
|
|
|
|
|
|
if node.module in legacy_modules or (
|
|
|
|
|
|
node.module == "server.agent_core"
|
|
|
|
|
|
and imported & {"intent", "dialog", "assistant"}
|
|
|
|
|
|
):
|
|
|
|
|
|
violations.append(f"{path.relative_to(server_root)}:{node.lineno}")
|
|
|
|
|
|
|
|
|
|
|
|
assert violations == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_schedule_wizard_tool_returns_readiness_block():
|
|
|
|
|
|
from server.aps_domain.workflow import handle_intent
|
|
|
|
|
|
|
|
|
|
|
|
reply = await handle_intent(
|
|
|
|
|
|
FakeStore(),
|
|
|
|
|
|
"s-pi-wizard",
|
|
|
|
|
|
IntentResult(
|
|
|
|
|
|
intent="schedule.wizard",
|
|
|
|
|
|
params={},
|
|
|
|
|
|
confidence=1.0,
|
|
|
|
|
|
source="LLM",
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert reply.blocks
|
|
|
|
|
|
assert reply.blocks[0].type == "readiness"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-08 00:07:26 +08:00
|
|
|
|
async def test_primary_reply_ignores_fallback_flag_and_returns_product_text(
|
|
|
|
|
|
tmp_path, monkeypatch,
|
|
|
|
|
|
):
|
|
|
|
|
|
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
|
|
|
|
|
|
captured: dict[str, str] = {}
|
|
|
|
|
|
|
|
|
|
|
|
def runner(task: str, work_dir: Path):
|
|
|
|
|
|
captured["task"] = task
|
|
|
|
|
|
report = "status: success\n\n你好,我可以直接回答问题,也可以分析当前 APS 项目。"
|
|
|
|
|
|
yield {
|
|
|
|
|
|
"type": "message_end",
|
|
|
|
|
|
"message": {
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"stopReason": "stop",
|
|
|
|
|
|
"content": [{"type": "text", "text": report}],
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
yield {"type": "agent_end", "messages": []}
|
|
|
|
|
|
|
|
|
|
|
|
reply = await fallback_lane.propose_reply(
|
|
|
|
|
|
FakeStore(),
|
|
|
|
|
|
"s-primary",
|
|
|
|
|
|
_primary_intent(
|
|
|
|
|
|
"你现在能干嘛",
|
|
|
|
|
|
[{"role": "user", "text": "我刚才问的是当前项目"}],
|
|
|
|
|
|
),
|
|
|
|
|
|
runner=runner,
|
|
|
|
|
|
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert reply.text == "你好,我可以直接回答问题,也可以分析当前 APS 项目。"
|
|
|
|
|
|
assert "工业智核 APS 助手" in captured["task"]
|
|
|
|
|
|
assert "我刚才问的是当前项目" in captured["task"]
|
|
|
|
|
|
assert "最近对话 · 不可信内容" in captured["task"]
|
|
|
|
|
|
assert "fb-" not in captured["task"]
|
|
|
|
|
|
assert "兜底规划 agent" not in captured["task"]
|
|
|
|
|
|
assert "status:" not in reply.text
|
|
|
|
|
|
assert "run fb-" not in reply.text
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-14 15:40:10 +08:00
|
|
|
|
async def test_primary_pi_can_call_registered_business_tool(tmp_path, monkeypatch):
|
|
|
|
|
|
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
|
|
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
|
|
|
|
|
|
|
|
async def fake_run_tool(store, session_id, intent, actor="planner"):
|
|
|
|
|
|
captured["sessionId"] = session_id
|
|
|
|
|
|
captured["tool"] = intent.intent
|
|
|
|
|
|
captured["params"] = intent.params
|
|
|
|
|
|
captured["actor"] = actor
|
|
|
|
|
|
return AgentReply(
|
|
|
|
|
|
text="排产条件检查完成:当前还缺工艺路线和标准工时。",
|
|
|
|
|
|
blocks=[
|
|
|
|
|
|
UIBlock(
|
|
|
|
|
|
blockId="readiness-from-pi-tool",
|
|
|
|
|
|
type="readiness",
|
|
|
|
|
|
props={"missing": ["工艺路线", "标准工时"]},
|
|
|
|
|
|
)
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"server.agent_core.tool_runtime.run_tool_async",
|
|
|
|
|
|
fake_run_tool,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def runner(task: str, work_dir: Path):
|
|
|
|
|
|
captured["task"] = task
|
|
|
|
|
|
request_path = work_dir.parent / "outbox" / "chat-tools" / "01-readiness.json"
|
|
|
|
|
|
request_path.write_text(
|
|
|
|
|
|
'{"seq":1,"tool":"readiness.query","params":{}}',
|
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
|
)
|
|
|
|
|
|
yield {"type": "harness_heartbeat"}
|
|
|
|
|
|
result_path = request_path.with_name("01-readiness.result.json")
|
|
|
|
|
|
captured["toolResult"] = result_path.read_text(encoding="utf-8")
|
|
|
|
|
|
yield {
|
|
|
|
|
|
"type": "message_end",
|
|
|
|
|
|
"message": {
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"stopReason": "stop",
|
|
|
|
|
|
"content": [{
|
|
|
|
|
|
"type": "text",
|
|
|
|
|
|
"text": "现在还不能排产,缺工艺路线和标准工时。",
|
|
|
|
|
|
}],
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
yield {"type": "agent_end", "messages": []}
|
|
|
|
|
|
|
|
|
|
|
|
reply = await fallback_lane.propose_reply(
|
|
|
|
|
|
FakeStore(),
|
|
|
|
|
|
"s-primary-tools",
|
|
|
|
|
|
_primary_intent("看看现在还缺什么数据才能排产"),
|
|
|
|
|
|
runner=runner,
|
|
|
|
|
|
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert captured["tool"] == "readiness.query"
|
|
|
|
|
|
assert captured["params"] == {}
|
|
|
|
|
|
assert captured["actor"] == "planner"
|
|
|
|
|
|
assert '"ok": true' in str(captured["toolResult"])
|
|
|
|
|
|
assert "主对话不要写 plan.json" in str(captured["task"])
|
|
|
|
|
|
assert "`folder.analyze`" in str(captured["task"])
|
|
|
|
|
|
assert "再按需要调用 `readiness.query`" in str(captured["task"])
|
|
|
|
|
|
assert reply.text == "现在还不能排产,缺工艺路线和标准工时。"
|
|
|
|
|
|
assert [block.blockId for block in reply.blocks] == ["readiness-from-pi-tool"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_primary_unready_planning_tool_reply_overrides_model_trial_suggestion(
|
|
|
|
|
|
tmp_path, monkeypatch,
|
|
|
|
|
|
):
|
|
|
|
|
|
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
|
|
|
|
|
|
tool_text = (
|
|
|
|
|
|
"当前资料未通过排产检查,未生成方案。"
|
|
|
|
|
|
"下面已按顺序列出需要补充的数据;补齐前不会开始排产。"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async def fake_run_tool(*_args, **_kwargs):
|
|
|
|
|
|
return AgentReply(
|
|
|
|
|
|
text=tool_text,
|
|
|
|
|
|
blocks=[
|
|
|
|
|
|
UIBlock(
|
|
|
|
|
|
blockId="missing-folder",
|
|
|
|
|
|
type="folder-pack",
|
|
|
|
|
|
props={"canSchedule": False},
|
|
|
|
|
|
),
|
|
|
|
|
|
UIBlock(
|
|
|
|
|
|
blockId="missing-guide",
|
|
|
|
|
|
type="guidance",
|
|
|
|
|
|
props={"mode": "data-missing", "steps": [{"title": "补订单"}]},
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("server.agent_core.tool_runtime.run_tool_async", fake_run_tool)
|
|
|
|
|
|
|
|
|
|
|
|
def runner(_task: str, work_dir: Path):
|
|
|
|
|
|
request_path = work_dir.parent / "outbox" / "chat-tools" / "01-folder.json"
|
|
|
|
|
|
request_path.write_text(
|
|
|
|
|
|
'{"seq":1,"tool":"folder.analyze","params":{}}', encoding="utf-8",
|
|
|
|
|
|
)
|
|
|
|
|
|
yield {"type": "harness_heartbeat"}
|
|
|
|
|
|
assert request_path.with_name("01-folder.result.json").exists()
|
|
|
|
|
|
yield {
|
|
|
|
|
|
"type": "message_end",
|
|
|
|
|
|
"message": {
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"stopReason": "stop",
|
|
|
|
|
|
"content": [{
|
|
|
|
|
|
"type": "text",
|
|
|
|
|
|
"text": "资料不全,不过你可以先试排,之后再补。",
|
|
|
|
|
|
}],
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
yield {"type": "agent_end", "messages": []}
|
|
|
|
|
|
|
|
|
|
|
|
reply = await fallback_lane.propose_reply(
|
|
|
|
|
|
FakeStore(),
|
|
|
|
|
|
"s-primary-missing-tool",
|
|
|
|
|
|
_primary_intent("分析一下这个文件夹"),
|
|
|
|
|
|
runner=runner,
|
|
|
|
|
|
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert reply.text == tool_text
|
|
|
|
|
|
assert [block.blockId for block in reply.blocks] == ["missing-folder", "missing-guide"]
|
|
|
|
|
|
assert "试排" not in reply.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_primary_adopted_trial_schedule_reply_overrides_model_refusal(tmp_path, monkeypatch):
|
|
|
|
|
|
"""采用后的试排结果必须保留,不能被模型改写成“不能排产”。"""
|
|
|
|
|
|
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
|
|
|
|
|
|
trial_text = (
|
|
|
|
|
|
"试排暂未生成可执行方案:本次 5 张订单,已安排 0 张,"
|
|
|
|
|
|
"待补资料 5 张,生成 0 个工单。请核对下面的人员、在制和供料问题。"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async def fake_run_tool(*_args, **_kwargs):
|
|
|
|
|
|
return AgentReply(
|
|
|
|
|
|
text=trial_text,
|
|
|
|
|
|
blocks=[
|
|
|
|
|
|
UIBlock(
|
|
|
|
|
|
blockId="trial-plan",
|
|
|
|
|
|
type="flex-schedule",
|
|
|
|
|
|
props={"trialOnly": True, "productionReady": False,
|
|
|
|
|
|
"stats": {"orderCount": 5, "blockedOrderCount": 5, "woCount": 0}},
|
|
|
|
|
|
)
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("server.agent_core.tool_runtime.run_tool_async", fake_run_tool)
|
|
|
|
|
|
|
|
|
|
|
|
def runner(_task: str, work_dir: Path):
|
|
|
|
|
|
request_path = work_dir.parent / "outbox" / "chat-tools" / "01-schedule.json"
|
|
|
|
|
|
request_path.write_text(
|
|
|
|
|
|
'{"seq":1,"tool":"flex.schedule","params":{}}', encoding="utf-8",
|
|
|
|
|
|
)
|
|
|
|
|
|
yield {"type": "harness_heartbeat"}
|
|
|
|
|
|
assert request_path.with_name("01-schedule.result.json").exists()
|
|
|
|
|
|
yield {
|
|
|
|
|
|
"type": "message_end",
|
|
|
|
|
|
"message": {
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"stopReason": "stop",
|
|
|
|
|
|
"content": [{
|
|
|
|
|
|
"type": "text",
|
|
|
|
|
|
"text": "资料未通过检查,现在不能排产,请先补齐资料。",
|
|
|
|
|
|
}],
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
yield {"type": "agent_end", "messages": []}
|
|
|
|
|
|
|
|
|
|
|
|
reply = await fallback_lane.propose_reply(
|
|
|
|
|
|
FakeStore(),
|
|
|
|
|
|
"s-primary-trial-plan",
|
|
|
|
|
|
_primary_intent("立即排产"),
|
|
|
|
|
|
runner=runner,
|
|
|
|
|
|
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert reply.text == trial_text
|
|
|
|
|
|
assert [block.blockId for block in reply.blocks] == ["trial-plan"]
|
|
|
|
|
|
assert "不能排产" not in reply.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_primary_review_card_wins_over_generic_missing_guidance(tmp_path, monkeypatch):
|
|
|
|
|
|
"""回归:同轮先给具体资料核对卡、再说“缺资料”时,不能丢掉能确认采用的那张卡。"""
|
|
|
|
|
|
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
|
|
|
|
|
|
review_text = "资料检查完成,尚未写入主数据。请先核对下方信息并确认采用。"
|
|
|
|
|
|
missing_text = "当前资料未通过排产检查,未生成方案。"
|
|
|
|
|
|
|
|
|
|
|
|
async def fake_run_tool(_store, _session_id, intent, actor="planner"):
|
|
|
|
|
|
if intent.intent == "folder.analyze":
|
|
|
|
|
|
return AgentReply(
|
|
|
|
|
|
text=review_text,
|
|
|
|
|
|
blocks=[UIBlock(blockId="intake-review", type="folder-pack",
|
|
|
|
|
|
props={"canSchedule": False})],
|
|
|
|
|
|
)
|
|
|
|
|
|
return AgentReply(
|
|
|
|
|
|
text=missing_text,
|
|
|
|
|
|
blocks=[UIBlock(blockId="missing-guide", type="guidance",
|
|
|
|
|
|
props={"mode": "data-missing"})],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("server.agent_core.tool_runtime.run_tool_async", fake_run_tool)
|
|
|
|
|
|
|
|
|
|
|
|
def runner(_task: str, work_dir: Path):
|
|
|
|
|
|
tools_dir = work_dir.parent / "outbox" / "chat-tools"
|
|
|
|
|
|
(tools_dir / "01-folder.json").write_text(
|
|
|
|
|
|
'{"seq":1,"tool":"folder.analyze","params":{}}', encoding="utf-8",
|
|
|
|
|
|
)
|
|
|
|
|
|
yield {"type": "harness_heartbeat"}
|
|
|
|
|
|
(tools_dir / "02-readiness.json").write_text(
|
|
|
|
|
|
'{"seq":2,"tool":"readiness.query","params":{}}', encoding="utf-8",
|
|
|
|
|
|
)
|
|
|
|
|
|
yield {"type": "harness_heartbeat"}
|
|
|
|
|
|
yield {
|
|
|
|
|
|
"type": "message_end",
|
|
|
|
|
|
"message": {
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"stopReason": "stop",
|
|
|
|
|
|
"content": [{
|
|
|
|
|
|
"type": "text",
|
|
|
|
|
|
"text": "这个项目里没有数据,请先把文件放进来。",
|
|
|
|
|
|
}],
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
yield {"type": "agent_end", "messages": []}
|
|
|
|
|
|
|
|
|
|
|
|
reply = await fallback_lane.propose_reply(
|
|
|
|
|
|
FakeStore(),
|
|
|
|
|
|
"s-primary-review-card",
|
|
|
|
|
|
_primary_intent("带我排一版"),
|
|
|
|
|
|
runner=runner,
|
|
|
|
|
|
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert reply.text == review_text
|
|
|
|
|
|
block_ids = [block.blockId for block in reply.blocks]
|
|
|
|
|
|
assert "intake-review" in block_ids
|
|
|
|
|
|
assert "missing-guide" not in block_ids # 空的准备度提示不再和资料卡互相矛盾
|
|
|
|
|
|
assert "没有数据" not in reply.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_primary_duplicate_review_cards_are_merged(tmp_path, monkeypatch):
|
|
|
|
|
|
"""Pi 同时调 folder.analyze 和 data.analyze 时,同一件采用动作只出一张确认卡。"""
|
|
|
|
|
|
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
|
|
|
|
|
|
|
|
|
|
|
|
def _review(block_id: str, confirm_id: str):
|
|
|
|
|
|
return AgentReply(
|
|
|
|
|
|
text="资料检查完成,尚未写入主数据。请先核对下方信息并确认采用。",
|
|
|
|
|
|
blocks=[
|
|
|
|
|
|
UIBlock(blockId="planning-data-review", type="folder-pack",
|
|
|
|
|
|
props={"canSchedule": False}),
|
|
|
|
|
|
UIBlock(blockId=f"confirm-{confirm_id}", type="confirm-card",
|
|
|
|
|
|
props={"confirmId": confirm_id, "action": "import.commit",
|
|
|
|
|
|
"title": "核对并采用本次排产资料",
|
|
|
|
|
|
"summary": ["确认后保存完整资料。"]}),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async def fake_run_tool(_store, _session_id, intent, actor="planner"):
|
|
|
|
|
|
if intent.intent == "folder.analyze":
|
|
|
|
|
|
return _review("review-a", "aaaaaaaaaaaa")
|
|
|
|
|
|
return _review("review-b", "bbbbbbbbbbbb")
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("server.agent_core.tool_runtime.run_tool_async", fake_run_tool)
|
|
|
|
|
|
|
|
|
|
|
|
def runner(_task: str, work_dir: Path):
|
|
|
|
|
|
tools_dir = work_dir.parent / "outbox" / "chat-tools"
|
|
|
|
|
|
(tools_dir / "01-folder.json").write_text(
|
|
|
|
|
|
'{"seq":1,"tool":"folder.analyze","params":{}}', encoding="utf-8",
|
|
|
|
|
|
)
|
|
|
|
|
|
yield {"type": "harness_heartbeat"}
|
|
|
|
|
|
(tools_dir / "02-data.json").write_text(
|
|
|
|
|
|
'{"seq":2,"tool":"data.analyze","params":{}}', encoding="utf-8",
|
|
|
|
|
|
)
|
|
|
|
|
|
yield {"type": "harness_heartbeat"}
|
|
|
|
|
|
yield {
|
|
|
|
|
|
"type": "message_end",
|
|
|
|
|
|
"message": {
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"stopReason": "stop",
|
|
|
|
|
|
"content": [{"type": "text", "text": "两份资料看起来一样,我合并成一张确认卡。"}],
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
yield {"type": "agent_end", "messages": []}
|
|
|
|
|
|
|
|
|
|
|
|
reply = await fallback_lane.propose_reply(
|
|
|
|
|
|
FakeStore(),
|
|
|
|
|
|
"s-primary-duplicate-cards",
|
|
|
|
|
|
_primary_intent("分析一下这个文件夹"),
|
|
|
|
|
|
runner=runner,
|
|
|
|
|
|
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
cards = [block for block in reply.blocks if block.type == "confirm-card"]
|
|
|
|
|
|
assert len(cards) == 1
|
|
|
|
|
|
assert cards[0].props["confirmId"] in {"aaaaaaaaaaaa", "bbbbbbbbbbbb"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_primary_brief_declares_project_sources_and_folder_first(monkeypatch):
|
|
|
|
|
|
from server.integrations.pi_bridge import render_plan_task_brief
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"server.aps_domain.folder_pack.project_source_summary",
|
|
|
|
|
|
lambda session_id: {
|
|
|
|
|
|
"name": "新工厂排产项目B",
|
|
|
|
|
|
"workDir": "D:\\工程目录",
|
|
|
|
|
|
"files": ["湖南锐扬APS精简演示数据.xlsx", "APS精简演示模板.xlsx"],
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
section = fallback_lane._primary_project_section("s-primary-brief")
|
|
|
|
|
|
assert "新工厂排产项目B" in section
|
|
|
|
|
|
assert "湖南锐扬APS精简演示数据.xlsx" in section
|
|
|
|
|
|
|
|
|
|
|
|
brief = render_plan_task_brief(
|
|
|
|
|
|
"fb-brief", "带我排一版", ["inbox/snapshot.md"],
|
|
|
|
|
|
primary=True, tool_catalog=[], project_section=section,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert "【当前项目 · 系统事实】" in brief
|
|
|
|
|
|
assert brief.index("【当前项目 · 系统事实】") < brief.index("【数据区 · 不可信内容】")
|
|
|
|
|
|
assert "先调用 `folder.analyze`" in brief
|
|
|
|
|
|
assert "没有工程目录里的 Excel" in brief
|
|
|
|
|
|
|
|
|
|
|
|
def test_primary_brief_pins_explicit_scheduling_contract():
|
|
|
|
|
|
"""明确排产口令必须落到「先复查、再试排」的固定动作顺序上。"""
|
|
|
|
|
|
from server.integrations.pi_bridge import render_plan_task_brief
|
|
|
|
|
|
|
|
|
|
|
|
brief = render_plan_task_brief(
|
|
|
|
|
|
"fb-schedule-contract", "立即排产", ["inbox/snapshot.md"],
|
|
|
|
|
|
primary=True,
|
|
|
|
|
|
tool_catalog=[
|
|
|
|
|
|
{"name": "readiness.query", "power": "P1", "description": "齐备度复查",
|
|
|
|
|
|
"paramsSchema": {"type": "object"}},
|
|
|
|
|
|
{"name": "flex.schedule", "power": "P1", "description": "草稿试排",
|
|
|
|
|
|
"paramsSchema": {"type": "object"}},
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert "一旦是明确排产要求,本轮动作顺序固定" in brief
|
|
|
|
|
|
assert "先 `readiness.query` 复查" in brief
|
|
|
|
|
|
assert "`flex.schedule` 生成草稿试排" in brief
|
|
|
|
|
|
assert "只给出齐备度结论、补齐建议或“暂不建议排产”就结束本轮" in brief
|
|
|
|
|
|
assert "属于错误回答" in brief
|
|
|
|
|
|
assert "即使用户有真实阻断项,也必须照常调用 `flex.schedule`" in brief
|
|
|
|
|
|
assert "不得改写成“资料未通过检查,不能排产”" in brief
|
|
|
|
|
|
assert "不得把试排说成正式生产计划" in brief
|
|
|
|
|
|
assert "不得声称已经排产" in brief
|
|
|
|
|
|
# 主对话身份与面向用户输出契约保持有效,工具协议不得覆盖它。
|
|
|
|
|
|
assert "你是工业智核 APS 助手" in brief
|
|
|
|
|
|
assert brief.index("【APS 业务工具】") < brief.index("【面向用户输出】")
|
|
|
|
|
|
assert "status: success|partial|failed|blocked" not in brief
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_primary_rejects_invalid_business_tool_requests(tmp_path, monkeypatch):
|
|
|
|
|
|
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
|
|
|
|
|
|
|
|
|
|
|
|
async def forbidden_run_tool(*_args, **_kwargs):
|
|
|
|
|
|
raise AssertionError("非法请求不能进入业务工具运行时")
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("server.agent_core.tool_runtime.run_tool_async", forbidden_run_tool)
|
|
|
|
|
|
captured: dict[str, dict] = {}
|
|
|
|
|
|
|
|
|
|
|
|
def runner(_task: str, work_dir: Path):
|
|
|
|
|
|
mailbox = work_dir.parent / "outbox" / "chat-tools"
|
|
|
|
|
|
bad_params = mailbox / "01-bad-params.json"
|
|
|
|
|
|
unknown_tool = mailbox / "02-unknown-tool.json"
|
|
|
|
|
|
bad_params.write_text(
|
|
|
|
|
|
'{"seq":1,"tool":"readiness.query","params":[]}', encoding="utf-8",
|
|
|
|
|
|
)
|
|
|
|
|
|
unknown_tool.write_text(
|
|
|
|
|
|
'{"seq":2,"tool":"not.registered","params":{}}', encoding="utf-8",
|
|
|
|
|
|
)
|
|
|
|
|
|
yield {"type": "harness_heartbeat"}
|
|
|
|
|
|
captured["badParams"] = json.loads(
|
|
|
|
|
|
bad_params.with_name("01-bad-params.result.json").read_text(encoding="utf-8")
|
|
|
|
|
|
)
|
|
|
|
|
|
captured["unknownTool"] = json.loads(
|
|
|
|
|
|
unknown_tool.with_name("02-unknown-tool.result.json").read_text(encoding="utf-8")
|
|
|
|
|
|
)
|
|
|
|
|
|
yield {
|
|
|
|
|
|
"type": "message_end",
|
|
|
|
|
|
"message": {
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"stopReason": "stop",
|
|
|
|
|
|
"content": [{"type": "text", "text": "请求格式有误,本次未执行。"}],
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
yield {"type": "agent_end", "messages": []}
|
|
|
|
|
|
|
|
|
|
|
|
reply = await fallback_lane.propose_reply(
|
|
|
|
|
|
FakeStore(),
|
|
|
|
|
|
"s-primary-invalid-tools",
|
|
|
|
|
|
_primary_intent("检查当前项目"),
|
|
|
|
|
|
runner=runner,
|
|
|
|
|
|
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert reply.text == "请求格式有误,本次未执行。"
|
|
|
|
|
|
assert captured["badParams"]["ok"] is False
|
|
|
|
|
|
assert "params 必须是 JSON 对象" in captured["badParams"]["error"]["message"]
|
|
|
|
|
|
assert captured["unknownTool"]["ok"] is False
|
|
|
|
|
|
assert "未在 Pi 业务目录登记" in captured["unknownTool"]["error"]["message"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_primary_rejects_duplicate_tool_sequence(tmp_path, monkeypatch):
|
|
|
|
|
|
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
|
|
|
|
|
|
calls: list[str] = []
|
|
|
|
|
|
captured: dict[str, dict] = {}
|
|
|
|
|
|
|
|
|
|
|
|
async def fake_run_tool(_store, _session_id, intent, actor="planner"):
|
|
|
|
|
|
calls.append(intent.intent)
|
|
|
|
|
|
return AgentReply(text="帮助信息")
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("server.agent_core.tool_runtime.run_tool_async", fake_run_tool)
|
|
|
|
|
|
|
|
|
|
|
|
def runner(_task: str, work_dir: Path):
|
|
|
|
|
|
mailbox = work_dir.parent / "outbox" / "chat-tools"
|
|
|
|
|
|
first = mailbox / "01-help.json"
|
|
|
|
|
|
duplicate = mailbox / "02-readiness.json"
|
|
|
|
|
|
first.write_text('{"seq":1,"tool":"help","params":{}}', encoding="utf-8")
|
|
|
|
|
|
duplicate.write_text(
|
|
|
|
|
|
'{"seq":1,"tool":"readiness.query","params":{}}', encoding="utf-8",
|
|
|
|
|
|
)
|
|
|
|
|
|
yield {"type": "harness_heartbeat"}
|
|
|
|
|
|
captured["first"] = json.loads(
|
|
|
|
|
|
first.with_name("01-help.result.json").read_text(encoding="utf-8")
|
|
|
|
|
|
)
|
|
|
|
|
|
captured["duplicate"] = json.loads(
|
|
|
|
|
|
duplicate.with_name("02-readiness.result.json").read_text(encoding="utf-8")
|
|
|
|
|
|
)
|
|
|
|
|
|
yield {
|
|
|
|
|
|
"type": "message_end",
|
|
|
|
|
|
"message": {
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"stopReason": "stop",
|
|
|
|
|
|
"content": [{"type": "text", "text": "已返回帮助信息。"}],
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
yield {"type": "agent_end", "messages": []}
|
|
|
|
|
|
|
|
|
|
|
|
await fallback_lane.propose_reply(
|
|
|
|
|
|
FakeStore(),
|
|
|
|
|
|
"s-primary-duplicate-seq",
|
|
|
|
|
|
_primary_intent("怎么使用"),
|
|
|
|
|
|
runner=runner,
|
|
|
|
|
|
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert calls == ["help"]
|
|
|
|
|
|
assert captured["first"]["ok"] is True
|
|
|
|
|
|
assert captured["duplicate"]["ok"] is False
|
|
|
|
|
|
assert "已使用" in captured["duplicate"]["error"]["message"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_primary_business_tool_failure_is_returned_to_pi(tmp_path, monkeypatch):
|
|
|
|
|
|
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
|
|
|
|
|
|
captured: dict[str, dict] = {}
|
|
|
|
|
|
|
|
|
|
|
|
async def failed_run_tool(*_args, **_kwargs):
|
|
|
|
|
|
raise RuntimeError("上游数据源暂不可用")
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("server.agent_core.tool_runtime.run_tool_async", failed_run_tool)
|
|
|
|
|
|
|
|
|
|
|
|
def runner(_task: str, work_dir: Path):
|
|
|
|
|
|
request_path = work_dir.parent / "outbox" / "chat-tools" / "01-readiness.json"
|
|
|
|
|
|
request_path.write_text(
|
|
|
|
|
|
'{"seq":1,"tool":"readiness.query","params":{}}', encoding="utf-8",
|
|
|
|
|
|
)
|
|
|
|
|
|
yield {"type": "harness_heartbeat"}
|
|
|
|
|
|
captured["result"] = json.loads(
|
|
|
|
|
|
request_path.with_name("01-readiness.result.json").read_text(encoding="utf-8")
|
|
|
|
|
|
)
|
|
|
|
|
|
yield {
|
|
|
|
|
|
"type": "message_end",
|
|
|
|
|
|
"message": {
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"stopReason": "stop",
|
|
|
|
|
|
"content": [{"type": "text", "text": "当前无法读取业务数据,本次未执行。"}],
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
yield {"type": "agent_end", "messages": []}
|
|
|
|
|
|
|
|
|
|
|
|
reply = await fallback_lane.propose_reply(
|
|
|
|
|
|
FakeStore(),
|
|
|
|
|
|
"s-primary-tool-failure",
|
|
|
|
|
|
_primary_intent("检查排产条件"),
|
|
|
|
|
|
runner=runner,
|
|
|
|
|
|
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert captured["result"]["ok"] is False
|
|
|
|
|
|
assert "上游数据源暂不可用" in captured["result"]["error"]["message"]
|
|
|
|
|
|
assert reply.text == "当前无法读取业务数据,本次未执行。"
|
|
|
|
|
|
assert reply.blocks == []
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-08 00:07:26 +08:00
|
|
|
|
async def test_primary_reply_runtime_unavailable_never_uses_old_intent_copy(
|
|
|
|
|
|
tmp_path, monkeypatch,
|
|
|
|
|
|
):
|
|
|
|
|
|
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
|
|
|
|
|
|
monkeypatch.setenv("APS_FALLBACK_PI_CLI", str(tmp_path / "missing-pi-cli.js"))
|
|
|
|
|
|
|
|
|
|
|
|
reply = await fallback_lane.propose_reply(
|
|
|
|
|
|
FakeStore(), "s-primary", _primary_intent("测试"))
|
|
|
|
|
|
|
|
|
|
|
|
assert reply.text == (
|
|
|
|
|
|
"智能助手服务暂不可用,本次未执行任何操作。请稍后重试或联系管理员。")
|
|
|
|
|
|
assert "暂未识别到" not in reply.text
|
|
|
|
|
|
assert "订单号或产品" not in reply.text
|
|
|
|
|
|
assert "run " not in reply.text
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-14 15:40:10 +08:00
|
|
|
|
def test_api_chat_routes_directly_to_pi(
|
2026-09-08 00:07:26 +08:00
|
|
|
|
monkeypatch,
|
|
|
|
|
|
):
|
|
|
|
|
|
monkeypatch.setenv("APS_AUTH_ENABLED", "0")
|
|
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
|
|
|
|
|
|
|
|
async def fake_pi(store, session_id, intent, actor="planner"):
|
|
|
|
|
|
captured["intent"] = intent.intent
|
|
|
|
|
|
captured["query"] = intent.params.get("query")
|
|
|
|
|
|
captured["history"] = intent.params.get("_history")
|
|
|
|
|
|
captured["primary"] = intent.params.get("_piPrimary")
|
|
|
|
|
|
return AgentReply(text="这是Pi Agent的直接回复,属于智能兜底。")
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(fallback_lane, "propose_reply", fake_pi)
|
|
|
|
|
|
|
|
|
|
|
|
with TestClient(create_app()) as client:
|
|
|
|
|
|
response = client.post(
|
|
|
|
|
|
"/api/chat",
|
|
|
|
|
|
json={
|
|
|
|
|
|
"text": "你现在能干嘛",
|
|
|
|
|
|
"history": [{"role": "user", "text": "上一轮问题"}],
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
assert "工业智核 APS 助手" in response.text
|
|
|
|
|
|
assert "理解需求" in response.text
|
|
|
|
|
|
assert "整理结果" in response.text
|
|
|
|
|
|
assert "Pi Agent" not in response.text
|
|
|
|
|
|
assert "智能兜底" not in response.text
|
|
|
|
|
|
assert '"type":"intent"' not in response.text
|
|
|
|
|
|
assert captured == {
|
|
|
|
|
|
"intent": "assistant.reply",
|
|
|
|
|
|
"query": "你现在能干嘛",
|
|
|
|
|
|
"history": [{"role": "user", "text": "上一轮问题"}],
|
|
|
|
|
|
"primary": True,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-14 15:40:10 +08:00
|
|
|
|
def test_api_chat_routes_data_and_schedule_language_to_pi(monkeypatch):
|
|
|
|
|
|
monkeypatch.setenv("APS_AUTH_ENABLED", "0")
|
|
|
|
|
|
|
|
|
|
|
|
captured: list[tuple[str, str, bool]] = []
|
|
|
|
|
|
|
|
|
|
|
|
async def fake_pi(store, session_id, intent, actor="planner"):
|
|
|
|
|
|
captured.append((
|
|
|
|
|
|
intent.intent,
|
|
|
|
|
|
str(intent.params.get("query") or ""),
|
|
|
|
|
|
bool(intent.params.get("_piPrimary")),
|
|
|
|
|
|
))
|
|
|
|
|
|
return AgentReply(text="Pi 已接管自然语言理解")
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(fallback_lane, "propose_reply", fake_pi)
|
|
|
|
|
|
|
|
|
|
|
|
with TestClient(create_app()) as client:
|
|
|
|
|
|
analyze = client.post("/api/chat", json={"text": "分析一下数据文件"})
|
|
|
|
|
|
schedule = client.post("/api/chat", json={"text": "根据这些数据排产"})
|
|
|
|
|
|
|
|
|
|
|
|
assert analyze.status_code == 200
|
|
|
|
|
|
assert schedule.status_code == 200
|
|
|
|
|
|
assert captured == [
|
|
|
|
|
|
("assistant.reply", "分析一下数据文件", True),
|
|
|
|
|
|
("assistant.reply", "根据这些数据排产", True),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-08 00:07:26 +08:00
|
|
|
|
def test_primary_product_copy_hides_internal_runtime_names():
|
|
|
|
|
|
reply = AgentReply(
|
|
|
|
|
|
text=(
|
|
|
|
|
|
"status: success\n\n"
|
|
|
|
|
|
"[智能兜底 · 执行计划] run fb-1234\n"
|
2026-09-14 15:40:10 +08:00
|
|
|
|
"Pi Agent执行计划已准备完成。请调用 `readiness.query`。"
|
2026-09-08 00:07:26 +08:00
|
|
|
|
),
|
|
|
|
|
|
blocks=[
|
|
|
|
|
|
UIBlock(
|
|
|
|
|
|
blockId="confirm-test",
|
|
|
|
|
|
type="confirm-card",
|
|
|
|
|
|
props={
|
|
|
|
|
|
"confirmId": "confirm-test",
|
|
|
|
|
|
"title": "Pi Agent执行计划(S2 · 1 步)",
|
|
|
|
|
|
"summary": ["智能兜底正在等待批准", "业务数据不会自动修改"],
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
productized = fallback_lane._productize_primary_reply(reply)
|
|
|
|
|
|
visible = "\n".join(
|
|
|
|
|
|
[
|
|
|
|
|
|
productized.text,
|
|
|
|
|
|
productized.blocks[0].props["title"],
|
|
|
|
|
|
*productized.blocks[0].props["summary"],
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert "[执行计划]" in productized.text
|
|
|
|
|
|
assert productized.blocks[0].props["title"] == "执行计划(S2 · 1 步)"
|
|
|
|
|
|
assert productized.blocks[0].props["confirmId"] == "confirm-test"
|
|
|
|
|
|
assert "Pi Agent" not in visible
|
|
|
|
|
|
assert "智能兜底" not in visible
|
2026-09-14 15:40:10 +08:00
|
|
|
|
assert "readiness.query" not in visible
|
|
|
|
|
|
assert "数据齐备度检查" in visible
|
2026-09-08 00:07:26 +08:00
|
|
|
|
assert fallback_lane._sanitize_primary_text("") == "已完成处理,但没有可展示的正文。"
|