2026-05-23 03:12:02 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from collections import Counter
|
|
|
|
|
from dataclasses import asdict, dataclass
|
|
|
|
|
from pathlib import Path
|
2026-05-23 03:32:16 +02:00
|
|
|
import json
|
2026-05-23 03:12:02 +02:00
|
|
|
from typing import Any
|
|
|
|
|
|
2026-05-23 07:23:48 +02:00
|
|
|
import yaml
|
|
|
|
|
|
2026-05-23 03:32:16 +02:00
|
|
|
from . import generation
|
2026-05-23 04:26:28 +02:00
|
|
|
from . import profiles
|
2026-05-23 03:12:02 +02:00
|
|
|
from .bench import (
|
|
|
|
|
Infospace,
|
|
|
|
|
KnowledgeArtifact,
|
|
|
|
|
export_mermaid,
|
|
|
|
|
load_infospace,
|
|
|
|
|
relationship_summary,
|
|
|
|
|
run_collection_checks,
|
|
|
|
|
)
|
2026-05-23 03:32:16 +02:00
|
|
|
from .validation import structural_checks
|
2026-09-05 00:50:09 +02:00
|
|
|
from .paths import infospace_root
|
2026-05-23 03:12:02 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
2026-09-05 00:50:09 +02:00
|
|
|
DEFAULT_INFOSPACE_ROOT = infospace_root()
|
2026-05-23 03:12:02 +02:00
|
|
|
|
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"
|
|
|
|
|
|
2026-05-23 03:12:02 +02:00
|
|
|
|
|
|
|
|
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:
|
2026-09-05 00:50:09 +02:00
|
|
|
resolved_root = infospace_root(root)
|
2026-05-23 03:12:02 +02:00
|
|
|
try:
|
2026-09-05 00:50:09 +02:00
|
|
|
infospace = load_infospace(resolved_root)
|
2026-05-23 03:12:02 +02:00
|
|
|
except Exception as exc:
|
|
|
|
|
raise CanonServiceError(
|
|
|
|
|
"infospace_load_failed",
|
2026-09-05 00:50:09 +02:00
|
|
|
f"Unable to load infospace at {resolved_root}",
|
|
|
|
|
{"root": str(resolved_root), "reason": str(exc)},
|
2026-05-23 03:12:02 +02:00
|
|
|
) from exc
|
|
|
|
|
return CanonContext(
|
2026-09-05 00:50:09 +02:00
|
|
|
repo_root=resolved_root.parent,
|
|
|
|
|
infospace_root=resolved_root,
|
2026-05-23 03:12:02 +02:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 03:12:02 +02:00
|
|
|
def validate_canon(root: Path | str | None = None) -> dict[str, Any]:
|
|
|
|
|
context = load_context(root)
|
|
|
|
|
errors: list[dict[str, Any]] = []
|
2026-05-23 03:32:16 +02:00
|
|
|
warnings: list[dict[str, Any]] = []
|
2026-05-23 03:12:02 +02:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
)
|
2026-09-05 22:11:46 +02:00
|
|
|
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"
|
2026-05-23 03:12:02 +02:00
|
|
|
errors.extend(threshold_errors)
|
2026-05-23 03:32:16 +02:00
|
|
|
structural = structural_checks(context)
|
|
|
|
|
errors.extend(structural["errors"])
|
|
|
|
|
warnings.extend(structural["warnings"])
|
2026-09-05 00:50:09 +02:00
|
|
|
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"])
|
Check import manifests by hash and name together (INFO-WP-0028 T01-T03)
import-review takes any partner manifest and returns, per concept, whether the
name resolves in the ownership index and to which artifact, and per entry
whether the pinned SHA-256 matches the blob at the declared source commit. Both
run in one pass so neither can be recorded without the other, which is the
failure this workplan exists to prevent. It exits non-zero on a finding, reads
JSON or YAML, needs no partner checkout, and carries its own limit: resolution
proves a name exists and names one owner, nothing more.
Accepted manifests are registered under infospace/interfaces/manifests/ as
provenance-preserving copies owned by the partner, with the partner revision and
retrieval date recorded. Editing a copy to make a check pass is forbidden in the
file itself. Validation re-resolves them and reports drift as
federation_import_drift, a warning naming the partner rather than an error,
because a stale partner pin is not this repository's file to fix.
The review kit gains an extension-boundary-review template requiring hash count,
resolution count and conflict count as three separate lines, and an operating
rule saying one is never evidence of another. Both boundary files carry the
standing-check result.
Verified live: security-canon resolves 11 of 11, interface-canon 23 of 25 with
the two known Interface and Endpoint pins. make check passes with 58 tests,
clean validation and those two 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:43:34 +02:00
|
|
|
from .federation import registered_drift
|
|
|
|
|
warnings.extend(registered_drift(context))
|
2026-05-23 03:12:02 +02:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"ok": not errors,
|
|
|
|
|
"errors": errors,
|
2026-05-23 03:32:16 +02:00
|
|
|
"warnings": warnings,
|
2026-05-23 03:12:02 +02:00
|
|
|
"metrics": checks.metrics,
|
|
|
|
|
"details": checks.details,
|
2026-09-05 00:50:09 +02:00
|
|
|
"coverage": coverage(context),
|
2026-05-23 03:12:02 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
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}
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 03:32:16 +02:00
|
|
|
def write_validation_report(
|
|
|
|
|
destination: Path | str,
|
|
|
|
|
root: Path | str | None = None,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
payload = validate_canon(root)
|
2026-09-05 00:50:09 +02:00
|
|
|
from .maintenance import source_evidence
|
|
|
|
|
payload["evidence"] = source_evidence(infospace_root(root))
|
2026-05-23 03:32:16 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 03:12:02 +02:00
|
|
|
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:
|
2026-05-23 03:12:02 +02:00
|
|
|
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 03:12:02 +02:00
|
|
|
)
|
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
|
2026-05-23 03:12:02 +02:00
|
|
|
|
|
|
|
|
|
2026-08-15 19:42:30 +02:00
|
|
|
def review_capability_record(
|
|
|
|
|
path: str | Path,
|
|
|
|
|
root: Path | str | None = None,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
from .capability import CapabilityReviewError, review_path
|
|
|
|
|
|
|
|
|
|
try:
|
2026-09-05 00:50:09 +02:00
|
|
|
return review_path(path, infospace_root(root) / "models/capability/capabilities.yaml")
|
2026-08-15 19:42:30 +02:00
|
|
|
except CapabilityReviewError as exc:
|
|
|
|
|
raise CanonServiceError(exc.code, exc.message) from exc
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 03:32:16 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 03:12:02 +02:00
|
|
|
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
|