Files
python-sdk/pkg/hanzo-memory/tests/test_graph_links.py
T
zeekay 136a4e3ff1 brain: port graph-links + recipes to hanzo-memory (Python parity with bot/extensions)
Adds the zero-LLM typed-link extractor + YAML recipe loader to the
existing hanzo-memory Python SDK so a single brain.db file can be
written by the TS bot and read by the Python SDK and vice versa.

  graph_links.py — Edge dataclass, extract_edges(), reconcile(),
                   slugify(). Same six edge types
                   (mentions/attended/works_at/invested_in/founded/
                   advises), same regex + role inference, same code-
                   fence stripping. Pure — no I/O, no LLM.

  recipes.py     — list_recipes() + load_recipe(name). Reads built-in
                   recipes/ dir + optional HANZO_BRAIN_RECIPES path.

  recipes/email.yaml — flagship swipe-to-reply recipe, byte-identical
                       to the TS pack so cross-runtime brains agree.

  tests/test_graph_links.py — mirror of TS suite. 12 assertions cover
                              slugify, every edge type, code-fence
                              stripping, dedup, bare slugs, reconcile,
                              and recipe load.

hanzo-memory already had pluggable backends (SQLite + sqlite-vec via
.db.factory) and an MCP server; this fills in the gbrain-shape graph +
recipe surfaces so the Python SDK reaches feature parity with the bot
extensions in TS.
2026-05-10 18:39:39 -07:00

117 lines
4.3 KiB
Python

"""Tests for hanzo_memory.graph_links — mirror of @hanzo/bot-graph-links TS suite."""
from hanzo_memory.graph_links import Edge, extract_edges, reconcile, slugify
# ── slugify ─────────────────────────────────────────────────────────
def test_slugify_lowercases_dashes_ascii() -> None:
assert slugify("Acme AI Inc.") == "acme-ai-inc"
assert slugify("José's Pizza") == "jose-s-pizza"
assert slugify("Slack & Discord") == "slack-and-discord"
# ── extract_edges ───────────────────────────────────────────────────
def _has(edges: list[Edge], **fields: object) -> bool:
return any(all(getattr(e, k) == v for k, v in fields.items()) for e in edges)
def test_mentions_from_md_links() -> None:
edges = extract_edges("originals/idea-1", "Inspired by [Alice](people/alice) at Acme.")
assert _has(edges, target="people/alice", type="mentions")
def test_meeting_emits_attended_not_mentions() -> None:
edges = extract_edges(
"meetings/2026-05-10",
"Met with [Bob](people/bob) and [Carol](people/carol).",
page_type="meeting",
)
types = {e.type for e in edges}
assert "attended" in types
assert "mentions" not in types
def test_founded_inference() -> None:
edges = extract_edges("people/alice", "Alice co-founded Acme AI. She also runs Beta Co.")
assert _has(edges, type="founded", target="companies/acme-ai")
def test_invested_in_inference() -> None:
e1 = extract_edges("people/dan", "Dan invested in Foobar.")
assert _has(e1, type="invested_in", target="companies/foobar")
e2 = extract_edges("people/erin", "Erin led Quux's seed round.")
assert _has(e2, type="invested_in", target="companies/quux")
def test_advises_inference() -> None:
edges = extract_edges("people/frank", "Frank is an advisor to Globex.")
assert _has(edges, type="advises", target="people/globex")
def test_works_at_inference() -> None:
e1 = extract_edges("people/grace", "Grace is the CEO of Acme.")
assert _has(e1, type="works_at", target="companies/acme")
e2 = extract_edges("people/henry", "Henry joined Initech in 2024.")
assert _has(e2, type="works_at", target="companies/initech")
def test_strip_code_fences() -> None:
edges = extract_edges(
"concepts/snippet",
"Normal: [link](people/real). Code:\n```\nfake = [should](people/fake)\n```\n",
)
targets = {e.target for e in edges}
assert "people/real" in targets
assert "people/fake" not in targets
def test_dedup_same_target_same_type() -> None:
edges = extract_edges("originals/x", "[Alice](people/alice) and again [Alice](people/alice).")
alice = [e for e in edges if e.target == "people/alice" and e.type == "mentions"]
assert len(alice) == 1
def test_bare_slug_refs() -> None:
edges = extract_edges("concepts/note", "See people/alice and companies/acme-ai.")
targets = {e.target for e in edges}
assert "people/alice" in targets
assert "companies/acme-ai" in targets
# ── reconcile ───────────────────────────────────────────────────────
def test_reconcile_add_remove() -> None:
prior = [Edge("a", "x", "mentions"), Edge("a", "y", "mentions")]
next_ = [Edge("a", "y", "mentions"), Edge("a", "z", "mentions")]
add, remove = reconcile(prior, next_)
assert [e.target for e in add] == ["z"]
assert [e.target for e in remove] == ["x"]
def test_reconcile_no_change() -> None:
same = [Edge("a", "x", "mentions")]
add, remove = reconcile(same, same)
assert add == [] and remove == []
# ── recipes ─────────────────────────────────────────────────────────
def test_recipes_list_and_load() -> None:
from hanzo_memory.recipes import list_recipes, load_recipe
names = list_recipes()
assert "email" in names
email = load_recipe("email")
assert email["recipe"] == "email"
assert email["version"] == 1
assert email["cron"].startswith("*/30")