338 lines
12 KiB
Python
338 lines
12 KiB
Python
"""已完成业务动作的计划员语言说明必须走只读 Pi 叙事段。"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from server.agent_core import fallback_lane, harness
|
||
from server.auth.context import bind_identity, reset_identity
|
||
from server.contracts import AgentReply
|
||
from server.state.seed import seed_world
|
||
from tests.auth_provider import install_test_auth
|
||
|
||
|
||
class _Store:
|
||
def __init__(self) -> None:
|
||
self.data = seed_world()
|
||
self._next = 0
|
||
|
||
def next_id(self, _kind: str) -> int:
|
||
self._next += 1
|
||
return self._next
|
||
|
||
def save(self) -> None:
|
||
pass
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _clear_pending_confirmations():
|
||
harness._approval_store.clear()
|
||
yield
|
||
harness._approval_store.clear()
|
||
|
||
|
||
def test_action_result_uses_readonly_pi_without_business_tools(tmp_path, monkeypatch):
|
||
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
|
||
captured: dict[str, object] = {}
|
||
|
||
def runner(task: str, work_dir: Path):
|
||
captured["task"] = task
|
||
facts_path = work_dir.parent / "inbox" / "completed-action.json"
|
||
raw = facts_path.read_text(encoding="utf-8")
|
||
captured["payload"] = json.loads(raw.split("\n", 1)[1])
|
||
yield {
|
||
"type": "message_end",
|
||
"message": {
|
||
"role": "assistant",
|
||
"stopReason": "stop",
|
||
"content": [{
|
||
"type": "text",
|
||
"text": (
|
||
"status: success\n\n"
|
||
"资料已经采用完成。本次保存了物料和设备资料,"
|
||
"下一步可以生成试排方案。"
|
||
),
|
||
}],
|
||
},
|
||
}
|
||
yield {"type": "agent_end", "messages": []}
|
||
|
||
reply = fallback_lane.propose_action_result(
|
||
_Store(),
|
||
"s-action-result",
|
||
action="采用排产资料",
|
||
fallback_text=(
|
||
"排产资料已采用完成。本次写入:物料 45 项、设备 15 台。\n\n"
|
||
"这次操作只保存资料,没有生成排产方案。"
|
||
),
|
||
facts={
|
||
"summary": "排产资料已采用完成。",
|
||
"items": [
|
||
{"label": "物料", "count": 45, "unit": "项"},
|
||
{"label": "设备", "count": 15, "unit": "台"},
|
||
],
|
||
"nextStep": "下一步可以生成试排方案。",
|
||
},
|
||
runner=runner,
|
||
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
|
||
)
|
||
|
||
assert reply.text == (
|
||
"资料已经采用完成。本次保存了物料和设备资料,下一步可以生成试排方案。"
|
||
)
|
||
payload = captured["payload"]
|
||
assert payload["本次写入"] == [
|
||
{"label": "物料", "count": 45, "unit": "项"},
|
||
{"label": "设备", "count": 15, "unit": "台"},
|
||
]
|
||
assert "materials×45" not in captured["task"]
|
||
assert "APS 业务工具" not in captured["task"]
|
||
assert "aps_invoke" not in captured["task"]
|
||
assert "completed-action.json" in captured["task"]
|
||
|
||
|
||
def test_action_result_keeps_success_when_pi_is_unavailable(tmp_path, monkeypatch):
|
||
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
|
||
|
||
def unavailable(*_args, **_kwargs):
|
||
raise fallback_lane.FallbackUnavailable("模型服务未配置")
|
||
|
||
monkeypatch.setattr(fallback_lane, "build_pi_runner", unavailable)
|
||
fallback_text = (
|
||
"排产资料已采用完成。本次写入:物料 45 项、设备 15 台。\n\n"
|
||
"这次操作只保存资料,没有生成排产方案。"
|
||
)
|
||
reply = fallback_lane.propose_action_result(
|
||
_Store(),
|
||
"s-action-result",
|
||
action="采用排产资料",
|
||
fallback_text=fallback_text,
|
||
facts={
|
||
"summary": "排产资料已采用完成。",
|
||
"items": [
|
||
{"label": "物料", "count": 45, "unit": "项"},
|
||
{"label": "设备", "count": 15, "unit": "台"},
|
||
],
|
||
"nextStep": "下一步可以生成试排方案。",
|
||
},
|
||
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
|
||
)
|
||
|
||
assert reply.text == fallback_text
|
||
|
||
|
||
def test_action_result_rejects_internal_field_names_from_pi(tmp_path, monkeypatch):
|
||
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback"))
|
||
|
||
def runner(_task: str, _work_dir: Path):
|
||
yield {
|
||
"type": "message_end",
|
||
"message": {
|
||
"role": "assistant",
|
||
"stopReason": "stop",
|
||
"content": [{
|
||
"type": "text",
|
||
"text": "status: success\n\n已采用 materials×45、equipment×15。",
|
||
}],
|
||
},
|
||
}
|
||
yield {"type": "agent_end", "messages": []}
|
||
|
||
fallback_text = "排产资料已采用完成。本次写入:物料 45 项、设备 15 台。"
|
||
reply = fallback_lane.propose_action_result(
|
||
_Store(),
|
||
"s-action-result",
|
||
action="采用排产资料",
|
||
fallback_text=fallback_text,
|
||
facts={
|
||
"summary": "排产资料已采用完成。",
|
||
"items": [
|
||
{"label": "物料", "count": 45, "unit": "项"},
|
||
{"label": "设备", "count": 15, "unit": "台"},
|
||
],
|
||
"nextStep": "下一步可以生成试排方案。",
|
||
},
|
||
runner=runner,
|
||
config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")),
|
||
)
|
||
|
||
assert reply.text == fallback_text
|
||
|
||
|
||
def test_import_commit_confirmation_route_returns_pi_explanation(tmp_path, monkeypatch):
|
||
monkeypatch.setenv("APS_SEED_DEMO", "0")
|
||
monkeypatch.setenv("APS_HOME", str(tmp_path))
|
||
monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "master.db"))
|
||
monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json"))
|
||
monkeypatch.setenv("APS_DATA_DIR", str(tmp_path / "data"))
|
||
provider = install_test_auth(monkeypatch, "action-result-test")
|
||
|
||
from server.gateway.app import create_app
|
||
from server.state.store import get_store
|
||
|
||
with TestClient(create_app()) as client:
|
||
login = client.post(
|
||
"/api/auth/login",
|
||
json={"username": "planner", "password": "test"},
|
||
)
|
||
assert login.status_code == 200, login.text
|
||
store = get_store()
|
||
token = bind_identity(provider._identity("planner"))
|
||
try:
|
||
block = harness.stage_confirmation(
|
||
"s-action-result",
|
||
"import.commit",
|
||
{
|
||
"filename": "测试资料.xlsx",
|
||
"analysisIngest": {
|
||
"projectName": "测试项目",
|
||
"sources": ["测试资料.xlsx"],
|
||
},
|
||
"batches": [{
|
||
"kind": "materials",
|
||
"sheet": "物料",
|
||
"okCount": 1,
|
||
"errorCount": 0,
|
||
"okRows": [{
|
||
"code": "M-100",
|
||
"name": "测试物料",
|
||
"type": "RAW_MATERIAL",
|
||
"unit": "kg",
|
||
"stock": 0,
|
||
}],
|
||
}],
|
||
},
|
||
title="核对并采用本次排产资料",
|
||
summary_lines=["确认后保存资料,本步不会排产。"],
|
||
)
|
||
finally:
|
||
reset_identity(token)
|
||
store.save()
|
||
|
||
captured: dict[str, object] = {}
|
||
|
||
def fake_result(
|
||
store, session_id, *, action, fallback_text, facts,
|
||
actor="planner", **_kwargs,
|
||
):
|
||
captured.update({
|
||
"session_id": session_id,
|
||
"action": action,
|
||
"fallback_text": fallback_text,
|
||
"facts": facts,
|
||
"actor": actor,
|
||
})
|
||
return AgentReply(text="资料已经采用,下一步可以生成试排方案。")
|
||
|
||
monkeypatch.setattr(fallback_lane, "propose_action_result", fake_result)
|
||
response = client.post(
|
||
"/api/actions/confirm",
|
||
json={
|
||
"sessionId": "s-action-result",
|
||
"confirmId": block.props["confirmId"],
|
||
"approve": True,
|
||
},
|
||
)
|
||
|
||
assert response.status_code == 200, response.text
|
||
assert response.json()["message"] == "资料已经采用,下一步可以生成试排方案。"
|
||
assert captured["session_id"] == "s-action-result"
|
||
assert captured["action"] == "采用排产资料"
|
||
assert str(captured["fallback_text"]).startswith("排产资料已采用完成。")
|
||
assert captured["facts"]["items"] == [
|
||
{"label": "物料", "count": 1, "unit": "项"},
|
||
]
|
||
assert "materials×" not in json.dumps(captured["facts"], ensure_ascii=False)
|
||
assert captured["actor"] == "planner"
|
||
|
||
|
||
def test_import_commit_batch_route_returns_pi_explanation(tmp_path, monkeypatch):
|
||
monkeypatch.setenv("APS_SEED_DEMO", "0")
|
||
monkeypatch.setenv("APS_HOME", str(tmp_path))
|
||
monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "master.db"))
|
||
monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json"))
|
||
monkeypatch.setenv("APS_DATA_DIR", str(tmp_path / "data"))
|
||
provider = install_test_auth(monkeypatch, "action-result-batch-test")
|
||
|
||
from server.gateway.app import create_app
|
||
from server.state.store import get_store
|
||
|
||
with TestClient(create_app()) as client:
|
||
login = client.post(
|
||
"/api/auth/login",
|
||
json={"username": "planner", "password": "test"},
|
||
)
|
||
assert login.status_code == 200, login.text
|
||
store = get_store()
|
||
token = bind_identity(provider._identity("planner"))
|
||
try:
|
||
block = harness.stage_confirmation(
|
||
"s-action-result-batch",
|
||
"import.commit",
|
||
{
|
||
"filename": "批量测试资料.xlsx",
|
||
"analysisIngest": {
|
||
"projectName": "批量测试项目",
|
||
"sources": ["批量测试资料.xlsx"],
|
||
},
|
||
"batches": [{
|
||
"kind": "equipment",
|
||
"sheet": "设备",
|
||
"okCount": 1,
|
||
"errorCount": 0,
|
||
"okRows": [{
|
||
"code": "EQ-100",
|
||
"name": "测试设备",
|
||
"type": "MACHINE",
|
||
}],
|
||
}],
|
||
},
|
||
title="核对并采用本次排产资料",
|
||
summary_lines=["确认后保存资料,本步不会排产。"],
|
||
)
|
||
finally:
|
||
reset_identity(token)
|
||
store.save()
|
||
|
||
captured: list[dict[str, object]] = []
|
||
|
||
def fake_result(
|
||
store, session_id, *, action, fallback_text, facts,
|
||
actor="planner", **_kwargs,
|
||
):
|
||
captured.append({
|
||
"session_id": session_id,
|
||
"action": action,
|
||
"fallback_text": fallback_text,
|
||
"facts": facts,
|
||
"actor": actor,
|
||
})
|
||
return AgentReply(text="资料已经采用,下一步可以生成试排方案。")
|
||
|
||
monkeypatch.setattr(fallback_lane, "propose_action_result", fake_result)
|
||
response = client.post(
|
||
"/api/actions/confirm-batch",
|
||
json={
|
||
"sessionId": "s-action-result-batch",
|
||
"confirmIds": [block.props["confirmId"]],
|
||
"approve": True,
|
||
},
|
||
)
|
||
|
||
assert response.status_code == 200, response.text
|
||
results = response.json()["results"]
|
||
assert len(results) == 1
|
||
assert results[0]["ok"] is True
|
||
assert results[0]["message"] == "资料已经采用,下一步可以生成试排方案。"
|
||
assert len(captured) == 1
|
||
assert captured[0]["session_id"] == "s-action-result-batch"
|
||
assert captured[0]["action"] == "采用排产资料"
|
||
assert captured[0]["facts"]["items"] == [
|
||
{"label": "设备", "count": 1, "unit": "台"},
|
||
]
|
||
assert "equipment×" not in json.dumps(captured[0]["facts"], ensure_ascii=False)
|
||
assert captured[0]["actor"] == "planner"
|