Files
markitect-main/markitect/infospace/state.py
tegwick d44a4cd3df feat(infospace,llm): agent ergonomics — entity lookup, model fallback, better errors
- `markitect infospace entity <name>`: single-entity lookup tolerating
  hyphens/underscores/case, with substring matching, ambiguity listing,
  and near-match hints. Prints slug, source path, domain, chapter, word
  count, VSM system, overall score, evaluator, and evaluation file path.
- `markitect infospace evaluate --model-fallback <model>`: if any
  entities fail with a rate-limit error, retry just those with a fresh
  adapter on the fallback model (different free-tier models have
  separate quota buckets).
- `markitect llm-check`: advisory when `OPENROUTER_API_KEY` is set but
  not used by the resolved provider; targeted hint when OpenRouter
  returns 401 (almost always a stale env key).
- `build_state`: raises `TypeError` with actionable message if passed a
  path instead of an `InfospaceConfig` — prior failure mode was a
  confusing `AttributeError` deep in the stack.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 01:07:25 +02:00

148 lines
4.6 KiB
Python

"""
Infospace runtime state.
Computed from the current entities, evaluations, and metrics on disk.
Provides the data behind ``markitect infospace status`` and
``markitect infospace viability``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from markitect.infospace.config import InfospaceConfig, ViabilityThreshold
from markitect.infospace.models import EntityMeta
from markitect.infospace.evaluation import EvaluationSnapshot
@dataclass
class ViabilityResult:
"""Result of checking a single viability threshold."""
metric: str
value: float
threshold: ViabilityThreshold
passed: bool
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {
"metric": self.metric,
"value": self.value,
"passed": self.passed,
}
if self.threshold.min is not None:
d["min"] = self.threshold.min
if self.threshold.max is not None:
d["max"] = self.threshold.max
return d
@dataclass
class InfospaceState:
"""Current runtime state of an infospace.
Aggregates entity metadata, evaluation results, and viability
checks into a single queryable object.
"""
config: InfospaceConfig
entities: List[EntityMeta] = field(default_factory=list)
latest_snapshot: Optional[EvaluationSnapshot] = None
viability_results: List[ViabilityResult] = field(default_factory=list)
computed_at: datetime = field(default_factory=datetime.utcnow)
@property
def entity_count(self) -> int:
return len(self.entities)
@property
def topic_name(self) -> str:
return self.config.topic.name
@property
def is_viable(self) -> bool:
"""``True`` if all viability thresholds are met."""
if not self.viability_results:
return False
return all(r.passed for r in self.viability_results)
@property
def viability_pass_count(self) -> int:
return sum(1 for r in self.viability_results if r.passed)
@property
def viability_total_count(self) -> int:
return len(self.viability_results)
@property
def domains(self) -> List[str]:
"""Distinct domain values across all entities."""
return sorted({e.domain for e in self.entities if e.domain})
@property
def has_evaluations(self) -> bool:
return self.latest_snapshot is not None
def check_viability(self, metrics: Dict[str, float]) -> List[ViabilityResult]:
"""Check *metrics* against the configured viability thresholds.
Updates :attr:`viability_results` and returns the results.
"""
results: List[ViabilityResult] = []
for name, threshold in self.config.viability.items():
value = metrics.get(name, 0.0)
results.append(ViabilityResult(
metric=name,
value=value,
threshold=threshold,
passed=threshold.check(value),
))
self.viability_results = results
return results
def summary(self) -> Dict[str, Any]:
"""Return a summary dict suitable for display or serialisation."""
d: Dict[str, Any] = {
"topic": self.topic_name,
"entity_count": self.entity_count,
"domains": self.domains,
"has_evaluations": self.has_evaluations,
}
if self.viability_results:
d["viable"] = self.is_viable
d["viability_pass"] = self.viability_pass_count
d["viability_total"] = self.viability_total_count
if self.latest_snapshot:
d["last_evaluated"] = self.latest_snapshot.created_at.isoformat()
return d
def build_state(
config: InfospaceConfig,
entities: Optional[List[EntityMeta]] = None,
snapshot: Optional[EvaluationSnapshot] = None,
metrics: Optional[Dict[str, float]] = None,
) -> InfospaceState:
"""Build an :class:`InfospaceState` from available data.
This is a convenience function that assembles the state object
and optionally runs viability checks if *metrics* are provided.
"""
if not isinstance(config, InfospaceConfig):
raise TypeError(
f"build_state(config=...) expects an InfospaceConfig instance, "
f"got {type(config).__name__}. If you have a path, load the "
f"config first with load_infospace_config(path)."
)
state = InfospaceState(
config=config,
entities=entities or [],
latest_snapshot=snapshot,
)
if metrics is not None:
state.check_viability(metrics)
return state