diff --git a/server_api/main.py b/server_api/main.py index a97153f..29305f1 100644 --- a/server_api/main.py +++ b/server_api/main.py @@ -21,6 +21,7 @@ from server_api.auth.database import get_db from server_api.auth.router import get_current_user from server_api.ehtool import router as ehtool_router +from server_api.workflow import router as workflow_router from fastapi.staticfiles import StaticFiles import os @@ -77,6 +78,7 @@ def _ensure_chatbot(): app.include_router(auth_router.router) app.include_router(ehtool_router.router, prefix="/eh", tags=["ehtool"]) +app.include_router(workflow_router) app.add_middleware( CORSMiddleware, diff --git a/server_api/workflow/__init__.py b/server_api/workflow/__init__.py new file mode 100644 index 0000000..5bc0c2e --- /dev/null +++ b/server_api/workflow/__init__.py @@ -0,0 +1,3 @@ +from .router import router + +__all__ = ["router"] diff --git a/server_api/workflow/metrics.py b/server_api/workflow/metrics.py new file mode 100644 index 0000000..2266f05 --- /dev/null +++ b/server_api/workflow/metrics.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from collections import Counter +from datetime import datetime, timezone +from typing import Any, Dict, Iterable, List, Optional + + +def _parse_timestamp(raw: Any) -> Optional[datetime]: + if not isinstance(raw, str) or not raw.strip(): + return None + normalized = raw.strip().replace("Z", "+00:00") + try: + dt = datetime.fromisoformat(normalized) + except ValueError: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def _isoformat_z(dt: Optional[datetime]) -> Optional[str]: + if dt is None: + return None + return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def compute_workflow_metrics(events: Iterable[Dict[str, Any]]) -> Dict[str, Any]: + event_list: List[Dict[str, Any]] = list(events) + + event_type_counts: Counter[str] = Counter() + transition_counts: Counter[str] = Counter() + timestamps: List[datetime] = [] + approvals = 0 + rejections = 0 + + for event in event_list: + event_type = event.get("type") + if isinstance(event_type, str) and event_type: + event_type_counts[event_type] += 1 + + outcome = event.get("outcome") + if outcome == "approved": + approvals += 1 + elif outcome == "rejected": + rejections += 1 + + from_stage = event.get("from_stage") + to_stage = event.get("to_stage") + if isinstance(from_stage, str) and from_stage and isinstance(to_stage, str) and to_stage: + transition_counts[f"{from_stage}->{to_stage}"] += 1 + + parsed_ts = _parse_timestamp(event.get("timestamp")) + if parsed_ts is not None: + timestamps.append(parsed_ts) + + decisions_total = approvals + rejections + approval_rate = approvals / decisions_total if decisions_total else 0.0 + rejection_rate = rejections / decisions_total if decisions_total else 0.0 + + first_ts = min(timestamps) if timestamps else None + last_ts = max(timestamps) if timestamps else None + + return { + "event_counts_by_type": dict(sorted(event_type_counts.items())), + "approvals": { + "count": approvals, + "rate": approval_rate, + }, + "rejections": { + "count": rejections, + "rate": rejection_rate, + }, + "stage_transitions": dict(sorted(transition_counts.items())), + "first_event_timestamp": _isoformat_z(first_ts), + "last_event_timestamp": _isoformat_z(last_ts), + "total_events": len(event_list), + } diff --git a/server_api/workflow/router.py b/server_api/workflow/router.py new file mode 100644 index 0000000..b808f73 --- /dev/null +++ b/server_api/workflow/router.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from fastapi import APIRouter, Request + +from server_api.workflow.metrics import compute_workflow_metrics + +router = APIRouter(prefix="/api/workflows", tags=["workflow"]) + + +@router.get("/{workflow_id}/metrics") +def get_workflow_metrics(workflow_id: str, request: Request): + store = getattr(request.app.state, "workflow_events_store", {}) + events = store.get(workflow_id, []) if isinstance(store, dict) else [] + if not isinstance(events, list): + events = [] + return { + "workflow_id": workflow_id, + "metrics": compute_workflow_metrics(events), + } diff --git a/tests/test_workflow_metrics.py b/tests/test_workflow_metrics.py new file mode 100644 index 0000000..1e597d5 --- /dev/null +++ b/tests/test_workflow_metrics.py @@ -0,0 +1,104 @@ +import unittest + +from fastapi.testclient import TestClient + +from fastapi import FastAPI +from server_api.workflow.router import router as workflow_router + + +class WorkflowMetricsEndpointTests(unittest.TestCase): + def setUp(self): + self.app = FastAPI() + self.app.include_router(workflow_router) + self.client = TestClient(self.app) + self.app.state.workflow_events_store = {} + + def test_endpoint_exists_and_returns_stable_schema(self): + response = self.client.get('/api/workflows/wf-1/metrics') + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload['workflow_id'], 'wf-1') + self.assertIn('metrics', payload) + + metrics = payload['metrics'] + self.assertEqual( + set(metrics.keys()), + { + 'event_counts_by_type', + 'approvals', + 'rejections', + 'stage_transitions', + 'first_event_timestamp', + 'last_event_timestamp', + 'total_events', + }, + ) + + def test_empty_workflow_events_use_safe_defaults(self): + self.app.state.workflow_events_store = {'empty': []} + + response = self.client.get('/api/workflows/empty/metrics') + self.assertEqual(response.status_code, 200) + + metrics = response.json()['metrics'] + self.assertEqual(metrics['event_counts_by_type'], {}) + self.assertEqual(metrics['approvals'], {'count': 0, 'rate': 0.0}) + self.assertEqual(metrics['rejections'], {'count': 0, 'rate': 0.0}) + self.assertEqual(metrics['stage_transitions'], {}) + self.assertIsNone(metrics['first_event_timestamp']) + self.assertIsNone(metrics['last_event_timestamp']) + self.assertEqual(metrics['total_events'], 0) + + def test_metrics_align_with_seeded_events(self): + self.app.state.workflow_events_store = { + 'wf-seeded': [ + { + 'type': 'stage_transition', + 'from_stage': 'draft', + 'to_stage': 'review', + 'timestamp': '2026-01-01T00:00:00Z', + }, + { + 'type': 'agent_action', + 'outcome': 'approved', + 'timestamp': '2026-01-01T00:02:00Z', + }, + { + 'type': 'agent_action', + 'outcome': 'rejected', + 'timestamp': '2026-01-01T00:03:00Z', + }, + { + 'type': 'stage_transition', + 'from_stage': 'review', + 'to_stage': 'approved', + 'timestamp': '2026-01-01T00:04:00Z', + }, + { + 'type': 'stage_transition', + 'from_stage': 'draft', + 'to_stage': 'review', + 'timestamp': '2026-01-01T00:05:00Z', + }, + ] + } + + response = self.client.get('/api/workflows/wf-seeded/metrics') + self.assertEqual(response.status_code, 200) + metrics = response.json()['metrics'] + + self.assertEqual(metrics['event_counts_by_type'], {'agent_action': 2, 'stage_transition': 3}) + self.assertEqual(metrics['approvals'], {'count': 1, 'rate': 0.5}) + self.assertEqual(metrics['rejections'], {'count': 1, 'rate': 0.5}) + self.assertEqual( + metrics['stage_transitions'], + {'draft->review': 2, 'review->approved': 1}, + ) + self.assertEqual(metrics['first_event_timestamp'], '2026-01-01T00:00:00Z') + self.assertEqual(metrics['last_event_timestamp'], '2026-01-01T00:05:00Z') + self.assertEqual(metrics['total_events'], 5) + + +if __name__ == '__main__': + unittest.main()