feat(investigations): add event impact and work-order feedback flows

Add isolated burst and pollution impact evidence, parallel work-order workflows, persisted notifications and graph updates.

Checkpoint current code. Known follow-ups remain for large pollution result previews and graceful geometry failure.
This commit is contained in:
2026-11-14 15:14:02 +08:00
parent 471ad92a47
commit 2e4f6cb93b
49 changed files with 1826 additions and 93 deletions
+41
View File
@@ -204,3 +204,44 @@ def test_legacy_execution_timeout_does_not_block_owner_authorization(monkeypatch
assert args.timeout_seconds <= 240
assert row['parameters']['timeout_seconds'] == 3600
authorize.assert_awaited_once()
def test_large_burst_result_keeps_saved_map_command_and_full_evidence(monkeypatch):
from copy import deepcopy
from unittest.mock import AsyncMock
from app.mcp import calculation_jobs as jobs
from app.mcp.calculation_models import CalculationResultInput
from app.mcp.models import QueryResult
original = binding()
original.task_id = uuid4()
full = {"parameters": {"burst_pipe_ids": ["P1"]}, "result": {
"impact": {"phase": "after_burst_before_intervention", "status": "ready",
"compared_node_count": 1000, "comparisons": [{"node_id": f"J{i}", "pressure_drop_m": 4} for i in range(1000)],
"grades": [{"group_id": "drop_3_5", "node_ids": [f"J{i}" for i in range(1000)]}],
"unavailable_node_ids": []},
"coverage": {"status": "ready", "groups": {"drop_3_5": {"node_ids": [f"J{i}" for i in range(1000)],
"mapped_node_count": 1000, "missing_node_ids": []}}},
"map_payload": {"type": "FeatureCollection", "features": [{"large": "recorded-geometry"}]},
}}
result = QueryResult(source='run_burst_simulation', source_ref='mcp:burst', queried_at=datetime.now(timezone.utc),
semantics='analysis', data=full, evidence_id='saved-burst')
row = {'job_id': uuid4(), 'binding': durable_binding(original), 'status': 'succeeded', 'result': result.model_dump(mode='json')}
persisted = deepcopy(row)
monkeypatch.setattr(jobs, 'CalculationJobRepository', lambda *a: SimpleNamespace(get=AsyncMock(return_value=row)))
monkeypatch.setattr(jobs, 'authorize_original', AsyncMock())
monkeypatch.setattr(jobs, 'attach_calculation_evidence', AsyncMock(side_effect=lambda scope, row, result: result))
scope = SimpleNamespace(connection=None, project=SimpleNamespace(project_id=original.project_id, user_id=uuid4()))
args = CalculationResultInput(context_handle='x' * 32, job_id=row['job_id'])
response = asyncio.run(jobs.read_calculation_job(scope, original, SimpleNamespace(name='get_calculation_result'), args))
assert response.truncated and len(response.model_dump_json().encode()) < 11000
assert response.data['result']['impact']['compared_node_count'] == 1000
assert response.data['result']['impact']['grades'][0]['node_count'] == 1000
assert response.data['presentation']['command']['evidence_id'] == 'saved-burst'
assert response.data['presentation']['command']['action'] == 'set_overlay'
assert response.data['resource_uri'].endswith('/evidence/saved-burst')
assert 'comparisons' not in response.data['result']['impact']
assert row == persisted
# Explicit path pagination still reads every original node without summary replacement.
page = asyncio.run(jobs.read_calculation_job(scope, original, SimpleNamespace(name='get_calculation_result'),
args.model_copy(update={'data_path': ['result', 'impact', 'comparisons'], 'offset': 990, 'limit': 10})))
assert page.data[0]['node_id'] == 'J990' and page.page.total == 1000