from __future__ import annotations import ast import json from pathlib import Path from fastapi.testclient import TestClient from server.agent_core import fallback_lane 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", ) 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" 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 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 == [] 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 def test_api_chat_routes_directly_to_pi( 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, } 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), ] def test_primary_product_copy_hides_internal_runtime_names(): reply = AgentReply( text=( "status: success\n\n" "[智能兜底 · 执行计划] run fb-1234\n" "Pi Agent执行计划已准备完成。请调用 `readiness.query`。" ), 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 assert "readiness.query" not in visible assert "数据齐备度检查" in visible assert fallback_lane._sanitize_primary_text("") == "已完成处理,但没有可展示的正文。"