feat: add node coverage and improve investigation presentation
This commit is contained in:
@@ -37,6 +37,18 @@ def test_unknown_node_state_is_rejected():
|
||||
InvestigationDiagramNodeRef(node_id="A", label="查询", status="blocked")
|
||||
|
||||
|
||||
def test_class_attached_nodes_are_valid_declarations_without_accepting_partial_ids():
|
||||
payload = {"request_id": uuid4(), "source_session_id": "session-1", "progress": {
|
||||
"summary": "核查", "diagram": {"title": "路线", "mermaid": "flowchart TD\nQUERY:::running --> REVIEW:::pending",
|
||||
"node_refs": [{"node_id": "QUERY", "label": "核查", "status": "running"},
|
||||
{"node_id": "REVIEW", "label": "复核", "status": "pending"}]},
|
||||
}}
|
||||
assert AgentArtifactsCommand.model_validate(payload).progress.diagram.node_refs[0].status == "running"
|
||||
payload["progress"]["diagram"]["node_refs"][0]["node_id"] = "QUER"
|
||||
with pytest.raises(ValidationError, match="QUER"):
|
||||
AgentArtifactsCommand.model_validate(payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("artifact", ["progress", "report"])
|
||||
def test_artifact_write_rejects_node_refs_missing_from_mermaid(artifact):
|
||||
diagram = {
|
||||
|
||||
@@ -35,6 +35,7 @@ INPUTS = {
|
||||
"run_burst_detection": {"target_time": START, "sensor_nodes": ["1", "2", "3", "4", "5"]},
|
||||
"run_burst_location": {"data_source": "simulation", "simulation_run_id": str(uuid4()), "burst_leakage": 0.02, "pressure_scada_ids": ["1"], "scada_burst_start": START, "scada_burst_end": END},
|
||||
"analyze_valve_isolation": {"accident_element": ["540672"]},
|
||||
"generate_node_coverage": {"groups": [{"group_id": "monitoring", "title": "监测节点分布", "node_ids": ["J1"]}]},
|
||||
"run_leakage_identification": {"scada_start": START, "scada_end": END, "sensor_nodes": ["1"], "q_sum": 0.02, "start_time": 12, "dma_count": 1},
|
||||
}
|
||||
|
||||
@@ -129,7 +130,7 @@ def test_missing_timescale_route_stops_online_simulation_before_model_changes(mo
|
||||
calculate.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["analyze_valve_isolation", "optimize_sensor_placement"])
|
||||
@pytest.mark.parametrize("name", ["analyze_valve_isolation", "optimize_sensor_placement", "generate_node_coverage"])
|
||||
def test_business_only_calculations_do_not_require_timescale(monkeypatch, routed_calculation_scope, name):
|
||||
scope, _, events = routed_calculation_scope
|
||||
|
||||
@@ -192,7 +193,7 @@ def test_catalog_and_snapshots_accept_calculations_but_reject_model_owned_author
|
||||
|
||||
@pytest.mark.parametrize("name", INPUTS)
|
||||
def test_calculation_permission_and_reader_boundary(monkeypatch, name):
|
||||
permissions = {"burst.run", "simulation.run", "simulation.view", "scada.view", "optimization.run"}
|
||||
permissions = {"burst.run", "simulation.run", "simulation.view", "scada.view", "optimization.run", "webgis.view"}
|
||||
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: permissions)
|
||||
scope = SimpleNamespace(project=SimpleNamespace(project_role="member"))
|
||||
binding = SimpleNamespace(tool_cap=list(TOOLS), purpose="task_execution", permission_cap=list(permissions), task_id=uuid4(), task_scope_cap=["scada_readings"])
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from app.domain.schemas.operational_task import TaskPresentationEventCommand
|
||||
from app.mcp.models import QueryResult
|
||||
from app.mcp.presentation_preview import saved_presentation_preview
|
||||
|
||||
|
||||
def test_abbreviated_coverage_keeps_publishable_command_without_copying_geometry():
|
||||
result = QueryResult(
|
||||
source="generate_node_coverage", source_ref="mcp:analysis", evidence_id="mcp:analysis",
|
||||
queried_at=datetime.now(timezone.utc), semantics="analysis", origin="system_data", truncated=False,
|
||||
data={"result": {"map_payload": {"type": "FeatureCollection", "features": [{"large": "geometry"}]},
|
||||
"coverage": {"status": "partial", "groups": {"isolation": {
|
||||
"node_ids": ["A", "B"], "mapped_node_count": 1, "missing_node_ids": ["B"],
|
||||
}}}}},
|
||||
)
|
||||
hint = saved_presentation_preview(result)
|
||||
assert hint == saved_presentation_preview(result)
|
||||
assert hint["groups"]["isolation"] == {"node_count": 2, "mapped_node_count": 1, "missing_node_count": 1}
|
||||
command = TaskPresentationEventCommand(
|
||||
request_id=uuid4(), run_id=uuid4(), source_session_id="session", command=hint["command"],
|
||||
)
|
||||
assert command.command.evidence_id == result.evidence_id and command.command.data is None
|
||||
assert "geometry" not in str(hint["groups"]) and result.data["result"]["map_payload"]["features"]
|
||||
result.evidence_id = None
|
||||
assert saved_presentation_preview(result) is None
|
||||
|
||||
|
||||
def test_missing_geometry_does_not_offer_a_broken_overlay_command():
|
||||
result = QueryResult(
|
||||
source="generate_node_coverage", source_ref="mcp:analysis", evidence_id="mcp:analysis",
|
||||
queried_at=datetime.now(timezone.utc), semantics="analysis", origin="system_data", truncated=False,
|
||||
data={"result": {"coverage": {"status": "unavailable"}}},
|
||||
)
|
||||
assert saved_presentation_preview(result) is None
|
||||
@@ -543,6 +543,42 @@ def test_evidence_is_saved_before_return_and_call_conflicts_fail(runtime, monkey
|
||||
assert len(saved) == 1
|
||||
|
||||
|
||||
def test_large_node_coverage_retains_publishable_hint_and_saves_complete_geometry(runtime, monkeypatch):
|
||||
app, client, _, _ = runtime
|
||||
value = binding(task=True)
|
||||
saved = []
|
||||
monkeypatch.setattr(application, "verify_task", AsyncMock())
|
||||
monkeypatch.setattr(contexts, "verify_task", AsyncMock())
|
||||
|
||||
class TaskService:
|
||||
repo = SimpleNamespace(lock=lambda key: asyncio.sleep(0))
|
||||
|
||||
async def record_data_snapshot(self, task_id, snapshot):
|
||||
saved.append(snapshot)
|
||||
return SimpleNamespace(evidence_id=snapshot.source_ref), snapshot
|
||||
|
||||
monkeypatch.setattr(application, "task_service", lambda scope: TaskService())
|
||||
geometry = {"type": "FeatureCollection", "features": [{
|
||||
"type": "Feature", "properties": {"label": "x" * 13000},
|
||||
"geometry": {"type": "Polygon", "coordinates": [[[117, 32], [118, 32], [118, 33], [117, 32]]]},
|
||||
}]}
|
||||
app.executor = AsyncMock(return_value=QueryResult(
|
||||
source="generate_node_coverage", source_ref="mcp:before-persisting", queried_at=datetime.now(timezone.utc),
|
||||
semantics="analysis", data={"result": {"map_payload": geometry, "coverage": {
|
||||
"status": "ready", "groups": {"compare": {"node_ids": ["A"], "mapped_node_count": 1}},
|
||||
}}},
|
||||
))
|
||||
output = request(client, "tools/call", {"name": "generate_node_coverage", "arguments": {
|
||||
"context_handle": value.context_handle,
|
||||
"groups": [{"group_id": "compare", "title": "节点比较", "node_ids": ["A"]}],
|
||||
}}).json()["result"]["structuredContent"]
|
||||
assert output["truncated"] is True
|
||||
command = output["data"]["presentation"]["command"]
|
||||
assert command["action"] == "set_overlay" and command["evidence_id"] == saved[0].source_ref
|
||||
assert "data" not in command
|
||||
assert saved[0].data["data"]["result"]["map_payload"] == geometry
|
||||
|
||||
|
||||
def test_business_skill_is_delivered_over_mcp_without_recording_event_evidence(runtime, monkeypatch):
|
||||
app, client, _, _ = runtime
|
||||
value = binding(task=True)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from pydantic import ValidationError
|
||||
from shapely.geometry import Point, shape
|
||||
|
||||
from app.domain.schemas.operational_data import TaskDataSnapshot
|
||||
from app.mcp.analysis_models import NodeCoverageInput
|
||||
from app.services.node_coverage import build_node_coverage
|
||||
from app.services.operational_presentation import resolve_overlay
|
||||
|
||||
|
||||
def position(node, longitude, latitude=32.75):
|
||||
return {"id": node, "longitude": longitude, "latitude": latitude}
|
||||
|
||||
|
||||
def group(ids, group_id="selected"):
|
||||
return {"group_id": group_id, "title": "节点覆盖比较", "node_ids": ids}
|
||||
|
||||
|
||||
def link(link_id, start, end, status=None):
|
||||
return {"id": link_id, "start_node_id": start, "end_node_id": end, "status": status}
|
||||
|
||||
|
||||
def test_coverage_can_compare_arbitrary_groups_without_assigning_isolation_semantics():
|
||||
positions = [position("A", 117.24), position("B", 117.241), position("C", 117.26)]
|
||||
coverage = build_node_coverage([group(["A", "B"], "monitoring"), group(["C"], "candidate")], positions, [])
|
||||
assert coverage["status"] == "ready"
|
||||
assert set(coverage["groups"]) == {"monitoring", "candidate"}
|
||||
assert [f["properties"]["coverage_group"] for f in coverage["map_payload"]["features"]] == ["monitoring", "candidate"]
|
||||
assert coverage["parameters"]["split_by_connectivity"] is False
|
||||
|
||||
|
||||
def test_connectivity_option_does_not_bridge_disconnected_groups():
|
||||
positions = [position("A", 117.24), position("B", 117.241),
|
||||
position("C", 117.26), position("D", 117.261)]
|
||||
coverage = build_node_coverage([group(["A", "B", "C", "D"])], positions,
|
||||
[link("P1", "A", "B"), link("P2", "C", "D")],
|
||||
split_by_connectivity=True)
|
||||
feature = coverage["map_payload"]["features"][0]
|
||||
geometry = shape(feature["geometry"])
|
||||
assert geometry.geom_type == "MultiPolygon" and geometry.is_valid
|
||||
assert feature["properties"]["component_count"] == 2
|
||||
assert all(geometry.covers(Point(row["longitude"], row["latitude"])) for row in positions)
|
||||
assert not geometry.covers(Point(117.25, 32.75))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status,excluded", [("CLOSED", []), (None, ["V"])])
|
||||
def test_connectivity_excludes_initially_closed_and_explicitly_excluded_links(status, excluded):
|
||||
coverage = build_node_coverage([group(["A", "B"])], [position("A", 117.24), position("B", 117.26)],
|
||||
[link("V", "A", "B", status)], split_by_connectivity=True,
|
||||
excluded_link_ids=excluded)
|
||||
assert coverage["groups"]["selected"]["component_count"] == 2
|
||||
|
||||
|
||||
def test_partial_geometries_are_reported_and_single_point_buffer_uses_meters():
|
||||
coverage = build_node_coverage([group(["A", "A", "missing", "invalid"])],
|
||||
[position("A", 117.24), position("invalid", float("nan"))], [])
|
||||
assert coverage["status"] == "partial"
|
||||
metadata = coverage["groups"]["selected"]
|
||||
assert metadata["mapped_node_count"] == 1 and metadata["missing_node_ids"] == ["invalid", "missing"]
|
||||
feature = coverage["map_payload"]["features"][0]
|
||||
geometry = shape(feature["geometry"])
|
||||
assert geometry.covers(Point(117.24, 32.75))
|
||||
assert 0.0004 < geometry.bounds[2] - geometry.bounds[0] < 0.001
|
||||
assert feature["properties"]["display_only"] is True
|
||||
|
||||
|
||||
def test_duplicate_and_collinear_positions_are_valid_and_parameters_are_preserved():
|
||||
coverage = build_node_coverage([group(["A", "B", "C"])],
|
||||
[position("A", 117.24), position("B", 117.24), position("C", 117.241)], [],
|
||||
concave_ratio=0.5, buffer_m=40)
|
||||
assert coverage["status"] == "ready"
|
||||
assert coverage["parameters"]["concave_ratio"] == 0.5 and coverage["parameters"]["buffer_m"] == 40
|
||||
assert shape(coverage["map_payload"]["features"][0]["geometry"]).is_valid
|
||||
|
||||
|
||||
def test_missing_positions_do_not_fabricate_an_area():
|
||||
coverage = build_node_coverage([group(["missing"])], [], [])
|
||||
assert coverage["status"] == "partial" and coverage["map_payload"]["features"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mcp_envelope", [False, True])
|
||||
def test_derived_payload_round_trips_through_saved_evidence_overlay(mcp_envelope):
|
||||
coverage = build_node_coverage([group(["A"])], [position("A", 117.24)], [])
|
||||
data = jsonable_encoder({"result": {"map_payload": coverage["map_payload"]}})
|
||||
if mcp_envelope:
|
||||
data = {"source": "generate_node_coverage", "source_ref": "mcp:coverage", "data": data}
|
||||
snapshot = TaskDataSnapshot(source="generate_node_coverage", source_ref="mcp:coverage",
|
||||
queried_at=datetime.now(timezone.utc), origin="system_data",
|
||||
truncated=False, data=data)
|
||||
overlay = resolve_overlay(snapshot, "geometry")
|
||||
assert overlay["features"][0]["properties"]["coverage_group"] == "selected"
|
||||
assert shape(overlay["features"][0]["geometry"]).covers(Point(117.24, 32.75))
|
||||
|
||||
|
||||
def test_service_resolves_only_selected_project_nodes_and_reads_topology_only_when_requested(monkeypatch):
|
||||
from app.services import node_coverage
|
||||
def positions(network, ids):
|
||||
assert network == "bound-project" and ids == ["A", "B"]
|
||||
return [position("A", 117.24), position("B", 117.241)]
|
||||
monkeypatch.setattr(node_coverage, "get_node_geographic_positions", positions)
|
||||
monkeypatch.setattr(node_coverage, "get_node_coverage_links", lambda *args: pytest.fail("unnecessary topology query"))
|
||||
result = node_coverage.generate_node_coverage("bound-project", [group(["A", "B"])])
|
||||
assert result["coverage"]["status"] == "ready"
|
||||
assert len(result["map_payload"]["features"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extra", [
|
||||
{"groups": []}, {"groups": [group(["A"]), group(["B"])]},
|
||||
{"buffer_m": 0}, {"buffer_m": 1001}, {"concave_ratio": float("nan")},
|
||||
{"excluded_link_ids": ["V"]}, {"groups": [{**group(["A"]), "coordinates": [117, 32]}]},
|
||||
{"groups": [group([str(i) for i in range(10000)]), group(["extra"], "other")]},
|
||||
])
|
||||
def test_tool_rejects_invalid_parameters_and_unbounded_or_arbitrary_coordinate_inputs(extra):
|
||||
with pytest.raises(ValidationError):
|
||||
NodeCoverageInput.model_validate({"context_handle": "x" * 43, "groups": [group(["A"])], **extra})
|
||||
@@ -598,6 +598,42 @@ async def test_unified_commands_record_canonical_events_without_mutating_task(pa
|
||||
assert len(repository.events) == 1 and repository.task["revision"] == task["revision"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_saved_mcp_coverage_envelope_publishes_groups_without_agent_geometry():
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from app.services.node_coverage import build_node_coverage
|
||||
|
||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
coverage = build_node_coverage(
|
||||
[{"group_id": "one", "title": "第一组", "node_ids": ["A"]},
|
||||
{"group_id": "two", "title": "第二组", "node_ids": ["A", "B"]}],
|
||||
[{"id": "A", "longitude": 117.24, "latitude": 32.75},
|
||||
{"id": "B", "longitude": 117.241, "latitude": 32.75}],
|
||||
[],
|
||||
)
|
||||
snapshot = TaskDataSnapshot(
|
||||
source="generate_node_coverage", source_ref="mcp:node-coverage", queried_at=now,
|
||||
origin="system_data", truncated=False,
|
||||
data=jsonable_encoder({"source": "generate_node_coverage", "data": {
|
||||
"result": {"map_payload": coverage["map_payload"]},
|
||||
}}),
|
||||
)
|
||||
evidence, _ = await service.record_data_snapshot(task["task_id"], snapshot)
|
||||
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
|
||||
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
|
||||
command={"action": "set_overlay", "overlay_id": "isolation-coverage", "title": "节点覆盖示意",
|
||||
"mode": "geometry", "evidence_id": evidence.evidence_id},
|
||||
))
|
||||
features = repository.events[-1]["metadata"]["command"]["data"]["features"]
|
||||
assert {feature["properties"]["coverage_group"] for feature in features} == {"one", "two"}
|
||||
assert all(feature["properties"]["display_only"] for feature in features)
|
||||
assert [feature["properties"]["node_count"] for feature in features] == [1, 2]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_oversized_data():
|
||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
from app.native.wndb.gis import network_views, region_geometry
|
||||
|
||||
|
||||
def test_selected_node_positions_use_bound_project_and_parameterized_geometry_query(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def read_all(network, statement, parameters):
|
||||
calls.append((network, statement, parameters))
|
||||
return [{"id": "A", "longitude": 117.24, "latitude": 32.75}]
|
||||
|
||||
monkeypatch.setattr(network_views, "read_all", read_all)
|
||||
assert network_views.get_node_geographic_positions("project-a", []) == []
|
||||
assert network_views.get_node_geographic_positions("project-a", ["A"])[0]["id"] == "A"
|
||||
assert len(calls) == 1 and calls[0][0] == "project-a" and calls[0][2] == (["A"],)
|
||||
assert "ST_Transform(geom,4326)" in calls[0][1] and "node_id = ANY(%s)" in calls[0][1]
|
||||
from app.native.wndb.model import elements
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user