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"
|