Add UI delivery presenters and event DTOs
This commit is contained in:
@@ -45,11 +45,11 @@
|
||||
## Package E: Delivery
|
||||
|
||||
- [x] typed worker registration schema
|
||||
- [ ] task event DTO normalization
|
||||
- [x] task event DTO normalization
|
||||
- [ ] confirmation cards
|
||||
- [ ] progress cards
|
||||
- [x] progress cards
|
||||
- [ ] artifact cards
|
||||
- [ ] error cards
|
||||
- [x] error cards
|
||||
|
||||
## Package F: Validation
|
||||
|
||||
|
||||
@@ -26,7 +26,14 @@
|
||||
"task_id": "task_1",
|
||||
"response_type": "answer | task_started | confirmation_required | error",
|
||||
"message": "string",
|
||||
"cards": []
|
||||
"cards": [
|
||||
{
|
||||
"type": "plan_card"
|
||||
},
|
||||
{
|
||||
"type": "progress_card"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -53,7 +60,8 @@
|
||||
"progress": {
|
||||
"completed": 2,
|
||||
"total": 5
|
||||
}
|
||||
},
|
||||
"cards": []
|
||||
}
|
||||
```
|
||||
|
||||
@@ -66,9 +74,18 @@ WebSocket может быть добавлен как secondary adapter, но co
|
||||
```json
|
||||
{
|
||||
"event": "node_started",
|
||||
"task_id": "task_1",
|
||||
"node_id": "node_3",
|
||||
"timestamp": "..."
|
||||
"data": {
|
||||
"event_id": "evt_1",
|
||||
"event_type": "node_started",
|
||||
"task_id": "task_1",
|
||||
"node_id": "node_3",
|
||||
"timestamp": "...",
|
||||
"payload": {},
|
||||
"card": {
|
||||
"type": "progress_card_ref",
|
||||
"task_id": "task_1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -309,6 +309,20 @@ syntax_check: success
|
||||
}
|
||||
```
|
||||
|
||||
## Backend Status
|
||||
|
||||
Сейчас backend уже формирует:
|
||||
|
||||
- `plan_card`
|
||||
- `progress_card`
|
||||
- `error_card`
|
||||
- normalized task event DTO with optional card references
|
||||
|
||||
Следующие карточки остаются следующими шагами:
|
||||
|
||||
- `confirmation_card`
|
||||
- `artifact_card`
|
||||
|
||||
## Принцип
|
||||
|
||||
Пользователь должен всегда понимать:
|
||||
|
||||
@@ -25,6 +25,12 @@ from ai_orchestrator.application.services.workers import (
|
||||
WorkerService,
|
||||
)
|
||||
from ai_orchestrator.config import AppSettings
|
||||
from ai_orchestrator.delivery.http.presenters import (
|
||||
build_error_card,
|
||||
build_plan_card,
|
||||
build_progress_card,
|
||||
build_task_event_dto,
|
||||
)
|
||||
from ai_orchestrator.delivery.http.schemas import (
|
||||
ChatRequest,
|
||||
ChatResponse,
|
||||
@@ -32,6 +38,7 @@ from ai_orchestrator.delivery.http.schemas import (
|
||||
ConfirmationRejectRequest,
|
||||
CreateTaskRequestSchema,
|
||||
TaskActionRequest,
|
||||
TaskEventDto,
|
||||
TaskStatusResponse,
|
||||
WorkerDispatchRequest,
|
||||
WorkerDispatchResponse,
|
||||
@@ -134,12 +141,16 @@ def create_app(settings: AppSettings | None = None) -> FastAPI:
|
||||
)
|
||||
graph = orchestrator.plan_task(task.task_id)
|
||||
execution_engine.execute_ready_nodes(task=task, graph=graph)
|
||||
cards = [
|
||||
build_plan_card(task, graph),
|
||||
build_progress_card(task, graph),
|
||||
]
|
||||
return ChatResponse(
|
||||
conversation_id=task.conversation_id or "conv_default",
|
||||
task_id=task.task_id,
|
||||
response_type="task_started",
|
||||
message="Task accepted and planned.",
|
||||
cards=[{"type": "plan_card", "task_id": task.task_id}],
|
||||
cards=cards,
|
||||
)
|
||||
|
||||
@app.post("/tasks", response_model=TaskStatusResponse)
|
||||
@@ -154,11 +165,16 @@ def create_app(settings: AppSettings | None = None) -> FastAPI:
|
||||
)
|
||||
graph = orchestrator.plan_task(task.task_id)
|
||||
execution_engine.execute_ready_nodes(task=task, graph=graph)
|
||||
cards = [
|
||||
build_plan_card(task, graph),
|
||||
build_progress_card(task, graph),
|
||||
]
|
||||
return TaskStatusResponse(
|
||||
task_id=task.task_id,
|
||||
status=task.status.value,
|
||||
current_node=task.current_node_id,
|
||||
progress={"completed": 0, "total": len(graph.nodes)},
|
||||
cards=cards,
|
||||
)
|
||||
|
||||
@app.get("/tasks/{task_id}", response_model=TaskStatusResponse)
|
||||
@@ -173,6 +189,7 @@ def create_app(settings: AppSettings | None = None) -> FastAPI:
|
||||
status=task.status.value,
|
||||
current_node=task.current_node_id,
|
||||
progress={"completed": completed, "total": len(graph.nodes)},
|
||||
cards=[build_progress_card(task, graph)],
|
||||
)
|
||||
|
||||
@app.get("/tasks/{task_id}/events")
|
||||
@@ -181,15 +198,10 @@ def create_app(settings: AppSettings | None = None) -> FastAPI:
|
||||
|
||||
async def iterator():
|
||||
for event in events:
|
||||
dto = TaskEventDto.model_validate(build_task_event_dto(event))
|
||||
yield {
|
||||
"event": event.event_type,
|
||||
"data": {
|
||||
"event_id": event.event_id,
|
||||
"task_id": event.task_id,
|
||||
"node_id": event.node_id,
|
||||
"timestamp": event.occurred_at.isoformat(),
|
||||
"payload": event.payload,
|
||||
},
|
||||
"data": dto.model_dump(),
|
||||
}
|
||||
|
||||
return EventSourceResponse(iterator())
|
||||
@@ -230,6 +242,7 @@ def create_app(settings: AppSettings | None = None) -> FastAPI:
|
||||
status=task.status.value,
|
||||
current_node=task.current_node_id,
|
||||
progress={"completed": completed, "total": len(graph.nodes)},
|
||||
cards=[build_progress_card(task, graph)],
|
||||
)
|
||||
|
||||
@app.post("/tasks/{task_id}/cancel", response_model=TaskStatusResponse)
|
||||
@@ -244,6 +257,14 @@ def create_app(settings: AppSettings | None = None) -> FastAPI:
|
||||
status=task.status.value,
|
||||
current_node=task.current_node_id,
|
||||
progress={"completed": completed, "total": len(graph.nodes)},
|
||||
cards=[
|
||||
build_progress_card(task, graph),
|
||||
build_error_card(
|
||||
task_id=task.task_id,
|
||||
title="Task cancelled",
|
||||
message=payload.reason or "cancelled",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@app.get("/workers", response_model=WorkerListResponse)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ai_orchestrator.domain.events import DomainEvent
|
||||
from ai_orchestrator.domain.models import ConfirmationRequest, ExecutionGraph, Task
|
||||
|
||||
|
||||
def build_plan_card(task: Task, graph: ExecutionGraph) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "plan_card",
|
||||
"task_id": task.task_id,
|
||||
"title": "Execution Plan",
|
||||
"goal": task.goal,
|
||||
"steps": [
|
||||
{
|
||||
"node_id": node.node_id,
|
||||
"node_type": node.node_type.value,
|
||||
"status": node.status.value,
|
||||
"dependencies": node.dependencies,
|
||||
}
|
||||
for node in graph.nodes
|
||||
],
|
||||
"actions": ["run", "edit_plan", "run_step_by_step", "use_strong_model", "use_local_only"],
|
||||
}
|
||||
|
||||
|
||||
def build_progress_card(task: Task, graph: ExecutionGraph) -> dict[str, Any]:
|
||||
completed = sum(1 for node in graph.nodes if node.status.value == "completed")
|
||||
return {
|
||||
"type": "progress_card",
|
||||
"task_id": task.task_id,
|
||||
"status": task.status.value,
|
||||
"current_node": task.current_node_id,
|
||||
"progress": {"completed": completed, "total": len(graph.nodes)},
|
||||
"steps": [
|
||||
{
|
||||
"node_id": node.node_id,
|
||||
"node_type": node.node_type.value,
|
||||
"status": node.status.value,
|
||||
"attempts": node.attempts,
|
||||
}
|
||||
for node in graph.nodes
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_confirmation_card(confirmation: ConfirmationRequest) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "confirmation_card",
|
||||
"task_id": confirmation.task_id,
|
||||
"confirmation_id": confirmation.confirmation_id,
|
||||
"node_id": confirmation.node_id,
|
||||
"status": confirmation.status.value,
|
||||
"decision": confirmation.decision.decision.value,
|
||||
"reason": confirmation.decision.reason,
|
||||
"preview": confirmation.decision.preview,
|
||||
"actions": [
|
||||
"approve_once",
|
||||
"approve_task",
|
||||
"approve_project",
|
||||
"reject",
|
||||
"edit_policy",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_artifact_card(
|
||||
*,
|
||||
task_id: str,
|
||||
artifact_type: str,
|
||||
title: str,
|
||||
data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "artifact_card",
|
||||
"task_id": task_id,
|
||||
"artifact_type": artifact_type,
|
||||
"title": title,
|
||||
"data": data,
|
||||
}
|
||||
|
||||
|
||||
def build_error_card(
|
||||
*,
|
||||
task_id: str,
|
||||
title: str,
|
||||
message: str,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "error_card",
|
||||
"task_id": task_id,
|
||||
"title": title,
|
||||
"message": message,
|
||||
"details": details or {},
|
||||
"actions": ["retry", "show_log", "stop_task"],
|
||||
}
|
||||
|
||||
|
||||
def build_task_event_dto(event: DomainEvent) -> dict[str, Any]:
|
||||
payload = dict(event.payload)
|
||||
card: dict[str, Any] | None = None
|
||||
if event.event_type == "confirmation_requested":
|
||||
card = {
|
||||
"type": "confirmation_card_ref",
|
||||
"confirmation_id": payload.get("confirmation_id"),
|
||||
"task_id": event.task_id,
|
||||
}
|
||||
elif event.event_type in {"node_started", "node_completed", "task_planned", "task_resumed"}:
|
||||
card = {"type": "progress_card_ref", "task_id": event.task_id}
|
||||
elif event.event_type in {"task_failed", "confirmation_rejected"}:
|
||||
card = {"type": "error_card_ref", "task_id": event.task_id}
|
||||
return {
|
||||
"event_id": event.event_id,
|
||||
"event_type": event.event_type,
|
||||
"task_id": event.task_id,
|
||||
"conversation_id": event.conversation_id,
|
||||
"node_id": event.node_id,
|
||||
"timestamp": event.occurred_at.isoformat(),
|
||||
"payload": payload,
|
||||
"card": card,
|
||||
}
|
||||
@@ -34,6 +34,7 @@ class TaskStatusResponse(BaseModel):
|
||||
status: str
|
||||
current_node: str | None
|
||||
progress: dict[str, int]
|
||||
cards: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ConfirmationApproveRequest(BaseModel):
|
||||
@@ -113,3 +114,14 @@ class WorkerResultMessage(BaseModel):
|
||||
result: dict[str, Any] = Field(default_factory=dict)
|
||||
artifacts: list[dict[str, Any]] = Field(default_factory=list)
|
||||
error: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class TaskEventDto(BaseModel):
|
||||
event_id: str
|
||||
event_type: str
|
||||
task_id: str
|
||||
conversation_id: str | None = None
|
||||
node_id: str | None = None
|
||||
timestamp: str
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
card: dict[str, Any] | None = None
|
||||
|
||||
@@ -30,6 +30,31 @@ def test_post_tasks_creates_planned_task() -> None:
|
||||
assert response.status_code == 200
|
||||
assert payload["status"] == "running"
|
||||
assert payload["progress"]["total"] == 2
|
||||
assert any(card["type"] == "plan_card" for card in payload["cards"])
|
||||
assert any(card["type"] == "progress_card" for card in payload["cards"])
|
||||
|
||||
|
||||
def test_chat_response_contains_ui_cards() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/chat",
|
||||
json={
|
||||
"project_id": "default",
|
||||
"conversation_id": "conv_demo",
|
||||
"message": "Build plan",
|
||||
"attachments": [],
|
||||
"mode": "auto",
|
||||
"preferences": {"show_plan": True},
|
||||
},
|
||||
)
|
||||
|
||||
payload = response.json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert payload["response_type"] == "task_started"
|
||||
assert any(card["type"] == "plan_card" for card in payload["cards"])
|
||||
assert any(card["type"] == "progress_card" for card in payload["cards"])
|
||||
|
||||
|
||||
def test_cancel_endpoint_marks_task_cancelled() -> None:
|
||||
@@ -48,6 +73,7 @@ def test_cancel_endpoint_marks_task_cancelled() -> None:
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "cancelled"
|
||||
assert any(card["type"] == "error_card" for card in response.json()["cards"])
|
||||
|
||||
|
||||
def test_worker_registration_is_exposed_via_list_endpoint() -> None:
|
||||
@@ -138,3 +164,23 @@ def test_worker_websocket_heartbeat_and_poll() -> None:
|
||||
assert heartbeat_ack["status"] in {"online", "busy"}
|
||||
assert commands["type"] == "commands"
|
||||
assert len(commands["commands"]) == 1
|
||||
|
||||
|
||||
def test_task_events_stream_returns_normalized_event_payload() -> None:
|
||||
client = TestClient(app)
|
||||
created = client.post(
|
||||
"/tasks",
|
||||
json={
|
||||
"project_id": "default",
|
||||
"goal": "Event check",
|
||||
"inputs": {},
|
||||
"execution_mode": "agent_graph",
|
||||
},
|
||||
).json()
|
||||
|
||||
with client.stream("GET", f"/tasks/{created['task_id']}/events") as response:
|
||||
body = response.read().decode()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "event_type" in body
|
||||
assert "progress_card_ref" in body or "task_planned" in body
|
||||
|
||||
Reference in New Issue
Block a user