infospace-bench/src/infospace_bench/engine.py
2026-05-14 16:26:42 +02:00

365 lines
11 KiB
Python

from __future__ import annotations
import hashlib
import uuid
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Protocol
import yaml
from .errors import InfospaceError
from .lifecycle import load_infospace
from .models import Infospace, KnowledgeArtifact
ENGINE_DIR = Path("output/engine")
ASSET_STORE = ENGINE_DIR / "assets.yaml"
SYNC_RUN_DIR = ENGINE_DIR / "sync-runs"
@dataclass(frozen=True)
class EngineCapabilityContract:
integration_posture: str
file_backed: list[str]
engine_backed: list[str]
explicit_non_goals: list[str]
review_required: list[str]
assumptions: dict[str, str]
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class AssetRecord:
asset_id: str
artifact_id: str
infospace_slug: str
kind: str
title: str
path: str
digest: str
size_bytes: int
version: str
provenance: dict[str, Any] = field(default_factory=dict)
relationships: list[dict[str, Any]] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "AssetRecord":
return cls(
asset_id=str(data["asset_id"]),
artifact_id=str(data["artifact_id"]),
infospace_slug=str(data["infospace_slug"]),
kind=str(data["kind"]),
title=str(data.get("title") or ""),
path=str(data["path"]),
digest=str(data["digest"]),
size_bytes=int(data.get("size_bytes") or 0),
version=str(data.get("version") or data["digest"][:12]),
provenance=dict(data.get("provenance") or {}),
relationships=list(data.get("relationships") or []),
metadata=dict(data.get("metadata") or {}),
)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class AssetSyncAction:
action: str
asset_id: str
artifact_id: str
path: str
digest: str
previous_digest: str = ""
workflow_id: str = ""
reason: str = ""
def to_dict(self) -> dict[str, Any]:
return {
key: value
for key, value in asdict(self).items()
if value not in ("", None)
}
@dataclass(frozen=True)
class AssetSyncPlan:
infospace_slug: str
repository: str
dry_run: bool
status: str
actions: list[AssetSyncAction]
capabilities: EngineCapabilityContract
run_record_path: str = ""
def to_dict(self) -> dict[str, Any]:
data = {
"infospace_slug": self.infospace_slug,
"repository": self.repository,
"dry_run": self.dry_run,
"status": self.status,
"actions": [action.to_dict() for action in self.actions],
"contract": self.capabilities.to_dict(),
"run_record_path": self.run_record_path,
}
return {key: value for key, value in data.items() if value not in ("", [])}
class AssetRepository(Protocol):
repository_id: str
def get_asset(self, asset_id: str) -> AssetRecord | None:
"""Return an asset by durable ID if present."""
def list_assets(self) -> list[AssetRecord]:
"""Return all assets known to the repository."""
def upsert_asset(self, asset: AssetRecord) -> None:
"""Create or update a durable asset record."""
class LocalAssetRepository:
"""File-backed adapter used until a kontextual-engine client exists."""
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self.repository_id = f"local-file:{self.path}"
def get_asset(self, asset_id: str) -> AssetRecord | None:
return self._asset_map().get(asset_id)
def list_assets(self) -> list[AssetRecord]:
return list(self._asset_map().values())
def upsert_asset(self, asset: AssetRecord) -> None:
assets = self._asset_map()
assets[asset.asset_id] = asset
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(
yaml.safe_dump(
{
"assets": [
item.to_dict()
for item in sorted(
assets.values(),
key=lambda record: record.asset_id,
)
]
},
sort_keys=False,
),
encoding="utf-8",
)
def _asset_map(self) -> dict[str, AssetRecord]:
if not self.path.is_file():
return {}
data = yaml.safe_load(self.path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
return {}
return {
item.asset_id: item
for item in [
AssetRecord.from_dict(raw)
for raw in data.get("assets", [])
if isinstance(raw, dict)
]
}
def engine_capability_contract() -> EngineCapabilityContract:
return EngineCapabilityContract(
integration_posture="optional",
file_backed=[
"artifact_manifest",
"infospace_config",
"markdown_artifacts",
"evaluation_history",
"workflow_run_records",
"dry_run_sync_plans",
],
engine_backed=[
"asset_identity",
"asset_persistence",
"provenance_graph",
"relationship_index",
"retrieval",
"workflow_runs",
"permissions",
"audit",
],
explicit_non_goals=[
"markdown_syntax_rules",
"infospace_domain_methodology",
"repository_identity",
"provider_specific_llm_calls",
],
review_required=[
"apply_sync",
"engine_client_configuration",
"permission_changes",
],
assumptions={
"source": "kontextual-engine/INTENT.md",
"engine_role": "durable knowledge operations engine",
"bench_role": "application workspace with file-backed default behavior",
},
)
def plan_asset_sync(
root: str | Path,
repository: AssetRepository | None = None,
) -> AssetSyncPlan:
infospace = load_infospace(root)
repo = repository or LocalAssetRepository(infospace.root / ASSET_STORE)
actions = [
_sync_action(record, repo.get_asset(record.asset_id))
for record in _asset_records(infospace)
]
return AssetSyncPlan(
infospace_slug=infospace.config.slug,
repository=repo.repository_id,
dry_run=True,
status="planned",
actions=actions,
capabilities=engine_capability_contract(),
)
def sync_assets(
root: str | Path,
*,
repository: AssetRepository | None = None,
dry_run: bool = True,
) -> AssetSyncPlan:
infospace = load_infospace(root)
repo = repository or LocalAssetRepository(infospace.root / ASSET_STORE)
plan = plan_asset_sync(infospace.root, repo)
if dry_run:
return plan
records = {record.asset_id: record for record in _asset_records(infospace)}
for action in plan.actions:
if action.action in {"create", "update"}:
repo.upsert_asset(records[action.asset_id])
applied = AssetSyncPlan(
infospace_slug=plan.infospace_slug,
repository=plan.repository,
dry_run=False,
status="completed",
actions=plan.actions,
capabilities=plan.capabilities,
)
run_record_path = _write_sync_run(infospace.root, applied)
return AssetSyncPlan(
infospace_slug=applied.infospace_slug,
repository=applied.repository,
dry_run=applied.dry_run,
status=applied.status,
actions=applied.actions,
capabilities=applied.capabilities,
run_record_path=run_record_path,
)
def asset_id_for(infospace_slug: str, artifact_id: str) -> str:
return f"infospace:{infospace_slug}:artifact:{artifact_id}"
def _asset_records(infospace: Infospace) -> list[AssetRecord]:
return [
_asset_record_for(infospace, artifact)
for artifact in infospace.artifacts
]
def _asset_record_for(
infospace: Infospace,
artifact: KnowledgeArtifact,
) -> AssetRecord:
artifact_path = infospace.root / artifact.path
if not artifact_path.is_file():
raise InfospaceError(
"missing_artifact_file",
f"Manifest artifact file does not exist: {artifact.path}",
{"artifact_id": artifact.id, "path": artifact.path},
)
data = artifact_path.read_bytes()
digest = hashlib.sha256(data).hexdigest()
workflow_id = str(artifact.provenance.get("workflow_id") or "")
return AssetRecord(
asset_id=asset_id_for(infospace.config.slug, artifact.id),
artifact_id=artifact.id,
infospace_slug=infospace.config.slug,
kind=artifact.kind,
title=artifact.title,
path=artifact.path,
digest=digest,
size_bytes=len(data),
version=digest[:12],
provenance={
**artifact.provenance,
"artifact_id": artifact.id,
"artifact_path": artifact.path,
"infospace_slug": infospace.config.slug,
},
relationships=artifact.relationships,
metadata={
"workflow_id": workflow_id,
"source_path": artifact.provenance.get("source_path", ""),
},
)
def _sync_action(
desired: AssetRecord,
existing: AssetRecord | None,
) -> AssetSyncAction:
workflow_id = str(desired.provenance.get("workflow_id") or "")
if existing is None:
return AssetSyncAction(
action="create",
asset_id=desired.asset_id,
artifact_id=desired.artifact_id,
path=desired.path,
digest=desired.digest,
workflow_id=workflow_id,
reason="asset_not_present",
)
if existing.digest != desired.digest:
return AssetSyncAction(
action="update",
asset_id=desired.asset_id,
artifact_id=desired.artifact_id,
path=desired.path,
digest=desired.digest,
previous_digest=existing.digest,
workflow_id=workflow_id,
reason="digest_changed",
)
return AssetSyncAction(
action="unchanged",
asset_id=desired.asset_id,
artifact_id=desired.artifact_id,
path=desired.path,
digest=desired.digest,
previous_digest=existing.digest,
workflow_id=workflow_id,
reason="digest_matches",
)
def _write_sync_run(root: Path, plan: AssetSyncPlan) -> str:
run_id = uuid.uuid4().hex[:12]
path = root / SYNC_RUN_DIR / f"{run_id}.yaml"
payload = plan.to_dict()
payload["recorded_at"] = datetime.now(timezone.utc).isoformat()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8")
return str(path)