Implement infospace scaffold and service baseline

This commit is contained in:
2026-05-23 03:12:02 +02:00
parent df6238c7e0
commit 9883a99f78
43 changed files with 35986 additions and 28 deletions

View File

@@ -0,0 +1,23 @@
"""InfoTechCanon service package."""
from .service import (
CanonServiceError,
artifact_graph,
inspect_canon,
list_artifacts,
list_models,
list_standards,
profile_inspect,
validate_canon,
)
__all__ = [
"CanonServiceError",
"artifact_graph",
"inspect_canon",
"list_artifacts",
"list_models",
"list_standards",
"profile_inspect",
"validate_canon",
]

View File

@@ -0,0 +1,3 @@
from .cli import main
raise SystemExit(main())

104
src/info_tech_canon/api.py Normal file
View File

@@ -0,0 +1,104 @@
from __future__ import annotations
import json
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
from .service import (
CanonServiceError,
artifact_graph,
inspect_canon,
list_artifacts,
list_models,
list_standards,
profile_inspect,
validate_canon,
)
def serve(host: str = "127.0.0.1", port: int = 8765, root: Path | None = None) -> None:
handler = _build_handler(root)
server = ThreadingHTTPServer((host, port), handler)
print(f"InfoTechCanon API listening on http://{host}:{port}", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
def _build_handler(root: Path | None) -> type[BaseHTTPRequestHandler]:
class CanonRequestHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
parsed = urlparse(self.path)
query = parse_qs(parsed.query)
try:
status, payload = _route(parsed.path, query, root)
except CanonServiceError as exc:
status, payload = HTTPStatus.BAD_REQUEST, exc.to_dict()
except Exception as exc:
status, payload = HTTPStatus.INTERNAL_SERVER_ERROR, {
"ok": False,
"error": {
"code": "unhandled_error",
"message": str(exc),
"details": {},
},
}
self._send_json(status, payload)
def log_message(self, format: str, *args: object) -> None:
return
def _send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None:
body = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
self.send_response(status.value)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return CanonRequestHandler
def _route(
path: str,
query: dict[str, list[str]],
root: Path | None,
) -> tuple[HTTPStatus, dict[str, Any]]:
if path == "/health":
return HTTPStatus.OK, {"ok": True, "service": "info-tech-canon"}
if path == "/inspect":
return HTTPStatus.OK, inspect_canon(root)
if path == "/artifacts":
return HTTPStatus.OK, list_artifacts(root, kind=_first(query, "kind"))
if path == "/models":
return HTTPStatus.OK, list_models(root)
if path == "/standards":
return HTTPStatus.OK, list_standards(root)
if path == "/validate":
payload = validate_canon(root)
return (HTTPStatus.OK if payload["ok"] else HTTPStatus.BAD_REQUEST), payload
if path == "/graph":
graph_format = _first(query, "format") or "json"
return HTTPStatus.OK, artifact_graph(root, output_format=graph_format)
if path.startswith("/profiles/") and path.endswith("/inspect"):
profile = path.removeprefix("/profiles/").removesuffix("/inspect").strip("/")
return HTTPStatus.OK, profile_inspect(profile, root)
return HTTPStatus.NOT_FOUND, {
"ok": False,
"error": {
"code": "not_found",
"message": f"Unknown endpoint: {path}",
"details": {"path": path},
},
}
def _first(query: dict[str, list[str]], name: str) -> str | None:
values = query.get(name) or []
return values[0] if values else None

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
import importlib.util
import sys
import types
from pathlib import Path
from types import ModuleType
from typing import Any
BENCH_PACKAGE = "_info_tech_canon_infospace_bench"
BENCH_SOURCE_ROOT = (
Path(__file__).resolve().parents[3] / "infospace-bench" / "src" / "infospace_bench"
)
def _ensure_package() -> ModuleType:
existing = sys.modules.get(BENCH_PACKAGE)
if existing is not None:
return existing
package = types.ModuleType(BENCH_PACKAGE)
package.__path__ = [str(BENCH_SOURCE_ROOT)] # type: ignore[attr-defined]
sys.modules[BENCH_PACKAGE] = package
return package
def _load_module(name: str) -> ModuleType:
_ensure_package()
module_name = f"{BENCH_PACKAGE}.{name}"
existing = sys.modules.get(module_name)
if existing is not None:
return existing
path = BENCH_SOURCE_ROOT / f"{name}.py"
if not path.is_file():
raise RuntimeError(f"Missing infospace-bench module: {path}")
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise RuntimeError(f"Unable to load infospace-bench module: {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
errors = _load_module("errors")
models = _load_module("models")
lifecycle = _load_module("lifecycle")
checks = _load_module("checks")
inspection = _load_module("inspection")
Infospace = models.Infospace
KnowledgeArtifact = models.KnowledgeArtifact
load_infospace = lifecycle.load_infospace
run_collection_checks = checks.run_collection_checks
relationship_summary = inspection.relationship_summary
export_mermaid = inspection.export_mermaid
__all__ = [
"Infospace",
"KnowledgeArtifact",
"export_mermaid",
"load_infospace",
"relationship_summary",
"run_collection_checks",
]

134
src/info_tech_canon/cli.py Normal file
View File

@@ -0,0 +1,134 @@
from __future__ import annotations
import argparse
import json
import sys
from collections.abc import Callable
from pathlib import Path
from typing import Any
from .api import serve
from .service import (
CanonServiceError,
artifact_graph,
inspect_canon,
list_artifacts,
list_models,
list_standards,
profile_inspect,
validate_canon,
)
Command = Callable[[argparse.Namespace], dict[str, Any]]
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="info-tech-canon")
parser.add_argument(
"--root",
default="",
help="Infospace root. Defaults to ./infospace from the repository root.",
)
sub = parser.add_subparsers(dest="command", required=True)
inspect = sub.add_parser("inspect", help="Inspect the canon infospace")
inspect.set_defaults(handler=_inspect)
artifacts = sub.add_parser("artifacts", help="List canon artifacts")
artifacts.add_argument("--kind", default="")
artifacts.set_defaults(handler=_artifacts)
models = sub.add_parser("models", help="List canon model artifacts")
models.set_defaults(handler=_models)
standards = sub.add_parser("standards", help="List canon standard artifacts")
standards.set_defaults(handler=_standards)
validate = sub.add_parser("validate", help="Validate the canon infospace")
validate.set_defaults(handler=_validate)
graph = sub.add_parser("graph", help="Export the canon artifact graph")
graph.add_argument("--format", choices=["json", "mermaid"], default="json")
graph.set_defaults(handler=_graph)
profile = sub.add_parser("profile", help="Inspect canon profiles")
profile_sub = profile.add_subparsers(dest="profile_command", required=True)
profile_inspect_cmd = profile_sub.add_parser("inspect", help="Inspect a profile")
profile_inspect_cmd.add_argument("profile")
profile_inspect_cmd.set_defaults(handler=_profile_inspect)
api = sub.add_parser("api", help="Run the read-only local API")
api.add_argument("--host", default="127.0.0.1")
api.add_argument("--port", type=int, default=8765)
api.set_defaults(handler=_api)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
handler: Command = args.handler
try:
result = handler(args)
except CanonServiceError as exc:
_print_json(exc.to_dict())
return 2
except Exception as exc:
_print_json(
{
"ok": False,
"error": {
"code": "unhandled_error",
"message": str(exc),
"details": {},
},
}
)
return 1
if result:
_print_json(result)
return 0 if result.get("ok", False) else 1
def _root(args: argparse.Namespace) -> Path | None:
return Path(args.root) if args.root else None
def _inspect(args: argparse.Namespace) -> dict[str, Any]:
return inspect_canon(_root(args))
def _artifacts(args: argparse.Namespace) -> dict[str, Any]:
return list_artifacts(_root(args), kind=args.kind or None)
def _models(args: argparse.Namespace) -> dict[str, Any]:
return list_models(_root(args))
def _standards(args: argparse.Namespace) -> dict[str, Any]:
return list_standards(_root(args))
def _validate(args: argparse.Namespace) -> dict[str, Any]:
return validate_canon(_root(args))
def _graph(args: argparse.Namespace) -> dict[str, Any]:
return artifact_graph(_root(args), output_format=args.format)
def _profile_inspect(args: argparse.Namespace) -> dict[str, Any]:
return profile_inspect(args.profile, _root(args))
def _api(args: argparse.Namespace) -> dict[str, Any]:
serve(host=args.host, port=args.port, root=_root(args))
return {}
def _print_json(data: dict[str, Any]) -> None:
json.dump(data, sys.stdout, indent=2, sort_keys=True)
sys.stdout.write("\n")

View File

@@ -0,0 +1,262 @@
from __future__ import annotations
from collections import Counter
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
import yaml
from .bench import (
Infospace,
KnowledgeArtifact,
export_mermaid,
load_infospace,
relationship_summary,
run_collection_checks,
)
REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_INFOSPACE_ROOT = REPO_ROOT / "infospace"
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:
infospace_root = Path(root) if root else DEFAULT_INFOSPACE_ROOT
try:
infospace = load_infospace(infospace_root)
except Exception as exc:
raise CanonServiceError(
"infospace_load_failed",
f"Unable to load infospace at {infospace_root}",
{"root": str(infospace_root), "reason": str(exc)},
) from exc
return CanonContext(
repo_root=REPO_ROOT,
infospace_root=infospace_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 validate_canon(root: Path | str | None = None) -> dict[str, Any]:
context = load_context(root)
errors: 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,
)
errors.extend(threshold_errors)
return {
"ok": not errors,
"errors": errors,
"metrics": checks.metrics,
"details": checks.details,
}
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)},
)
with profile_path.open("r", encoding="utf-8") as handle:
data = yaml.safe_load(handle) or {}
if not isinstance(data, dict):
raise CanonServiceError(
"invalid_profile",
f"Profile must be a YAML mapping: {profile}",
{"profile": profile, "path": str(profile_path)},
)
return {"ok": True, "profile": data, "path": str(profile_path)}
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