Files
lore-engine-poc/plugins/world.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

171 lines
6.5 KiB
Python

"""
world plugin — pure Neo4j queries.
Tools:
- entity_context(name, world_id?): one-hop summary of a Person / Faction / Location / Item.
- was_true_at(relation, subject, object, at_time, world_id?): time-bounded edge lookup.
- state_at(entity, at_time, world_id?): comprehensive snapshot of an entity at a time.
- list_worlds(): distinct world_id values present in the graph.
Per docs/01-ontology.md, every node carries a `world_id` namespace and
all read tools filter by it. The default is "default", preserving v1
behaviour for callers that don't pass the parameter.
"""
from server import get_neo4j, REGISTRY
DEFAULT_WORLD = "default"
def _world(args):
"""Return the world_id from args, defaulting to DEFAULT_WORLD."""
return args.get("world_id") or DEFAULT_WORLD
def _q(query, params=None):
"""Run a single read query against Neo4j, return list of dicts."""
driver = get_neo4j()
with driver.session() as s:
result = s.run(query, params or {})
return [dict(r) for r in result]
@REGISTRY.tool(
name="entity_context",
description="One-hop summary of a named entity (Person, Faction, Location, Item, Event) in a given world. Returns labels, properties, and immediate relations.",
input_schema={
"type": "object",
"properties": {
"name": {"type": "string", "description": "Entity name to look up"},
"world_id": {"type": "string", "default": DEFAULT_WORLD,
"description": "World namespace; defaults to 'default'"},
},
"required": ["name"],
},
)
def entity_context(args):
name = args["name"]
world_id = _world(args)
rows = _q("""
MATCH (e)
WHERE (e:Person OR e:Faction OR e:Location OR e:Item OR e:Event)
AND (e.name = $name OR e.id = $name)
AND coalesce(e.world_id, $fallback_world) = $world_id
OPTIONAL MATCH (e)-[r]->(other)
WHERE type(r) IN ['MEMBER_OF','RULED','LOCATED_IN','PART_OF','PARENT_OF','SPOUSE_OF','POSSESSES','PARTICIPATED_IN']
RETURN e, labels(e) AS labels,
collect(DISTINCT {rel: type(r), to: other.name, to_id: other.id}) AS relations
LIMIT 1
""", {"name": name, "world_id": world_id, "fallback_world": DEFAULT_WORLD})
if not rows:
return {"found": False, "name": name, "world_id": world_id}
r = rows[0]
e = r["e"]
return {
"found": True,
"name": e.get("name"),
"id": e.get("id"),
"world_id": e.get("world_id") or DEFAULT_WORLD,
"labels": r["labels"],
"properties": {k: v for k, v in dict(e).items() if not k.startswith("_")},
"relations": [rel for rel in r["relations"] if rel.get("to")],
}
@REGISTRY.tool(
name="was_true_at",
description="Check whether a typed relation was true between subject and object at a given in-fiction time, in a given world. Times use the canonical {era}.{year} format, e.g. '2nd_age.year_340'.",
input_schema={
"type": "object",
"properties": {
"relation": {"type": "string", "description": "Edge type, e.g. RULED, ALLIED_WITH, MEMBER_OF"},
"subject": {"type": "string"},
"object": {"type": "string"},
"at_time": {"type": "string", "description": "Canonical time string, e.g. '2nd_age.year_340'"},
"world_id": {"type": "string", "default": DEFAULT_WORLD},
},
"required": ["relation", "subject", "object", "at_time"],
},
)
def was_true_at(args):
world_id = _world(args)
rows = _q("""
MATCH (s {name: $subject, world_id: $world_id})-[r:`%s`]->(o {name: $object, world_id: $world_id})
WHERE r.valid_from IS NULL OR $at_time >= r.valid_from
AND r.valid_until IS NULL OR $at_time <= r.valid_until
RETURN r, s, o
""" % args["relation"], {
"subject": args["subject"], "object": args["object"],
"at_time": args["at_time"], "world_id": world_id,
})
if not rows:
return {"was_true": False, "relation": args["relation"],
"subject": args["subject"], "object": args["object"],
"at_time": args["at_time"], "world_id": world_id}
r = rows[0]["r"]
return {
"was_true": True,
"relation": args["relation"],
"valid_from": r.get("valid_from"),
"valid_until": r.get("valid_until"),
"world_id": world_id,
}
@REGISTRY.tool(
name="state_at",
description="Snapshot of an entity at a given in-fiction time: who/what they were allied with, where they were located, what they held. Scoped to a world.",
input_schema={
"type": "object",
"properties": {
"entity": {"type": "string"},
"at_time": {"type": "string", "description": "Canonical time string, e.g. '2nd_age.year_340'"},
"world_id": {"type": "string", "default": DEFAULT_WORLD},
},
"required": ["entity", "at_time"],
},
)
def state_at(args):
world_id = _world(args)
rows = _q("""
MATCH (e {name: $entity, world_id: $world_id})
WHERE e:Person OR e:Faction OR e:Location OR e:Item
OPTIONAL MATCH (e)-[r]->(other)
WHERE type(r) IN ['MEMBER_OF','RULED','LOCATED_IN','PART_OF','POSSESSES','ALLIED_WITH','ENEMY_OF']
AND (r.valid_from IS NULL OR $at_time >= r.valid_from)
AND (r.valid_until IS NULL OR $at_time <= r.valid_until)
RETURN e, labels(e) AS labels,
collect(DISTINCT {rel: type(r), to: other.name}) AS active_relations
LIMIT 1
""", {"entity": args["entity"], "at_time": args["at_time"], "world_id": world_id})
if not rows:
return {"found": False, "entity": args["entity"], "world_id": world_id}
r = rows[0]
return {
"entity": r["e"].get("name"),
"at_time": args["at_time"],
"world_id": world_id,
"labels": r["labels"],
"active_relations": [x for x in r["active_relations"] if x.get("to")],
}
@REGISTRY.tool(
name="list_worlds",
description="Admin: list the distinct world_id values present in the graph. Useful to discover what parallel worlds exist.",
input_schema={"type": "object", "properties": {}},
)
def list_worlds(args):
rows = _q("""
MATCH (n)
WHERE n.world_id IS NOT NULL
RETURN DISTINCT n.world_id AS world_id
ORDER BY world_id
""")
return rows
def register(registry):
"""Plugin entry point — server.py calls this."""
# Decorators already registered via the @REGISTRY.tool wrappers above.
pass