feat: extend operational investigation workflows
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
"""Topology-only valve isolation search."""
|
||||
|
||||
from collections import defaultdict, deque
|
||||
from typing import Any, Iterable
|
||||
from typing import Any, Iterable, Literal
|
||||
|
||||
|
||||
VALVE_LINK_TYPE = "valve"
|
||||
AccidentElementType = Literal["auto", "link", "node"]
|
||||
|
||||
|
||||
def _parse_link_entry(link_entry: str) -> tuple[str, str, str, str]:
|
||||
@@ -14,12 +15,37 @@ def _parse_link_entry(link_entry: str) -> tuple[str, str, str, str]:
|
||||
return parts[0], parts[1], parts[2], parts[3]
|
||||
|
||||
|
||||
def _reachable_nodes(
|
||||
adjacency: dict[str, list[tuple[str, str]]],
|
||||
start_nodes: Iterable[str],
|
||||
blocked_links: set[str],
|
||||
) -> set[str]:
|
||||
visited = set(start_nodes)
|
||||
queue = deque(visited)
|
||||
while queue:
|
||||
for neighbor, link_id in adjacency.get(queue.popleft(), ()):
|
||||
if link_id not in blocked_links and neighbor not in visited:
|
||||
visited.add(neighbor)
|
||||
queue.append(neighbor)
|
||||
return visited
|
||||
|
||||
|
||||
def valve_isolation_analysis(
|
||||
link_entries: Iterable[str],
|
||||
accident_elements: str | list[str],
|
||||
disabled_valves: list[str] | None = None,
|
||||
*,
|
||||
accident_element_type: AccidentElementType = "auto",
|
||||
water_source_nodes: Iterable[str] | None = None,
|
||||
closed_links: Iterable[str] = (),
|
||||
) -> dict[str, Any]:
|
||||
"""Determine boundary valves and affected nodes from a topology snapshot."""
|
||||
"""Find nearest valve boundaries, then check source connectivity.
|
||||
|
||||
Legacy untyped IDs prefer links; explicit node mode handles cross-type ID
|
||||
collisions. Supply impact is a before/after connectivity difference, not
|
||||
a hydraulic pressure or demand calculation. Without source metadata only
|
||||
the provisional boundary search is available.
|
||||
"""
|
||||
|
||||
disabled_valves_set = set(disabled_valves or [])
|
||||
target_elements = (
|
||||
@@ -27,8 +53,13 @@ def valve_isolation_analysis(
|
||||
if isinstance(accident_elements, str)
|
||||
else accident_elements
|
||||
)
|
||||
if not target_elements:
|
||||
raise ValueError("At least one accident element is required")
|
||||
if accident_element_type not in {"auto", "link", "node"}:
|
||||
raise ValueError(f"Invalid accident element type: {accident_element_type}")
|
||||
closed_links_set = set(closed_links)
|
||||
|
||||
pipe_adj: dict[str, set[str]] = defaultdict(set)
|
||||
adjacency: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||
all_valves: dict[str, tuple[str, str]] = {}
|
||||
link_lookup: dict[str, tuple[str, str, str]] = {}
|
||||
node_set: set[str] = set()
|
||||
@@ -39,46 +70,31 @@ def valve_isolation_analysis(
|
||||
node_set.update((node1, node2))
|
||||
if link_type_name == VALVE_LINK_TYPE:
|
||||
all_valves[link_id] = (node1, node2)
|
||||
else:
|
||||
pipe_adj[node1].add(node2)
|
||||
pipe_adj[node2].add(node1)
|
||||
if link_id not in closed_links_set:
|
||||
adjacency[node1].append((node2, link_id))
|
||||
adjacency[node2].append((node1, link_id))
|
||||
|
||||
unknown_disabled = disabled_valves_set - all_valves.keys()
|
||||
if unknown_disabled:
|
||||
raise ValueError(f"Disabled valve was not found in topology: {sorted(unknown_disabled)}")
|
||||
|
||||
start_nodes: set[str] = set()
|
||||
for element in target_elements:
|
||||
if element in node_set:
|
||||
start_nodes.add(element)
|
||||
elif element in link_lookup:
|
||||
if accident_element_type != "node" and element in link_lookup:
|
||||
node1, node2, _ = link_lookup[element]
|
||||
start_nodes.update((node1, node2))
|
||||
elif accident_element_type != "link" and element in node_set:
|
||||
start_nodes.add(element)
|
||||
else:
|
||||
raise ValueError(f"Accident element {element} was not found in topology")
|
||||
|
||||
extra_adj: dict[str, list[str]] = defaultdict(list)
|
||||
boundary_valves: dict[str, tuple[str, str]] = {}
|
||||
for valve_id, (node1, node2) in all_valves.items():
|
||||
if valve_id in disabled_valves_set:
|
||||
extra_adj[node1].append(node2)
|
||||
extra_adj[node2].append(node1)
|
||||
else:
|
||||
boundary_valves[valve_id] = (node1, node2)
|
||||
|
||||
affected_nodes: set[str] = set()
|
||||
queue = deque(start_nodes)
|
||||
while queue:
|
||||
node = queue.popleft()
|
||||
if node in affected_nodes:
|
||||
continue
|
||||
affected_nodes.add(node)
|
||||
queue.extend(pipe_adj.get(node, set()) - affected_nodes)
|
||||
queue.extend(
|
||||
neighbor
|
||||
for neighbor in extra_adj.get(node, ())
|
||||
if neighbor not in affected_nodes
|
||||
)
|
||||
closable_valves = set(all_valves) - disabled_valves_set - closed_links_set
|
||||
affected_nodes = _reachable_nodes(adjacency, start_nodes, closable_valves)
|
||||
|
||||
must_close_valves: list[str] = []
|
||||
optional_valves: list[str] = []
|
||||
for valve_id, (node1, node2) in boundary_valves.items():
|
||||
for valve_id in closable_valves:
|
||||
node1, node2 = all_valves[valve_id]
|
||||
node1_affected = node1 in affected_nodes
|
||||
node2_affected = node2 in affected_nodes
|
||||
if node1_affected and node2_affected:
|
||||
@@ -89,14 +105,37 @@ def valve_isolation_analysis(
|
||||
must_close_valves.sort()
|
||||
optional_valves.sort()
|
||||
isolatable = bool(must_close_valves)
|
||||
failure_reason = None if isolatable else "no_boundary_valves"
|
||||
sources = set(water_source_nodes or ())
|
||||
source_check_performed = bool(sources)
|
||||
supply_affected_nodes = None
|
||||
if water_source_nodes is not None:
|
||||
if not sources:
|
||||
isolatable = False
|
||||
failure_reason = "water_sources_unavailable"
|
||||
else:
|
||||
after_closure = _reachable_nodes(adjacency, sources, set(must_close_valves))
|
||||
if affected_nodes & after_closure:
|
||||
isolatable = False
|
||||
failure_reason = "connected_to_water_source"
|
||||
if isolatable:
|
||||
before_closure = _reachable_nodes(adjacency, sources, set())
|
||||
supply_affected_nodes = sorted(before_closure - after_closure)
|
||||
result: dict[str, Any] = {
|
||||
"accident_elements": target_elements,
|
||||
"accident_element_type": accident_element_type,
|
||||
"disabled_valves": disabled_valves,
|
||||
"affected_nodes": sorted(affected_nodes) if isolatable else [],
|
||||
"affected_node_count": len(affected_nodes),
|
||||
"must_close_valves": must_close_valves,
|
||||
"optional_valves": optional_valves,
|
||||
"isolatable": isolatable,
|
||||
"source_check_performed": source_check_performed,
|
||||
"isolation_failure_reason": failure_reason,
|
||||
"supply_affected_nodes": supply_affected_nodes,
|
||||
"supply_affected_node_count": (
|
||||
len(supply_affected_nodes) if supply_affected_nodes is not None else None
|
||||
),
|
||||
}
|
||||
if len(target_elements) == 1:
|
||||
result["accident_element"] = target_elements[0]
|
||||
|
||||
@@ -26,6 +26,8 @@ from app.auth.project_dependencies import (
|
||||
from app.domain.domain_errors import DomainError
|
||||
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
|
||||
from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot
|
||||
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply, TaskQuestionRequest
|
||||
from app.services.task_questions import create_question, answer_question
|
||||
from app.domain.schemas.operational_task import (
|
||||
ActionDecisionCommand,
|
||||
ActionExecuteCommand,
|
||||
@@ -74,6 +76,7 @@ from app.domain.schemas.operational_task import (
|
||||
TaskPresentationEventCommand,
|
||||
TaskPresentationEventResult,
|
||||
TaskPresentationCursor,
|
||||
TaskPresentationHistoryPage,
|
||||
)
|
||||
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||
from app.infra.integrations.operational_actions import (
|
||||
@@ -521,6 +524,21 @@ async def get_operational_task_presentation_cursor(
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.get("/operational-tasks/{task_id}/presentation-events", response_model=TaskPresentationHistoryPage)
|
||||
async def get_operational_task_presentation_history(
|
||||
task_id: UUID,
|
||||
after: int = Query(default=0, ge=0),
|
||||
through: int | None = Query(default=None, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).presentation_history(task_id, after=after, through=through, limit=limit)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.post("/operational-tasks/{task_id}/presentation-events", response_model=TaskPresentationEventResult)
|
||||
async def publish_operational_task_presentation_event(
|
||||
task_id: UUID,
|
||||
@@ -771,3 +789,27 @@ async def withdraw_operational_task_external_source(
|
||||
return await _service(ctx, conn).withdraw_external_source(task_id, command)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.post("/operational-tasks/{task_id}/questions", response_model=TaskQuestionRequest)
|
||||
async def create_operational_task_question(
|
||||
task_id: UUID, command: TaskQuestionCreate,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await create_question(_service(ctx, conn), task_id, command)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.post("/operational-tasks/{task_id}/questions/{question_id}/answer", response_model=OperationalTaskDetail)
|
||||
async def answer_operational_task_question(
|
||||
task_id: UUID, question_id: UUID, command: TaskQuestionReply,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await answer_question(_service(ctx, conn), task_id, question_id, command)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
@@ -161,6 +161,9 @@ def valve_isolation_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
accident_element: List[str] = Query(..., description="发生事故的管段/节点ID列表"),
|
||||
disabled_valves: List[str] = Query(None, description="已故障的阀门ID列表(可选)"),
|
||||
accident_element_type: Literal["auto", "link", "node"] = Query(
|
||||
"auto", description="事故要素类型;auto优先管段再节点;分析同名节点须指定node,纯管段分析可指定link",
|
||||
),
|
||||
):
|
||||
"""
|
||||
阀门隔离分析
|
||||
@@ -168,13 +171,19 @@ def valve_isolation_endpoint(
|
||||
- **network**: 管网名称(或数据库名称)
|
||||
- **accident_element**: 发生事故的管段/节点ID列表
|
||||
- **disabled_valves**: 已故障的阀门ID列表(可选)
|
||||
- **accident_element_type**: auto优先管段;link仅匹配管段;node仅匹配节点
|
||||
|
||||
返回隔离方案,包括:
|
||||
- must_close_valves: 必须关闭的阀门列表
|
||||
- optional_valves: 可选关闭的阀门列表
|
||||
- affected_nodes: 受影响的节点列表;不可隔离时为空列表
|
||||
- affected_node_count: 受影响的节点总数
|
||||
- isolatable: 是否可以有效隔离
|
||||
- affected_nodes / affected_node_count: 最近阀门围成的直接隔离区;不可隔离时列表为空,计数保留
|
||||
- supply_affected_nodes / supply_affected_node_count: 按建议关阀后新增失去水源连接的节点;未验证或不可隔离时为null
|
||||
- source_check_performed: 是否已执行水源连通检查
|
||||
- isolation_failure_reason: 不可隔离原因;可隔离时为null
|
||||
- isolatable: 存在关阀边界,且直接隔离区不再连通任何水源
|
||||
|
||||
使用模型初始开关状态进行无向拓扑分析,水池按潜在水源处理。
|
||||
不包含时变控制、水池蓄水量或压力流量计算,不是现场停水范围的水力证明。
|
||||
"""
|
||||
# result = {
|
||||
# "accident_element": "P461309",
|
||||
@@ -193,7 +202,10 @@ def valve_isolation_endpoint(
|
||||
# "must_close_valves": ["210521658", "V12974", "V12986", "V12993"],
|
||||
# "optional_valves": [],
|
||||
# }
|
||||
result = analyze_valve_isolation(network, accident_element, disabled_valves)
|
||||
result = analyze_valve_isolation(
|
||||
network, accident_element, disabled_valves,
|
||||
accident_element_type=accident_element_type,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from jsonschema import Draft7Validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_serializer, model_validator
|
||||
|
||||
_catalog = json.loads(
|
||||
(Path(__file__).resolve().parents[3] / "contracts/answer-ui-catalog.json").read_text()
|
||||
@@ -34,9 +34,24 @@ class AnswerUiSpec(UiModel):
|
||||
class AnswerUiBlock(UiModel):
|
||||
version: Literal["answer-ui@1"]
|
||||
block_id: Key
|
||||
phase: Literal["process", "final"] | None = None
|
||||
fallback_text: Annotated[str, Field(min_length=1, max_length=4000)]
|
||||
spec: AnswerUiSpec
|
||||
|
||||
@field_validator("phase", mode="before")
|
||||
@classmethod
|
||||
def validate_explicit_phase(cls, value):
|
||||
if value is None:
|
||||
raise ValueError("UI phase must be process or final when supplied")
|
||||
return value
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def serialize_optional_phase(self, handler):
|
||||
data = handler(self)
|
||||
if self.phase is None:
|
||||
data.pop("phase", None)
|
||||
return data
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_components(self):
|
||||
spec = self.spec
|
||||
@@ -83,6 +98,12 @@ class ReportUiBlock(UiModel):
|
||||
block: AnswerUiBlock
|
||||
evidence_refs: list[Annotated[str, Field(min_length=1, max_length=128)]] = Field(min_length=1, max_length=100)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_report_phase(self):
|
||||
if self.block.phase == "process":
|
||||
raise ValueError("Report UI blocks must contain final results")
|
||||
return self
|
||||
|
||||
|
||||
def validate_report_ui_blocks(blocks: list[ReportUiBlock], evidence_refs: list[str]) -> None:
|
||||
ids = [item.block.block_id for item in blocks]
|
||||
|
||||
@@ -8,6 +8,7 @@ from uuid import UUID
|
||||
from pydantic import Field, JsonValue, model_validator
|
||||
from app.domain.schemas.answer_ui import ReportUiBlock, validate_report_ui_blocks
|
||||
|
||||
from app.domain.schemas.task_question import TaskQuestionRequest
|
||||
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
|
||||
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
|
||||
from app.domain.schemas.workbench_runtime import (
|
||||
@@ -218,8 +219,12 @@ class InvestigationDiagramNodeRef(StrictModel):
|
||||
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
|
||||
action_refs: list[UUID] = Field(default_factory=list)
|
||||
status: Literal[
|
||||
"pending", "running", "completed", "waiting_human", "failed", "skipped", "cancelled"
|
||||
] | None = None
|
||||
"pending", "running", "completed", "waiting", "waiting_human", "failed", "skipped", "cancelled"
|
||||
] | None = Field(default=None, description=(
|
||||
"Evidence-confirmed node state; waiting means prerequisites/external/system result, "
|
||||
"waiting_human requires manual authorization/approval/feedback. "
|
||||
"Unknown stays omitted/null; host controls colors."
|
||||
))
|
||||
map_targets: list[InvestigationMapTarget] = Field(default_factory=list, max_length=100)
|
||||
|
||||
|
||||
@@ -240,6 +245,21 @@ class InvestigationDiagram(StrictModel):
|
||||
return self
|
||||
|
||||
|
||||
def _undeclared_diagram_node_refs(diagram: InvestigationDiagram | None) -> list[str]:
|
||||
if diagram is None:
|
||||
return []
|
||||
missing: list[str] = []
|
||||
for node in diagram.node_refs:
|
||||
escaped = re.escape(node.node_id)
|
||||
if not re.search(
|
||||
rf"(?<![A-Za-z0-9_-]){escaped}(?=\s*(?:[\[({{@;]|--|==|-\.|$))",
|
||||
diagram.mermaid,
|
||||
re.MULTILINE,
|
||||
):
|
||||
missing.append(node.node_id)
|
||||
return missing
|
||||
|
||||
|
||||
class InvestigationReport(StrictModel):
|
||||
"""Read model: absent historical facts remain unknown, never inferred."""
|
||||
|
||||
@@ -341,9 +361,19 @@ class AgentArtifactsCommand(StrictModel):
|
||||
progress: InvestigationProgress | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_artifact(self):
|
||||
def validate_artifacts(self):
|
||||
if not self.evidence and self.plan is None and self.report is None and self.progress is None:
|
||||
raise ValueError("at least one artifact must be supplied")
|
||||
for diagram in (
|
||||
self.report.diagram if self.report is not None else None,
|
||||
self.progress.diagram if self.progress is not None else None,
|
||||
):
|
||||
missing = _undeclared_diagram_node_refs(diagram)
|
||||
if missing:
|
||||
raise ValueError(
|
||||
"diagram node references must use ids declared in Mermaid source: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
@@ -502,7 +532,7 @@ class ExternalWorkOrderSnapshot(StrictModel):
|
||||
scenario_code: NonEmptyStr | None = None
|
||||
feedback_requirements: list[NonEmptyStr] = Field(default_factory=list)
|
||||
activity: list[WorkOrderActivity] = Field(default_factory=list)
|
||||
origin: Literal["synthetic"]
|
||||
origin: Literal["system_data"]
|
||||
updated_at: WorkbenchDateTime
|
||||
|
||||
@model_validator(mode="before")
|
||||
@@ -521,6 +551,7 @@ class OperationalTaskRun(StrictModel):
|
||||
status: OperationalTaskRunStatus
|
||||
revision: PositiveInt
|
||||
agent_session_id: NonEmptyStr | None = None
|
||||
agent_instance_id: NonEmptyStr | None = None
|
||||
lease_expires_at: WorkbenchDateTime | None = None
|
||||
heartbeat_at: WorkbenchDateTime | None = None
|
||||
started_at: WorkbenchDateTime | None = None
|
||||
@@ -570,6 +601,7 @@ class OperationalTaskPermissions(StrictModel):
|
||||
can_decide_commands: bool
|
||||
can_execute_commands: bool
|
||||
can_decide_access: bool = False
|
||||
can_answer_questions: bool = False
|
||||
|
||||
|
||||
class OperationalTaskDetail(OperationalTaskSummary):
|
||||
@@ -579,6 +611,7 @@ class OperationalTaskDetail(OperationalTaskSummary):
|
||||
evidence: list[EvidenceRecord]
|
||||
actions: list[HumanApprovalAction]
|
||||
access_requests: list[TaskAccessRequest] = Field(default_factory=list)
|
||||
questions: list[TaskQuestionRequest] = Field(default_factory=list)
|
||||
permissions: OperationalTaskPermissions
|
||||
work_order_snapshot: ExternalWorkOrderSnapshot | None = None
|
||||
|
||||
@@ -605,6 +638,7 @@ class AgentRunCommand(StrictModel):
|
||||
request_id: UUID
|
||||
expected_revision: PositiveInt
|
||||
source_session_id: NonEmptyStr
|
||||
agent_instance_id: NonEmptyStr | None = None
|
||||
|
||||
|
||||
class AgentRunFailureCommand(AgentRunCommand):
|
||||
@@ -671,7 +705,59 @@ class TaskHighlightFeatures(StrictModel):
|
||||
action: Literal["highlight_features"]
|
||||
layer: TaskMapLayer
|
||||
feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
|
||||
effect: Literal["steady", "pulse"] = "steady"
|
||||
effect: Literal["steady", "pulse"]
|
||||
|
||||
|
||||
class TaskLocateFeatures(StrictModel):
|
||||
action: Literal["locate_features"]
|
||||
layer: TaskMapLayer
|
||||
feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
|
||||
|
||||
|
||||
class TaskZoomToMap(StrictModel):
|
||||
action: Literal["zoom_to_map"]
|
||||
center: tuple[
|
||||
Annotated[float, Field(strict=True, ge=-180, le=180, allow_inf_nan=False)],
|
||||
Annotated[float, Field(strict=True, ge=-90, le=90, allow_inf_nan=False)],
|
||||
]
|
||||
zoom: Annotated[float, Field(strict=True, ge=0, le=24, allow_inf_nan=False)]
|
||||
|
||||
|
||||
class TaskScadaAnalysisItem(StrictModel):
|
||||
device_id: TaskFeatureId
|
||||
level: Literal["high", "medium", "low", "unrated"]
|
||||
|
||||
|
||||
class TaskRenderScadaAnalysis(StrictModel):
|
||||
action: Literal["render_scada_analysis"]
|
||||
items: Annotated[list[TaskScadaAnalysisItem], Field(min_length=1, max_length=100)]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_unique_devices(self):
|
||||
if len({item.device_id for item in self.items}) != len(self.items):
|
||||
raise ValueError("SCADA analysis device IDs must be unique")
|
||||
return self
|
||||
|
||||
|
||||
class TaskClearScadaAnalysis(StrictModel):
|
||||
action: Literal["clear_scada_analysis"]
|
||||
|
||||
|
||||
class TaskOverlayGeometry(StrictModel):
|
||||
type: Literal["Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon"]
|
||||
coordinates: list[JsonValue]
|
||||
|
||||
|
||||
class TaskOverlayFeature(StrictModel):
|
||||
type: Literal["Feature"]
|
||||
geometry: TaskOverlayGeometry
|
||||
properties: dict[str, JsonValue] | None
|
||||
id: Annotated[str, Field(strict=True)] | Annotated[float, Field(strict=True, allow_inf_nan=False)] | None = None
|
||||
|
||||
|
||||
class TaskOverlayData(StrictModel):
|
||||
type: Literal["FeatureCollection"]
|
||||
features: Annotated[list[TaskOverlayFeature], Field(min_length=1, max_length=1000)]
|
||||
|
||||
|
||||
class TaskClearHighlights(StrictModel):
|
||||
@@ -696,6 +782,7 @@ class TaskSetOverlay(StrictModel):
|
||||
title: Annotated[str, Field(min_length=1, max_length=200)]
|
||||
mode: Literal["geometry", "heatmap"]
|
||||
evidence_id: NonEmptyStr
|
||||
data: TaskOverlayData | None = None
|
||||
|
||||
|
||||
class TaskClearOverlay(StrictModel):
|
||||
@@ -704,7 +791,8 @@ class TaskClearOverlay(StrictModel):
|
||||
|
||||
|
||||
TaskPresentationCommand = Annotated[
|
||||
TaskHighlightFeatures | TaskClearHighlights | TaskOpenChart | TaskCloseChart | TaskSetOverlay | TaskClearOverlay,
|
||||
TaskHighlightFeatures | TaskLocateFeatures | TaskZoomToMap | TaskRenderScadaAnalysis
|
||||
| TaskClearScadaAnalysis | TaskClearHighlights | TaskOpenChart | TaskCloseChart | TaskSetOverlay | TaskClearOverlay,
|
||||
Field(discriminator="action"),
|
||||
]
|
||||
|
||||
@@ -736,6 +824,23 @@ class TaskPresentationCursor(StrictModel):
|
||||
event_cursor: int = Field(ge=0)
|
||||
|
||||
|
||||
class TaskPresentationHistoryItem(StrictModel):
|
||||
event_seq: PositiveInt
|
||||
run_id: UUID
|
||||
execution_generation: PositiveInt
|
||||
occurred_at: WorkbenchDateTime
|
||||
# Stored records are validated against the current command contract by the
|
||||
# reader; an obsolete record must not prevent reading the rest of the page.
|
||||
command: dict[str, JsonValue]
|
||||
|
||||
|
||||
class TaskPresentationHistoryPage(StrictModel):
|
||||
task_id: UUID
|
||||
event_cursor: int = Field(ge=0)
|
||||
items: list[TaskPresentationHistoryItem]
|
||||
next_after: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class TaskDataQueryResult(StrictModel):
|
||||
evidence: EvidenceRecord
|
||||
snapshot: TaskDataSnapshot
|
||||
@@ -816,7 +921,7 @@ class OperationalTaskNotification(StrictModel):
|
||||
description: NonEmptyStr
|
||||
required_permission: NonEmptyStr | None = None
|
||||
lifecycle_status: Literal["active", "resolved"] = "active"
|
||||
subject_type: Literal["task", "run", "access_request", "action"] = "task"
|
||||
subject_type: Literal["task", "run", "access_request", "action", "question"] = "task"
|
||||
subject_id: UUID | None = None
|
||||
report_reference: InvestigationReportReference | None = None
|
||||
resolved_at: WorkbenchDateTime | None = None
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Durable investigation questions; answers are user input, not field evidence."""
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from app.domain.schemas.shared import StrictModel, PositiveInt
|
||||
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
|
||||
|
||||
Id = Annotated[str, Field(min_length=1, max_length=128)]
|
||||
|
||||
|
||||
class TaskQuestionOption(StrictModel):
|
||||
id: Id
|
||||
label: Annotated[str, Field(min_length=1, max_length=200)]
|
||||
|
||||
|
||||
class TaskQuestion(StrictModel):
|
||||
question_id: Id
|
||||
title: Annotated[str, Field(min_length=1, max_length=200)]
|
||||
body: Annotated[str, Field(min_length=1, max_length=2000)]
|
||||
options: list[TaskQuestionOption] = Field(max_length=12)
|
||||
mode: Literal["single", "multiple"]
|
||||
allow_custom: bool
|
||||
required: bool
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_options(self):
|
||||
if len({o.id for o in self.options}) != len(self.options):
|
||||
raise ValueError("duplicate option ids")
|
||||
if not self.options and not self.allow_custom:
|
||||
raise ValueError("question must offer an answer input")
|
||||
return self
|
||||
|
||||
|
||||
class TaskQuestionAnswer(StrictModel):
|
||||
question_id: Id
|
||||
selected_option_ids: list[Id] = Field(max_length=12)
|
||||
custom_text: Annotated[str, Field(max_length=2000)]
|
||||
|
||||
|
||||
class TaskQuestionCreate(StrictModel):
|
||||
request_id: UUID
|
||||
source_session_id: Id
|
||||
run_id: UUID
|
||||
questions: list[TaskQuestion] = Field(min_length=1, max_length=3)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def unique_questions(self):
|
||||
if len({q.question_id for q in self.questions}) != len(self.questions):
|
||||
raise ValueError("duplicate question ids")
|
||||
if len(self.model_dump_json().encode()) > 32768:
|
||||
raise ValueError("question definition too large")
|
||||
return self
|
||||
|
||||
|
||||
class TaskQuestionReply(StrictModel):
|
||||
request_id: UUID
|
||||
expected_version: PositiveInt
|
||||
answers: list[TaskQuestionAnswer] = Field(max_length=3)
|
||||
|
||||
|
||||
class TaskQuestionRequest(StrictModel):
|
||||
question_request_id: UUID
|
||||
task_id: UUID
|
||||
run_id: UUID
|
||||
execution_generation: PositiveInt
|
||||
source_session_id: Id
|
||||
questions: list[TaskQuestion]
|
||||
status: Literal["pending", "answered", "cancelled"]
|
||||
version: PositiveInt
|
||||
created_at: WorkbenchDateTime
|
||||
answered_at: WorkbenchDateTime | None = None
|
||||
answered_by: str | None = None
|
||||
answers: list[TaskQuestionAnswer] = Field(default_factory=list)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Database resources owned by the standalone calculation worker."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def queue_repository(project_id):
|
||||
"""Resolve the current project route and open its calculation queue."""
|
||||
from app.core.config import settings
|
||||
from app.infra.db.dynamic_manager import project_connection_manager
|
||||
from app.infra.db.metadb.database import SessionLocal
|
||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||
from app.infra.db.postgresql.calculation_jobs import CalculationJobRepository
|
||||
|
||||
project_uuid = UUID(str(project_id))
|
||||
async with SessionLocal() as session:
|
||||
metadata = MetadataRepository(session)
|
||||
project = await metadata.get_project_by_id(project_uuid)
|
||||
if not project or project.status != "active":
|
||||
raise ValueError("Calculation project is not active")
|
||||
routing = await metadata.get_project_db_routing(project_uuid, "biz_data")
|
||||
if not routing or routing.db_type != "postgresql":
|
||||
raise ValueError("Project business PostgreSQL routing is unavailable")
|
||||
await session.commit()
|
||||
pool_min_size = (
|
||||
routing.pool_min_size
|
||||
if routing.pool_min_size is not None
|
||||
else settings.PROJECT_PG_POOL_MIN_SIZE
|
||||
)
|
||||
pool_max_size = (
|
||||
routing.pool_max_size
|
||||
if routing.pool_max_size is not None
|
||||
else settings.PROJECT_PG_POOL_SIZE
|
||||
)
|
||||
async with project_connection_manager.pg_connection(
|
||||
project_uuid,
|
||||
"biz_data",
|
||||
routing.dsn,
|
||||
pool_min_size,
|
||||
pool_max_size,
|
||||
) as connection:
|
||||
yield CalculationJobRepository(connection, project_id)
|
||||
|
||||
|
||||
class CalculationSupervisorLease:
|
||||
"""Keep the singleton advisory lock alive on one metadata connection."""
|
||||
|
||||
def __init__(self, connection):
|
||||
self.connection = connection
|
||||
|
||||
async def ping(self) -> None:
|
||||
await self.connection.execute(text("SELECT 1"))
|
||||
await self.connection.commit()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def calculation_supervisor_lease():
|
||||
"""Acquire and release the session-level calculation supervisor lock."""
|
||||
from app.infra.db.metadb.database import engine
|
||||
|
||||
async with engine.connect() as connection:
|
||||
acquired = await connection.scalar(text("SELECT pg_try_advisory_lock(1748531781, 2)"))
|
||||
await connection.commit()
|
||||
if not acquired:
|
||||
raise RuntimeError("Another calculation supervisor is already active")
|
||||
try:
|
||||
yield CalculationSupervisorLease(connection)
|
||||
finally:
|
||||
await connection.execute(text("SELECT pg_advisory_unlock(1748531781, 2)"))
|
||||
await connection.commit()
|
||||
@@ -3,6 +3,14 @@ from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from psycopg.types.json import Jsonb
|
||||
from app.mcp.errors import CalculationCapacityExceeded
|
||||
|
||||
|
||||
# Explicit metadata projection prevents status polling from decoding large JSON results.
|
||||
JOB_COLUMNS = "job_id,project_id,owner_user_id,request_key,tool,parameters,binding,timeout_seconds,status,error,lease_generation,lease_owner,lease_expires_at,created_at,updated_at,started_at,finished_at,queue_expires_at,result_expires_at"
|
||||
|
||||
|
||||
PUBLIC_JOB_COLUMNS = "job_id,status,tool,error,created_at,updated_at,started_at,finished_at,queue_expires_at,result_expires_at"
|
||||
|
||||
|
||||
class CalculationJobRepository:
|
||||
@@ -10,30 +18,65 @@ class CalculationJobRepository:
|
||||
self.connection = connection
|
||||
self.project_id = str(project_id)
|
||||
|
||||
async def enqueue(self, *, owner_user_id, request_key, tool, parameters, binding, timeout_seconds):
|
||||
cursor = await self.connection.execute(
|
||||
"""INSERT INTO workbench.calculation_jobs
|
||||
(job_id,project_id,owner_user_id,request_key,tool,parameters,binding,timeout_seconds)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
ON CONFLICT(project_id,owner_user_id,request_key) DO NOTHING RETURNING *""",
|
||||
(uuid4(), self.project_id, owner_user_id, request_key, tool,
|
||||
Jsonb(parameters), Jsonb(binding), timeout_seconds),
|
||||
async def maintain(self):
|
||||
await self.connection.execute(
|
||||
"""UPDATE workbench.calculation_jobs SET status='timed_out',
|
||||
error='{"code":"QUEUE_TIMEOUT"}'::jsonb,finished_at=clock_timestamp(),updated_at=clock_timestamp()
|
||||
WHERE project_id=%s AND status='queued' AND queue_expires_at<=clock_timestamp()""",
|
||||
(self.project_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
await self.connection.execute(
|
||||
"""UPDATE workbench.calculation_jobs SET result=NULL
|
||||
WHERE project_id=%s AND result IS NOT NULL AND result_expires_at<=clock_timestamp()""",
|
||||
(self.project_id,),
|
||||
)
|
||||
|
||||
async def enqueue(self, *, owner_user_id, request_key, tool, parameters, binding, timeout_seconds,
|
||||
queue_wait_seconds=3600, max_active_jobs=100):
|
||||
async with self.connection.transaction():
|
||||
return await self._enqueue(owner_user_id=owner_user_id, request_key=request_key,
|
||||
tool=tool, parameters=parameters, binding=binding, timeout_seconds=timeout_seconds,
|
||||
queue_wait_seconds=queue_wait_seconds, max_active_jobs=max_active_jobs)
|
||||
|
||||
async def _enqueue(self, *, owner_user_id, request_key, tool, parameters, binding,
|
||||
timeout_seconds, queue_wait_seconds, max_active_jobs):
|
||||
# Serialize admission within the caller's transaction, across every API process.
|
||||
await self.connection.execute(
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended(%s, 1748531781))", (self.project_id,))
|
||||
await self.maintain()
|
||||
cursor = await self.connection.execute(
|
||||
"SELECT * FROM workbench.calculation_jobs WHERE project_id=%s AND owner_user_id=%s AND request_key=%s",
|
||||
f"SELECT {JOB_COLUMNS} FROM workbench.calculation_jobs WHERE project_id=%s AND owner_user_id=%s AND request_key=%s",
|
||||
(self.project_id, owner_user_id, request_key),
|
||||
)
|
||||
row = dict(await cursor.fetchone())
|
||||
if row['tool'] != tool or row['parameters'] != parameters or row['binding'] != binding:
|
||||
raise HTTPException(409, 'Calculation request_key was used with different inputs')
|
||||
return row
|
||||
|
||||
async def get(self, job_id, owner_user_id=None):
|
||||
existing = await cursor.fetchone()
|
||||
if existing:
|
||||
row = dict(existing)
|
||||
if (row['tool'] != tool
|
||||
or {key: value for key, value in row['parameters'].items() if key != 'timeout_seconds'}
|
||||
!= {key: value for key, value in parameters.items() if key != 'timeout_seconds'}
|
||||
or row['binding'] != binding):
|
||||
raise HTTPException(409, 'Calculation request_key was used with different inputs')
|
||||
return row
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT * FROM workbench.calculation_jobs WHERE project_id=%s AND job_id=%s
|
||||
"""SELECT count(*) AS active FROM workbench.calculation_jobs WHERE project_id=%s
|
||||
AND status IN ('queued','running','cancel_requested')""", (self.project_id,))
|
||||
if (await cursor.fetchone())['active'] >= max_active_jobs:
|
||||
raise CalculationCapacityExceeded()
|
||||
cursor = await self.connection.execute(
|
||||
f"""INSERT INTO workbench.calculation_jobs
|
||||
(job_id,project_id,owner_user_id,request_key,tool,parameters,binding,timeout_seconds,queue_expires_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,clock_timestamp()+%s*interval '1 second')
|
||||
RETURNING {JOB_COLUMNS}""",
|
||||
(uuid4(), self.project_id, owner_user_id, request_key, tool,
|
||||
Jsonb(parameters), Jsonb(binding), timeout_seconds, queue_wait_seconds),
|
||||
)
|
||||
return dict(await cursor.fetchone())
|
||||
|
||||
async def get(self, job_id, owner_user_id=None, *, include_result=False):
|
||||
await self.maintain()
|
||||
columns = JOB_COLUMNS + (",result" if include_result else "")
|
||||
cursor = await self.connection.execute(
|
||||
f"""SELECT {columns} FROM workbench.calculation_jobs WHERE project_id=%s AND job_id=%s
|
||||
AND (%s::uuid IS NULL OR owner_user_id=%s)""",
|
||||
(self.project_id, job_id, owner_user_id, owner_user_id),
|
||||
)
|
||||
@@ -41,8 +84,9 @@ class CalculationJobRepository:
|
||||
return dict(row) if row else None
|
||||
|
||||
async def list(self, *, owner_user_id, binding, limit, offset, status=None):
|
||||
await self.maintain()
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT * FROM workbench.calculation_jobs WHERE project_id=%s AND owner_user_id=%s
|
||||
f"""SELECT {PUBLIC_JOB_COLUMNS} FROM workbench.calculation_jobs WHERE project_id=%s AND owner_user_id=%s
|
||||
AND binding @> %s AND (%s::text IS NULL OR status=%s) ORDER BY created_at DESC,job_id LIMIT %s OFFSET %s""",
|
||||
(self.project_id, owner_user_id, Jsonb(binding), status, status, limit, offset),
|
||||
)
|
||||
@@ -51,28 +95,30 @@ class CalculationJobRepository:
|
||||
async def cancel(self, job_id, owner_user_id):
|
||||
await self.connection.execute(
|
||||
"""UPDATE workbench.calculation_jobs SET status=CASE WHEN status='queued' THEN 'cancelled'
|
||||
ELSE 'cancel_requested' END,updated_at=clock_timestamp()
|
||||
ELSE 'cancel_requested' END,finished_at=CASE WHEN status='queued' THEN clock_timestamp() ELSE finished_at END,updated_at=clock_timestamp()
|
||||
WHERE project_id=%s AND job_id=%s AND owner_user_id=%s AND status IN ('queued','running')""",
|
||||
(self.project_id, job_id, owner_user_id),
|
||||
)
|
||||
return await self.get(job_id, owner_user_id)
|
||||
|
||||
async def recover(self):
|
||||
await self.maintain()
|
||||
# A crashed write is never automatically replayed.
|
||||
await self.connection.execute(
|
||||
"""UPDATE workbench.calculation_jobs SET status='interrupted',
|
||||
error='{"code":"WORKER_INTERRUPTED"}'::jsonb,updated_at=clock_timestamp()
|
||||
error='{"code":"WORKER_INTERRUPTED"}'::jsonb,finished_at=clock_timestamp(),updated_at=clock_timestamp()
|
||||
WHERE project_id=%s AND status IN ('running','cancel_requested')
|
||||
AND lease_expires_at < clock_timestamp()""", (self.project_id,),
|
||||
)
|
||||
|
||||
async def claim(self, owner, lease_seconds):
|
||||
await self.maintain()
|
||||
cursor = await self.connection.execute(
|
||||
"""UPDATE workbench.calculation_jobs j SET status='running',lease_owner=%s,
|
||||
"""UPDATE workbench.calculation_jobs j SET status='running',started_at=clock_timestamp(),lease_owner=%s,
|
||||
lease_generation=j.lease_generation+1,
|
||||
lease_expires_at=clock_timestamp()+%s*interval '1 second',updated_at=clock_timestamp()
|
||||
WHERE j.job_id=(SELECT job_id FROM workbench.calculation_jobs
|
||||
WHERE project_id=%s AND status='queued' ORDER BY created_at,job_id
|
||||
WHERE project_id=%s AND status='queued' AND queue_expires_at>clock_timestamp() ORDER BY created_at,job_id
|
||||
FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING j.*""",
|
||||
(owner, lease_seconds, self.project_id),
|
||||
)
|
||||
@@ -90,14 +136,15 @@ class CalculationJobRepository:
|
||||
result = await cursor.fetchone()
|
||||
return result['status'] if result else None
|
||||
|
||||
async def finish(self, row, owner, status, *, result=None, error=None):
|
||||
async def finish(self, row, owner, status, *, result=None, error=None, result_retention_seconds=604800):
|
||||
cursor = await self.connection.execute(
|
||||
"""UPDATE workbench.calculation_jobs SET status=%s,result=%s,error=%s,updated_at=clock_timestamp()
|
||||
"""UPDATE workbench.calculation_jobs SET status=%s,result=%s,error=%s,finished_at=clock_timestamp(),
|
||||
result_expires_at=clock_timestamp()+%s*interval '1 second',updated_at=clock_timestamp()
|
||||
WHERE project_id=%s AND job_id=%s AND lease_owner=%s AND lease_generation=%s
|
||||
AND lease_expires_at>clock_timestamp() AND status IN ('running','cancel_requested')
|
||||
AND (status!='cancel_requested' OR %s IN ('cancelled','interrupted')
|
||||
OR (%s='failed' AND %s='CLEANUP_FAILED')) RETURNING job_id""",
|
||||
(status, Jsonb(result) if result is not None else None, Jsonb(error) if error else None,
|
||||
(status, Jsonb(result) if result is not None else None, Jsonb(error) if error else None, result_retention_seconds,
|
||||
self.project_id, row['job_id'], owner, row['lease_generation'], status, status, (error or {}).get('code')),
|
||||
)
|
||||
return await cursor.fetchone() is not None
|
||||
|
||||
@@ -19,6 +19,7 @@ class OperationalTaskRepository:
|
||||
def __init__(self, connection: AsyncConnection, project_id):
|
||||
self.connection = connection
|
||||
self.project_id = str(project_id)
|
||||
self._agent_ownership_available: bool | None = None
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self):
|
||||
@@ -89,6 +90,34 @@ class OperationalTaskRepository:
|
||||
)
|
||||
return [str(row["job_id"]) for row in await cursor.fetchall()]
|
||||
|
||||
async def list_questions(self, task_id: str) -> list[dict]:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT document FROM workbench.operational_task_questions
|
||||
WHERE project_id=%s AND task_id=%s
|
||||
ORDER BY document->>'created_at' DESC, question_request_id DESC LIMIT 100""",
|
||||
(self.project_id, task_id),
|
||||
)
|
||||
return [row["document"] for row in await cursor.fetchall()]
|
||||
|
||||
async def get_question(self, task_id: str, question_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT document FROM workbench.operational_task_questions
|
||||
WHERE project_id=%s AND task_id=%s AND question_request_id=%s""",
|
||||
(self.project_id, task_id, question_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["document"] if row else None
|
||||
|
||||
async def save_question(self, task_id: str, document: dict) -> None:
|
||||
# All writers hold the owning task advisory lock.
|
||||
await self.connection.execute(
|
||||
"""INSERT INTO workbench.operational_task_questions
|
||||
(project_id,task_id,question_request_id,document) VALUES (%s,%s,%s,%s)
|
||||
ON CONFLICT (project_id,question_request_id) DO UPDATE SET document=excluded.document
|
||||
WHERE operational_task_questions.task_id=excluded.task_id""",
|
||||
(self.project_id, task_id, document["question_request_id"], Jsonb(document)),
|
||||
)
|
||||
|
||||
async def get_task(self, task_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT document FROM workbench.operational_tasks
|
||||
@@ -101,7 +130,8 @@ class OperationalTaskRepository:
|
||||
async def get_active_run(self, task_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||
agent_session_id,to_jsonb(operational_task_runs)->>'agent_instance_id' AS agent_instance_id,
|
||||
lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||
created_at,updated_at
|
||||
FROM workbench.operational_task_runs
|
||||
@@ -116,7 +146,8 @@ class OperationalTaskRepository:
|
||||
async def get_run(self, run_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||
agent_session_id,to_jsonb(operational_task_runs)->>'agent_instance_id' AS agent_instance_id,
|
||||
lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||
created_at,updated_at
|
||||
FROM workbench.operational_task_runs
|
||||
@@ -143,13 +174,23 @@ class OperationalTaskRepository:
|
||||
async def require_execution_storage(self) -> None:
|
||||
"""Reject claims before changing state if the inbox cannot be used."""
|
||||
cursor = await self.connection.execute(
|
||||
"SELECT to_regclass('workbench.agent_messages') IS NOT NULL AS available"
|
||||
"""SELECT to_regclass('workbench.agent_messages') IS NOT NULL
|
||||
AND to_regclass('workbench.operational_task_questions') IS NOT NULL AS available,
|
||||
EXISTS (
|
||||
SELECT 1 FROM pg_attribute
|
||||
WHERE attrelid=to_regclass('workbench.operational_task_runs')
|
||||
AND attname='agent_instance_id' AND NOT attisdropped
|
||||
) AS agent_ownership_available"""
|
||||
)
|
||||
if not (await cursor.fetchone())["available"]:
|
||||
readiness = await cursor.fetchone()
|
||||
self._agent_ownership_available = bool(readiness.get("agent_ownership_available"))
|
||||
if not readiness["available"] or not self._agent_ownership_available:
|
||||
raise DomainError(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"Agent message storage is not provisioned; apply "
|
||||
"resources/sql/024_agent_messages.sql to this project database before starting runs",
|
||||
"Agent execution storage is not provisioned; apply "
|
||||
"resources/sql/024_agent_messages.sql, resources/sql/028_task_questions.sql, "
|
||||
"and resources/sql/030_operational_run_agent_ownership.sql "
|
||||
"to this project database before starting runs",
|
||||
status=503,
|
||||
)
|
||||
|
||||
@@ -190,14 +231,26 @@ class OperationalTaskRepository:
|
||||
)
|
||||
|
||||
async def update_run(self, run: dict, expected_revision: int) -> None:
|
||||
if self._agent_ownership_available is None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT EXISTS (
|
||||
SELECT 1 FROM pg_attribute
|
||||
WHERE attrelid=to_regclass('workbench.operational_task_runs')
|
||||
AND attname='agent_instance_id' AND NOT attisdropped
|
||||
) AS available"""
|
||||
)
|
||||
self._agent_ownership_available = bool((await cursor.fetchone())["available"])
|
||||
owner_assignment = "agent_instance_id=%s," if self._agent_ownership_available else ""
|
||||
owner_values = (run.get("agent_instance_id"),) if self._agent_ownership_available else ()
|
||||
cursor = await self.connection.execute(
|
||||
"""UPDATE workbench.operational_task_runs SET
|
||||
status=%s,revision=%s,agent_session_id=%s,lease_expires_at=%s,
|
||||
f"""UPDATE workbench.operational_task_runs SET
|
||||
status=%s,revision=%s,agent_session_id=%s,{owner_assignment}lease_expires_at=%s,
|
||||
heartbeat_at=%s,started_at=%s,finished_at=%s,failure_code=%s,
|
||||
failure_detail=%s,dispatch_attempts=%s,next_dispatch_at=%s,updated_at=%s
|
||||
WHERE project_id=%s AND run_id=%s AND revision=%s""",
|
||||
(
|
||||
run["status"], run["revision"], run.get("agent_session_id"),
|
||||
*owner_values,
|
||||
run.get("lease_expires_at"), run.get("heartbeat_at"),
|
||||
run.get("started_at"), run.get("finished_at"), run.get("failure_code"),
|
||||
run.get("failure_detail"), run.get("dispatch_attempts", 0),
|
||||
@@ -214,7 +267,8 @@ class OperationalTaskRepository:
|
||||
) -> list[dict]:
|
||||
result = await self.connection.execute(
|
||||
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||
agent_session_id,to_jsonb(operational_task_runs)->>'agent_instance_id' AS agent_instance_id,
|
||||
lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||
created_at,updated_at
|
||||
FROM workbench.operational_task_runs
|
||||
@@ -243,7 +297,8 @@ class OperationalTaskRepository:
|
||||
) -> list[dict]:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||
agent_session_id,to_jsonb(operational_task_runs)->>'agent_instance_id' AS agent_instance_id,
|
||||
lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||
created_at,updated_at
|
||||
FROM workbench.operational_task_runs
|
||||
@@ -300,6 +355,18 @@ class OperationalTaskRepository:
|
||||
async def current_event_cursor(self) -> int:
|
||||
return await WorkbenchEventRepository(self.connection, self.project_id).current_cursor()
|
||||
|
||||
async def list_presentation_events(self, task_id: str, after: int, through: int, limit: int) -> list[dict]:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT event_seq,occurred_at,metadata
|
||||
FROM workbench.domain_event_log
|
||||
WHERE project_id=%s AND topic='operational.presentation.requested'
|
||||
AND aggregate_type='operational_task_presentation' AND aggregate_id=%s
|
||||
AND event_seq>%s AND event_seq<=%s
|
||||
ORDER BY event_seq LIMIT %s""",
|
||||
(self.project_id, task_id, after, through, limit),
|
||||
)
|
||||
return [dict(row) for row in await cursor.fetchall()]
|
||||
|
||||
async def get_task_by_create_request(self, request_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT document FROM workbench.operational_tasks
|
||||
|
||||
@@ -337,6 +337,7 @@ async def task_context_page(
|
||||
"evidence": ("operational_task_evidence", "evidence_id"),
|
||||
"actions": ("operational_task_actions", "action_id"),
|
||||
"access_requests": ("operational_task_access_requests", "access_request_id"),
|
||||
"questions": ("operational_task_questions", "question_request_id"),
|
||||
}
|
||||
documents = {"task": "operational_tasks", "report": "operational_task_reports", "plan": "operational_task_plans"}
|
||||
if section in {"access_requests", "progress"}:
|
||||
|
||||
@@ -52,7 +52,7 @@ class SampleWorkOrderReceipt(BaseModel):
|
||||
work_order_id: UUID
|
||||
project_id: UUID
|
||||
created_at: datetime
|
||||
origin: Literal["synthetic"]
|
||||
origin: Literal["system_data"]
|
||||
status: Literal[
|
||||
"draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled"
|
||||
]
|
||||
@@ -195,7 +195,8 @@ class HttpOperationalActionDispatcher:
|
||||
receipt = ActionReceipt(
|
||||
adapter="peripheral_sample", receipt_id=str(order.work_order_id),
|
||||
submitted_at=order.created_at, completed_at=order.created_at,
|
||||
external_status=order.status, detail="合成外围工单已创建,现场工作进度由外围系统继续同步",
|
||||
external_status=order.status,
|
||||
detail="外围工单已创建,现场工作进度由外围系统继续同步",
|
||||
)
|
||||
return ActionDispatchOutcome("succeeded", receipt.detail, receipt)
|
||||
result = CommandGatewayReceipt.model_validate(value)
|
||||
|
||||
@@ -5,6 +5,7 @@ import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
from pydantic import Field
|
||||
@@ -33,6 +34,23 @@ class TaskDataError(Exception):
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def peripheral_records_origin(items: object) -> Literal["system_data", "synthetic"]:
|
||||
"""Classify a record collection; mixed collections retain per-item provenance."""
|
||||
if not isinstance(items, list) or any(
|
||||
not isinstance(item, dict) or item.get("origin") not in {"system_data", "synthetic"}
|
||||
for item in items
|
||||
):
|
||||
raise TaskDataError(502, "外围记录缺少明确来源,不能作为已核实证据")
|
||||
return "synthetic" if items and all(item["origin"] == "synthetic" for item in items) else "system_data"
|
||||
|
||||
|
||||
def normalize_peripheral_work_orders(items: object) -> list[dict[str, object]]:
|
||||
"""Expose peripheral work orders only as project-owned system data."""
|
||||
if not isinstance(items, list) or any(not isinstance(item, dict) for item in items):
|
||||
raise TaskDataError(502, "外围工单格式无效,不能作为已核实证据")
|
||||
return [{**item, "origin": "system_data"} for item in items]
|
||||
|
||||
|
||||
def _matches_project(value: object, project_id: str) -> bool:
|
||||
if isinstance(value, list):
|
||||
return all(_matches_project(item, project_id) for item in value)
|
||||
@@ -147,7 +165,14 @@ async def read_task_data(
|
||||
data = _select_demo_observations(data, query, str(context.project_id))
|
||||
elif not _matches_project(data, str(context.project_id)):
|
||||
raise TaskDataError(502, "数据源返回了其他项目的数据")
|
||||
origin = "synthetic" if peripheral else "system_data"
|
||||
origin = "system_data"
|
||||
if query.source == "peripheral_work_orders":
|
||||
items = data.get("items") if isinstance(data, dict) else None
|
||||
data = {**data, "items": normalize_peripheral_work_orders(items)}
|
||||
elif query.source == "peripheral_test_data":
|
||||
items = data.get("items") if isinstance(data, dict) else None
|
||||
origin = peripheral_records_origin(items)
|
||||
data = {**data, "provenance_scope": "per_record", "source_origins": sorted({item["origin"] for item in items})}
|
||||
historical_replay = False
|
||||
if query.source in {"peripheral_points", "peripheral_observations"}:
|
||||
source_origin = data.get("origin") if isinstance(data, dict) else None
|
||||
|
||||
@@ -9,13 +9,13 @@ from pydantic import Field, model_validator
|
||||
from app.mcp.models import Id, StrictInput
|
||||
|
||||
|
||||
MAX_CALCULATION_TIMEOUT_SECONDS = 86400
|
||||
MAX_REQUEST_TIMEOUT_SECONDS = 240
|
||||
|
||||
|
||||
class CalculationInput(StrictInput):
|
||||
timeout_seconds: int = Field(
|
||||
default=120, ge=1, le=MAX_CALCULATION_TIMEOUT_SECONDS, strict=True,
|
||||
description="本次计算等待预算,秒;由Agent按模型规模和任务时长选择,受工具目录中的服务端上限约束;与模拟duration不同,超时不保证已启动的计算停止或回滚",
|
||||
default=120, ge=1, le=MAX_REQUEST_TIMEOUT_SECONDS, strict=True,
|
||||
description="本次HTTP请求响应等待预算,秒,默认120秒;不是后台计算执行时限,受工具目录中的请求上限约束;后台任务通过状态和结果工具查询",
|
||||
)
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class BurstDetectionInput(CalculationInput):
|
||||
class BurstLocationInput(CalculationInput):
|
||||
data_source: Literal["monitoring", "simulation"] = "monitoring"
|
||||
simulation_run_id: UUID | None = Field(default=None, description="已有分析运行UUID,不是方案名称;simulation模式只读取已有异常结果及模型基线")
|
||||
burst_leakage: float = Field(gt=0, allow_inf_nan=False, description="有依据的假定漏水流量,WNTR SI单位m³/s;不是已知爆管管段,不能随意猜测")
|
||||
burst_leakage: float = Field(gt=0, allow_inf_nan=False, description="当前爆管定位算法必需的假定漏水流量,WNTR SI单位m³/s;可用有数据和方法依据的估计或明确给定的情景假设,说明来源与不确定性,不要求现场实测确认;不能任填数值")
|
||||
pressure_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="压力监测设备关联的模型节点ID,不是设备查询编号;省略时自动读取当前项目SCADA配置中的压力测点,不需要猜测节点ID")
|
||||
flow_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="流量监测设备关联的模型要素ID,不是设备查询编号")
|
||||
scada_burst_start: datetime
|
||||
@@ -69,6 +69,7 @@ class BurstLocationInput(CalculationInput):
|
||||
|
||||
class ValveIsolationInput(CalculationInput):
|
||||
accident_element: list[Id] = Field(min_length=1, max_length=20, description="经核实或明确作为候选的事故管段/节点ID")
|
||||
accident_element_type: Literal["auto", "link", "node"] = Field(default="auto", description="事故要素类型;auto优先识别管段,再识别节点;节点与管段重号时,节点分析须指定node,纯管段分析可指定link")
|
||||
disabled_valves: list[Id] = Field(default_factory=list, max_length=200, description="已知无法关闭的阀门;未提供不代表现场确认全部可用")
|
||||
|
||||
|
||||
|
||||
+20
-6
@@ -203,7 +203,11 @@ class McpQueryApplication:
|
||||
return None
|
||||
schema = tool.model.model_json_schema()
|
||||
if tool.computation or tool.mutating:
|
||||
schema["properties"]["timeout_seconds"]["maximum"] = self.settings.max_calculation_timeout_seconds
|
||||
schema["properties"]["timeout_seconds"]["maximum"] = self.settings.max_request_timeout_seconds
|
||||
schema["properties"]["timeout_seconds"]["default"] = min(
|
||||
self.settings.query_timeout_seconds,
|
||||
self.settings.max_request_timeout_seconds,
|
||||
)
|
||||
return schema
|
||||
|
||||
async def list_tools(self, ctx, params):
|
||||
@@ -213,6 +217,10 @@ class McpQueryApplication:
|
||||
for name in sorted(TOOLS):
|
||||
tool = TOOLS[name].protocol_tool()
|
||||
tool.input_schema.update(self.tool_input_schema(name))
|
||||
tool.meta = {**(tool.meta or {}), "io.tjwater/request-timeout": {
|
||||
"defaultSeconds": self.settings.query_timeout_seconds,
|
||||
"maxSeconds": self.settings.max_request_timeout_seconds,
|
||||
}}
|
||||
tools.append(tool)
|
||||
return types.ListToolsResult(
|
||||
tools=tools,
|
||||
@@ -291,11 +299,13 @@ class McpQueryApplication:
|
||||
return tool_error(
|
||||
"INVALID_QUERY", "Query parameters do not match the tool schema"
|
||||
)
|
||||
if (tool.computation or tool.mutating) and "timeout_seconds" not in args.model_fields_set:
|
||||
args.timeout_seconds = min(self.settings.query_timeout_seconds, self.settings.max_request_timeout_seconds)
|
||||
timeout_seconds = (
|
||||
args.timeout_seconds if tool.computation or tool.mutating else self.settings.query_timeout_seconds
|
||||
)
|
||||
if (tool.computation or tool.mutating) and timeout_seconds > self.settings.max_calculation_timeout_seconds:
|
||||
return tool_error("INVALID_QUERY", "Calculation timeout exceeds the server limit")
|
||||
if (tool.computation or tool.mutating) and timeout_seconds > self.settings.max_request_timeout_seconds:
|
||||
return tool_error("INVALID_QUERY", "HTTP request timeout exceeds the server limit")
|
||||
request = ctx.request
|
||||
project = request.headers.get("x-project-id", "") if request else ""
|
||||
meta = ctx.meta or {}
|
||||
@@ -349,7 +359,10 @@ class McpQueryApplication:
|
||||
else:
|
||||
from app.mcp.calculation_jobs import should_queue, submit_calculation
|
||||
if should_queue(tool, args):
|
||||
result = await submit_calculation(scope, binding, tool, args, call_id)
|
||||
result = await submit_calculation(
|
||||
scope, binding, tool, args, call_id,
|
||||
job_timeout_seconds=self.settings.max_calculation_timeout_seconds,
|
||||
)
|
||||
else:
|
||||
result = (
|
||||
await self.executor(
|
||||
@@ -463,6 +476,7 @@ class McpQueryApplication:
|
||||
403: "QUERY_CONTEXT_DENIED",
|
||||
404: "NOT_FOUND",
|
||||
409: "QUERY_CONTEXT_STALE",
|
||||
410: "RESULT_EXPIRED",
|
||||
413: "RESULT_TOO_LARGE",
|
||||
422: "INVALID_QUERY",
|
||||
503: "SOURCE_UNAVAILABLE",
|
||||
@@ -471,7 +485,7 @@ class McpQueryApplication:
|
||||
codes.get(exc.status_code, "QUERY_FAILED"),
|
||||
str(exc.detail)
|
||||
if isinstance(exc.detail, str)
|
||||
and exc.status_code in {403, 404, 409, 413, 422}
|
||||
and exc.status_code in {403, 404, 409, 410, 413, 422}
|
||||
else "Query is unavailable",
|
||||
)
|
||||
except (UndefinedTable, InvalidSchemaName):
|
||||
@@ -480,7 +494,7 @@ class McpQueryApplication:
|
||||
"MCP project storage has not been provisioned",
|
||||
)
|
||||
except TimeoutError:
|
||||
return tool_error("QUERY_TIMEOUT", "Query execution timed out")
|
||||
return tool_error("QUERY_TIMEOUT", "HTTP request timed out")
|
||||
except ValueError:
|
||||
return tool_error(
|
||||
"INVALID_QUERY", "Invalid query value or incompatible source data"
|
||||
|
||||
@@ -39,12 +39,15 @@ def envelope(source: str, data, *, job_id="list", page=None) -> QueryResult:
|
||||
|
||||
|
||||
def public_job(row: dict) -> dict:
|
||||
fields = {"job_id", "status", "tool", "created_at", "started_at", "finished_at", "updated_at", "error"}
|
||||
fields = {"job_id", "status", "tool", "created_at", "started_at", "finished_at", "updated_at", "error", "queue_expires_at", "result_expires_at"}
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
return {**jsonable_encoder({key: row[key] for key in fields if key in row}), "poll_after_ms": 1500}
|
||||
expiry = row.get("result_expires_at")
|
||||
available = row.get("status") == "succeeded" and (expiry is None or expiry > datetime.now(timezone.utc))
|
||||
return {**jsonable_encoder({key: row[key] for key in fields if key in row}),
|
||||
"result_available": available, "poll_after_ms": 1500}
|
||||
|
||||
|
||||
async def submit_calculation(scope, binding, tool, args, call_id: str) -> QueryResult:
|
||||
async def submit_calculation(scope, binding, tool, args, call_id: str, *, job_timeout_seconds: int = 3600) -> QueryResult:
|
||||
from app.mcp.query_service import authorize_query
|
||||
await authorize_query(scope, binding, tool, args)
|
||||
if binding.purpose == "task_reader":
|
||||
@@ -68,7 +71,7 @@ async def submit_calculation(scope, binding, tool, args, call_id: str) -> QueryR
|
||||
if not retryable_capacity_rejection(previous["result"]):
|
||||
raise HTTPException(409, "Operation already started; inspect the existing calculation or mutation status")
|
||||
check_digest(args, await preview_change(scope, binding, tool.name, args))
|
||||
parameters = args.model_dump(mode="json", exclude={"context_handle"})
|
||||
parameters = args.model_dump(mode="json", exclude={"context_handle", "timeout_seconds"})
|
||||
request_id = str(args.request_id) if tool.mutating else call_id
|
||||
key = str(uuid5(NAMESPACE_URL, ":".join([binding.subject, binding.client_id, binding.native_session_id, str(binding.task_id), str(binding.run_id), request_id])))
|
||||
repo = CalculationJobRepository(scope.connection, scope.project.project_id)
|
||||
@@ -84,12 +87,12 @@ async def submit_calculation(scope, binding, tool, args, call_id: str) -> QueryR
|
||||
await verify_task(scope, binding)
|
||||
row = await repo.enqueue(
|
||||
owner_user_id=scope.project.user_id, request_key=key, tool=tool.name,
|
||||
parameters=parameters, binding=durable_binding(binding), timeout_seconds=args.timeout_seconds,
|
||||
parameters=parameters, binding=durable_binding(binding), timeout_seconds=job_timeout_seconds,
|
||||
)
|
||||
else:
|
||||
row = await repo.enqueue(
|
||||
owner_user_id=scope.project.user_id, request_key=key, tool=tool.name,
|
||||
parameters=parameters, binding=durable_binding(binding), timeout_seconds=args.timeout_seconds,
|
||||
parameters=parameters, binding=durable_binding(binding), timeout_seconds=job_timeout_seconds,
|
||||
)
|
||||
await scope.connection.commit()
|
||||
return envelope(tool.name, public_job(row), job_id=row["job_id"])
|
||||
@@ -107,7 +110,9 @@ async def authorize_original(scope, row):
|
||||
from app.mcp.contexts import verify_task
|
||||
original = restore_binding(row["binding"])
|
||||
tool = TOOLS[row["tool"]]
|
||||
args = tool.model.model_validate({**row["parameters"], "context_handle": "durable-calculation-context-handle"})
|
||||
# Legacy queue rows stored the old HTTP timeout. It does not govern durable execution.
|
||||
parameters = {key: value for key, value in row["parameters"].items() if key != "timeout_seconds"}
|
||||
args = tool.model.model_validate({**parameters, "context_handle": "durable-calculation-context-handle"})
|
||||
if (str(scope.project.user_id) != str(row["owner_user_id"])
|
||||
or str(scope.project.project_id) != str(original.project_id)):
|
||||
raise HTTPException(403, "Calculation owner is no longer authorized")
|
||||
@@ -125,7 +130,8 @@ async def read_calculation_job(scope, binding, tool, args) -> QueryResult:
|
||||
})
|
||||
rows = await repo.list(owner_user_id=scope.project.user_id, binding=identity_scope, limit=args.limit + 1, offset=args.offset, status=args.status)
|
||||
return envelope(tool.name, [public_job(row) for row in rows[:args.limit]], page=PageInfo(limit=args.limit, offset=args.offset, has_more=len(rows) > args.limit))
|
||||
row = await repo.get(args.job_id, owner_user_id=scope.project.user_id)
|
||||
row = await repo.get(args.job_id, owner_user_id=scope.project.user_id,
|
||||
include_result=tool.name == "get_calculation_result")
|
||||
if row is None:
|
||||
raise HTTPException(404, "Calculation was not found")
|
||||
check_binding(binding, row)
|
||||
@@ -137,6 +143,9 @@ async def read_calculation_job(scope, binding, tool, args) -> QueryResult:
|
||||
await scope.connection.commit()
|
||||
if tool.name != "get_calculation_result" or row["status"] != "succeeded":
|
||||
return envelope(tool.name, public_job(row), job_id=row["job_id"])
|
||||
expiry = row.get("result_expires_at")
|
||||
if row.get("result") is None or (expiry is not None and expiry <= datetime.now(timezone.utc)):
|
||||
raise HTTPException(410, "Calculation result retention period has expired")
|
||||
result = QueryResult.model_validate(row["result"])
|
||||
result = await attach_calculation_evidence(scope, row, result)
|
||||
value = result.data
|
||||
|
||||
+3
-3
@@ -59,8 +59,8 @@ _specs = [
|
||||
("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;仅返回候选节点,不保存正式方案,不保证污染溯源可观测性", SensorPlacementInput, "optimization.run", "analysis", False, True),
|
||||
("run_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回区域估计,不是已确认漏点。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True),
|
||||
("run_burst_detection", "爆管异常侦测:使用至少5个完整压力传感器此前15天数据;只提示压力异常,不定位、不证明爆管", BurstDetectionInput, "burst.run", "analysis", False, True),
|
||||
("run_burst_location", "爆管候选定位:对比异常与正常压力,可辅助流量;需有依据的漏水量假设。压力节点可省略,自动读取项目SCADA压力测点;monitoring正常窗可成对省略,默认前一天同时间段,须说明该对照假设。simulation须有已有分析运行UUID并读取同窗模型基线,不是实测;定位内部会执行水力计算,不创建新的事件方案", BurstLocationInput, "burst.run", "analysis", False, True),
|
||||
("analyze_valve_isolation", "按事故候选要素分析拓扑隔离边界、建议关闭阀门和受影响节点;不是污染扩散或水力影响范围,不执行关阀,须现场核实", ValveIsolationInput, "simulation.run", "analysis", False, True),
|
||||
("run_burst_location", "爆管候选定位:对比异常与正常压力,可辅助流量;burst_leakage必需,可使用有依据的估计或明确给定的情景假设,不要求现场确诊;本工具不反演总漏水量。压力节点可省略,自动读取项目SCADA压力测点;monitoring正常窗可成对省略,默认前一天同时间段,须说明该对照假设。simulation须有已有分析运行UUID并读取同窗模型基线,不是实测;定位内部会执行水力计算,不创建新的事件方案", BurstLocationInput, "burst.run", "analysis", False, True),
|
||||
("analyze_valve_isolation", "按事故候选要素分析拓扑隔离边界、建议关闭阀门和受影响节点;候选分析无需等待现场确诊。输出不是污染扩散或水力影响范围;现场操作前须核实阀门映射及可用性,本工具不执行关阀", ValveIsolationInput, "simulation.run", "analysis", False, True),
|
||||
(
|
||||
"get_element_properties",
|
||||
"读取当前项目的单个管网元素属性",
|
||||
@@ -213,7 +213,7 @@ _specs = [
|
||||
),
|
||||
(
|
||||
"query_peripheral_data",
|
||||
"查询有限外围数据源;synthetic是合成证据",
|
||||
"查询外围业务工单及监测数据;外围工单属于系统数据,监测数据按实际来源保留来源信息",
|
||||
m.PeripheralInput,
|
||||
"simulation.view",
|
||||
"peripheral",
|
||||
|
||||
+4
-1
@@ -21,7 +21,8 @@ class McpSettings(BaseSettings):
|
||||
)
|
||||
allowed_origins: list[str] = Field(default_factory=list)
|
||||
context_ttl_seconds: int = Field(default=600, ge=30, le=600)
|
||||
query_timeout_seconds: int = Field(default=30, ge=1, le=60)
|
||||
query_timeout_seconds: int = Field(default=120, ge=1, le=240)
|
||||
max_request_timeout_seconds: int = Field(default=240, ge=1, le=240)
|
||||
max_calculation_timeout_seconds: int = Field(default=3600, ge=120, le=86400)
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="TJWATER_MCP_",
|
||||
@@ -32,6 +33,8 @@ class McpSettings(BaseSettings):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_resource(self):
|
||||
if self.query_timeout_seconds > self.max_request_timeout_seconds:
|
||||
raise ValueError("query timeout exceeds the HTTP request timeout limit")
|
||||
if self.dev_http_issuer_uri:
|
||||
dev_url = urlsplit(self.dev_http_issuer_uri)
|
||||
try:
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ class PageInput(StrictInput):
|
||||
|
||||
|
||||
class TaskContextInput(PageInput):
|
||||
section: Literal["summary", "task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill"] = "summary"
|
||||
section: Literal["summary", "task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"] = "summary"
|
||||
report_revision: int | None = Field(default=None, ge=1)
|
||||
data_path: list[Id] = Field(default_factory=list, max_length=8)
|
||||
|
||||
|
||||
+19
-6
@@ -7,7 +7,12 @@ from app.infra.db.postgresql.read_queries import MAX_QUERY_BYTES, read_rows, sca
|
||||
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataQuery
|
||||
from app.services.content_digest import digest
|
||||
from samples.peripheral.store import PostgresStore
|
||||
from app.infra.integrations.operational_data import _select_demo_observations
|
||||
from app.infra.integrations.operational_data import (
|
||||
TaskDataError,
|
||||
_select_demo_observations,
|
||||
normalize_peripheral_work_orders,
|
||||
peripheral_records_origin,
|
||||
)
|
||||
|
||||
|
||||
async def read_peripheral_query(scope, args):
|
||||
@@ -37,17 +42,25 @@ async def read_peripheral_query(scope, args):
|
||||
truncated = len(rows) > args.limit
|
||||
if any(item.get("project_id") != project for item in items):
|
||||
raise HTTPException(502, "Peripheral project mismatch")
|
||||
try:
|
||||
if args.source == "peripheral_work_orders":
|
||||
items = normalize_peripheral_work_orders(items)
|
||||
origin = "system_data"
|
||||
else:
|
||||
origin = peripheral_records_origin(items)
|
||||
except TaskDataError as exc:
|
||||
raise HTTPException(exc.status, exc.detail) from exc
|
||||
data = {
|
||||
"items": items,
|
||||
"truncated": truncated,
|
||||
"limit": args.limit,
|
||||
"offset": args.offset,
|
||||
}
|
||||
origin = (
|
||||
"synthetic"
|
||||
if items and any(item.get("origin") == "synthetic" for item in items)
|
||||
else "system_data"
|
||||
)
|
||||
if args.source == "peripheral_test_data":
|
||||
data.update(
|
||||
provenance_scope="per_record",
|
||||
source_origins=sorted({item["origin"] for item in items}),
|
||||
)
|
||||
elif args.source == "peripheral_points":
|
||||
rows = await scada_devices(conn, limit=args.limit + 1, offset=args.offset)
|
||||
data = {
|
||||
|
||||
@@ -412,14 +412,14 @@ async def execute_query(
|
||||
data["display_time_zone"] = "Asia/Shanghai"
|
||||
data["approved_data_scopes"] = binding.task_scope_cap
|
||||
data["bound_run_id"] = str(binding.run_id) if binding.run_id else None
|
||||
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill"]
|
||||
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"]
|
||||
truncated = True
|
||||
gaps.append("Task context is a bounded summary; use section, data_path, limit and offset for detail. Previews are not complete reports.")
|
||||
else:
|
||||
projection = await reads.task_context_page(conn, project_id=binding.project_id, task_id=binding.task_id,
|
||||
section=args.section, report_revision=args.report_revision, data_path=args.data_path, limit=args.limit, offset=args.offset)
|
||||
total = projection["total"]
|
||||
if args.section in {"evidence", "actions", "access_requests"} and not args.data_path:
|
||||
if args.section in {"evidence", "actions", "access_requests", "questions"} and not args.data_path:
|
||||
total = projection["snapshot_metadata"].get("total", total)
|
||||
if projection["value_type"] in {"array", "object", "string"}:
|
||||
page = PageInfo(limit=args.limit, offset=args.offset, total=total, has_more=args.offset + args.limit < total)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Read-only projections backed by the GIS materialized-view query layer."""
|
||||
"""Read-only GIS projections and authoritative network topology snapshots."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -69,6 +69,46 @@ def get_network_link_nodes(name: str) -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
def get_valve_isolation_topology(name: str) -> tuple[list[str], list[str], list[str]]:
|
||||
"""Read links, closed statuses and sources from one consistent SQL snapshot.
|
||||
|
||||
Isolation must include model links without published geometries, and use
|
||||
initial statuses rather than treating every link as open. UNION ALL keeps
|
||||
source nodes in the same statement snapshot without repeating them for
|
||||
every link or relying on materialized view refresh timing.
|
||||
"""
|
||||
rows = read_all(
|
||||
name,
|
||||
"""
|
||||
SELECT l.id, l.link_type::text AS element_type,
|
||||
l.start_node_id, l.end_node_id,
|
||||
COALESCE(s.status::text, p.status::text) AS status
|
||||
FROM network.links AS l
|
||||
LEFT JOIN network.pipes AS p ON p.link_id = l.id
|
||||
LEFT JOIN network.link_initial_settings AS s ON s.link_id = l.id
|
||||
UNION ALL
|
||||
SELECT n.id, n.node_type::text AS element_type,
|
||||
NULL, NULL, NULL
|
||||
FROM network.nodes AS n
|
||||
WHERE n.node_type::text IN ('reservoir', 'tank')
|
||||
""",
|
||||
)
|
||||
link_entries: list[str] = []
|
||||
source_nodes: list[str] = []
|
||||
closed_links: list[str] = []
|
||||
for row in rows:
|
||||
element_id = str(row["id"])
|
||||
if row["element_type"] in {"reservoir", "tank"}:
|
||||
source_nodes.append(element_id)
|
||||
else:
|
||||
link_entries.append(
|
||||
f"{element_id}:{row['element_type']}:{row['start_node_id']}:{row['end_node_id']}"
|
||||
)
|
||||
if row["status"] == "CLOSED":
|
||||
closed_links.append(element_id)
|
||||
return link_entries, source_nodes, closed_links
|
||||
|
||||
|
||||
def get_major_pipe_nodes(name: str, diameter: int) -> list[str]:
|
||||
"""Return large pipes in the established API wire format."""
|
||||
rows = read_all(
|
||||
|
||||
@@ -46,22 +46,10 @@ class WorkerSettings:
|
||||
@asynccontextmanager
|
||||
async def queue_repository(project_id):
|
||||
"""Resolve current routing from metadata, never from a job payload."""
|
||||
from psycopg import AsyncConnection
|
||||
from psycopg.rows import dict_row
|
||||
from app.infra.db.metadb.database import SessionLocal
|
||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||
from app.infra.db.postgresql.calculation_jobs import CalculationJobRepository
|
||||
async with SessionLocal() as session:
|
||||
metadata = MetadataRepository(session)
|
||||
project = await metadata.get_project_by_id(UUID(str(project_id)))
|
||||
if not project or project.status != 'active':
|
||||
raise ValueError('Calculation project is not active')
|
||||
routing = await metadata.get_project_db_routing(UUID(str(project_id)), 'biz_data')
|
||||
if not routing or routing.db_type != 'postgresql':
|
||||
raise ValueError('Project business PostgreSQL routing is unavailable')
|
||||
await session.commit()
|
||||
async with await AsyncConnection.connect(routing.dsn, row_factory=dict_row) as conn:
|
||||
yield CalculationJobRepository(conn, project_id)
|
||||
from app.infra.db.calculation_worker import queue_repository as open_repository
|
||||
|
||||
async with open_repository(project_id) as repository:
|
||||
yield repository
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -246,15 +234,11 @@ class CalculationWorker:
|
||||
await cleanup_job_resources(row, directory)
|
||||
|
||||
async def run(self):
|
||||
from sqlalchemy import text
|
||||
from app.infra.db.metadb.database import engine
|
||||
from app.infra.db.calculation_worker import calculation_supervisor_lease
|
||||
|
||||
# Dedicated session-level metadata lock: independent API workers never
|
||||
# participate; duplicate supervisors exit before claiming any jobs.
|
||||
async with engine.connect() as lock:
|
||||
acquired = await lock.scalar(text("SELECT pg_try_advisory_lock(1748531781, 2)"))
|
||||
await lock.commit()
|
||||
if not acquired:
|
||||
raise RuntimeError('Another calculation supervisor is already active')
|
||||
async with calculation_supervisor_lease() as lease:
|
||||
tasks = set()
|
||||
project_cursor = 0
|
||||
try:
|
||||
@@ -264,8 +248,7 @@ class CalculationWorker:
|
||||
await recover_job_resources(self.settings.project_ids)
|
||||
while True:
|
||||
async with asyncio.timeout(self.settings.heartbeat_seconds):
|
||||
await lock.execute(text('SELECT 1'))
|
||||
await lock.commit()
|
||||
await lease.ping()
|
||||
for task in tuple(tasks):
|
||||
if task.done():
|
||||
tasks.remove(task)
|
||||
@@ -286,8 +269,6 @@ class CalculationWorker:
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
await lock.execute(text('SELECT pg_advisory_unlock(1748531781, 2)'))
|
||||
await lock.commit()
|
||||
|
||||
|
||||
async def guard_child(input_path, output_path):
|
||||
|
||||
@@ -88,7 +88,7 @@ def resolve_overlay(snapshot: TaskDataSnapshot, mode: str) -> dict:
|
||||
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):
|
||||
if isinstance(feature.get("id"), str) or _number(feature.get("id")):
|
||||
normalized["id"] = feature["id"]
|
||||
if mode == "heatmap":
|
||||
weight = properties.get("weight", 1)
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.auth.permissions import (
|
||||
SIMULATION_VIEW,
|
||||
)
|
||||
from app.domain.domain_errors import DomainError, require
|
||||
from app.services import task_questions
|
||||
from app.domain.schemas.operational_task import (
|
||||
ActionDecisionCommand,
|
||||
ActionExecuteCommand,
|
||||
@@ -65,6 +66,8 @@ from app.domain.schemas.operational_task import (
|
||||
TaskPresentationEventCommand,
|
||||
TaskPresentationEventResult,
|
||||
TaskPresentationCursor,
|
||||
TaskPresentationHistoryItem,
|
||||
TaskPresentationHistoryPage,
|
||||
)
|
||||
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
|
||||
from app.services.content_digest import digest
|
||||
@@ -97,6 +100,15 @@ class OperationalTaskService:
|
||||
def _actor(self) -> str:
|
||||
return f"principal:{self.context.user_id}"
|
||||
|
||||
@staticmethod
|
||||
def _require_run_owner(run: dict, command: AgentRunCommand) -> None:
|
||||
require(run.get("agent_session_id") == command.source_session_id,
|
||||
"SESSION_MISMATCH", "Agent session does not own this run", status=409)
|
||||
owner = run.get("agent_instance_id")
|
||||
if owner is not None:
|
||||
require(owner == command.agent_instance_id,
|
||||
"AGENT_INSTANCE_MISMATCH", "Agent instance does not own this run", status=409)
|
||||
|
||||
@property
|
||||
def _supports_runs(self) -> bool:
|
||||
return hasattr(self.repo, "get_active_run") and hasattr(self.repo, "update_run")
|
||||
@@ -138,6 +150,7 @@ class OperationalTaskService:
|
||||
permissions = self.permissions
|
||||
return OperationalTaskPermissions(
|
||||
can_publish_artifacts=OPERATIONAL_TASK_EXECUTE in permissions,
|
||||
can_answer_questions=OPERATIONAL_TASK_WRITE in permissions,
|
||||
can_propose_actions=OPERATIONAL_TASK_WRITE in permissions,
|
||||
can_sync_external_feedback=OPERATIONAL_TASK_EXTERNAL_SYNC in permissions,
|
||||
can_decide_work_orders=OPERATIONAL_ACTION_WORK_ORDER_APPROVE in permissions,
|
||||
@@ -258,6 +271,7 @@ class OperationalTaskService:
|
||||
evidence=evidence,
|
||||
actions=actions,
|
||||
access_requests=access_requests,
|
||||
questions=await task_questions.list_questions(self, task_id),
|
||||
permissions=self._permission_view(),
|
||||
work_order_snapshot=work_order_snapshot,
|
||||
)
|
||||
@@ -483,6 +497,7 @@ class OperationalTaskService:
|
||||
status="running",
|
||||
revision=expected + 1,
|
||||
agent_session_id=command.source_session_id,
|
||||
agent_instance_id=command.agent_instance_id,
|
||||
lease_expires_at=now + LEASE_DURATION,
|
||||
heartbeat_at=now,
|
||||
started_at=run.get("started_at") or now,
|
||||
@@ -527,8 +542,7 @@ class OperationalTaskService:
|
||||
"Run revision changed", status=409)
|
||||
require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION",
|
||||
"Only an active run has a lease", status=409)
|
||||
require(run.get("agent_session_id") == command.source_session_id,
|
||||
"SESSION_MISMATCH", "Agent session does not own this run", status=409)
|
||||
self._require_run_owner(run, command)
|
||||
now = self._now()
|
||||
self._require_active_run_lease(run, now)
|
||||
expected = run["revision"]
|
||||
@@ -580,6 +594,7 @@ class OperationalTaskService:
|
||||
replay = await self._request_replay(scope, command.request_id, body)
|
||||
if replay:
|
||||
return await self.detail(task_id)
|
||||
await self.repo.lock(f"task:{task_id}")
|
||||
await self.repo.lock(f"run:{run_id}")
|
||||
run = await self._run(task_id, run_id)
|
||||
require(run["revision"] == command.expected_revision, "VERSION_CONFLICT",
|
||||
@@ -587,6 +602,7 @@ class OperationalTaskService:
|
||||
require(run["status"] in {"queued", "claimed", "running"},
|
||||
"INVALID_TRANSITION", "Run is already terminal", status=409)
|
||||
now = self._now()
|
||||
await task_questions.cancel_pending(self, task_id, now)
|
||||
expected = run["revision"]
|
||||
run.update(
|
||||
status="cancelled", revision=expected + 1, finished_at=now,
|
||||
@@ -624,6 +640,7 @@ class OperationalTaskService:
|
||||
require(await self.repo.get_active_run(str(task_id)) is None,
|
||||
"ACTIVE_RUN_EXISTS", "Task already has an active run", status=409)
|
||||
now = self._now()
|
||||
await task_questions.cancel_pending(self, task_id, now)
|
||||
generation = task["execution_generation"] + 1
|
||||
run = self._new_run(task_id, generation, now)
|
||||
await self.repo.insert_run(_json(run))
|
||||
@@ -986,6 +1003,27 @@ class OperationalTaskService:
|
||||
event_cursor=cursor,
|
||||
)
|
||||
|
||||
async def presentation_history(
|
||||
self, task_id: UUID, *, after: int = 0, through: int | None = None, limit: int = 100,
|
||||
) -> TaskPresentationHistoryPage:
|
||||
require(0 <= after and (through is None or through >= 0) and 1 <= limit <= 200,
|
||||
"INVALID_ARGUMENT", "Invalid presentation history page", status=422)
|
||||
async with self.repo.transaction():
|
||||
await self._task(task_id)
|
||||
cursor = await self.repo.current_event_cursor()
|
||||
boundary = min(through, cursor) if through is not None else cursor
|
||||
rows = await self.repo.list_presentation_events(str(task_id), after, boundary, limit + 1)
|
||||
items = [TaskPresentationHistoryItem(
|
||||
event_seq=row["event_seq"], occurred_at=row["occurred_at"],
|
||||
run_id=row["metadata"]["run_id"],
|
||||
execution_generation=row["metadata"]["execution_generation"],
|
||||
command=row["metadata"]["command"],
|
||||
) for row in rows[:limit]]
|
||||
return TaskPresentationHistoryPage(
|
||||
task_id=task_id, event_cursor=boundary, items=items,
|
||||
next_after=items[-1].event_seq if len(rows) > limit else None,
|
||||
)
|
||||
|
||||
async def publish_presentation_event(
|
||||
self, task_id: UUID, command: TaskPresentationEventCommand
|
||||
) -> TaskPresentationEventResult:
|
||||
@@ -1019,7 +1057,16 @@ class OperationalTaskService:
|
||||
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)
|
||||
resolved = resolve_overlay(evidence.data_snapshot, command.command.mode)
|
||||
if command.command.data is not None:
|
||||
supplied = resolve_overlay(
|
||||
evidence.data_snapshot.model_copy(update={
|
||||
"data": command.command.data.model_dump(mode="json", exclude_unset=True),
|
||||
}), command.command.mode,
|
||||
)
|
||||
require(supplied == resolved, "OVERLAY_DATA_MISMATCH",
|
||||
"Overlay data must match the immutable evidence snapshot", status=422)
|
||||
payload["data"] = resolved
|
||||
metadata = {"task_id": str(task_id), "run_id": str(run["run_id"]),
|
||||
"execution_generation": run["generation"], "command": payload}
|
||||
try:
|
||||
@@ -1890,18 +1937,21 @@ class OperationalTaskService:
|
||||
replay = await self._request_replay(scope, command.request_id, body)
|
||||
if replay:
|
||||
return await self.detail(task_id)
|
||||
await self.repo.lock(f"task:{task_id}")
|
||||
await self.repo.lock(f"run:{run_id}")
|
||||
run = await self._run(task_id, run_id)
|
||||
require(run["revision"] == command.expected_revision, "VERSION_CONFLICT",
|
||||
"Run revision changed", status=409)
|
||||
require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION",
|
||||
"Only an active run can finish", status=409)
|
||||
require(run.get("agent_session_id") == command.source_session_id,
|
||||
"SESSION_MISMATCH", "Agent session does not own this run", status=409)
|
||||
self._require_run_owner(run, command)
|
||||
now = self._now()
|
||||
self._require_active_run_lease(run, now)
|
||||
task = await self._task(task_id)
|
||||
final_outcome = outcome
|
||||
pending_question = any(q["status"] == "pending" and str(q["run_id"]) == str(run_id)
|
||||
for q in await task_questions.list_questions(self, task_id))
|
||||
actions = []
|
||||
if outcome == "succeeded":
|
||||
await self._require_no_pending_calculations(task_id, run)
|
||||
if hasattr(self.repo, "pending_run_messages"):
|
||||
@@ -1929,7 +1979,9 @@ class OperationalTaskService:
|
||||
and plan.source_session_id == command.source_session_id
|
||||
))
|
||||
)
|
||||
if not same_generation or report.report.stage != "final":
|
||||
if pending_question:
|
||||
lifecycle = "waiting_human"
|
||||
elif not same_generation or report.report.stage != "final":
|
||||
final_outcome = "failed"
|
||||
lifecycle = "failed"
|
||||
failure_code = "ARTIFACT_INCOMPLETE"
|
||||
@@ -1974,6 +2026,7 @@ class OperationalTaskService:
|
||||
run_model = OperationalTaskRun.model_validate(run)
|
||||
await self.repo.update_run(_json(run_model), expected)
|
||||
if final_outcome != "succeeded":
|
||||
await task_questions.cancel_pending(self, task_id, now)
|
||||
await self._fail_run_messages(run_id, failure_code or "Run failed", now)
|
||||
task.update(
|
||||
lifecycle_status=lifecycle,
|
||||
@@ -1994,7 +2047,7 @@ class OperationalTaskService:
|
||||
failure_detail or "工况任务执行失败",
|
||||
now,
|
||||
)
|
||||
elif lifecycle == "waiting_human":
|
||||
elif lifecycle == "waiting_human" and not pending_question:
|
||||
pending_actions = any(
|
||||
action.approval_status == "pending" for action in actions
|
||||
)
|
||||
@@ -2207,6 +2260,7 @@ class OperationalTaskService:
|
||||
task["agent_lease_expires_at"] = None
|
||||
|
||||
async def _requeue_task(self, task: dict, now: datetime) -> None:
|
||||
await task_questions.cancel_pending(self, UUID(str(task["task_id"])), now)
|
||||
if self._supports_runs:
|
||||
active_run = await self.repo.get_active_run(str(task["task_id"]))
|
||||
while active_run is not None:
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Question lifecycle sharing the operational task transaction and notification bus."""
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.auth.permissions import OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE
|
||||
from app.domain.domain_errors import require
|
||||
from app.domain.schemas.operational_task import OperationalTaskDetail, OperationalTaskSummary
|
||||
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply, TaskQuestionRequest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.services.operational_tasks import OperationalTaskService
|
||||
|
||||
|
||||
async def list_questions(service: "OperationalTaskService", task_id: UUID) -> list[dict]:
|
||||
# Legacy test repositories do not implement question persistence.
|
||||
reader = getattr(service.repo, "list_questions", None)
|
||||
return await reader(str(task_id)) if reader else []
|
||||
|
||||
|
||||
async def cancel_pending(service: "OperationalTaskService", task_id: UUID, now: datetime) -> None:
|
||||
for raw in await list_questions(service, task_id):
|
||||
question = TaskQuestionRequest.model_validate(raw)
|
||||
if question.status != "pending":
|
||||
continue
|
||||
question = question.model_copy(update={"status": "cancelled", "version": question.version + 1})
|
||||
await service.repo.save_question(str(task_id), question.model_dump(mode="json"))
|
||||
await service._resolve_notifications(task_id, kinds=["human_input_required"],
|
||||
subject_type="question", subject_id=question.question_request_id,
|
||||
now=now, reason="question_superseded")
|
||||
|
||||
|
||||
async def create_question(service: "OperationalTaskService", task_id: UUID,
|
||||
command: TaskQuestionCreate) -> TaskQuestionRequest:
|
||||
require(OPERATIONAL_TASK_EXECUTE in service.permissions, "PERMISSION_DENIED",
|
||||
"Current user cannot publish task questions", status=403)
|
||||
body = command.model_dump(mode="json")
|
||||
scope = f"task:{task_id}:question:create"
|
||||
async with service.repo.transaction():
|
||||
await service.repo.lock(f"task:{task_id}")
|
||||
replay = await service._request_replay(scope, command.request_id, body)
|
||||
if replay:
|
||||
return TaskQuestionRequest.model_validate(await service.repo.get_question(
|
||||
str(task_id), replay["question_request_id"]))
|
||||
task = await service._task(task_id)
|
||||
service._require_actionable_task(task)
|
||||
require(task["task_type"] == "event_investigation", "INVALID_TASK_TYPE",
|
||||
"Questions require an investigation task", status=409)
|
||||
await service.repo.lock(f"run:{command.run_id}")
|
||||
run = await service._run(task_id, command.run_id)
|
||||
require(run["status"] in {"claimed", "running"}
|
||||
and run["generation"] == task["execution_generation"]
|
||||
and run.get("agent_session_id") == command.source_session_id,
|
||||
"SESSION_MISMATCH", "Question must belong to the active investigation run", status=409)
|
||||
now = service._now()
|
||||
service._require_active_run_lease(run, now)
|
||||
await service._require_no_pending_calculations(task_id, run)
|
||||
require(not any(q["status"] == "pending" for q in await list_questions(service, task_id)),
|
||||
"QUESTION_PENDING", "Answer the existing questions first", status=409)
|
||||
question = TaskQuestionRequest(question_request_id=uuid4(), task_id=task_id,
|
||||
run_id=command.run_id, execution_generation=run["generation"],
|
||||
source_session_id=command.source_session_id, questions=command.questions,
|
||||
status="pending", version=1, created_at=now)
|
||||
await service.repo.save_question(str(task_id), question.model_dump(mode="json"))
|
||||
task.update(revision=task["revision"] + 1, updated_at=now)
|
||||
await service.repo.update_task(OperationalTaskSummary.model_validate(task).model_dump(mode="json", exclude={"active_run", "latest_run"}))
|
||||
await service._task_event(task, now)
|
||||
await service._notify(task_id, "human_input_required", task["title"],
|
||||
";".join(q.title for q in question.questions), now,
|
||||
required_permission=OPERATIONAL_TASK_WRITE, subject_type="question",
|
||||
subject_id=question.question_request_id)
|
||||
await service._save_request(scope, command.request_id, body,
|
||||
{"question_request_id": str(question.question_request_id)})
|
||||
return question
|
||||
|
||||
|
||||
async def answer_question(service: "OperationalTaskService", task_id: UUID, question_id: UUID,
|
||||
command: TaskQuestionReply) -> OperationalTaskDetail:
|
||||
require(OPERATIONAL_TASK_WRITE in service.permissions, "PERMISSION_DENIED",
|
||||
"Current user cannot answer task questions", status=403)
|
||||
body = command.model_dump(mode="json")
|
||||
scope = f"task:{task_id}:question:{question_id}:answer"
|
||||
async with service.repo.transaction():
|
||||
await service.repo.lock(f"task:{task_id}")
|
||||
if await service._request_replay(scope, command.request_id, body):
|
||||
return await service.detail(task_id)
|
||||
task = await service._task(task_id)
|
||||
service._require_actionable_task(task)
|
||||
raw = await service.repo.get_question(str(task_id), str(question_id))
|
||||
require(raw is not None, "QUESTION_NOT_FOUND", "Question not found", status=404)
|
||||
question = TaskQuestionRequest.model_validate(raw)
|
||||
require(question.status == "pending" and question.version == command.expected_version
|
||||
and question.execution_generation == task["execution_generation"],
|
||||
"VERSION_CONFLICT", "Question is no longer pending in the current generation", status=409)
|
||||
require(task["lifecycle_status"] == "waiting_human"
|
||||
and await service.repo.get_active_run(str(task_id)) is None,
|
||||
"RUN_NOT_SETTLED", "Wait until the investigation has entered waiting_human", status=409)
|
||||
answers = {a.question_id: a for a in command.answers}
|
||||
known = {q.question_id for q in question.questions}
|
||||
require(len(answers) == len(command.answers) and set(answers) <= known,
|
||||
"ANSWER_INVALID", "Unknown or duplicate questions", status=422)
|
||||
for q in question.questions:
|
||||
answer = answers.get(q.question_id)
|
||||
selected = answer.selected_option_ids if answer else []
|
||||
text = answer.custom_text.strip() if answer else ""
|
||||
require(len(set(selected)) == len(selected) and set(selected) <= {o.id for o in q.options}
|
||||
and (q.mode == "multiple" or len(selected) <= 1)
|
||||
and (q.allow_custom or not text) and (not q.required or bool(selected or text)),
|
||||
"ANSWER_INVALID", "Answer does not satisfy the question", status=422)
|
||||
now = service._now()
|
||||
question = question.model_copy(update={"status": "answered", "version": question.version + 1,
|
||||
"answers": command.answers, "answered_at": now, "answered_by": service._actor()})
|
||||
await service.repo.save_question(str(task_id), question.model_dump(mode="json"))
|
||||
await service._resolve_notifications(task_id, kinds=["human_input_required"],
|
||||
subject_type="question", subject_id=question_id, now=now, reason="question_answered")
|
||||
await service._requeue_task(task, now)
|
||||
await service.repo.update_task(OperationalTaskSummary.model_validate(task).model_dump(mode="json", exclude={"active_run", "latest_run"}))
|
||||
await service._task_event(task, now)
|
||||
await service._save_request(scope, command.request_id, body, {"task_id": str(task_id)})
|
||||
return await service.detail(task_id)
|
||||
@@ -1,13 +1,22 @@
|
||||
from typing import Any
|
||||
|
||||
from app.algorithms.valve_isolation.topology_search import valve_isolation_analysis
|
||||
from app.native.wndb.gis.network_views import get_network_link_nodes
|
||||
from app.algorithms.valve_isolation.topology_search import (
|
||||
AccidentElementType,
|
||||
valve_isolation_analysis,
|
||||
)
|
||||
from app.native.wndb.gis.network_views import get_valve_isolation_topology
|
||||
|
||||
|
||||
def analyze_valve_isolation(
|
||||
network: str,
|
||||
accident_element: str | list[str],
|
||||
disabled_valves: list[str] = None,
|
||||
disabled_valves: list[str] | None = None,
|
||||
accident_element_type: AccidentElementType = "auto",
|
||||
) -> dict[str, Any]:
|
||||
link_entries = get_network_link_nodes(network)
|
||||
return valve_isolation_analysis(link_entries, accident_element, disabled_valves)
|
||||
link_entries, source_nodes, closed_links = get_valve_isolation_topology(network)
|
||||
return valve_isolation_analysis(
|
||||
link_entries, accident_element, disabled_valves,
|
||||
accident_element_type=accident_element_type,
|
||||
water_source_nodes=source_nodes,
|
||||
closed_links=closed_links,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user