info-tech-canon/src/info_tech_canon/service.py

483 lines
16 KiB
Python
Raw Normal View History

from __future__ import annotations
from collections import Counter
from dataclasses import asdict, dataclass
from pathlib import Path
import json
from typing import Any
2026-05-23 07:23:48 +02:00
import yaml
from . import generation
2026-05-23 04:26:28 +02:00
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()
2026-05-23 07:23:48 +02:00
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")
2026-05-23 07:23:48 +02:00
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"])
Report and enforce concept-declaration coverage (T03) validation-coverage gains a concept_declaration block: declared, defined in prose, undeclared, ratio, silent artifacts, and the extraction limit stated in words. The ratio sits slightly above one because seed-concept lists and YAML payload concepts are declared but not extractable, and saying so in the report is better than a number that looks complete. Two checks carry different weights. concept_declaration_missing is an error: an artifact that defines concepts and declares none, with the kernel map exempt by name because it assigns concepts rather than defining them. Zero today, so a new artifact added without declarations fails. concept_defined_without_owner is a warning over concepts no artifact declares; a name another artifact owns is an import rather than a gap, which keeps the warning from firing 57 times and training reviewers to ignore it. Three warnings today and each is real: the Organization Model defines eleven concepts nobody owns, CARING four including Effective Access and Declared Access, and the Capability Model two. They are carried as T07 rather than declared in passing, because declaring without a boundary review is the mistake this workplan exists to fix. make check passes with 53 tests, clean validation and three warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 3588@bnt-lap001 Assistant-Session: 24b80f66-e5a7-4e61-99fe-2d422e6d17da
2026-09-20 23:24:27 +02:00
declaration = concept_declaration_checks(context, ownership)
errors.extend(declaration["errors"])
warnings.extend(declaration["warnings"])
return {
"ok": not errors,
"errors": errors,
"warnings": warnings,
"metrics": checks.metrics,
"details": checks.details,
"coverage": coverage(context),
}
Report and enforce concept-declaration coverage (T03) validation-coverage gains a concept_declaration block: declared, defined in prose, undeclared, ratio, silent artifacts, and the extraction limit stated in words. The ratio sits slightly above one because seed-concept lists and YAML payload concepts are declared but not extractable, and saying so in the report is better than a number that looks complete. Two checks carry different weights. concept_declaration_missing is an error: an artifact that defines concepts and declares none, with the kernel map exempt by name because it assigns concepts rather than defining them. Zero today, so a new artifact added without declarations fails. concept_defined_without_owner is a warning over concepts no artifact declares; a name another artifact owns is an import rather than a gap, which keeps the warning from firing 57 times and training reviewers to ignore it. Three warnings today and each is real: the Organization Model defines eleven concepts nobody owns, CARING four including Effective Access and Declared Access, and the Capability Model two. They are carried as T07 rather than declared in passing, because declaring without a boundary review is the mistake this workplan exists to fix. make check passes with 53 tests, clean validation and three warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 3588@bnt-lap001 Assistant-Session: 24b80f66-e5a7-4e61-99fe-2d422e6d17da
2026-09-20 23:24:27 +02:00
#: 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)},
)
2026-05-23 04:26:28 +02:00
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)},
2026-05-23 04:26:28 +02:00
) 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)},
)
2026-05-23 04:26:28 +02:00
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
2026-05-23 07:23:48 +02:00
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