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:
@@ -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)
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
@@ -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"])
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user