Files
lore-engine-poc/plugins/lineage.py
Kay 4f922899af T6: multi-world namespace — world_id on every node, list_worlds(), arda_greyscale seed
Every read tool (entity_context, state_at, was_true_at, ancestors_of,
descendants_of, lineage_of, recall_images, search_images_by_caption,
register_image) now accepts an optional world_id parameter, defaulting
to 'default' so v1 callers see no change.

* Neo4j: every Person/Faction/Location/Item/Event/Lineage/Image node
  carries a world_id string property. seed.py backfills existing nodes
  to 'default' and writes a second minimal world 'arda_greyscale'
  (2 people, 1 faction, 1 location, 4 relations, 1 event, 1 era).
* Cypher: every MATCH in the world/lineage/images plugins filters by
  world_id on the right node type.
* New admin tool list_worlds() — runs
  MATCH (n) WHERE n.world_id IS NOT NULL RETURN DISTINCT n.world_id
  to expose the namespace.
* Postgres image_manifest gains a world_id column (init.sql).
* 4 placeholder images generated for the greyscale world (portraits of
  Mael and Sira, Ashen Hall, the Ashen Oath).
* test.sh now calls every tool with world_id='default' to verify v1
  behaviour, plus a new section 12 that calls list_worlds().
* tests/test_multi_world.py: 14 pytest cases covering list_worlds,
  entity_context/world isolation, was_true_at, state_at, lineage
  filter, image recall isolation, and the image_manifest schema.

Verification:
  pytest tests/test_multi_world.py         14/14 pass
  bash test.sh                             12/12 sections green,
                                           MinIO image bytes flow 200 OK
  list_worlds() returns [{arda_greyscale}, {default}]
2026-06-16 23:09:40 +00:00

115 lines
4.1 KiB
Python

"""
lineage plugin — bloodline / family tree queries.
Tools:
- ancestors_of(person, generations, world_id?): walk PARENT_OF upward.
- descendants_of(person, generations, world_id?): walk PARENT_OF downward.
- lineage_of(person, world_id?): the Lineage node this person belongs to + its members.
Per docs/01-ontology.md, all entity lookups are scoped to a world_id.
"""
from server import get_neo4j, REGISTRY
DEFAULT_WORLD = "default"
def _world(args):
return args.get("world_id") or DEFAULT_WORLD
def _q(query, params=None):
driver = get_neo4j()
with driver.session() as s:
result = s.run(query, params or {})
return [dict(r) for r in result]
@REGISTRY.tool(
name="ancestors_of",
description="Walk PARENT_OF upstream from a person for N generations. Returns chain of ancestors with their lifespans. Scoped to a world.",
input_schema={
"type": "object",
"properties": {
"person": {"type": "string"},
"generations": {"type": "integer", "default": 5, "minimum": 1, "maximum": 20},
"world_id": {"type": "string", "default": DEFAULT_WORLD},
},
"required": ["person"],
},
)
def ancestors_of(args):
# In our schema, (parent)-[:PARENT_OF]->(child). So to get ancestors of `person`,
# we walk PARENT_OF in the *incoming* direction, i.e. (ancestor)-[:PARENT_OF]->(person).
world_id = _world(args)
rows = _q("""
MATCH path = (ancestor:Person {world_id: $world_id})-[:PARENT_OF*1..%d]->(p:Person {name: $person, world_id: $world_id})
UNWIND nodes(path) AS n
WITH ancestor WHERE ancestor <> p
RETURN DISTINCT ancestor.name AS name, ancestor.born AS born, ancestor.died AS died,
ancestor.id AS id
ORDER BY ancestor.born ASC
""" % args.get("generations", 5), {"person": args["person"], "world_id": world_id})
return {"ancestors": rows, "world_id": world_id}
@REGISTRY.tool(
name="descendants_of",
description="Walk PARENT_OF downward from a person for N generations. Returns all known descendants. Scoped to a world.",
input_schema={
"type": "object",
"properties": {
"person": {"type": "string"},
"generations": {"type": "integer", "default": 5, "minimum": 1, "maximum": 20},
"world_id": {"type": "string", "default": DEFAULT_WORLD},
},
"required": ["person"],
},
)
def descendants_of(args):
# In our schema, (parent)-[:PARENT_OF]->(child). So descendants of `person` follow
# the outgoing PARENT_OF direction.
world_id = _world(args)
rows = _q("""
MATCH (a:Person {name: $person, world_id: $world_id})-[:PARENT_OF*1..%d]->(desc:Person {world_id: $world_id})
RETURN DISTINCT desc.name AS name, desc.born AS born, desc.died AS died,
desc.id AS id
ORDER BY desc.born ASC
""" % args.get("generations", 5), {"person": args["person"], "world_id": world_id})
return {"descendants": rows, "world_id": world_id}
@REGISTRY.tool(
name="lineage_of",
description="The Lineage group this person belongs to, plus all other members of the bloodline. Scoped to a world.",
input_schema={
"type": "object",
"properties": {
"person": {"type": "string"},
"world_id": {"type": "string", "default": DEFAULT_WORLD},
},
"required": ["person"],
},
)
def lineage_of(args):
world_id = _world(args)
rows = _q("""
MATCH (p:Person {name: $person, world_id: $world_id})-[:MEMBER_OF]->(lin:Lineage)
OPTIONAL MATCH (other:Person {world_id: $world_id})-[:MEMBER_OF]->(lin)
RETURN lin.name AS lineage, lin.id AS lineage_id,
collect(DISTINCT {name: other.name, born: other.born, died: other.died}) AS members
""", {"person": args["person"], "world_id": world_id})
if not rows:
return {"found": False, "person": args["person"], "world_id": world_id}
r = rows[0]
return {
"person": args["person"],
"lineage": r["lineage"],
"lineage_id": r["lineage_id"],
"members": r["members"],
"world_id": world_id,
}
def register(registry):
pass