diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 88c8290..a321a88 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -50,7 +50,18 @@ "Bash(mv /c/Users/antjs/uds-renderer/create_plan.js /c/Users/antjs/uds-renderer/create_plan.cjs)", "Bash(node create_plan.cjs)", "Bash(git pull:*)", - "Bash(PYTHONIOENCODING=utf-8 python packages/pm-data-tools/scripts/generate_synthetic_data.py --verify)" + "Bash(PYTHONIOENCODING=utf-8 python packages/pm-data-tools/scripts/generate_synthetic_data.py --verify)", + "Bash(git worktree:*)", + "mcp__c1fc4002-5f49-5f9d-a4e5-93c4ef5d6a75__google_drive_search", + "WebSearch", + "WebFetch(domain:www.gov.uk)", + "WebFetch(domain:www.instituteforgovernment.org.uk)", + "WebFetch(domain:www.newcivilengineer.com)", + "WebFetch(domain:publications.parliament.uk)", + "WebFetch(domain:www.constructionnews.co.uk)", + "WebFetch(domain:www.productivity.ac.uk)", + "WebFetch(domain:www.reinforcementproductsonline.co.uk)", + "Bash(2)" ] } } diff --git a/.gitignore b/.gitignore index 48d077d..daa28e0 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,24 @@ Thumbs.db .env .env.local *.local + +# SQLite databases (demo/debug) +*.db +*.sqlite + +# Claude worktrees +.claude/worktrees/ + +# Temp/debug files +*_debug.db +demo_*.json + +# Internal docs (not for public repo) +ASSUMPTION_DRIFT_PROMPT.md + +# Office temp files +~$* +*.docx +*.pdf +*.pptx +PROJ-MPA-HACK-005-dashboard.html diff --git a/README.md b/README.md index 54334dd..3c38530 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,9 @@ AI reliability framework with confidence extraction and outlier mining. ### pm-mcp-servers MCP servers enabling Claude to interact with PM data. -- **Servers**: pm-data, pm-validate, pm-analyse, pm-benchmark +- **Unified server**: `pda-platform-server` exposes all 41 tools through a single endpoint +- **Modules**: pm-data (6 tools), pm-analyse (6), pm-validate (4), pm-nista (5), pm-assure (20) +- **Remote access**: `pda-platform-remote` adds SSE transport for use with Claude.ai - **Install**: `pip install pm-mcp-servers` ## Specifications @@ -78,7 +80,7 @@ All specifications are in the `specs/` directory: | Spec | Description | |------|-------------| | [Canonical Model](specs/canonical-model/) | 12-entity JSON Schema for PM data | -| [MCP Servers](specs/mcp-servers/) | 4 servers, 19 tools for AI integration | +| [MCP Servers](specs/mcp-servers/) | 5 modules, 41 tools for AI integration | | [Benchmarks](specs/benchmarks/) | 5 evaluation tasks for PM AI | | [Synthetic Data](specs/synthetic-data/) | Privacy-preserving data generation | diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index add7c78..942582b 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -191,33 +191,28 @@ openai # Optional: GPT integration └──────────────────────────┘ ``` -#### Server Types +#### Unified Server -**1. pm-data-server** -- Tools: `read_project`, `list_tasks`, `get_resource`, `export_project` -- Purpose: Basic CRUD operations on PM data +The **`pda-platform-server`** is a single MCP endpoint that aggregates all 41 tools +from 5 modules. Each module exports a `registry.py` with its tool definitions and a +`dispatch()` function. The unified server imports and combines them at startup. -**2. pm-validate-server** -- Tools: `validate_nista`, `validate_structure`, `check_compliance` -- Purpose: Data quality and compliance checking +For remote access (e.g., from Claude.ai), **`pda-platform-remote`** wraps the unified +server in an SSE transport layer using Starlette and uvicorn. -**3. pm-analyse-server** -- Tools: `analyze_schedule`, `find_critical_path`, `identify_risks`, `calculate_metrics` -- Purpose: Advanced analytics and insights +#### Server Modules -**4. pm-benchmark-server** -- Tools: `compare_projects`, `benchmark_performance`, `generate_report` -- Purpose: Cross-project comparison +| Module | Tools | Purpose | +|--------|-------|---------| +| **pm-data** | 6 | Project data loading, querying, critical path, conversion | +| **pm-analyse** | 6 | AI-powered risk identification, forecasting, health assessment | +| **pm-validate** | 4 | Structural, semantic, and NISTA compliance validation | +| **pm-nista** | 5 | GMPP reporting, AI narratives, NISTA API integration | +| **pm-assure** | 20 | Assurance lifecycle: assumptions, compliance, findings, scheduling, overrides, lessons, overhead, workflows, domain classification | +| **Total** | **41** | | -**5. pm-nista-server** -- Tools: `nista_export`, `nista_validate`, `nista_transform` -- Purpose: NISTA-specific operations - -**6. pm-assure-server** -- Tools: 16 tools across P1–P10 (see [docs/assurance.md](./assurance.md)) -- Purpose: Full assurance quality lifecycle — artefact currency, compliance trends, - review action tracking, confidence monitoring, adaptive scheduling, override logging, - lessons learned, overhead optimisation, workflow orchestration, domain classification +Individual servers (`pm-data-server`, `pm-assure-server`, etc.) remain available for +use cases where only a subset of tools is needed. **Design Decisions**: - **Stateless**: Each request is independent diff --git a/docs/getting-started.md b/docs/getting-started.md index 0a0f31e..37946db 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -208,25 +208,13 @@ pip install pm-mcp-servers # or %APPDATA%/Claude/claude_desktop_config.json (Windows) ``` -Add to your Claude config: +Add the **unified server** to your Claude config (recommended — gives access to all 41 tools): ```json { "mcpServers": { - "pm-data": { - "command": "pm-data-server", - "args": [] - }, - "pm-validate": { - "command": "pm-validate-server", - "args": [] - }, - "pm-analyse": { - "command": "pm-analyse-server", - "args": [] - }, - "pm-assure": { - "command": "pm-assure-server", + "pda-platform": { + "command": "pda-platform-server", "args": [], "env": { "ANTHROPIC_API_KEY": "sk-ant-..." } } diff --git a/packages/agent-task-planning/examples/02_multi_provider.py b/packages/agent-task-planning/examples/02_multi_provider.py index 064b34a..87563e3 100644 --- a/packages/agent-task-planning/examples/02_multi_provider.py +++ b/packages/agent-task-planning/examples/02_multi_provider.py @@ -7,13 +7,12 @@ import asyncio import os -from typing import Optional -from agent_planning import TodoListPlanner, GuardrailConfig +from agent_planning import GuardrailConfig, TodoListPlanner from agent_planning.providers.base import BaseProvider -def get_provider(name: str) -> Optional[BaseProvider]: +def get_provider(name: str) -> BaseProvider | None: """Get a provider by name.""" if name == "anthropic": from agent_planning.providers import AnthropicProvider diff --git a/packages/agent-task-planning/examples/03_with_guardrails.py b/packages/agent-task-planning/examples/03_with_guardrails.py index 3dbcd0f..8647e7d 100644 --- a/packages/agent-task-planning/examples/03_with_guardrails.py +++ b/packages/agent-task-planning/examples/03_with_guardrails.py @@ -9,7 +9,7 @@ import asyncio import os -from agent_planning import TodoListPlanner, GuardrailConfig +from agent_planning import GuardrailConfig, TodoListPlanner from agent_planning.providers import AnthropicProvider diff --git a/packages/agent-task-planning/examples/04_temporal_hybrid.py b/packages/agent-task-planning/examples/04_temporal_hybrid.py index 0e9f75f..ca52640 100644 --- a/packages/agent-task-planning/examples/04_temporal_hybrid.py +++ b/packages/agent-task-planning/examples/04_temporal_hybrid.py @@ -18,9 +18,9 @@ # For actual use, install temporalio and configure a Temporal server. try: - from temporalio import workflow, activity + from temporalio import activity, workflow from temporalio.client import Client - from temporalio.worker import Worker + from temporalio.worker import Worker # noqa: F401 TEMPORAL_AVAILABLE = True except ImportError: TEMPORAL_AVAILABLE = False @@ -67,9 +67,10 @@ async def agent_analyse(sources: list[str], topic: str) -> dict: This is where the LLM-based planning happens, wrapped in a deterministic activity with timeout and retry policies. """ + import os + from agent_planning import TodoListPlanner from agent_planning.providers import AnthropicProvider - import os provider = AnthropicProvider(api_key=os.getenv("ANTHROPIC_API_KEY")) planner = TodoListPlanner(provider=provider) @@ -164,7 +165,7 @@ async def main(): task_queue="research-queue", ) - print(f"Research complete!") + print("Research complete!") print(f"Summary: {result.summary[:200]}...") print(f"Confidence: {result.confidence}") diff --git a/packages/agent-task-planning/examples/05_basic_confidence.py b/packages/agent-task-planning/examples/05_basic_confidence.py index 8447152..4a2f247 100644 --- a/packages/agent-task-planning/examples/05_basic_confidence.py +++ b/packages/agent-task-planning/examples/05_basic_confidence.py @@ -2,9 +2,10 @@ import asyncio import os + from agent_planning import ConfidenceExtractor, SchemaType -from agent_planning.providers import AnthropicProvider from agent_planning.guardrails import GuardrailConfig +from agent_planning.providers import AnthropicProvider async def main(): diff --git a/packages/agent-task-planning/examples/06_pm_extraction.py b/packages/agent-task-planning/examples/06_pm_extraction.py index aa38b8d..2dcb370 100644 --- a/packages/agent-task-planning/examples/06_pm_extraction.py +++ b/packages/agent-task-planning/examples/06_pm_extraction.py @@ -2,14 +2,14 @@ import asyncio import os + from agent_planning.confidence import ( ConfidenceExtractor, - SchemaType, ReviewLevel, + SchemaType, ) from agent_planning.providers import AnthropicProvider - PROJECT_CONTEXT = """ Project: Legacy System Migration Duration: 18 months diff --git a/packages/agent-task-planning/examples/07_batch_confidence.py b/packages/agent-task-planning/examples/07_batch_confidence.py index 6abfb57..3e4e2c3 100644 --- a/packages/agent-task-planning/examples/07_batch_confidence.py +++ b/packages/agent-task-planning/examples/07_batch_confidence.py @@ -2,14 +2,13 @@ import asyncio import os + from agent_planning.confidence import ( - ConfidenceExtractor, SchemaType, confidence_extract_batch, ) from agent_planning.providers import AnthropicProvider - CONTEXT = """ Project: Digital Transformation Initiative Budget: £5M over 2 years @@ -70,7 +69,7 @@ async def main(): progress_callback=progress, ) - print(f"\nBatch complete!") + print("\nBatch complete!") print(f"Succeeded: {result.queries_succeeded}") print(f"Failed: {result.queries_failed}") print(f"Total cost: ${result.total_cost_usd:.4f}") diff --git a/packages/agent-task-planning/examples/08_custom_schema.py b/packages/agent-task-planning/examples/08_custom_schema.py index a5958e4..a2a3c5c 100644 --- a/packages/agent-task-planning/examples/08_custom_schema.py +++ b/packages/agent-task-planning/examples/08_custom_schema.py @@ -3,6 +3,7 @@ import asyncio import os from dataclasses import dataclass + from agent_planning.confidence import ( ConfidenceExtractor, CustomSchema, diff --git a/packages/agent-task-planning/examples/09_basic_mining.py b/packages/agent-task-planning/examples/09_basic_mining.py index e67649d..2ec79d7 100644 --- a/packages/agent-task-planning/examples/09_basic_mining.py +++ b/packages/agent-task-planning/examples/09_basic_mining.py @@ -2,8 +2,9 @@ import asyncio import os -from agent_planning.mining import OutlierMiner, MiningConfig + from agent_planning.confidence import SchemaType +from agent_planning.mining import MiningConfig, OutlierMiner from agent_planning.providers import AnthropicProvider diff --git a/packages/agent-task-planning/examples/10_risk_mining.py b/packages/agent-task-planning/examples/10_risk_mining.py index 06868bc..c0eac93 100644 --- a/packages/agent-task-planning/examples/10_risk_mining.py +++ b/packages/agent-task-planning/examples/10_risk_mining.py @@ -2,11 +2,11 @@ import asyncio import os -from agent_planning.mining import OutlierMiner, MiningConfig + from agent_planning.confidence import SchemaType +from agent_planning.mining import MiningConfig, OutlierMiner from agent_planning.providers import AnthropicProvider - PROJECT_CONTEXT = """ Project: Cloud Migration Programme Budget: £5.2M diff --git a/packages/agent-task-planning/pyproject.toml b/packages/agent-task-planning/pyproject.toml index 81c9224..60e69b1 100644 --- a/packages/agent-task-planning/pyproject.toml +++ b/packages/agent-task-planning/pyproject.toml @@ -94,11 +94,12 @@ testpaths = ["tests"] asyncio_mode = "auto" [tool.ruff] -line-length = 88 +line-length = 120 target-version = "py310" [tool.ruff.lint] select = ["E", "F", "I", "N", "W", "UP"] +ignore = ["E501", "E722", "N818", "N806", "F821"] [tool.mypy] python_version = "3.10" diff --git a/packages/agent-task-planning/scripts/demo.py b/packages/agent-task-planning/scripts/demo.py index 14fedc3..29d427e 100644 --- a/packages/agent-task-planning/scripts/demo.py +++ b/packages/agent-task-planning/scripts/demo.py @@ -16,7 +16,7 @@ # Add src to path for development sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) -from agent_planning import TodoListPlanner, GuardrailConfig +from agent_planning import GuardrailConfig, TodoListPlanner def get_provider(provider_name: str, model: str | None = None): @@ -73,8 +73,9 @@ async def main(): # Setup logging if verbose if args.verbose: - import structlog import logging + + import structlog structlog.configure( wrapper_class=structlog.make_filtering_bound_logger(logging.DEBUG), ) diff --git a/packages/agent-task-planning/src/agent_planning/__init__.py b/packages/agent-task-planning/src/agent_planning/__init__.py index f3e88c9..31c1461 100644 --- a/packages/agent-task-planning/src/agent_planning/__init__.py +++ b/packages/agent-task-planning/src/agent_planning/__init__.py @@ -10,26 +10,26 @@ result = await planner.execute("Your complex task here") """ -from agent_planning.core.task import Task, TaskStatus -from agent_planning.core.state import TaskState, ExecutionResult -from agent_planning.core.planner import BasePlanner -from agent_planning.planners.todo_list import TodoListPlanner -from agent_planning.guardrails.limits import GuardrailConfig from agent_planning.confidence import ( ConfidenceExtractor, - confidence_extract, - confidence_extract_batch, ConfidenceResult, - SchemaType, ReviewLevel, + SchemaType, + confidence_extract, + confidence_extract_batch, ) +from agent_planning.core.planner import BasePlanner +from agent_planning.core.state import ExecutionResult, TaskState +from agent_planning.core.task import Task, TaskStatus +from agent_planning.guardrails.limits import GuardrailConfig from agent_planning.mining import ( + MiningConfig, + MiningResult, OutlierMiner, mine, mine_batch, - MiningConfig, - MiningResult, ) +from agent_planning.planners.todo_list import TodoListPlanner __version__ = "0.1.0" diff --git a/packages/agent-task-planning/src/agent_planning/confidence/__init__.py b/packages/agent-task-planning/src/agent_planning/confidence/__init__.py index c2c2eae..8eac699 100644 --- a/packages/agent-task-planning/src/agent_planning/confidence/__init__.py +++ b/packages/agent-task-planning/src/agent_planning/confidence/__init__.py @@ -6,21 +6,21 @@ confidence_extract_batch, ) from .models import ( - ConfidenceResult, BatchConfidenceResult, + ConfidenceResult, + EstimateMode, OutlierReport, ReviewLevel, - EstimateMode, ) from .schemas import ( - SchemaType, + BarrierItem, CustomSchema, - RiskItem, EstimateItem, - RecommendationItem, MilestoneItem, - BarrierItem, OutcomeMeasureItem, + RecommendationItem, + RiskItem, + SchemaType, StakeholderImpactItem, ) diff --git a/packages/agent-task-planning/src/agent_planning/confidence/aggregation.py b/packages/agent-task-planning/src/agent_planning/confidence/aggregation.py index f890be7..9c0dca4 100644 --- a/packages/agent-task-planning/src/agent_planning/confidence/aggregation.py +++ b/packages/agent-task-planning/src/agent_planning/confidence/aggregation.py @@ -2,7 +2,7 @@ from collections import Counter from statistics import mean, median, stdev -from typing import Any, Optional +from typing import Any from .models import OutlierReport @@ -213,7 +213,7 @@ def compute_overall_confidence(field_confidences: dict[str, float]) -> float: def check_early_stop( extractions: list[dict[str, Any]], threshold: float, - key_fields: Optional[list[str]] = None + key_fields: list[str] | None = None ) -> bool: """Check if samples agree enough to stop early. diff --git a/packages/agent-task-planning/src/agent_planning/confidence/extractor.py b/packages/agent-task-planning/src/agent_planning/confidence/extractor.py index 8aeafcf..087455a 100644 --- a/packages/agent-task-planning/src/agent_planning/confidence/extractor.py +++ b/packages/agent-task-planning/src/agent_planning/confidence/extractor.py @@ -3,31 +3,31 @@ import asyncio import json import time -from typing import Any, Optional, Union, Callable +from collections.abc import Callable +from typing import Any -from ..providers.base import BaseProvider from ..guardrails.limits import GuardrailConfig +from ..providers.base import BaseProvider +from .aggregation import ( + aggregate_categorical, + aggregate_list_fields, + aggregate_numeric, + aggregate_text_exact, + check_early_stop, + compute_field_confidence, + compute_overall_confidence, + detect_numeric_outliers, +) from .models import ( - ConfidenceResult, BatchConfidenceResult, + ConfidenceResult, OutlierReport, ReviewLevel, ) from .schemas import ( - SchemaType, CustomSchema, + SchemaType, get_schema_definition, - SCHEMA_DEFINITIONS, -) -from .aggregation import ( - detect_numeric_outliers, - aggregate_numeric, - aggregate_categorical, - aggregate_text_exact, - aggregate_list_fields, - compute_field_confidence, - compute_overall_confidence, - check_early_stop, ) @@ -37,7 +37,7 @@ class ConfidenceExtractor: def __init__( self, provider: BaseProvider, - guardrails: Optional[GuardrailConfig] = None, + guardrails: GuardrailConfig | None = None, ): """Initialise the confidence extractor. @@ -58,10 +58,10 @@ def __init__( async def extract( self, query: str, - context: Optional[str] = None, - schema: Union[SchemaType, CustomSchema] = SchemaType.RISK, - samples: Optional[int] = None, - temperature: Optional[float] = None, + context: str | None = None, + schema: SchemaType | CustomSchema = SchemaType.RISK, + samples: int | None = None, + temperature: float | None = None, early_stop: bool = True, ) -> ConfidenceResult: """Extract structured data with confidence scoring. @@ -201,10 +201,10 @@ async def extract( async def extract_batch( self, queries: list[str], - context: Optional[str] = None, - schemas: Optional[list[Union[SchemaType, CustomSchema]]] = None, + context: str | None = None, + schemas: list[SchemaType | CustomSchema] | None = None, max_concurrent: int = 3, - progress_callback: Optional[Callable] = None, + progress_callback: Callable | None = None, ) -> BatchConfidenceResult: """Extract from multiple queries with concurrency control. @@ -229,7 +229,7 @@ async def extract_batch( completed = 0 failed = 0 - async def process_one(query: str, schema: Union[SchemaType, CustomSchema]): + async def process_one(query: str, schema: SchemaType | CustomSchema): nonlocal completed, failed async with semaphore: try: @@ -238,7 +238,7 @@ async def process_one(query: str, schema: Union[SchemaType, CustomSchema]): if progress_callback: await progress_callback(completed, len(queries)) return result - except Exception as e: + except Exception: failed += 1 if progress_callback: await progress_callback(completed, len(queries)) @@ -264,7 +264,7 @@ async def process_one(query: str, schema: Union[SchemaType, CustomSchema]): def _build_extraction_prompt( self, query: str, - context: Optional[str], + context: str | None, schema_prompt: str ) -> str: """Build the full extraction prompt.""" @@ -298,7 +298,7 @@ def _build_extraction_prompt( return "\n".join(parts) - def _parse_extraction(self, content: str) -> Optional[dict[str, Any]]: + def _parse_extraction(self, content: str) -> dict[str, Any] | None: """Parse extraction response into structured data.""" # Clean up common formatting issues content = content.strip() @@ -351,7 +351,7 @@ def _aggregate_extractions( # Categorise fields numeric_fields = aggregation_fields.get("numeric", []) categorical_fields = aggregation_fields.get("categorical", []) - text_fields = aggregation_fields.get("text", []) + aggregation_fields.get("text", []) list_fields = aggregation_fields.get("list", []) for field in all_fields: @@ -407,7 +407,7 @@ def _determine_review_level( confidence: float, outliers: list[OutlierReport], field_confidence: dict[str, float] - ) -> tuple[ReviewLevel, Optional[str]]: + ) -> tuple[ReviewLevel, str | None]: """Determine appropriate human review level.""" # Expert required if outliers detected or very low confidence @@ -440,9 +440,9 @@ def _determine_review_level( async def confidence_extract( query: str, provider: BaseProvider, - context: Optional[str] = None, - schema: Union[SchemaType, CustomSchema] = SchemaType.RISK, - guardrails: Optional[GuardrailConfig] = None, + context: str | None = None, + schema: SchemaType | CustomSchema = SchemaType.RISK, + guardrails: GuardrailConfig | None = None, **kwargs ) -> ConfidenceResult: """Convenience function for single extraction. @@ -465,9 +465,9 @@ async def confidence_extract( async def confidence_extract_batch( queries: list[str], provider: BaseProvider, - context: Optional[str] = None, - schemas: Optional[list[Union[SchemaType, CustomSchema]]] = None, - guardrails: Optional[GuardrailConfig] = None, + context: str | None = None, + schemas: list[SchemaType | CustomSchema] | None = None, + guardrails: GuardrailConfig | None = None, **kwargs ) -> BatchConfidenceResult: """Convenience function for batch extraction. diff --git a/packages/agent-task-planning/src/agent_planning/confidence/models.py b/packages/agent-task-planning/src/agent_planning/confidence/models.py index ce34a1a..7bbed44 100644 --- a/packages/agent-task-planning/src/agent_planning/confidence/models.py +++ b/packages/agent-task-planning/src/agent_planning/confidence/models.py @@ -1,8 +1,8 @@ """Data models for confidence extraction.""" -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import Enum -from typing import Any, Optional +from typing import Any class ReviewLevel(Enum): @@ -48,7 +48,7 @@ class ConfidenceResult: tokens_used: int # Total tokens latency_ms: int # Total time review_level: ReviewLevel # Recommended review level - review_reason: Optional[str] = None # Why review recommended + review_reason: str | None = None # Why review recommended @property def review_recommended(self) -> bool: diff --git a/packages/agent-task-planning/src/agent_planning/confidence/schemas.py b/packages/agent-task-planning/src/agent_planning/confidence/schemas.py index b70b7d7..009e7a2 100644 --- a/packages/agent-task-planning/src/agent_planning/confidence/schemas.py +++ b/packages/agent-task-planning/src/agent_planning/confidence/schemas.py @@ -2,7 +2,6 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Any, Optional class SchemaType(Enum): @@ -25,9 +24,9 @@ class RiskItem: probability: int # 1-5 scale impact: int # 1-5 scale mitigation: str - owner: Optional[str] = None + owner: str | None = None status: str = "Open" # Open, Mitigating, Closed, Accepted - id: Optional[str] = None # Auto-generated if not provided + id: str | None = None # Auto-generated if not provided @property def score(self) -> int: @@ -41,12 +40,12 @@ class EstimateItem: description: str value: float # Point estimate unit: str # days, hours, GBP, etc. - range_low: Optional[float] = None # Lower bound - range_high: Optional[float] = None # Upper bound - optimistic: Optional[float] = None # For three-point estimates - pessimistic: Optional[float] = None # For three-point estimates + range_low: float | None = None # Lower bound + range_high: float | None = None # Upper bound + optimistic: float | None = None # For three-point estimates + pessimistic: float | None = None # For three-point estimates assumptions: list[str] = field(default_factory=list) - confidence_notes: Optional[str] = None + confidence_notes: str | None = None @dataclass @@ -55,8 +54,8 @@ class RecommendationItem: action: str rationale: str priority: str # High, Medium, Low - owner: Optional[str] = None # Suggested owner - timeframe: Optional[str] = None # When to complete + owner: str | None = None # Suggested owner + timeframe: str | None = None # When to complete dependencies: list[str] = field(default_factory=list) @@ -65,7 +64,7 @@ class MilestoneItem: """A project milestone.""" name: str description: str - target_date: Optional[str] = None # ISO format or descriptive + target_date: str | None = None # ISO format or descriptive dependencies: list[str] = field(default_factory=list) deliverables: list[str] = field(default_factory=list) @@ -86,10 +85,10 @@ class OutcomeMeasureItem: """An outcome measure or KPI.""" measure: str description: str - target: Optional[str] = None - baseline: Optional[str] = None - measurement_method: Optional[str] = None - frequency: Optional[str] = None # How often measured + target: str | None = None + baseline: str | None = None + measurement_method: str | None = None + frequency: str | None = None # How often measured @dataclass @@ -99,7 +98,7 @@ class StakeholderImpactItem: impact_description: str sentiment: str # Positive, Negative, Neutral, Mixed actions_required: list[str] = field(default_factory=list) - communication_needs: Optional[str] = None + communication_needs: str | None = None # Schema definitions for extraction prompts @@ -249,4 +248,4 @@ class CustomSchema: name: str extraction_prompt: str aggregation_fields: dict[str, list[str]] - output_class: Optional[type] = None # Optional dataclass for typed output + output_class: type | None = None # Optional dataclass for typed output diff --git a/packages/agent-task-planning/src/agent_planning/core/__init__.py b/packages/agent-task-planning/src/agent_planning/core/__init__.py index 1808e44..25bd44b 100644 --- a/packages/agent-task-planning/src/agent_planning/core/__init__.py +++ b/packages/agent-task-planning/src/agent_planning/core/__init__.py @@ -1,7 +1,7 @@ """Core components for agent planning.""" -from agent_planning.core.task import Task, TaskStatus -from agent_planning.core.state import TaskState, ExecutionResult from agent_planning.core.planner import BasePlanner +from agent_planning.core.state import ExecutionResult, TaskState +from agent_planning.core.task import Task, TaskStatus __all__ = ["Task", "TaskStatus", "TaskState", "ExecutionResult", "BasePlanner"] diff --git a/packages/agent-task-planning/src/agent_planning/core/planner.py b/packages/agent-task-planning/src/agent_planning/core/planner.py index 7211848..3a303a3 100644 --- a/packages/agent-task-planning/src/agent_planning/core/planner.py +++ b/packages/agent-task-planning/src/agent_planning/core/planner.py @@ -1,7 +1,6 @@ """Base planner interface.""" from abc import ABC, abstractmethod -from typing import Optional from agent_planning.core.state import ExecutionResult, TaskState from agent_planning.guardrails.limits import GuardrailConfig @@ -17,7 +16,7 @@ class BasePlanner(ABC): def __init__( self, provider: "BaseProvider", # type: ignore # Forward reference - guardrails: Optional[GuardrailConfig] = None, + guardrails: GuardrailConfig | None = None, ): """ Initialise the planner. diff --git a/packages/agent-task-planning/src/agent_planning/core/state.py b/packages/agent-task-planning/src/agent_planning/core/state.py index 2909895..5ba8d55 100644 --- a/packages/agent-task-planning/src/agent_planning/core/state.py +++ b/packages/agent-task-planning/src/agent_planning/core/state.py @@ -1,7 +1,7 @@ """State management for agent planning.""" from datetime import datetime -from typing import Optional + from pydantic import BaseModel, Field from agent_planning.core.task import Task, TaskStatus @@ -23,7 +23,7 @@ class TaskState(BaseModel): created_at: datetime = Field(default_factory=datetime.now) iteration: int = 0 - def add_task(self, content: str, dependencies: Optional[list[str]] = None) -> Task: + def add_task(self, content: str, dependencies: list[str] | None = None) -> Task: """Add a new task to the state.""" task = Task( content=content, @@ -32,14 +32,14 @@ def add_task(self, content: str, dependencies: Optional[list[str]] = None) -> Ta self.tasks.append(task) return task - def get_task(self, task_id: str) -> Optional[Task]: + def get_task(self, task_id: str) -> Task | None: """Get a task by ID.""" for task in self.tasks: if task.id == task_id: return task return None - def get_next_pending(self) -> Optional[Task]: + def get_next_pending(self) -> Task | None: """Get the next pending task that has no unmet dependencies.""" for task in self.tasks: if task.status == TaskStatus.PENDING: @@ -115,8 +115,8 @@ class ExecutionResult(BaseModel): total_tokens: int = 0 total_cost_usd: float = 0.0 duration_seconds: float = 0.0 - final_output: Optional[str] = None - error: Optional[str] = None + final_output: str | None = None + error: str | None = None def summary(self) -> str: """Generate a human-readable summary.""" diff --git a/packages/agent-task-planning/src/agent_planning/core/task.py b/packages/agent-task-planning/src/agent_planning/core/task.py index dabbded..afe4893 100644 --- a/packages/agent-task-planning/src/agent_planning/core/task.py +++ b/packages/agent-task-planning/src/agent_planning/core/task.py @@ -2,7 +2,7 @@ from datetime import datetime from enum import Enum -from typing import Optional + from pydantic import BaseModel, Field @@ -39,8 +39,8 @@ class Task(BaseModel): created_at: datetime = Field(default_factory=datetime.now) updated_at: datetime = Field(default_factory=datetime.now) attempts: int = 0 - error: Optional[str] = None - result: Optional[str] = None + error: str | None = None + result: str | None = None dependencies: list[str] = Field(default_factory=list) def mark_in_progress(self) -> "Task": @@ -50,7 +50,7 @@ def mark_in_progress(self) -> "Task": self.attempts += 1 return self - def mark_completed(self, result: Optional[str] = None) -> "Task": + def mark_completed(self, result: str | None = None) -> "Task": """Mark task as completed with optional result.""" self.status = TaskStatus.COMPLETED self.updated_at = datetime.now() @@ -64,7 +64,7 @@ def mark_failed(self, error: str) -> "Task": self.error = error return self - def mark_blocked(self, reason: Optional[str] = None) -> "Task": + def mark_blocked(self, reason: str | None = None) -> "Task": """Mark task as blocked.""" self.status = TaskStatus.BLOCKED self.updated_at = datetime.now() @@ -72,7 +72,7 @@ def mark_blocked(self, reason: Optional[str] = None) -> "Task": self.error = reason return self - def mark_skipped(self, reason: Optional[str] = None) -> "Task": + def mark_skipped(self, reason: str | None = None) -> "Task": """Mark task as skipped.""" self.status = TaskStatus.SKIPPED self.updated_at = datetime.now() diff --git a/packages/agent-task-planning/src/agent_planning/guardrails/limits.py b/packages/agent-task-planning/src/agent_planning/guardrails/limits.py index cc2abd7..02b3f4c 100644 --- a/packages/agent-task-planning/src/agent_planning/guardrails/limits.py +++ b/packages/agent-task-planning/src/agent_planning/guardrails/limits.py @@ -1,6 +1,6 @@ """Guardrail configuration and limits.""" -from typing import Optional + from pydantic import BaseModel, Field @@ -33,7 +33,7 @@ class GuardrailConfig(BaseModel): # Content validation blocked_patterns: list[str] = Field(default_factory=list) - allowed_tools: Optional[list[str]] = None + allowed_tools: list[str] | None = None # Confidence extraction settings confidence_enabled: bool = False diff --git a/packages/agent-task-planning/src/agent_planning/guardrails/validators.py b/packages/agent-task-planning/src/agent_planning/guardrails/validators.py index 4c78a7f..be45584 100644 --- a/packages/agent-task-planning/src/agent_planning/guardrails/validators.py +++ b/packages/agent-task-planning/src/agent_planning/guardrails/validators.py @@ -1,14 +1,13 @@ """Content validation for tasks and outputs.""" import re -from typing import Optional from agent_planning.guardrails.limits import GuardrailConfig, GuardrailViolation def validate_task_content( content: str, - config: Optional[GuardrailConfig] = None, + config: GuardrailConfig | None = None, ) -> None: """ Validate task content against guardrails. @@ -39,7 +38,7 @@ def validate_task_content( def validate_tool_usage( tool_name: str, - config: Optional[GuardrailConfig] = None, + config: GuardrailConfig | None = None, ) -> None: """ Validate that a tool is allowed. diff --git a/packages/agent-task-planning/src/agent_planning/mining/__init__.py b/packages/agent-task-planning/src/agent_planning/mining/__init__.py index 7a8acb5..05ed254 100644 --- a/packages/agent-task-planning/src/agent_planning/mining/__init__.py +++ b/packages/agent-task-planning/src/agent_planning/mining/__init__.py @@ -1,20 +1,20 @@ """Outlier mining module for diverse approach discovery.""" -from .miner import OutlierMiner, mine, mine_batch from .config import ( MiningConfig, - TemperatureSchedule, PromptDiversification, SaturationMethod, + TemperatureSchedule, ) +from .miner import OutlierMiner, mine, mine_batch from .models import ( - MiningCandidate, - MiningResult, + AssumptionReport, BatchMiningResult, ClusterInfo, DifferenceReport, DifferenceType, - AssumptionReport, + MiningCandidate, + MiningResult, QualityScore, SaturationSignal, ) diff --git a/packages/agent-task-planning/src/agent_planning/mining/clustering.py b/packages/agent-task-planning/src/agent_planning/mining/clustering.py index 6824e1f..1ba3431 100644 --- a/packages/agent-task-planning/src/agent_planning/mining/clustering.py +++ b/packages/agent-task-planning/src/agent_planning/mining/clustering.py @@ -1,8 +1,8 @@ """Response clustering using UMAP + HDBSCAN with fallbacks.""" -import numpy as np from dataclasses import dataclass -from typing import Optional + +import numpy as np # Optional dependencies with fallbacks try: @@ -24,8 +24,8 @@ SENTENCE_TRANSFORMERS_AVAILABLE = False try: - from sklearn.metrics import silhouette_score from sklearn.cluster import AgglomerativeClustering + from sklearn.metrics import silhouette_score SKLEARN_AVAILABLE = True except ImportError: SKLEARN_AVAILABLE = False @@ -38,7 +38,7 @@ class ClusterResult: n_clusters: int # Number of clusters found silhouette: float # Cluster quality score embeddings: np.ndarray # Original embeddings - reduced_embeddings: Optional[np.ndarray] # UMAP-reduced embeddings + reduced_embeddings: np.ndarray | None # UMAP-reduced embeddings cluster_centers: dict[int, np.ndarray] # Centroid per cluster diff --git a/packages/agent-task-planning/src/agent_planning/mining/config.py b/packages/agent-task-planning/src/agent_planning/mining/config.py index 78f665e..6280528 100644 --- a/packages/agent-task-planning/src/agent_planning/mining/config.py +++ b/packages/agent-task-planning/src/agent_planning/mining/config.py @@ -1,8 +1,7 @@ """Configuration for outlier mining.""" -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import Enum -from typing import Optional class TemperatureSchedule(Enum): @@ -83,7 +82,7 @@ class MiningConfig: characterise_clusters: bool = True # Generate cluster summaries extract_assumptions: bool = True # Extract implicit assumptions explain_differences: bool = True # Pairwise difference explanations - characterisation_model: Optional[str] = None # Use different model for characterisation + characterisation_model: str | None = None # Use different model for characterisation # === Output === max_candidates_returned: int = 5 # Top N candidates to return diff --git a/packages/agent-task-planning/src/agent_planning/mining/miner.py b/packages/agent-task-planning/src/agent_planning/mining/miner.py index 3ef9041..7d9d37a 100644 --- a/packages/agent-task-planning/src/agent_planning/mining/miner.py +++ b/packages/agent-task-planning/src/agent_planning/mining/miner.py @@ -2,28 +2,25 @@ import asyncio import time -from typing import Any, Optional, Union +from ..confidence.schemas import CustomSchema, SchemaType, get_schema_definition from ..providers.base import BaseProvider -from ..confidence.schemas import SchemaType, CustomSchema, get_schema_definition +from .clustering import ResponseClusterer from .config import MiningConfig from .models import ( - MiningCandidate, - MiningResult, BatchMiningResult, ClusterInfo, - SaturationSignal, - QualityScore, + MiningCandidate, + MiningResult, ) -from .clustering import ResponseClusterer from .utils import ( - diversify_prompt, - parse_json_response, assess_quality, - compute_novelty, compute_coherence, - compute_coverage, compute_composite_score, + compute_coverage, + compute_novelty, + diversify_prompt, + parse_json_response, ) @@ -33,7 +30,7 @@ class OutlierMiner: def __init__( self, provider: BaseProvider, - config: Optional[MiningConfig] = None, + config: MiningConfig | None = None, ): """Initialise the outlier miner. @@ -54,8 +51,8 @@ def __init__( async def mine( self, query: str, - context: Optional[str] = None, - schema: Union[SchemaType, CustomSchema] = SchemaType.RISK, + context: str | None = None, + schema: SchemaType | CustomSchema = SchemaType.RISK, ) -> MiningResult: """Mine for diverse approaches and outlier insights. @@ -279,8 +276,8 @@ async def mine( async def mine_batch( self, queries: list[str], - context: Optional[str] = None, - schemas: Optional[list[Union[SchemaType, CustomSchema]]] = None, + context: str | None = None, + schemas: list[SchemaType | CustomSchema] | None = None, max_concurrent: int = 2, ) -> BatchMiningResult: """Mine multiple queries with concurrency control. @@ -303,14 +300,14 @@ async def mine_batch( succeeded = 0 failed = 0 - async def mine_one(query: str, schema: Union[SchemaType, CustomSchema]): + async def mine_one(query: str, schema: SchemaType | CustomSchema): nonlocal succeeded, failed async with semaphore: try: result = await self.mine(query, context, schema) succeeded += 1 return result - except Exception as e: + except Exception: failed += 1 return None @@ -331,7 +328,7 @@ async def mine_one(query: str, schema: Union[SchemaType, CustomSchema]): def _build_extraction_prompt( self, query: str, - context: Optional[str], + context: str | None, schema_prompt: str ) -> str: """Build the full extraction prompt.""" @@ -368,7 +365,7 @@ def _build_extraction_prompt( def _empty_result( self, query: str, - context: Optional[str], + context: str | None, schema_name: str, tokens: int, cost: float, @@ -406,9 +403,9 @@ def _empty_result( async def mine( query: str, provider: BaseProvider, - context: Optional[str] = None, - schema: Union[SchemaType, CustomSchema] = SchemaType.RISK, - config: Optional[MiningConfig] = None, + context: str | None = None, + schema: SchemaType | CustomSchema = SchemaType.RISK, + config: MiningConfig | None = None, ) -> MiningResult: """Convenience function for single mining operation.""" miner = OutlierMiner(provider, config) @@ -418,9 +415,9 @@ async def mine( async def mine_batch( queries: list[str], provider: BaseProvider, - context: Optional[str] = None, - schemas: Optional[list[Union[SchemaType, CustomSchema]]] = None, - config: Optional[MiningConfig] = None, + context: str | None = None, + schemas: list[SchemaType | CustomSchema] | None = None, + config: MiningConfig | None = None, max_concurrent: int = 2, ) -> BatchMiningResult: """Convenience function for batch mining.""" diff --git a/packages/agent-task-planning/src/agent_planning/mining/models.py b/packages/agent-task-planning/src/agent_planning/mining/models.py index eac10cb..fe0616b 100644 --- a/packages/agent-task-planning/src/agent_planning/mining/models.py +++ b/packages/agent-task-planning/src/agent_planning/mining/models.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Any, Optional +from typing import Any from .config import MiningConfig @@ -127,12 +127,12 @@ class MiningCandidate: class MiningResult: """Result of an outlier mining operation.""" query: str # Original query - context: Optional[str] # Context document (if provided) + context: str | None # Context document (if provided) schema_used: str # Schema name used # Candidates candidates: list[MiningCandidate] # Top candidates (sorted by score) - consensus_baseline: Optional[dict[str, Any]] # What consensus mode would return + consensus_baseline: dict[str, Any] | None # What consensus mode would return # Clustering num_clusters: int # Number of distinct approaches found @@ -144,7 +144,7 @@ class MiningResult: effective_diversity: float # Diversity × quality_pass_rate # Generation metadata - convergence_point: Optional[int] # Sample where saturation detected + convergence_point: int | None # Sample where saturation detected samples_generated: int # Total samples generated samples_passed_quality: int # Samples passing quality filter quality_pass_rate: float # Pass rate (0-1) diff --git a/packages/agent-task-planning/src/agent_planning/mining/utils.py b/packages/agent-task-planning/src/agent_planning/mining/utils.py index 20a057f..5886b7b 100644 --- a/packages/agent-task-planning/src/agent_planning/mining/utils.py +++ b/packages/agent-task-planning/src/agent_planning/mining/utils.py @@ -2,12 +2,12 @@ import json import re +from typing import Any + import numpy as np -from typing import Any, Optional -from .models import QualityScore from .config import PromptDiversification - +from .models import QualityScore # Role injection templates for prompt diversification ROLE_TEMPLATES = [ @@ -73,7 +73,7 @@ def diversify_prompt( return base_prompt -def parse_json_response(content: str) -> Optional[dict[str, Any]]: +def parse_json_response(content: str) -> dict[str, Any] | None: """Parse JSON from LLM response.""" content = content.strip() @@ -104,7 +104,7 @@ def parse_json_response(content: str) -> Optional[dict[str, Any]]: def assess_quality( response: str, query: str, - extracted: Optional[dict], + extracted: dict | None, threshold: float = 0.6 ) -> QualityScore: """Assess quality of a response. diff --git a/packages/agent-task-planning/src/agent_planning/planners/todo_list.py b/packages/agent-task-planning/src/agent_planning/planners/todo_list.py index 643ee52..98b8640 100644 --- a/packages/agent-task-planning/src/agent_planning/planners/todo_list.py +++ b/packages/agent-task-planning/src/agent_planning/planners/todo_list.py @@ -2,7 +2,6 @@ import asyncio import time -from typing import Optional import structlog @@ -63,8 +62,8 @@ class TodoListPlanner(BasePlanner): def __init__( self, provider: BaseProvider, - guardrails: Optional[GuardrailConfig] = None, - system_prompt: Optional[str] = None, + guardrails: GuardrailConfig | None = None, + system_prompt: str | None = None, ): """ Initialise the TodoListPlanner. diff --git a/packages/agent-task-planning/src/agent_planning/providers/anthropic.py b/packages/agent-task-planning/src/agent_planning/providers/anthropic.py index ef7e2ed..32d00e3 100644 --- a/packages/agent-task-planning/src/agent_planning/providers/anthropic.py +++ b/packages/agent-task-planning/src/agent_planning/providers/anthropic.py @@ -1,6 +1,5 @@ """Anthropic (Claude) provider implementation.""" -from typing import Optional try: import anthropic @@ -12,7 +11,6 @@ from agent_planning.providers.base import BaseProvider, ProviderResponse - # Pricing per 1M tokens (as of Dec 2024) PRICING = { "claude-3-5-sonnet-20241022": {"input": 3.00, "output": 15.00}, @@ -35,7 +33,7 @@ class AnthropicProvider(BaseProvider): def __init__( self, - api_key: Optional[str] = None, + api_key: str | None = None, model: str = "claude-3-5-sonnet-20241022", max_tokens: int = 4096, ): @@ -58,7 +56,7 @@ def name(self) -> str: async def complete( self, messages: list[dict], - system: Optional[str] = None, + system: str | None = None, **kwargs, ) -> ProviderResponse: """Generate a completion using Claude.""" diff --git a/packages/agent-task-planning/src/agent_planning/providers/base.py b/packages/agent-task-planning/src/agent_planning/providers/base.py index 6870946..276680a 100644 --- a/packages/agent-task-planning/src/agent_planning/providers/base.py +++ b/packages/agent-task-planning/src/agent_planning/providers/base.py @@ -1,7 +1,7 @@ """Base provider interface.""" from abc import ABC, abstractmethod -from typing import Optional + from pydantic import BaseModel @@ -12,7 +12,7 @@ class ProviderResponse(BaseModel): tokens_used: int = 0 cost_usd: float = 0.0 model: str = "" - raw_response: Optional[dict] = None + raw_response: dict | None = None class BaseProvider(ABC): @@ -26,7 +26,7 @@ class BaseProvider(ABC): async def complete( self, messages: list[dict], - system: Optional[str] = None, + system: str | None = None, **kwargs, ) -> ProviderResponse: """ diff --git a/packages/agent-task-planning/src/agent_planning/providers/google.py b/packages/agent-task-planning/src/agent_planning/providers/google.py index 7a55707..c1c8c1c 100644 --- a/packages/agent-task-planning/src/agent_planning/providers/google.py +++ b/packages/agent-task-planning/src/agent_planning/providers/google.py @@ -1,6 +1,5 @@ """Google (Gemini) provider implementation.""" -from typing import Optional try: import google.generativeai as genai @@ -12,7 +11,6 @@ from agent_planning.providers.base import BaseProvider, ProviderResponse - # Pricing per 1M tokens (as of Dec 2024) PRICING = { "gemini-1.5-pro": {"input": 1.25, "output": 5.00}, @@ -34,7 +32,7 @@ class GoogleProvider(BaseProvider): def __init__( self, - api_key: Optional[str] = None, + api_key: str | None = None, model: str = "gemini-1.5-pro", max_tokens: int = 4096, ): @@ -59,7 +57,7 @@ def name(self) -> str: async def complete( self, messages: list[dict], - system: Optional[str] = None, + system: str | None = None, **kwargs, ) -> ProviderResponse: """Generate a completion using Gemini.""" diff --git a/packages/agent-task-planning/src/agent_planning/providers/ollama.py b/packages/agent-task-planning/src/agent_planning/providers/ollama.py index 0870168..a503525 100644 --- a/packages/agent-task-planning/src/agent_planning/providers/ollama.py +++ b/packages/agent-task-planning/src/agent_planning/providers/ollama.py @@ -1,6 +1,5 @@ """Ollama (local models) provider implementation.""" -from typing import Optional try: import ollama @@ -46,7 +45,7 @@ def name(self) -> str: async def complete( self, messages: list[dict], - system: Optional[str] = None, + system: str | None = None, **kwargs, ) -> ProviderResponse: """Generate a completion using Ollama.""" diff --git a/packages/agent-task-planning/src/agent_planning/providers/openai.py b/packages/agent-task-planning/src/agent_planning/providers/openai.py index 8a507d1..4eaf047 100644 --- a/packages/agent-task-planning/src/agent_planning/providers/openai.py +++ b/packages/agent-task-planning/src/agent_planning/providers/openai.py @@ -1,6 +1,5 @@ """OpenAI provider implementation.""" -from typing import Optional try: import openai @@ -12,7 +11,6 @@ from agent_planning.providers.base import BaseProvider, ProviderResponse - # Pricing per 1M tokens (as of Dec 2024) PRICING = { "gpt-4-turbo": {"input": 10.00, "output": 30.00}, @@ -34,7 +32,7 @@ class OpenAIProvider(BaseProvider): def __init__( self, - api_key: Optional[str] = None, + api_key: str | None = None, model: str = "gpt-4o", max_tokens: int = 4096, ): @@ -57,7 +55,7 @@ def name(self) -> str: async def complete( self, messages: list[dict], - system: Optional[str] = None, + system: str | None = None, **kwargs, ) -> ProviderResponse: """Generate a completion using OpenAI.""" diff --git a/packages/agent-task-planning/src/agent_planning/tools/__init__.py b/packages/agent-task-planning/src/agent_planning/tools/__init__.py index 4e716d6..3fa989a 100644 --- a/packages/agent-task-planning/src/agent_planning/tools/__init__.py +++ b/packages/agent-task-planning/src/agent_planning/tools/__init__.py @@ -1,5 +1,5 @@ """Tools for agent planning.""" -from agent_planning.tools.write_todos import write_todos_tool, WriteTodosInput +from agent_planning.tools.write_todos import WriteTodosInput, write_todos_tool __all__ = ["write_todos_tool", "WriteTodosInput"] diff --git a/packages/agent-task-planning/src/agent_planning/tools/write_todos.py b/packages/agent-task-planning/src/agent_planning/tools/write_todos.py index 6a53679..1965f84 100644 --- a/packages/agent-task-planning/src/agent_planning/tools/write_todos.py +++ b/packages/agent-task-planning/src/agent_planning/tools/write_todos.py @@ -1,10 +1,11 @@ """write_todos tool for LLM function calling.""" from typing import Literal + from pydantic import BaseModel, Field -from agent_planning.core.task import Task, TaskStatus from agent_planning.core.state import TaskState +from agent_planning.core.task import Task, TaskStatus class TodoItem(BaseModel): diff --git a/packages/agent-task-planning/tests/conftest.py b/packages/agent-task-planning/tests/conftest.py index 5db8c77..dc8e4bc 100644 --- a/packages/agent-task-planning/tests/conftest.py +++ b/packages/agent-task-planning/tests/conftest.py @@ -1,10 +1,9 @@ """Pytest configuration and fixtures.""" + import pytest -from unittest.mock import AsyncMock from agent_planning.core.state import TaskState -from agent_planning.core.task import Task, TaskStatus from agent_planning.providers.base import BaseProvider, ProviderResponse diff --git a/packages/agent-task-planning/tests/test_confidence/conftest.py b/packages/agent-task-planning/tests/test_confidence/conftest.py index 9bd493c..1e93768 100644 --- a/packages/agent-task-planning/tests/test_confidence/conftest.py +++ b/packages/agent-task-planning/tests/test_confidence/conftest.py @@ -1,8 +1,8 @@ """Fixtures for confidence extraction tests.""" -import pytest from dataclasses import dataclass -from typing import Any, Optional, List, Dict + +import pytest @dataclass @@ -12,7 +12,7 @@ class MockResponse: tokens_used: int = 100 cost_usd: float = 0.001 model: str = "mock-model" - raw_response: Optional[dict] = None + raw_response: dict | None = None class MockProvider: @@ -25,8 +25,8 @@ def __init__(self, responses: list[str]): async def complete( self, - messages: List[Dict[str, str]], - system: Optional[str] = None, + messages: list[dict[str, str]], + system: str | None = None, temperature: float = 0.7, max_tokens: int = 2000, **kwargs diff --git a/packages/agent-task-planning/tests/test_confidence/test_aggregation.py b/packages/agent-task-planning/tests/test_confidence/test_aggregation.py index a2c7620..d3d7483 100644 --- a/packages/agent-task-planning/tests/test_confidence/test_aggregation.py +++ b/packages/agent-task-planning/tests/test_confidence/test_aggregation.py @@ -1,15 +1,14 @@ """Tests for aggregation functions.""" -import pytest from agent_planning.confidence.aggregation import ( - compute_iqr, - detect_numeric_outliers, - aggregate_numeric, aggregate_categorical, aggregate_list_fields, + aggregate_numeric, + check_early_stop, compute_field_confidence, + compute_iqr, compute_overall_confidence, - check_early_stop, + detect_numeric_outliers, ) diff --git a/packages/agent-task-planning/tests/test_confidence/test_extractor.py b/packages/agent-task-planning/tests/test_confidence/test_extractor.py index a5cdffb..2e46fbe 100644 --- a/packages/agent-task-planning/tests/test_confidence/test_extractor.py +++ b/packages/agent-task-planning/tests/test_confidence/test_extractor.py @@ -1,10 +1,11 @@ """Tests for confidence extractor.""" import pytest + from agent_planning.confidence import ( ConfidenceExtractor, - SchemaType, ReviewLevel, + SchemaType, ) from agent_planning.guardrails import GuardrailConfig diff --git a/packages/agent-task-planning/tests/test_guardrails.py b/packages/agent-task-planning/tests/test_guardrails.py index a008813..59de821 100644 --- a/packages/agent-task-planning/tests/test_guardrails.py +++ b/packages/agent-task-planning/tests/test_guardrails.py @@ -3,7 +3,10 @@ import pytest from agent_planning.guardrails.limits import GuardrailConfig, GuardrailViolation -from agent_planning.guardrails.validators import validate_task_content, validate_tool_usage +from agent_planning.guardrails.validators import ( + validate_task_content, + validate_tool_usage, +) class TestGuardrailConfig: diff --git a/packages/agent-task-planning/tests/test_mining/test_miner.py b/packages/agent-task-planning/tests/test_mining/test_miner.py index a1410be..2e16f2a 100644 --- a/packages/agent-task-planning/tests/test_mining/test_miner.py +++ b/packages/agent-task-planning/tests/test_mining/test_miner.py @@ -1,8 +1,9 @@ """Tests for OutlierMiner.""" import pytest -from agent_planning.mining import OutlierMiner, MiningConfig + from agent_planning.confidence import SchemaType +from agent_planning.mining import MiningConfig, OutlierMiner @pytest.mark.asyncio diff --git a/packages/agent-task-planning/tests/test_planner.py b/packages/agent-task-planning/tests/test_planner.py index 43a6828..5644dd4 100644 --- a/packages/agent-task-planning/tests/test_planner.py +++ b/packages/agent-task-planning/tests/test_planner.py @@ -1,10 +1,10 @@ """Tests for TodoListPlanner.""" + import pytest -from unittest.mock import AsyncMock, patch -from agent_planning.planners.todo_list import TodoListPlanner from agent_planning.guardrails.limits import GuardrailConfig +from agent_planning.planners.todo_list import TodoListPlanner class TestTodoListPlanner: diff --git a/packages/agent-task-planning/tests/test_task.py b/packages/agent-task-planning/tests/test_task.py index cee1e4f..9cf3357 100644 --- a/packages/agent-task-planning/tests/test_task.py +++ b/packages/agent-task-planning/tests/test_task.py @@ -1,7 +1,5 @@ """Tests for Task model.""" -import pytest -from datetime import datetime from agent_planning.core.task import Task, TaskStatus diff --git a/packages/pm-api/src/pm_api/config.py b/packages/pm-api/src/pm_api/config.py index 536b0fc..429668e 100644 --- a/packages/pm-api/src/pm_api/config.py +++ b/packages/pm-api/src/pm_api/config.py @@ -27,6 +27,7 @@ class Settings(BaseSettings): host: str = "0.0.0.0" port: int = 8000 cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"] + cors_origin_regex: str = r"https://.*\.netlify\.app" class Config: env_prefix = "PDA_" diff --git a/packages/pm-api/src/pm_api/main.py b/packages/pm-api/src/pm_api/main.py index c27d8ce..53c70dd 100644 --- a/packages/pm-api/src/pm_api/main.py +++ b/packages/pm-api/src/pm_api/main.py @@ -32,6 +32,7 @@ app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, + allow_origin_regex=settings.cors_origin_regex, allow_methods=["*"], allow_headers=["*"], ) diff --git a/packages/pm-api/src/pm_api/routers/projects.py b/packages/pm-api/src/pm_api/routers/projects.py index 372424e..4dc264f 100644 --- a/packages/pm-api/src/pm_api/routers/projects.py +++ b/packages/pm-api/src/pm_api/routers/projects.py @@ -2,13 +2,140 @@ from __future__ import annotations +import json +import uuid +from datetime import date +from typing import Optional + from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field -from ..config import get_registry, get_store +from ..config import _resolve, get_registry, get_store, settings router = APIRouter(prefix="/api/projects", tags=["projects"]) +# --------------------------------------------------------------------------- +# Request model for project creation +# --------------------------------------------------------------------------- + + +class ProjectCreateRequest(BaseModel): + """Profile produced by the Claude analysis step in the hackathon tool.""" + + project_id: Optional[str] = None + name: str + department: str + category: str = "ICT" + sro: str = "Programme Director" + start_date: str = Field(default_factory=lambda: date.today().isoformat()) + end_date: str = Field( + default_factory=lambda: date.today().replace(year=date.today().year + 2).isoformat() + ) + whole_life_cost_m: float = 10.0 + domain: str = "COMPLICATED" + # P10 classifier inputs (0.0–1.0 scale) + technical_complexity: float = 0.5 + stakeholder_complexity: float = 0.5 + requirement_clarity: float = 0.5 + delivery_track_record: float = 0.5 + organisational_change: float = 0.5 + regulatory_exposure: float = 0.5 + dependency_count: float = 0.5 + # Supplementary fields (stored in registry, displayed in dashboard header) + summary: str = "" + key_risks: list[str] = [] + + +@router.post("", status_code=201) +async def create_project( + payload: ProjectCreateRequest, + registry: dict = Depends(get_registry), + store=Depends(get_store), +) -> dict: + """Create a new project, generate P1–P12 synthetic assurance data, and return project_id. + + This endpoint is used by the hackathon PDF-drop tool to onboard a new + project profile produced by Claude. The project is added to the + in-memory registry and persisted to ``demo_registry.json`` so that all + other GET endpoints can serve data for it immediately. + """ + import sys + from pathlib import Path + + # Ensure pm_data_tools is on the path (mirrors config.py pattern) + _pkg_src = Path(__file__).resolve().parents[4] / "pm-data-tools" / "src" + if str(_pkg_src) not in sys.path: + sys.path.insert(0, str(_pkg_src)) + + from pm_data_tools.assurance.classifier import ClassificationInput + from pm_data_tools.assurance.generator import generate_single_project + + # Assign project_id if caller didn't supply one + if not payload.project_id: + short = str(uuid.uuid4())[:8].upper() + payload.project_id = f"HACKATHON-{short}" + + project_id = payload.project_id + + if project_id in registry: + raise HTTPException(status_code=409, detail=f"Project {project_id!r} already exists") + + # Build registry entry (mirrors demo_registry.json structure) + entry: dict = { + "name": payload.name, + "department": payload.department, + "category": payload.category, + "sro": payload.sro, + "start_date": payload.start_date, + "end_date": payload.end_date, + "whole_life_cost_m": payload.whole_life_cost_m, + "domain": payload.domain, + "summary": payload.summary, + "key_risks": payload.key_risks, + } + + # Add to in-memory registry immediately so GET endpoints respond during generation + registry[project_id] = entry + + # Persist registry to JSON (best-effort — ephemeral on Railway, fine for hackathon) + try: + registry_path = _resolve(settings.registry_path) + with open(registry_path, "w", encoding="utf-8") as fh: + json.dump(registry, fh, indent=2) + except OSError: + pass # Container might be read-only; in-memory registry is sufficient + + # Build P10 classifier input from Claude's complexity scores + classifier_input = ClassificationInput( + project_id=project_id, + technical_complexity=payload.technical_complexity, + stakeholder_complexity=payload.stakeholder_complexity, + requirement_clarity=payload.requirement_clarity, + delivery_track_record=payload.delivery_track_record, + organisational_change=payload.organisational_change, + regulatory_exposure=payload.regulatory_exposure, + dependency_count=payload.dependency_count, + ) + + # Run full P1–P12 generation synchronously + # (typically 2–8 s; within Netlify's 10 s function timeout for most domains) + generate_single_project( + store=store, + project_id=project_id, + domain=payload.domain, + meta={"name": payload.name, "sro": payload.sro, "domain": payload.domain}, + classifier_input=classifier_input, + ) + + return { + "project_id": project_id, + "status": "created", + "domain": payload.domain, + "name": payload.name, + } + + @router.get("") async def list_projects(registry: dict = Depends(get_registry)) -> dict: """List all projects with metadata.""" diff --git a/packages/pm-data-tools/pyproject.toml b/packages/pm-data-tools/pyproject.toml index 10f8262..0de7ff5 100644 --- a/packages/pm-data-tools/pyproject.toml +++ b/packages/pm-data-tools/pyproject.toml @@ -104,7 +104,7 @@ exclude_lines = [ fail_under = 100 [tool.ruff] -line-length = 88 +line-length = 120 target-version = "py310" src = ["src", "tests"] @@ -120,7 +120,7 @@ select = [ "C4", # flake8-comprehensions "SIM", # flake8-simplify ] -ignore = [] +ignore = ["E501", "E722", "E741", "E402", "B904", "SIM108", "SIM102", "SIM116", "N802", "B017"] [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/packages/pm-data-tools/scripts/generate_synthetic_data.py b/packages/pm-data-tools/scripts/generate_synthetic_data.py index 7d309f4..5eb74ed 100644 --- a/packages/pm-data-tools/scripts/generate_synthetic_data.py +++ b/packages/pm-data-tools/scripts/generate_synthetic_data.py @@ -19,7 +19,6 @@ import argparse import json -import os import random import sys import uuid @@ -40,7 +39,6 @@ AssuranceOverheadOptimiser, AssuranceWorkflowEngine, ClassificationInput, - ComplexityDomain, LessonCategory, LessonRecord, LessonSentiment, @@ -274,7 +272,7 @@ def complex_trajectory(project_id: str) -> list[float]: """COMPLEX: volatile 55–80.""" base = random.uniform(68.0, 76.0) scores: list[float] = [] - for i in range(MONTHS): + for _i in range(MONTHS): swing = random.uniform(-8.0, 8.0) s = max(55.0, min(80.0, base + swing)) scores.append(round(s, 1)) @@ -1126,7 +1124,7 @@ def generate_workflow_executions(store: AssuranceStore, project_id: str, domain: d = month_date(month_idx) try: - result = engine.execute(project_id=project_id, workflow_type=wf_type) + engine.execute(project_id=project_id, workflow_type=wf_type) except Exception: # Fall back to direct insert if engine execution fails (no artefact data) health = health_pool[i % len(health_pool)] @@ -1339,7 +1337,7 @@ def verify(db_path: Path) -> None: _DOMAIN_DRIFT_SCALE = {"CLEAR": 0.02, "COMPLICATED": 0.08, "COMPLEX": 0.25, "CHAOTIC": 0.50} -def generate_assumptions(store: "AssuranceStore", project_id: str, domain: str, months: int = 12) -> None: +def generate_assumptions(store: AssuranceStore, project_id: str, domain: str, months: int = 12) -> None: """Generate P11 assumption data for a project. Args: @@ -1348,13 +1346,14 @@ def generate_assumptions(store: "AssuranceStore", project_id: str, domain: str, domain: Complexity domain (CLEAR/COMPLICATED/COMPLEX/CHAOTIC). months: Number of months in the history window. """ + from datetime import date, timedelta + from pm_data_tools.assurance.assumptions import ( Assumption, AssumptionCategory, AssumptionSource, AssumptionTracker, ) - from datetime import date, timedelta n_assumptions = _DOMAIN_ASSUMPTION_COUNTS.get(domain, 5) drift_scale = _DOMAIN_DRIFT_SCALE.get(domain, 0.05) @@ -1408,7 +1407,7 @@ def generate_assumptions(store: "AssuranceStore", project_id: str, domain: str, for v in range(effective_validations): # Each validation adds cumulative drift months_elapsed = int((v + 1) * (months / effective_validations)) if effective_validations else 0 - val_date = base_date + timedelta(days=months_elapsed * 30) + base_date + timedelta(days=months_elapsed * 30) drift_factor = 1.0 + drift_scale * (v + 1) * random.uniform(0.5, 1.5) # Some assumptions drift down (costs rise, timelines slip) @@ -1456,7 +1455,7 @@ def generate_assumptions(store: "AssuranceStore", project_id: str, domain: str, def generate_armm_assessments( - store: "AssuranceStore", project_id: str, domain: str, months: int = 12 + store: AssuranceStore, project_id: str, domain: str, months: int = 12 ) -> None: """Generate P12 ARMM assessment data for a project. @@ -1470,12 +1469,13 @@ def generate_armm_assessments( months: History window in months. """ from datetime import date, timedelta + from pm_data_tools.assurance.armm import ( + TOPIC_CRITERIA_COUNT, + TOPIC_DIMENSION, ARMMScorer, ARMMTopic, CriterionResult, - TOPIC_CRITERIA_COUNT, - TOPIC_DIMENSION, ) scorer = ARMMScorer(store=store) @@ -1619,7 +1619,7 @@ def main() -> None: output: Path = args.output.resolve() print(f"Generating synthetic data -> {output}") - print(f"15 projects | 12 months | 14 tables (P1-P12)\n") + print("15 projects | 12 months | 14 tables (P1-P12)\n") generate(output) diff --git a/packages/pm-data-tools/src/pm_data_tools/__init__.py b/packages/pm-data-tools/src/pm_data_tools/__init__.py index 6e6f59c..25a8d62 100644 --- a/packages/pm-data-tools/src/pm_data_tools/__init__.py +++ b/packages/pm-data-tools/src/pm_data_tools/__init__.py @@ -8,15 +8,15 @@ __version__ = "0.2.0" # Import public API -from .parsers import detect_format, create_parser, parse_project -from .exporters import create_exporter from .exceptions import ( - PMDataToolsError, + ExportError, ParseError, + PMDataToolsError, UnsupportedFormatError, ValidationError, - ExportError, ) +from .exporters import create_exporter +from .parsers import create_parser, detect_format, parse_project # Models are imported via pm_data_tools.models # from .models import Project, Task, Resource, Dependency, etc. diff --git a/packages/pm-data-tools/src/pm_data_tools/assurance/__init__.py b/packages/pm-data-tools/src/pm_data_tools/assurance/__init__.py index 9bfdf6c..f1d55a4 100644 --- a/packages/pm-data-tools/src/pm_data_tools/assurance/__init__.py +++ b/packages/pm-data-tools/src/pm_data_tools/assurance/__init__.py @@ -92,6 +92,38 @@ """ from .analyzer import FindingAnalyzer +from .armm import ( + ARMMAssessment, + ARMMConfig, + ARMMDimension, + ARMMDimensionResult, + ARMMReport, + ARMMScorer, + ARMMTopic, + ARMMTopicResult, + CriterionResult, + MaturityLevel, +) +from .assumptions import ( + Assumption, + AssumptionCategory, + AssumptionConfig, + AssumptionHealthReport, + AssumptionSource, + AssumptionTracker, + AssumptionValidation, + DriftResult, + DriftSeverity, +) +from .classifier import ( + ClassificationInput, + ClassificationResult, + ClassifierConfig, + ComplexityDomain, + DomainAssuranceProfile, + DomainIndicator, + ProjectDomainClassifier, +) from .currency import ( ArtefactCurrencyValidator, CurrencyConfig, @@ -106,22 +138,6 @@ DivergenceSnapshot, SignalType, ) -from .models import ( - FindingAnalysisResult, - ReviewAction, - ReviewActionStatus, - # Backward-compatibility aliases - Recommendation, - RecommendationExtractionResult, - RecommendationStatus, -) -from .overrides import ( - OverrideDecision, - OverrideDecisionLogger, - OverrideOutcome, - OverridePatternSummary, - OverrideType, -) from .lessons import ( LessonCategory, LessonPatternSummary, @@ -131,6 +147,15 @@ LessonSentiment, LessonsKnowledgeEngine, ) +from .models import ( + FindingAnalysisResult, + # Backward-compatibility aliases + Recommendation, + RecommendationExtractionResult, + RecommendationStatus, + ReviewAction, + ReviewActionStatus, +) from .overhead import ( ActivityType, AssuranceActivity, @@ -139,7 +164,21 @@ EfficiencyRating, OverheadAnalysis, ) +from .overrides import ( + OverrideDecision, + OverrideDecisionLogger, + OverrideOutcome, + OverridePatternSummary, + OverrideType, +) from .recurrence import RecurrenceDetector +from .scheduler import ( + AdaptiveReviewScheduler, + ReviewUrgency, + SchedulerConfig, + SchedulerRecommendation, + SchedulerSignal, +) from .workflows import ( AssuranceWorkflowEngine, ProjectHealth, @@ -150,45 +189,6 @@ WorkflowStepStatus, WorkflowType, ) -from .classifier import ( - ClassificationInput, - ClassificationResult, - ClassifierConfig, - ComplexityDomain, - DomainAssuranceProfile, - DomainIndicator, - ProjectDomainClassifier, -) -from .assumptions import ( - AssumptionTracker, - Assumption, - AssumptionCategory, - AssumptionConfig, - AssumptionSource, - AssumptionValidation, - DriftSeverity, - DriftResult, - AssumptionHealthReport, -) -from .armm import ( - ARMMScorer, - ARMMAssessment, - ARMMConfig, - ARMMDimension, - ARMMDimensionResult, - ARMMReport, - ARMMTopic, - ARMMTopicResult, - CriterionResult, - MaturityLevel, -) -from .scheduler import ( - AdaptiveReviewScheduler, - ReviewUrgency, - SchedulerConfig, - SchedulerRecommendation, - SchedulerSignal, -) # Backward-compatibility alias for FindingAnalyzer RecommendationExtractor = FindingAnalyzer diff --git a/packages/pm-data-tools/src/pm_data_tools/assurance/analyzer.py b/packages/pm-data-tools/src/pm_data_tools/assurance/analyzer.py index 80d6cec..b220f40 100644 --- a/packages/pm-data-tools/src/pm_data_tools/assurance/analyzer.py +++ b/packages/pm-data-tools/src/pm_data_tools/assurance/analyzer.py @@ -9,13 +9,12 @@ from __future__ import annotations from datetime import date, datetime, timezone -from typing import Optional import structlog from agent_planning.confidence import ConfidenceExtractor, SchemaType from ..db.store import AssuranceStore -from .models import ReviewAction, FindingAnalysisResult, ReviewActionStatus +from .models import FindingAnalysisResult, ReviewAction, ReviewActionStatus from .recurrence import RecurrenceDetector logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) @@ -57,8 +56,8 @@ def __init__( self, extractor: ConfidenceExtractor, min_confidence: float = 0.60, - store: Optional[AssuranceStore] = None, - recurrence_detector: Optional[RecurrenceDetector] = None, + store: AssuranceStore | None = None, + recurrence_detector: RecurrenceDetector | None = None, ) -> None: """Initialise the finding analyzer. diff --git a/packages/pm-data-tools/src/pm_data_tools/assurance/armm.py b/packages/pm-data-tools/src/pm_data_tools/assurance/armm.py index 244c22a..4144537 100644 --- a/packages/pm-data-tools/src/pm_data_tools/assurance/armm.py +++ b/packages/pm-data-tools/src/pm_data_tools/assurance/armm.py @@ -61,7 +61,6 @@ import uuid from datetime import datetime, timezone from enum import Enum -from typing import Optional import structlog from pydantic import BaseModel, Field @@ -291,7 +290,7 @@ class CriterionResult(BaseModel): criterion_id: str # e.g. "TC-IV-1", "OR-BC-3" met: bool - evidence_ref: Optional[str] = None # document ID, URL, or artefact ref + evidence_ref: str | None = None # document ID, URL, or artefact ref notes: str = "" @@ -314,7 +313,7 @@ class ARMMDimensionResult(BaseModel): topic_results: dict[str, ARMMTopicResult] = Field(default_factory=dict) level: MaturityLevel # min of constituent topic levels score_pct: float # average topic score within dimension - blocking_topic: Optional[str] = None # topic code that is weakest + blocking_topic: str | None = None # topic code that is weakest class ARMMAssessment(BaseModel): @@ -340,18 +339,18 @@ class ARMMReport(BaseModel): """Summary ARMM report for a project, derived from the latest assessment.""" project_id: str - latest_assessment_id: Optional[str] = None - assessed_at: Optional[str] = None + latest_assessment_id: str | None = None + assessed_at: str | None = None overall_level: MaturityLevel = MaturityLevel.EXPERIMENTING overall_score_pct: float = 0.0 criteria_total: int = 0 criteria_met: int = 0 dimension_levels: dict[str, int] = Field(default_factory=dict) dimension_scores: dict[str, float] = Field(default_factory=dict) - dimension_blocking_topics: dict[str, Optional[str]] = Field(default_factory=dict) + dimension_blocking_topics: dict[str, str | None] = Field(default_factory=dict) topic_levels: dict[str, int] = Field(default_factory=dict) topic_scores: dict[str, float] = Field(default_factory=dict) - blocking_dimension: Optional[str] = None + blocking_dimension: str | None = None history_count: int = 0 maturity_trend: str = "stable" # "improving" | "stable" | "declining" @@ -381,7 +380,7 @@ class ARMMScorer: def __init__( self, store: AssuranceStore, - config: Optional[ARMMConfig] = None, + config: ARMMConfig | None = None, ) -> None: self.store = store self.config = config or ARMMConfig() @@ -457,12 +456,12 @@ def get_report(self, project_id: str) -> ARMMReport: topic_levels_raw: dict[str, int] = json.loads(latest["topic_levels_json"]) dim_scores_raw: dict[str, float] = json.loads(latest["dimension_scores_json"]) dim_levels_raw: dict[str, int] = json.loads(latest["dimension_levels_json"]) - dim_blocking_raw: dict[str, Optional[str]] = json.loads( + dim_blocking_raw: dict[str, str | None] = json.loads( latest["dimension_blocking_json"] ) # Identify weakest dimension - blocking_dim: Optional[str] = None + blocking_dim: str | None = None if dim_levels_raw: blocking_dim = min(dim_levels_raw, key=lambda k: dim_levels_raw[k]) @@ -492,7 +491,7 @@ def get_report(self, project_id: str) -> ARMMReport: return report def get_portfolio_overview( - self, project_ids: Optional[list[str]] = None + self, project_ids: list[str] | None = None ) -> list[ARMMReport]: """Return :class:`ARMMReport` for multiple projects. @@ -575,7 +574,7 @@ def _compute( # Mark blocking topic min_level = int(dim_level) - blocking_topic: Optional[str] = None + blocking_topic: str | None = None for t in dim_topics: tr = d_topic_results[t.value] if int(tr.level) == min_level: @@ -611,7 +610,7 @@ def _compute( # --------------------------------------------------------------------------- -def _criterion_to_topic(criterion_id: str) -> Optional[ARMMTopic]: +def _criterion_to_topic(criterion_id: str) -> ARMMTopic | None: """Extract the ARMMTopic from a criterion ID (e.g. 'TC-IV-1' → TC_IV). Args: diff --git a/packages/pm-data-tools/src/pm_data_tools/assurance/classifier.py b/packages/pm-data-tools/src/pm_data_tools/assurance/classifier.py index ae1d870..c4f9fff 100644 --- a/packages/pm-data-tools/src/pm_data_tools/assurance/classifier.py +++ b/packages/pm-data-tools/src/pm_data_tools/assurance/classifier.py @@ -44,7 +44,6 @@ import uuid from datetime import datetime, timezone from enum import Enum -from typing import Any import structlog from pydantic import BaseModel, Field, field_validator diff --git a/packages/pm-data-tools/src/pm_data_tools/assurance/currency.py b/packages/pm-data-tools/src/pm_data_tools/assurance/currency.py index 1772ba3..7563b9a 100644 --- a/packages/pm-data-tools/src/pm_data_tools/assurance/currency.py +++ b/packages/pm-data-tools/src/pm_data_tools/assurance/currency.py @@ -39,10 +39,10 @@ from datetime import datetime, timezone from enum import Enum -from typing import Any, Optional +from typing import Any import structlog -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, field_validator logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) @@ -168,7 +168,7 @@ class ArtefactCurrencyValidator: # score.status == CurrencyStatus.ANOMALOUS_UPDATE """ - def __init__(self, config: Optional[CurrencyConfig] = None) -> None: + def __init__(self, config: CurrencyConfig | None = None) -> None: """Initialise the validator. Args: diff --git a/packages/pm-data-tools/src/pm_data_tools/assurance/divergence.py b/packages/pm-data-tools/src/pm_data_tools/assurance/divergence.py index 46fcd0a..c25a10d 100644 --- a/packages/pm-data-tools/src/pm_data_tools/assurance/divergence.py +++ b/packages/pm-data-tools/src/pm_data_tools/assurance/divergence.py @@ -39,7 +39,6 @@ import uuid from datetime import datetime, timezone from enum import Enum -from typing import Optional import structlog from pydantic import BaseModel, Field, field_validator @@ -181,7 +180,7 @@ class DivergenceSignal(BaseModel): review_id: str confidence_score: float spread: float - previous_confidence: Optional[float] + previous_confidence: float | None message: str @@ -242,8 +241,8 @@ class DivergenceMonitor: def __init__( self, - config: Optional[DivergenceConfig] = None, - store: Optional[object] = None, + config: DivergenceConfig | None = None, + store: object | None = None, ) -> None: """Initialise the monitor. @@ -299,7 +298,7 @@ def _is_degrading(self, project_id: str) -> bool: scores = [r["confidence_score"] for r in recent] return all(scores[i] > scores[i + 1] for i in range(len(scores) - 1)) - def _previous_confidence(self, project_id: str) -> Optional[float]: + def _previous_confidence(self, project_id: str) -> float | None: """Return the confidence score from the most recent prior snapshot. Args: diff --git a/packages/pm-data-tools/src/pm_data_tools/assurance/generator.py b/packages/pm-data-tools/src/pm_data_tools/assurance/generator.py new file mode 100644 index 0000000..ec6b3ee --- /dev/null +++ b/packages/pm-data-tools/src/pm_data_tools/assurance/generator.py @@ -0,0 +1,645 @@ +"""On-demand single-project synthetic data generator. + +Provides :func:`generate_single_project` which runs the full P1–P12 +generation pipeline for one project using a parameterised profile. +Used by the PDA API's ``POST /api/projects`` endpoint to generate +assurance data for dynamically-created projects (e.g. from the hackathon +PDF-drop tool). + +Unlike ``generate_synthetic_data.py`` (a batch script), this module: +- Never touches the global ``PROJECT_REGISTRY`` or ``_CLASSIFIER_INPUTS`` +- Accepts all project-specific values as arguments +- Has no ``random.seed()`` call at module level +""" + +from __future__ import annotations + +import json +import random +import uuid +from datetime import date, datetime, timedelta, timezone +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from pm_data_tools.db.store import AssuranceStore + +# --------------------------------------------------------------------------- +# Date helpers (mirrors generate_synthetic_data.py) +# --------------------------------------------------------------------------- + +_MONTHS = 12 +_START_DATE = date(2025, 4, 1) + + +def _month_date(month_index: int) -> date: + year = 2025 + (3 + month_index) // 12 + month = ((3 + month_index) % 12) + 1 + return date(year, month, 1) + + +def _ts(d: date) -> str: + return datetime(d.year, d.month, d.day, tzinfo=timezone.utc).isoformat() + + +def _jitter(base: float, amplitude: float = 2.0) -> float: + return round(max(0.0, min(100.0, base + random.uniform(-amplitude, amplitude))), 1) + + +# --------------------------------------------------------------------------- +# Score trajectories +# --------------------------------------------------------------------------- + +def _score_trajectory(domain: str) -> list[float]: + if domain == "CLEAR": + base = random.uniform(87.0, 93.0) + return [_jitter(base, 1.5) for _ in range(_MONTHS)] + if domain == "COMPLICATED": + base = random.uniform(74.0, 83.0) + return [_jitter(base, 2.5) for _ in range(_MONTHS)] + if domain == "COMPLEX": + base = random.uniform(68.0, 76.0) + scores: list[float] = [] + for _ in range(_MONTHS): + swing = random.uniform(-8.0, 8.0) + s = max(55.0, min(80.0, base + swing)) + scores.append(round(s, 1)) + base = s + return scores + # CHAOTIC + scores = [] + score = random.uniform(66.0, 72.0) + for _ in range(_MONTHS): + score = max(30.0, score - random.uniform(1.5, 4.0)) + scores.append(round(score + random.uniform(-2.0, 1.0), 1)) + return scores + + +def _dimension_scores(overall: float) -> dict[str, float]: + dims = ["required_fields", "recommended_fields", "data_quality", "timeliness"] + return {d: round(max(0.0, min(100.0, overall + random.uniform(-8.0, 8.0))), 1) for d in dims} + + +# --------------------------------------------------------------------------- +# P2 — Confidence scores +# --------------------------------------------------------------------------- + +def _generate_confidence_scores(store: AssuranceStore, project_id: str, domain: str) -> list[float]: + scores = _score_trajectory(domain) + for i, score in enumerate(scores): + d = _month_date(i) + store.insert_confidence_score( + project_id=project_id, + run_id=f"run-{project_id}-{d.isoformat()}", + timestamp=_ts(d), + score=score, + dimension_scores=_dimension_scores(score), + ) + return scores + + +# --------------------------------------------------------------------------- +# P3 — Recommendations +# --------------------------------------------------------------------------- + +_ACTION_TEXTS: dict[str, list[str]] = { + "CLEAR": [ + "Confirm gate readiness documentation is current.", + "Verify benefits realisation plan is reviewed by SRO.", + "Archive previous-version artefacts in line with retention policy.", + ], + "COMPLICATED": [ + "Update interface specification register before next gate.", + "Ensure procurement timeline is reflected in updated schedule.", + "Schedule deep-dive with technical lead on dependency mapping.", + "Reassess risk register following scope change.", + "Close outstanding action from last gate regarding data migration plan.", + ], + "COMPLEX": [ + "Address recurring divergence in supplier dependency classification.", + "Resolve open action on governance override.", + "Update benefits profile — current version is out of date.", + "Escalate stale Benefits Realisation Plan to programme board.", + "Convene emergency review of RAID log following restructure.", + "Review AI extraction confidence thresholds with assurance team.", + "Confirm stakeholder sign-off on revised scope baseline.", + ], + "CHAOTIC": [ + "Immediately update Programme Business Case — current version unfit for gate.", + "Escalate CRITICAL divergence signal to SRO within 24 hours.", + "Commission emergency independent review of benefits realisation.", + "Governance board to meet within 7 days to address recurring overrides.", + "Address all OUTSTANDING compliance actions before next gate.", + "Reinstate benefits owner — role has been vacant.", + "Produce emergency schedule recovery plan.", + ], +} + +_CATEGORIES = ["HIGH", "MEDIUM", "LOW", "CRITICAL"] +_STATUS_BY_DOMAIN = { + "CLEAR": ["CLOSED", "CLOSED", "CLOSED"], + "COMPLICATED": ["CLOSED", "CLOSED", "OPEN"], + "COMPLEX": ["OPEN", "OPEN", "RECURRING", "CLOSED"], + "CHAOTIC": ["OPEN", "OPEN", "OPEN", "RECURRING", "RECURRING"], +} + + +def _generate_recommendations(store: AssuranceStore, project_id: str, domain: str) -> None: + texts = _ACTION_TEXTS[domain] + statuses = _STATUS_BY_DOMAIN[domain] + prev_ids: list[str] = [] + for quarter in range(1, 5): + year_suffix = "2025" if quarter <= 2 else "2026" + review_id = f"review-{project_id}-Q{quarter}-{year_suffix}" + review_month = (quarter - 1) * 3 + review_d = _month_date(review_month) + n_actions = random.randint(2, min(5, len(texts))) + selected = random.sample(texts, n_actions) + for i, text in enumerate(selected): + status = random.choice(statuses) + rec_id = f"rec-{project_id}-Q{quarter}-{i}" + recurrence_of = random.choice(prev_ids) if prev_ids and status == "RECURRING" else None + store.upsert_recommendation({ + "id": rec_id, + "project_id": project_id, + "text": text, + "category": random.choice(_CATEGORIES), + "source_review_id": review_id, + "review_date": review_d.isoformat(), + "status": status, + "owner": random.choice(["PMO", "Tech Lead", "SRO", "Delivery Manager", None]), + "recurrence_of": recurrence_of, + "confidence": round(random.uniform(0.65, 0.97), 2), + "created_at": _ts(review_d), + }) + prev_ids.append(rec_id) + + +# --------------------------------------------------------------------------- +# P4 — Divergence snapshots +# --------------------------------------------------------------------------- + +_SIGNAL_BY_DOMAIN: dict[str, list[str]] = { + "CLEAR": ["STABLE", "STABLE", "STABLE", "STABLE"], + "COMPLICATED": ["STABLE", "STABLE", "STABLE", "LOW_CONSENSUS"], + "COMPLEX": ["STABLE", "HIGH_DIVERGENCE", "LOW_CONSENSUS", "DEGRADING_CONFIDENCE"], + "CHAOTIC": ["HIGH_DIVERGENCE", "LOW_CONSENSUS", "HIGH_DIVERGENCE", "DEGRADING_CONFIDENCE"], +} + + +def _sample_scores_for_signal(signal: str) -> tuple[float, list[float]]: + if signal == "STABLE": + c = round(random.uniform(0.78, 0.95), 2) + s = [round(c + random.uniform(-0.04, 0.04), 2) for _ in range(5)] + elif signal == "HIGH_DIVERGENCE": + c = round(random.uniform(0.55, 0.75), 2) + s = [round(random.uniform(0.30, 0.90), 2) for _ in range(5)] + elif signal == "LOW_CONSENSUS": + c = round(random.uniform(0.40, 0.58), 2) + s = [round(c + random.uniform(-0.05, 0.05), 2) for _ in range(5)] + else: # DEGRADING_CONFIDENCE + c = round(random.uniform(0.55, 0.70), 2) + s = [round(c + random.uniform(-0.06, 0.04), 2) for _ in range(5)] + return c, s + + +def _generate_divergence_snapshots(store: AssuranceStore, project_id: str, domain: str) -> None: + signals = _SIGNAL_BY_DOMAIN[domain] + for quarter in range(1, 5): + year_suffix = "2025" if quarter <= 2 else "2026" + review_id = f"review-{project_id}-Q{quarter}-{year_suffix}" + review_d = _month_date((quarter - 1) * 3) + signal = signals[quarter - 1] + confidence, samples = _sample_scores_for_signal(signal) + store.insert_divergence_snapshot( + snapshot_id=str(uuid.uuid4()), + project_id=project_id, + review_id=review_id, + confidence_score=confidence, + sample_scores=samples, + signal_type=signal, + timestamp=_ts(review_d), + ) + + +# --------------------------------------------------------------------------- +# P5 — Schedule recommendations +# --------------------------------------------------------------------------- + +_URGENCY_BY_DOMAIN = { + "CLEAR": "DEFERRED", + "COMPLICATED": "STANDARD", + "COMPLEX": "EXPEDITED", + "CHAOTIC": "IMMEDIATE", +} +_URGENCY_DAYS = {"IMMEDIATE": 7, "EXPEDITED": 14, "STANDARD": 42, "DEFERRED": 90} + + +def _generate_schedule_recommendations(store: AssuranceStore, project_id: str, domain: str) -> None: + urgency = _URGENCY_BY_DOMAIN[domain] + days_ahead = _URGENCY_DAYS[urgency] + for quarter in range(1, 5): + rec_d = _month_date((quarter - 1) * 3) + recommended_d = rec_d + timedelta(days=days_ahead + random.randint(-3, 3)) + signals = json.dumps([ + {"source": "P2", "severity": round(random.uniform(0.1, 0.8), 2), "description": "Compliance trend"}, + {"source": "P4", "severity": round(random.uniform(0.1, 0.7), 2), "description": "Divergence signal"}, + ]) + store.insert_schedule_recommendation( + project_id=project_id, + timestamp=_ts(rec_d), + urgency=urgency, + recommended_date=recommended_d.isoformat(), + composite_score=round(random.uniform(0.1, 0.9), 2), + signals_json=signals, + rationale=f"{urgency} review recommended based on {domain} domain profile.", + ) + + +# --------------------------------------------------------------------------- +# P6 — Override decisions +# --------------------------------------------------------------------------- + +from pm_data_tools.assurance.overrides import ( # noqa: E402 + OverrideDecision, + OverrideDecisionLogger, + OverrideOutcome, + OverrideType, +) + +_CHAOTIC_OVERRIDE_TEMPLATES = [ + { + "override_type": OverrideType.GATE_PROGRESSION, + "rationale": "Crisis timeline means gate cannot be postponed despite critical assurance findings.", + "overridden_value": "RED", + "override_value": "Proceed — emergency conditions apply", + "outcome": OverrideOutcome.SIGNIFICANT_IMPACT, + "outcome_notes": "Predicted consequences materialised. Emergency recovery plan now active.", + }, + { + "override_type": OverrideType.RECOMMENDATION_DISMISSED, + "rationale": "Assurance recommendation considered impractical given operational constraints.", + "outcome": OverrideOutcome.SIGNIFICANT_IMPACT, + }, + { + "override_type": OverrideType.RAG_OVERRIDE, + "rationale": "Board overrides RED rating. Delivery must continue.", + "overridden_value": "RED", + "override_value": "AMBER — board accepted residual risk", + "outcome": OverrideOutcome.ESCALATED, + "outcome_notes": "Situation deteriorated. Escalation to IPA required.", + }, +] + + +def _generate_overrides(store: AssuranceStore, project_id: str, domain: str, sro: str) -> None: + if domain == "CLEAR": + return + logger_obj = OverrideDecisionLogger(store=store) + counts = {"COMPLICATED": (0, 2), "COMPLEX": (2, 4), "CHAOTIC": (4, 6)} + lo, hi = counts.get(domain, (1, 3)) + count = random.randint(lo, hi) + months_used: set[int] = set() + for _ in range(count): + month = random.randint(1, 11) + while month in months_used: + month = random.randint(1, 11) + months_used.add(month) + d = _month_date(month) + if domain == "CHAOTIC": + tpl = random.choice(_CHAOTIC_OVERRIDE_TEMPLATES) + outcome = tpl["outcome"] + decision = OverrideDecision( + project_id=project_id, + override_type=tpl["override_type"], + decision_date=d, + authoriser=sro, + rationale=tpl["rationale"], + overridden_value=tpl.get("overridden_value"), + override_value=tpl.get("override_value"), + outcome=outcome, + outcome_notes=tpl.get("outcome_notes"), + outcome_date=d + timedelta(days=45) if outcome != OverrideOutcome.PENDING else None, + ) + else: + otype = random.choice(list(OverrideType)) + outcome = ( + random.choice([OverrideOutcome.NO_IMPACT, OverrideOutcome.PENDING]) + if domain == "COMPLICATED" + else random.choice([OverrideOutcome.NO_IMPACT, OverrideOutcome.MINOR_IMPACT, OverrideOutcome.PENDING]) + ) + decision = OverrideDecision( + project_id=project_id, + override_type=otype, + decision_date=d, + authoriser=sro, + rationale=f"Override required due to project constraints. Domain: {domain}.", + outcome=outcome, + outcome_date=d + timedelta(days=45) if outcome != OverrideOutcome.PENDING else None, + ) + logger_obj.log_override(decision) + + +# --------------------------------------------------------------------------- +# P8 — Assurance activities +# --------------------------------------------------------------------------- + +from pm_data_tools.assurance.overhead import ( # noqa: E402 + ActivityType, + AssuranceActivity, + AssuranceOverheadOptimiser, +) + +_ACTIVITY_COUNTS = {"CLEAR": (3, 4), "COMPLICATED": (4, 6), "COMPLEX": (6, 8), "CHAOTIC": (7, 9)} +_ACTIVITY_TYPES_BY_DOMAIN = { + "CLEAR": [ActivityType.COMPLIANCE_CHECK, ActivityType.DOCUMENT_REVIEW, ActivityType.GATE_REVIEW], + "COMPLICATED": [ActivityType.GATE_REVIEW, ActivityType.COMPLIANCE_CHECK, ActivityType.DOCUMENT_REVIEW, ActivityType.RISK_ASSESSMENT], + "COMPLEX": list(ActivityType), + "CHAOTIC": [ActivityType.GATE_REVIEW, ActivityType.GATE_REVIEW, ActivityType.AUDIT, ActivityType.STAKEHOLDER_REVIEW, ActivityType.RISK_ASSESSMENT], +} + + +def _generate_activities( + store: AssuranceStore, project_id: str, domain: str, scores: list[float], project_name: str +) -> None: + lo, hi = _ACTIVITY_COUNTS[domain] + count = random.randint(lo, hi) + optimiser = AssuranceOverheadOptimiser(store=store) + activity_types = _ACTIVITY_TYPES_BY_DOMAIN[domain] + used_months = sorted(random.sample(range(_MONTHS), min(count, _MONTHS))) + for i, month_idx in enumerate(used_months): + d = _month_date(month_idx) + atype = activity_types[i % len(activity_types)] + before = scores[max(0, month_idx - 1)] if month_idx > 0 else scores[0] + after = scores[month_idx] + if domain == "CHAOTIC": + effort, participants, findings = round(random.uniform(20.0, 40.0), 1), random.randint(4, 8), random.randint(0, 2) + elif domain == "COMPLEX": + effort, participants, findings = round(random.uniform(10.0, 24.0), 1), random.randint(3, 6), random.randint(1, 5) + elif domain == "COMPLICATED": + effort, participants, findings = round(random.uniform(6.0, 16.0), 1), random.randint(2, 4), random.randint(1, 4) + else: + effort, participants, findings = round(random.uniform(4.0, 10.0), 1), random.randint(1, 3), random.randint(0, 2) + artefacts = [f"artefact-{project_id.lower()}-{random.randint(1, 6)}" for _ in range(random.randint(1, 3))] + activity = AssuranceActivity( + project_id=project_id, + activity_type=atype, + description=f"{atype.value.replace('_', ' ').title()} — {project_name}", + date=d, + effort_hours=effort, + participants=participants, + artefacts_reviewed=artefacts, + findings_count=findings, + confidence_before=before, + confidence_after=after, + ) + optimiser.log_activity(activity) + optimiser.analyse(project_id) + + +# --------------------------------------------------------------------------- +# P9 — Workflow executions +# --------------------------------------------------------------------------- + +from pm_data_tools.assurance.workflows import ( # noqa: E402 + AssuranceWorkflowEngine, + WorkflowType, +) + +_WORKFLOW_TYPES = [ + WorkflowType.FULL_ASSURANCE, + WorkflowType.RISK_ASSESSMENT, + WorkflowType.COMPLIANCE_FOCUS, + WorkflowType.TREND_ANALYSIS, + WorkflowType.CURRENCY_FOCUS, +] +_HEALTH_BY_DOMAIN = { + "CLEAR": ["HEALTHY", "HEALTHY", "HEALTHY"], + "COMPLICATED": ["HEALTHY", "ATTENTION_NEEDED", "HEALTHY"], + "COMPLEX": ["ATTENTION_NEEDED", "AT_RISK", "ATTENTION_NEEDED", "AT_RISK"], + "CHAOTIC": ["AT_RISK", "CRITICAL", "CRITICAL", "CRITICAL"], +} + + +def _generate_workflow_executions(store: AssuranceStore, project_id: str, domain: str) -> None: + engine = AssuranceWorkflowEngine(store=store) + health_pool = _HEALTH_BY_DOMAIN[domain] + n_workflows = random.randint(2, 4) + months_used = sorted(random.sample(range(1, 12), n_workflows)) + for i, month_idx in enumerate(months_used): + wf_type = _WORKFLOW_TYPES[i % len(_WORKFLOW_TYPES)] + d = _month_date(month_idx) + try: + engine.execute(project_id=project_id, workflow_type=wf_type) + except Exception: + health = health_pool[i % len(health_pool)] + wf_id = str(uuid.uuid4()) + started = datetime(d.year, d.month, d.day, tzinfo=timezone.utc) + completed = started + timedelta(seconds=random.uniform(1.5, 8.0)) + store.insert_workflow_execution( + workflow_id=wf_id, + project_id=project_id, + workflow_type=wf_type.value, + started_at=started.isoformat(), + completed_at=completed.isoformat(), + duration_ms=round((completed - started).total_seconds() * 1000, 1), + health=health, + result_json=json.dumps({"steps": [], "health": health, "risk_signals": []}), + ) + + +# --------------------------------------------------------------------------- +# P10 — Domain classification +# --------------------------------------------------------------------------- + +from pm_data_tools.assurance.classifier import ProjectDomainClassifier # noqa: E402 + + +def _generate_domain_classification(store: AssuranceStore, classifier_input: Any) -> None: + clf = ProjectDomainClassifier(store=store) + clf.classify(classifier_input) + clf.reclassify_from_store(classifier_input.project_id) + + +# --------------------------------------------------------------------------- +# P11 — Assumptions +# --------------------------------------------------------------------------- + +_ASSUMPTION_TEMPLATES: list[dict[str, Any]] = [ + {"text": "Annual inflation rate will not exceed 3%", "category": "COST", "baseline": 2.5, "unit": "%", "ext": "ONS_CPI"}, + {"text": "Contractor day rates within 10% of current levels", "category": "COST", "baseline": 850.0, "unit": "GBP"}, + {"text": "Planning approval granted within 12 weeks", "category": "SCHEDULE", "baseline": 12.0, "unit": "weeks"}, + {"text": "Delivery milestone dates will not slip more than 4 weeks", "category": "SCHEDULE", "baseline": 4.0, "unit": "weeks"}, + {"text": "Senior developer availability >= 3 FTE through delivery", "category": "RESOURCE", "baseline": 3.0, "unit": "FTE"}, + {"text": "Specialist contractor supply remains stable", "category": "RESOURCE", "baseline": 1.0, "unit": "score"}, + {"text": "API response times will remain under 200ms", "category": "TECHNICAL", "baseline": 200.0, "unit": "ms"}, + {"text": "Cloud platform SLA stays at 99.9%", "category": "TECHNICAL", "baseline": 99.9, "unit": "%"}, + {"text": "Primary supplier remains financially viable", "category": "COMMERCIAL", "baseline": 1.0, "unit": "score"}, + {"text": "GDPR requirements will not change materially", "category": "REGULATORY", "baseline": 1.0, "unit": "score"}, +] + +_DOMAIN_ASSUMPTION_COUNTS = {"CLEAR": 4, "COMPLICATED": 6, "COMPLEX": 8, "CHAOTIC": 10} +_DOMAIN_DRIFT_SCALE = {"CLEAR": 0.02, "COMPLICATED": 0.08, "COMPLEX": 0.25, "CHAOTIC": 0.50} + + +def _generate_assumptions(store: AssuranceStore, project_id: str, domain: str) -> None: + from pm_data_tools.assurance.assumptions import ( + Assumption, + AssumptionCategory, + AssumptionSource, + AssumptionTracker, + ) + n = _DOMAIN_ASSUMPTION_COUNTS.get(domain, 5) + drift_scale = _DOMAIN_DRIFT_SCALE.get(domain, 0.05) + tracker = AssumptionTracker(store=store) + templates = _ASSUMPTION_TEMPLATES[:n] + assumption_ids: list[str] = [] + base_date = date(2025, 4, 1) + for i, tmpl in enumerate(templates): + cat = AssumptionCategory(tmpl["category"]) + src = AssumptionSource.EXTERNAL_API if tmpl.get("ext") else AssumptionSource.MANUAL + a = Assumption( + project_id=project_id, + text=tmpl["text"], + category=cat, + baseline_value=tmpl["baseline"], + unit=tmpl.get("unit", ""), + tolerance_pct=15.0 if domain in ("COMPLEX", "CHAOTIC") else 10.0, + source=src, + external_ref=tmpl.get("ext"), + dependencies=[], + owner="SRO" if i == 0 else ("Finance Lead" if cat == AssumptionCategory.COST else "PM"), + created_date=base_date, + ) + tracker.ingest(a) + assumption_ids.append(a.id) + n_validations = {"CLEAR": 2, "COMPLICATED": 3, "COMPLEX": 3, "CHAOTIC": 4}.get(domain, 2) + for idx, assumption_id in enumerate(assumption_ids): + row = store.get_assumption_by_id(assumption_id) + if row is None: + continue + baseline = float(row["baseline_value"]) + for v in range(n_validations): + months_elapsed = int((v + 1) * (12 / n_validations)) + base_date + timedelta(days=months_elapsed * 30) + drift_factor = 1.0 + drift_scale * (v + 1) * random.uniform(0.5, 1.5) + new_val = round(baseline * drift_factor if idx % 3 == 0 else baseline / drift_factor, 3) + tracker.update_value( + assumption_id=assumption_id, + new_value=new_val, + source=AssumptionSource.EXTERNAL_API if idx % 2 == 0 else AssumptionSource.MANUAL, + notes=f"Periodic review month {months_elapsed}", + ) + + +# --------------------------------------------------------------------------- +# P12 — ARMM assessments +# --------------------------------------------------------------------------- + +_ARMM_DOMAIN_PCT: dict[str, dict[str, float]] = { + "CLEAR": {"TC": 0.82, "OR": 0.78, "GA": 0.75, "CC": 0.70}, + "COMPLICATED": {"TC": 0.58, "OR": 0.52, "GA": 0.55, "CC": 0.45}, + "COMPLEX": {"TC": 0.30, "OR": 0.22, "GA": 0.35, "CC": 0.28}, + "CHAOTIC": {"TC": 0.12, "OR": 0.08, "GA": 0.18, "CC": 0.10}, +} +_ARMM_WEAKEST_TOPIC: dict[str, dict[str, str]] = { + "CLEAR": {"TC": "TC-SC", "OR": "OR-DR", "GA": "GA-EA", "CC": "CC-CI"}, + "COMPLICATED": {"TC": "TC-RT", "OR": "OR-BC", "GA": "GA-EA", "CC": "CC-CM"}, + "COMPLEX": {"TC": "TC-SC", "OR": "OR-BC", "GA": "GA-ER", "CC": "CC-SK"}, + "CHAOTIC": {"TC": "TC-IV", "OR": "OR-BC", "GA": "GA-PF", "CC": "CC-LC"}, +} + + +def _generate_armm_assessments(store: AssuranceStore, project_id: str, domain: str) -> None: + from pm_data_tools.assurance.armm import ( + TOPIC_CRITERIA_COUNT, + TOPIC_DIMENSION, + ARMMScorer, + ARMMTopic, + CriterionResult, + ) + scorer = ARMMScorer(store=store) + pct_profile = _ARMM_DOMAIN_PCT.get(domain, _ARMM_DOMAIN_PCT["COMPLICATED"]) + weakest = _ARMM_WEAKEST_TOPIC.get(domain, {}) + n_assessments = {"CLEAR": 3, "COMPLICATED": 3, "COMPLEX": 2, "CHAOTIC": 2}.get(domain, 2) + base_date = date(2025, 4, 1) + assessors = ["Assurance Lead", "Senior Responsible Owner", "Portfolio Manager"] + for idx in range(n_assessments): + months_offset = int(idx * 12 / n_assessments) + assessment_date = (base_date + timedelta(days=months_offset * 30)).isoformat() + improvement_bonus = 0.06 * idx if idx > 0 and domain != "CHAOTIC" else 0.0 + criterion_results: list[CriterionResult] = [] + for topic in ARMMTopic: + dim_code = TOPIC_DIMENSION[topic].value + n_criteria = TOPIC_CRITERIA_COUNT[topic] + base_pct = pct_profile.get(dim_code, 0.3) + improvement_bonus + is_weakest = weakest.get(dim_code) == topic.value + topic_pct = max(0.0, min(1.0, base_pct - 0.25 if is_weakest else base_pct)) + for i in range(1, n_criteria + 1): + criterion_id = f"{topic.value}-{i}" + met = (i / n_criteria) <= topic_pct + criterion_results.append(CriterionResult( + criterion_id=criterion_id, + met=met, + evidence_ref=f"DOC-{project_id}-{topic.value}-{i}" if met else None, + )) + assessment = scorer.assess( + project_id=project_id, + criterion_results=criterion_results, + assessed_by=assessors[idx % len(assessors)], + notes=f"Assessment {idx + 1} of {n_assessments}", + ) + with store._connect() as conn: + conn.execute( + "UPDATE armm_assessments SET assessed_at = ? WHERE id = ?", + (assessment_date + "T09:00:00+00:00", assessment.id), + ) + store.insert_armm_criterion_results( + assessment_id=assessment.id, + project_id=project_id, + results=[ + { + "criterion_id": r.criterion_id, + "topic_code": "-".join(r.criterion_id.split("-")[:2]), + "dimension_code": r.criterion_id.split("-")[0], + "met": r.met, + "evidence_ref": r.evidence_ref or "", + "notes": "", + } + for r in criterion_results + ], + ) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def generate_single_project( + store: AssuranceStore, + project_id: str, + domain: str, + meta: dict[str, Any], + classifier_input: Any, +) -> None: + """Run the full P1–P12 generation pipeline for a single project. + + Args: + store: Initialised AssuranceStore instance. + project_id: Unique project identifier (e.g. ``"HACKATHON-A1B2C3D4"``). + domain: Complexity domain — ``"CLEAR"``, ``"COMPLICATED"``, ``"COMPLEX"``, + or ``"CHAOTIC"``. + meta: Project metadata dict with at least ``"name"`` and ``"sro"`` keys. + classifier_input: A :class:`ClassificationInput` instance for P10 + domain classification. + """ + scores = _generate_confidence_scores(store, project_id, domain) + _generate_recommendations(store, project_id, domain) + _generate_divergence_snapshots(store, project_id, domain) + _generate_schedule_recommendations(store, project_id, domain) + _generate_overrides(store, project_id, domain, sro=meta.get("sro", "Programme Director")) + _generate_activities(store, project_id, domain, scores, project_name=meta.get("name", project_id)) + _generate_assumptions(store, project_id, domain) + _generate_armm_assessments(store, project_id, domain) + _generate_workflow_executions(store, project_id, domain) + _generate_domain_classification(store, classifier_input) diff --git a/packages/pm-data-tools/src/pm_data_tools/assurance/lessons.py b/packages/pm-data-tools/src/pm_data_tools/assurance/lessons.py index d2b5caf..ea4af8a 100644 --- a/packages/pm-data-tools/src/pm_data_tools/assurance/lessons.py +++ b/packages/pm-data-tools/src/pm_data_tools/assurance/lessons.py @@ -39,7 +39,7 @@ from collections import Counter from datetime import date, datetime, timezone from enum import Enum -from typing import Any, Optional +from typing import Any import structlog from pydantic import BaseModel, Field @@ -248,13 +248,13 @@ def __init__( self._store = store or AssuranceStore() self._model_name = model_name self._similarity_threshold = similarity_threshold - self._model: Optional[object] = None # lazy-loaded + self._model: object | None = None # lazy-loaded # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ - def _get_model(self) -> "SentenceTransformer": + def _get_model(self) -> SentenceTransformer: """Lazily load the sentence-transformer model. Returns: diff --git a/packages/pm-data-tools/src/pm_data_tools/assurance/models.py b/packages/pm-data-tools/src/pm_data_tools/assurance/models.py index 4991878..7ebd993 100644 --- a/packages/pm-data-tools/src/pm_data_tools/assurance/models.py +++ b/packages/pm-data-tools/src/pm_data_tools/assurance/models.py @@ -9,7 +9,6 @@ import uuid from datetime import date from enum import Enum -from typing import Optional from pydantic import BaseModel, Field @@ -54,8 +53,8 @@ class ReviewAction(BaseModel): source_review_id: str review_date: date status: ReviewActionStatus = ReviewActionStatus.OPEN - owner: Optional[str] = None - recurrence_of: Optional[str] = None + owner: str | None = None + recurrence_of: str | None = None confidence: float flagged_for_review: bool = False diff --git a/packages/pm-data-tools/src/pm_data_tools/assurance/overhead.py b/packages/pm-data-tools/src/pm_data_tools/assurance/overhead.py index 3b90b8d..d72a7db 100644 --- a/packages/pm-data-tools/src/pm_data_tools/assurance/overhead.py +++ b/packages/pm-data-tools/src/pm_data_tools/assurance/overhead.py @@ -44,7 +44,6 @@ from collections import Counter from datetime import date, datetime, timezone from enum import Enum -from typing import Any, Optional import structlog from pydantic import BaseModel, Field diff --git a/packages/pm-data-tools/src/pm_data_tools/assurance/overrides.py b/packages/pm-data-tools/src/pm_data_tools/assurance/overrides.py index 6289eda..abb57fb 100644 --- a/packages/pm-data-tools/src/pm_data_tools/assurance/overrides.py +++ b/packages/pm-data-tools/src/pm_data_tools/assurance/overrides.py @@ -46,7 +46,7 @@ from collections import Counter from datetime import date, datetime, timezone from enum import Enum -from typing import Any, Optional +from typing import Any import structlog from pydantic import BaseModel, Field @@ -129,14 +129,14 @@ class OverrideDecision(BaseModel): decision_date: date authoriser: str rationale: str - overridden_finding_id: Optional[str] = None - overridden_value: Optional[str] = None - override_value: Optional[str] = None + overridden_finding_id: str | None = None + overridden_value: str | None = None + override_value: str | None = None conditions: list[str] = Field(default_factory=list) evidence_refs: list[str] = Field(default_factory=list) outcome: OverrideOutcome = OverrideOutcome.PENDING - outcome_date: Optional[date] = None - outcome_notes: Optional[str] = None + outcome_date: date | None = None + outcome_notes: str | None = None class OverridePatternSummary(BaseModel): diff --git a/packages/pm-data-tools/src/pm_data_tools/assurance/recurrence.py b/packages/pm-data-tools/src/pm_data_tools/assurance/recurrence.py index 5459523..688cdad 100644 --- a/packages/pm-data-tools/src/pm_data_tools/assurance/recurrence.py +++ b/packages/pm-data-tools/src/pm_data_tools/assurance/recurrence.py @@ -8,7 +8,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING import structlog @@ -63,9 +63,9 @@ def __init__( """ self._threshold = similarity_threshold self._model_name = model_name - self._model: Optional[object] = None # lazy-loaded + self._model: object | None = None # lazy-loaded - def _get_model(self) -> "SentenceTransformer": + def _get_model(self) -> SentenceTransformer: """Lazily load the sentence-transformer model. Returns: @@ -77,9 +77,9 @@ def _get_model(self) -> "SentenceTransformer": def detect_recurrences( self, - new_recommendations: "list[ReviewAction]", - prior_recommendations: "list[ReviewAction]", - ) -> "list[ReviewAction]": + new_recommendations: list[ReviewAction], + prior_recommendations: list[ReviewAction], + ) -> list[ReviewAction]: """Mark new recommendations as recurring where a prior match is found. Recommendations from ``new_recommendations`` are compared against diff --git a/packages/pm-data-tools/src/pm_data_tools/assurance/scheduler.py b/packages/pm-data-tools/src/pm_data_tools/assurance/scheduler.py index f904f5e..9c416a5 100644 --- a/packages/pm-data-tools/src/pm_data_tools/assurance/scheduler.py +++ b/packages/pm-data-tools/src/pm_data_tools/assurance/scheduler.py @@ -35,7 +35,6 @@ import json from datetime import date, datetime, timedelta, timezone from enum import Enum -from typing import Any, Optional import structlog from pydantic import BaseModel, Field, field_validator @@ -219,7 +218,7 @@ class AdaptiveReviewScheduler: def __init__( self, config: SchedulerConfig | None = None, - store: Optional[object] = None, + store: object | None = None, ) -> None: """Initialise the scheduler. diff --git a/packages/pm-data-tools/src/pm_data_tools/assurance/workflows.py b/packages/pm-data-tools/src/pm_data_tools/assurance/workflows.py index 742660a..3938ab3 100644 --- a/packages/pm-data-tools/src/pm_data_tools/assurance/workflows.py +++ b/packages/pm-data-tools/src/pm_data_tools/assurance/workflows.py @@ -1194,7 +1194,7 @@ def _step_armm_readiness( output={"reason": "No store configured."}, ) - from .armm import ARMMScorer, MATURITY_LABELS + from .armm import MATURITY_LABELS, ARMMScorer scorer = ARMMScorer(store=self._store) report = scorer.get_report(project_id) diff --git a/packages/pm-data-tools/src/pm_data_tools/cli/__init__.py b/packages/pm-data-tools/src/pm_data_tools/cli/__init__.py index bea1f19..43f26d3 100644 --- a/packages/pm-data-tools/src/pm_data_tools/cli/__init__.py +++ b/packages/pm-data-tools/src/pm_data_tools/cli/__init__.py @@ -14,7 +14,7 @@ def main() -> None: # Register commands -from .commands import convert, validate, inspect_cmd +from .commands import convert, inspect_cmd, validate main.add_command(convert) main.add_command(validate) diff --git a/packages/pm-data-tools/src/pm_data_tools/cli/commands/__init__.py b/packages/pm-data-tools/src/pm_data_tools/cli/commands/__init__.py index 695df82..251a1fd 100644 --- a/packages/pm-data-tools/src/pm_data_tools/cli/commands/__init__.py +++ b/packages/pm-data-tools/src/pm_data_tools/cli/commands/__init__.py @@ -1,7 +1,7 @@ """CLI commands for PM Data Tools.""" from .convert import convert -from .validate import validate from .inspect import inspect_cmd +from .validate import validate __all__ = ["convert", "validate", "inspect_cmd"] diff --git a/packages/pm-data-tools/src/pm_data_tools/cli/commands/convert.py b/packages/pm-data-tools/src/pm_data_tools/cli/commands/convert.py index 3596f85..4f6a166 100644 --- a/packages/pm-data-tools/src/pm_data_tools/cli/commands/convert.py +++ b/packages/pm-data-tools/src/pm_data_tools/cli/commands/convert.py @@ -1,8 +1,8 @@ """Convert command for format conversion.""" -import click from pathlib import Path -from typing import Optional + +import click @click.command() @@ -35,7 +35,7 @@ def convert( input_file: Path, output_file: Path, - source_format: Optional[str], + source_format: str | None, target_format: str, validate: bool, ) -> None: diff --git a/packages/pm-data-tools/src/pm_data_tools/cli/commands/inspect.py b/packages/pm-data-tools/src/pm_data_tools/cli/commands/inspect.py index 1ae3d85..13e9f66 100644 --- a/packages/pm-data-tools/src/pm_data_tools/cli/commands/inspect.py +++ b/packages/pm-data-tools/src/pm_data_tools/cli/commands/inspect.py @@ -1,8 +1,8 @@ """Inspect command for examining project structure.""" -import click from pathlib import Path -from typing import Optional + +import click @click.command(name="inspect") @@ -39,7 +39,7 @@ ) def inspect_cmd( input_file: Path, - file_format: Optional[str], + file_format: str | None, show_tasks: bool, show_resources: bool, show_risks: bool, diff --git a/packages/pm-data-tools/src/pm_data_tools/cli/commands/validate.py b/packages/pm-data-tools/src/pm_data_tools/cli/commands/validate.py index 8386f22..bfe5367 100644 --- a/packages/pm-data-tools/src/pm_data_tools/cli/commands/validate.py +++ b/packages/pm-data-tools/src/pm_data_tools/cli/commands/validate.py @@ -1,8 +1,8 @@ """Validate command for project data validation.""" -import click from pathlib import Path -from typing import Optional + +import click @click.command() @@ -29,9 +29,9 @@ ) def validate( input_file: Path, - file_format: Optional[str], + file_format: str | None, strict: bool, - output: Optional[Path], + output: Path | None, ) -> None: """Validate project data file. diff --git a/packages/pm-data-tools/src/pm_data_tools/db/store.py b/packages/pm-data-tools/src/pm_data_tools/db/store.py index 8ea4b9c..99bf122 100644 --- a/packages/pm-data-tools/src/pm_data_tools/db/store.py +++ b/packages/pm-data-tools/src/pm_data_tools/db/store.py @@ -26,7 +26,6 @@ import json import sqlite3 from pathlib import Path -from typing import Optional import structlog @@ -58,7 +57,7 @@ class AssuranceStore: ) """ - def __init__(self, db_path: Optional[Path] = None) -> None: + def __init__(self, db_path: Path | None = None) -> None: """Initialise the store and create tables if absent. Args: @@ -396,7 +395,7 @@ def upsert_recommendation(self, data: dict[str, object]) -> None: def get_recommendations( self, project_id: str, - status_filter: Optional[str] = None, + status_filter: str | None = None, ) -> list[dict[str, object]]: """Retrieve recommendations for a project, optionally filtered by status. @@ -655,8 +654,8 @@ def upsert_override_decision(self, data: dict[str, object]) -> None: def get_override_decisions( self, project_id: str, - override_type: Optional[str] = None, - outcome: Optional[str] = None, + override_type: str | None = None, + outcome: str | None = None, ) -> list[dict[str, object]]: """Retrieve override decisions, optionally filtered by type and/or outcome. @@ -708,8 +707,8 @@ def update_override_outcome( self, override_id: str, outcome: str, - outcome_date: Optional[str] = None, - outcome_notes: Optional[str] = None, + outcome_date: str | None = None, + outcome_notes: str | None = None, ) -> None: """Update the outcome of a previously logged override decision. @@ -783,9 +782,9 @@ def upsert_lesson(self, data: dict[str, object]) -> None: def get_lessons( self, - project_id: Optional[str] = None, - category: Optional[str] = None, - sentiment: Optional[str] = None, + project_id: str | None = None, + category: str | None = None, + sentiment: str | None = None, ) -> list[dict[str, object]]: """Retrieve lessons, optionally filtered by project, category, and sentiment. @@ -903,7 +902,7 @@ def upsert_assurance_activity(self, data: dict[str, object]) -> None: def get_assurance_activities( self, project_id: str, - activity_type: Optional[str] = None, + activity_type: str | None = None, ) -> list[dict[str, object]]: """Retrieve assurance activities for a project. @@ -1198,7 +1197,7 @@ def upsert_assumption(self, data: dict[str, object]) -> None: def get_assumptions( self, project_id: str, - category: Optional[str] = None, + category: str | None = None, ) -> list[dict[str, object]]: """Retrieve assumptions for a project, optionally filtered by category. @@ -1459,10 +1458,10 @@ def get_armm_project_ids(self) -> list[str]: def get_armm_criterion_results( self, - assessment_id: Optional[str] = None, - project_id: Optional[str] = None, - dimension_code: Optional[str] = None, - topic_code: Optional[str] = None, + assessment_id: str | None = None, + project_id: str | None = None, + dimension_code: str | None = None, + topic_code: str | None = None, ) -> list[dict[str, object]]: """Retrieve criterion-level results with optional filters. diff --git a/packages/pm-data-tools/src/pm_data_tools/exporters.py b/packages/pm-data-tools/src/pm_data_tools/exporters.py index 8254063..649adf1 100644 --- a/packages/pm-data-tools/src/pm_data_tools/exporters.py +++ b/packages/pm-data-tools/src/pm_data_tools/exporters.py @@ -5,6 +5,7 @@ """ from typing import Protocol + from .models import Project @@ -44,14 +45,14 @@ def create_exporter(format_name: str) -> Exporter: from .schemas.mspdi.exporter import MspdiExporter return MspdiExporter() except ImportError: - raise ValueError(f"MSPDI exporter not yet implemented") + raise ValueError("MSPDI exporter not yet implemented") elif format_name == 'gmpp': try: from .schemas.gmpp.exporter import GmppExporter return GmppExporter() except ImportError: - raise ValueError(f"GMPP exporter not yet implemented") + raise ValueError("GMPP exporter not yet implemented") else: raise ValueError( diff --git a/packages/pm-data-tools/src/pm_data_tools/gmpp/__init__.py b/packages/pm-data-tools/src/pm_data_tools/gmpp/__init__.py index 139f890..7350408 100644 --- a/packages/pm-data-tools/src/pm_data_tools/gmpp/__init__.py +++ b/packages/pm-data-tools/src/pm_data_tools/gmpp/__init__.py @@ -16,16 +16,16 @@ ... ) """ +from pm_data_tools.gmpp.aggregator import GMPPDataAggregator from pm_data_tools.gmpp.models import ( - QuarterPeriod, + BenefitsPerformance, DCANarrative, FinancialPerformance, - SchedulePerformance, - BenefitsPerformance, QuarterlyReport, + QuarterPeriod, ReviewLevel, + SchedulePerformance, ) -from pm_data_tools.gmpp.aggregator import GMPPDataAggregator from pm_data_tools.gmpp.narratives import NarrativeGenerator __all__ = [ diff --git a/packages/pm-data-tools/src/pm_data_tools/gmpp/aggregator.py b/packages/pm-data-tools/src/pm_data_tools/gmpp/aggregator.py index 4e28e1c..6663f8c 100644 --- a/packages/pm-data-tools/src/pm_data_tools/gmpp/aggregator.py +++ b/packages/pm-data-tools/src/pm_data_tools/gmpp/aggregator.py @@ -9,20 +9,19 @@ - Data lineage tracking """ -from typing import Dict, List, Optional -from datetime import date, datetime, timedelta +from datetime import date, datetime from decimal import Decimal -from pm_data_tools.models import Project, DeliveryConfidence from pm_data_tools.gmpp.models import ( - QuarterlyReport, - QuarterPeriod, - FinancialPerformance, - SchedulePerformance, BenefitsPerformance, DCANarrative, + FinancialPerformance, + QuarterlyReport, + QuarterPeriod, ReviewLevel, + SchedulePerformance, ) +from pm_data_tools.models import DeliveryConfidence, Project class GMPPDataAggregator: @@ -41,7 +40,7 @@ class GMPPDataAggregator: ... ) """ - def __init__(self, api_key: Optional[str] = None): + def __init__(self, api_key: str | None = None): """Initialize data aggregator. Args: @@ -54,7 +53,7 @@ async def aggregate_quarterly_report( project: Project, quarter: str, financial_year: str, - previous_quarter_report: Optional[QuarterlyReport] = None, + previous_quarter_report: QuarterlyReport | None = None, generate_narratives: bool = True, ) -> QuarterlyReport: """Generate complete GMPP quarterly report from project data. @@ -208,7 +207,7 @@ def _extract_financial_performance(self, project: Project) -> FinancialPerforman # Calculate variance variance_amount = forecast_cost - baseline_cost - variance_percent = float((variance_amount / baseline_cost * 100)) if baseline_cost > 0 else 0.0 + variance_percent = float(variance_amount / baseline_cost * 100) if baseline_cost > 0 else 0.0 # Calculate confidence based on data freshness and source confidence = self._score_financial_confidence(project) @@ -281,7 +280,7 @@ def _extract_benefits_performance(self, project: Project) -> BenefitsPerformance confidence=confidence, ) - def _map_dca_rating(self, delivery_confidence: Optional[DeliveryConfidence]) -> str: + def _map_dca_rating(self, delivery_confidence: DeliveryConfidence | None) -> str: """Map DeliveryConfidence enum to GMPP DCA rating string. Args: @@ -347,7 +346,7 @@ def _extract_project_context( financial: FinancialPerformance, schedule: SchedulePerformance, benefits: BenefitsPerformance, - ) -> Dict: + ) -> dict: """Build project context for narrative generation. Args: @@ -502,7 +501,7 @@ def _score_benefits_confidence(self, project: Project) -> float: return max(0.0, min(1.0, score)) - def _extract_data_sources(self, project: Project) -> List[str]: + def _extract_data_sources(self, project: Project) -> list[str]: """Extract list of data sources used. Args: @@ -528,7 +527,7 @@ def _calculate_confidence_scores( financial: FinancialPerformance, schedule: SchedulePerformance, benefits: BenefitsPerformance, - ) -> Dict[str, float]: + ) -> dict[str, float]: """Calculate field-level confidence scores. Args: @@ -547,7 +546,7 @@ def _calculate_confidence_scores( "risks": 0.9 if project.risks else 0.5, } - def _identify_missing_fields(self, project: Project) -> List[str]: + def _identify_missing_fields(self, project: Project) -> list[str]: """Identify recommended fields that are missing. Args: @@ -576,7 +575,7 @@ def _generate_validation_warnings( financial: FinancialPerformance, schedule: SchedulePerformance, benefits: BenefitsPerformance, - ) -> List[str]: + ) -> list[str]: """Generate validation warnings. Args: diff --git a/packages/pm-data-tools/src/pm_data_tools/gmpp/models.py b/packages/pm-data-tools/src/pm_data_tools/gmpp/models.py index 48e904a..17d880d 100644 --- a/packages/pm-data-tools/src/pm_data_tools/gmpp/models.py +++ b/packages/pm-data-tools/src/pm_data_tools/gmpp/models.py @@ -5,12 +5,12 @@ metrics, and AI-generated narratives with confidence scoring. """ -from pydantic import BaseModel, Field, field_validator from datetime import date, datetime from decimal import Decimal -from typing import Optional, List, Dict from enum import Enum +from pydantic import BaseModel, Field, field_validator + class QuarterPeriod(str, Enum): """UK Government financial year quarters. @@ -81,7 +81,7 @@ class DCANarrative(BaseModel): le=10, description="Number of AI samples used (typically 3-5)" ) - review_reason: Optional[str] = Field( + review_reason: str | None = Field( None, description="Explanation for why review is recommended (if applicable)" ) @@ -137,7 +137,7 @@ class FinancialPerformance(BaseModel): decimal_places=2, description="Current forecast whole life cost (£ millions)" ) - actual_cost: Optional[Decimal] = Field( + actual_cost: Decimal | None = Field( None, decimal_places=2, description="Actual cost incurred to date (£ millions)" @@ -191,7 +191,7 @@ class SchedulePerformance(BaseModel): ..., description="Current forecast completion date" ) - actual_completion: Optional[date] = Field( + actual_completion: date | None = Field( None, description="Actual completion date (if project completed)" ) @@ -352,7 +352,7 @@ class QuarterlyReport(BaseModel): ..., description="AI-generated DCA narrative with confidence metadata" ) - previous_dca_rating: Optional[str] = Field( + previous_dca_rating: str | None = Field( None, pattern=r"^(GREEN|AMBER/GREEN|AMBER|AMBER/RED|RED|EXEMPT)$", description="DCA rating from previous quarter" @@ -361,7 +361,7 @@ class QuarterlyReport(BaseModel): default=False, description="Whether DCA rating changed from previous quarter" ) - dca_change_rationale: Optional[str] = Field( + dca_change_rationale: str | None = Field( None, max_length=500, description="Rationale for DCA rating change (if changed)" @@ -400,26 +400,26 @@ class QuarterlyReport(BaseModel): ) # ========== Metadata ========== - data_sources: List[str] = Field( + data_sources: list[str] = Field( default_factory=list, description="List of data sources used (e.g., 'MS Project', 'Jira', 'Manual entry')" ) - confidence_scores: Dict[str, float] = Field( + confidence_scores: dict[str, float] = Field( default_factory=dict, description="Field-level confidence scores (field_name -> score)" ) - missing_fields: List[str] = Field( + missing_fields: list[str] = Field( default_factory=list, description="List of recommended fields that are missing" ) - validation_warnings: List[str] = Field( + validation_warnings: list[str] = Field( default_factory=list, description="Non-critical validation warnings" ) @field_validator("dca_change_rationale") @classmethod - def validate_rationale_if_changed(cls, v: Optional[str], info) -> Optional[str]: + def validate_rationale_if_changed(cls, v: str | None, info) -> str | None: """Require rationale if DCA changed.""" values = info.data if values.get("dca_changed") and not v: diff --git a/packages/pm-data-tools/src/pm_data_tools/gmpp/narratives.py b/packages/pm-data-tools/src/pm_data_tools/gmpp/narratives.py index e020af5..3878f11 100644 --- a/packages/pm-data-tools/src/pm_data_tools/gmpp/narratives.py +++ b/packages/pm-data-tools/src/pm_data_tools/gmpp/narratives.py @@ -4,17 +4,15 @@ civil service narratives with confidence scoring and multi-sample consensus. """ -from typing import Dict from datetime import datetime from agent_planning import ConfidenceExtractor -from agent_planning.providers import AnthropicProvider from agent_planning.confidence import CustomSchema from agent_planning.guardrails import GuardrailConfig +from agent_planning.providers import AnthropicProvider from pm_data_tools.gmpp.models import DCANarrative, ReviewLevel - # Custom schema for GMPP narratives GMPP_DCA_SCHEMA = CustomSchema( name="GMPP DCA Narrative", @@ -152,7 +150,7 @@ def __init__( async def generate_dca_narrative( self, - project_data: Dict, + project_data: dict, dca_rating: str, ) -> DCANarrative: """Generate Delivery Confidence Assessment narrative. @@ -175,7 +173,7 @@ async def generate_dca_narrative( return self._result_to_narrative(result) - async def generate_cost_narrative(self, project_data: Dict) -> DCANarrative: + async def generate_cost_narrative(self, project_data: dict) -> DCANarrative: """Generate cost performance narrative. Args: @@ -195,7 +193,7 @@ async def generate_cost_narrative(self, project_data: Dict) -> DCANarrative: return self._result_to_narrative(result) - async def generate_schedule_narrative(self, project_data: Dict) -> DCANarrative: + async def generate_schedule_narrative(self, project_data: dict) -> DCANarrative: """Generate schedule performance narrative. Args: @@ -215,7 +213,7 @@ async def generate_schedule_narrative(self, project_data: Dict) -> DCANarrative: return self._result_to_narrative(result) - async def generate_benefits_narrative(self, project_data: Dict) -> DCANarrative: + async def generate_benefits_narrative(self, project_data: dict) -> DCANarrative: """Generate benefits realisation narrative. Args: @@ -235,7 +233,7 @@ async def generate_benefits_narrative(self, project_data: Dict) -> DCANarrative: return self._result_to_narrative(result) - async def generate_risk_narrative(self, project_data: Dict) -> DCANarrative: + async def generate_risk_narrative(self, project_data: dict) -> DCANarrative: """Generate risk status narrative. Args: @@ -255,7 +253,7 @@ async def generate_risk_narrative(self, project_data: Dict) -> DCANarrative: return self._result_to_narrative(result) - def _build_dca_prompt(self, project_data: Dict, dca_rating: str) -> str: + def _build_dca_prompt(self, project_data: dict, dca_rating: str) -> str: """Build context-rich prompt for DCA narrative generation. Args: @@ -301,7 +299,7 @@ def _build_dca_prompt(self, project_data: Dict, dca_rating: str) -> str: describe critical issues and their impact, and outline mitigation actions being taken. """ - def _build_cost_prompt(self, project_data: Dict) -> str: + def _build_cost_prompt(self, project_data: dict) -> str: """Build prompt for cost narrative. Args: @@ -328,7 +326,7 @@ def _build_cost_prompt(self, project_data: Dict) -> str: Use professional civil service style. """ - def _build_schedule_prompt(self, project_data: Dict) -> str: + def _build_schedule_prompt(self, project_data: dict) -> str: """Build prompt for schedule narrative. Args: @@ -354,7 +352,7 @@ def _build_schedule_prompt(self, project_data: Dict) -> str: Use professional civil service style. """ - def _build_benefits_prompt(self, project_data: Dict) -> str: + def _build_benefits_prompt(self, project_data: dict) -> str: """Build prompt for benefits narrative. Args: @@ -381,7 +379,7 @@ def _build_benefits_prompt(self, project_data: Dict) -> str: Use professional civil service style. """ - def _build_risk_prompt(self, project_data: Dict) -> str: + def _build_risk_prompt(self, project_data: dict) -> str: """Build prompt for risk narrative. Args: @@ -390,7 +388,7 @@ def _build_risk_prompt(self, project_data: Dict) -> str: Returns: Formatted prompt """ - high_risks = project_data.get('high_risks_count', 0) + project_data.get('high_risks_count', 0) return f""" Generate a risk status narrative for UK Government GMPP quarterly reporting. diff --git a/packages/pm-data-tools/src/pm_data_tools/integrations/nista/__init__.py b/packages/pm-data-tools/src/pm_data_tools/integrations/nista/__init__.py index 808075a..cbf21db 100644 --- a/packages/pm-data-tools/src/pm_data_tools/integrations/nista/__init__.py +++ b/packages/pm-data-tools/src/pm_data_tools/integrations/nista/__init__.py @@ -19,9 +19,9 @@ >>> result = await client.submit_quarterly_return(project_id, report) """ +from pm_data_tools.integrations.nista.audit import AuditEntry, AuditLogger from pm_data_tools.integrations.nista.auth import NISTAAuthClient, NISTAAuthConfig from pm_data_tools.integrations.nista.client import NISTAAPIClient, SubmissionResult -from pm_data_tools.integrations.nista.audit import AuditLogger, AuditEntry __all__ = [ "NISTAAuthClient", diff --git a/packages/pm-data-tools/src/pm_data_tools/integrations/nista/audit.py b/packages/pm-data-tools/src/pm_data_tools/integrations/nista/audit.py index 5a936c9..7b02718 100644 --- a/packages/pm-data-tools/src/pm_data_tools/integrations/nista/audit.py +++ b/packages/pm-data-tools/src/pm_data_tools/integrations/nista/audit.py @@ -8,13 +8,13 @@ - Full traceability from source to NISTA """ -from dataclasses import dataclass, asdict -from datetime import datetime -from typing import Optional, List, Dict, Any import hashlib import json import os +from dataclasses import asdict, dataclass +from datetime import datetime from pathlib import Path +from typing import Any @dataclass @@ -40,12 +40,12 @@ class AuditEntry: user: str project_id: str data_hash: str - source_systems: List[str] - nista_submission_id: Optional[str] + source_systems: list[str] + nista_submission_id: str | None response_status: int - response_body: Optional[Dict[str, Any]] - entry_hash: Optional[str] = None - previous_entry_hash: Optional[str] = None + response_body: dict[str, Any] | None + entry_hash: str | None = None + previous_entry_hash: str | None = None class AuditLogger: @@ -70,7 +70,7 @@ class AuditLogger: def __init__( self, - log_dir: Optional[Path] = None, + log_dir: Path | None = None, retention_years: int = 7 ): """Initialize audit logger. @@ -84,14 +84,14 @@ def __init__( self.retention_years = retention_years # Initialize or load chain - self._last_entry_hash: Optional[str] = self._load_last_entry_hash() + self._last_entry_hash: str | None = self._load_last_entry_hash() def log_submission( self, project_id: str, report: Any, response_status: int, - response_body: Optional[Dict[str, Any]] = None, + response_body: dict[str, Any] | None = None, ) -> AuditEntry: """Log GMPP quarterly return submission. @@ -139,7 +139,7 @@ def log_data_fetch( project_id: str, action: str, response_status: int, - response_body: Optional[Dict[str, Any]] = None, + response_body: dict[str, Any] | None = None, ) -> AuditEntry: """Log data fetch operation. @@ -173,12 +173,12 @@ def log_data_fetch( def get_entries( self, - project_id: Optional[str] = None, - action: Optional[str] = None, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None, + project_id: str | None = None, + action: str | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, limit: int = 100, - ) -> List[AuditEntry]: + ) -> list[AuditEntry]: """Retrieve audit entries matching criteria. Args: @@ -197,7 +197,7 @@ def get_entries( log_files = sorted(self.log_dir.glob("audit_*.jsonl"), reverse=True) for log_file in log_files: - with open(log_file, "r") as f: + with open(log_file) as f: for line in reversed(f.readlines()): try: entry_dict = json.loads(line) @@ -237,7 +237,7 @@ def verify_chain_integrity(self) -> bool: previous_hash = None for log_file in log_files: - with open(log_file, "r") as f: + with open(log_file) as f: for line in f: try: entry_dict = json.loads(line) @@ -273,7 +273,7 @@ def _store(self, entry: AuditEntry) -> None: with open(log_file, "a") as f: f.write(json.dumps(asdict(entry), default=str) + "\n") - def _hash_data(self, data: Dict[str, Any]) -> str: + def _hash_data(self, data: dict[str, Any]) -> str: """Calculate SHA-256 hash of data for tamper detection. Args: @@ -308,7 +308,7 @@ def _get_current_user(self) -> str: """ return os.getenv("USER") or os.getenv("USERNAME") or "system" - def _load_last_entry_hash(self) -> Optional[str]: + def _load_last_entry_hash(self) -> str | None: """Load hash of last entry for chain continuation. Returns: @@ -318,7 +318,7 @@ def _load_last_entry_hash(self) -> Optional[str]: for log_file in log_files: try: - with open(log_file, "r") as f: + with open(log_file) as f: lines = f.readlines() if lines: last_line = lines[-1] @@ -329,7 +329,7 @@ def _load_last_entry_hash(self) -> Optional[str]: return None - def _dict_to_entry(self, entry_dict: Dict[str, Any]) -> AuditEntry: + def _dict_to_entry(self, entry_dict: dict[str, Any]) -> AuditEntry: """Convert dictionary to AuditEntry. Args: diff --git a/packages/pm-data-tools/src/pm_data_tools/integrations/nista/auth.py b/packages/pm-data-tools/src/pm_data_tools/integrations/nista/auth.py index 2ca4fda..904dd2a 100644 --- a/packages/pm-data-tools/src/pm_data_tools/integrations/nista/auth.py +++ b/packages/pm-data-tools/src/pm_data_tools/integrations/nista/auth.py @@ -7,11 +7,11 @@ - Support for sandbox and production environments """ -from typing import Optional +import os from datetime import datetime, timedelta -from pydantic import BaseModel, Field + import httpx -import os +from pydantic import BaseModel, Field class NISTAAuthConfig(BaseModel): @@ -30,11 +30,11 @@ class NISTAAuthConfig(BaseModel): client_id: str = Field(..., min_length=1, description="OAuth 2.0 client ID") client_secret: str = Field(..., min_length=1, description="OAuth 2.0 client secret") - certificate_path: Optional[str] = Field( + certificate_path: str | None = Field( None, description="Path to client certificate for mTLS (.pem format)" ) - private_key_path: Optional[str] = Field( + private_key_path: str | None = Field( None, description="Path to private key for mTLS (.pem format)" ) @@ -43,11 +43,11 @@ class NISTAAuthConfig(BaseModel): pattern=r"^(sandbox|production)$", description="NISTA environment" ) - base_url: Optional[str] = Field( + base_url: str | None = Field( None, description="Override base URL (auto-configured if not provided)" ) - token_url: Optional[str] = Field( + token_url: str | None = Field( None, description="Override token URL (auto-configured if not provided)" ) @@ -134,9 +134,9 @@ def __init__(self, config: NISTAAuthConfig): config: NISTA authentication configuration """ self.config = config - self._access_token: Optional[str] = None - self._token_expires: Optional[datetime] = None - self._http_client: Optional[httpx.AsyncClient] = None + self._access_token: str | None = None + self._token_expires: datetime | None = None + self._http_client: httpx.AsyncClient | None = None async def get_access_token(self) -> str: """Get valid access token (cached or freshly fetched). diff --git a/packages/pm-data-tools/src/pm_data_tools/integrations/nista/client.py b/packages/pm-data-tools/src/pm_data_tools/integrations/nista/client.py index e0547b1..e889179 100644 --- a/packages/pm-data-tools/src/pm_data_tools/integrations/nista/client.py +++ b/packages/pm-data-tools/src/pm_data_tools/integrations/nista/client.py @@ -4,13 +4,14 @@ including quarterly return submission, project metadata fetching, and error handling. """ -from typing import Optional, Dict, Any, List from datetime import datetime -from pydantic import BaseModel, Field +from typing import Any + import httpx +from pydantic import BaseModel, Field -from pm_data_tools.integrations.nista.auth import NISTAAuthClient from pm_data_tools.gmpp.models import QuarterlyReport +from pm_data_tools.integrations.nista.auth import NISTAAuthClient class SubmissionResult(BaseModel): @@ -26,20 +27,20 @@ class SubmissionResult(BaseModel): """ success: bool = Field(..., description="Submission success status") - submission_id: Optional[str] = Field( + submission_id: str | None = Field( None, description="NISTA submission ID (assigned on success)" ) timestamp: datetime = Field(..., description="Submission timestamp") - validation_warnings: List[str] = Field( + validation_warnings: list[str] = Field( default_factory=list, description="Non-critical validation warnings" ) - error: Optional[str] = Field( + error: str | None = Field( None, description="Error message if submission failed" ) - details: Optional[Dict[str, Any]] = Field( + details: dict[str, Any] | None = Field( None, description="Additional error details" ) @@ -101,7 +102,7 @@ def __init__(self, auth_client: NISTAAuthClient): """ self.auth = auth_client self.base_url = auth_client.config.base_url - self._http_client: Optional[httpx.AsyncClient] = None + self._http_client: httpx.AsyncClient | None = None async def submit_quarterly_return( self, @@ -244,7 +245,7 @@ async def fetch_project_metadata(self, project_id: str) -> ProjectMetadata: raise ValueError(f"Project not found in NISTA registry: {project_id}") from e raise - async def fetch_guidance(self, topic: str) -> Dict[str, Any]: + async def fetch_guidance(self, topic: str) -> dict[str, Any]: """Fetch latest NISTA guidance documents. Args: @@ -282,7 +283,7 @@ async def get_submission_history( self, project_id: str, limit: int = 10 - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Get submission history for a project. Args: diff --git a/packages/pm-data-tools/src/pm_data_tools/migration/__init__.py b/packages/pm-data-tools/src/pm_data_tools/migration/__init__.py index 9cc4a25..8221745 100644 --- a/packages/pm-data-tools/src/pm_data_tools/migration/__init__.py +++ b/packages/pm-data-tools/src/pm_data_tools/migration/__init__.py @@ -1,5 +1,5 @@ """Migration tools for NISTA compliance.""" -from .nista_assistant import NISTAMigrationAssistant, MigrationReport, MigrationGap +from .nista_assistant import MigrationGap, MigrationReport, NISTAMigrationAssistant __all__ = ["NISTAMigrationAssistant", "MigrationReport", "MigrationGap"] diff --git a/packages/pm-data-tools/src/pm_data_tools/migration/nista_assistant.py b/packages/pm-data-tools/src/pm_data_tools/migration/nista_assistant.py index 1e6418b..6153ee8 100644 --- a/packages/pm-data-tools/src/pm_data_tools/migration/nista_assistant.py +++ b/packages/pm-data-tools/src/pm_data_tools/migration/nista_assistant.py @@ -2,7 +2,6 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Optional from ..models import Project from ..schemas.nista import NISTAValidator, StrictnessLevel, ValidationResult @@ -22,9 +21,9 @@ class MigrationGap: field_name: str description: str - current_value: Optional[str] + current_value: str | None required: bool - mapping_suggestion: Optional[str] = None + mapping_suggestion: str | None = None effort: EffortLevel = EffortLevel.MEDIUM @@ -37,7 +36,7 @@ class MigrationReport: gaps: list[MigrationGap] = field(default_factory=list) mapping_suggestions: dict[str, str] = field(default_factory=dict) estimated_effort: EffortLevel = EffortLevel.MEDIUM - validation_result: Optional[ValidationResult] = None + validation_result: ValidationResult | None = None @property def required_gaps_count(self) -> int: @@ -246,7 +245,7 @@ def _identify_gaps( def _get_project_field_value( self, project: Project, field_name: str - ) -> Optional[str]: + ) -> str | None: """Get current value of a field from Project. Args: @@ -284,7 +283,7 @@ def _get_project_field_value( return None - def _suggest_mapping(self, project: Project, field_name: str) -> Optional[str]: + def _suggest_mapping(self, project: Project, field_name: str) -> str | None: """Suggest field mapping from source data. Args: @@ -342,7 +341,7 @@ def _generate_mapping_suggestions( return suggestions def _estimate_field_effort( - self, field_name: str, current_value: Optional[str] + self, field_name: str, current_value: str | None ) -> EffortLevel: """Estimate effort to populate a field. diff --git a/packages/pm-data-tools/src/pm_data_tools/models/assignment.py b/packages/pm-data-tools/src/pm_data_tools/models/assignment.py index af6bcd4..24b4580 100644 --- a/packages/pm-data-tools/src/pm_data_tools/models/assignment.py +++ b/packages/pm-data-tools/src/pm_data_tools/models/assignment.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from datetime import datetime -from typing import Optional from uuid import UUID from .base import Duration, Money, SourceInfo @@ -30,17 +29,17 @@ class Assignment: units: float = 1.0 # 1.0 = 100% allocation # Schedule - start_date: Optional[datetime] = None - finish_date: Optional[datetime] = None + start_date: datetime | None = None + finish_date: datetime | None = None # Work - budgeted_work: Optional[Duration] = None - actual_work: Optional[Duration] = None - remaining_work: Optional[Duration] = None + budgeted_work: Duration | None = None + actual_work: Duration | None = None + remaining_work: Duration | None = None # Cost - budgeted_cost: Optional[Money] = None - actual_cost: Optional[Money] = None + budgeted_cost: Money | None = None + actual_cost: Money | None = None def __str__(self) -> str: """String representation.""" @@ -56,7 +55,7 @@ def allocation_percent(self) -> float: return self.units * 100.0 @property - def work_complete_percent(self) -> Optional[float]: + def work_complete_percent(self) -> float | None: """Calculate work completion percentage. Returns: @@ -74,7 +73,7 @@ def work_complete_percent(self) -> Optional[float]: return (actual_hours / budgeted_hours) * 100.0 @property - def cost_variance(self) -> Optional[Money]: + def cost_variance(self) -> Money | None: """Calculate cost variance (budgeted - actual). Returns: diff --git a/packages/pm-data-tools/src/pm_data_tools/models/base.py b/packages/pm-data-tools/src/pm_data_tools/models/base.py index 371cdb1..64f6b01 100644 --- a/packages/pm-data-tools/src/pm_data_tools/models/base.py +++ b/packages/pm-data-tools/src/pm_data_tools/models/base.py @@ -11,7 +11,6 @@ from datetime import datetime from decimal import Decimal from enum import Enum -from typing import Optional class DurationType(Enum): @@ -151,10 +150,10 @@ class SourceInfo: """ tool: str # "mspdi", "p6", "jira", etc. - tool_version: Optional[str] = None - file_path: Optional[str] = None - extracted_at: Optional[datetime] = None - original_id: Optional[str] = None # ID in source system + tool_version: str | None = None + file_path: str | None = None + extracted_at: datetime | None = None + original_id: str | None = None # ID in source system def __str__(self) -> str: """String representation.""" @@ -177,7 +176,7 @@ class CustomField: value: str | int | float | bool | datetime | None field_type: str # "text", "number", "date", "boolean", "choice" source_tool: str # Which tool this came from - source_field_id: Optional[str] = None + source_field_id: str | None = None def __str__(self) -> str: """String representation.""" diff --git a/packages/pm-data-tools/src/pm_data_tools/models/calendar.py b/packages/pm-data-tools/src/pm_data_tools/models/calendar.py index 56a6f72..06d3819 100644 --- a/packages/pm-data-tools/src/pm_data_tools/models/calendar.py +++ b/packages/pm-data-tools/src/pm_data_tools/models/calendar.py @@ -6,7 +6,6 @@ from dataclasses import dataclass, field from datetime import date -from typing import Optional from uuid import UUID from .base import SourceInfo @@ -37,7 +36,7 @@ class Calendar: holidays: list[date] = field(default_factory=list) # Base calendar - base_calendar_id: Optional[UUID] = None + base_calendar_id: UUID | None = None def __str__(self) -> str: """String representation.""" diff --git a/packages/pm-data-tools/src/pm_data_tools/models/dependency.py b/packages/pm-data-tools/src/pm_data_tools/models/dependency.py index 7133b40..a779bdb 100644 --- a/packages/pm-data-tools/src/pm_data_tools/models/dependency.py +++ b/packages/pm-data-tools/src/pm_data_tools/models/dependency.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional from uuid import UUID from .base import Duration, SourceInfo @@ -39,7 +38,7 @@ class Dependency: dependency_type: DependencyType = DependencyType.FINISH_TO_START # Lag (positive = delay, negative = lead) - lag: Optional[Duration] = None + lag: Duration | None = None def __str__(self) -> str: """String representation.""" diff --git a/packages/pm-data-tools/src/pm_data_tools/models/project.py b/packages/pm-data-tools/src/pm_data_tools/models/project.py index 5714f50..ee647af 100644 --- a/packages/pm-data-tools/src/pm_data_tools/models/project.py +++ b/packages/pm-data-tools/src/pm_data_tools/models/project.py @@ -7,11 +7,10 @@ from dataclasses import dataclass, field from datetime import datetime from enum import Enum -from typing import Optional from uuid import UUID from .assignment import Assignment -from .base import Money, SourceInfo, CustomField +from .base import CustomField, Money, SourceInfo from .calendar import Calendar from .dependency import Dependency from .resource import Resource @@ -43,34 +42,34 @@ class Project: source: SourceInfo # Description - description: Optional[str] = None + description: str | None = None # Classification (GMPP categories) - category: Optional[str] = None # Infrastructure, Transformation, etc. - department: Optional[str] = None + category: str | None = None # Infrastructure, Transformation, etc. + department: str | None = None # Schedule - start_date: Optional[datetime] = None - finish_date: Optional[datetime] = None - status_date: Optional[datetime] = None # Data date / time now + start_date: datetime | None = None + finish_date: datetime | None = None + status_date: datetime | None = None # Data date / time now # Status (GMPP DCA) - delivery_confidence: Optional[DeliveryConfidence] = None + delivery_confidence: DeliveryConfidence | None = None # Financials - whole_life_cost: Optional[Money] = None - budgeted_cost: Optional[Money] = None - actual_cost: Optional[Money] = None + whole_life_cost: Money | None = None + budgeted_cost: Money | None = None + actual_cost: Money | None = None # Benefits - monetised_benefits: Optional[Money] = None + monetised_benefits: Money | None = None # Governance - senior_responsible_owner: Optional[str] = None - project_manager: Optional[str] = None + senior_responsible_owner: str | None = None + project_manager: str | None = None # Calendars - default_calendar_id: Optional[UUID] = None + default_calendar_id: UUID | None = None calendars: list[Calendar] = field(default_factory=list) # Core entities @@ -162,7 +161,7 @@ def completion_percent(self) -> float: return (completed / len(work_tasks)) * 100.0 @property - def cost_variance(self) -> Optional[Money]: + def cost_variance(self) -> Money | None: """Calculate cost variance (budgeted - actual). Returns: diff --git a/packages/pm-data-tools/src/pm_data_tools/models/resource.py b/packages/pm-data-tools/src/pm_data_tools/models/resource.py index 4bc15a5..5ef3ee3 100644 --- a/packages/pm-data-tools/src/pm_data_tools/models/resource.py +++ b/packages/pm-data-tools/src/pm_data_tools/models/resource.py @@ -7,10 +7,9 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Optional from uuid import UUID -from .base import Money, SourceInfo, CustomField +from .base import CustomField, Money, SourceInfo class ResourceType(Enum): @@ -42,15 +41,15 @@ class Resource: max_units: float = 1.0 # 1.0 = 100% availability # Rates - standard_rate: Optional[Money] = None # Per hour for work resources - overtime_rate: Optional[Money] = None - cost_per_use: Optional[Money] = None # One-time cost per assignment + standard_rate: Money | None = None # Per hour for work resources + overtime_rate: Money | None = None + cost_per_use: Money | None = None # One-time cost per assignment # Contact - email: Optional[str] = None + email: str | None = None # Grouping - group: Optional[str] = None # Department, team, etc. + group: str | None = None # Department, team, etc. # Extensions custom_fields: list[CustomField] = field(default_factory=list) diff --git a/packages/pm-data-tools/src/pm_data_tools/models/risk.py b/packages/pm-data-tools/src/pm_data_tools/models/risk.py index cbce8e0..4e36402 100644 --- a/packages/pm-data-tools/src/pm_data_tools/models/risk.py +++ b/packages/pm-data-tools/src/pm_data_tools/models/risk.py @@ -7,10 +7,9 @@ from dataclasses import dataclass, field from datetime import date from enum import Enum -from typing import Optional from uuid import UUID -from .base import SourceInfo, CustomField +from .base import CustomField, SourceInfo class RiskStatus(Enum): @@ -50,26 +49,26 @@ class Risk: source: SourceInfo # Description - description: Optional[str] = None - cause: Optional[str] = None - effect: Optional[str] = None + description: str | None = None + cause: str | None = None + effect: str | None = None # Classification category: RiskCategory = RiskCategory.TECHNICAL status: RiskStatus = RiskStatus.IDENTIFIED # Assessment (1-5 scale, aligned with GMPP) - probability: Optional[int] = None # 1=Very Low, 5=Very High - impact: Optional[int] = None # 1=Very Low, 5=Very High + probability: int | None = None # 1=Very Low, 5=Very High + impact: int | None = None # 1=Very Low, 5=Very High # Response - mitigation: Optional[str] = None - contingency: Optional[str] = None - owner: Optional[str] = None + mitigation: str | None = None + contingency: str | None = None + owner: str | None = None # Dates - identified_date: Optional[date] = None - target_resolution_date: Optional[date] = None + identified_date: date | None = None + target_resolution_date: date | None = None # Linked tasks related_task_ids: list[UUID] = field(default_factory=list) @@ -83,7 +82,7 @@ def __str__(self) -> str: return f"Risk({self.name}, {self.category.value}, {self.status.value}{score_str})" @property - def score(self) -> Optional[int]: + def score(self) -> int | None: """Calculate risk score (probability × impact). Returns: diff --git a/packages/pm-data-tools/src/pm_data_tools/models/task.py b/packages/pm-data-tools/src/pm_data_tools/models/task.py index fbf9053..10b4d33 100644 --- a/packages/pm-data-tools/src/pm_data_tools/models/task.py +++ b/packages/pm-data-tools/src/pm_data_tools/models/task.py @@ -8,10 +8,9 @@ from dataclasses import dataclass, field from datetime import datetime from enum import Enum -from typing import Optional from uuid import UUID -from .base import Duration, Money, SourceInfo, CustomField +from .base import CustomField, Duration, Money, SourceInfo class TaskStatus(Enum): @@ -52,27 +51,27 @@ class Task: source: SourceInfo # Hierarchy - wbs_code: Optional[str] = None + wbs_code: str | None = None outline_level: int = 1 - parent_id: Optional[UUID] = None + parent_id: UUID | None = None # Schedule - start_date: Optional[datetime] = None - finish_date: Optional[datetime] = None - actual_start: Optional[datetime] = None - actual_finish: Optional[datetime] = None - duration: Optional[Duration] = None - actual_duration: Optional[Duration] = None - remaining_duration: Optional[Duration] = None + start_date: datetime | None = None + finish_date: datetime | None = None + actual_start: datetime | None = None + actual_finish: datetime | None = None + duration: Duration | None = None + actual_duration: Duration | None = None + remaining_duration: Duration | None = None # Progress percent_complete: float = 0.0 status: TaskStatus = TaskStatus.NOT_STARTED # Constraints - constraint_type: Optional[ConstraintType] = None - constraint_date: Optional[datetime] = None - deadline: Optional[datetime] = None + constraint_type: ConstraintType | None = None + constraint_date: datetime | None = None + deadline: datetime | None = None # Flags is_milestone: bool = False @@ -80,18 +79,18 @@ class Task: is_critical: bool = False # Cost - budgeted_cost: Optional[Money] = None - actual_cost: Optional[Money] = None - remaining_cost: Optional[Money] = None + budgeted_cost: Money | None = None + actual_cost: Money | None = None + remaining_cost: Money | None = None # Work - budgeted_work: Optional[Duration] = None - actual_work: Optional[Duration] = None - remaining_work: Optional[Duration] = None + budgeted_work: Duration | None = None + actual_work: Duration | None = None + remaining_work: Duration | None = None # Text - description: Optional[str] = None - notes: Optional[str] = None + description: str | None = None + notes: str | None = None # Extensions custom_fields: list[CustomField] = field(default_factory=list) @@ -129,7 +128,7 @@ def is_started(self) -> bool: return self.percent_complete > 0.0 or self.actual_start is not None @property - def cost_variance(self) -> Optional[Money]: + def cost_variance(self) -> Money | None: """Calculate cost variance (budgeted - actual). Returns: @@ -140,7 +139,7 @@ def cost_variance(self) -> Optional[Money]: return None @property - def schedule_variance_days(self) -> Optional[float]: + def schedule_variance_days(self) -> float | None: """Calculate schedule variance in days (planned finish - actual/forecast finish). Returns: diff --git a/packages/pm-data-tools/src/pm_data_tools/parsers.py b/packages/pm-data-tools/src/pm_data_tools/parsers.py index 1ac73d9..1702030 100644 --- a/packages/pm-data-tools/src/pm_data_tools/parsers.py +++ b/packages/pm-data-tools/src/pm_data_tools/parsers.py @@ -5,13 +5,11 @@ """ from pathlib import Path -from typing import Optional -import mimetypes from .models import Project -def detect_format(file_path: str | Path) -> Optional[str]: +def detect_format(file_path: str | Path) -> str | None: """Auto-detect project file format. Args: @@ -30,7 +28,7 @@ def detect_format(file_path: str | Path) -> Optional[str]: if ext in ['.xml', '.mpp']: # Check if MSPDI by reading first few lines try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, encoding='utf-8') as f: header = f.read(500) if 'schemas.microsoft.com/project' in header or 'MSPDI' in header: return 'mspdi' @@ -47,7 +45,7 @@ def detect_format(file_path: str | Path) -> Optional[str]: # Try to determine if it's NISTA, Jira, Monday, etc. try: import json - with open(path, 'r', encoding='utf-8') as f: + with open(path, encoding='utf-8') as f: data = json.load(f) # Check for NISTA-specific fields @@ -69,7 +67,7 @@ def detect_format(file_path: str | Path) -> Optional[str]: elif ext in ['.csv']: # Could be GMPP or generic CSV try: - with open(path, 'r', encoding='utf-8') as f: + with open(path, encoding='utf-8') as f: header = f.readline() if 'GMPP' in header or 'Delivery Confidence' in header: return 'gmpp' @@ -100,20 +98,21 @@ def create_parser(format_name: str): return MspdiParser() elif format_name == 'p6_xer' or format_name == 'p6': - from .schemas.p6.xer_parser import XERParser from pathlib import Path + from .schemas.p6.xer_parser import XERParser + # XERParser has non-standard interface - needs file_path in __init__ # Create wrapper to match standard interface class XERParserWrapper: def __init__(self): self.source_tool = "primavera-p6" - def parse_file(self, file_path: str | Path) -> Optional[Project]: + def parse_file(self, file_path: str | Path) -> Project | None: parser = XERParser(Path(file_path)) return parser.parse() - def parse_string(self, content: str) -> Optional[Project]: + def parse_string(self, content: str) -> Project | None: # XER parser doesn't support string parsing raise NotImplementedError("XER parser requires file path") @@ -151,7 +150,7 @@ def parse_string(self, content: str) -> Optional[Project]: ) -def parse_project(file_path: str | Path, format: Optional[str] = None) -> Optional[Project]: +def parse_project(file_path: str | Path, format: str | None = None) -> Project | None: """Convenience function to parse a project file. Auto-detects format if not specified, creates appropriate parser, diff --git a/packages/pm-data-tools/src/pm_data_tools/schemas/asana/constants.py b/packages/pm-data-tools/src/pm_data_tools/schemas/asana/constants.py index 3c42b70..165e8b5 100644 --- a/packages/pm-data-tools/src/pm_data_tools/schemas/asana/constants.py +++ b/packages/pm-data-tools/src/pm_data_tools/schemas/asana/constants.py @@ -2,6 +2,7 @@ from ...models import TaskStatus + # Asana completed boolean to canonical TaskStatus mapping def get_status_from_completed(completed: bool) -> TaskStatus: """Map Asana completed status to canonical TaskStatus. diff --git a/packages/pm-data-tools/src/pm_data_tools/schemas/asana/parser.py b/packages/pm-data-tools/src/pm_data_tools/schemas/asana/parser.py index fe3a9cf..f35d782 100644 --- a/packages/pm-data-tools/src/pm_data_tools/schemas/asana/parser.py +++ b/packages/pm-data-tools/src/pm_data_tools/schemas/asana/parser.py @@ -3,7 +3,7 @@ import json from datetime import datetime from pathlib import Path -from typing import Any, Optional +from typing import Any from uuid import UUID from ...models import ( @@ -31,7 +31,7 @@ class AsanaParser: - assignee → Resources """ - def __init__(self, project_name: Optional[str] = None): + def __init__(self, project_name: str | None = None): """Initialise parser. Args: @@ -51,7 +51,7 @@ def parse_file(self, file_path: Path) -> Project: Returns: Parsed Project """ - with open(file_path, "r", encoding="utf-8") as f: + with open(file_path, encoding="utf-8") as f: data = json.load(f) return self.parse(data) @@ -146,7 +146,7 @@ def parse(self, data: dict[str, Any]) -> Project: def _parse_section( self, section: dict[str, Any], project_gid: str - ) -> Optional[Task]: + ) -> Task | None: """Parse Asana section to summary Task. Args: @@ -181,7 +181,7 @@ def _parse_section( def _parse_task( self, task_data: dict[str, Any], project_gid: str, parent_id: UUID - ) -> Optional[Task]: + ) -> Task | None: """Parse Asana task to Task. Args: @@ -231,7 +231,7 @@ def _parse_task( def _parse_subtask( self, subtask_data: dict[str, Any], project_gid: str, parent_id: UUID - ) -> Optional[Task]: + ) -> Task | None: """Parse Asana subtask to Task. Args: @@ -327,7 +327,7 @@ def _extract_resources( return resources - def _parse_date_string(self, date_str: Optional[str]) -> Optional[datetime]: + def _parse_date_string(self, date_str: str | None) -> datetime | None: """Parse date string to datetime. Args: diff --git a/packages/pm-data-tools/src/pm_data_tools/schemas/gmpp/parser.py b/packages/pm-data-tools/src/pm_data_tools/schemas/gmpp/parser.py index 6192372..1d7a22a 100644 --- a/packages/pm-data-tools/src/pm_data_tools/schemas/gmpp/parser.py +++ b/packages/pm-data-tools/src/pm_data_tools/schemas/gmpp/parser.py @@ -4,7 +4,7 @@ from datetime import datetime from decimal import Decimal from pathlib import Path -from typing import Any, Optional +from typing import Any from ...models import ( DeliveryConfidence, @@ -56,7 +56,7 @@ def parse_file(self, file_path: Path) -> list[Project]: Returns: List of parsed Projects (one per row) """ - with open(file_path, "r", encoding="utf-8") as f: + with open(file_path, encoding="utf-8") as f: reader = csv.DictReader(f) return self.parse(list(reader)) @@ -78,7 +78,7 @@ def parse(self, rows: list[dict[str, Any]]) -> list[Project]: return projects - def _parse_row(self, row: dict[str, Any]) -> Optional[Project]: + def _parse_row(self, row: dict[str, Any]) -> Project | None: """Parse single GMPP CSV row to Project. Args: @@ -172,7 +172,7 @@ def _parse_row(self, row: dict[str, Any]) -> Optional[Project]: return project - def _find_value(self, row: dict[str, Any], column_names: list[str]) -> Optional[str]: + def _find_value(self, row: dict[str, Any], column_names: list[str]) -> str | None: """Find value in row by trying multiple column name variants. Args: @@ -189,7 +189,7 @@ def _find_value(self, row: dict[str, Any], column_names: list[str]) -> Optional[ return value return None - def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]: + def _parse_date(self, date_str: str | None) -> datetime | None: """Parse date string to datetime. Args: @@ -218,7 +218,7 @@ def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]: return None - def _parse_money(self, amount_str: Optional[str]) -> Optional[Money]: + def _parse_money(self, amount_str: str | None) -> Money | None: """Parse money string to Money object. Args: diff --git a/packages/pm-data-tools/src/pm_data_tools/schemas/jira/constants.py b/packages/pm-data-tools/src/pm_data_tools/schemas/jira/constants.py index 4014488..69094a2 100644 --- a/packages/pm-data-tools/src/pm_data_tools/schemas/jira/constants.py +++ b/packages/pm-data-tools/src/pm_data_tools/schemas/jira/constants.py @@ -6,7 +6,6 @@ from pm_data_tools.models import TaskStatus - # Jira status categories to canonical task status STATUS_CATEGORY_TO_TASK_STATUS = { "new": TaskStatus.NOT_STARTED, diff --git a/packages/pm-data-tools/src/pm_data_tools/schemas/jira/parser.py b/packages/pm-data-tools/src/pm_data_tools/schemas/jira/parser.py index 8a86bca..7e46c4f 100644 --- a/packages/pm-data-tools/src/pm_data_tools/schemas/jira/parser.py +++ b/packages/pm-data-tools/src/pm_data_tools/schemas/jira/parser.py @@ -7,7 +7,7 @@ import json from datetime import datetime from pathlib import Path -from typing import Any, Optional +from typing import Any from uuid import UUID from pm_data_tools.models import ( @@ -37,7 +37,7 @@ class JiraParser: - Issue links → Dependencies (where applicable) """ - def __init__(self, project_key: str, project_name: Optional[str] = None): + def __init__(self, project_key: str, project_name: str | None = None): """Initialize parser. Args: @@ -57,7 +57,7 @@ def parse_from_file(self, file_path: Path) -> Project: Returns: Parsed Project """ - with open(file_path, "r", encoding="utf-8") as f: + with open(file_path, encoding="utf-8") as f: data = json.load(f) issues = data.get("issues", []) @@ -122,7 +122,7 @@ def _parse_tasks(self, issues: list[dict[str, Any]]) -> list[Task]: ) # Parse parent relationship - parent_id: Optional[UUID] = None + parent_id: UUID | None = None parent = fields.get("parent") if parent: parent_key = parent.get("key") @@ -135,7 +135,7 @@ def _parse_tasks(self, issues: list[dict[str, Any]]) -> list[Task]: # Parse dates created_date = self._parse_jira_date(fields.get("created")) - updated_date = self._parse_jira_date(fields.get("updated")) + self._parse_jira_date(fields.get("updated")) due_date = self._parse_jira_date(fields.get("duedate")) # Parse progress (Jira doesn't have built-in % complete) @@ -263,7 +263,7 @@ def _map_status(self, status_field: dict[str, Any]) -> TaskStatus: # Default to in progress return TaskStatus.IN_PROGRESS - def _parse_jira_date(self, date_str: Optional[str]) -> Optional[datetime]: + def _parse_jira_date(self, date_str: str | None) -> datetime | None: """Parse Jira date string. Jira dates can be in formats: diff --git a/packages/pm-data-tools/src/pm_data_tools/schemas/monday/parser.py b/packages/pm-data-tools/src/pm_data_tools/schemas/monday/parser.py index 0b10803..0d94d52 100644 --- a/packages/pm-data-tools/src/pm_data_tools/schemas/monday/parser.py +++ b/packages/pm-data-tools/src/pm_data_tools/schemas/monday/parser.py @@ -3,7 +3,7 @@ import json from datetime import datetime from pathlib import Path -from typing import Any, Optional +from typing import Any from uuid import UUID from ...models import ( @@ -39,7 +39,7 @@ class MondayParser: - People columns → Resources """ - def __init__(self, board_name: Optional[str] = None): + def __init__(self, board_name: str | None = None): """Initialize parser. Args: @@ -59,7 +59,7 @@ def parse_file(self, file_path: Path) -> Project: Returns: Parsed Project """ - with open(file_path, "r", encoding="utf-8") as f: + with open(file_path, encoding="utf-8") as f: data = json.load(f) return self.parse(data) @@ -152,7 +152,7 @@ def parse(self, data: dict[str, Any]) -> Project: return project - def _parse_group(self, group: dict[str, Any], board_id: str) -> Optional[Task]: + def _parse_group(self, group: dict[str, Any], board_id: str) -> Task | None: """Parse Monday.com group to summary Task. Args: @@ -187,7 +187,7 @@ def _parse_group(self, group: dict[str, Any], board_id: str) -> Optional[Task]: def _parse_item( self, item: dict[str, Any], board_id: str, parent_id: UUID - ) -> Optional[Task]: + ) -> Task | None: """Parse Monday.com item to Task. Args: @@ -245,7 +245,7 @@ def _parse_item( def _parse_subitem( self, subitem: dict[str, Any], board_id: str, parent_id: UUID - ) -> Optional[Task]: + ) -> Task | None: """Parse Monday.com subitem to Task. Args: @@ -316,7 +316,7 @@ def _extract_status(self, column_values: list[dict[str, Any]]) -> TaskStatus: def _extract_dates( self, column_values: list[dict[str, Any]] - ) -> tuple[Optional[datetime], Optional[datetime]]: + ) -> tuple[datetime | None, datetime | None]: """Extract start and finish dates from column values. Args: @@ -325,8 +325,8 @@ def _extract_dates( Returns: Tuple of (start_date, finish_date) """ - start_date: Optional[datetime] = None - finish_date: Optional[datetime] = None + start_date: datetime | None = None + finish_date: datetime | None = None for col in column_values: col_type = col.get("type") @@ -364,7 +364,7 @@ def _extract_dates( def _extract_percent_complete( self, column_values: list[dict[str, Any]] - ) -> Optional[float]: + ) -> float | None: """Extract percent complete from column values. Args: @@ -385,7 +385,7 @@ def _extract_percent_complete( if isinstance(value_data, dict): return float(value_data.get("value", 0)) return float(value_data) - elif isinstance(value, (int, float)): + elif isinstance(value, int | float): return float(value) except (json.JSONDecodeError, ValueError, TypeError): continue @@ -395,9 +395,7 @@ def _extract_percent_complete( title = col.get("title", "").lower() if "progress" in title or "complete" in title: try: - if isinstance(value, str) and value != "null": - return float(value) - elif isinstance(value, (int, float)): + if isinstance(value, str) and value != "null" or isinstance(value, int | float): return float(value) except (ValueError, TypeError): continue @@ -462,7 +460,7 @@ def _extract_resources( return resources - def _parse_date_string(self, date_str: str) -> Optional[datetime]: + def _parse_date_string(self, date_str: str) -> datetime | None: """Parse date string to datetime. Args: diff --git a/packages/pm-data-tools/src/pm_data_tools/schemas/mspdi/constants.py b/packages/pm-data-tools/src/pm_data_tools/schemas/mspdi/constants.py index cf6b925..a08803d 100644 --- a/packages/pm-data-tools/src/pm_data_tools/schemas/mspdi/constants.py +++ b/packages/pm-data-tools/src/pm_data_tools/schemas/mspdi/constants.py @@ -6,9 +6,9 @@ Schema reference: https://schemas.microsoft.com/project/2007/mspdi_pj12.xsd """ -from pm_data_tools.models.task import ConstraintType, TaskStatus from pm_data_tools.models.dependency import DependencyType from pm_data_tools.models.resource import ResourceType +from pm_data_tools.models.task import ConstraintType, TaskStatus # MSPDI namespace MSPDI_NAMESPACE = "http://schemas.microsoft.com/project" diff --git a/packages/pm-data-tools/src/pm_data_tools/schemas/mspdi/parser.py b/packages/pm-data-tools/src/pm_data_tools/schemas/mspdi/parser.py index 11126d2..d920c0a 100644 --- a/packages/pm-data-tools/src/pm_data_tools/schemas/mspdi/parser.py +++ b/packages/pm-data-tools/src/pm_data_tools/schemas/mspdi/parser.py @@ -4,45 +4,43 @@ Data Interchange) XML files into the canonical project management data model. """ -from pathlib import Path -from typing import Optional from decimal import Decimal +from pathlib import Path from uuid import UUID -from datetime import datetime from lxml import etree from pm_data_tools.models import ( - Project, - Task, - Resource, Assignment, - Dependency, Calendar, - SourceInfo, - Duration, - Money, CustomField, DeliveryConfidence, + Dependency, + Duration, + Money, + Project, + Resource, + SourceInfo, + Task, ) +from pm_data_tools.utils.dates import parse_iso_datetime, parse_mspdi_duration +from pm_data_tools.utils.identifiers import generate_uuid_from_source from pm_data_tools.utils.xml_helpers import ( + get_bool, + get_float, + get_int, + get_text, parse_xml_file, parse_xml_string, - get_text, - get_int, - get_float, - get_bool, strip_namespaces, ) -from pm_data_tools.utils.dates import parse_iso_datetime, parse_mspdi_duration -from pm_data_tools.utils.identifiers import generate_uuid_from_source from .constants import ( + DEFAULT_CURRENCY, MSPDI_CONSTRAINT_TYPE_MAP, MSPDI_DEPENDENCY_TYPE_MAP, MSPDI_RESOURCE_TYPE_MAP, get_task_status_from_percent, - DEFAULT_CURRENCY, ) @@ -58,7 +56,7 @@ def __init__(self) -> None: """Initialise MSPDI parser.""" self.source_tool = "mspdi" - def parse_file(self, file_path: str | Path) -> Optional[Project]: + def parse_file(self, file_path: str | Path) -> Project | None: """Parse MSPDI XML file into Project model. Args: @@ -76,7 +74,7 @@ def parse_file(self, file_path: str | Path) -> Optional[Project]: return self._parse_project(root) - def parse_string(self, xml_content: str | bytes) -> Optional[Project]: + def parse_string(self, xml_content: str | bytes) -> Project | None: """Parse MSPDI XML string into Project model. Args: @@ -113,10 +111,10 @@ def _parse_project(self, root: etree._Element) -> Project: start_date = parse_iso_datetime(get_text(root, "StartDate")) finish_date = parse_iso_datetime(get_text(root, "FinishDate")) status_date = parse_iso_datetime(get_text(root, "StatusDate")) - baseline_date = parse_iso_datetime(get_text(root, "CurrentDate")) + parse_iso_datetime(get_text(root, "CurrentDate")) # Parse currency - currency_code = get_text(root, "CurrencyCode", default=DEFAULT_CURRENCY) + get_text(root, "CurrencyCode", default=DEFAULT_CURRENCY) # Parse custom fields custom_fields_dict: dict[str, str] = {} @@ -205,7 +203,7 @@ def _parse_tasks(self, root: etree._Element) -> list[Task]: return tasks - def _parse_task(self, elem: etree._Element) -> Optional[Task]: + def _parse_task(self, elem: etree._Element) -> Task | None: """Parse single Task element. Args: @@ -230,7 +228,7 @@ def _parse_task(self, elem: etree._Element) -> Optional[Task]: # Parent task # Parent task - parent_id: Optional[UUID] = None + parent_id: UUID | None = None parent_uid = get_text(elem, "OutlineParent") if parent_uid: parent_id = generate_uuid_from_source(self.source_tool, parent_uid) @@ -240,8 +238,8 @@ def _parse_task(self, elem: etree._Element) -> Optional[Task]: finish_date = parse_iso_datetime(get_text(elem, "Finish")) actual_start = parse_iso_datetime(get_text(elem, "ActualStart")) actual_finish = parse_iso_datetime(get_text(elem, "ActualFinish")) - baseline_start = parse_iso_datetime(get_text(elem, "BaselineStart")) - baseline_finish = parse_iso_datetime(get_text(elem, "BaselineFinish")) + parse_iso_datetime(get_text(elem, "BaselineStart")) + parse_iso_datetime(get_text(elem, "BaselineFinish")) # Duration duration_str = get_text(elem, "Duration", default="PT0H0M0S") @@ -252,7 +250,7 @@ def _parse_task(self, elem: etree._Element) -> Optional[Task]: # Progress percent_complete = get_float(elem, "PercentComplete", default=0.0) - percent_work_complete = get_float(elem, "PercentWorkComplete", default=0.0) + get_float(elem, "PercentWorkComplete", default=0.0) # Status from percent complete status = get_task_status_from_percent(percent_complete) @@ -290,7 +288,7 @@ def _parse_task(self, elem: etree._Element) -> Optional[Task]: ) # Priority - priority = get_int(elem, "Priority", default=500) + get_int(elem, "Priority", default=500) # Source info source = SourceInfo( @@ -347,7 +345,7 @@ def _parse_resources(self, root: etree._Element) -> list[Resource]: return resources - def _parse_resource(self, elem: etree._Element) -> Optional[Resource]: + def _parse_resource(self, elem: etree._Element) -> Resource | None: """Parse single Resource element. Args: @@ -429,7 +427,7 @@ def _parse_assignments(self, root: etree._Element) -> list[Assignment]: return assignments - def _parse_assignment(self, elem: etree._Element) -> Optional[Assignment]: + def _parse_assignment(self, elem: etree._Element) -> Assignment | None: """Parse single Assignment element. Args: @@ -587,7 +585,7 @@ def _parse_calendars(self, root: etree._Element) -> list[Calendar]: return calendars - def _parse_calendar(self, elem: etree._Element) -> Optional[Calendar]: + def _parse_calendar(self, elem: etree._Element) -> Calendar | None: """Parse single Calendar element. Args: diff --git a/packages/pm-data-tools/src/pm_data_tools/schemas/mspdi/writer.py b/packages/pm-data-tools/src/pm_data_tools/schemas/mspdi/writer.py index be10419..c6543fd 100644 --- a/packages/pm-data-tools/src/pm_data_tools/schemas/mspdi/writer.py +++ b/packages/pm-data-tools/src/pm_data_tools/schemas/mspdi/writer.py @@ -5,25 +5,23 @@ """ from pathlib import Path -from typing import Optional from lxml import etree from pm_data_tools.models import ( - Project, - Task, - Resource, Assignment, - Dependency, Calendar, + Project, + Resource, + Task, ) -from pm_data_tools.utils.xml_helpers import write_xml_string from pm_data_tools.utils.dates import format_iso_datetime, format_mspdi_duration +from pm_data_tools.utils.xml_helpers import write_xml_string from .constants import ( - MSPDI_NAMESPACE, CONSTRAINT_TYPE_TO_MSPDI, DEPENDENCY_TYPE_TO_MSPDI, + MSPDI_NAMESPACE, RESOURCE_TYPE_TO_MSPDI, mspdi_bool, ) @@ -350,7 +348,7 @@ def _build_calendar(self, parent: etree._Element, calendar: Calendar) -> None: self._add_element(calendar_elem, "IsBaseCalendar", is_base) def _add_element( - self, parent: etree._Element, tag: str, text: Optional[str] + self, parent: etree._Element, tag: str, text: str | None ) -> None: """Add a child element with text content. diff --git a/packages/pm-data-tools/src/pm_data_tools/schemas/nista/__init__.py b/packages/pm-data-tools/src/pm_data_tools/schemas/nista/__init__.py index bc81c46..8d7be97 100644 --- a/packages/pm-data-tools/src/pm_data_tools/schemas/nista/__init__.py +++ b/packages/pm-data-tools/src/pm_data_tools/schemas/nista/__init__.py @@ -19,7 +19,12 @@ TrendDirection, ) from .parser import NISTAParser -from .validator import NISTAValidator, StrictnessLevel, ValidationResult, ValidationIssue +from .validator import ( + NISTAValidator, + StrictnessLevel, + ValidationIssue, + ValidationResult, +) __all__ = [ "NISTAParser", diff --git a/packages/pm-data-tools/src/pm_data_tools/schemas/nista/exporter.py b/packages/pm-data-tools/src/pm_data_tools/schemas/nista/exporter.py index 5e753f1..ed47d19 100644 --- a/packages/pm-data-tools/src/pm_data_tools/schemas/nista/exporter.py +++ b/packages/pm-data-tools/src/pm_data_tools/schemas/nista/exporter.py @@ -3,9 +3,8 @@ import csv import json from datetime import datetime -from decimal import Decimal from pathlib import Path -from typing import Any, Optional, Union +from typing import Any from ...models import DeliveryConfidence, Project @@ -149,7 +148,7 @@ def export(self, project: Project) -> dict[str, Any]: return data - def to_file(self, project: Project, file_path: Union[str, Path]) -> None: + def to_file(self, project: Project, file_path: str | Path) -> None: """Export Project to NISTA JSON file. Args: @@ -163,7 +162,7 @@ def to_file(self, project: Project, file_path: Union[str, Path]) -> None: json.dump(data, f, indent=2, ensure_ascii=False) def to_csv( - self, projects: Union[Project, list[Project]], file_path: Union[str, Path] + self, projects: Project | list[Project], file_path: str | Path ) -> None: """Export Project(s) to NISTA/GMPP CSV file. @@ -210,7 +209,7 @@ def to_csv( writer.writerow(row) def to_excel( - self, projects: Union[Project, list[Project]], file_path: Union[str, Path] + self, projects: Project | list[Project], file_path: str | Path ) -> None: """Export Project(s) to NISTA/GMPP Excel file. diff --git a/packages/pm-data-tools/src/pm_data_tools/schemas/nista/history.py b/packages/pm-data-tools/src/pm_data_tools/schemas/nista/history.py index 3f1c97e..bd98abd 100644 --- a/packages/pm-data-tools/src/pm_data_tools/schemas/nista/history.py +++ b/packages/pm-data-tools/src/pm_data_tools/schemas/nista/history.py @@ -6,13 +6,17 @@ from .longitudinal import ( # noqa: F401 ComplianceThresholdConfig, - ComplianceThresholdConfig as NISTAThresholdConfig, ConfidenceScoreRecord, LongitudinalComplianceTracker, - LongitudinalComplianceTracker as NISTAScoreHistory, ThresholdBreach, TrendDirection, ) +from .longitudinal import ( + ComplianceThresholdConfig as NISTAThresholdConfig, +) +from .longitudinal import ( + LongitudinalComplianceTracker as NISTAScoreHistory, +) __all__ = [ "ConfidenceScoreRecord", diff --git a/packages/pm-data-tools/src/pm_data_tools/schemas/nista/longitudinal.py b/packages/pm-data-tools/src/pm_data_tools/schemas/nista/longitudinal.py index 2a2ba43..8c12b79 100644 --- a/packages/pm-data-tools/src/pm_data_tools/schemas/nista/longitudinal.py +++ b/packages/pm-data-tools/src/pm_data_tools/schemas/nista/longitudinal.py @@ -29,7 +29,6 @@ import uuid from datetime import datetime, timezone from enum import Enum -from typing import Optional import structlog from pydantic import BaseModel, Field, field_validator @@ -155,7 +154,7 @@ class ThresholdBreach(BaseModel): breach_type: str # "drop" or "floor" project_id: str current_score: float - previous_score: Optional[float] = None + previous_score: float | None = None threshold_value: float message: str @@ -180,8 +179,8 @@ class LongitudinalComplianceTracker: def __init__( self, - store: Optional[AssuranceStore] = None, - thresholds: Optional[ComplianceThresholdConfig] = None, + store: AssuranceStore | None = None, + thresholds: ComplianceThresholdConfig | None = None, ) -> None: """Initialise the longitudinal compliance tracker. diff --git a/packages/pm-data-tools/src/pm_data_tools/schemas/nista/parser.py b/packages/pm-data-tools/src/pm_data_tools/schemas/nista/parser.py index ac95450..7af150f 100644 --- a/packages/pm-data-tools/src/pm_data_tools/schemas/nista/parser.py +++ b/packages/pm-data-tools/src/pm_data_tools/schemas/nista/parser.py @@ -5,7 +5,7 @@ from datetime import datetime from decimal import Decimal from pathlib import Path -from typing import Any, Optional, Union +from typing import Any from ...models import ( CustomField, @@ -28,9 +28,6 @@ DCA_MAPPINGS, FIELD_BENEFITS_BASELINE, FIELD_BENEFITS_FORECAST, - FIELD_BENEFITS_NARRATIVE, - FIELD_BENEFITS_NON_MONETISED, - FIELD_BUDGET_NARRATIVE, FIELD_CATEGORY, FIELD_CUSTOM_FIELDS, FIELD_DCA_IPA, @@ -39,23 +36,15 @@ FIELD_DESCRIPTION, FIELD_END_BASELINE, FIELD_END_FORECAST, - FIELD_FY_BASELINE, - FIELD_FY_FORECAST, - FIELD_FY_VARIANCE, - FIELD_IPA_COMMENTARY, - FIELD_ISSUES, - FIELD_METADATA, FIELD_MILESTONES, FIELD_PROJECT_ID, FIELD_PROJECT_NAME, FIELD_RISKS, - FIELD_SCHEDULE_NARRATIVE, FIELD_SRO, FIELD_START_BASELINE, FIELD_START_FORECAST, FIELD_WLC_BASELINE, FIELD_WLC_FORECAST, - FIELD_WLC_NARRATIVE, ) @@ -88,7 +77,7 @@ def __init__(self, source_tool: str = "nista", source_version: str = "v1.0"): self.source_tool = source_tool self.source_version = source_version - def parse_file(self, file_path: Union[str, Path]) -> Union[Project, list[Project]]: + def parse_file(self, file_path: str | Path) -> Project | list[Project]: """Parse NISTA data file (auto-detects format). Args: @@ -124,7 +113,7 @@ def parse_json_file(self, file_path: Path) -> Project: Returns: Parsed Project """ - with open(file_path, "r", encoding="utf-8") as f: + with open(file_path, encoding="utf-8") as f: data = json.load(f) return self.parse_json(data) @@ -137,7 +126,7 @@ def parse_csv_file(self, file_path: Path) -> list[Project]: Returns: List of parsed Projects (one per row) """ - with open(file_path, "r", encoding="utf-8") as f: + with open(file_path, encoding="utf-8") as f: reader = csv.DictReader(f) return self.parse_csv(list(reader)) @@ -376,7 +365,7 @@ def _normalize_csv_row(self, row: dict[str, Any]) -> dict[str, Any]: def _parse_milestone( self, milestone_data: dict[str, Any], index: int, project_id: str - ) -> Optional[Task]: + ) -> Task | None: """Parse milestone data to Task. Args: @@ -476,7 +465,7 @@ def _parse_risks( return risks - def _parse_date(self, date_str: Optional[Union[str, datetime]]) -> Optional[datetime]: + def _parse_date(self, date_str: str | datetime | None) -> datetime | None: """Parse date string to datetime. Args: @@ -510,8 +499,8 @@ def _parse_date(self, date_str: Optional[Union[str, datetime]]) -> Optional[date return None def _parse_money_millions( - self, amount: Optional[Union[str, int, float, Decimal]] - ) -> Optional[Money]: + self, amount: str | int | float | Decimal | None + ) -> Money | None: """Parse money amount in millions to Money object. Args: @@ -544,7 +533,7 @@ def _parse_money_millions( except (ValueError, ArithmeticError): return None - def _parse_dca(self, dca_str: Optional[str]) -> Optional[DeliveryConfidence]: + def _parse_dca(self, dca_str: str | None) -> DeliveryConfidence | None: """Parse DCA string to DeliveryConfidence enum. Args: @@ -569,7 +558,7 @@ def _parse_dca(self, dca_str: Optional[str]) -> Optional[DeliveryConfidence]: return None - def _normalize_category(self, category_str: Optional[str]) -> Optional[str]: + def _normalize_category(self, category_str: str | None) -> str | None: """Normalize project category string. Args: diff --git a/packages/pm-data-tools/src/pm_data_tools/schemas/nista/validator.py b/packages/pm-data-tools/src/pm_data_tools/schemas/nista/validator.py index 26fdcc1..d6d3206 100644 --- a/packages/pm-data-tools/src/pm_data_tools/schemas/nista/validator.py +++ b/packages/pm-data-tools/src/pm_data_tools/schemas/nista/validator.py @@ -8,14 +8,14 @@ from datetime import datetime, timezone from enum import Enum from pathlib import Path -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from .longitudinal import LongitudinalComplianceTracker try: - import jsonschema - from jsonschema import Draft7Validator, ValidationError + import jsonschema # noqa: F401 + from jsonschema import Draft7Validator, ValidationError # noqa: F401 JSONSCHEMA_AVAILABLE = True except ImportError: @@ -119,7 +119,7 @@ def __init__( self, version: str = "1.0", strictness: StrictnessLevel = StrictnessLevel.STANDARD, - schema_path: Optional[Path] = None, + schema_path: Path | None = None, ): """Initialize NISTA validator. @@ -144,7 +144,7 @@ def _get_default_schema_path(self) -> Path: current_dir = Path(__file__).parent return current_dir / "v1.0" / "project.schema.json" - def _load_schema(self) -> Optional[dict[str, Any]]: + def _load_schema(self) -> dict[str, Any] | None: """Load JSON schema. Returns: @@ -153,14 +153,14 @@ def _load_schema(self) -> Optional[dict[str, Any]]: if not self.schema_path.exists(): return None - with open(self.schema_path, "r", encoding="utf-8") as f: + with open(self.schema_path, encoding="utf-8") as f: return json.load(f) def validate( self, data: dict[str, Any], - project_id: Optional[str] = None, - history: Optional["LongitudinalComplianceTracker"] = None, + project_id: str | None = None, + history: LongitudinalComplianceTracker | None = None, ) -> ValidationResult: """Validate NISTA data dictionary. @@ -352,11 +352,11 @@ def validate_file(self, file_path: Path) -> ValidationResult: """ if not file_path.suffix.lower() == ".json": raise ValueError( - f"Only JSON files can be validated directly. " - f"For CSV/Excel, parse first then validate the Project." + "Only JSON files can be validated directly. " + "For CSV/Excel, parse first then validate the Project." ) - with open(file_path, "r", encoding="utf-8") as f: + with open(file_path, encoding="utf-8") as f: data = json.load(f) return self.validate(data) diff --git a/packages/pm-data-tools/src/pm_data_tools/schemas/p6/__init__.py b/packages/pm-data-tools/src/pm_data_tools/schemas/p6/__init__.py index 2b4fcfc..e45a9f8 100644 --- a/packages/pm-data-tools/src/pm_data_tools/schemas/p6/__init__.py +++ b/packages/pm-data-tools/src/pm_data_tools/schemas/p6/__init__.py @@ -5,8 +5,8 @@ """ from .constants import ( - ActivityType, ActivityStatus, + ActivityType, RelationshipType, ResourceType, ) diff --git a/packages/pm-data-tools/src/pm_data_tools/schemas/p6/xer_parser.py b/packages/pm-data-tools/src/pm_data_tools/schemas/p6/xer_parser.py index 2af4f18..619db73 100644 --- a/packages/pm-data-tools/src/pm_data_tools/schemas/p6/xer_parser.py +++ b/packages/pm-data-tools/src/pm_data_tools/schemas/p6/xer_parser.py @@ -6,16 +6,13 @@ """ from datetime import datetime -from decimal import Decimal from pathlib import Path -from typing import Optional from uuid import UUID from pm_data_tools.models import ( DeliveryConfidence, Dependency, DependencyType, - Money, Project, Resource, ResourceType, @@ -25,12 +22,6 @@ ) from pm_data_tools.utils.identifiers import generate_uuid_from_source -from .constants import ( - RELATIONSHIP_TYPE_TO_DEPENDENCY_TYPE, - RESOURCE_TYPE_TO_CANONICAL, - RelationshipType, -) - class XERParser: """Parser for P6 XER format files. @@ -96,8 +87,8 @@ def parse(self) -> Project: def _read_xer_file(self) -> None: """Read and parse XER file structure into tables.""" - with open(self.file_path, "r", encoding="utf-8") as f: - current_table: Optional[str] = None + with open(self.file_path, encoding="utf-8") as f: + current_table: str | None = None current_columns: list[str] = [] for line in f: @@ -177,7 +168,7 @@ def _parse_tasks(self) -> list[Task]: ) # Parse parent WBS - parent_id: Optional[UUID] = None + parent_id: UUID | None = None wbs_id = task_record.get("wbs_id") if wbs_id: # Note: In real implementation, would need to track WBS hierarchy @@ -323,7 +314,7 @@ def _parse_assignments(self) -> list: # Simplified for now - full implementation would parse TASKRSRC table return [] - def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]: + def _parse_date(self, date_str: str | None) -> datetime | None: """Parse P6 date string. P6 dates are typically in format: YYYY-MM-DD HH:MM diff --git a/packages/pm-data-tools/src/pm_data_tools/schemas/smartsheet/parser.py b/packages/pm-data-tools/src/pm_data_tools/schemas/smartsheet/parser.py index f9a4ef6..228cfc4 100644 --- a/packages/pm-data-tools/src/pm_data_tools/schemas/smartsheet/parser.py +++ b/packages/pm-data-tools/src/pm_data_tools/schemas/smartsheet/parser.py @@ -3,7 +3,7 @@ import json from datetime import datetime from pathlib import Path -from typing import Any, Optional +from typing import Any from uuid import UUID from ...models import ( @@ -30,7 +30,7 @@ class SmartsheetParser: - assignedTo → Resources """ - def __init__(self, sheet_name: Optional[str] = None): + def __init__(self, sheet_name: str | None = None): """Initialise parser. Args: @@ -51,7 +51,7 @@ def parse_file(self, file_path: Path) -> Project: Returns: Parsed Project """ - with open(file_path, "r", encoding="utf-8") as f: + with open(file_path, encoding="utf-8") as f: data = json.load(f) return self.parse(data) @@ -145,7 +145,7 @@ def _build_hierarchy(self, rows: list[dict[str, Any]]) -> None: self._row_children[parent_id] = [] self._row_children[parent_id].append(row) - def _parse_row(self, row: dict[str, Any], sheet_id: str) -> Optional[Task]: + def _parse_row(self, row: dict[str, Any], sheet_id: str) -> Task | None: """Parse Smartsheet row to Task. Args: @@ -165,13 +165,13 @@ def _parse_row(self, row: dict[str, Any], sheet_id: str) -> Optional[Task]: is_summary = row_id_str in self._row_children # Extract column values - columns = {cell.get("columnId"): cell for cell in row.get("cells", [])} + {cell.get("columnId"): cell for cell in row.get("cells", [])} # Get task name (first column or from specific column) task_name = self._extract_value(row, "name") or "Untitled Row" # Get parent ID if exists - parent_id: Optional[UUID] = None + parent_id: UUID | None = None parent_row_id = row.get("parentId") if parent_row_id: parent_row_id_str = str(parent_row_id) @@ -210,7 +210,7 @@ def _parse_row(self, row: dict[str, Any], sheet_id: str) -> Optional[Task]: status=status, ) - def _extract_value(self, row: dict[str, Any], field_name: str) -> Optional[str]: + def _extract_value(self, row: dict[str, Any], field_name: str) -> str | None: """Extract value from row by field name. Args: @@ -264,7 +264,7 @@ def _extract_percent_complete(self, row: dict[str, Any]) -> float: def _extract_date( self, row: dict[str, Any], field_name: str - ) -> Optional[datetime]: + ) -> datetime | None: """Extract date from row. Args: @@ -356,7 +356,7 @@ def _extract_resources( return resources - def _parse_date_string(self, date_str: Any) -> Optional[datetime]: + def _parse_date_string(self, date_str: Any) -> datetime | None: """Parse date string to datetime. Args: diff --git a/packages/pm-data-tools/src/pm_data_tools/utils/__init__.py b/packages/pm-data-tools/src/pm_data_tools/utils/__init__.py index 2fcc357..955fbf0 100644 --- a/packages/pm-data-tools/src/pm_data_tools/utils/__init__.py +++ b/packages/pm-data-tools/src/pm_data_tools/utils/__init__.py @@ -1,34 +1,34 @@ """Utility functions and helpers.""" from .dates import ( - parse_iso_datetime, + add_working_days, + calculate_working_days, + duration_to_timedelta, + format_iso_datetime, + format_mspdi_duration, parse_datetime, + parse_iso_datetime, parse_mspdi_duration, - format_mspdi_duration, - duration_to_timedelta, timedelta_to_duration, - calculate_working_days, - add_working_days, - format_iso_datetime, ) from .identifiers import ( + generate_random_uuid, generate_uuid_from_source, get_namespace_for_tool, - generate_random_uuid, - parse_uuid, is_valid_uuid, + parse_uuid, ) from .xml_helpers import ( + create_element, + get_bool, + get_float, + get_int, + get_text, parse_xml_file, parse_xml_string, - get_text, - get_int, - get_float, - get_bool, - create_element, + strip_namespaces, write_xml_file, write_xml_string, - strip_namespaces, ) __all__ = [ diff --git a/packages/pm-data-tools/src/pm_data_tools/utils/dates.py b/packages/pm-data-tools/src/pm_data_tools/utils/dates.py index 6561e11..e506427 100644 --- a/packages/pm-data-tools/src/pm_data_tools/utils/dates.py +++ b/packages/pm-data-tools/src/pm_data_tools/utils/dates.py @@ -7,14 +7,13 @@ import re from datetime import datetime, timedelta -from typing import Optional from dateutil import parser as dateutil_parser from ..models.base import Duration -def parse_iso_datetime(value: Optional[str]) -> Optional[datetime]: +def parse_iso_datetime(value: str | None) -> datetime | None: """Parse ISO 8601 datetime string. Args: @@ -32,7 +31,7 @@ def parse_iso_datetime(value: Optional[str]) -> Optional[datetime]: return None -def parse_datetime(value: Optional[str]) -> Optional[datetime]: +def parse_datetime(value: str | None) -> datetime | None: """Parse datetime string in various formats. Tries ISO 8601 first, then falls back to dateutil parser for diff --git a/packages/pm-data-tools/src/pm_data_tools/utils/identifiers.py b/packages/pm-data-tools/src/pm_data_tools/utils/identifiers.py index 5e8e4d0..7d0a498 100644 --- a/packages/pm-data-tools/src/pm_data_tools/utils/identifiers.py +++ b/packages/pm-data-tools/src/pm_data_tools/utils/identifiers.py @@ -4,9 +4,7 @@ source system identifiers, ensuring consistent ID mapping across conversions. """ -from uuid import UUID, uuid4, uuid5, NAMESPACE_URL -from typing import Optional - +from uuid import NAMESPACE_URL, UUID, uuid4, uuid5 # Namespaces for different source tools MSPDI_NAMESPACE = uuid5(NAMESPACE_URL, "https://schemas.microsoft.com/project/mspdi") @@ -18,7 +16,7 @@ def generate_uuid_from_source( - source_tool: str, source_id: str, namespace: Optional[UUID] = None + source_tool: str, source_id: str, namespace: UUID | None = None ) -> UUID: """Generate deterministic UUID from source tool and ID. @@ -71,7 +69,7 @@ def generate_random_uuid() -> UUID: return uuid4() -def parse_uuid(uuid_str: str) -> Optional[UUID]: +def parse_uuid(uuid_str: str) -> UUID | None: """Parse UUID string. Args: diff --git a/packages/pm-data-tools/src/pm_data_tools/utils/xml_helpers.py b/packages/pm-data-tools/src/pm_data_tools/utils/xml_helpers.py index 0e9b158..4df9add 100644 --- a/packages/pm-data-tools/src/pm_data_tools/utils/xml_helpers.py +++ b/packages/pm-data-tools/src/pm_data_tools/utils/xml_helpers.py @@ -4,13 +4,13 @@ XML data from PM tools, with error handling and namespace support. """ -from typing import Optional, Any from pathlib import Path +from typing import Any from lxml import etree -def parse_xml_file(file_path: str | Path) -> Optional[etree._Element]: +def parse_xml_file(file_path: str | Path) -> etree._Element | None: """Parse XML file safely. Args: @@ -25,11 +25,11 @@ def parse_xml_file(file_path: str | Path) -> Optional[etree._Element]: ) tree = etree.parse(str(file_path), parser) return tree.getroot() - except (etree.XMLSyntaxError, IOError, OSError): + except (etree.XMLSyntaxError, OSError): return None -def parse_xml_string(xml_string: str | bytes) -> Optional[etree._Element]: +def parse_xml_string(xml_string: str | bytes) -> etree._Element | None: """Parse XML string safely. Args: @@ -127,7 +127,7 @@ def get_bool(element: etree._Element, xpath: str, default: bool = False) -> bool return default -def create_element(tag: str, text: Optional[str] = None, **attributes: Any) -> etree._Element: +def create_element(tag: str, text: str | None = None, **attributes: Any) -> etree._Element: """Create XML element with optional text and attributes. Args: diff --git a/packages/pm-data-tools/src/pm_data_tools/validators/__init__.py b/packages/pm-data-tools/src/pm_data_tools/validators/__init__.py index a0165b9..f9122e7 100644 --- a/packages/pm-data-tools/src/pm_data_tools/validators/__init__.py +++ b/packages/pm-data-tools/src/pm_data_tools/validators/__init__.py @@ -1,8 +1,8 @@ """Validation framework for project data.""" from .base import Severity, ValidationIssue, ValidationResult -from .structural import StructuralValidator from .semantic import SemanticValidator +from .structural import StructuralValidator __all__ = [ "Severity", diff --git a/packages/pm-data-tools/src/pm_data_tools/validators/base.py b/packages/pm-data-tools/src/pm_data_tools/validators/base.py index 0768816..b0e6255 100644 --- a/packages/pm-data-tools/src/pm_data_tools/validators/base.py +++ b/packages/pm-data-tools/src/pm_data_tools/validators/base.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional class Severity(Enum): @@ -32,8 +31,8 @@ class ValidationIssue: code: str message: str severity: Severity - context: Optional[str] = None - suggestion: Optional[str] = None + context: str | None = None + suggestion: str | None = None def __str__(self) -> str: """String representation.""" diff --git a/packages/pm-data-tools/src/pm_data_tools/validators/semantic.py b/packages/pm-data-tools/src/pm_data_tools/validators/semantic.py index b1e67f6..18c83fd 100644 --- a/packages/pm-data-tools/src/pm_data_tools/validators/semantic.py +++ b/packages/pm-data-tools/src/pm_data_tools/validators/semantic.py @@ -8,12 +8,12 @@ - Risk assessment validation """ -from typing import Optional from collections import defaultdict, deque from decimal import Decimal from pm_data_tools.models import Project -from .base import ValidationIssue, ValidationResult, Severity + +from .base import Severity, ValidationIssue, ValidationResult class SemanticValidator: @@ -60,7 +60,7 @@ def _validate_dependency_cycles(self, project: Project) -> list[ValidationIssue] # Build adjacency list (successor -> predecessors) graph: dict = defaultdict(list) in_degree: dict = defaultdict(int) - task_ids = {task.id for task in project.tasks} + {task.id for task in project.tasks} for dep in project.dependencies: # successor depends on predecessor @@ -259,30 +259,28 @@ def _validate_risk_assessments(self, project: Project) -> list[ValidationIssue]: for risk in project.risks: # Check probability is in valid range (typically 1-5) - if risk.probability is not None: - if not (1 <= risk.probability <= 5): - issues.append( - ValidationIssue( - code="INVALID_RISK_PROBABILITY", - message="Risk probability outside valid range (1-5)", - severity=Severity.ERROR, - context=f"Risk: {risk.name} (ID: {risk.id}), Probability: {risk.probability}", - suggestion="Set probability to a value between 1 and 5", - ) + if risk.probability is not None and not (1 <= risk.probability <= 5): + issues.append( + ValidationIssue( + code="INVALID_RISK_PROBABILITY", + message="Risk probability outside valid range (1-5)", + severity=Severity.ERROR, + context=f"Risk: {risk.name} (ID: {risk.id}), Probability: {risk.probability}", + suggestion="Set probability to a value between 1 and 5", ) + ) # Check impact is in valid range (typically 1-5) - if risk.impact is not None: - if not (1 <= risk.impact <= 5): - issues.append( - ValidationIssue( - code="INVALID_RISK_IMPACT", - message="Risk impact outside valid range (1-5)", - severity=Severity.ERROR, - context=f"Risk: {risk.name} (ID: {risk.id}), Impact: {risk.impact}", - suggestion="Set impact to a value between 1 and 5", - ) + if risk.impact is not None and not (1 <= risk.impact <= 5): + issues.append( + ValidationIssue( + code="INVALID_RISK_IMPACT", + message="Risk impact outside valid range (1-5)", + severity=Severity.ERROR, + context=f"Risk: {risk.name} (ID: {risk.id}), Impact: {risk.impact}", + suggestion="Set impact to a value between 1 and 5", ) + ) # Check for risks without mitigation strategy (high severity) if risk.probability and risk.impact: diff --git a/packages/pm-data-tools/src/pm_data_tools/validators/structural.py b/packages/pm-data-tools/src/pm_data_tools/validators/structural.py index 44dc700..a3860c4 100644 --- a/packages/pm-data-tools/src/pm_data_tools/validators/structural.py +++ b/packages/pm-data-tools/src/pm_data_tools/validators/structural.py @@ -7,10 +7,10 @@ - Date consistency """ -from typing import Optional from pm_data_tools.models import Project -from .base import ValidationIssue, ValidationResult, Severity + +from .base import Severity, ValidationIssue, ValidationResult class StructuralValidator: @@ -109,7 +109,7 @@ def _validate_task_references(self, project: Project) -> list[ValidationIssue]: issues.append( ValidationIssue( code="INVALID_PARENT_TASK_REF", - message=f"Task references non-existent parent task", + message="Task references non-existent parent task", severity=Severity.ERROR, context=f"Task: {task.name} (ID: {task.id}), Parent ID: {task.parent_id}", suggestion="Ensure the parent task exists in the project", @@ -262,17 +262,16 @@ def _validate_date_consistency(self, project: Project) -> list[ValidationIssue]: issues: list[ValidationIssue] = [] # Validate project dates - if project.start_date and project.finish_date: - if project.finish_date < project.start_date: - issues.append( - ValidationIssue( - code="INVALID_PROJECT_DATES", - message="Project finish date is before start date", - severity=Severity.ERROR, - context=f"Start: {project.start_date}, Finish: {project.finish_date}", - suggestion="Ensure finish date is on or after start date", - ) + if project.start_date and project.finish_date and project.finish_date < project.start_date: + issues.append( + ValidationIssue( + code="INVALID_PROJECT_DATES", + message="Project finish date is before start date", + severity=Severity.ERROR, + context=f"Start: {project.start_date}, Finish: {project.finish_date}", + suggestion="Ensure finish date is on or after start date", ) + ) # Validate task dates for task in project.tasks: diff --git a/packages/pm-data-tools/tests/conftest.py b/packages/pm-data-tools/tests/conftest.py index fd7c8c7..e1edd56 100644 --- a/packages/pm-data-tools/tests/conftest.py +++ b/packages/pm-data-tools/tests/conftest.py @@ -4,12 +4,9 @@ validators, and CLI commands. """ -import pytest -from datetime import datetime, date -from decimal import Decimal from pathlib import Path -from typing import Any -from uuid import uuid4 + +import pytest # Fixtures will be added as models are built # For now, provide basic test utilities diff --git a/packages/pm-data-tools/tests/test_assurance/conftest.py b/packages/pm-data-tools/tests/test_assurance/conftest.py index 398084a..d2827c4 100644 --- a/packages/pm-data-tools/tests/test_assurance/conftest.py +++ b/packages/pm-data-tools/tests/test_assurance/conftest.py @@ -7,8 +7,6 @@ from __future__ import annotations -import json -import tempfile from datetime import date, datetime, timezone from pathlib import Path from typing import Any @@ -17,26 +15,23 @@ import pytest from agent_planning.confidence.models import ConfidenceResult, ReviewLevel -from pm_data_tools.assurance.currency import ArtefactCurrencyValidator, CurrencyConfig -from pm_data_tools.assurance.divergence import DivergenceConfig, DivergenceMonitor -from pm_data_tools.assurance.lessons import ( - LessonCategory, - LessonRecord, - LessonSentiment, - LessonsKnowledgeEngine, -) from pm_data_tools.assurance.assumptions import ( Assumption, AssumptionCategory, - AssumptionConfig, - AssumptionSource, AssumptionTracker, ) from pm_data_tools.assurance.classifier import ( ClassificationInput, - ClassifierConfig, ProjectDomainClassifier, ) +from pm_data_tools.assurance.currency import ArtefactCurrencyValidator +from pm_data_tools.assurance.divergence import DivergenceConfig, DivergenceMonitor +from pm_data_tools.assurance.lessons import ( + LessonCategory, + LessonRecord, + LessonSentiment, + LessonsKnowledgeEngine, +) from pm_data_tools.assurance.overhead import ( ActivityType, AssuranceActivity, @@ -45,22 +40,19 @@ from pm_data_tools.assurance.overrides import ( OverrideDecision, OverrideDecisionLogger, - OverrideOutcome, OverrideType, ) from pm_data_tools.assurance.scheduler import AdaptiveReviewScheduler from pm_data_tools.assurance.workflows import ( AssuranceWorkflowEngine, - WorkflowConfig, ) from pm_data_tools.db.store import AssuranceStore from pm_data_tools.schemas.nista.longitudinal import ( - ConfidenceScoreRecord, ComplianceThresholdConfig, + ConfidenceScoreRecord, LongitudinalComplianceTracker, ) - # --------------------------------------------------------------------------- # SQLite store (temp file per test) # --------------------------------------------------------------------------- @@ -396,8 +388,8 @@ def make_assumption( baseline_value: float = 100.0, unit: str = "GBP", dependencies: list[str] | None = None, - last_validated: "date | None" = None, - created_date: "date | None" = None, + last_validated: date | None = None, + created_date: date | None = None, **kwargs: object, ) -> Assumption: """Helper to build an Assumption with sensible defaults.""" @@ -419,7 +411,7 @@ def make_assumption( @pytest.fixture() def populated_assumption_tracker(store: AssuranceStore) -> AssumptionTracker: """AssumptionTracker pre-loaded with 8 diverse assumptions including dependency chains.""" - from datetime import date, timedelta + from datetime import date tracker = AssumptionTracker(store=store) @@ -441,7 +433,7 @@ def populated_assumption_tracker(store: AssuranceStore) -> AssumptionTracker: tracker.ingest_batch([a0, a1, a2, a3, a4, a5, a6, a7]) - today = date.today() + date.today() # Validate most assumptions recently for a, drift in [(a0, 3.1), (a1, 920.0), (a2, 16.0), (a3, 2.5), (a4, 220.0), (a5, 0.9), (a6, 0.8)]: tracker.update_value(a.id, new_value=drift) @@ -459,7 +451,7 @@ def make_activity( project_id: str = "PROJ-001", activity_type: ActivityType = ActivityType.GATE_REVIEW, description: str = "Test activity", - activity_date: "date | None" = None, + activity_date: date | None = None, effort_hours: float = 8.0, participants: int = 2, findings_count: int = 2, diff --git a/packages/pm-data-tools/tests/test_assurance/test_armm.py b/packages/pm-data-tools/tests/test_assurance/test_armm.py index 510cac9..d97bacb 100644 --- a/packages/pm-data-tools/tests/test_assurance/test_armm.py +++ b/packages/pm-data-tools/tests/test_assurance/test_armm.py @@ -2,24 +2,21 @@ from __future__ import annotations -import pytest - from pm_data_tools.assurance.armm import ( + DIMENSION_TOPICS, + TOPIC_CRITERIA_COUNT, + TOPIC_DIMENSION, ARMMConfig, ARMMDimension, ARMMScorer, ARMMTopic, CriterionResult, MaturityLevel, - TOPIC_CRITERIA_COUNT, - TOPIC_DIMENSION, - DIMENSION_TOPICS, _criterion_to_topic, _score_to_level, ) from pm_data_tools.db.store import AssuranceStore - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/packages/pm-data-tools/tests/test_assurance/test_assumptions.py b/packages/pm-data-tools/tests/test_assurance/test_assumptions.py index b464e8c..5973107 100644 --- a/packages/pm-data-tools/tests/test_assurance/test_assumptions.py +++ b/packages/pm-data-tools/tests/test_assurance/test_assumptions.py @@ -4,20 +4,16 @@ from datetime import date, timedelta -import pytest - from conftest import make_assumption from pm_data_tools.assurance.assumptions import ( Assumption, AssumptionCategory, AssumptionConfig, AssumptionHealthReport, - AssumptionSource, AssumptionTracker, DriftSeverity, ) - # --------------------------------------------------------------------------- # 1. Ingest and retrieve # --------------------------------------------------------------------------- diff --git a/packages/pm-data-tools/tests/test_assurance/test_classifier.py b/packages/pm-data-tools/tests/test_assurance/test_classifier.py index 86e7c3a..f493e2d 100644 --- a/packages/pm-data-tools/tests/test_assurance/test_classifier.py +++ b/packages/pm-data-tools/tests/test_assurance/test_classifier.py @@ -12,38 +12,19 @@ from __future__ import annotations -from datetime import date, datetime, timezone -from pathlib import Path - -import pytest - from pm_data_tools.assurance.classifier import ( ClassificationInput, ClassificationResult, ClassifierConfig, ComplexityDomain, - DomainAssuranceProfile, - DomainIndicator, ProjectDomainClassifier, ) -from pm_data_tools.assurance.overhead import ( - ActivityType, - AssuranceActivity, - AssuranceOverheadOptimiser, -) -from pm_data_tools.assurance.overrides import ( - OverrideDecision, - OverrideDecisionLogger, - OverrideOutcome, - OverrideType, -) from pm_data_tools.db.store import AssuranceStore from pm_data_tools.schemas.nista.longitudinal import ( ConfidenceScoreRecord, LongitudinalComplianceTracker, ) - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -259,7 +240,7 @@ def test_indicators_list_empty_when_no_explicit(store: AssuranceStore) -> None: def test_indicator_contribution_correct_for_inverse() -> None: clf = make_classifier() inp = ClassificationInput(project_id="P", requirement_clarity=0.7) - _, indicators, _ = ( + _, _indicators, _ = ( clf._compute_explicit_score(inp)[0], clf._compute_explicit_score(inp)[0], clf._compute_explicit_score(inp)[1], diff --git a/packages/pm-data-tools/tests/test_assurance/test_divergence.py b/packages/pm-data-tools/tests/test_assurance/test_divergence.py index 37f35fb..eb9f55a 100644 --- a/packages/pm-data-tools/tests/test_assurance/test_divergence.py +++ b/packages/pm-data-tools/tests/test_assurance/test_divergence.py @@ -18,7 +18,6 @@ ) from pm_data_tools.db.store import AssuranceStore - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -178,7 +177,7 @@ def test_get_history_empty(tmp_path: Path) -> None: def test_get_history_multiple_ordered(tmp_path: Path) -> None: """get_divergence_history returns snapshots ordered by timestamp ASC.""" store = AssuranceStore(db_path=tmp_path / "store.db") - mon = _monitor(store=store) + _monitor(store=store) for i, ts in enumerate(["2026-01-01T00:00:00", "2026-02-01T00:00:00"]): store.insert_divergence_snapshot( diff --git a/packages/pm-data-tools/tests/test_assurance/test_finding_analyzer.py b/packages/pm-data-tools/tests/test_assurance/test_finding_analyzer.py index c24c4e2..bbb6f43 100644 --- a/packages/pm-data-tools/tests/test_assurance/test_finding_analyzer.py +++ b/packages/pm-data-tools/tests/test_assurance/test_finding_analyzer.py @@ -7,19 +7,15 @@ from __future__ import annotations from datetime import date, datetime, timezone -from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -import pytest - from pm_data_tools.assurance import ( - ReviewAction, FindingAnalysisResult, FindingAnalyzer, - ReviewActionStatus, RecurrenceDetector, + ReviewAction, + ReviewActionStatus, ) -from pm_data_tools.assurance.recurrence import SENTENCE_TRANSFORMERS_AVAILABLE from pm_data_tools.db.store import AssuranceStore from .conftest import make_confidence_result @@ -119,7 +115,7 @@ async def test_deduplication_within_single_review( ) texts = [r.text for r in result.recommendations] - assert len(texts) == len(set(t.lower() for t in texts)) + assert len(texts) == len({t.lower() for t in texts}) assert len(result.recommendations) == 1 diff --git a/packages/pm-data-tools/tests/test_assurance/test_lessons.py b/packages/pm-data-tools/tests/test_assurance/test_lessons.py index 60a6c92..f99d24b 100644 --- a/packages/pm-data-tools/tests/test_assurance/test_lessons.py +++ b/packages/pm-data-tools/tests/test_assurance/test_lessons.py @@ -6,7 +6,6 @@ from __future__ import annotations -from datetime import date from pathlib import Path import pytest @@ -20,7 +19,6 @@ ) from pm_data_tools.db.store import AssuranceStore - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/packages/pm-data-tools/tests/test_assurance/test_longitudinal_compliance.py b/packages/pm-data-tools/tests/test_assurance/test_longitudinal_compliance.py index 5d22aad..77415d1 100644 --- a/packages/pm-data-tools/tests/test_assurance/test_longitudinal_compliance.py +++ b/packages/pm-data-tools/tests/test_assurance/test_longitudinal_compliance.py @@ -5,15 +5,13 @@ from __future__ import annotations -from datetime import datetime, timezone - import pytest from pm_data_tools.db.store import AssuranceStore from pm_data_tools.schemas.nista.longitudinal import ( + ComplianceThresholdConfig, ConfidenceScoreRecord, LongitudinalComplianceTracker, - ComplianceThresholdConfig, TrendDirection, ) from pm_data_tools.schemas.nista.validator import NISTAValidator, StrictnessLevel diff --git a/packages/pm-data-tools/tests/test_assurance/test_overhead.py b/packages/pm-data-tools/tests/test_assurance/test_overhead.py index 5eaaa89..1c0ab2b 100644 --- a/packages/pm-data-tools/tests/test_assurance/test_overhead.py +++ b/packages/pm-data-tools/tests/test_assurance/test_overhead.py @@ -20,7 +20,6 @@ ) from pm_data_tools.db.store import AssuranceStore - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/packages/pm-data-tools/tests/test_assurance/test_overrides.py b/packages/pm-data-tools/tests/test_assurance/test_overrides.py index 9eba49f..ea77b70 100644 --- a/packages/pm-data-tools/tests/test_assurance/test_overrides.py +++ b/packages/pm-data-tools/tests/test_assurance/test_overrides.py @@ -15,12 +15,10 @@ OverrideDecision, OverrideDecisionLogger, OverrideOutcome, - OverridePatternSummary, OverrideType, ) from pm_data_tools.db.store import AssuranceStore - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -217,7 +215,7 @@ def test_analyse_patterns_mixed_outcomes(tmp_path: Path) -> None: d1 = log.log_override(_make_override()) d2 = log.log_override(_make_override()) d3 = log.log_override(_make_override()) - d4 = log.log_override(_make_override()) + log.log_override(_make_override()) log.record_outcome(d1.id, OverrideOutcome.NO_IMPACT) log.record_outcome(d2.id, OverrideOutcome.MINOR_IMPACT) diff --git a/packages/pm-data-tools/tests/test_assurance/test_scheduler.py b/packages/pm-data-tools/tests/test_assurance/test_scheduler.py index 4947f4b..c7edce4 100644 --- a/packages/pm-data-tools/tests/test_assurance/test_scheduler.py +++ b/packages/pm-data-tools/tests/test_assurance/test_scheduler.py @@ -8,6 +8,7 @@ from __future__ import annotations from datetime import date, datetime, timedelta, timezone +from pathlib import Path import pytest @@ -21,7 +22,6 @@ AdaptiveReviewScheduler, ReviewUrgency, SchedulerConfig, - SchedulerRecommendation, ) from pm_data_tools.db.store import AssuranceStore from pm_data_tools.schemas.nista.longitudinal import ( @@ -29,7 +29,6 @@ TrendDirection, ) - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -344,7 +343,7 @@ def test_custom_source_weights() -> None: recurring_actions=0, ) - p3_signal = next(s for s in rec.signals if s.source == "P3") + next(s for s in rec.signals if s.source == "P3") # composite = P3.severity * 1.0 / (0.0 + 1.0) = 0.6 assert rec.composite_score == pytest.approx(0.60) @@ -431,9 +430,8 @@ def test_rationale_no_signals() -> None: # --------------------------------------------------------------------------- -def test_persistence(tmp_path: "Path") -> None: +def test_persistence(tmp_path: Path) -> None: """Recommendation is persisted and retrievable from the store.""" - from pathlib import Path store = AssuranceStore(db_path=tmp_path / "store.db") scheduler = AdaptiveReviewScheduler(store=store) diff --git a/packages/pm-data-tools/tests/test_assurance/test_workflows.py b/packages/pm-data-tools/tests/test_assurance/test_workflows.py index 5607894..943c653 100644 --- a/packages/pm-data-tools/tests/test_assurance/test_workflows.py +++ b/packages/pm-data-tools/tests/test_assurance/test_workflows.py @@ -13,10 +13,7 @@ from __future__ import annotations -from datetime import date, datetime, timedelta, timezone -from pathlib import Path - -import pytest +from datetime import datetime, timedelta, timezone from pm_data_tools.assurance.workflows import ( AssuranceWorkflowEngine, @@ -24,13 +21,11 @@ WorkflowConfig, WorkflowResult, WorkflowRiskSignal, - WorkflowStepResult, WorkflowStepStatus, WorkflowType, ) from pm_data_tools.db.store import AssuranceStore - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/packages/pm-data-tools/tests/test_cli/test_commands.py b/packages/pm-data-tools/tests/test_cli/test_commands.py index 47ccee7..d4e329f 100644 --- a/packages/pm-data-tools/tests/test_cli/test_commands.py +++ b/packages/pm-data-tools/tests/test_cli/test_commands.py @@ -2,14 +2,15 @@ import subprocess import sys +from pathlib import Path + import pytest from click.testing import CliRunner -from pathlib import Path from pm_data_tools.cli import main from pm_data_tools.cli.commands.convert import convert -from pm_data_tools.cli.commands.validate import validate from pm_data_tools.cli.commands.inspect import inspect_cmd +from pm_data_tools.cli.commands.validate import validate @pytest.fixture diff --git a/packages/pm-data-tools/tests/test_gmpp/test_models.py b/packages/pm-data-tools/tests/test_gmpp/test_models.py index c23c38c..aa52ed2 100644 --- a/packages/pm-data-tools/tests/test_gmpp/test_models.py +++ b/packages/pm-data-tools/tests/test_gmpp/test_models.py @@ -1,17 +1,18 @@ """Unit tests for GMPP data models.""" -import pytest from datetime import date, datetime from decimal import Decimal +import pytest + from pm_data_tools.gmpp.models import ( - QuarterPeriod, - ReviewLevel, + BenefitsPerformance, DCANarrative, FinancialPerformance, - SchedulePerformance, - BenefitsPerformance, QuarterlyReport, + QuarterPeriod, + ReviewLevel, + SchedulePerformance, ) diff --git a/packages/pm-data-tools/tests/test_models/test_assignment.py b/packages/pm-data-tools/tests/test_models/test_assignment.py index ca87535..4b33b14 100644 --- a/packages/pm-data-tools/tests/test_models/test_assignment.py +++ b/packages/pm-data-tools/tests/test_models/test_assignment.py @@ -1,10 +1,11 @@ """Tests for Assignment model.""" -import pytest -from decimal import Decimal from datetime import datetime +from decimal import Decimal from uuid import uuid4 +import pytest + from pm_data_tools.models import Assignment, Duration, Money, SourceInfo diff --git a/packages/pm-data-tools/tests/test_models/test_base.py b/packages/pm-data-tools/tests/test_models/test_base.py index 4e31f2d..4dc0fb5 100644 --- a/packages/pm-data-tools/tests/test_models/test_base.py +++ b/packages/pm-data-tools/tests/test_models/test_base.py @@ -1,10 +1,11 @@ """Tests for base types and classes.""" -import pytest from datetime import datetime from decimal import Decimal -from pm_data_tools.models.base import Duration, Money, SourceInfo, CustomField +import pytest + +from pm_data_tools.models.base import CustomField, Duration, Money, SourceInfo class TestDuration: diff --git a/packages/pm-data-tools/tests/test_models/test_calendar.py b/packages/pm-data-tools/tests/test_models/test_calendar.py index 2c37f3c..aebe56c 100644 --- a/packages/pm-data-tools/tests/test_models/test_calendar.py +++ b/packages/pm-data-tools/tests/test_models/test_calendar.py @@ -1,9 +1,10 @@ """Tests for Calendar model.""" -import pytest from datetime import date from uuid import uuid4 +import pytest + from pm_data_tools.models import Calendar, SourceInfo diff --git a/packages/pm-data-tools/tests/test_models/test_dependency.py b/packages/pm-data-tools/tests/test_models/test_dependency.py index 1985e6e..4f61900 100644 --- a/packages/pm-data-tools/tests/test_models/test_dependency.py +++ b/packages/pm-data-tools/tests/test_models/test_dependency.py @@ -1,8 +1,9 @@ """Tests for Dependency model.""" -import pytest from uuid import uuid4 +import pytest + from pm_data_tools.models import Dependency, DependencyType, Duration, SourceInfo diff --git a/packages/pm-data-tools/tests/test_models/test_project.py b/packages/pm-data-tools/tests/test_models/test_project.py index 57f550d..7710c1e 100644 --- a/packages/pm-data-tools/tests/test_models/test_project.py +++ b/packages/pm-data-tools/tests/test_models/test_project.py @@ -1,23 +1,20 @@ """Tests for Project model.""" -import pytest from datetime import datetime from decimal import Decimal from uuid import uuid4 +import pytest + from pm_data_tools.models import ( + DeliveryConfidence, + Money, Project, - Task, Resource, - Assignment, - Dependency, Risk, - Calendar, - DeliveryConfidence, - TaskStatus, RiskStatus, - Money, SourceInfo, + Task, ) diff --git a/packages/pm-data-tools/tests/test_models/test_resource.py b/packages/pm-data-tools/tests/test_models/test_resource.py index cc7b715..2c5badd 100644 --- a/packages/pm-data-tools/tests/test_models/test_resource.py +++ b/packages/pm-data-tools/tests/test_models/test_resource.py @@ -1,10 +1,11 @@ """Tests for Resource model.""" -import pytest from decimal import Decimal from uuid import uuid4 -from pm_data_tools.models import Resource, ResourceType, Money, SourceInfo +import pytest + +from pm_data_tools.models import Money, Resource, ResourceType, SourceInfo @pytest.fixture diff --git a/packages/pm-data-tools/tests/test_models/test_risk.py b/packages/pm-data-tools/tests/test_models/test_risk.py index 6f44252..b8a4b10 100644 --- a/packages/pm-data-tools/tests/test_models/test_risk.py +++ b/packages/pm-data-tools/tests/test_models/test_risk.py @@ -1,10 +1,11 @@ """Tests for Risk model.""" -import pytest from datetime import date from uuid import uuid4 -from pm_data_tools.models import Risk, RiskStatus, RiskCategory, SourceInfo +import pytest + +from pm_data_tools.models import Risk, RiskCategory, RiskStatus, SourceInfo @pytest.fixture diff --git a/packages/pm-data-tools/tests/test_models/test_task.py b/packages/pm-data-tools/tests/test_models/test_task.py index 13faad6..1753efe 100644 --- a/packages/pm-data-tools/tests/test_models/test_task.py +++ b/packages/pm-data-tools/tests/test_models/test_task.py @@ -1,18 +1,19 @@ """Tests for Task model.""" -import pytest from datetime import datetime from decimal import Decimal from uuid import uuid4 +import pytest + from pm_data_tools.models import ( - Task, - TaskStatus, ConstraintType, + CustomField, Duration, Money, SourceInfo, - CustomField, + Task, + TaskStatus, ) diff --git a/packages/pm-data-tools/tests/test_schemas/__init__.py b/packages/pm-data-tools/tests/test_schemas/__init__.py index 2bb88df..0255b47 100644 --- a/packages/pm-data-tools/tests/test_schemas/__init__.py +++ b/packages/pm-data-tools/tests/test_schemas/__init__.py @@ -1 +1 @@ -from pathlib import Path +from pathlib import Path # noqa: F401 diff --git a/packages/pm-data-tools/tests/test_schemas/test_mspdi_parser.py b/packages/pm-data-tools/tests/test_schemas/test_mspdi_parser.py index 676e752..fa25d24 100644 --- a/packages/pm-data-tools/tests/test_schemas/test_mspdi_parser.py +++ b/packages/pm-data-tools/tests/test_schemas/test_mspdi_parser.py @@ -1,13 +1,14 @@ """Tests for MSPDI parser.""" -import pytest -from pathlib import Path from decimal import Decimal +from pathlib import Path + +import pytest -from pm_data_tools.schemas.mspdi.parser import MspdiParser -from pm_data_tools.models.task import TaskStatus from pm_data_tools.models.dependency import DependencyType from pm_data_tools.models.resource import ResourceType +from pm_data_tools.models.task import TaskStatus +from pm_data_tools.schemas.mspdi.parser import MspdiParser @pytest.fixture diff --git a/packages/pm-data-tools/tests/test_schemas/test_mspdi_roundtrip.py b/packages/pm-data-tools/tests/test_schemas/test_mspdi_roundtrip.py index f78c440..575d60c 100644 --- a/packages/pm-data-tools/tests/test_schemas/test_mspdi_roundtrip.py +++ b/packages/pm-data-tools/tests/test_schemas/test_mspdi_roundtrip.py @@ -4,9 +4,10 @@ which is essential for lossless conversion workflows. """ -import pytest from pathlib import Path +import pytest + from pm_data_tools.schemas.mspdi.parser import MspdiParser from pm_data_tools.schemas.mspdi.writer import MspdiWriter diff --git a/packages/pm-data-tools/tests/test_schemas/test_mspdi_writer.py b/packages/pm-data-tools/tests/test_schemas/test_mspdi_writer.py index b7be2c4..20c5c5a 100644 --- a/packages/pm-data-tools/tests/test_schemas/test_mspdi_writer.py +++ b/packages/pm-data-tools/tests/test_schemas/test_mspdi_writer.py @@ -1,30 +1,35 @@ """Tests for MSPDI writer.""" -import pytest -from pathlib import Path -from decimal import Decimal from datetime import datetime +from decimal import Decimal +from pathlib import Path from uuid import uuid4 -from lxml import etree -from pm_data_tools.schemas.mspdi.writer import MspdiWriter +import pytest + from pm_data_tools.models import ( - Project, - Task, - Resource, Assignment, - Dependency, Calendar, - SourceInfo, - Duration, - Money, CustomField, DeliveryConfidence, + Dependency, + Duration, + Money, + Project, + Resource, + SourceInfo, + Task, ) -from pm_data_tools.models.task import TaskStatus, ConstraintType from pm_data_tools.models.dependency import DependencyType from pm_data_tools.models.resource import ResourceType -from pm_data_tools.utils.xml_helpers import parse_xml_string, get_text, get_int, strip_namespaces +from pm_data_tools.models.task import TaskStatus +from pm_data_tools.schemas.mspdi.writer import MspdiWriter +from pm_data_tools.utils.xml_helpers import ( + get_int, + get_text, + parse_xml_string, + strip_namespaces, +) @pytest.fixture diff --git a/packages/pm-data-tools/tests/test_utils/test_dates.py b/packages/pm-data-tools/tests/test_utils/test_dates.py index a96dcb7..61fa6d2 100644 --- a/packages/pm-data-tools/tests/test_utils/test_dates.py +++ b/packages/pm-data-tools/tests/test_utils/test_dates.py @@ -1,20 +1,21 @@ """Tests for date utilities.""" -import pytest from datetime import datetime, timedelta +import pytest + +from pm_data_tools.models.base import Duration from pm_data_tools.utils.dates import ( - parse_iso_datetime, + add_working_days, + calculate_working_days, + duration_to_timedelta, + format_iso_datetime, + format_mspdi_duration, parse_datetime, + parse_iso_datetime, parse_mspdi_duration, - format_mspdi_duration, - duration_to_timedelta, timedelta_to_duration, - calculate_working_days, - add_working_days, - format_iso_datetime, ) -from pm_data_tools.models.base import Duration class TestParseIsoDatetime: diff --git a/packages/pm-data-tools/tests/test_utils/test_identifiers.py b/packages/pm-data-tools/tests/test_utils/test_identifiers.py index 5c621e8..c84d59e 100644 --- a/packages/pm-data-tools/tests/test_utils/test_identifiers.py +++ b/packages/pm-data-tools/tests/test_utils/test_identifiers.py @@ -1,16 +1,15 @@ """Tests for identifier utilities.""" -import pytest from uuid import UUID from pm_data_tools.utils.identifiers import ( + MSPDI_NAMESPACE, + P6_NAMESPACE, + generate_random_uuid, generate_uuid_from_source, get_namespace_for_tool, - generate_random_uuid, - parse_uuid, is_valid_uuid, - MSPDI_NAMESPACE, - P6_NAMESPACE, + parse_uuid, ) diff --git a/packages/pm-data-tools/tests/test_utils/test_xml_helpers.py b/packages/pm-data-tools/tests/test_utils/test_xml_helpers.py index c705ea8..bdffb4b 100644 --- a/packages/pm-data-tools/tests/test_utils/test_xml_helpers.py +++ b/packages/pm-data-tools/tests/test_utils/test_xml_helpers.py @@ -1,20 +1,20 @@ """Tests for XML helper utilities.""" -import pytest from pathlib import Path + +import pytest from lxml import etree from pm_data_tools.utils.xml_helpers import ( - parse_xml_file, - parse_xml_string, - get_text, - get_int, - get_float, - get_bool, create_element, + get_bool, + get_float, + get_int, + get_text, + parse_xml_string, + strip_namespaces, write_xml_file, write_xml_string, - strip_namespaces, ) diff --git a/packages/pm-data-tools/tests/test_validators/test_semantic.py b/packages/pm-data-tools/tests/test_validators/test_semantic.py index f5b4178..aaf56ad 100644 --- a/packages/pm-data-tools/tests/test_validators/test_semantic.py +++ b/packages/pm-data-tools/tests/test_validators/test_semantic.py @@ -13,15 +13,13 @@ Duration, Money, Project, - Resource, - ResourceType, Risk, RiskStatus, SourceInfo, Task, TaskStatus, ) -from pm_data_tools.validators import SemanticValidator, Severity +from pm_data_tools.validators import SemanticValidator @pytest.fixture @@ -606,7 +604,7 @@ def test_task_100_percent_with_actual_start_no_finish( tasks=[task], ) - result = validator.validate(project) + validator.validate(project) # This is caught by structural validator, so semantic skips it (line 235) # Should not crash @@ -629,7 +627,7 @@ def test_task_with_actual_cost_no_budget( tasks=[task], ) - result = validator.validate(project) + validator.validate(project) # Should not crash def test_task_with_actual_work_no_budget( @@ -651,7 +649,7 @@ def test_task_with_actual_work_no_budget( tasks=[task], ) - result = validator.validate(project) + validator.validate(project) # Should not crash @@ -762,7 +760,7 @@ def test_start_to_start_dependency( dependencies=[dep], ) - result = validator.validate(project) + validator.validate(project) # Validator only checks FS dependencies currently, so this should not crash # (SS validation not yet implemented) @@ -801,7 +799,7 @@ def test_finish_to_finish_dependency( dependencies=[dep], ) - result = validator.validate(project) + validator.validate(project) # Validator only checks FS dependencies currently, so this should not crash # (FF validation not yet implemented) @@ -840,7 +838,7 @@ def test_start_to_finish_dependency( dependencies=[dep], ) - result = validator.validate(project) + validator.validate(project) # Validator only checks FS dependencies currently, so this should not crash # (SF validation not yet implemented) @@ -860,7 +858,7 @@ def test_project_cost_variance_no_actual( actual_cost=None, ) - result = validator.validate(project) + validator.validate(project) # Should not crash def test_task_cost_overrun_edge( @@ -999,7 +997,7 @@ def test_task_finish_only_with_project_dates( tasks=[task], ) - result = validator.validate(project) + validator.validate(project) # Should still check finish date against project dates # No error expected since finish is within project dates @@ -1023,7 +1021,7 @@ def test_task_no_dates_with_project_dates( tasks=[task], ) - result = validator.validate(project) + validator.validate(project) # Should not crash or have date validation issues for dateless tasks def test_risk_no_probability( diff --git a/packages/pm-data-tools/tests/test_validators/test_structural.py b/packages/pm-data-tools/tests/test_validators/test_structural.py index cf5a724..5c07099 100644 --- a/packages/pm-data-tools/tests/test_validators/test_structural.py +++ b/packages/pm-data-tools/tests/test_validators/test_structural.py @@ -1,7 +1,6 @@ """Tests for structural validator.""" from datetime import datetime -from decimal import Decimal from uuid import uuid4 import pytest @@ -9,11 +8,9 @@ from pm_data_tools.models import ( Assignment, Calendar, + DeliveryConfidence, Dependency, DependencyType, - DeliveryConfidence, - Duration, - Money, Project, Resource, ResourceType, @@ -21,7 +18,7 @@ Task, TaskStatus, ) -from pm_data_tools.validators import Severity, StructuralValidator +from pm_data_tools.validators import StructuralValidator @pytest.fixture diff --git a/packages/pm-mcp-servers/README.md b/packages/pm-mcp-servers/README.md index 2e2d680..dd575ba 100644 --- a/packages/pm-mcp-servers/README.md +++ b/packages/pm-mcp-servers/README.md @@ -16,7 +16,32 @@ PM MCP Servers provides Model Context Protocol (MCP) servers that enable Claude Desktop and other MCP clients to interact with project management data and assurance tooling. Built to support the NISTA Programme and Project Data Standard trial. -## Available Servers +## Unified Server (Recommended) + +The **`pda-platform-server`** is a single MCP endpoint that exposes all 41 tools +from all 5 modules. This is the recommended way to use PDA. + +```json +{ + "mcpServers": { + "pda-platform": { + "command": "pda-platform-server", + "args": [], + "env": { + "ANTHROPIC_API_KEY": "sk-ant-..." + } + } + } +} +``` + +For remote access (e.g., from Claude.ai), use `pda-platform-remote` which adds +SSE transport over HTTP. See the deployment section below. + +## Individual Servers + +Individual servers are still available for use cases where you only need a +subset of tools. ### pm-data-server @@ -102,12 +127,8 @@ Add to `claude_desktop_config.json` ```json { "mcpServers": { - "pm-data": { - "command": "pm-data-server", - "args": [] - }, - "pm-assure": { - "command": "pm-assure-server", + "pda-platform": { + "command": "pda-platform-server", "args": [], "env": { "ANTHROPIC_API_KEY": "sk-ant-..." @@ -117,6 +138,19 @@ Add to `claude_desktop_config.json` } ``` +This gives Claude access to all 41 tools across data loading, analysis, +validation, NISTA reporting, and assurance. + +### Remote Deployment + +For remote access from Claude.ai, deploy `pda-platform-remote`: + +```bash +PORT=8080 pda-platform-remote +``` + +This starts an SSE server at `/sse` with a health check at `/health`. + ### Example prompts Once configured you can ask Claude: diff --git a/packages/pm-mcp-servers/diagnose_pm_data_tools.py b/packages/pm-mcp-servers/diagnose_pm_data_tools.py index 15a7b6b..6c12908 100644 --- a/packages/pm-mcp-servers/diagnose_pm_data_tools.py +++ b/packages/pm-mcp-servers/diagnose_pm_data_tools.py @@ -1,8 +1,7 @@ +# ruff: noqa """Diagnose pm-data-tools actual data structures.""" -import json from pathlib import Path -from datetime import date, datetime # Check if pm-data-tools is installed try: @@ -45,7 +44,7 @@ # Check models print("\n=== Checking models ===") try: - from pm_data_tools.models import Project, Task, Resource, Dependency + from pm_data_tools.models import Dependency, Project, Resource, Task print("✓ Can import Project, Task, Resource, Dependency") # Check Project attributes @@ -79,7 +78,6 @@ print(f"✗ {e}") # Try alternate try: - from pm_data_tools import ParseError print(" ✓ from pm_data_tools import ParseError") except: pass @@ -116,7 +114,7 @@ parser = MSPDIParser() project = parser.parse(str(test_file)) - print(f"\n✓ Parsed successfully!") + print("\n✓ Parsed successfully!") print(f" Type: {type(project)}") print(f" Name: {project.name if hasattr(project, 'name') else 'N/A'}") diff --git a/packages/pm-mcp-servers/pyproject.toml b/packages/pm-mcp-servers/pyproject.toml index 90cc35c..b5da907 100644 --- a/packages/pm-mcp-servers/pyproject.toml +++ b/packages/pm-mcp-servers/pyproject.toml @@ -11,6 +11,8 @@ authors = [ dependencies = [ "mcp>=0.9.0", "pm-data-tools>=0.2.0", + "uvicorn>=0.20.0", + "starlette>=0.27.0", ] keywords = ["mcp", "model-context-protocol", "claude", "ai", "project-management"] @@ -35,6 +37,16 @@ pm-analyse-server = "pm_mcp_servers.pm_analyse.server:main" pm-benchmark-server = "pm_mcp_servers.pm_benchmark.server:main" pm-nista-server = "pm_mcp_servers.pm_nista.server:main" pm-assure-server = "pm_mcp_servers.pm_assure.server:main" +pda-platform-server = "pm_mcp_servers.pda_platform.server:main" +pda-platform-remote = "pm_mcp_servers.pda_platform.remote:main" + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP"] +ignore = ["E501", "E722"] [build-system] requires = ["setuptools>=61.0"] diff --git a/packages/pm-mcp-servers/src/pm_mcp_servers/pda_platform/__init__.py b/packages/pm-mcp-servers/src/pm_mcp_servers/pda_platform/__init__.py new file mode 100644 index 0000000..7782e5c --- /dev/null +++ b/packages/pm-mcp-servers/src/pm_mcp_servers/pda_platform/__init__.py @@ -0,0 +1 @@ +"""PDA Platform — unified MCP server exposing all PDA tools.""" diff --git a/packages/pm-mcp-servers/src/pm_mcp_servers/pda_platform/remote.py b/packages/pm-mcp-servers/src/pm_mcp_servers/pda_platform/remote.py new file mode 100644 index 0000000..af8af19 --- /dev/null +++ b/packages/pm-mcp-servers/src/pm_mcp_servers/pda_platform/remote.py @@ -0,0 +1,85 @@ +"""PDA Platform — remote HTTP server with SSE transport. + +Wraps the unified pda-platform MCP server in an SSE transport layer +so it can be accessed remotely from Claude.ai or any MCP client over HTTP. + +Usage: + pda-platform-remote # starts on $PORT or 8080 + PORT=3000 pda-platform-remote # custom port + +Endpoints: + GET /sse SSE connection endpoint (MCP client connects here) + POST /messages Message endpoint (MCP client sends tool calls here) + GET /health Health check for Render/Railway +""" + +from __future__ import annotations + +import logging +import os + +from mcp.server.sse import SseServerTransport +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route + +from .server import ALL_TOOLS, server + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +sse = SseServerTransport("/messages") + + +async def handle_sse(request: Request): + """Handle SSE connection from MCP client.""" + logger.info("SSE connection from %s", request.client) + async with sse.connect_sse( + request.scope, request.receive, request._send + ) as streams: + await server.run( + streams[0], + streams[1], + server.create_initialization_options(), + ) + + +async def handle_messages(request: Request): + """Handle POST messages from MCP client.""" + await sse.handle_post_message(request.scope, request.receive, request._send) + + +async def health(request: Request): + """Health check endpoint.""" + return JSONResponse({ + "status": "ok", + "server": "pda-platform", + "tools": len(ALL_TOOLS), + "transport": "sse", + }) + + +app = Starlette( + debug=False, + routes=[ + Route("/sse", endpoint=handle_sse), + Route("/messages", endpoint=handle_messages, methods=["POST"]), + Route("/health", endpoint=health), + ], +) + + +def main() -> None: + """Entry point for pda-platform-remote.""" + import uvicorn + + port = int(os.environ.get("PORT", 8080)) + logger.info("Starting PDA Platform remote server on port %d", port) + logger.info("SSE endpoint: /sse") + logger.info("Tools available: %d", len(ALL_TOOLS)) + uvicorn.run(app, host="0.0.0.0", port=port) + + +if __name__ == "__main__": + main() diff --git a/packages/pm-mcp-servers/src/pm_mcp_servers/pda_platform/server.py b/packages/pm-mcp-servers/src/pm_mcp_servers/pda_platform/server.py new file mode 100644 index 0000000..1811aee --- /dev/null +++ b/packages/pm-mcp-servers/src/pm_mcp_servers/pda_platform/server.py @@ -0,0 +1,91 @@ +"""PDA Platform — unified MCP server. + +Aggregates all five PDA MCP servers into a single endpoint: + + pm-data (6 tools) Project data loading, querying, conversion + pm-analyse (6 tools) AI-powered risk, forecasting, health assessment + pm-validate (4 tools) Structural, semantic, and NISTA validation + pm-nista (5 tools) GMPP reporting and NISTA integration + pm-assure (20 tools) Assurance quality, compliance, assumptions, workflows + +Total: 41 tools accessible through one connection. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp.types import TextContent, Tool + +from ..pm_analyse.registry import TOOLS as ANALYSE_TOOLS +from ..pm_analyse.registry import dispatch as analyse_dispatch +from ..pm_assure.registry import TOOLS as ASSURE_TOOLS +from ..pm_assure.registry import dispatch as assure_dispatch +from ..pm_data.registry import TOOLS as DATA_TOOLS +from ..pm_data.registry import dispatch as data_dispatch +from ..pm_nista.registry import TOOLS as NISTA_TOOLS +from ..pm_nista.registry import dispatch as nista_dispatch +from ..pm_validate.registry import TOOLS as VALIDATE_TOOLS +from ..pm_validate.registry import dispatch as validate_dispatch + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +server = Server("pda-platform") + +# Build lookup: tool_name -> dispatch function +_TOOL_DISPATCH: dict[str, Any] = {} +for _tools, _dispatch_fn in [ + (DATA_TOOLS, data_dispatch), + (ANALYSE_TOOLS, analyse_dispatch), + (VALIDATE_TOOLS, validate_dispatch), + (NISTA_TOOLS, nista_dispatch), + (ASSURE_TOOLS, assure_dispatch), +]: + for _tool in _tools: + _TOOL_DISPATCH[_tool.name] = _dispatch_fn + +ALL_TOOLS: list[Tool] = DATA_TOOLS + ANALYSE_TOOLS + VALIDATE_TOOLS + NISTA_TOOLS + ASSURE_TOOLS + +logger.info( + "PDA Platform unified server: %d tools from %d modules", + len(ALL_TOOLS), + 5, +) + + +@server.list_tools() +async def list_tools() -> list[Tool]: + """Return all tools from all PDA modules.""" + return ALL_TOOLS + + +@server.call_tool() +async def call_tool(name: str, arguments: Any) -> list[TextContent]: + """Route tool call to the owning module's dispatcher.""" + dispatch_fn = _TOOL_DISPATCH.get(name) + if dispatch_fn is None: + return [TextContent(type="text", text=f"Unknown tool: {name}")] + return await dispatch_fn(name, arguments) + + +async def _run() -> None: + async with stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + + +def main() -> None: + """Entry point for pda-platform-server.""" + asyncio.run(_run()) + + +if __name__ == "__main__": + main() diff --git a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/analyzers.py b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/analyzers.py index b3f7886..b572afc 100644 --- a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/analyzers.py +++ b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/analyzers.py @@ -10,7 +10,7 @@ import uuid from datetime import date, datetime from statistics import mean, stdev -from typing import Any, List, Optional +from typing import Any from .models import ( BaselineVariance, @@ -52,8 +52,8 @@ def detect( self, project: Any, sensitivity: float = 1.0, - focus_areas: Optional[List[str]] = None - ) -> List[Outlier]: + focus_areas: list[str] | None = None + ) -> list[Outlier]: """ Detect outliers in project data. @@ -66,7 +66,7 @@ def detect( Returns: List of detected outliers with confidence scores """ - outliers: List[Outlier] = [] + outliers: list[Outlier] = [] tasks = getattr(project, 'tasks', []) # Filter to work tasks only @@ -103,11 +103,11 @@ def detect( def _detect_duration_outliers( self, - tasks: List[Any], + tasks: list[Any], stdev_multiplier: float - ) -> List[Outlier]: + ) -> list[Outlier]: """Detect tasks with unusual durations.""" - outliers: List[Outlier] = [] + outliers: list[Outlier] = [] # Calculate duration statistics durations = [] @@ -209,7 +209,7 @@ def _detect_duration_outliers( return outliers - def _detect_progress_outliers(self, tasks: List[Any]) -> List[Outlier]: + def _detect_progress_outliers(self, tasks: list[Any]) -> list[Outlier]: """Detect tasks with suspicious progress patterns.""" outliers = [] today = date.today() @@ -282,9 +282,9 @@ def _detect_progress_outliers(self, tasks: List[Any]) -> List[Outlier]: def _detect_float_outliers( self, - tasks: List[Any], + tasks: list[Any], excessive_threshold: float - ) -> List[Outlier]: + ) -> list[Outlier]: """Detect tasks with abnormal float values.""" outliers = [] @@ -341,7 +341,7 @@ def _detect_float_outliers( return outliers - def _detect_date_outliers(self, tasks: List[Any]) -> List[Outlier]: + def _detect_date_outliers(self, tasks: list[Any]) -> list[Outlier]: """Detect impossible or suspicious date values.""" outliers = [] today = date.today() @@ -433,7 +433,7 @@ def assess( self, project: Any, include_trends: bool = True, - weights: Optional[dict] = None + weights: dict | None = None ) -> HealthAssessment: """ Assess overall project health across all dimensions. @@ -689,7 +689,7 @@ def _assess_scope_health(self, project: Any, weight: float) -> HealthDimension: # Check completion rate completed = sum(1 for t in work_tasks if getattr(t, 'percent_complete', 0) == 100) - completion_pct = (completed / len(work_tasks)) * 100 + (completed / len(work_tasks)) * 100 # Determine status if score >= 80: @@ -765,7 +765,7 @@ def _assess_quality_health(self, project: Any, weight: float) -> HealthDimension weight=weight ) - def _generate_recommendations(self, dimensions: List[HealthDimension]) -> List[str]: + def _generate_recommendations(self, dimensions: list[HealthDimension]) -> list[str]: """Generate recommendations based on dimension health.""" recommendations = [] @@ -780,7 +780,7 @@ def _generate_recommendations(self, dimensions: List[HealthDimension]) -> List[s return recommendations[:5] # Top 5 recommendations - def _calculate_confidence(self, project: Any, dimensions: List[HealthDimension]) -> float: + def _calculate_confidence(self, project: Any, dimensions: list[HealthDimension]) -> float: """Calculate confidence in health assessment.""" # Base confidence confidence = 0.70 @@ -795,15 +795,15 @@ def _calculate_confidence(self, project: Any, dimensions: List[HealthDimension]) return min(confidence, 0.95) - def _get_cost_value(self, cost: Any) -> Optional[float]: + def _get_cost_value(self, cost: Any) -> float | None: """Extract numeric value from cost object or number.""" if cost is None: return None - if isinstance(cost, (int, float)): + if isinstance(cost, int | float): return float(cost) if hasattr(cost, 'amount'): amount = getattr(cost, 'amount') - if isinstance(amount, (int, float)): + if isinstance(amount, int | float): return float(amount) return None @@ -836,7 +836,7 @@ def compare( project: Any, baseline_type: str = "current", threshold: float = 0.0 - ) -> List[BaselineVariance]: + ) -> list[BaselineVariance]: """ Compare current values against baseline. diff --git a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/forecasters.py b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/forecasters.py index 22e33b6..dbaa655 100644 --- a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/forecasters.py +++ b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/forecasters.py @@ -7,7 +7,7 @@ import random from datetime import date, datetime, timedelta -from typing import Any, Dict, List, Optional +from typing import Any from .models import ( AnalysisDepth, @@ -99,7 +99,7 @@ def forecast( return forecast - def _calculate_status(self, project: Any, tasks: List[Any]) -> Dict: + def _calculate_status(self, project: Any, tasks: list[Any]) -> dict: """Calculate SPI, percent complete, etc.""" today = date.today() @@ -185,9 +185,9 @@ def _calculate_status(self, project: Any, tasks: List[Any]) -> Dict: def _earned_value_forecast( self, project: Any, - tasks: List[Any], - status: Dict, - baseline_finish: Optional[date] + tasks: list[Any], + status: dict, + baseline_finish: date | None ) -> Forecast: """EVM: Completion = Today + (Remaining / SPI).""" today = date.today() @@ -266,9 +266,9 @@ def _earned_value_forecast( def _monte_carlo_forecast( self, project: Any, - tasks: List[Any], - status: Dict, - baseline_finish: Optional[date], + tasks: list[Any], + status: dict, + baseline_finish: date | None, depth: AnalysisDepth ) -> Forecast: """Simulation with triangular distribution variability.""" @@ -313,7 +313,7 @@ def _monte_carlo_forecast( confidence=0.75, factors=[ f"{iterations} simulations run", - f"P50 forecast", + "P50 forecast", f"{status['percent_complete']:.1f}% complete" ], evidence=[ @@ -334,8 +334,8 @@ def _monte_carlo_forecast( def _simulate_completion( self, project: Any, - tasks: List[Any], - status: Dict, + tasks: list[Any], + status: dict, today: date ) -> date: """Run single Monte Carlo simulation.""" @@ -373,9 +373,9 @@ def _simulate_completion( def _reference_class_forecast( self, project: Any, - tasks: List[Any], - status: Dict, - baseline_finish: Optional[date] + tasks: list[Any], + status: dict, + baseline_finish: date | None ) -> Forecast: """Historical overrun factors (Flyvbjerg research).""" if not baseline_finish: @@ -442,9 +442,9 @@ def _reference_class_forecast( def _simple_extrapolation_forecast( self, project: Any, - tasks: List[Any], - status: Dict, - baseline_finish: Optional[date] + tasks: list[Any], + status: dict, + baseline_finish: date | None ) -> Forecast: """Linear projection based on current progress rate.""" today = date.today() @@ -498,9 +498,9 @@ def _simple_extrapolation_forecast( def _ensemble_forecast( self, project: Any, - tasks: List[Any], - status: Dict, - baseline_finish: Optional[date], + tasks: list[Any], + status: dict, + baseline_finish: date | None, depth: AnalysisDepth ) -> Forecast: """Weighted ensemble of all methods.""" @@ -572,9 +572,9 @@ def _ensemble_forecast( } ) - def _generate_scenarios(self, base_date: date, status: Dict) -> Dict[str, date]: + def _generate_scenarios(self, base_date: date, status: dict) -> dict[str, date]: """Generate optimistic/likely/pessimistic scenarios.""" - spi = status.get("spi", 1.0) + status.get("spi", 1.0) # Optimistic: +10% performance optimistic_days = int((base_date - date.today()).days * 0.90) diff --git a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/models.py b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/models.py index e274912..3663831 100644 --- a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/models.py +++ b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/models.py @@ -10,8 +10,7 @@ from dataclasses import dataclass, field from datetime import date, datetime, timezone from enum import Enum -from typing import Any, Dict, List, Optional - +from typing import Any # ============================================================================ # Enumerations @@ -89,10 +88,10 @@ class Evidence: source: str description: str - data_point: Optional[str] = None + data_point: str | None = None confidence: float = 1.0 - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Convert to JSON-serializable dictionary.""" return { "source": self.source, @@ -114,9 +113,9 @@ class Risk: impact: int # 1-5 score: int # probability * impact confidence: float # 0.0-1.0 - evidence: List[Evidence] = field(default_factory=list) - related_tasks: List[str] = field(default_factory=list) - suggested_mitigation: Optional[str] = None + evidence: list[Evidence] = field(default_factory=list) + related_tasks: list[str] = field(default_factory=list) + suggested_mitigation: str | None = None detected_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) def __post_init__(self): @@ -141,7 +140,7 @@ def severity(self) -> Severity: return Severity.LOW return Severity.INFO - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Convert to JSON-serializable dictionary.""" return { "id": self.id, @@ -171,9 +170,9 @@ class Mitigation: effort: str # low/medium/high effectiveness: float # 0.0-1.0 confidence: float - implementation_steps: List[str] = field(default_factory=list) - resource_requirements: List[str] = field(default_factory=list) - timeline_days: Optional[int] = None + implementation_steps: list[str] = field(default_factory=list) + resource_requirements: list[str] = field(default_factory=list) + timeline_days: int | None = None def __post_init__(self): """Validate effectiveness and confidence ranges.""" @@ -184,7 +183,7 @@ def __post_init__(self): if self.effort not in ("low", "medium", "high"): raise ValueError(f"Effort must be low/medium/high, got {self.effort}") - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Convert to JSON-serializable dictionary.""" return { "id": self.id, @@ -214,14 +213,14 @@ class Outlier: severity: Severity confidence: float explanation: str - evidence: List[Evidence] = field(default_factory=list) + evidence: list[Evidence] = field(default_factory=list) def __post_init__(self): """Validate confidence range.""" if not 0.0 <= self.confidence <= 1.0: raise ValueError(f"Confidence must be between 0.0 and 1.0, got {self.confidence}") - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Convert to JSON-serializable dictionary.""" return { "id": self.id, @@ -249,9 +248,9 @@ class Forecast: variance_days: int on_track: bool confidence: float - factors: List[str] = field(default_factory=list) - evidence: List[Evidence] = field(default_factory=list) - scenarios: Dict[str, date] = field(default_factory=dict) + factors: list[str] = field(default_factory=list) + evidence: list[Evidence] = field(default_factory=list) + scenarios: dict[str, date] = field(default_factory=dict) def __post_init__(self): """Validate confidence values.""" @@ -260,7 +259,7 @@ def __post_init__(self): if not 0.0 <= self.confidence <= 1.0: raise ValueError(f"Confidence must be between 0.0 and 1.0, got {self.confidence}") - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Convert to JSON-serializable dictionary.""" return { "forecast_date": self.forecast_date.isoformat() if isinstance(self.forecast_date, date) else str(self.forecast_date), @@ -290,7 +289,7 @@ class HealthDimension: score: float # 0-100 status: HealthStatus trend: TrendDirection - issues: List[str] = field(default_factory=list) + issues: list[str] = field(default_factory=list) weight: float = 0.2 def __post_init__(self): @@ -300,7 +299,7 @@ def __post_init__(self): if not 0.0 <= self.weight <= 1.0: raise ValueError(f"Weight must be between 0.0 and 1.0, got {self.weight}") - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Convert to JSON-serializable dictionary.""" return { "name": self.name, @@ -318,9 +317,9 @@ class HealthAssessment: overall_score: float overall_status: HealthStatus - dimensions: List[HealthDimension] - top_concerns: List[str] - recommendations: List[str] + dimensions: list[HealthDimension] + top_concerns: list[str] + recommendations: list[str] confidence: float assessed_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) @@ -331,7 +330,7 @@ def __post_init__(self): if not 0.0 <= self.confidence <= 1.0: raise ValueError(f"Confidence must be between 0.0 and 1.0, got {self.confidence}") - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Convert to JSON-serializable dictionary.""" return { "overall_score": self.overall_score, @@ -358,7 +357,7 @@ class BaselineVariance: severity: Severity explanation: str - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Convert to JSON-serializable dictionary.""" return { "task_id": self.task_id, @@ -380,12 +379,12 @@ class AnalysisMetadata: analysis_id: str analysis_type: str started_at: datetime - completed_at: Optional[datetime] = None - duration_ms: Optional[int] = None + completed_at: datetime | None = None + duration_ms: int | None = None depth: AnalysisDepth = AnalysisDepth.STANDARD tasks_analyzed: int = 0 overall_confidence: float = 0.0 - warnings: List[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) def __post_init__(self): """Validate confidence range.""" @@ -398,7 +397,7 @@ def complete(self) -> None: delta = self.completed_at - self.started_at self.duration_ms = int(delta.total_seconds() * 1000) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Convert to JSON-serializable dictionary.""" return { "analysis_id": self.analysis_id, diff --git a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/registry.py b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/registry.py new file mode 100644 index 0000000..94c77a3 --- /dev/null +++ b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/registry.py @@ -0,0 +1,235 @@ +"""PM-Analyse tool registry for unified server aggregation.""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from mcp.types import TextContent, Tool + +from .tools import ( + assess_health, + compare_baseline, + detect_outliers, + forecast_completion, + identify_risks, + suggest_mitigations, +) + +logger = logging.getLogger(__name__) + +TOOLS: list[Tool] = [ + Tool( + name="identify_risks", + description="Identify project risks using AI-powered risk engine across schedule, cost, resource, scope, technical, and external dimensions", + inputSchema={ + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Project identifier from load_project", + }, + "focus_areas": { + "type": "array", + "items": { + "type": "string", + "enum": ["schedule", "cost", "resource", "scope", "technical", "external", "organizational", "stakeholder"], + }, + "description": "Optional list of risk categories to focus on (analyzes all if omitted)", + }, + "depth": { + "type": "string", + "enum": ["quick", "standard", "deep"], + "default": "standard", + "description": "Analysis depth (quick: basic, standard: normal, deep: comprehensive with dependency chains)", + }, + }, + "required": ["project_id"], + }, + ), + Tool( + name="forecast_completion", + description="Forecast project completion date using multiple methods (EVM, Monte Carlo, Reference Class, ML Ensemble) with confidence intervals", + inputSchema={ + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Project identifier from load_project", + }, + "method": { + "type": "string", + "enum": ["earned_value", "monte_carlo", "reference_class", "simple_extrapolation", "ml_ensemble"], + "default": "ml_ensemble", + "description": "Forecasting method to use (ml_ensemble combines all methods)", + }, + "confidence_level": { + "type": "number", + "minimum": 0.50, + "maximum": 0.95, + "default": 0.80, + "description": "Confidence level for prediction interval (0.50-0.95)", + }, + "scenarios": { + "type": "boolean", + "default": True, + "description": "Generate optimistic/likely/pessimistic scenario forecasts", + }, + "depth": { + "type": "string", + "enum": ["quick", "standard", "deep"], + "default": "standard", + "description": "Analysis depth (affects Monte Carlo iteration count)", + }, + }, + "required": ["project_id"], + }, + ), + Tool( + name="detect_outliers", + description="Detect anomalies in task data across duration, progress, float, and dates using statistical analysis", + inputSchema={ + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Project identifier from load_project", + }, + "sensitivity": { + "type": "number", + "minimum": 0.5, + "maximum": 2.0, + "default": 1.0, + "description": "Detection sensitivity (0.5: less sensitive, 2.0: more sensitive)", + }, + "focus_areas": { + "type": "array", + "items": { + "type": "string", + "enum": ["duration", "progress", "float", "dates"], + }, + "description": "Optional list of areas to check (checks all if omitted)", + }, + }, + "required": ["project_id"], + }, + ), + Tool( + name="assess_health", + description="Assess multi-dimensional project health across schedule, cost, scope, resource, and quality dimensions with weighted scoring", + inputSchema={ + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Project identifier from load_project", + }, + "include_trends": { + "type": "boolean", + "default": True, + "description": "Include trend analysis (improving/stable/declining)", + }, + "weights": { + "type": "object", + "properties": { + "schedule": {"type": "number", "minimum": 0, "maximum": 1}, + "cost": {"type": "number", "minimum": 0, "maximum": 1}, + "scope": {"type": "number", "minimum": 0, "maximum": 1}, + "resource": {"type": "number", "minimum": 0, "maximum": 1}, + "quality": {"type": "number", "minimum": 0, "maximum": 1}, + }, + "description": "Optional custom weights for dimensions (must sum to 1.0, default: equal weights 0.2 each)", + }, + }, + "required": ["project_id"], + }, + ), + Tool( + name="suggest_mitigations", + description="Generate AI-powered mitigation strategies for identified risks with effectiveness ratings and implementation steps", + inputSchema={ + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Project identifier from load_project", + }, + "risk_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional list of specific risk IDs to generate mitigations for (from identify_risks)", + }, + "focus_areas": { + "type": "array", + "items": { + "type": "string", + "enum": ["schedule", "cost", "resource", "scope", "technical", "external"], + }, + "description": "Optional list of risk categories to focus on", + }, + "depth": { + "type": "string", + "enum": ["quick", "standard", "deep"], + "default": "standard", + "description": "Analysis depth for risk identification before mitigation", + }, + }, + "required": ["project_id"], + }, + ), + Tool( + name="compare_baseline", + description="Compare current project state against baseline to identify schedule, duration, and cost variances with severity classification", + inputSchema={ + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Project identifier from load_project", + }, + "baseline_type": { + "type": "string", + "enum": ["current", "original", "approved"], + "default": "current", + "description": "Type of baseline to compare against", + }, + "threshold": { + "type": "number", + "minimum": 0, + "maximum": 100, + "default": 0, + "description": "Minimum variance percentage to report (0: all variances, higher: filter small changes)", + }, + }, + "required": ["project_id"], + }, + ), +] + +_TOOL_NAMES = {t.name for t in TOOLS} + + +async def dispatch(name: str, arguments: Any) -> list[TextContent]: + """Dispatch a pm-analyse tool call and return normalised TextContent.""" + try: + if name == "identify_risks": + result = await identify_risks(arguments) + elif name == "forecast_completion": + result = await forecast_completion(arguments) + elif name == "detect_outliers": + result = await detect_outliers(arguments) + elif name == "assess_health": + result = await assess_health(arguments) + elif name == "suggest_mitigations": + result = await suggest_mitigations(arguments) + elif name == "compare_baseline": + result = await compare_baseline(arguments) + else: + result = {"error": {"code": "UNKNOWN_TOOL", "message": f"Unknown tool: {name}"}} + + return [TextContent(type="text", text=json.dumps(result, indent=2, default=str))] + + except Exception as exc: + logger.error("Error executing tool %s: %s", name, exc, exc_info=True) + return [TextContent(type="text", text=json.dumps({"error": {"code": "TOOL_EXECUTION_ERROR", "message": str(exc)}}, indent=2))] diff --git a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/risk_engine.py b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/risk_engine.py index c782cbf..c3c0300 100644 --- a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/risk_engine.py +++ b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/risk_engine.py @@ -6,8 +6,8 @@ """ import uuid -from datetime import date, datetime, timedelta, timezone -from typing import Any, Dict, List, Optional +from datetime import date, datetime, timezone +from typing import Any from .models import ( AnalysisDepth, @@ -15,7 +15,6 @@ Mitigation, Risk, RiskCategory, - Severity, ) @@ -43,14 +42,14 @@ class RiskEngine: def __init__(self): """Initialize the risk engine.""" - self.risks: List[Risk] = [] + self.risks: list[Risk] = [] def analyze( self, project: Any, - focus_areas: Optional[List[str]] = None, + focus_areas: list[str] | None = None, depth: AnalysisDepth = AnalysisDepth.STANDARD - ) -> List[Risk]: + ) -> list[Risk]: """ Main analysis entry point. @@ -96,7 +95,7 @@ def analyze( return self.risks - def _analyze_schedule_risks(self, project: Any, tasks: List[Any]) -> None: + def _analyze_schedule_risks(self, project: Any, tasks: list[Any]) -> None: """Detect: slippage, overdue, low float.""" today = date.today() @@ -247,7 +246,7 @@ def _analyze_schedule_risks(self, project: Any, tasks: List[Any]) -> None: suggested_mitigation="Monitor closely and prepare contingency plans" ) - def _analyze_cost_risks(self, project: Any, tasks: List[Any]) -> None: + def _analyze_cost_risks(self, project: Any, tasks: list[Any]) -> None: """Detect: overruns, forecast variance.""" budget = getattr(project, 'budget', None) actual_cost = getattr(project, 'actual_cost', None) @@ -322,13 +321,13 @@ def _analyze_cost_risks(self, project: Any, tasks: List[Any]) -> None: def _analyze_resource_risks( self, project: Any, - tasks: List[Any], - resources: List[Any] + tasks: list[Any], + resources: list[Any] ) -> None: """Detect: overallocation, SPOFs.""" # Check for resource overallocation for resource in resources: - resource_id = str(getattr(resource, 'id', '')) + str(getattr(resource, 'id', '')) resource_name = getattr(resource, 'name', 'Unnamed Resource') max_units = getattr(resource, 'max_units', 1.0) @@ -355,12 +354,11 @@ def _analyze_resource_risks( critical_tasks = [t for t in tasks if getattr(t, 'is_critical', False)] if len(critical_tasks) >= self.CRITICAL_TASK_CONCENTRATION: # Group by resource - resource_task_count: Dict[str, int] = {} for task in critical_tasks: # Simplified - in real implementation, iterate assignments pass # Implementation would check assignments - def _analyze_scope_risks(self, project: Any, tasks: List[Any]) -> None: + def _analyze_scope_risks(self, project: Any, tasks: list[Any]) -> None: """Detect: missing milestones, incomplete definitions.""" # Check for milestone presence milestones = [t for t in tasks if getattr(t, 'is_milestone', False)] @@ -377,7 +375,7 @@ def _analyze_scope_risks(self, project: Any, tasks: List[Any]) -> None: evidence=[Evidence( source="scope_analysis", description=f"Tasks: {total_tasks}, Milestones: {len(milestones)}", - data_point=f"milestones=0", + data_point="milestones=0", confidence=1.0 )], related_tasks=[], @@ -412,12 +410,12 @@ def _analyze_scope_risks(self, project: Any, tasks: List[Any]) -> None: def _analyze_technical_risks( self, project: Any, - tasks: List[Any], - dependencies: List[Any] + tasks: list[Any], + dependencies: list[Any] ) -> None: """Detect: bottlenecks, integration points.""" # Count dependencies per task (successors) - dependency_count: Dict[str, int] = {} + dependency_count: dict[str, int] = {} for dep in dependencies: predecessor_id = str(getattr(dep, 'predecessor_id', '')) if predecessor_id: @@ -476,12 +474,12 @@ def _analyze_external_risks(self, project: Any) -> None: def _analyze_dependency_chains( self, project: Any, - tasks: List[Any], - dependencies: List[Any] + tasks: list[Any], + dependencies: list[Any] ) -> None: """DEEP: Long chain analysis.""" # Build dependency graph - graph: Dict[str, List[str]] = {} + graph: dict[str, list[str]] = {} for dep in dependencies: pred = str(getattr(dep, 'predecessor_id', '')) succ = str(getattr(dep, 'successor_id', '')) @@ -524,7 +522,7 @@ def find_chain_length(task_id: str, visited: set) -> int: suggested_mitigation="Review if dependencies can be parallelized" ) - def _analyze_duration_patterns(self, tasks: List[Any]) -> None: + def _analyze_duration_patterns(self, tasks: list[Any]) -> None: """DEEP: Pattern matching.""" for task in tasks: task_id = str(getattr(task, 'id', '')) @@ -571,9 +569,9 @@ def _add_risk( probability: int, impact: int, confidence: float, - evidence: List[Evidence], - related_tasks: List[str], - suggested_mitigation: Optional[str] = None + evidence: list[Evidence], + related_tasks: list[str], + suggested_mitigation: str | None = None ) -> None: """Helper to add a risk to the list.""" risk = Risk( @@ -592,19 +590,19 @@ def _add_risk( ) self.risks.append(risk) - def _get_cost_value(self, cost: Any) -> Optional[float]: + def _get_cost_value(self, cost: Any) -> float | None: """Extract numeric value from cost object or number.""" if cost is None: return None - if isinstance(cost, (int, float)): + if isinstance(cost, int | float): return float(cost) if hasattr(cost, 'amount'): amount = getattr(cost, 'amount') - if isinstance(amount, (int, float)): + if isinstance(amount, int | float): return float(amount) return None - def generate_mitigations(self, risks: List[Risk]) -> List[Mitigation]: + def generate_mitigations(self, risks: list[Risk]) -> list[Mitigation]: """Create mitigation strategies.""" mitigations = [] diff --git a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/tools.py b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/tools.py index f40ad58..3c2d336 100644 --- a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/tools.py +++ b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_analyse/tools.py @@ -11,7 +11,7 @@ import logging import uuid from datetime import datetime, timezone -from typing import Any, Dict, List, Optional +from typing import Any from pm_mcp_servers.shared import project_store @@ -28,7 +28,7 @@ # ============================================================================ -async def identify_risks(params: Dict[str, Any]) -> Dict[str, Any]: +async def identify_risks(params: dict[str, Any]) -> dict[str, Any]: """ Identify project risks using AI-powered risk engine. @@ -107,7 +107,7 @@ async def identify_risks(params: Dict[str, Any]) -> Dict[str, Any]: high_count = sum(1 for r in risks if r.severity.value == "high") # Count by category - by_category: Dict[str, int] = {} + by_category: dict[str, int] = {} for risk in risks: cat = risk.category.value by_category[cat] = by_category.get(cat, 0) + 1 @@ -143,7 +143,7 @@ async def identify_risks(params: Dict[str, Any]) -> Dict[str, Any]: # ============================================================================ -async def forecast_completion(params: Dict[str, Any]) -> Dict[str, Any]: +async def forecast_completion(params: dict[str, Any]) -> dict[str, Any]: """ Forecast project completion date using multiple methods. @@ -253,7 +253,7 @@ async def forecast_completion(params: Dict[str, Any]) -> Dict[str, Any]: # ============================================================================ -async def detect_outliers(params: Dict[str, Any]) -> Dict[str, Any]: +async def detect_outliers(params: dict[str, Any]) -> dict[str, Any]: """ Detect anomalies in project data. @@ -340,7 +340,7 @@ async def detect_outliers(params: Dict[str, Any]) -> Dict[str, Any]: critical_count = sum(1 for o in outliers if o.severity.value == "critical") # Count by field - by_field: Dict[str, int] = {} + by_field: dict[str, int] = {} for outlier in outliers: field = outlier.field_name by_field[field] = by_field.get(field, 0) + 1 @@ -375,7 +375,7 @@ async def detect_outliers(params: Dict[str, Any]) -> Dict[str, Any]: # ============================================================================ -async def assess_health(params: Dict[str, Any]) -> Dict[str, Any]: +async def assess_health(params: dict[str, Any]) -> dict[str, Any]: """ Assess multi-dimensional project health. @@ -480,7 +480,7 @@ async def assess_health(params: Dict[str, Any]) -> Dict[str, Any]: # ============================================================================ -async def suggest_mitigations(params: Dict[str, Any]) -> Dict[str, Any]: +async def suggest_mitigations(params: dict[str, Any]) -> dict[str, Any]: """ Generate mitigation strategies for identified risks. @@ -576,7 +576,7 @@ async def suggest_mitigations(params: Dict[str, Any]) -> Dict[str, Any]: high_effectiveness = sum(1 for m in mitigations if m.effectiveness >= 0.75) # Count by strategy - by_strategy: Dict[str, int] = {} + by_strategy: dict[str, int] = {} for mitigation in mitigations: strategy = mitigation.strategy by_strategy[strategy] = by_strategy.get(strategy, 0) + 1 @@ -611,7 +611,7 @@ async def suggest_mitigations(params: Dict[str, Any]) -> Dict[str, Any]: # ============================================================================ -async def compare_baseline(params: Dict[str, Any]) -> Dict[str, Any]: +async def compare_baseline(params: dict[str, Any]) -> dict[str, Any]: """ Compare current project state against baseline. @@ -704,7 +704,7 @@ async def compare_baseline(params: Dict[str, Any]) -> Dict[str, Any]: avg_variance = sum(abs(v.variance_percent) for v in variances) / len(variances) if variances else 0 # Count by field - by_field: Dict[str, int] = {} + by_field: dict[str, int] = {} for variance in variances: field = variance.field_name by_field[field] = by_field.get(field, 0) + 1 diff --git a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_assure/registry.py b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_assure/registry.py new file mode 100644 index 0000000..7d988f5 --- /dev/null +++ b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_assure/registry.py @@ -0,0 +1,63 @@ +"""PM-Assure tool registry for unified server aggregation.""" + +from __future__ import annotations + +from typing import Any + +from mcp.types import TextContent + +from .server import ASSURE_TOOLS as TOOLS # noqa: F401 — re-exported for unified server + +from .server import ( + _analyse_assurance_overhead, + _analyse_override_patterns, + _check_artefact_currency, + _check_confidence_divergence, + _classify_project_domain, + _get_assumption_drift, + _get_cascade_impact, + _get_workflow_history, + _ingest_assumption, + _ingest_lesson, + _log_assurance_activity, + _log_override_decision, + _nista_longitudinal_trend, + _reclassify_from_store, + _recommend_review_schedule, + _review_action_status, + _run_assurance_workflow, + _search_lessons, + _track_review_actions, + _validate_assumption, +) + +_DISPATCH = { + "nista_longitudinal_trend": _nista_longitudinal_trend, + "track_review_actions": _track_review_actions, + "review_action_status": _review_action_status, + "check_artefact_currency": _check_artefact_currency, + "check_confidence_divergence": _check_confidence_divergence, + "recommend_review_schedule": _recommend_review_schedule, + "log_override_decision": _log_override_decision, + "analyse_override_patterns": _analyse_override_patterns, + "ingest_lesson": _ingest_lesson, + "search_lessons": _search_lessons, + "log_assurance_activity": _log_assurance_activity, + "analyse_assurance_overhead": _analyse_assurance_overhead, + "run_assurance_workflow": _run_assurance_workflow, + "get_workflow_history": _get_workflow_history, + "classify_project_domain": _classify_project_domain, + "reclassify_from_store": _reclassify_from_store, + "ingest_assumption": _ingest_assumption, + "validate_assumption": _validate_assumption, + "get_assumption_drift": _get_assumption_drift, + "get_cascade_impact": _get_cascade_impact, +} + + +async def dispatch(name: str, arguments: Any) -> list[TextContent]: + """Dispatch a pm-assure tool call. Handlers already return list[TextContent].""" + handler = _DISPATCH.get(name) + if handler is not None: + return await handler(arguments) + return [TextContent(type="text", text=f"Unknown tool: {name}")] diff --git a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_assure/server.py b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_assure/server.py index 3a270f9..7d2a496 100644 --- a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_assure/server.py +++ b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_assure/server.py @@ -23,10 +23,7 @@ # --------------------------------------------------------------------------- -@app.list_tools() -async def list_tools() -> list[Tool]: - """List available PM Assure tools.""" - return [ +ASSURE_TOOLS: list[Tool] = [ Tool( name="nista_longitudinal_trend", description=( @@ -830,6 +827,12 @@ async def list_tools() -> list[Tool]: ] +@app.list_tools() +async def list_tools() -> list[Tool]: + """List available PM Assure tools.""" + return ASSURE_TOOLS + + # --------------------------------------------------------------------------- # Tool dispatch # --------------------------------------------------------------------------- @@ -944,7 +947,6 @@ async def _track_review_actions( """Extract and persist review actions from project review text.""" try: import anthropic as _anthropic_module # noqa: F401 — import check - from agent_planning.confidence import ConfidenceExtractor from agent_planning.providers.anthropic import AnthropicProvider from pm_data_tools.assurance import FindingAnalyzer @@ -1166,7 +1168,6 @@ async def _log_override_decision( from pm_data_tools.assurance.overrides import ( OverrideDecision, OverrideDecisionLogger, - OverrideOutcome, OverrideType, ) from pm_data_tools.db.store import AssuranceStore diff --git a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_data/registry.py b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_data/registry.py new file mode 100644 index 0000000..ccbb9f9 --- /dev/null +++ b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_data/registry.py @@ -0,0 +1,200 @@ +"""PM-Data tool registry for unified server aggregation.""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from mcp.types import TextContent, Tool + +from .tools import ( + convert_format, + get_critical_path, + get_dependencies, + get_project_summary, + load_project, + query_tasks, +) + +logger = logging.getLogger(__name__) + +# Shared in-memory project store (session-scoped) +projects: dict[str, Any] = {} + +TOOLS: list[Tool] = [ + Tool( + name="load_project", + description="Load a project file from various PM tools (MS Project, P6, Jira, Monday, Asana, Smartsheet, GMPP, NISTA) and return canonical representation", + inputSchema={ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to project file", + }, + "format": { + "type": "string", + "enum": ["auto", "mspdi", "p6_xer", "jira", "monday", "asana", "smartsheet", "gmpp", "nista"], + "default": "auto", + "description": "File format (auto-detect by default)", + }, + }, + "required": ["file_path"], + }, + ), + Tool( + name="query_tasks", + description="Query tasks with optional filters (status, critical path, milestones, assignee, dates)", + inputSchema={ + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Project identifier from load_project", + }, + "filters": { + "type": "object", + "properties": { + "status": { + "type": "array", + "items": {"type": "string"}, + "description": "Filter by task status", + }, + "is_critical": { + "type": "boolean", + "description": "Filter critical path tasks", + }, + "is_milestone": { + "type": "boolean", + "description": "Filter milestone tasks", + }, + "assignee": { + "type": "string", + "description": "Filter by resource assignment", + }, + "start_after": { + "type": "string", + "format": "date", + "description": "Filter tasks starting after date (YYYY-MM-DD)", + }, + "end_before": { + "type": "string", + "format": "date", + "description": "Filter tasks ending before date (YYYY-MM-DD)", + }, + }, + }, + "limit": { + "type": "integer", + "default": 100, + "description": "Maximum number of tasks to return", + }, + }, + "required": ["project_id"], + }, + ), + Tool( + name="get_critical_path", + description="Get critical path tasks and optionally near-critical tasks (within 5 days float)", + inputSchema={ + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Project identifier", + }, + "include_near_critical": { + "type": "boolean", + "default": False, + "description": "Include tasks with <=5 days float", + }, + }, + "required": ["project_id"], + }, + ), + Tool( + name="get_dependencies", + description="Get task dependencies (predecessors/successors) for dependency analysis", + inputSchema={ + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Project identifier", + }, + "task_id": { + "type": "string", + "description": "Specific task ID (optional, returns all if omitted)", + }, + "direction": { + "type": "string", + "enum": ["predecessors", "successors", "both"], + "default": "both", + "description": "Direction of dependencies to return", + }, + }, + "required": ["project_id"], + }, + ), + Tool( + name="convert_format", + description="Convert project to different format (MSPDI XML, JSON, NISTA)", + inputSchema={ + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Project identifier", + }, + "target_format": { + "type": "string", + "enum": ["mspdi", "json", "nista_json", "nista_csv"], + "description": "Target format for conversion", + }, + }, + "required": ["project_id", "target_format"], + }, + ), + Tool( + name="get_project_summary", + description="Get high-level project summary (task counts, dates, critical path length, source format)", + inputSchema={ + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Project identifier", + }, + }, + "required": ["project_id"], + }, + ), +] + +_TOOL_NAMES = {t.name for t in TOOLS} + + +async def dispatch(name: str, arguments: Any) -> list[TextContent]: + """Dispatch a pm-data tool call and return normalised TextContent.""" + try: + if name == "load_project": + result = await load_project(arguments, projects) + elif name == "query_tasks": + result = await query_tasks(arguments, projects) + elif name == "get_critical_path": + result = await get_critical_path(arguments, projects) + elif name == "get_dependencies": + result = await get_dependencies(arguments, projects) + elif name == "convert_format": + result = await convert_format(arguments, projects) + elif name == "get_project_summary": + result = await get_project_summary(arguments, projects) + else: + result = {"error": {"code": "UNKNOWN_TOOL", "message": f"Unknown tool: {name}"}} + + return [TextContent(type="text", text=json.dumps(result, indent=2, default=str))] + + except Exception as exc: + logger.error("Error executing tool %s: %s", name, exc, exc_info=True) + return [TextContent(type="text", text=json.dumps({"error": {"code": "TOOL_EXECUTION_ERROR", "message": str(exc)}}, indent=2))] diff --git a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_data/server.py b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_data/server.py index f51aac7..676b658 100644 --- a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_data/server.py +++ b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_data/server.py @@ -10,15 +10,15 @@ from mcp.server import Server from mcp.server.stdio import stdio_server -from mcp.types import Tool, TextContent +from mcp.types import TextContent, Tool from .tools import ( - load_project, - query_tasks, + convert_format, get_critical_path, get_dependencies, - convert_format, get_project_summary, + load_project, + query_tasks, ) # Configure logging diff --git a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_data/tools.py b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_data/tools.py index be25cb9..62481bd 100644 --- a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_data/tools.py +++ b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_data/tools.py @@ -11,16 +11,15 @@ import logging import uuid from dataclasses import dataclass, field -from datetime import date, datetime +from datetime import date from pathlib import Path -from typing import Any, Optional # Real imports from pm-data-tools try: - from pm_data_tools.parsers import detect_format, create_parser - from pm_data_tools.models import Project from pm_data_tools.exceptions import ParseError, UnsupportedFormatError from pm_data_tools.exporters import create_exporter + from pm_data_tools.models import Project + from pm_data_tools.parsers import create_parser, detect_format HAS_PM_DATA_TOOLS = True except ImportError: HAS_PM_DATA_TOOLS = False @@ -38,7 +37,7 @@ def add(self, project_id: str, project: Project) -> None: """Store a project.""" self._projects[project_id] = project - def get(self, project_id: str) -> Optional[Project]: + def get(self, project_id: str) -> Project | None: """Retrieve a project by ID.""" return self._projects.get(project_id) @@ -62,7 +61,7 @@ def remove(self, project_id: str) -> bool: _store = ProjectStore() -def _serialize_date(d: Optional[date]) -> Optional[str]: +def _serialize_date(d: date | None) -> str | None: """Safely serialize date to ISO format.""" if d is None: return None @@ -74,7 +73,7 @@ def _serialize_date(d: Optional[date]) -> Optional[str]: async def load_project(arguments: dict, store: ProjectStore = _store) -> dict: """Load project from file using real pm-data-tools parsers. - + Supports automatic format detection or explicit format specification. Handles all 8 pm-data-tools formats with comprehensive error handling. """ diff --git a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_nista/registry.py b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_nista/registry.py new file mode 100644 index 0000000..568e321 --- /dev/null +++ b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_nista/registry.py @@ -0,0 +1,158 @@ +"""PM-NISTA tool registry for unified server aggregation.""" + +from __future__ import annotations + +from typing import Any + +from mcp.types import TextContent, Tool + +# Import handler functions from server module. +from .server import ( + _fetch_nista_metadata, + _generate_gmpp_report, + _generate_narrative, + _submit_to_nista, + _validate_gmpp_report, +) + +TOOLS: list[Tool] = [ + Tool( + name="generate_gmpp_report", + description="Generate complete GMPP quarterly report from project data file", + inputSchema={ + "type": "object", + "properties": { + "project_file": { + "type": "string", + "description": "Path to project file (MS Project, GMPP CSV, etc.)", + }, + "quarter": { + "type": "string", + "enum": ["Q1", "Q2", "Q3", "Q4"], + "description": "Quarter period (Q1-Q4)", + }, + "financial_year": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}$", + "description": "Financial year (format: 2025-26)", + }, + "generate_narratives": { + "type": "boolean", + "description": "Generate AI narratives (requires ANTHROPIC_API_KEY)", + "default": True, + }, + }, + "required": ["project_file", "quarter", "financial_year"], + }, + ), + Tool( + name="generate_narrative", + description="Generate AI-powered narrative with confidence scoring", + inputSchema={ + "type": "object", + "properties": { + "narrative_type": { + "type": "string", + "enum": ["dca", "cost", "schedule", "benefits", "risk"], + "description": "Type of narrative to generate", + }, + "project_context": { + "type": "object", + "description": "Project context data (project_name, dca_rating, costs, etc.)", + "properties": { + "project_name": {"type": "string"}, + "department": {"type": "string"}, + "dca_rating": {"type": "string"}, + "baseline_cost": {"type": "number"}, + "forecast_cost": {"type": "number"}, + "cost_variance_percent": {"type": "number"}, + }, + "required": ["project_name"], + }, + }, + "required": ["narrative_type", "project_context"], + }, + ), + Tool( + name="submit_to_nista", + description="Submit GMPP quarterly return to NISTA API (sandbox or production)", + inputSchema={ + "type": "object", + "properties": { + "report_file": { + "type": "string", + "description": "Path to quarterly report JSON file", + }, + "project_id": { + "type": "string", + "description": "Project identifier", + }, + "environment": { + "type": "string", + "enum": ["sandbox", "production"], + "description": "NISTA environment", + "default": "sandbox", + }, + }, + "required": ["report_file", "project_id"], + }, + ), + Tool( + name="fetch_nista_metadata", + description="Fetch project metadata from NISTA master registry", + inputSchema={ + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "NISTA project code or internal project ID", + }, + "environment": { + "type": "string", + "enum": ["sandbox", "production"], + "description": "NISTA environment", + "default": "sandbox", + }, + }, + "required": ["project_id"], + }, + ), + Tool( + name="validate_gmpp_report", + description="Validate GMPP quarterly report against NISTA requirements", + inputSchema={ + "type": "object", + "properties": { + "report_file": { + "type": "string", + "description": "Path to quarterly report JSON file", + }, + "strictness": { + "type": "string", + "enum": ["LENIENT", "STANDARD", "STRICT"], + "description": "Validation strictness level", + "default": "STANDARD", + }, + }, + "required": ["report_file"], + }, + ), +] + +_TOOL_NAMES = {t.name for t in TOOLS} + + +async def dispatch(name: str, arguments: Any) -> list[TextContent]: + """Dispatch a pm-nista tool call. Handlers already return list[TextContent].""" + if name == "generate_gmpp_report": + return await _generate_gmpp_report(arguments) + elif name == "generate_narrative": + return await _generate_narrative(arguments) + elif name == "submit_to_nista": + return await _submit_to_nista(arguments) + elif name == "fetch_nista_metadata": + return await _fetch_nista_metadata(arguments) + elif name == "validate_gmpp_report": + return await _validate_gmpp_report(arguments) + else: + return [TextContent(type="text", text=f"Unknown tool: {name}")] diff --git a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_nista/server.py b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_nista/server.py index 2519631..4634309 100644 --- a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_nista/server.py +++ b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_nista/server.py @@ -5,16 +5,14 @@ """ import asyncio -import os import json -from typing import Any +import os from mcp.server import Server -from mcp.types import Tool, TextContent - +from mcp.types import TextContent, Tool from pm_data_tools import parse_project from pm_data_tools.gmpp import GMPPDataAggregator, NarrativeGenerator -from pm_data_tools.integrations.nista import NISTAAuthClient, NISTAAPIClient, NISTAAuthConfig +from pm_data_tools.integrations.nista import NISTAAPIClient, NISTAAuthClient, NISTAAuthConfig app = Server("pm-nista-server") @@ -287,7 +285,7 @@ async def _submit_to_nista(arguments: dict) -> list[TextContent]: environment = arguments.get("environment", "sandbox") # Load report - with open(report_file, "r") as f: + with open(report_file) as f: report_data = json.load(f) from pm_data_tools.gmpp.models import QuarterlyReport @@ -417,7 +415,7 @@ async def _validate_gmpp_report(arguments: dict) -> list[TextContent]: strictness = arguments.get("strictness", "STANDARD") # Load report - with open(report_file, "r") as f: + with open(report_file) as f: report_data = json.load(f) # Validate using NISTA validator diff --git a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_validate/__init__.py b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_validate/__init__.py index adb5489..fedaf0b 100644 --- a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_validate/__init__.py +++ b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_validate/__init__.py @@ -7,10 +7,10 @@ """ from pm_mcp_servers.pm_validate.tools import ( - validate_structure, - validate_semantic, - validate_nista, validate_custom, + validate_nista, + validate_semantic, + validate_structure, ) __all__ = [ diff --git a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_validate/registry.py b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_validate/registry.py new file mode 100644 index 0000000..ccf5a97 --- /dev/null +++ b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_validate/registry.py @@ -0,0 +1,132 @@ +"""PM-Validate tool registry for unified server aggregation.""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from mcp.types import TextContent, Tool + +from pm_mcp_servers.pm_validate.tools import ( + validate_custom, + validate_nista, + validate_semantic, + validate_structure, +) + +logger = logging.getLogger(__name__) + +TOOLS: list[Tool] = [ + Tool( + name="validate_structure", + description="Validate project data structure and integrity. Checks for orphan tasks, circular dependencies, invalid references, duplicate IDs, hierarchy integrity, date consistency, and assignment validity.", + inputSchema={ + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Project ID to validate", + }, + "checks": { + "type": "array", + "items": {"type": "string"}, + "description": "Specific checks to run, or ['all']", + "default": ["all"], + }, + }, + "required": ["project_id"], + }, + ), + Tool( + name="validate_semantic", + description="Validate business rules and scheduling logic. Checks schedule logic, negative float, resource overallocation, constraint violations, cost consistency, baseline variance, and milestone dates.", + inputSchema={ + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Project ID to validate", + }, + "rules": { + "type": "array", + "items": {"type": "string"}, + "description": "Specific rules to check, or ['all']", + "default": ["all"], + }, + "thresholds": { + "type": "object", + "description": "Custom threshold values", + }, + }, + "required": ["project_id"], + }, + ), + Tool( + name="validate_nista", + description="Validate against NISTA Programme and Project Data Standard. Essential for UK government project compliance. Checks required fields, DCA values, and formats per NISTA specification.", + inputSchema={ + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Project ID to validate", + }, + "schema_version": { + "type": "string", + "description": "NISTA schema version", + "default": "1.0", + }, + "strictness": { + "type": "string", + "enum": ["lenient", "standard", "strict"], + "description": "Validation strictness level", + "default": "standard", + }, + }, + "required": ["project_id"], + }, + ), + Tool( + name="validate_custom", + description="Run custom validation rules defined by the user. Supports multiple condition types for organization-specific requirements.", + inputSchema={ + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Project ID to validate", + }, + "rules": { + "type": "array", + "items": {"type": "object"}, + "description": "List of custom rule definitions", + }, + }, + "required": ["project_id", "rules"], + }, + ), +] + +_TOOL_NAMES = {t.name for t in TOOLS} + + +async def dispatch(name: str, arguments: Any) -> list[TextContent]: + """Dispatch a pm-validate tool call and return normalised TextContent.""" + try: + if name == "validate_structure": + result = await validate_structure(arguments) + elif name == "validate_semantic": + result = await validate_semantic(arguments) + elif name == "validate_nista": + result = await validate_nista(arguments) + elif name == "validate_custom": + result = await validate_custom(arguments) + else: + return [TextContent(type="text", text=f"Unknown tool: {name}")] + + return [TextContent(type="text", text=json.dumps(result, indent=2, default=str))] + + except Exception as exc: + logger.exception("Error in %s: %s", name, exc) + return [TextContent(type="text", text=f"Error: {exc}")] diff --git a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_validate/server.py b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_validate/server.py index 3532b8a..a4c114a 100644 --- a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_validate/server.py +++ b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_validate/server.py @@ -8,14 +8,15 @@ import asyncio import logging + from mcp.server import Server -from mcp.types import Tool, TextContent +from mcp.types import TextContent, Tool from pm_mcp_servers.pm_validate.tools import ( - validate_structure, - validate_semantic, - validate_nista, validate_custom, + validate_nista, + validate_semantic, + validate_structure, ) logger = logging.getLogger(__name__) @@ -133,10 +134,10 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: result = await validate_custom(arguments) else: return [TextContent(type="text", text=f"Unknown tool: {name}")] - + import json return [TextContent(type="text", text=json.dumps(result, indent=2, default=str))] - + except Exception as e: logger.exception(f"Error in {name}: {e}") return [TextContent(type="text", text=f"Error: {str(e)}")] @@ -145,7 +146,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: async def main(): """Run the MCP server.""" from mcp.server.stdio import stdio_server - + async with stdio_server() as (read_stream, write_stream): await app.run( read_stream, diff --git a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_validate/tools.py b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_validate/tools.py index e75fd9e..af3cf9f 100644 --- a/packages/pm-mcp-servers/src/pm_mcp_servers/pm_validate/tools.py +++ b/packages/pm-mcp-servers/src/pm_mcp_servers/pm_validate/tools.py @@ -12,11 +12,10 @@ from __future__ import annotations import logging -import re from dataclasses import dataclass -from datetime import date, timedelta +from datetime import date from enum import Enum -from typing import Any, Callable, Optional +from typing import Any logger = logging.getLogger(__name__) @@ -43,10 +42,10 @@ class ValidationIssue: severity: Severity code: str message: str - location: Optional[str] = None - field: Optional[str] = None - suggestion: Optional[str] = None - + location: str | None = None + field: str | None = None + suggestion: str | None = None + def to_dict(self) -> dict[str, Any]: """Convert to dictionary for JSON serialization.""" return { @@ -76,7 +75,7 @@ def _make_result(valid: bool, issues: list[ValidationIssue], **kwargs) -> dict[s errors = [i for i in issues if i.severity == Severity.ERROR] warnings = [i for i in issues if i.severity == Severity.WARNING] info = [i for i in issues if i.severity == Severity.INFO] - + result = { "valid": valid, "error_count": len(errors), @@ -97,24 +96,24 @@ async def validate_structure(arguments: dict[str, Any]) -> dict[str, Any]: '''Validate project data structure and integrity.''' project_id = arguments.get("project_id") checks = arguments.get("checks", ["all"]) - + if not project_id: return _make_error("MISSING_PARAMETER", "project_id is required") - + project = _get_project(project_id) if not project: return _make_error("PROJECT_NOT_FOUND", f"Project {project_id} not found") - + issues: list[ValidationIssue] = [] checks_run: list[str] = [] run_all = "all" in checks - + tasks = project.tasks or [] resources = project.resources or [] dependencies = project.dependencies or [] task_ids = {t.id for t in tasks} - resource_ids = {r.id for r in resources} - + {r.id for r in resources} + # Check 1: Orphan tasks if run_all or "orphan_tasks" in checks: checks_run.append("orphan_tasks") @@ -127,7 +126,7 @@ async def validate_structure(arguments: dict[str, Any]) -> dict[str, Any]: message=f"Task '{task.name}' references missing parent '{parent_id}'", location=f"task:{task.id}", )) - + # Check 2: Circular dependencies if run_all or "circular_dependencies" in checks: checks_run.append("circular_dependencies") @@ -135,7 +134,7 @@ async def validate_structure(arguments: dict[str, Any]) -> dict[str, Any]: for dep in dependencies: if dep.predecessor_id in graph: graph[dep.predecessor_id].append(dep.successor_id) - + def has_cycle(node: str, visited: set, rec_stack: set) -> bool: visited.add(node) rec_stack.add(node) @@ -147,7 +146,7 @@ def has_cycle(node: str, visited: set, rec_stack: set) -> bool: return True rec_stack.remove(node) return False - + visited_global = set() for task_id in graph: if task_id not in visited_global: @@ -159,7 +158,7 @@ def has_cycle(node: str, visited: set, rec_stack: set) -> bool: location="dependencies", )) break - + # Check 3: Invalid references if run_all or "invalid_references" in checks: checks_run.append("invalid_references") @@ -178,7 +177,7 @@ def has_cycle(node: str, visited: set, rec_stack: set) -> bool: message=f"Dependency references missing successor '{dep.successor_id}'", location=f"dependency:{dep.predecessor_id}->{dep.successor_id}", )) - + # Check 4: Duplicate IDs if run_all or "duplicate_ids" in checks: checks_run.append("duplicate_ids") @@ -192,7 +191,7 @@ def has_cycle(node: str, visited: set, rec_stack: set) -> bool: location=f"task:{task.id}", )) seen_ids.add(task.id) - + # Check 5: Date consistency if run_all or "date_consistency" in checks: checks_run.append("date_consistency") @@ -204,32 +203,32 @@ def has_cycle(node: str, visited: set, rec_stack: set) -> bool: message=f"Task '{task.name}' starts after it finishes", location=f"task:{task.id}", )) - + errors = [i for i in issues if i.severity == Severity.ERROR] return _make_result(valid=len(errors) == 0, issues=issues, checks_run=checks_run) # ============================================================================ -# SEMANTIC VALIDATION +# SEMANTIC VALIDATION # ============================================================================ async def validate_semantic(arguments: dict[str, Any]) -> dict[str, Any]: """Validate business rules and scheduling logic.""" project_id = arguments.get("project_id") rules = arguments.get("rules", ["all"]) - + if not project_id: return _make_error("MISSING_PARAMETER", "project_id is required") - + project = _get_project(project_id) if not project: return _make_error("PROJECT_NOT_FOUND", f"Project {project_id} not found") - + issues: list[ValidationIssue] = [] rules_run: list[str] = [] run_all = "all" in rules tasks = project.tasks or [] - + # Rule: Negative float if run_all or "negative_float" in rules: rules_run.append("negative_float") @@ -242,7 +241,7 @@ async def validate_semantic(arguments: dict[str, Any]) -> dict[str, Any]: message=f"Task {repr(task.name)} has negative float ({total_float} days)", location=f"task:{task.id}", )) - + # Rule: Overdue milestones if run_all or "milestone_dates" in rules: rules_run.append("milestone_dates") @@ -256,7 +255,7 @@ async def validate_semantic(arguments: dict[str, Any]) -> dict[str, Any]: message=f"Milestone {repr(task.name)} is {days_overdue} days overdue", location=f"task:{task.id}", )) - + errors = [i for i in issues if i.severity == Severity.ERROR] return _make_result(valid=len(errors) == 0, issues=issues, rules_checked=rules_run) @@ -280,18 +279,18 @@ async def validate_nista(arguments: dict[str, Any]) -> dict[str, Any]: """Validate against NISTA standard.""" project_id = arguments.get("project_id") strictness = arguments.get("strictness", "standard") - + if not project_id: return _make_error("MISSING_PARAMETER", "project_id is required") - + project = _get_project(project_id) if not project: return _make_error("PROJECT_NOT_FOUND", f"Project {project_id} not found") - + issues: list[ValidationIssue] = [] required = list(NISTA_REQUIRED_FIELDS) required_present = 0 - + for field_name, field_desc in required: value = getattr(project, field_name, None) if value is None or value == "": @@ -303,7 +302,7 @@ async def validate_nista(arguments: dict[str, Any]) -> dict[str, Any]: )) else: required_present += 1 - + # Validate DCA dca = getattr(project, "delivery_confidence_assessment", None) if dca and dca.lower().replace("-", "_") not in VALID_DCA_VALUES: @@ -313,10 +312,10 @@ async def validate_nista(arguments: dict[str, Any]) -> dict[str, Any]: message=f"Invalid DCA value {repr(dca)}", field="delivery_confidence_assessment", )) - + compliance = (required_present / len(required) * 100) if required else 0 errors = [i for i in issues if i.severity == Severity.ERROR] - + return { "compliant": len(errors) == 0, "compliance_score": round(compliance, 1), @@ -337,40 +336,40 @@ async def validate_custom(arguments: dict[str, Any]) -> dict[str, Any]: """Run custom validation rules.""" project_id = arguments.get("project_id") rules = arguments.get("rules", []) - + if not project_id: return _make_error("MISSING_PARAMETER", "project_id is required") if not rules: return _make_error("MISSING_PARAMETER", "rules is required") - + project = _get_project(project_id) if not project: return _make_error("PROJECT_NOT_FOUND", f"Project {project_id} not found") - + issues: list[ValidationIssue] = [] rules_passed = 0 rules_failed = 0 - + for rule in rules: rule_name = rule.get("name", "Unnamed") field = rule.get("field") condition = rule.get("condition") value = rule.get("value") severity = Severity(rule.get("severity", "error")) - + if not field or not condition: continue - + field_value = getattr(project, field, None) rule_passed = False - + if condition == "required": rule_passed = field_value is not None and field_value != "" elif condition == "equals": rule_passed = field_value == value elif condition == "in_list": rule_passed = field_value in (value or []) - + if rule_passed: rules_passed += 1 else: @@ -381,7 +380,7 @@ async def validate_custom(arguments: dict[str, Any]) -> dict[str, Any]: message=f"Rule {repr(rule_name)} failed", field=field, )) - + errors = [i for i in issues if i.severity == Severity.ERROR] return { "valid": len(errors) == 0, diff --git a/packages/pm-mcp-servers/src/pm_mcp_servers/shared.py b/packages/pm-mcp-servers/src/pm_mcp_servers/shared.py index 4d7c215..c6071ec 100644 --- a/packages/pm-mcp-servers/src/pm_mcp_servers/shared.py +++ b/packages/pm-mcp-servers/src/pm_mcp_servers/shared.py @@ -7,7 +7,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Optional +from typing import Any @dataclass @@ -19,7 +19,7 @@ def add(self, project_id: str, project: Any) -> None: """Store a project.""" self._projects[project_id] = project - def get(self, project_id: str) -> Optional[Any]: + def get(self, project_id: str) -> Any | None: """Retrieve a project by ID.""" return self._projects.get(project_id) diff --git a/packages/pm-mcp-servers/tests/pm_analyse/_generate_all_tests.py b/packages/pm-mcp-servers/tests/pm_analyse/_generate_all_tests.py index 70f6ad2..03d41f0 100644 --- a/packages/pm-mcp-servers/tests/pm_analyse/_generate_all_tests.py +++ b/packages/pm-mcp-servers/tests/pm_analyse/_generate_all_tests.py @@ -24,15 +24,15 @@ def write_file(filename, content): try: from test_content_defs import ( CONFTEST_CONTENT, + TEST_ANALYZERS_CONTENT, + TEST_FORECASTERS_CONTENT, + TEST_INTEGRATION_CONTENT, TEST_MODELS_CONTENT, TEST_RISK_ENGINE_CONTENT, - TEST_FORECASTERS_CONTENT, - TEST_ANALYZERS_CONTENT, - TEST_TOOLS_CONTENT, TEST_SERVER_CONTENT, - TEST_INTEGRATION_CONTENT + TEST_TOOLS_CONTENT, ) - + # Generate all files write_file('conftest.py', CONFTEST_CONTENT) write_file('test_models.py', TEST_MODELS_CONTENT) @@ -42,14 +42,14 @@ def write_file(filename, content): write_file('test_tools.py', TEST_TOOLS_CONTENT) write_file('test_server.py', TEST_SERVER_CONTENT) write_file('test_integration.py', TEST_INTEGRATION_CONTENT) - + print("=" * 70) print("All test files generated successfully!") - + except ImportError: print("ERROR: test_content_defs.py not found") print("Creating inline instead...") - + # We'll generate files with minimal content first # Then expand them iteratively - + diff --git a/packages/pm-mcp-servers/tests/pm_analyse/conftest.py b/packages/pm-mcp-servers/tests/pm_analyse/conftest.py index 24b2a7b..302699f 100644 --- a/packages/pm-mcp-servers/tests/pm_analyse/conftest.py +++ b/packages/pm-mcp-servers/tests/pm_analyse/conftest.py @@ -5,8 +5,7 @@ all analysis modules. """ -from datetime import date, datetime, timedelta -from typing import Any, Dict, List +from datetime import date, timedelta import pytest diff --git a/packages/pm-mcp-servers/tests/pm_analyse/test_analyzers.py b/packages/pm-mcp-servers/tests/pm_analyse/test_analyzers.py index 03e7334..8ab73fc 100644 --- a/packages/pm-mcp-servers/tests/pm_analyse/test_analyzers.py +++ b/packages/pm-mcp-servers/tests/pm_analyse/test_analyzers.py @@ -7,8 +7,6 @@ from datetime import date, timedelta -import pytest - from pm_mcp_servers.pm_analyse.analyzers import ( BaselineComparator, HealthAnalyzer, @@ -21,7 +19,7 @@ ) # Import mock classes from conftest -from .conftest import MockProject, MockTask, MockResource, MockCost +from .conftest import MockCost, MockProject, MockResource, MockTask class TestOutlierDetector: @@ -50,7 +48,6 @@ def test_detect_simple_project(self, simple_task): def test_detect_duration_outliers_long_task(self, long_duration_task): """Test detection of tasks with unusually long duration.""" - from .conftest import MockProject project = MockProject(tasks=[long_duration_task]) detector = OutlierDetector() outliers = detector.detect(project, focus_areas=["duration"]) @@ -60,7 +57,6 @@ def test_detect_duration_outliers_long_task(self, long_duration_task): def test_detect_progress_outliers_stuck_task(self, stuck_task): """Test detection of stuck tasks.""" - from .conftest import MockProject project = MockProject(tasks=[stuck_task]) detector = OutlierDetector() outliers = detector.detect(project, focus_areas=["progress"]) @@ -70,7 +66,6 @@ def test_detect_progress_outliers_stuck_task(self, stuck_task): def test_detect_float_outliers_negative_float(self): """Test detection of negative float (critical).""" - from .conftest import MockProject, MockTask task = MockTask( id='task-1', name='Critical Float Task', @@ -86,7 +81,6 @@ def test_detect_float_outliers_negative_float(self): def test_detect_float_outliers_excessive(self): """Test detection of excessive float on non-critical tasks.""" - from .conftest import MockProject, MockTask task = MockTask( id='task-1', name='Loose Task', @@ -100,7 +94,6 @@ def test_detect_float_outliers_excessive(self): def test_detect_date_outliers_invalid_dates(self): """Test detection of impossible dates (finish before start).""" - from .conftest import MockProject, MockTask task = MockTask( id='task-1', name='Invalid Date Task', @@ -115,7 +108,6 @@ def test_detect_date_outliers_invalid_dates(self): def test_detect_date_outliers_overdue_incomplete(self): """Test detection of overdue incomplete tasks.""" - from .conftest import MockProject, MockTask task = MockTask( id='task-1', name='Overdue Task', @@ -158,7 +150,6 @@ def test_detect_focus_multiple_areas(self, complex_project): def test_detect_summary_tasks_excluded(self): """Test that summary tasks are excluded from detection.""" - from .conftest import MockProject, MockTask summary = MockTask( id='summary-1', name='Summary Task', @@ -173,7 +164,6 @@ def test_detect_summary_tasks_excluded(self): def test_detect_outlier_has_evidence(self): """Test that detected outliers include evidence.""" - from .conftest import MockProject, MockTask task = MockTask( id='task-1', name='Test Task', @@ -187,7 +177,6 @@ def test_detect_outlier_has_evidence(self): def test_detect_outlier_has_confidence(self): """Test that detected outliers have confidence scores.""" - from .conftest import MockProject, MockTask task = MockTask( id='task-1', name='Test Task', @@ -201,7 +190,6 @@ def test_detect_outlier_has_confidence(self): def test_detect_date_outlier_100_percent_future_finish(self): """Test detection of impossible progress (100% but future finish).""" - from .conftest import MockProject, MockTask task = MockTask( id='task-1', name='Impossible Progress Task', @@ -299,7 +287,6 @@ def test_assess_custom_weights(self, basic_project): def test_assess_healthy_project(self): """Test assessment recognizes healthy project.""" - from .conftest import MockProject, MockTask, MockResource, MockCost tasks = [ MockTask(id='t1', name='Task 1', percent_complete=80), MockTask(id='t2', name='Task 2', percent_complete=90) @@ -399,7 +386,6 @@ def test_compare_no_baseline_data(self, basic_project): def test_compare_with_baseline_variance(self, task_with_baseline): """Test comparison detects baseline variance.""" - from .conftest import MockProject project = MockProject(tasks=[task_with_baseline]) comparator = BaselineComparator() variances = comparator.compare(project) @@ -408,7 +394,6 @@ def test_compare_with_baseline_variance(self, task_with_baseline): def test_compare_variance_attributes(self, task_with_baseline): """Test variance has all required attributes.""" - from .conftest import MockProject project = MockProject(tasks=[task_with_baseline]) comparator = BaselineComparator() variances = comparator.compare(project) @@ -424,7 +409,6 @@ def test_compare_variance_attributes(self, task_with_baseline): def test_compare_with_threshold(self, task_with_baseline): """Test comparison with variance threshold.""" - from .conftest import MockProject project = MockProject(tasks=[task_with_baseline]) comparator = BaselineComparator() variances_all = comparator.compare(project, threshold=0) @@ -434,7 +418,6 @@ def test_compare_with_threshold(self, task_with_baseline): def test_compare_variance_severity_classification(self, task_with_baseline): """Test variance severity is correctly classified.""" - from .conftest import MockProject project = MockProject(tasks=[task_with_baseline]) comparator = BaselineComparator() variances = comparator.compare(project) @@ -443,7 +426,6 @@ def test_compare_variance_severity_classification(self, task_with_baseline): def test_compare_variance_percentage(self, task_with_baseline): """Test variance includes percentage calculation.""" - from .conftest import MockProject project = MockProject(tasks=[task_with_baseline]) comparator = BaselineComparator() variances = comparator.compare(project) @@ -451,7 +433,6 @@ def test_compare_variance_percentage(self, task_with_baseline): def test_compare_to_dict(self, task_with_baseline): """Test that variance can be serialized to dict.""" - from .conftest import MockProject project = MockProject(tasks=[task_with_baseline]) comparator = BaselineComparator() variances = comparator.compare(project) @@ -464,7 +445,6 @@ def test_compare_to_dict(self, task_with_baseline): def test_compare_multiple_tasks_with_variance(self): """Test comparison with multiple tasks having variance.""" - from .conftest import MockProject, MockTask tasks = [ MockTask( id='task-1', @@ -492,7 +472,6 @@ def test_compare_multiple_tasks_with_variance(self): def test_compare_pulls_in_vs_slips(self): """Test comparison detects both slippage and pull-in.""" - from .conftest import MockProject, MockTask slip_task = MockTask( id='task-slip', name='Slipping Task', @@ -516,7 +495,6 @@ def test_compare_pulls_in_vs_slips(self): def test_compare_summary_tasks_excluded(self): """Test that summary tasks are excluded from comparison.""" - from .conftest import MockProject, MockTask summary = MockTask( id='summary-1', name='Summary Task', diff --git a/packages/pm-mcp-servers/tests/pm_analyse/test_forecasters.py b/packages/pm-mcp-servers/tests/pm_analyse/test_forecasters.py index 4012302..a2f4824 100644 --- a/packages/pm-mcp-servers/tests/pm_analyse/test_forecasters.py +++ b/packages/pm-mcp-servers/tests/pm_analyse/test_forecasters.py @@ -1,21 +1,23 @@ """Tests for PM-Analyse forecasting engine - all 5 forecast methods.""" from datetime import date, timedelta -import pytest + from pm_mcp_servers.pm_analyse.forecasters import ForecastEngine from pm_mcp_servers.pm_analyse.models import AnalysisDepth, ForecastMethod -from .conftest import MockTask, MockProject + +from .conftest import MockProject, MockTask + class TestForecastEngineBasics: def test_engine_initialization(self): engine = ForecastEngine() assert engine is not None - + def test_forecast_returns_forecast_object(self, basic_project): engine = ForecastEngine() forecast = engine.forecast(basic_project) assert hasattr(forecast, 'forecast_date') assert hasattr(forecast, 'confidence') - + def test_forecast_empty_project(self, empty_project): engine = ForecastEngine() forecast = engine.forecast(empty_project) @@ -27,20 +29,20 @@ def test_earned_value_forecast(self, complex_project): forecast = engine.forecast(complex_project, method=ForecastMethod.EARNED_VALUE) assert forecast.method == ForecastMethod.EARNED_VALUE assert forecast.forecast_date is not None - + def test_evm_with_progress(self): tasks = [MockTask(id=f't{i}', start_date=date.today() - timedelta(days=30), finish_date=date.today() + timedelta(days=30), percent_complete=50) for i in range(5)] project = MockProject(tasks=tasks, start_date=date.today() - timedelta(days=30), finish_date=date.today() + timedelta(days=30)) engine = ForecastEngine() forecast = engine.forecast(project, method=ForecastMethod.EARNED_VALUE) assert any("SPI" in f for f in forecast.factors) - + def test_evm_confidence_interval(self, complex_project): engine = ForecastEngine() forecast = engine.forecast(complex_project, method=ForecastMethod.EARNED_VALUE) assert len(forecast.confidence_interval) == 2 assert forecast.confidence_interval[0] <= forecast.forecast_date <= forecast.confidence_interval[1] - + def test_evm_variance_calculation(self, complex_project): engine = ForecastEngine() forecast = engine.forecast(complex_project, method=ForecastMethod.EARNED_VALUE) @@ -51,17 +53,17 @@ def test_monte_carlo_forecast(self, complex_project): engine = ForecastEngine() forecast = engine.forecast(complex_project, method=ForecastMethod.MONTE_CARLO) assert forecast.method == ForecastMethod.MONTE_CARLO - + def test_monte_carlo_iterations_quick(self, basic_project): engine = ForecastEngine() forecast = engine.forecast(basic_project, method=ForecastMethod.MONTE_CARLO, depth=AnalysisDepth.QUICK) assert forecast.forecast_date is not None - + def test_monte_carlo_iterations_deep(self, basic_project): engine = ForecastEngine() forecast = engine.forecast(basic_project, method=ForecastMethod.MONTE_CARLO, depth=AnalysisDepth.DEEP) assert forecast.forecast_date is not None - + def test_monte_carlo_scenarios(self, basic_project): engine = ForecastEngine() forecast = engine.forecast(basic_project, method=ForecastMethod.MONTE_CARLO) @@ -73,13 +75,13 @@ def test_reference_class_forecast(self, complex_project): engine = ForecastEngine() forecast = engine.forecast(complex_project, method=ForecastMethod.REFERENCE_CLASS) assert forecast.method == ForecastMethod.REFERENCE_CLASS - + def test_reference_class_it_projects(self): project = MockProject(start_date=date.today(), finish_date=date.today() + timedelta(days=100), project_type='it') engine = ForecastEngine() forecast = engine.forecast(project, method=ForecastMethod.REFERENCE_CLASS) assert forecast.variance_days > 0 - + def test_reference_class_infrastructure(self): project = MockProject(start_date=date.today(), finish_date=date.today() + timedelta(days=100), project_type='infrastructure') engine = ForecastEngine() @@ -91,7 +93,7 @@ def test_simple_extrapolation_forecast(self, complex_project): engine = ForecastEngine() forecast = engine.forecast(complex_project, method=ForecastMethod.SIMPLE_EXTRAPOLATION) assert forecast.method == ForecastMethod.SIMPLE_EXTRAPOLATION - + def test_extrapolation_with_progress(self): tasks = [MockTask(id='t1', start_date=date.today() - timedelta(days=20), finish_date=date.today() + timedelta(days=80), percent_complete=20)] project = MockProject(tasks=tasks, start_date=date.today() - timedelta(days=20), finish_date=date.today() + timedelta(days=80)) @@ -104,12 +106,12 @@ def test_ensemble_forecast(self, complex_project): engine = ForecastEngine() forecast = engine.forecast(complex_project, method=ForecastMethod.ML_ENSEMBLE) assert forecast.method == ForecastMethod.ML_ENSEMBLE - + def test_ensemble_combines_methods(self, basic_project): engine = ForecastEngine() forecast = engine.forecast(basic_project, method=ForecastMethod.ML_ENSEMBLE) assert len(forecast.evidence) >= 4 - + def test_ensemble_scenarios(self, basic_project): engine = ForecastEngine() forecast = engine.forecast(basic_project, method=ForecastMethod.ML_ENSEMBLE) @@ -121,7 +123,7 @@ def test_confidence_interval_ordering(self, basic_project): forecast = engine.forecast(basic_project) lower, upper = forecast.confidence_interval assert lower <= forecast.forecast_date <= upper - + def test_different_confidence_levels(self, basic_project): engine = ForecastEngine() f80 = engine.forecast(basic_project, confidence_level=0.80) diff --git a/packages/pm-mcp-servers/tests/pm_analyse/test_integration.py b/packages/pm-mcp-servers/tests/pm_analyse/test_integration.py index 5a031e7..ded37f0 100644 --- a/packages/pm-mcp-servers/tests/pm_analyse/test_integration.py +++ b/packages/pm-mcp-servers/tests/pm_analyse/test_integration.py @@ -5,9 +5,7 @@ verifying component interactions and data flow with 10+ test cases. """ -from datetime import date, timedelta -import pytest from pm_mcp_servers.pm_analyse.models import ( HealthStatus, @@ -21,8 +19,8 @@ class TestAnalysisWorkflows: def test_outlier_detection_feeds_health_assessment(self, complex_project): """Test that outlier detection and health assessment work together.""" from pm_mcp_servers.pm_analyse.analyzers import ( - OutlierDetector, HealthAnalyzer, + OutlierDetector, ) detector = OutlierDetector() @@ -49,12 +47,13 @@ def test_risk_identification_and_mitigation_flow(self, complex_project): def test_baseline_comparison_with_health_assessment(self, task_with_baseline): """Test baseline comparison integrated with health assessment.""" - from .conftest import MockProject from pm_mcp_servers.pm_analyse.analyzers import ( BaselineComparator, HealthAnalyzer, ) + from .conftest import MockProject + project = MockProject(tasks=[task_with_baseline]) comparator = BaselineComparator() analyzer = HealthAnalyzer() @@ -75,12 +74,12 @@ def test_baseline_comparison_with_health_assessment(self, task_with_baseline): def test_complete_project_analysis_workflow(self, complex_project): """Test complete analysis workflow on complex project.""" from pm_mcp_servers.pm_analyse.analyzers import ( - OutlierDetector, - HealthAnalyzer, BaselineComparator, + HealthAnalyzer, + OutlierDetector, ) - from pm_mcp_servers.pm_analyse.risk_engine import RiskEngine from pm_mcp_servers.pm_analyse.forecasters import ForecastEngine + from pm_mcp_servers.pm_analyse.risk_engine import RiskEngine # Run all analyses detector = OutlierDetector() @@ -129,8 +128,8 @@ def test_health_assessment_consistency(self, basic_project): def test_multiple_analysis_depths(self, complex_project): """Test analysis with different depth levels.""" - from pm_mcp_servers.pm_analyse.risk_engine import RiskEngine from pm_mcp_servers.pm_analyse.models import AnalysisDepth + from pm_mcp_servers.pm_analyse.risk_engine import RiskEngine engine = RiskEngine() @@ -156,9 +155,10 @@ def test_multiple_analysis_depths(self, complex_project): def test_analysis_on_empty_vs_populated_project(self): """Test analysis behavior on empty vs populated projects.""" - from .conftest import MockProject, MockTask from pm_mcp_servers.pm_analyse.analyzers import HealthAnalyzer + from .conftest import MockProject, MockTask + empty_project = MockProject() task = MockTask(id='t1', name='Task 1', percent_complete=50) populated_project = MockProject(tasks=[task]) @@ -200,9 +200,10 @@ def test_analysis_result_data_integrity(self, complex_project): def test_tool_integration_with_project_store(self, basic_project): """Test tool execution with project store integration.""" + import asyncio + from pm_mcp_servers.pm_analyse.tools import identify_risks from pm_mcp_servers.shared import project_store - import asyncio project_store.add("integration-test", basic_project) diff --git a/packages/pm-mcp-servers/tests/pm_analyse/test_risk_engine.py b/packages/pm-mcp-servers/tests/pm_analyse/test_risk_engine.py index 01c0147..1fe4fc5 100644 --- a/packages/pm-mcp-servers/tests/pm_analyse/test_risk_engine.py +++ b/packages/pm-mcp-servers/tests/pm_analyse/test_risk_engine.py @@ -6,8 +6,6 @@ from datetime import date, timedelta -import pytest - from pm_mcp_servers.pm_analyse.models import AnalysisDepth, RiskCategory, Severity from pm_mcp_servers.pm_analyse.risk_engine import RiskEngine diff --git a/packages/pm-mcp-servers/tests/pm_analyse/test_tools.py b/packages/pm-mcp-servers/tests/pm_analyse/test_tools.py index f3e3e05..43bb603 100644 --- a/packages/pm-mcp-servers/tests/pm_analyse/test_tools.py +++ b/packages/pm-mcp-servers/tests/pm_analyse/test_tools.py @@ -5,16 +5,9 @@ assess_health, suggest_mitigations, compare_baseline) with 30+ test cases. """ -from datetime import datetime import pytest -from pm_mcp_servers.pm_analyse.models import ( - AnalysisDepth, - HealthStatus, - Severity, -) - class TestIdentifyRisks: """Tests for identify_risks tool.""" @@ -530,10 +523,11 @@ async def test_compare_baseline_success(self, basic_project): @pytest.mark.asyncio async def test_compare_baseline_all_types(self, task_with_baseline): """Test comparison with all baseline types.""" - from .conftest import MockProject from pm_mcp_servers.pm_analyse.tools import compare_baseline from pm_mcp_servers.shared import project_store + from .conftest import MockProject + project = MockProject(tasks=[task_with_baseline]) project_store.add("test-project", project) @@ -547,10 +541,11 @@ async def test_compare_baseline_all_types(self, task_with_baseline): @pytest.mark.asyncio async def test_compare_baseline_with_threshold(self, task_with_baseline): """Test baseline comparison with threshold.""" - from .conftest import MockProject from pm_mcp_servers.pm_analyse.tools import compare_baseline from pm_mcp_servers.shared import project_store + from .conftest import MockProject + project = MockProject(tasks=[task_with_baseline]) project_store.add("test-project", project) @@ -564,10 +559,11 @@ async def test_compare_baseline_with_threshold(self, task_with_baseline): @pytest.mark.asyncio async def test_compare_baseline_result_structure(self, task_with_baseline): """Test baseline comparison result structure.""" - from .conftest import MockProject from pm_mcp_servers.pm_analyse.tools import compare_baseline from pm_mcp_servers.shared import project_store + from .conftest import MockProject + project = MockProject(tasks=[task_with_baseline]) project_store.add("test-project", project) result = await compare_baseline({"project_id": "test-project"}) @@ -585,12 +581,12 @@ class TestToolMetadata: async def test_all_tools_return_metadata(self, basic_project): """Test that all tools return metadata in response.""" from pm_mcp_servers.pm_analyse.tools import ( - identify_risks, - forecast_completion, - detect_outliers, assess_health, + compare_baseline, + detect_outliers, + forecast_completion, + identify_risks, suggest_mitigations, - compare_baseline ) from pm_mcp_servers.shared import project_store diff --git a/packages/pm-mcp-servers/tests/test_pda_platform.py b/packages/pm-mcp-servers/tests/test_pda_platform.py new file mode 100644 index 0000000..8ae9732 --- /dev/null +++ b/packages/pm-mcp-servers/tests/test_pda_platform.py @@ -0,0 +1,264 @@ +"""Tests for the unified pda-platform MCP server. + +Covers: + - Server creation and tool aggregation + - All 5 registry modules load correctly + - No duplicate tool names across modules + - Every tool has a dispatch entry + - Tool count matches expected total + - Remote SSE server creates correctly + - Individual servers still work independently +""" + +import pytest + + +class TestUnifiedServerImports: + """Test that the unified server and all registries import cleanly.""" + + def test_unified_server_imports(self): + from pm_mcp_servers.pda_platform import server + + assert server is not None + + def test_unified_server_instance(self): + from pm_mcp_servers.pda_platform.server import server + + assert server is not None + assert server.name == "pda-platform" + + def test_all_tools_populated(self): + from pm_mcp_servers.pda_platform.server import ALL_TOOLS + + assert len(ALL_TOOLS) > 0 + + def test_tool_dispatch_populated(self): + from pm_mcp_servers.pda_platform.server import _TOOL_DISPATCH + + assert len(_TOOL_DISPATCH) > 0 + + +class TestRegistryModules: + """Test that each registry module exports TOOLS and dispatch correctly.""" + + def test_data_registry_loads(self): + from pm_mcp_servers.pm_data.registry import TOOLS, dispatch + + assert len(TOOLS) == 6 + assert callable(dispatch) + + def test_analyse_registry_loads(self): + from pm_mcp_servers.pm_analyse.registry import TOOLS, dispatch + + assert len(TOOLS) == 6 + assert callable(dispatch) + + def test_validate_registry_loads(self): + from pm_mcp_servers.pm_validate.registry import TOOLS, dispatch + + assert len(TOOLS) == 4 + assert callable(dispatch) + + def test_nista_registry_loads(self): + from pm_mcp_servers.pm_nista.registry import TOOLS, dispatch + + assert len(TOOLS) == 5 + assert callable(dispatch) + + def test_assure_registry_loads(self): + from pm_mcp_servers.pm_assure.registry import TOOLS, dispatch + + assert len(TOOLS) == 20 + assert callable(dispatch) + + +class TestToolAggregation: + """Test that tool aggregation in the unified server is correct.""" + + def test_total_tool_count(self): + """Unified server has exactly 41 tools (6+6+4+5+20).""" + from pm_mcp_servers.pda_platform.server import ALL_TOOLS + + assert len(ALL_TOOLS) == 41 + + def test_no_duplicate_tool_names(self): + """No two tools share the same name across modules.""" + from pm_mcp_servers.pda_platform.server import ALL_TOOLS + + names = [t.name for t in ALL_TOOLS] + assert len(names) == len(set(names)), f"Duplicate tools: {[n for n in names if names.count(n) > 1]}" + + def test_every_tool_has_dispatch(self): + """Every registered tool has a corresponding dispatch function.""" + from pm_mcp_servers.pda_platform.server import ALL_TOOLS, _TOOL_DISPATCH + + missing = [t.name for t in ALL_TOOLS if t.name not in _TOOL_DISPATCH] + assert len(missing) == 0, f"Tools without dispatch: {missing}" + + def test_tool_ordering(self): + """Tools appear in module order: data, analyse, validate, nista, assure.""" + from pm_mcp_servers.pda_platform.server import ALL_TOOLS + + names = [t.name for t in ALL_TOOLS] + # First tool should be from pm-data + assert names[0] == "load_project" + # Last tool should be from pm-assure + assert names[-1] == "get_cascade_impact" + + def test_all_tools_have_valid_schemas(self): + """Every tool has a name, description, and inputSchema.""" + from pm_mcp_servers.pda_platform.server import ALL_TOOLS + + for tool in ALL_TOOLS: + assert tool.name, "Tool has no name" + assert tool.description, f"Tool {tool.name} has no description" + assert tool.inputSchema, f"Tool {tool.name} has no inputSchema" + assert tool.inputSchema.get("type") == "object", f"Tool {tool.name} schema is not object type" + + +class TestExpectedTools: + """Verify the exact set of expected tool names is present.""" + + EXPECTED_DATA_TOOLS = { + "load_project", "query_tasks", "get_critical_path", + "get_dependencies", "convert_format", "get_project_summary", + } + + EXPECTED_ANALYSE_TOOLS = { + "identify_risks", "forecast_completion", "detect_outliers", + "assess_health", "suggest_mitigations", "compare_baseline", + } + + EXPECTED_VALIDATE_TOOLS = { + "validate_structure", "validate_semantic", + "validate_nista", "validate_custom", + } + + EXPECTED_NISTA_TOOLS = { + "generate_gmpp_report", "generate_narrative", + "submit_to_nista", "fetch_nista_metadata", "validate_gmpp_report", + } + + EXPECTED_ASSURE_TOOLS = { + "nista_longitudinal_trend", "track_review_actions", "review_action_status", + "check_artefact_currency", "check_confidence_divergence", + "recommend_review_schedule", "log_override_decision", + "analyse_override_patterns", "ingest_lesson", "search_lessons", + "log_assurance_activity", "analyse_assurance_overhead", + "run_assurance_workflow", "get_workflow_history", + "classify_project_domain", "reclassify_from_store", + "ingest_assumption", "validate_assumption", + "get_assumption_drift", "get_cascade_impact", + } + + def test_data_tools_present(self): + from pm_mcp_servers.pm_data.registry import TOOLS + + actual = {t.name for t in TOOLS} + assert actual == self.EXPECTED_DATA_TOOLS + + def test_analyse_tools_present(self): + from pm_mcp_servers.pm_analyse.registry import TOOLS + + actual = {t.name for t in TOOLS} + assert actual == self.EXPECTED_ANALYSE_TOOLS + + def test_validate_tools_present(self): + from pm_mcp_servers.pm_validate.registry import TOOLS + + actual = {t.name for t in TOOLS} + assert actual == self.EXPECTED_VALIDATE_TOOLS + + def test_nista_tools_present(self): + from pm_mcp_servers.pm_nista.registry import TOOLS + + actual = {t.name for t in TOOLS} + assert actual == self.EXPECTED_NISTA_TOOLS + + def test_assure_tools_present(self): + from pm_mcp_servers.pm_assure.registry import TOOLS + + actual = {t.name for t in TOOLS} + assert actual == self.EXPECTED_ASSURE_TOOLS + + def test_all_expected_tools_in_unified(self): + """Every expected tool from every module is in the unified server.""" + from pm_mcp_servers.pda_platform.server import ALL_TOOLS + + actual = {t.name for t in ALL_TOOLS} + expected = ( + self.EXPECTED_DATA_TOOLS + | self.EXPECTED_ANALYSE_TOOLS + | self.EXPECTED_VALIDATE_TOOLS + | self.EXPECTED_NISTA_TOOLS + | self.EXPECTED_ASSURE_TOOLS + ) + assert actual == expected + + +class TestRemoteServer: + """Test the SSE remote server wrapper.""" + + def test_remote_module_imports(self): + from pm_mcp_servers.pda_platform import remote + + assert remote is not None + + def test_starlette_app_created(self): + from pm_mcp_servers.pda_platform.remote import app + + assert app is not None + + def test_routes_registered(self): + from pm_mcp_servers.pda_platform.remote import app + + paths = [r.path for r in app.routes] + assert "/sse" in paths + assert "/messages" in paths + assert "/health" in paths + + def test_main_entry_point_exists(self): + from pm_mcp_servers.pda_platform.remote import main + + assert callable(main) + + +class TestIndividualServersStillWork: + """Verify that individual servers are unbroken by unified server changes.""" + + def test_pm_data_server(self): + from pm_mcp_servers.pm_data.server import server + + assert server.name == "pm-data" + + def test_pm_analyse_server(self): + from pm_mcp_servers.pm_analyse.server import server + + assert server.name == "pm-analyse" + + def test_pm_validate_server(self): + from pm_mcp_servers.pm_validate.server import app + + assert app.name == "pm-validate" + + def test_pm_nista_server(self): + from pm_mcp_servers.pm_nista.server import app + + assert app.name == "pm-nista-server" + + def test_pm_assure_server(self): + from pm_mcp_servers.pm_assure.server import app + + assert app.name == "pm-assure-server" + + +@pytest.mark.asyncio +class TestUnifiedDispatch: + """Test that the unified call_tool dispatcher routes correctly.""" + + async def test_unknown_tool_returns_error(self): + from pm_mcp_servers.pda_platform.server import call_tool + + result = await call_tool("nonexistent_tool_xyz", {}) + assert len(result) == 1 + assert "Unknown tool" in result[0].text diff --git a/packages/pm-mcp-servers/tests/test_pm_data_integration.py b/packages/pm-mcp-servers/tests/test_pm_data_integration.py index 14f1175..f8efd2e 100644 --- a/packages/pm-mcp-servers/tests/test_pm_data_integration.py +++ b/packages/pm-mcp-servers/tests/test_pm_data_integration.py @@ -6,23 +6,21 @@ Coverage target: 90%+ """ +from unittest.mock import Mock + import pytest -import json -from pathlib import Path -from datetime import datetime -from unittest.mock import Mock, patch + from pm_mcp_servers.pm_data.tools import ( ProjectStore, - load_project, - query_tasks, + _serialize_date, + convert_format, get_critical_path, get_dependencies, - convert_format, get_project_summary, - _serialize_date, + load_project, + query_tasks, ) - # ============================================================================ # FIXTURES - Test Data and Utilities # ============================================================================ diff --git a/packages/pm-mcp-servers/tests/test_pm_data_server.py b/packages/pm-mcp-servers/tests/test_pm_data_server.py index bd7c03e..1061d2c 100644 --- a/packages/pm-mcp-servers/tests/test_pm_data_server.py +++ b/packages/pm-mcp-servers/tests/test_pm_data_server.py @@ -1,16 +1,17 @@ """Tests for PM-Data MCP Server - Basic functionality tests.""" -import pytest -from pathlib import Path - # Import tools for testing import sys +from pathlib import Path + +import pytest + sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from pm_mcp_servers.pm_data.tools import ( + ProjectStore, load_project, query_tasks, - ProjectStore, ) diff --git a/packages/pm-mcp-servers/tests/test_pm_validate.py b/packages/pm-mcp-servers/tests/test_pm_validate.py index bd124e8..c546a4f 100644 --- a/packages/pm-mcp-servers/tests/test_pm_validate.py +++ b/packages/pm-mcp-servers/tests/test_pm_validate.py @@ -1,13 +1,14 @@ """Basic test suite for PM-Validate MCP Server.""" import pytest + from pm_mcp_servers.pm_validate.tools import ( Severity, ValidationIssue, - validate_structure, - validate_semantic, - validate_nista, validate_custom, + validate_nista, + validate_semantic, + validate_structure, ) diff --git a/railway.toml b/railway.toml new file mode 100644 index 0000000..b8a5684 --- /dev/null +++ b/railway.toml @@ -0,0 +1,9 @@ +[build] +builder = "NIXPACKS" + +[deploy] +startCommand = "pip install -e packages/pm-data-tools && pip install -e packages/pm-api && uvicorn pm_api.main:app --host 0.0.0.0 --port $PORT" +healthcheckPath = "/api/health" +healthcheckTimeout = 300 +restartPolicyType = "on_failure" +restartPolicyMaxRetries = 3 diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..15c6e3b --- /dev/null +++ b/render.yaml @@ -0,0 +1,11 @@ +services: + - type: web + name: pda-platform + runtime: python + buildCommand: pip install -e packages/pm-data-tools && pip install -e packages/pm-mcp-servers && pip install uvicorn + startCommand: pda-platform-remote + envVars: + - key: PORT + value: "10000" + - key: ANTHROPIC_API_KEY + sync: false