"""Rendering helpers for the FablePool inspection CLI. This module turns claims, evidence, operations, and derivation graphs into rich terminal output (tables, panels, trees) and into plain serializable structures for ``--json`` mode. It deliberately depends only on a small, stable surface of the reference node: * ``node.get_claim(id)`` / ``node.get_evidence(id)`` (raising a :class:`fablepool.errors.FablePoolError` subclass when absent), * ``node.claims()`` returning every claim regardless of status, * optionally ``node.dependents(id)``; if the node does not expose it we recompute dependents from claim sources. """ from __future__ import annotations import dataclasses import json from typing import Any, Dict, Iterable, List, Optional, Tuple from rich.console import Console from rich.panel import Panel from rich.table import Table from rich.text import Text from rich.tree import Tree from fablepool.errors import FablePoolError # --------------------------------------------------------------------------- # Basic helpers # --------------------------------------------------------------------------- STATUS_STYLES = { "active": "green", "refuted": "red", "invalidated": "yellow", "superseded": "magenta", } SHORT_ID_LEN = 16 def make_console(no_color: bool = False) -> Console: """Create a console for CLI output. Rich degrades to plain text in pipes.""" return Console(no_color=no_color, highlight=False, soft_wrap=True) def obj_id(obj: Any) -> str: """Return the identifier of a claim, evidence record, or operation.""" for attr in ("id", "claim_id", "evidence_id", "op_id"): value = getattr(obj, attr, None) if value: return str(value) if isinstance(obj, dict): for key in ("id", "claim_id", "evidence_id", "op_id"): if obj.get(key): return str(obj[key]) raise FablePoolError(f"object {obj!r} has no identifier") def short(identifier: str, n: int = SHORT_ID_LEN) -> str: """Shorten an identifier for display; prefixes remain resolvable.""" identifier = str(identifier) return identifier if len(identifier) <= n else identifier[:n] + "…" def to_plain(obj: Any) -> Dict[str, Any]: """Convert a claim/evidence/operation object to a plain dict.""" if isinstance(obj, dict): return dict(obj) if dataclasses.is_dataclass(obj) and not isinstance(obj, type): return dataclasses.asdict(obj) if hasattr(obj, "to_dict") and callable(obj.to_dict): return obj.to_dict() return {k: v for k, v in vars(obj).items() if not k.startswith("_")} def humanize(predicate: str) -> str: """Turn ``attends_weekly_yoga`` into ``attends weekly yoga``.""" return str(predicate).replace("_", " ").strip() def confidence_bar(confidence: Optional[float]) -> str: """Render a confidence value as a small bar plus percentage.""" if confidence is None: return "(no confidence)" confidence = max(0.0, min(1.0, float(confidence))) blocks = int(round(confidence * 8)) return "█" * blocks + "░" * (8 - blocks) + f" {int(round(confidence * 100))}%" def status_text(status: Optional[str]) -> Text: status = status or "unknown" return Text(status, style=STATUS_STYLES.get(status, "white")) # --------------------------------------------------------------------------- # Reference resolution # --------------------------------------------------------------------------- def ref_kind(node: Any, ref: str) -> Tuple[str, Any]: """Classify an identifier as a claim or evidence and return the object.""" try: return "claim", node.get_claim(ref) except (FablePoolError, KeyError, LookupError): pass try: return "evidence", node.get_evidence(ref) except (FablePoolError, KeyError, LookupError): pass raise FablePoolError(f"'{ref}' is neither a known claim nor evidence record") def direct_dependents(node: Any, ref_id: str) -> List[str]: """Claims that directly cite ``ref_id`` as a source.""" method = getattr(node, "dependents", None) if callable(method): try: return [str(d) for d in method(ref_id)] except (FablePoolError, KeyError, LookupError): return [] out: List[str] = [] for claim in node.claims(): sources = getattr(claim, "sources", None) or [] if ref_id in sources: out.append(obj_id(claim)) return out # --------------------------------------------------------------------------- # Snippets and labels # --------------------------------------------------------------------------- def evidence_snippet(evidence: Any, width: int = 60) -> str: """A one-line human summary of an evidence record's content.""" content = getattr(evidence, "content", None) or {} if isinstance(content, str): text = content else: for key in ("summary", "title", "text", "filename", "name"): if isinstance(content, dict) and content.get(key): text = str(content[key]) break else: text = json.dumps(content, sort_keys=True) text = " ".join(text.split()) return text if len(text) <= width else text[: width - 1] + "…" def claim_brief(claim: Any) -> str: predicate = getattr(claim, "predicate", "?") value = getattr(claim, "value", "?") return f"{humanize(predicate)} = {value}" def claim_label(claim: Any) -> Text: status = getattr(claim, "status", "unknown") label = Text() label.append(claim_brief(claim), style="bold") label.append(" ") label.append(f"[{status}]", style=STATUS_STYLES.get(status, "white")) label.append( f" conf {confidence_bar(getattr(claim, 'confidence', None))}" f" rule={getattr(claim, 'rule', '?')} {short(obj_id(claim))}", style="dim", ) return label def evidence_label(evidence: Any) -> Text: label = Text() label.append("evidence", style="cyan bold") label.append( f" {getattr(evidence, 'source', '?')}/{getattr(evidence, 'media_type', '?')}: " ) label.append(evidence_snippet(evidence)) label.append(f" {short(obj_id(evidence))}", style="dim") return label def op_summary(op: Any) -> str: """A one-line summary of an operation for log views.""" kind = getattr(op, "kind", "?") body = getattr(op, "body", None) or {} if not isinstance(body, dict): body = {} if kind == "evidence": source = body.get("source", "?") content = body.get("content", {}) hint = "" if isinstance(content, dict): for key in ("summary", "title", "filename"): if content.get(key): hint = f": {content[key]}" break return f"ingested from {source}{hint}" if kind == "claim": predicate = body.get("predicate", "?") value = body.get("value", "?") return f"{humanize(str(predicate))} = {value}" if kind in ("refutation", "correction"): target = body.get("target") or body.get("claim_id") or "?" reason = body.get("reason", "") suffix = f" ({reason})" if reason else "" return f"{kind} of {short(str(target))}{suffix}" if kind == "grant": audience = body.get("audience") or body.get("grantee") or "?" scope = body.get("scope") or body.get("topics") or "?" return f"grant to {audience}, scope {scope}" if kind == "revoke": target = body.get("target") or body.get("grant_id") or "?" return f"revoke {short(str(target))}" return json.dumps(body, sort_keys=True)[:60] if body else kind # --------------------------------------------------------------------------- # Tables and panels # --------------------------------------------------------------------------- def topics_table(counts: Dict[str, Tuple[int, int]]) -> Table: """``counts`` maps topic -> (active, total).""" table = Table(title="Topics", show_lines=False) table.add_column("topic", style="bold") table.add_column("active claims", justify="right") table.add_column("total claims", justify="right") for topic in sorted(counts): active, total = counts[topic] table.add_row(topic, str(active), str(total)) return table def claims_table(claims: Iterable[Any], title: str = "Claims") -> Table: table = Table(title=title, show_lines=False) table.add_column("id", style="dim") table.add_column("status") table.add_column("topic") table.add_column("claim", overflow="fold") table.add_column("confidence") table.add_column("rule", style="dim") for claim in claims: table.add_row( short(obj_id(claim)), status_text(getattr(claim, "status", None)), str(getattr(claim, "topic", "?")), claim_brief(claim), confidence_bar(getattr(claim, "confidence", None)), str(getattr(claim, "rule", "?")), ) return table def claim_detail(node: Any, claim: Any) -> Panel: table = Table.grid(padding=(0, 2)) table.add_column(style="bold dim", justify="right") table.add_column(overflow="fold") table.add_row("id", obj_id(claim)) table.add_row("status", status_text(getattr(claim, "status", None))) table.add_row("topic", str(getattr(claim, "topic", "?"))) table.add_row("subject", str(getattr(claim, "subject", "self"))) table.add_row("predicate", str(getattr(claim, "predicate", "?"))) table.add_row("value", str(getattr(claim, "value", "?"))) table.add_row("confidence", confidence_bar(getattr(claim, "confidence", None))) table.add_row("rule", str(getattr(claim, "rule", "?"))) table.add_row("created", str(getattr(claim, "created_at", "?"))) sources = getattr(claim, "sources", None) or [] source_lines = [] for src in sources: try: kind, obj = ref_kind(node, src) except FablePoolError: source_lines.append(f"? {short(src)} (unresolved)") continue if kind == "claim": source_lines.append(f"claim {short(src)} — {claim_brief(obj)}") else: source_lines.append( f"evidence {short(src)} — {getattr(obj, 'source', '?')}: {evidence_snippet(obj)}" ) table.add_row("sources", "\n".join(source_lines) if source_lines else "(none)") dependents = direct_dependents(node, obj_id(claim)) dep_lines = [] for dep in dependents: try: dep_claim = node.get_claim(dep) dep_lines.append(f"{short(dep)} — {claim_brief(dep_claim)}") except (FablePoolError, KeyError, LookupError): dep_lines.append(short(dep)) table.add_row("dependents", "\n".join(dep_lines) if dep_lines else "(none)") title = f"Claim: {claim_brief(claim)}" return Panel(table, title=title, border_style="blue") def evidence_detail(evidence: Any) -> Panel: table = Table.grid(padding=(0, 2)) table.add_column(style="bold dim", justify="right") table.add_column(overflow="fold") table.add_row("id", obj_id(evidence)) table.add_row("source", str(getattr(evidence, "source", "?"))) table.add_row("media type", str(getattr(evidence, "media_type", "?"))) table.add_row("observed at", str(getattr(evidence, "observed_at", "?"))) table.add_row("recorded at", str(getattr(evidence, "created_at", "?"))) content = getattr(evidence, "content", None) or {} pretty = ( content if isinstance(content, str) else json.dumps(content, indent=2, sort_keys=True) ) table.add_row("content", pretty) return Panel(table, title="Evidence record", border_style="cyan") def ops_table(ops: Iterable[Any], title: str = "Operation log") -> Table: table = Table(title=title, show_lines=False) table.add_column("#", justify="right", style="dim") table.add_column("op id", style="dim") table.add_column("kind") table.add_column("created", style="dim") table.add_column("summary", overflow="fold") for index, op in enumerate(ops): table.add_row( str(index), short(obj_id(op)), str(getattr(op, "kind", "?")), str(getattr(op, "created_at", "?")), op_summary(op), ) return table # --------------------------------------------------------------------------- # Derivation graph trees (provenance down, dependents down-stream) # --------------------------------------------------------------------------- def provenance_tree(node: Any, claim_id: str, max_depth: int = 6) -> Tree: """Root claim at the top, sources expanding downward to raw evidence.""" claim = node.get_claim(claim_id) root = Tree(claim_label(claim)) _expand_provenance(node, root, claim, depth=0, max_depth=max_depth, seen={claim_id}) return root def _expand_provenance(node: Any, branch: Tree, claim: Any, depth: int, max_depth: int, seen: set) -> None: if depth >= max_depth: branch.add(Text("… depth limit reached", style="dim")) return for src in getattr(claim, "sources", None) or []: if src in seen: branch.add(Text(f"(cycle) {short(src)}", style="red dim")) continue try: kind, obj = ref_kind(node, src) except FablePoolError: branch.add(Text(f"? unresolved source {short(src)}", style="red")) continue if kind == "claim": child = branch.add(claim_label(obj)) _expand_provenance(node, child, obj, depth + 1, max_depth, seen | {src}) else: branch.add(evidence_label(obj)) def dependents_tree(node: Any, ref_id: str, max_depth: int = 6) -> Tree: """Show every claim downstream of ``ref_id`` (what a correction touches).""" try: kind, obj = ref_kind(node, ref_id) label = claim_label(obj) if kind == "claim" else evidence_label(obj) except FablePoolError: label = Text(short(ref_id)) root = Tree(label) _expand_dependents(node, root, ref_id, depth=0, max_depth=max_depth, seen={ref_id}) return root def _expand_dependents(node: Any, branch: Tree, ref_id: str, depth: int, max_depth: int, seen: set) -> None: if depth >= max_depth: branch.add(Text("… depth limit reached", style="dim")) return for dep in direct_dependents(node, ref_id): if dep in seen: branch.add(Text(f"(cycle) {short(dep)}", style="red dim")) continue try: dep_claim = node.get_claim(dep) child = branch.add(claim_label(dep_claim)) except (FablePoolError, KeyError, LookupError): child = branch.add(Text(short(dep))) _expand_dependents(node, child, dep, depth + 1, max_depth, seen | {dep}) # --------------------------------------------------------------------------- # JSON-mode structures # --------------------------------------------------------------------------- def provenance_dict(node: Any, ref: str, max_depth: int = 6, _depth: int = 0, _seen: Optional[set] = None) -> Dict[str, Any]: """Nested provenance structure for ``--json`` output.""" _seen = _seen or set() if ref in _seen: return {"id": ref, "cycle": True} if _depth > max_depth: return {"id": ref, "truncated": True} try: kind, obj = ref_kind(node, ref) except FablePoolError: return {"id": ref, "unresolved": True} record: Dict[str, Any] = {"id": obj_id(obj), "kind": kind} if kind == "claim": record.update( predicate=getattr(obj, "predicate", None), value=getattr(obj, "value", None), topic=getattr(obj, "topic", None), confidence=getattr(obj, "confidence", None), rule=getattr(obj, "rule", None), status=getattr(obj, "status", None), sources=[ provenance_dict(node, src, max_depth, _depth + 1, _seen | {ref}) for src in (getattr(obj, "sources", None) or []) ], ) else: record.update( source=getattr(obj, "source", None), media_type=getattr(obj, "media_type", None), observed_at=getattr(obj, "observed_at", None), content=getattr(obj, "content", None), ) return record def dependents_dict(node: Any, ref: str, max_depth: int = 6, _depth: int = 0, _seen: Optional[set] = None) -> Dict[str, Any]: _seen = _seen or set() record: Dict[str, Any] = {"id": ref} try: kind, obj = ref_kind(node, ref) record["kind"] = kind if kind == "claim": record["predicate"] = getattr(obj, "predicate", None) record["status"] = getattr(obj, "status", None) except FablePoolError: record["unresolved"] = True if _depth > max_depth: record["truncated"] = True return record record["dependents"] = [ dependents_dict(node, dep, max_depth, _depth + 1, _seen | {ref}) for dep in direct_dependents(node, ref) if dep not in _seen ] return record