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}]
115 lines
4.1 KiB
Python
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
|