feat(research): streamline template details and enable bot versioning
Deploy production / deploy (push) Successful in 1m35s
Deploy production / deploy (push) Successful in 1m35s
This commit is contained in:
@@ -162,7 +162,8 @@ async def test_official_sdk_client_and_error_contract(mcp_app):
|
||||
async with ClientSession(streams[0], streams[1]) as client:
|
||||
await client.initialize()
|
||||
listed = await client.list_tools()
|
||||
assert len(listed.tools) == 29
|
||||
assert len(listed.tools) == 32
|
||||
assert {"search_research_templates", "get_research_template", "create_research_template_version"} <= {t.name for t in listed.tools}
|
||||
assert any(tool.name == "get_pyramid_distribution" for tool in listed.tools)
|
||||
assert {"search_data_preparations", "get_data_preparation"} <= {t.name for t in listed.tools}
|
||||
caps = await client.call_tool("get_research_capabilities", {})
|
||||
|
||||
@@ -236,3 +236,32 @@ async def test_browser_can_issue_template_only_and_all_permissions(app, logged_i
|
||||
async with app.state.sessions() as db:
|
||||
principal = await authenticate(db, response.json()["token"])
|
||||
assert principal.scopes == scopes
|
||||
|
||||
|
||||
async def test_mcp_template_version_is_idempotent_and_preserves_history(app, completed_source):
|
||||
principal, _ = await credentials(app, {"research:read", "research:write"})
|
||||
body = template_request(completed_source["id"])
|
||||
created = await invoke(app, principal, TOOL, body)
|
||||
content = deepcopy(body["template"])
|
||||
content["variables"]["field"] = {"kind": "field", "field_type": "MATRIX", "description": "数据准备中的矩阵字段"}
|
||||
update = {**body, "template": content, "template_id": created["id"],
|
||||
"expected_version": 1, "idempotency_key": "version-2"}
|
||||
tool = "create_research_template_version"
|
||||
first = await invoke(app, principal, tool, update)
|
||||
replay = await invoke(app, principal, tool, update)
|
||||
assert first == replay and first["version"] == 2
|
||||
assert first["combination_count"] is None
|
||||
assert first["provenance"]["parent_template"] == {"id": created["id"], "version": 1}
|
||||
read = await invoke(app, principal, "get_research_template", {"template_id": created["id"], "version": 1})
|
||||
assert read["content"]["variables"]["field"]["values"] == ["TEST_FIN_001", "TEST_FIN_002"]
|
||||
listed = await invoke(app, principal, "search_research_templates", {"q": content["name"]})
|
||||
assert listed["items"][0]["version"] == 2
|
||||
stale = await app.state.mcp.invoke(principal, tool, update | {"idempotency_key": "stale-version"})
|
||||
assert stale.is_error and stale.structured_content["error"]["code"] == "CONFLICT"
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(ResearchRevision)) == 2
|
||||
denied, _ = await credentials(app, {"research:read"})
|
||||
from fastapi import HTTPException
|
||||
with pytest.raises(HTTPException) as forbidden:
|
||||
await app.state.mcp.invoke(denied, tool, update)
|
||||
assert forbidden.value.status_code == 403
|
||||
|
||||
@@ -526,3 +526,62 @@ async def test_model_cannot_append_preparation_references_to_fixed_inputs(app, l
|
||||
assert response.status_code == 422 and "不能改变" in response.text
|
||||
async with app.state.sessions() as db:
|
||||
assert not await db.scalar(select(ResearchAsset).where(ResearchAsset.name == "untrusted"))
|
||||
|
||||
|
||||
async def test_template_definitions_bind_selected_fields_without_mutating_asset(logged_in, research_input):
|
||||
content = template()
|
||||
content["expression"] = "rank({field}) + rank({field})"
|
||||
content["variables"]["field"] = {"kind": "field", "field_type": "MATRIX", "description": "横截面字段"}
|
||||
saved = await logged_in.post("/api/v1/research/assets", json={"kind": "template", "content": content})
|
||||
assert saved.status_code == 201, saved.text
|
||||
asset = saved.json()
|
||||
assert asset["content"]["variables"]["field"] == {
|
||||
"kind": "field", "field_type": "MATRIX", "description": "横截面字段", "values": [],
|
||||
}
|
||||
body = expansion(research_input["id"], asset_id=asset["id"], version=1, mode="random", limit=2, seed=19)
|
||||
body.pop("template")
|
||||
first = await logged_in.post("/api/v1/research/experiments", json=body)
|
||||
second = await logged_in.post("/api/v1/research/experiments", json=body)
|
||||
assert first.status_code == second.status_code == 201, first.text
|
||||
assert first.json()["candidates"] == second.json()["candidates"]
|
||||
assert len(first.json()["candidates"]) == 2
|
||||
for candidate in first.json()["candidates"]:
|
||||
field = candidate["bindings"]["field"]
|
||||
assert research_input["field_types"][field] == "MATRIX"
|
||||
assert candidate["expression"] == f"rank({field}) + rank({field})"
|
||||
stored = (await logged_in.get(f"/api/v1/research/assets/{asset['id']}")).json()
|
||||
assert stored["content"] == asset["content"]
|
||||
|
||||
|
||||
async def test_empty_template_domains_fail_expansion_with_actionable_errors(logged_in, research_input):
|
||||
body = expansion(research_input["id"])
|
||||
body["template"]["variables"]["field"] = {"kind": "field", "field_type": "GROUP"}
|
||||
response = await logged_in.post("/api/v1/research/experiments", json=body)
|
||||
assert response.status_code == 422 and "没有匹配的 GROUP 字段" in response.text
|
||||
body["template"]["expression"] = "ts_mean(TEST_FIN_001, {window})"
|
||||
body["template"]["variables"] = {"window": {"kind": "integer", "description": "时间窗口"}}
|
||||
response = await logged_in.post("/api/v1/research/experiments", json=body)
|
||||
assert response.status_code == 422 and "变量 window 缺少候选取值" in response.text
|
||||
|
||||
|
||||
async def test_native_bot_creates_template_and_immutable_version(app, logged_in):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.ai.capabilities import ToolContext
|
||||
from app.ai.tools import CAPABILITIES
|
||||
from app.business import Business
|
||||
|
||||
async with app.state.sessions.begin() as db:
|
||||
ctx = ToolContext(Business(db))
|
||||
content = template()
|
||||
content["variables"]["field"].pop("values")
|
||||
created = await CAPABILITIES["create_research_template"].invoke(ctx, content)
|
||||
content["description"] = "修订后的研究解释"
|
||||
args = {"asset_id": created["id"], "version": 1, "content": content}
|
||||
updated = await CAPABILITIES["create_research_template_version"].invoke(ctx, args)
|
||||
assert updated["version"] == 2
|
||||
with pytest.raises(HTTPException) as conflict:
|
||||
await CAPABILITIES["create_research_template_version"].invoke(ctx, args)
|
||||
assert conflict.value.status_code == 409
|
||||
old = (await logged_in.get(f"/api/v1/research/assets/{created['id']}?version=1")).json()
|
||||
assert old["content"]["description"] == "测试经济假设"
|
||||
|
||||
Reference in New Issue
Block a user