527 lines
14 KiB
Python
527 lines
14 KiB
Python
"""Core models for human-centered ecosystem analysis.
|
|
|
|
The module intentionally keeps the data model dependency-free. The platform is
|
|
expected to run inside operational mirrors where installing new packages is not
|
|
always desirable, so every object can be serialized with the standard library.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field, fields, is_dataclass
|
|
from datetime import datetime, timezone
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
from typing import Any, Iterable, Mapping, MutableMapping, Sequence
|
|
|
|
|
|
def utc_now() -> str:
|
|
"""Return an ISO-8601 timestamp without relying on local locale settings."""
|
|
|
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
|
|
|
|
def slugify(value: str) -> str:
|
|
"""Create a stable ASCII-ish slug for filenames and object ids."""
|
|
|
|
allowed: list[str] = []
|
|
last_dash = False
|
|
for char in value.lower().strip():
|
|
if "a" <= char <= "z" or "0" <= char <= "9":
|
|
allowed.append(char)
|
|
last_dash = False
|
|
elif char in {" ", "_", "-", ".", "/", "\\"} and not last_dash:
|
|
allowed.append("-")
|
|
last_dash = True
|
|
return "".join(allowed).strip("-") or "item"
|
|
|
|
|
|
def as_plain_data(value: Any) -> Any:
|
|
"""Convert dataclasses, enums, paths, and nested values to JSON-safe data."""
|
|
|
|
if isinstance(value, Enum):
|
|
return value.value
|
|
if isinstance(value, Path):
|
|
return str(value)
|
|
if is_dataclass(value):
|
|
return {item.name: as_plain_data(getattr(value, item.name)) for item in fields(value)}
|
|
if isinstance(value, Mapping):
|
|
return {str(key): as_plain_data(inner) for key, inner in value.items()}
|
|
if isinstance(value, (list, tuple, set)):
|
|
return [as_plain_data(item) for item in value]
|
|
return value
|
|
|
|
|
|
class EvidenceKind(str, Enum):
|
|
"""Kinds of evidence detected in a platform repository."""
|
|
|
|
README = "readme"
|
|
PACKAGE_SCRIPT = "package_script"
|
|
ROUTE = "route"
|
|
OPENAPI = "openapi"
|
|
TEST = "test"
|
|
CONFIG = "config"
|
|
DOC = "doc"
|
|
WORKER = "worker"
|
|
STORAGE = "storage"
|
|
MCP_TOOL = "mcp_tool"
|
|
UI_SURFACE = "ui_surface"
|
|
SECURITY = "security"
|
|
BUSINESS_RULE = "business_rule"
|
|
OBSERVABILITY = "observability"
|
|
UNKNOWN = "unknown"
|
|
|
|
|
|
class NeedCategory(str, Enum):
|
|
"""Human need categories used by the matrix engine."""
|
|
|
|
ADMINISTRATION = "administration"
|
|
SUPPORT = "support"
|
|
FINANCE = "finance"
|
|
LEGAL = "legal"
|
|
SECURITY = "security"
|
|
OPERATIONS = "operations"
|
|
STRATEGY = "strategy"
|
|
DOCUMENTATION = "documentation"
|
|
SELF_SERVICE = "self_service"
|
|
COMMERCIAL = "commercial"
|
|
EXPERIENCE = "experience"
|
|
GOVERNANCE = "governance"
|
|
INTEGRATION = "integration"
|
|
OBSERVABILITY = "observability"
|
|
|
|
|
|
class MaturityLevel(str, Enum):
|
|
"""Operational maturity level from a human point of view."""
|
|
|
|
NOT_FOUND = "not_found"
|
|
PLANNED = "planned"
|
|
CATALOGED = "cataloged"
|
|
TECHNICAL = "technical"
|
|
EXPLAINABLE = "explainable"
|
|
ACTIONABLE = "actionable"
|
|
READY_FOR_HUMAN = "ready_for_human"
|
|
AUDITABLE = "auditable"
|
|
|
|
|
|
class OrderType(str, Enum):
|
|
"""Service-order type used by the order generator."""
|
|
|
|
EXECUTIVE = "executiva"
|
|
MANAGERIAL = "gerencial"
|
|
|
|
|
|
class OrderStatus(str, Enum):
|
|
"""Compact status for generated and executed orders."""
|
|
|
|
PLANNED = "planejada"
|
|
RUNNING = "em_execucao"
|
|
COMPLETED = "concluida"
|
|
PARTIAL = "parcial"
|
|
BLOCKED = "bloqueada"
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class Evidence:
|
|
"""A small, inspectable proof that a capability exists or is missing."""
|
|
|
|
kind: EvidenceKind
|
|
path: str
|
|
summary: str
|
|
line: int | None = None
|
|
confidence: float = 0.5
|
|
tags: tuple[str, ...] = ()
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return as_plain_data(self)
|
|
|
|
@property
|
|
def reference(self) -> str:
|
|
if self.line is None:
|
|
return self.path
|
|
return f"{self.path}:{self.line}"
|
|
|
|
def is_strong(self) -> bool:
|
|
return self.confidence >= 0.75
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class HumanNeed:
|
|
"""A concrete need a person may have while using the ecosystem."""
|
|
|
|
need_id: str
|
|
title: str
|
|
category: NeedCategory
|
|
description: str
|
|
success_markers: tuple[str, ...]
|
|
risk_if_missing: str
|
|
expected_surfaces: tuple[str, ...] = ()
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return as_plain_data(self)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class HumanProfile:
|
|
"""A human role that should be served by one or more platforms."""
|
|
|
|
profile_id: str
|
|
name: str
|
|
description: str
|
|
priority_needs: tuple[NeedCategory, ...]
|
|
typical_questions: tuple[str, ...]
|
|
expected_outputs: tuple[str, ...]
|
|
sensitive_concerns: tuple[str, ...] = ()
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return as_plain_data(self)
|
|
|
|
def wants(self, category: NeedCategory) -> bool:
|
|
return category in self.priority_needs
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class PlatformDefinition:
|
|
"""Canonical description of a managed platform."""
|
|
|
|
platform_id: str
|
|
repo_name: str
|
|
central_folder: str
|
|
title: str
|
|
mission: str
|
|
primary_categories: tuple[NeedCategory, ...]
|
|
expected_profiles: tuple[str, ...]
|
|
related_platforms: tuple[str, ...] = ()
|
|
expected_surfaces: tuple[str, ...] = ()
|
|
known_blockers: tuple[str, ...] = ()
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return as_plain_data(self)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class FileMetric:
|
|
"""Line and extension metric for a repository file."""
|
|
|
|
path: str
|
|
extension: str
|
|
lines: int
|
|
bytes_size: int
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return as_plain_data(self)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class ScriptCommand:
|
|
"""A command exposed by package.json or other local metadata."""
|
|
|
|
name: str
|
|
command: str
|
|
source_file: str
|
|
intent: str = "unknown"
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return as_plain_data(self)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class PlatformScan:
|
|
"""Repository scan result used as input for matrix and report generation."""
|
|
|
|
platform: PlatformDefinition
|
|
repo_path: str
|
|
exists: bool
|
|
git_present: bool
|
|
branch: str | None
|
|
head: str | None
|
|
remote_origin: str | None
|
|
readme_excerpt: str
|
|
file_metrics: tuple[FileMetric, ...]
|
|
scripts: tuple[ScriptCommand, ...]
|
|
evidence: tuple[Evidence, ...]
|
|
warnings: tuple[str, ...]
|
|
scanned_at: str = field(default_factory=utc_now)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return as_plain_data(self)
|
|
|
|
@property
|
|
def total_lines(self) -> int:
|
|
return sum(metric.lines for metric in self.file_metrics)
|
|
|
|
@property
|
|
def code_lines(self) -> int:
|
|
code_ext = {".ts", ".tsx", ".js", ".mjs", ".cjs", ".py", ".java"}
|
|
return sum(metric.lines for metric in self.file_metrics if metric.extension in code_ext)
|
|
|
|
@property
|
|
def has_tests(self) -> bool:
|
|
return any(item.kind == EvidenceKind.TEST for item in self.evidence)
|
|
|
|
@property
|
|
def has_openapi(self) -> bool:
|
|
return any(item.kind == EvidenceKind.OPENAPI for item in self.evidence)
|
|
|
|
@property
|
|
def has_worker(self) -> bool:
|
|
return any(item.kind == EvidenceKind.WORKER for item in self.evidence)
|
|
|
|
@property
|
|
def strong_evidence_count(self) -> int:
|
|
return sum(1 for item in self.evidence if item.is_strong())
|
|
|
|
def evidence_by_kind(self, kind: EvidenceKind) -> tuple[Evidence, ...]:
|
|
return tuple(item for item in self.evidence if item.kind == kind)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class MatrixCell:
|
|
"""Human service score for one platform and one profile."""
|
|
|
|
platform_id: str
|
|
profile_id: str
|
|
score: int
|
|
maturity: MaturityLevel
|
|
explanation: str
|
|
strengths: tuple[str, ...]
|
|
gaps: tuple[str, ...]
|
|
evidence_refs: tuple[str, ...]
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return as_plain_data(self)
|
|
|
|
@property
|
|
def normalized_score(self) -> float:
|
|
return max(0.0, min(1.0, self.score / 100.0))
|
|
|
|
def is_ready(self) -> bool:
|
|
return self.score >= 75
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class Recommendation:
|
|
"""Actionable recommendation derived from scan and matrix signals."""
|
|
|
|
recommendation_id: str
|
|
platform_id: str
|
|
title: str
|
|
reason: str
|
|
expected_impact: str
|
|
categories: tuple[NeedCategory, ...]
|
|
priority: int
|
|
suggested_order_type: OrderType
|
|
affected_paths: tuple[str, ...] = ()
|
|
validation_steps: tuple[str, ...] = ()
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return as_plain_data(self)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class PlatformHumanReport:
|
|
"""Report model for one platform."""
|
|
|
|
platform: PlatformDefinition
|
|
scan: PlatformScan
|
|
cells: tuple[MatrixCell, ...]
|
|
recommendations: tuple[Recommendation, ...]
|
|
summary: str
|
|
current_state: tuple[str, ...]
|
|
future_state: tuple[str, ...]
|
|
missing_for_humans: tuple[str, ...]
|
|
generated_at: str = field(default_factory=utc_now)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return as_plain_data(self)
|
|
|
|
@property
|
|
def average_score(self) -> int:
|
|
if not self.cells:
|
|
return 0
|
|
return round(sum(cell.score for cell in self.cells) / len(self.cells))
|
|
|
|
@property
|
|
def ready_profiles(self) -> tuple[str, ...]:
|
|
return tuple(cell.profile_id for cell in self.cells if cell.is_ready())
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class EcosystemHumanReport:
|
|
"""Full ecosystem report model."""
|
|
|
|
scans: tuple[PlatformScan, ...]
|
|
platform_reports: tuple[PlatformHumanReport, ...]
|
|
recommendations: tuple[Recommendation, ...]
|
|
generated_at: str = field(default_factory=utc_now)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return as_plain_data(self)
|
|
|
|
@property
|
|
def total_code_lines(self) -> int:
|
|
return sum(scan.code_lines for scan in self.scans)
|
|
|
|
@property
|
|
def total_files(self) -> int:
|
|
return sum(len(scan.file_metrics) for scan in self.scans)
|
|
|
|
@property
|
|
def average_score(self) -> int:
|
|
cells = [cell for report in self.platform_reports for cell in report.cells]
|
|
if not cells:
|
|
return 0
|
|
return round(sum(cell.score for cell in cells) / len(cells))
|
|
|
|
def report_for(self, platform_id: str) -> PlatformHumanReport | None:
|
|
for report in self.platform_reports:
|
|
if report.platform.platform_id == platform_id:
|
|
return report
|
|
return None
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class ServiceOrder:
|
|
"""Generated service order metadata and body."""
|
|
|
|
order_id: str
|
|
order_type: OrderType
|
|
project_id: str
|
|
title: str
|
|
purpose: str
|
|
object_scope: str
|
|
reason: str
|
|
expected_result: str
|
|
affected_paths: tuple[str, ...]
|
|
validations: tuple[str, ...]
|
|
ready_criteria: tuple[str, ...]
|
|
status: OrderStatus = OrderStatus.PLANNED
|
|
priority: str = "media"
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return as_plain_data(self)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class GeneratedFile:
|
|
"""File generated or updated by the platform runtime."""
|
|
|
|
path: str
|
|
description: str
|
|
function: str
|
|
file_type: str
|
|
changed_by: str
|
|
change_summary: str
|
|
relation_to_order: str
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return as_plain_data(self)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class ReportBundle:
|
|
"""Paths and counters produced by a generation run."""
|
|
|
|
output_root: str
|
|
generated_files: tuple[GeneratedFile, ...]
|
|
platform_count: int
|
|
profile_count: int
|
|
matrix_cells: int
|
|
total_code_lines_analyzed: int
|
|
warnings: tuple[str, ...]
|
|
generated_at: str = field(default_factory=utc_now)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return as_plain_data(self)
|
|
|
|
|
|
def clamp_score(value: int | float) -> int:
|
|
"""Clamp a numeric score into the 0..100 range."""
|
|
|
|
return int(max(0, min(100, round(float(value)))))
|
|
|
|
|
|
def maturity_from_score(score: int) -> MaturityLevel:
|
|
"""Map numeric score to a human maturity label."""
|
|
|
|
score = clamp_score(score)
|
|
if score == 0:
|
|
return MaturityLevel.NOT_FOUND
|
|
if score < 20:
|
|
return MaturityLevel.PLANNED
|
|
if score < 35:
|
|
return MaturityLevel.CATALOGED
|
|
if score < 50:
|
|
return MaturityLevel.TECHNICAL
|
|
if score < 65:
|
|
return MaturityLevel.EXPLAINABLE
|
|
if score < 80:
|
|
return MaturityLevel.ACTIONABLE
|
|
if score < 92:
|
|
return MaturityLevel.READY_FOR_HUMAN
|
|
return MaturityLevel.AUDITABLE
|
|
|
|
|
|
def merge_unique(values: Iterable[str]) -> tuple[str, ...]:
|
|
"""Return values in input order while dropping empty strings and duplicates."""
|
|
|
|
seen: set[str] = set()
|
|
output: list[str] = []
|
|
for value in values:
|
|
cleaned = str(value).strip()
|
|
if not cleaned or cleaned in seen:
|
|
continue
|
|
seen.add(cleaned)
|
|
output.append(cleaned)
|
|
return tuple(output)
|
|
|
|
|
|
def group_by_platform(recommendations: Sequence[Recommendation]) -> dict[str, list[Recommendation]]:
|
|
grouped: dict[str, list[Recommendation]] = {}
|
|
for item in recommendations:
|
|
grouped.setdefault(item.platform_id, []).append(item)
|
|
for items in grouped.values():
|
|
items.sort(key=lambda rec: (-rec.priority, rec.title))
|
|
return grouped
|
|
|
|
|
|
def incrementing_id(prefix: str, index: int, title: str) -> str:
|
|
"""Create a readable id with a numeric prefix and a slug."""
|
|
|
|
return f"{index:04d}_{prefix}__{slugify(title)}"
|
|
|
|
|
|
def summarize_warnings(scans: Sequence[PlatformScan]) -> tuple[str, ...]:
|
|
warnings: list[str] = []
|
|
for scan in scans:
|
|
for warning in scan.warnings:
|
|
warnings.append(f"{scan.platform.platform_id}: {warning}")
|
|
return merge_unique(warnings)
|
|
|
|
|
|
def score_label(score: int) -> str:
|
|
"""Return a compact label for user-facing matrix tables."""
|
|
|
|
score = clamp_score(score)
|
|
if score >= 90:
|
|
return "excelente"
|
|
if score >= 75:
|
|
return "forte"
|
|
if score >= 60:
|
|
return "util"
|
|
if score >= 40:
|
|
return "tecnico"
|
|
if score >= 20:
|
|
return "inicial"
|
|
if score > 0:
|
|
return "fragil"
|
|
return "ausente"
|
|
|
|
|
|
def ensure_mapping(data: MutableMapping[str, Any], key: str, default: Any) -> Any:
|
|
"""Small helper used by storage migration code."""
|
|
|
|
if key not in data:
|
|
data[key] = default
|
|
return data[key]
|
|
|