from __future__ import annotations from collections import Counter from dataclasses import asdict, dataclass from pathlib import Path import json from typing import Any import yaml from . import generation from . import profiles from .bench import ( Infospace, KnowledgeArtifact, export_mermaid, load_infospace, relationship_summary, run_collection_checks, ) from .validation import structural_checks from .paths import infospace_root REPO_ROOT = Path(__file__).resolve().parents[2] DEFAULT_INFOSPACE_ROOT = infospace_root() REVIEW_KIT_COMPONENTS = { "manifest": "agent/review-kit/review-kit.yaml", "workflow": "agent/review-kit/review-workflow.yaml", "scorecard": "agent/review-kit/scorecard.yaml", "model_selection_guide": "agent/review-kit/model-selection-guide.yaml", "schema": "schemas/alignment-review.schema.yaml", } ALIGNMENT_TEMPLATE_PATH = "agent/templates/consumer-alignment-workplan.template.md" class CanonServiceError(Exception): def __init__( self, code: str, message: str, details: dict[str, Any] | None = None, ) -> None: super().__init__(message) self.code = code self.message = message self.details = details or {} def to_dict(self) -> dict[str, Any]: return { "ok": False, "error": { "code": self.code, "message": self.message, "details": self.details, }, } @dataclass(frozen=True) class CanonContext: repo_root: Path infospace_root: Path infospace: Infospace def load_context(root: Path | str | None = None) -> CanonContext: resolved_root = infospace_root(root) try: infospace = load_infospace(resolved_root) except Exception as exc: raise CanonServiceError( "infospace_load_failed", f"Unable to load infospace at {resolved_root}", {"root": str(resolved_root), "reason": str(exc)}, ) from exc return CanonContext( repo_root=resolved_root.parent, infospace_root=resolved_root, infospace=infospace, ) def inspect_canon(root: Path | str | None = None) -> dict[str, Any]: context = load_context(root) artifacts = context.infospace.artifacts kinds = Counter(artifact.kind for artifact in artifacts) return { "ok": True, "repo": { "slug": "info-tech-canon", "root": str(context.repo_root), }, "infospace": { "slug": context.infospace.config.slug, "name": context.infospace.config.name, "root": str(context.infospace_root), "artifact_count": len(artifacts), "kinds": dict(sorted(kinds.items())), }, "service": { "package": "info_tech_canon", "contract": "cli-json-api", }, } def list_artifacts( root: Path | str | None = None, *, kind: str | None = None, ) -> dict[str, Any]: context = load_context(root) artifacts = [ _artifact_to_dict(artifact, context.infospace_root) for artifact in context.infospace.artifacts if kind is None or artifact.kind == kind ] return { "ok": True, "count": len(artifacts), "artifacts": artifacts, } def list_models(root: Path | str | None = None) -> dict[str, Any]: return list_artifacts(root, kind="model") def list_standards(root: Path | str | None = None) -> dict[str, Any]: return list_artifacts(root, kind="standard") def review_kit(root: Path | str | None = None) -> dict[str, Any]: context = load_context(root) components = { name: { "path": relative, "content": _read_yaml_component(context.infospace_root, relative), } for name, relative in REVIEW_KIT_COMPONENTS.items() } template = alignment_template(root) return { "ok": True, "review_kit": components["manifest"]["content"], "components": components, "template": { "path": template["path"], "content": template["content"], }, } def alignment_template(root: Path | str | None = None) -> dict[str, Any]: context = load_context(root) path = context.infospace_root / ALIGNMENT_TEMPLATE_PATH try: content = path.read_text(encoding="utf-8") except FileNotFoundError as exc: raise CanonServiceError( "missing_alignment_template", "Consumer alignment workplan template not found.", {"path": str(path)}, ) from exc return { "ok": True, "path": ALIGNMENT_TEMPLATE_PATH, "content": content, } def validate_canon(root: Path | str | None = None) -> dict[str, Any]: context = load_context(root) errors: list[dict[str, Any]] = [] warnings: list[dict[str, Any]] = [] artifact_ids = {artifact.id for artifact in context.infospace.artifacts} for artifact in context.infospace.artifacts: artifact_path = context.infospace_root / artifact.path if not artifact_path.is_file(): errors.append( { "code": "missing_artifact_path", "artifact_id": artifact.id, "path": artifact.path, } ) for relationship in artifact.relationships: target = relationship.get("target") if target not in artifact_ids: errors.append( { "code": "missing_relationship_target", "artifact_id": artifact.id, "target": target, } ) for discipline in context.infospace.config.disciplines: discipline_path = context.infospace_root / discipline.path if not discipline_path.is_file(): errors.append( { "code": "missing_discipline_path", "discipline": discipline.name, "path": discipline.path, } ) checks = run_collection_checks(context.infospace.artifacts) threshold_errors = _evaluate_thresholds( checks.metrics, context.infospace.config.viability, ) from .import_reviews import reviewed_import_cycles import_reviews = reviewed_import_cycles(context.infospace.artifacts, context.infospace_root) if import_reviews: threshold_errors = [error for error in threshold_errors if error.get("metric") != "consistency_cycles"] checks.details["reviewed_model_import_cycles"] = import_reviews checks.details["cycle_threshold_exception"] = "Exact reviewed reciprocal model imports; raw metric retained" errors.extend(threshold_errors) structural = structural_checks(context) errors.extend(structural["errors"]) warnings.extend(structural["warnings"]) from .contracts import bound_artifact_errors, coverage errors.extend(bound_artifact_errors(context)) ownership = generation.concept_ownership(context) errors.extend(dict(item, code="concept_ownership_conflict") for item in ownership["ownership_conflicts"]) declaration = concept_declaration_checks(context, ownership) errors.extend(declaration["errors"]) warnings.extend(declaration["warnings"]) from .federation import registered_drift warnings.extend(registered_drift(context)) return { "ok": not errors, "errors": errors, "warnings": warnings, "metrics": checks.metrics, "details": checks.details, "coverage": coverage(context), } #: The kernel map assigns concepts to owners rather than defining them, so it is #: the one artifact allowed to define concept names and declare none. DECLARATION_EXEMPT = {"kernel/itc-kernel-map"} def concept_declaration_checks(context, ownership: dict) -> dict: """Undeclared prose is a warning; defining concepts and declaring none is an error. A defined name another artifact owns is an import, not a gap, so only names no artifact declares are reported. """ from .maintenance import concept_candidates owned = {generation._normalize_concept(item["concept"]) for item in ownership["concepts"]} errors, warnings = [], [] for item in concept_candidates(context)["artifacts"]: unowned = [name for name in item["undeclared"] if generation._normalize_concept(name) not in owned] if not item["declares_frontmatter"] and item["candidate_count"] \ and item["artifact"] not in DECLARATION_EXEMPT: errors.append({"code": "concept_declaration_missing", "artifact_id": item["artifact"], "path": item["path"], "defined": item["candidate_count"]}) if unowned: warnings.append({"code": "concept_defined_without_owner", "artifact_id": item["artifact"], "path": item["path"], "concepts": unowned}) return {"errors": errors, "warnings": warnings} def write_validation_report( destination: Path | str, root: Path | str | None = None, ) -> dict[str, Any]: payload = validate_canon(root) from .maintenance import source_evidence payload["evidence"] = source_evidence(infospace_root(root)) path = Path(destination) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") payload["report_path"] = str(path) return payload def artifact_graph( root: Path | str | None = None, *, output_format: str = "json", ) -> dict[str, Any]: context = load_context(root) summary = relationship_summary(context.infospace.artifacts) if output_format == "mermaid": return {"ok": True, "format": "mermaid", "graph": export_mermaid(summary)} if output_format != "json": raise CanonServiceError( "unsupported_graph_format", f"Unsupported graph format: {output_format}", {"supported": ["json", "mermaid"]}, ) return { "ok": True, "format": "json", "graph": { "node_count": summary.node_count, "edge_count": summary.edge_count, "nodes": summary.nodes, "edges": [asdict(edge) for edge in summary.edges], "relationship_types": summary.relationship_types, }, } def profile_inspect( profile: str, root: Path | str | None = None, ) -> dict[str, Any]: context = load_context(root) profile_path = context.infospace_root / "profiles" / profile / "profile.yaml" if not profile_path.is_file(): raise CanonServiceError( "missing_profile", f"Profile not found: {profile}", {"profile": profile, "path": str(profile_path)}, ) try: return profiles.inspect_profile(context, profile) except ValueError as exc: raise CanonServiceError( "invalid_profile", f"Profile must be a YAML mapping: {profile}", {"profile": profile, "path": str(profile_path)}, ) from exc def profile_validate( profile: str, root: Path | str | None = None, ) -> dict[str, Any]: context = load_context(root) profile_path = context.infospace_root / "profiles" / profile / "profile.yaml" if not profile_path.is_file(): raise CanonServiceError( "missing_profile", f"Profile not found: {profile}", {"profile": profile, "path": str(profile_path)}, ) return profiles.validate_profile(context, profile) def profile_graph( profile: str, root: Path | str | None = None, *, output_format: str = "json", ) -> dict[str, Any]: context = load_context(root) profile_path = context.infospace_root / "profiles" / profile / "profile.yaml" if not profile_path.is_file(): raise CanonServiceError( "missing_profile", f"Profile not found: {profile}", {"profile": profile, "path": str(profile_path)}, ) try: return profiles.profile_graph(context, profile, output_format=output_format) except ValueError as exc: raise CanonServiceError( "unsupported_graph_format", str(exc), {"supported": ["json", "mermaid"]}, ) from exc def review_capability_record( path: str | Path, root: Path | str | None = None, ) -> dict[str, Any]: from .capability import CapabilityReviewError, review_path try: return review_path(path, infospace_root(root) / "models/capability/capabilities.yaml") except CapabilityReviewError as exc: raise CanonServiceError(exc.code, exc.message) from exc def generate_indexes(root: Path | str | None = None) -> dict[str, Any]: return generation.generate_indexes(load_context(root)) def generate_tree(root: Path | str | None = None) -> dict[str, Any]: return generation.generate_tree(load_context(root)) def generate_agent_briefs(root: Path | str | None = None) -> dict[str, Any]: return generation.generate_agent_briefs(load_context(root)) def list_views(root: Path | str | None = None) -> dict[str, Any]: return generation.list_generated_views(load_context(root)) def read_view(name: str, root: Path | str | None = None) -> dict[str, Any]: try: return generation.read_generated_view(load_context(root), name) except FileNotFoundError as exc: raise CanonServiceError( "missing_view", f"View not found: {name}", {"view": name}, ) from exc except ValueError as exc: raise CanonServiceError( "invalid_view_name", str(exc), {"view": name}, ) from exc def _read_yaml_component(infospace_root: Path, relative: str) -> Any: path = infospace_root / relative try: with path.open("r", encoding="utf-8") as handle: return yaml.safe_load(handle) or {} except FileNotFoundError as exc: raise CanonServiceError( "missing_review_kit_component", f"Review kit component not found: {relative}", {"path": str(path)}, ) from exc except yaml.YAMLError as exc: raise CanonServiceError( "invalid_review_kit_component", f"Review kit component is not valid YAML: {relative}", {"path": str(path), "reason": str(exc)}, ) from exc def _artifact_to_dict( artifact: KnowledgeArtifact, infospace_root: Path, ) -> dict[str, Any]: data = artifact.to_dict() data["exists"] = (infospace_root / artifact.path).is_file() return data def _evaluate_thresholds( metrics: dict[str, float], thresholds: dict[str, Any], ) -> list[dict[str, Any]]: errors: list[dict[str, Any]] = [] for metric, threshold in thresholds.items(): value = metrics.get(metric) if value is None: continue min_value = getattr(threshold, "min", None) max_value = getattr(threshold, "max", None) if min_value is not None and value < min_value: errors.append( { "code": "metric_below_threshold", "metric": metric, "value": value, "min": min_value, } ) if max_value is not None and value > max_value: errors.append( { "code": "metric_above_threshold", "metric": metric, "value": value, "max": max_value, } ) return errors