feat(server): integrate MCP queries and investigation messaging

Add project-bound OAuth MCP queries and controlled analysis with scoped evidence,
limits, timeouts and a nearby-model spatial query using EPSG:4326 inputs.
Default nearby searches to 200 meters and five results.

Integrate investigation messaging/presentation, health/readiness and typed history;
remove duplicate web search and fix online simulation and compressed baseline writes.
Include project migrations 023-025; keep local credentials and demo seeding excluded.

Validation: 319 focused pytest checks; real PostGIS temporary-table regression;
read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls.
This commit is contained in:
2026-10-19 18:46:58 +08:00
parent 2b6fc5aa52
commit be6317eb48
117 changed files with 12703 additions and 638 deletions
+186
View File
@@ -0,0 +1,186 @@
"""Messages never change task generation or silently target a replacement run."""
from datetime import datetime, timezone
from uuid import UUID, uuid4
from app.auth.permissions import OPERATIONAL_TASK_EXECUTE, permissions_for_context
from app.domain.domain_errors import DomainError, require
from app.domain.schemas.agent_messages import (
AgentMessage, AgentMessageCreate, AgentMessageDelivery, AgentMessagePage, AgentMessageReply,
)
def _message_cursor(cursor: str | None):
if not cursor:
return None, None
try:
stamp, identifier = cursor.rsplit("|", 1)
cursor_at, cursor_id = datetime.fromisoformat(stamp), UUID(identifier)
require(cursor_at.tzinfo is not None, "INVALID_CURSOR", "Cursor needs a timezone", status=400)
return cursor_at, cursor_id
except (ValueError, TypeError) as exc:
raise DomainError("INVALID_CURSOR", "Invalid message cursor", status=400) from exc
class AgentMessageService:
def __init__(self, repository, context, *, clock=None):
self.repo = repository
self.context = context
self.clock = clock or (lambda: datetime.now(timezone.utc))
def _require_service(self) -> None:
require(self.context.system_role == "service" and
OPERATIONAL_TASK_EXECUTE in permissions_for_context(self.context), "SERVICE_REQUIRED",
"Message dispatch requires a project service account", status=403)
async def get(self, message_id: UUID) -> AgentMessage:
if self.context.system_role == "service":
self._require_service()
row = await self.repo.get_message(str(message_id))
require(row is not None, "MESSAGE_NOT_FOUND", "Message not found", status=404)
require(self.context.system_role == "service" or
str(row["conversation_owner_id"]) == str(self.context.user_id),
"MESSAGE_NOT_FOUND", "Message not found", status=404)
return AgentMessage.model_validate(row)
async def _active_run(self, task_id: UUID, run_id: UUID) -> dict:
run = await self.repo.get_run(str(run_id))
require(run is not None and str(run["task_id"]) == str(task_id),
"RUN_NOT_FOUND", "Run does not belong to the task", status=404)
task = await self.repo.get_task(str(task_id))
require(task is not None and task["task_type"] == "event_investigation",
"INVALID_TASK_TYPE", "Messages require an investigation task", status=409)
require(run["status"] in {"queued", "claimed", "running"},
"RUN_TERMINAL", "Run no longer accepts messages", status=409)
if run["status"] != "queued":
expires = run.get("lease_expires_at")
if isinstance(expires, str):
expires = datetime.fromisoformat(expires.replace("Z", "+00:00"))
require(expires is not None and expires > self.clock(),
"LEASE_EXPIRED", "Run lease expired", status=409)
return run
async def _insert(self, *, command, owner_id, conversation_owner_id, direction, task_id, run_id,
target_session_id, in_reply_to=None) -> AgentMessage:
prior = await self.repo.get_message_request(
str(owner_id), command.source_session_id, str(command.request_id),
)
expected = {
"task_id": str(task_id), "run_id": str(run_id),
"direction": direction, "in_reply_to": str(in_reply_to) if in_reply_to else None,
"text": command.text,
}
if prior is not None:
require(all((str(prior[key]) if prior[key] is not None else None) == value
for key, value in expected.items()),
"IDEMPOTENCY_CONFLICT", "request_id was used with different content", status=409)
return AgentMessage.model_validate(prior)
# The run lock serializes message creation with completion/cancellation.
await self._active_run(task_id, run_id)
message_id = uuid4()
now = self.clock()
message = AgentMessage(
project_id=self.context.project_id,
message_id=message_id, request_id=command.request_id,
task_id=task_id, run_id=run_id, source_session_id=command.source_session_id,
source_owner_id=owner_id, conversation_owner_id=conversation_owner_id,
target_session_id=target_session_id,
direction=direction, in_reply_to=in_reply_to, text=command.text,
status="pending", native_message_id=f"msg_{message_id}",
created_at=now, updated_at=now,
)
await self.repo.insert_message(message.model_dump(mode="json"))
return message
async def create(self, command: AgentMessageCreate) -> AgentMessage:
async with self.repo.transaction():
await self.repo.lock(f"run:{command.run_id}")
run = await self.repo.get_run(str(command.run_id))
return await self._insert(
command=command, owner_id=self.context.user_id,
conversation_owner_id=self.context.user_id, direction="to_task",
task_id=command.task_id, run_id=command.run_id,
target_session_id=run.get("agent_session_id") if run else None,
)
async def reply(self, message_id: UUID, command: AgentMessageReply) -> AgentMessage:
async with self.repo.transaction():
original = await self.get(message_id)
await self.repo.lock(f"run:{original.run_id}")
run = await self.repo.get_run(str(original.run_id))
if original.direction == "to_task":
self._require_service()
require(run is not None and run.get("agent_session_id") == command.source_session_id,
"SESSION_MISMATCH", "Session does not own the message run", status=409)
direction, target = "to_chat", original.source_session_id
else:
require(original.conversation_owner_id == self.context.user_id,
"MESSAGE_NOT_FOUND", "Message not found", status=404)
require(command.source_session_id == original.target_session_id,
"SESSION_MISMATCH", "Only the original chat can reply", status=409)
direction = "to_task"
target = run.get("agent_session_id") if run else None
return await self._insert(
command=command, owner_id=self.context.user_id,
conversation_owner_id=original.conversation_owner_id, direction=direction,
task_id=original.task_id, run_id=original.run_id,
target_session_id=target, in_reply_to=message_id,
)
async def list_session(
self, session_id: str, limit: int, cursor: str | None, pending_only: bool = False,
) -> AgentMessagePage:
cursor_at, cursor_id = _message_cursor(cursor)
kwargs = {"cursor_at": cursor_at, "cursor_id": cursor_id, "pending_only": pending_only}
if self.context.system_role == "service":
self._require_service()
rows = await self.repo.list_run_session_messages(session_id, limit + 1, **kwargs)
if not rows:
rows = await self.repo.list_session_messages(str(self.context.user_id), session_id, limit + 1, **kwargs)
else:
rows = await self.repo.list_session_messages(str(self.context.user_id), session_id, limit + 1, **kwargs)
items = [AgentMessage.model_validate(row) for row in rows[:limit]]
next_cursor = f"{items[-1].created_at.isoformat()}|{items[-1].message_id}" if len(rows) > limit else None
return AgentMessagePage(items=items, next_cursor=next_cursor)
async def pending(self, limit: int, cursor: str | None = None) -> AgentMessagePage:
self._require_service()
cursor_at, cursor_id = _message_cursor(cursor)
rows = await self.repo.list_pending_messages(limit + 1, cursor_at=cursor_at, cursor_id=cursor_id)
items = [AgentMessage.model_validate(row) for row in rows[:limit]]
next_cursor = f"{items[-1].created_at.isoformat()}|{items[-1].message_id}" if len(rows) > limit else None
return AgentMessagePage(items=items, next_cursor=next_cursor)
async def delivery(self, message_id: UUID, command: AgentMessageDelivery) -> AgentMessage:
async with self.repo.transaction():
message = await self.get(message_id)
if self.context.system_role == "service":
self._require_service()
if self.context.system_role != "service":
require(message.direction == "to_chat",
"SERVICE_REQUIRED", "Only the receiver can acknowledge a chat reply", status=403)
await self.repo.lock(f"run:{message.run_id}")
await self.repo.lock(f"message:{message_id}")
message = await self.get(message_id)
if message.status in {"delivered", "failed"}:
require(message.status == command.status, "MESSAGE_TERMINAL",
"Message already has a terminal delivery result", status=409)
return message
if message.direction == "to_task" and command.status != "failed":
run = await self._active_run(message.task_id, message.run_id)
target = run.get("agent_session_id")
require(target is not None and command.target_session_id == target,
"SESSION_MISMATCH", "Delivery must target the owning run session", status=409)
else:
target = message.target_session_id
require(command.target_session_id in {None, target}, "SESSION_MISMATCH",
"Delivery cannot change the reply channel", status=409)
require(command.native_message_id in {None, message.native_message_id},
"MESSAGE_ID_MISMATCH", "Native message identity is immutable", status=409)
now = self.clock()
update = {
"status": command.status, "target_session_id": target,
"failure_detail": command.failure_detail, "updated_at": now,
"delivered_at": now if command.status == "delivered" else None,
}
await self.repo.update_message_delivery(str(message_id), update)
return message.model_copy(update=update)
+2
View File
@@ -63,6 +63,7 @@ def run_burst_location_by_network(
use_scada_flow: bool = False,
scheme_name: str | None = None,
simulation_run_id: UUID | None = None,
final_candidates_csv_path: str | None = "temp/burst_location/final_round_candidates.csv",
) -> dict[str, Any]:
if not network:
raise ValueError("network is required.")
@@ -312,6 +313,7 @@ def run_burst_location_by_network(
min_dpressure=min_dpressure,
basic_pressure=basic_pressure,
visualize_partition=False,
final_candidates_csv_path=final_candidates_csv_path,
)
payload: dict[str, Any] = {
+6 -3
View File
@@ -5,6 +5,9 @@ import httpx
from pydantic import AliasChoices, BaseModel, Field
from app.core.config import settings
from app.core.tianditu_logging import install_tianditu_token_filter
install_tianditu_token_filter()
class TiandituGeocodeRequest(BaseModel):
@@ -32,12 +35,12 @@ async def geocode_tianditu(
*,
client: httpx.AsyncClient | None = None,
) -> dict[str, Any]:
if not settings.TIANDITU_GEOCODER_TOKEN:
raise TiandituGeocodingConfigError("TIANDITU_GEOCODER_TOKEN is not configured")
if not settings.TIANDITU_TOKEN:
raise TiandituGeocodingConfigError("TIANDITU_TOKEN is not configured")
params = {
"ds": json.dumps({"keyWord": request.keyword}, ensure_ascii=False),
"tk": settings.TIANDITU_GEOCODER_TOKEN,
"tk": settings.TIANDITU_TOKEN,
}
if client is not None:
+99
View File
@@ -0,0 +1,99 @@
"""Resolve browser overlays only from immutable Server-ingested snapshots."""
from __future__ import annotations
from math import isfinite
from app.domain.domain_errors import require
from app.domain.schemas.operational_data import TaskDataSnapshot
def _number(value) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value)
def _position(value):
require(isinstance(value, list) and len(value) in {2, 3}
and all(_number(item) for item in value)
and -180 <= value[0] <= 180 and -90 <= value[1] <= 90,
"OVERLAY_GEOMETRY_INVALID", "Overlay coordinates must be finite WGS84 positions", status=422)
return value[:2]
def _geometry(value):
require(isinstance(value, dict), "OVERLAY_GEOMETRY_INVALID", "Overlay geometry is missing", status=422)
kind = value.get("type")
depths = {"Point": 0, "MultiPoint": 1, "LineString": 1,
"MultiLineString": 2, "Polygon": 2, "MultiPolygon": 3}
require(kind in depths, "OVERLAY_GEOMETRY_INVALID", "Unsupported overlay geometry", status=422)
positions = 0
def coordinates(items, depth):
nonlocal positions
if depth == 0:
positions += 1
require(positions <= 20000, "OVERLAY_TOO_LARGE", "Overlay geometry is too large", status=422)
return _position(items)
require(isinstance(items, list) and 0 < len(items) <= 20000,
"OVERLAY_GEOMETRY_INVALID", "Overlay coordinates are empty or too large", status=422)
return [coordinates(item, depth - 1) for item in items]
normalized = coordinates(value.get("coordinates"), depths[kind])
lines = ([normalized] if kind == "LineString" else normalized if kind == "MultiLineString" else [])
require(all(len(line) >= 2 for line in lines), "OVERLAY_GEOMETRY_INVALID",
"Overlay lines need at least two positions", status=422)
rings = (normalized if kind == "Polygon" else [ring for polygon in normalized for ring in polygon]
if kind == "MultiPolygon" else [])
require(all(len(ring) >= 4 and ring[0] == ring[-1] for ring in rings),
"OVERLAY_GEOMETRY_INVALID", "Overlay polygon rings must be closed", status=422)
return {"type": kind, "coordinates": normalized}
def resolve_overlay(snapshot: TaskDataSnapshot, mode: str) -> dict:
"""Use recorded GeoJSON or recorded coordinates; never geocode or infer a boundary."""
data = snapshot.data
candidates = [data]
if isinstance(data, dict):
candidates.extend([data.get("map_payload"), data.get("drawing_payload")])
latest = data.get("latest_analysis")
if isinstance(latest, dict):
candidates.append(latest.get("map_payload"))
collection = next((item for item in candidates
if isinstance(item, dict) and item.get("type") == "FeatureCollection"), None)
if collection is not None:
raw_features = collection.get("features")
require(isinstance(raw_features, list), "OVERLAY_GEOMETRY_INVALID",
"Snapshot FeatureCollection is invalid", status=422)
else:
rows = data.get("items") if isinstance(data, dict) else data
require(isinstance(rows, list), "OVERLAY_DATA_MISSING",
"Evidence snapshot has no recorded coordinates or GeoJSON", status=422)
raw_features = []
for row in rows:
if not isinstance(row, dict) or row.get("longitude") is None or row.get("latitude") is None:
continue
raw_features.append({
"type": "Feature", "geometry": {"type": "Point", "coordinates": [row["longitude"], row["latitude"]]},
"id": row.get("device_id") or row.get("point_id") or row.get("work_order_id") or row.get("id"),
"properties": {"weight": row.get("weight", 1)},
})
require(0 < len(raw_features) <= 1000, "OVERLAY_DATA_MISSING",
"Overlay needs between one and 1000 recorded features", status=422)
features = []
for feature in raw_features:
require(isinstance(feature, dict) and feature.get("type") == "Feature",
"OVERLAY_GEOMETRY_INVALID", "Snapshot contains an invalid feature", status=422)
geometry = _geometry(feature.get("geometry"))
require(mode != "heatmap" or geometry["type"] == "Point", "OVERLAY_HEATMAP_POINTS_REQUIRED",
"Heatmaps require recorded points, not inferred polygon centroids", status=422)
properties = feature.get("properties") or {}
require(isinstance(properties, dict), "OVERLAY_GEOMETRY_INVALID", "Feature properties are invalid", status=422)
normalized = {"type": "Feature", "geometry": geometry, "properties": dict(properties)}
if isinstance(feature.get("id"), (str, int)) and not isinstance(feature.get("id"), bool):
normalized["id"] = feature["id"]
if mode == "heatmap":
weight = properties.get("weight", 1)
require(_number(weight) and weight >= 0, "OVERLAY_WEIGHT_INVALID",
"Heatmap weight must be a finite nonnegative number", status=422)
normalized["properties"]["weight"] = weight
features.append(normalized)
return {"type": "FeatureCollection", "features": features}
+210 -9
View File
@@ -1,6 +1,7 @@
"""State machines for durable operational tasks and approved external actions."""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from typing import Iterable
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
@@ -46,6 +47,7 @@ from app.domain.schemas.operational_task import (
HumanEvidenceCommand,
InvestigationReportRevision,
InvestigationReportReference,
InvestigationProgressSnapshot,
IssueCommandPayload,
NotificationAcknowledgeCommand,
OperationalTaskCreate,
@@ -60,9 +62,13 @@ from app.domain.schemas.operational_task import (
OperationalTaskRun,
RecoverExpiredLeasesResult,
StoredEvidenceRecord,
TaskPresentationEventCommand,
TaskPresentationEventResult,
TaskPresentationCursor,
)
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
from app.services.content_digest import digest
from app.services.operational_presentation import resolve_overlay
LEASE_DURATION = timedelta(seconds=60)
@@ -73,7 +79,7 @@ def _json(model) -> dict:
def _task_json(model: OperationalTaskSummary) -> dict:
return model.model_dump(mode="json", by_alias=True, exclude={"active_run"})
return model.model_dump(mode="json", by_alias=True, exclude={"active_run", "latest_run"})
class OperationalTaskService:
@@ -223,6 +229,8 @@ class OperationalTaskService:
require(task is not None, "TASK_NOT_FOUND", "Operational task not found", status=404)
plan = await self.repo.get_latest_plan(str(task_id))
report = await self.repo.get_latest_report(str(task_id))
if report is not None:
report = await self._report_with_progress_diagram(task_id, report)
evidence = [
self._public_evidence(document)
for document in await self.repo.list_evidence(str(task_id))
@@ -230,6 +238,9 @@ class OperationalTaskService:
actions = await self.repo.list_actions(str(task_id))
access_requests = await self._list_access_requests(task_id)
active_run = await self._active_run(task)
latest_run = (await self.repo.get_latest_run(str(task_id))
if hasattr(self.repo, "get_latest_run") else active_run)
progress = await self.repo.get_latest_progress(str(task_id), task["execution_generation"])
work_order_snapshot = None
if task["task_type"] == "external_work_order" and task["source"].get("external_id"):
feedback = await self.repo.get_feedback(
@@ -238,10 +249,12 @@ class OperationalTaskService:
if feedback:
work_order_snapshot = feedback["document"].get("work_order_snapshot")
return OperationalTaskDetail(
**{key: value for key, value in task.items() if key != "active_run"},
**{key: value for key, value in task.items() if key not in {"active_run", "latest_run"}},
active_run=active_run,
latest_run=latest_run,
latest_plan=plan,
latest_report=report,
latest_progress=progress,
evidence=evidence,
actions=actions,
access_requests=access_requests,
@@ -249,18 +262,45 @@ class OperationalTaskService:
work_order_snapshot=work_order_snapshot,
)
async def _report_with_progress_diagram(
self, task_id: UUID, value: dict | InvestigationReportRevision
) -> InvestigationReportRevision:
"""Archive an eligible recorded graph, without inventing completion states.
Also projects older reports that predate graph archival. Read paths never
rewrite their stored document or borrow a newer round's progress.
"""
snapshot = InvestigationReportRevision.model_validate(value)
if (snapshot.report.diagram is not None or snapshot.report.stage != "final"
or snapshot.execution_generation is None):
return snapshot
stored = await self.repo.get_latest_progress(str(task_id), snapshot.execution_generation)
if stored is None:
return snapshot
progress = InvestigationProgressSnapshot.model_validate(stored)
diagram = progress.progress.diagram
if (diagram is None or progress.execution_generation != snapshot.execution_generation
or progress.source_session_id != snapshot.source_session_id
or progress.published_at > snapshot.published_at
or any(not set(node.evidence_refs) <= set(snapshot.report.evidence_refs)
for node in diagram.node_refs)):
return snapshot
return snapshot.model_copy(update={
"report": snapshot.report.model_copy(update={"diagram": diagram}),
})
async def report(self, task_id: UUID, revision: int) -> InvestigationReportRevision:
await self._task(task_id)
value = await self.repo.get_report(str(task_id), revision)
require(value is not None, "REPORT_NOT_FOUND", "Report revision not found", status=404)
return InvestigationReportRevision.model_validate(value)
return await self._report_with_progress_diagram(task_id, value)
async def list_reports(
self, task_id: UUID, limit: int, offset: int
) -> tuple[list[InvestigationReportRevision], int | None]:
await self._task(task_id)
rows = await self.repo.list_reports(str(task_id), limit + 1, offset)
return [InvestigationReportRevision.model_validate(row) for row in rows[:limit]], (
return [await self._report_with_progress_diagram(task_id, row) for row in rows[:limit]], (
offset + limit if len(rows) > limit else None
)
@@ -398,12 +438,15 @@ class OperationalTaskService:
async def list_runs(
self, *, status: str | None, limit: int,
cursor_created_at=None, cursor_run_id=None,
task_type: str | None = None, task_created_from: datetime | None = None,
) -> tuple[list[OperationalTaskRun], str | None]:
rows = await self.repo.list_runs(
status=status,
limit=limit + 1,
cursor_created_at=cursor_created_at,
cursor_run_id=cursor_run_id,
task_type=task_type,
task_created_from=task_created_from,
)
items = [OperationalTaskRun.model_validate(row) for row in rows[:limit]]
next_cursor = None
@@ -418,6 +461,7 @@ class OperationalTaskService:
body = _json(command)
scope = f"run:{run_id}:claim"
async with self.repo.transaction():
await self.repo.require_execution_storage()
replay = await self._request_replay(scope, command.request_id, body)
if replay:
return OperationalTaskRun.model_validate(replay["run"])
@@ -507,6 +551,18 @@ class OperationalTaskService:
) -> OperationalTaskDetail:
return await self._finish_run(task_id, run_id, command, outcome="succeeded")
async def task_runs(self, task_id: UUID, limit: int, offset: int) -> list[OperationalTaskRun]:
await self._task(task_id)
return [OperationalTaskRun.model_validate(row)
for row in await self.repo.list_task_runs(str(task_id), limit, offset)]
async def task_run(self, task_id: UUID, run_id: UUID) -> OperationalTaskRun:
return OperationalTaskRun.model_validate(await self._run(task_id, run_id))
async def _fail_run_messages(self, run_id, reason: str, now: datetime) -> None:
if hasattr(self.repo, "fail_run_messages"):
await self.repo.fail_run_messages(str(run_id), reason, now)
async def fail_run(
self, task_id: UUID, run_id: UUID, command: AgentRunFailureCommand
) -> OperationalTaskDetail:
@@ -539,6 +595,7 @@ class OperationalTaskService:
)
run_model = OperationalTaskRun.model_validate(run)
await self.repo.update_run(_json(run_model), expected)
await self._fail_run_messages(run_id, "Run cancelled", now)
task = await self._task(task_id)
task.update(
lifecycle_status="cancelled", status="cancelled",
@@ -630,6 +687,7 @@ class OperationalTaskService:
"Agent 执行投递连续失败 10 次,请检查服务连接", now,
)
if not command.retryable:
await self._fail_run_messages(run_id, "Run dispatch failed", now)
task = await self._task(task_id)
task.update(
lifecycle_status="failed", status="failed",
@@ -642,11 +700,20 @@ class OperationalTaskService:
await self._save_request(scope, command.request_id, body, {"run": _json(run_model)})
return run_model
async def recover_expired_leases(self, limit: int) -> RecoverExpiredLeasesResult:
async def recover_expired_leases(
self, limit: int, *, task_type: str | None = None, task_created_from: datetime | None = None,
) -> RecoverExpiredLeasesResult:
now = self._now()
recovered: list[UUID] = []
async with self.repo.transaction():
for run in await self.repo.claim_expired_runs(limit):
for run in await self.repo.claim_expired_runs(
limit, task_type=task_type, task_created_from=task_created_from,
):
# Lease recovery already holds a row lock. Try rather than wait
# for the shared run lock to avoid reversing the lock order of
# message/finish transactions; a busy run is checked next tick.
if hasattr(self.repo, "try_lock") and not await self.repo.try_lock(f"run:{run['run_id']}"):
continue
task_id = UUID(str(run["task_id"]))
expected = run["revision"]
run.update(
@@ -656,6 +723,7 @@ class OperationalTaskService:
)
timed_out = OperationalTaskRun.model_validate(run)
await self.repo.update_run(_json(timed_out), expected)
await self._fail_run_messages(run["run_id"], "Run lease expired", now)
await self._run_event(timed_out, now)
task = await self._task(task_id)
timeout_count = await self.repo.consecutive_timed_out_runs(str(task_id))
@@ -738,6 +806,29 @@ class OperationalTaskService:
item["action_id"]: HumanApprovalAction.model_validate(item)
for item in await self.repo.list_actions(str(task_id))
}
progress_revision = None
if command.progress is not None:
require(command.run_id is not None, "RUN_REQUIRED",
"Progress must identify its current execution run", status=422)
if command.progress.diagram:
for node in command.progress.diagram.node_refs:
self._require_evidence_refs(node.evidence_refs, known_evidence)
require(all(str(ref) in actions for ref in node.action_refs),
"ACTION_NOT_FOUND", "Progress references an unknown action", status=422)
previous = await self.repo.get_latest_progress(str(task_id), task["execution_generation"])
progress_revision = (previous["revision"] if previous else 0) + 1
snapshot = InvestigationProgressSnapshot(
revision=progress_revision, run_id=active_run["run_id"],
execution_generation=task["execution_generation"],
source_session_id=command.source_session_id, published_at=now, progress=command.progress,
)
await self.repo.save_progress(str(task_id), _json(snapshot))
await self._append_event(
topic="operational.progress.changed", aggregate_type="operational_task_progress",
aggregate_id=str(task_id), aggregate_revision=progress_revision, occurred_at=now,
metadata={"task_id": str(task_id), "run_id": str(active_run["run_id"]),
"execution_generation": task["execution_generation"]},
)
if command.plan:
self._require_evidence_refs(
(ref for node in command.plan.nodes for ref in node.evidence_refs),
@@ -805,6 +896,7 @@ class OperationalTaskService:
source_session_id=command.source_session_id,
published_at=now, published_by=self._actor(), report=command.report,
)
report = await self._report_with_progress_diagram(task_id, report)
await self.repo.insert_report(str(task_id), _json(report))
await self._append_event(
topic="operational.report.published",
@@ -873,9 +965,76 @@ class OperationalTaskService:
)
await self._task_event(task, now)
response = {"plan_revision": plan_revision, "report_revision": report_revision}
if progress_revision is not None:
response["progress_revision"] = progress_revision
await self._save_request(scope, command.request_id, body, response)
return AgentArtifactsResult(task=await self.detail(task_id), **response)
async def presentation_cursor(self, task_id: UUID) -> TaskPresentationCursor:
# The shared event-order lock gives the run and cursor one committed boundary.
async with self.repo.transaction():
task = await self._task(task_id)
run = await self._active_run(task)
cursor = await self.repo.current_event_cursor()
return TaskPresentationCursor(
task_id=task_id, execution_generation=task["execution_generation"],
active_run=({key: run[key] for key in ("run_id", "generation", "status")} if run else None),
event_cursor=cursor,
)
async def publish_presentation_event(
self, task_id: UUID, command: TaskPresentationEventCommand
) -> TaskPresentationEventResult:
body = _json(command)
scope = f"task:{task_id}:presentation"
async with self.repo.transaction():
replay = await self._request_replay(scope, command.request_id, body)
if replay:
return TaskPresentationEventResult.model_validate(replay)
await self.repo.lock(f"task:{task_id}")
await self.repo.lock(f"run:{command.run_id}")
task = await self._task(task_id)
self._require_actionable_task(task)
run = await self._active_run(task)
require(run is not None and str(run["run_id"]) == str(command.run_id)
and run["generation"] == task["execution_generation"],
"EXECUTION_GENERATION_MISMATCH", "Presentation targets a stale execution run", status=409)
require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION",
"Only a running run can publish presentation", status=409)
require(run.get("agent_session_id") == command.source_session_id,
"SESSION_MISMATCH", "Presentation must come from the claimed Agent session", status=409)
now = self._now()
self._require_active_run_lease(run, now)
payload = _json(command.command)
if command.command.action == "open_chart":
known = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))}
self._require_evidence_refs(command.command.evidence_refs, known)
elif command.command.action == "set_overlay":
stored = await self.repo.get_evidence(str(task_id), command.command.evidence_id)
require(stored is not None, "EVIDENCE_NOT_FOUND", "Overlay evidence does not belong to this task", status=422)
evidence = StoredEvidenceRecord.model_validate(stored)
require(evidence.data_snapshot is not None, "SNAPSHOT_NOT_FOUND",
"Overlay requires a Server-ingested immutable data snapshot", status=422)
payload["data"] = resolve_overlay(evidence.data_snapshot, command.command.mode)
metadata = {"task_id": str(task_id), "run_id": str(run["run_id"]),
"execution_generation": run["generation"], "command": payload}
try:
size = len(json.dumps(metadata, ensure_ascii=False, allow_nan=False).encode("utf-8"))
except (ValueError, TypeError) as exc:
raise DomainError("PRESENTATION_DATA_INVALID", "Presentation data must be finite JSON", status=422) from exc
require(size <= 512 * 1024, "PRESENTATION_TOO_LARGE",
"Presentation event exceeds the 512 KiB limit", status=422)
event_seq = await self._append_event(
topic="operational.presentation.requested", aggregate_type="operational_task_presentation",
aggregate_id=str(task_id), aggregate_revision=run["revision"], occurred_at=now,
metadata=metadata,
)
require(event_seq is not None, "DEPENDENCY_UNAVAILABLE", "Presentation event storage is unavailable", status=503)
result = TaskPresentationEventResult(event_seq=event_seq, run_id=run["run_id"],
execution_generation=run["generation"])
await self._save_request(scope, command.request_id, body, _json(result))
return result
async def propose_action(
self, task_id: UUID, command: ActionProposalCommand
) -> HumanApprovalAction:
@@ -1246,6 +1405,22 @@ class OperationalTaskService:
return ExternalFeedbackResult(task=await self.detail(task_id), **replay)
await self.repo.lock(f"task:{task_id}")
task = await self._task(task_id)
if task["task_type"] == "event_investigation":
self._require_actionable_task(task)
own_source = task["source"].get("external_id")
matching_source = own_source is not None and command.source_event_id in {
own_source, f"peripheral_sample:{own_source}",
}
if task["status"] == "completed" and not matching_source:
actions = await self.repo.list_actions(str(task_id))
require(any(
item["kind"] == "create_work_order" and item.get("receipt")
and item["receipt"].get("adapter") == "peripheral_sample"
and command.source_event_id == f"peripheral_sample:{item['receipt']['receipt_id']}"
for item in actions
), "EXTERNAL_SOURCE_MISMATCH", "Feedback does not match an associated child work order", status=409)
require(command.source_revision > 1, "INITIAL_CHILD_FEEDBACK",
"The child creation receipt is not new field feedback", status=409)
if task["task_type"] == "external_work_order":
require(
task["source"].get("external_id") == command.source_event_id,
@@ -1263,7 +1438,9 @@ class OperationalTaskService:
existing = await self.repo.get_feedback(str(task_id), command.source_event_id)
current_source_revision = (
task.get("source", {}).get("external_revision")
if task["task_type"] == "external_work_order"
if task["task_type"] == "external_work_order" or (
task["task_type"] == "event_investigation" and matching_source
)
else None
)
if current_source_revision is not None:
@@ -1276,7 +1453,8 @@ class OperationalTaskService:
if existing:
prior = existing["document"]
if command.source_revision == existing["source_revision"]:
require(digest(prior) == digest(body), "IDEMPOTENCY_CONFLICT",
require(digest({key: value for key, value in prior.items() if key != "request_id"})
== digest({key: value for key, value in body.items() if key != "request_id"}), "IDEMPOTENCY_CONFLICT",
"External feedback revision was reused with different content", status=409)
response = {
"source_event_id": command.source_event_id,
@@ -1287,6 +1465,10 @@ class OperationalTaskService:
return ExternalFeedbackResult(task=await self.detail(task_id), **response)
require(command.source_revision > existing["source_revision"],
"STALE_EXTERNAL_REVISION", "External feedback revision is stale", status=409)
if (task["task_type"] == "event_investigation" and matching_source
and current_source_revision is not None):
require(command.source_revision > current_source_revision, "STALE_EXTERNAL_REVISION",
"Original event feedback must contain a new source version", status=409)
now = self._now()
await self.repo.upsert_feedback(str(task_id), body, now)
if command.evidence:
@@ -1322,6 +1504,8 @@ class OperationalTaskService:
),
)
if task["task_type"] == "event_investigation":
if matching_source:
task["source"] = {**task["source"], "external_revision": command.source_revision}
await self._requeue_task(task, now)
else:
task.update(revision=task["revision"] + 1, updated_at=now)
@@ -1706,6 +1890,11 @@ class OperationalTaskService:
task = await self._task(task_id)
final_outcome = outcome
if outcome == "succeeded":
if hasattr(self.repo, "pending_run_messages"):
pending_messages = await self.repo.pending_run_messages(str(run_id))
require(not pending_messages, "PENDING_AGENT_MESSAGES",
"Run has messages not yet entered into Agent context",
status=409, recovery="retry_same_request", refs=pending_messages)
plan_document = await self.repo.get_latest_plan(str(task_id))
report_document = await self.repo.get_latest_report(str(task_id))
plan = (
@@ -1770,6 +1959,8 @@ class OperationalTaskService:
)
run_model = OperationalTaskRun.model_validate(run)
await self.repo.update_run(_json(run_model), expected)
if final_outcome != "succeeded":
await self._fail_run_messages(run_id, failure_code or "Run failed", now)
task.update(
lifecycle_status=lifecycle,
status=lifecycle,
@@ -1825,7 +2016,8 @@ class OperationalTaskService:
def _require_actionable_task(task: dict) -> None:
source = task.get("source") or {}
require(
task.get("lifecycle_status") != "cancelled" and source.get("deleted_at") is None,
task.get("lifecycle_status") != "cancelled" and task.get("status") != "cancelled"
and source.get("deleted_at") is None,
"TASK_NOT_ACTIONABLE",
"Cancelled or withdrawn tasks do not accept action changes",
status=409,
@@ -2003,6 +2195,14 @@ class OperationalTaskService:
async def _requeue_task(self, task: dict, now: datetime) -> None:
if self._supports_runs:
active_run = await self.repo.get_active_run(str(task["task_id"]))
while active_run is not None:
await self.repo.lock(f"run:{active_run['run_id']}")
# Completion may have won the run lock after our first read.
refreshed_run = await self.repo.get_active_run(str(task["task_id"]))
if refreshed_run is None or str(refreshed_run["run_id"]) == str(active_run["run_id"]):
active_run = refreshed_run
break
active_run = refreshed_run
if active_run is not None and active_run["status"] == "queued":
# Multiple inputs can arrive before the worker claims the next
# generation. They are all visible when that queued run starts,
@@ -2027,6 +2227,7 @@ class OperationalTaskService:
)
cancelled = OperationalTaskRun.model_validate(active_run)
await self.repo.update_run(_json(cancelled), expected)
await self._fail_run_messages(active_run["run_id"], "Run superseded by requeue", now)
await self._run_event(cancelled, now)
generation = task["execution_generation"] + 1
task_id = UUID(task["task_id"])
+115
View File
@@ -0,0 +1,115 @@
import json
from typing import Any
import httpx
from pydantic import ValidationError
from app.core.config import settings
from app.core.tianditu_logging import install_tianditu_token_filter
from app.domain.schemas.place_search import (
MAX_PLACE_SEARCH_RESULTS,
PlaceSearchItem,
PlaceSearchRequest,
PlaceSearchResponse,
)
FENGXIAN_ADMIN_CODE = "156310120"
install_tianditu_token_filter()
class PlaceSearchError(RuntimeError):
def __init__(self, status_code: int, detail: str):
super().__init__(detail)
self.status_code = status_code
self.detail = detail
async def search_tianditu_places(
request: PlaceSearchRequest,
*,
client: httpx.AsyncClient | None = None,
) -> PlaceSearchResponse:
token = settings.TIANDITU_TOKEN
if not token:
raise PlaceSearchError(503, "地名搜索服务未配置")
params = {
"postStr": json.dumps(
{
"keyWord": request.keyword,
"queryType": 12,
"specify": FENGXIAN_ADMIN_CODE,
"start": 0,
"count": MAX_PLACE_SEARCH_RESULTS,
"show": 2,
},
ensure_ascii=False,
),
"type": "query",
"tk": token,
}
try:
if client is not None:
response = await client.get(settings.TIANDITU_SEARCH_URL, params=params)
else:
async with httpx.AsyncClient(
timeout=settings.TIANDITU_SEARCH_TIMEOUT_SECONDS
) as managed_client:
response = await managed_client.get(
settings.TIANDITU_SEARCH_URL, params=params
)
response.raise_for_status()
except httpx.TimeoutException:
raise PlaceSearchError(504, "地名搜索超时,请稍后重试") from None
except httpx.HTTPError:
# Request URLs contain the provider key and must not reach the client.
raise PlaceSearchError(502, "天地图地名搜索服务暂不可用") from None
try:
return _parse_response(response.json())
except (ValueError, TypeError, KeyError, AttributeError, ValidationError):
raise PlaceSearchError(502, "天地图返回了无效的地名搜索结果") from None
def _parse_response(payload: dict[str, Any]) -> PlaceSearchResponse:
code = str(payload.get("status", {}).get("infocode", ""))
if code == "3001":
return PlaceSearchResponse(items=[], total=0)
if code != "1000":
raise PlaceSearchError(502, "天地图地名搜索请求失败,请稍后重试")
result_type = str(payload.get("resultType", ""))
if result_type not in {"1", "3"}:
raise PlaceSearchError(502, "未获得可定位的地名结果,请细化关键字")
raw_items = payload.get("pois", []) if result_type == "1" else payload.get("area", [])
if not isinstance(raw_items, list) or len(raw_items) > MAX_PLACE_SEARCH_RESULTS:
raise ValueError("Invalid search results")
items = []
for raw in raw_items:
admin_code = str(raw.get("provinceCode") or raw.get("adminCode") or "")
if admin_code and not admin_code.startswith(("156310", "310")):
raise ValueError("Result outside Shanghai")
county_code = str(raw.get("countyCode") or (raw.get("adminCode") if result_type == "3" else "") or "")
county = raw.get("county")
if county_code and not county_code.startswith((FENGXIAN_ADMIN_CODE, "310120")):
raise ValueError("Result outside Fengxian")
if county and county != "奉贤区" or not county_code and county != "奉贤区":
raise ValueError("Missing or mismatched district")
lon, lat = str(raw["lonlat"]).split(",")
name = raw["name"]
if not isinstance(name, str) or not name.strip():
raise ValueError("Missing place name")
name = name.strip()
category = raw.get("typeName") or ("公交站" if str(raw.get("poiType")) == "102" else None)
items.append(PlaceSearchItem(
id=str(raw.get("hotPointID") or raw.get("adminCode") or f"{name}:{lon},{lat}"),
name=name,
address=str(raw.get("address") or name),
longitude=float(lon), latitude=float(lat),
category="行政区" if result_type == "3" else category,
district="奉贤区",
))
total = int(payload.get("count", 0))
if total < len(items):
raise ValueError("Invalid search total")
return PlaceSearchResponse(
items=items, total=len(items), truncated=total > MAX_PLACE_SEARCH_RESULTS,
)
+12
View File
@@ -82,8 +82,16 @@ def _create_validated_placement(
sensor_locations: list[str],
sensor_type: str = "pressure",
method: str | None = None,
persist_results: bool = True,
) -> dict[str, Any]:
validate_sensor_placement_nodes(project_code, sensor_locations)
if not persist_results:
return {
"sensor_locations": sensor_locations,
"sensor_points": _sensor_points(project_code, sensor_locations),
"method": method,
"sensor_type": sensor_type,
}
return sensor_placement_repository.create_sensor_placement(
project_code,
run_name=run_name,
@@ -102,6 +110,7 @@ def optimize_sensor_placement_by_sensitivity(
min_diameter: int,
created_by: str,
sensor_type: str = "pressure",
persist_results: bool = True,
) -> dict[str, Any]:
"""Run sensitivity placement and persist the validated result."""
@@ -119,6 +128,7 @@ def optimize_sensor_placement_by_sensitivity(
sensor_locations=sensor_locations,
sensor_type=sensor_type,
method="sensitivity",
persist_results=persist_results,
)
@@ -129,6 +139,7 @@ def optimize_sensor_placement_by_kmeans(
min_diameter: int,
created_by: str,
sensor_type: str = "pressure",
persist_results: bool = True,
) -> dict[str, Any]:
"""Export the model, run K-means placement, and persist the result."""
@@ -146,6 +157,7 @@ def optimize_sensor_placement_by_kmeans(
sensor_locations=sensor_locations,
sensor_type=sensor_type,
method="kmeans",
persist_results=persist_results,
)
+74 -2
View File
@@ -11,7 +11,7 @@ from app.native.wndb.model.tanks import get_tank, set_tank
from app.native.wndb.model.times import get_time, set_time
# from get_real_status import *
from datetime import datetime
from datetime import datetime, timedelta
from math import modf
import json
import pytz
@@ -150,6 +150,69 @@ def _apply_valve_control(
set_status(project_name, cs)
def run_online_simulation(
name: str,
start_time: datetime,
duration_seconds: int,
*,
output_node_ids: list[str] | None = None,
output_link_ids: list[str] | None = None,
) -> dict:
"""Recalculate online baseline snapshots without cloning a model database."""
if start_time.tzinfo is None or duration_seconds <= 0:
raise ValueError("Online simulation requires an aware start time and positive duration")
step = parse_clock_duration_seconds(
get_time(name)["HYDRAULIC TIMESTEP"], field_name="HYDRAULIC TIMESTEP"
)
if step <= 0:
raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.")
mappings = query_corresponding_element_id_and_query_id(name)
selected = {
"node": {node: [] for node in output_node_ids or []},
"link": {link: [] for link in output_link_ids or []},
}
sample_times = []
units = {}
current = start_time
end = start_time + timedelta(seconds=duration_seconds)
while current < end:
output = run_simulation(
name=name,
simulation_type="realtime",
modify_pattern_start_time=current.isoformat(timespec="seconds"),
scada_mappings=mappings,
)
if not isinstance(output, dict):
raise RuntimeError("Online simulation did not return calculation results")
units = output.get("units", {})
sample_times.append(current.isoformat(timespec="seconds"))
for kind, elements in selected.items():
if not elements:
continue
rows = {
str(row[kind]): row.get("result", [])
for row in output.get(f"{kind}_results", []) if str(row[kind]) in elements
}
for element, values in elements.items():
samples = rows.get(element, [])
# Each realtime run is a single snapshot. Missing samples remain
# explicit so later values cannot shift to an earlier timestamp.
values.append(samples[0] if samples else None)
current += timedelta(seconds=step)
return {
"units": units,
"times": {
"report_start": 0, "report_step": step,
"sim_duration": duration_seconds, "num_periods": len(sample_times),
"sample_times": sample_times,
},
**{f"{kind}_results": [
{kind: element, "result": values}
for element, values in elements.items() if any(value is not None for value in values)
] for kind, elements in selected.items()},
}
# 2025/01/11
def run_simulation(
name: str,
@@ -170,7 +233,8 @@ def run_simulation(
scheme_username: str = "system",
scheme_detail: dict | None = None,
scada_mappings: ScadaElementMappings | None = None,
) -> UUID | None:
persist_results: bool = True,
) -> UUID | dict | None:
"""
传入需要修改的参数,改变数据库中对应位置的值,然后计算,返回结果
:param name: 模型名称,数据库中对应的名字
@@ -189,6 +253,10 @@ def run_simulation(
:param scheme_name:模拟方案名称
:return:
"""
if not persist_results and (
not name.startswith("tjw_tmp_") or simulation_type.upper() != "EXTENDED"
):
raise ValueError("Non-persistent simulation requires an isolated temporary model")
# 记录开始时间
time_cost_start = time.perf_counter()
@@ -514,6 +582,8 @@ def run_simulation(
num_periods_result = times_info.get("num_periods")
if num_periods_result is None:
raise RuntimeError("run_project output missing times.num_periods")
if not persist_results:
return output_data
print("simulation_type", simulation_type)
print("before store result")
# print(num_periods_result)
@@ -583,4 +653,6 @@ def run_simulation(
# TimescaleInternalQueries.fill_scheme_simulation_result_to_SCADA(scheme_type=scheme_type, scheme_name=scheme_name)
print("after store result")
if simulation_type.upper() == "REALTIME":
return output_data
return run_id if simulation_type.upper() == "EXTENDED" else None
+20 -8
View File
@@ -222,7 +222,8 @@ def valve_close_analysis(
modify_valve_opening: dict[str, float] = None,
scheme_name: str = None,
_temporary_project: str | None = None,
) -> None:
persist_results: bool = True,
) -> dict | str | None:
"""
关阀模拟
:param name: 模型名称,数据库中对应的名字
@@ -271,7 +272,7 @@ def valve_close_analysis(
set_option(new_name, cs_options)
# result = run_simulation_ex(new_name,'realtime', modify_pattern_start_time, modify_pattern_start_time, modify_total_duration,
# downloading_prohibition=True)
simulation.run_simulation(
result = simulation.run_simulation(
name=new_name,
simulation_type="extended",
modify_pattern_start_time=modify_pattern_start_time,
@@ -280,7 +281,9 @@ def valve_close_analysis(
scheme_type="valve_close_analysis",
scheme_name=scheme_name,
result_db_name=name,
persist_results=persist_results,
)
return result if not persist_results else None
# step 3. restore the base model
# for valve in valves:
# execute_undo(name)
@@ -303,7 +306,8 @@ def flushing_analysis(
username: str | None = None,
valve_control: dict[str, dict] = None,
_temporary_project: str | None = None,
) -> None:
persist_results: bool = True,
) -> dict | str | None:
"""
管道冲洗模拟
:param name: 模型名称,数据库中对应的名字
@@ -425,7 +429,7 @@ def flushing_analysis(
set_option(new_name, cs_options)
# result = run_simulation_ex(new_name,'realtime', modify_pattern_start_time, modify_pattern_start_time, modify_total_duration,
# downloading_prohibition=True)
simulation.run_simulation(
result = simulation.run_simulation(
name=new_name,
simulation_type="extended",
modify_pattern_start_time=modify_pattern_start_time,
@@ -437,7 +441,9 @@ def flushing_analysis(
result_db_name=name,
scheme_username=username,
scheme_detail=scheme_detail,
persist_results=persist_results,
)
return result if not persist_results else None
# step 4. restore the base model
# return result
@@ -457,7 +463,8 @@ def contaminant_simulation(
source_pattern: str = None, # 污染源时间变化模式名称
username: str | None = None,
_temporary_project: str | None = None,
) -> None:
persist_results: bool = True,
) -> dict | str | None:
"""
污染模拟
:param name: 模型名称,数据库中对应的名字
@@ -568,7 +575,7 @@ def contaminant_simulation(
# step 5. run simulation
# result = run_simulation_ex(new_name,'realtime', modify_pattern_start_time, modify_pattern_start_time, modify_total_duration,
# downloading_prohibition=True)
simulation.run_simulation(
result = simulation.run_simulation(
name=new_name,
simulation_type="extended",
modify_pattern_start_time=modify_pattern_start_time,
@@ -578,7 +585,9 @@ def contaminant_simulation(
result_db_name=name,
scheme_username=username,
scheme_detail=scheme_detail,
persist_results=persist_results,
)
return result if not persist_results else None
# for i in range(1,operation_step):
# execute_undo(name)
@@ -600,7 +609,8 @@ def pressure_regulation(
scheme_name: str = None,
scada_mappings: simulation.ScadaElementMappings | None = None,
_temporary_project: str | None = None,
) -> None:
persist_results: bool = True,
) -> dict | str | None:
"""
区域调压模拟,用来模拟未来15分钟内,开关水泵对区域压力的影响
:param name: 模型名称,数据库中对应的名字
@@ -645,7 +655,7 @@ def pressure_regulation(
# pump_control=pump_control,
# tank_initial_level_control=tank_initial_level_control,
# downloading_prohibition=True)
simulation.run_simulation(
result = simulation.run_simulation(
name=new_name,
simulation_type="extended",
modify_pattern_start_time=modify_pattern_start_time,
@@ -657,5 +667,7 @@ def pressure_regulation(
scheme_name=scheme_name,
result_db_name=name,
scada_mappings=scada_mappings,
persist_results=persist_results,
)
return result if not persist_results else None
# return result
+66 -1
View File
@@ -16,6 +16,7 @@ from app.domain.schemas.timeseries_history import (
from app.infra.db.postgresql.analysis import AnalysisRepository
from app.infra.db.postgresql.network_settings import NetworkSettingsRepository
from app.infra.db.postgresql.scada import ScadaInfoRepository
from app.infra.db.postgresql import read_queries
from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
@@ -56,13 +57,69 @@ def _points(
]
async def _query_page(timescale_conn, postgres_conn, query, selected_devices, limit, offset):
descriptors = []
series = []
def add(*, source, element_type, element_id, device_id=None, field, unit, unit_inferred=False):
descriptors.append({
"source": source, "element_type": element_type,
"id": device_id if source == "scada" else element_id, "field": field,
})
metric = _target_metric(element_type)
series.append(ElementHistorySeries(
element_id=element_id, element_type=element_type, device_id=device_id,
metric=metric, source=(
HistorySource.SCADA_RAW if field == "monitored_value" else HistorySource.SCADA_CLEANED
) if source == "scada" else (
HistorySource.ANALYSIS_SIMULATION if source == "analysis" else HistorySource.REALTIME_SIMULATION
),
source_unit=unit, display_unit=display_unit_for_metric(metric.value),
unit_inferred=unit_inferred, points=[],
))
# Stable series ordering makes a page crossing raw/cleaned/model boundaries repeatable.
for device_id, (device, element_type, element_id) in sorted(selected_devices.items()):
for field in ("monitored_value", "cleaned_value"):
add(source="scada", element_type=element_type, element_id=element_id,
device_id=device_id, field=field, unit=device["measurement_unit"])
if query.mode.value != "observed":
units = await NetworkSettingsRepository.get_result_units(postgres_conn)
source = "analysis" if query.mode.value == "analysis_comparison" else "realtime"
inferred = False
if source == "analysis":
run = await AnalysisRepository.get_run(postgres_conn, query.run_id)
if run is None:
raise ValueError(f"analysis run does not exist: {query.run_id}")
recorded = (run.get("parameters") or {}).get("result_units")
inferred = not isinstance(recorded, dict)
if not inferred:
units = recorded
targets = {(t.element_type, t.element_id) for t in query.elements}
for element_type, element_id in sorted(targets):
metric = _target_metric(element_type)
add(source=source, element_type=element_type, element_id=element_id,
field=metric.value, unit=str(units.get(metric.value, "")).strip(), unit_inferred=inferred)
rows = await read_queries.history_rows(
timescale_conn, descriptors, start=query.start_time, end=query.end_time,
run_id=query.run_id, limit=limit + 1, offset=offset,
)
for row in rows[:limit]:
series[row["series_index"]].points.append(HistoryPoint(time=row["time"], value=row["value"]))
return ElementHistoryResponse(series=[s for s in series if s.points], truncated=len(rows) > limit)
class TimeseriesHistoryService:
@staticmethod
async def query(
timescale_conn: AsyncConnection,
postgres_conn: AsyncConnection,
query: ElementHistoryQuery,
*, row_limit: int | None = None, row_offset: int = 0,
) -> ElementHistoryResponse:
if row_offset < 0 or (row_offset and row_limit is None) or (row_limit is not None and row_limit < 1):
raise ValueError("row_offset requires a bounded page")
pipe_ids = list(
dict.fromkeys(
target.element_id
@@ -78,8 +135,10 @@ class TimeseriesHistoryService:
)
)
devices = await ScadaInfoRepository.get_scadas_for_elements(
postgres_conn, junction_ids, pipe_ids
postgres_conn, junction_ids, pipe_ids, **({"row_limit": 1001} if row_limit is not None else {})
)
if row_limit is not None and len(devices) > 1000:
raise ValueError("too many device mappings; narrow the element selection")
devices_by_element: dict[tuple[HistoryElementType, str], list[dict[str, Any]]] = (
defaultdict(list)
)
@@ -122,6 +181,12 @@ class TimeseriesHistoryService:
target.element_id,
)
if row_limit is not None and len(selected_devices) > 20:
raise ValueError("select at most 20 devices explicitly")
if row_limit is not None:
return await _query_page(
timescale_conn, postgres_conn, query, selected_devices, row_limit, row_offset,
)
scada_rows = await ScadaRepository.get_scada_by_ids_time_range(
timescale_conn,
list(selected_devices),
-93
View File
@@ -1,93 +0,0 @@
from typing import Any, Literal
import httpx
from pydantic import BaseModel, Field
from app.core.config import settings
Freshness = Literal["noLimit", "oneDay", "oneWeek", "oneMonth", "oneYear"]
class WebSearchRequest(BaseModel):
query: str = Field(..., min_length=1, description="搜索关键词")
freshness: Freshness | str = Field(
default="noLimit",
description="时间范围:noLimit、oneDay、oneWeek、oneMonth、oneYear 或日期范围",
)
summary: bool = Field(default=True, description="是否返回网页摘要")
count: int = Field(default=10, ge=1, le=50, description="返回结果数量")
include: list[str] | None = Field(default=None, description="限定搜索域名")
exclude: list[str] | None = Field(default=None, description="排除搜索域名")
class BochaSearchConfigError(RuntimeError):
pass
class BochaSearchAPIError(RuntimeError):
def __init__(self, status_code: int, detail: Any):
super().__init__("Bocha Web Search API request failed")
self.status_code = status_code
self.detail = detail
def _build_payload(request: WebSearchRequest) -> dict[str, Any]:
payload = request.model_dump(exclude_none=True)
if request.include:
payload["include"] = ",".join(request.include)
if request.exclude:
payload["exclude"] = ",".join(request.exclude)
return payload
async def search_bocha_web(
request: WebSearchRequest,
*,
client: httpx.AsyncClient | None = None,
) -> dict[str, Any]:
if not settings.BOCHA_API_KEY:
raise BochaSearchConfigError("BOCHA_API_KEY is not configured")
headers = {
"Authorization": f"Bearer {settings.BOCHA_API_KEY}",
"Content-Type": "application/json",
}
payload = _build_payload(request)
if client is not None:
response = await client.post(
settings.BOCHA_WEB_SEARCH_URL,
headers=headers,
json=payload,
)
return _parse_response(response)
async with httpx.AsyncClient(
timeout=settings.BOCHA_WEB_SEARCH_TIMEOUT_SECONDS
) as managed_client:
response = await managed_client.post(
settings.BOCHA_WEB_SEARCH_URL,
headers=headers,
json=payload,
)
return _parse_response(response)
def _parse_response(response: httpx.Response) -> dict[str, Any]:
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise BochaSearchAPIError(
exc.response.status_code,
_response_detail(exc.response),
) from exc
return response.json()
def _response_detail(response: httpx.Response) -> Any:
try:
return response.json()
except ValueError:
return response.text
+109
View File
@@ -371,3 +371,112 @@ def _timestamp(value: datetime | str) -> float:
else datetime.fromisoformat(value.replace("Z", "+00:00"))
)
return parsed.timestamp()
# Persisted runtime queries shared by REST and MCP.
from contextlib import asynccontextmanager
import logging
from pydantic import ValidationError
from app.auth.project_dependencies import ProjectContext
from app.infra.db.postgresql.analysis import AnalysisRepository
from app.infra.db.postgresql.workbench_operations import WorkbenchOperationsRepository
from app.domain.schemas.workbench_runtime import WorkbenchRuntimeSnapshot
logger = logging.getLogger(__name__)
@asynccontextmanager
async def _projection_transaction(conn):
transaction = getattr(conn, "transaction", None)
if transaction is None:
yield
return
async with transaction():
yield
async def _sensor_location_map_payload(conn, results):
node_ids = sensor_location_ids_for_map_projection(results)
if not node_ids:
return None
points = await AnalysisRepository.list_junction_map_points(conn,node_ids)
return project_sensor_location_map_payload(node_ids,points)
def _validate_documents(documents, model, label):
valid=[];rejected=0
for document in documents:
try:valid.append(model.model_validate(document).model_dump())
except ValidationError:
rejected+=1
logger.warning("Workbench %s document was rejected",label)
return valid,rejected
async def read_workbench_runtime(ctx: ProjectContext, conn):
analysis_runs: list[dict] | None = None
latest_results: list[dict] | None = None
latest_analysis_map_payload: dict | None = None
analysis_availability = "not_configured"
alert_storage_configured = False
alerts: list[dict] | None = None
schedule_availability = "not_configured"
alert_availability = "not_configured"
try:
async with _projection_transaction(conn):
analysis_runs = await AnalysisRepository.list_recent_runs(conn, limit=100)
if analysis_runs:
latest_results = await AnalysisRepository.list_recent_results(
conn, analysis_runs[0]["run_id"], limit=100
)
analysis_availability = "ready" if analysis_runs else "not_configured"
except Exception:
logger.warning("Workbench analysis projection is unavailable", exc_info=True)
analysis_runs = None
latest_results = None
analysis_availability = "degraded"
if analysis_runs and latest_results:
try:
async with _projection_transaction(conn):
latest_analysis_map_payload = await _sensor_location_map_payload(
conn, latest_results
)
except Exception:
logger.warning(
"Workbench analysis spatial references are unavailable",
exc_info=True,
)
try:
async with _projection_transaction(conn):
operations_availability = await WorkbenchOperationsRepository.availability(conn)
alert_storage_configured = operations_availability.alerts
except Exception:
logger.warning("Workbench operations storage probe is unavailable", exc_info=True)
schedule_availability = "degraded"
alert_availability = "degraded"
if alert_storage_configured:
try:
async with _projection_transaction(conn):
alert_documents = await WorkbenchOperationsRepository.list_alerts(
conn, str(ctx.project_id)
)
alerts, invalid_alerts = _validate_documents(
alert_documents, WorkbenchPersistedAlert, "alert"
)
alert_availability = "degraded" if invalid_alerts else "ready"
except Exception:
alerts = None
alert_availability = "degraded"
logger.warning("Workbench alert projection is unavailable", exc_info=True)
return await build_workbench_runtime_snapshot(
analysis_runs=analysis_runs,
latest_analysis_results=latest_results,
latest_analysis_map_payload=latest_analysis_map_payload,
alert_storage_configured=alert_storage_configured,
persisted_alerts=alerts,
scenario_availability=analysis_availability,
artifact_availability=analysis_availability,
schedule_availability=schedule_availability,
alert_availability=alert_availability,
projection_degraded=False,
)