feat(workbench): add runtime GIS and sample integrations

This commit is contained in:
2026-09-22 10:44:31 +08:00
parent a3b4e75317
commit 7ab9b36a5e
62 changed files with 6685 additions and 51 deletions
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
class DomainError(Exception):
"""A domain rejection with a stable API error payload."""
def __init__(self, code: str, message: str, *, refs: list | None = None,
recovery: str = "none", status: int = 422):
super().__init__(message)
self.code, self.message, self.status = code, message, status
self.refs, self.recovery = refs or [], recovery
def payload(self) -> dict:
return {"code": self.code, "message": self.message,
"retryable": self.recovery == "retry_same_request",
"related_refs": self.refs, "recovery_action": self.recovery,
"retry_after_seconds": None}
def require(condition: bool, code: str, message: str, **kwargs) -> None:
if not condition:
raise DomainError(code, message, **kwargs)
+47
View File
@@ -0,0 +1,47 @@
"""Schemas for project-scoped source GIS attributes."""
from datetime import date
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
GisElementType = Literal[
"pipes",
"junctions",
"valves",
"reservoirs",
"pumps",
"tanks",
"scada",
]
class GisAttributeGroup(BaseModel):
model_config = ConfigDict(extra="forbid")
dataset_key: str
dataset_title: str
source_date: date
layer_key: str
layer_name: str
source_fid: int
match_method: Literal["node_pair", "geometry", "source"]
match_distance_m: float | None = None
attributes: dict[str, Any] = Field(default_factory=dict)
class ElementGisProperties(BaseModel):
model_config = ConfigDict(extra="forbid")
element_type: GisElementType
element_id: str
groups: list[GisAttributeGroup] = Field(default_factory=list)
class SourceGisFeatureProperties(BaseModel):
model_config = ConfigDict(extra="forbid")
layer_key: str
source_fid: int
group: GisAttributeGroup
+3 -1
View File
@@ -25,7 +25,7 @@ class SensorPlacementOptimizeRequest(BaseModel):
pattern=r"^[^/\\\x00]+$",
)
run_name: str = Field(..., min_length=1, max_length=64)
sensor_type: Literal["pressure"]
sensor_type: Literal["pressure", "water_quality"]
method: Literal["sensitivity", "kmeans"]
sensor_count: int = Field(..., gt=0, le=200)
min_diameter: int = Field(default=0, ge=0)
@@ -89,6 +89,8 @@ class SensorPlacementSchemeResponse(BaseModel):
created_by: str
created_at: datetime
status: str
sensor_type: Literal["pressure", "water_quality"] = "pressure"
method: Literal["sensitivity", "kmeans"] | None = None
sensor_locations: list[str]
sensor_points: list[SensorPointResponse]
can_edit: bool = False
+58
View File
@@ -0,0 +1,58 @@
"""Small strict value types shared by independent backend features."""
from datetime import datetime
from typing import Annotated, Literal
from uuid import UUID
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, StringConstraints, model_validator
NonEmptyStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
StrictBool = Annotated[bool, Field(strict=True)]
PositiveInt = Annotated[int, Field(strict=True, ge=1)]
NonNegativeInt = Annotated[int, Field(strict=True, ge=0)]
def _datetime_wire_value(value: object) -> datetime | str:
if not isinstance(value, (datetime, str)):
raise ValueError("datetime input must be an RFC3339 string or datetime instance")
return value
WireDateTime = Annotated[datetime, BeforeValidator(_datetime_wire_value)]
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
ObjectType = Literal[
"analysis_task",
"goal",
"state_snapshot",
"action_contract",
"validation",
"run",
"outcome",
"evidence",
"artifact",
"operation",
]
class ObjectRef(StrictModel):
object_type: ObjectType
object_id: UUID
revision: PositiveInt
class TimeRange(StrictModel):
start: WireDateTime
end: WireDateTime
@model_validator(mode="after")
def validate_range(self) -> "TimeRange":
if self.start.tzinfo is None or self.end.tzinfo is None:
raise ValueError("timestamps must include a timezone")
if self.start >= self.end:
raise ValueError("start must be before end")
return self
+38
View File
@@ -0,0 +1,38 @@
"""Idempotent alert events emitted by scheduled and other backend tasks."""
from __future__ import annotations
from typing import Literal
from uuid import UUID
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictModel
from app.domain.schemas.workbench_runtime import WorkbenchAlertSource, WorkbenchDateTime
class TaskAlertCommand(StrictModel):
event_id: UUID
alert_id: NonEmptyStr
event_type: Literal["raised", "acknowledged", "resolved"]
occurred_at: WorkbenchDateTime
condition_id: NonEmptyStr | None
source: WorkbenchAlertSource
title: NonEmptyStr
description: NonEmptyStr
severity: Literal["warning", "critical"]
class TaskAlertEvent(TaskAlertCommand):
producer_id: NonEmptyStr
class TaskAlertState(StrictModel):
alert_id: NonEmptyStr
revision: PositiveInt
producer_id: NonEmptyStr
condition_id: NonEmptyStr | None
source: WorkbenchAlertSource
title: NonEmptyStr
description: NonEmptyStr
occurred_at: WorkbenchDateTime
severity: Literal["warning", "critical"]
status: Literal["active", "acknowledged", "resolved"]
updated_at: WorkbenchDateTime
+209
View File
@@ -0,0 +1,209 @@
"""Typed read-only projections consumed by the supply workbench."""
from typing import Annotated, Literal
from pydantic import AwareDatetime, BeforeValidator, JsonValue, model_validator
from app.domain.schemas.shared import (
NonEmptyStr,
NonNegativeInt,
PositiveInt,
StrictBool,
StrictModel,
)
def _workbench_datetime_wire_value(value: object):
from datetime import datetime
if not isinstance(value, (datetime, str)):
raise ValueError("datetime input must be an RFC3339 string or datetime instance")
return value
WorkbenchDateTime = Annotated[
AwareDatetime, BeforeValidator(_workbench_datetime_wire_value)
]
ProjectionAvailability = Literal["ready", "not_configured", "degraded"]
class WorkbenchRuntimeCondition(StrictModel):
id: NonEmptyStr
task_id: Literal["backend-schedule"]
session_id: NonEmptyStr
title: NonEmptyStr
summary: NonEmptyStr
status: Literal["pending", "running", "completed", "warning", "error"]
risk_level: Literal["normal", "attention", "critical"]
scheduled_at: WorkbenchDateTime
updated_at: WorkbenchDateTime
detail: NonEmptyStr
recommendation: NonEmptyStr | None
evidence: list[NonEmptyStr]
model_name: Literal["TJWater scheduler"]
class WorkbenchAlertSource(StrictModel):
task_type: Literal["scheduled_task", "analysis_task", "other_task"]
task_id: NonEmptyStr
run_id: NonEmptyStr
class WorkbenchRuntimeAlert(StrictModel):
id: NonEmptyStr
condition_id: NonEmptyStr | None
source: WorkbenchAlertSource
title: NonEmptyStr
description: NonEmptyStr
occurred_at: WorkbenchDateTime
severity: Literal["warning", "critical"]
class WorkbenchPersistedAlert(StrictModel):
alert_id: NonEmptyStr
condition_id: NonEmptyStr | None
source: WorkbenchAlertSource
title: NonEmptyStr
description: NonEmptyStr
occurred_at: WorkbenchDateTime
severity: Literal["warning", "critical"]
status: Literal["active", "acknowledged", "resolved"]
producer_id: NonEmptyStr | None = None
updated_at: WorkbenchDateTime | None = None
class WorkbenchRuntimeScenario(StrictModel):
id: NonEmptyStr
run_id: NonEmptyStr
name: NonEmptyStr
description: NonEmptyStr
status: Literal["draft", "active", "review"]
run_type: NonEmptyStr
run_type_label: NonEmptyStr
run_status: NonEmptyStr
created_at: WorkbenchDateTime
class WorkbenchRuntimeAnalysisFact(StrictModel):
label: NonEmptyStr
value: NonEmptyStr
class WorkbenchRuntimeAnalysisResult(StrictModel):
result_type: NonEmptyStr
created_at: WorkbenchDateTime
facts: list[WorkbenchRuntimeAnalysisFact]
class WorkbenchRuntimeLatestAnalysis(StrictModel):
run_id: NonEmptyStr
name: NonEmptyStr
run_type: NonEmptyStr
run_type_label: NonEmptyStr
status: NonEmptyStr
status_label: NonEmptyStr
created_by: NonEmptyStr
created_at: WorkbenchDateTime
started_at: WorkbenchDateTime
results: list[WorkbenchRuntimeAnalysisResult]
map_payload: dict[str, JsonValue] | None
class WorkbenchAnalysisHistoryItem(StrictModel):
run_id: NonEmptyStr
name: NonEmptyStr
run_type: NonEmptyStr
run_type_label: NonEmptyStr
status: NonEmptyStr
status_label: NonEmptyStr
created_by: NonEmptyStr
created_at: WorkbenchDateTime
started_at: WorkbenchDateTime
has_results: StrictBool
class WorkbenchAnalysisHistoryPage(StrictModel):
items: list[WorkbenchAnalysisHistoryItem]
limit: PositiveInt
offset: NonNegativeInt
next_offset: NonNegativeInt | None
class WorkbenchRuntimeWorkOrderStage(StrictModel):
id: NonEmptyStr
sequence: PositiveInt
title: NonEmptyStr
status: Literal["done", "current", "pending", "blocked", "skipped"]
owner: NonEmptyStr
time_label: NonEmptyStr
description: NonEmptyStr
class WorkbenchRuntimeWorkOrderSubtask(StrictModel):
id: NonEmptyStr
title: NonEmptyStr
summary: NonEmptyStr
status: Literal["pending", "running", "blocked", "completed", "cancelled"]
scheduled_at: WorkbenchDateTime
location: NonEmptyStr
assignee: NonEmptyStr
duration_minutes: PositiveInt
reply_window_minutes: PositiveInt
stages: list[WorkbenchRuntimeWorkOrderStage]
reply_requirements: list[NonEmptyStr]
@model_validator(mode="after")
def validate_stages(self):
if not self.stages:
raise ValueError("subtask stages must not be empty")
if [stage.sequence for stage in self.stages] != list(
range(1, len(self.stages) + 1)
):
raise ValueError("subtask stages must use contiguous sequence numbers")
if len({stage.id for stage in self.stages}) != len(self.stages):
raise ValueError("subtask stage ids must be unique")
if sum(stage.status == "current" for stage in self.stages) > 1:
raise ValueError("subtask stages may contain at most one current stage")
return self
class WorkbenchRuntimeWorkOrder(StrictModel):
id: NonEmptyStr
code: NonEmptyStr
scheduled_at: WorkbenchDateTime
title: NonEmptyStr
summary: NonEmptyStr
status: Literal["pending", "running", "awaiting_reply", "completed"]
risk_level: Literal["normal", "attention", "critical"]
updated_at: WorkbenchDateTime
detail: NonEmptyStr
recommendation: NonEmptyStr | None
duration_minutes: PositiveInt
source: NonEmptyStr
dispatcher: NonEmptyStr
priority: Literal["normal", "urgent"]
subtasks: list[WorkbenchRuntimeWorkOrderSubtask]
@model_validator(mode="after")
def validate_subtasks(self):
if not self.subtasks:
raise ValueError("work order subtasks must not be empty")
if len({subtask.id for subtask in self.subtasks}) != len(self.subtasks):
raise ValueError("work order subtask ids must be unique")
return self
class WorkbenchRuntimeSnapshot(StrictModel):
availability: ProjectionAvailability
source: Literal["none", "scheduled_tasks"]
scenario_availability: ProjectionAvailability
artifact_availability: ProjectionAvailability
schedule_availability: ProjectionAvailability
work_order_availability: ProjectionAvailability
alert_availability: ProjectionAvailability
generated_at: WorkbenchDateTime
conditions: list[WorkbenchRuntimeCondition]
alerts: list[WorkbenchRuntimeAlert]
scenarios: list[WorkbenchRuntimeScenario]
latest_analysis: WorkbenchRuntimeLatestAnalysis | None
work_orders: list[WorkbenchRuntimeWorkOrder]