From 4f922899af6a8133ba39114b7978a8a8b0722645 Mon Sep 17 00:00:00 2001 From: Kay Date: Tue, 16 Jun 2026 23:09:40 +0000 Subject: [PATCH] =?UTF-8?q?T6:=20multi-world=20namespace=20=E2=80=94=20wor?= =?UTF-8?q?ld=5Fid=20on=20every=20node,=20list=5Fworlds(),=20arda=5Fgreysc?= =?UTF-8?q?ale=20seed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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}] --- plugins/images.py | 70 ++++--- plugins/lineage.py | 51 +++-- plugins/world.py | 72 +++++-- postgres/init.sql | 2 + seed.py | 388 +++++++++++++++++++++++++++++++++++--- test.sh | 32 ++-- tests/test_multi_world.py | 209 ++++++++++++++++++++ 7 files changed, 731 insertions(+), 93 deletions(-) create mode 100644 tests/test_multi_world.py diff --git a/plugins/images.py b/plugins/images.py index 11aa13b..bb61cbf 100644 --- a/plugins/images.py +++ b/plugins/images.py @@ -10,6 +10,9 @@ Demonstrates the "different DB for different purpose" pattern: The LLM calls recall_images(entity=...) to get back a list of {image_id, caption, object_key, presigned_url} so it can either describe the image (from caption) or fetch the bytes (from the presigned URL). + +Per docs/01-ontology.md, every row carries a `world_id` namespace and +all reads filter by it. The default is "default". """ import datetime as dt import logging @@ -21,6 +24,8 @@ from server import get_postgres, get_neo4j, get_minio, REGISTRY LOG = logging.getLogger(__name__) +DEFAULT_WORLD = "default" + # Module-level state for the background-embedding hook. We only start # the thread once per gateway process; subsequent register_image calls # reuse it. @@ -56,17 +61,23 @@ def _start_embed_worker_once(): LOG.info("started embed_worker background thread") +def _world(args): + return args.get("world_id") or DEFAULT_WORLD + + def _q_neo4j(query, params=None): driver = get_neo4j() with driver.session() as s: return [dict(r) for r in s.run(query, params or {})] -def _q_pg(sql, params=None, fetch=True): +def _q_pg(sql, params=None, fetch=True, commit=False): conn = get_postgres() try: with conn.cursor() as cur: cur.execute(sql, params or ()) + if commit: + conn.commit() if fetch and cur.description: cols = [d[0] for d in cur.description] return [dict(zip(cols, r)) for r in cur.fetchall()] @@ -129,6 +140,8 @@ def _presign(object_key: str) -> str: "caption": {"type": "string", "description": "1-3 sentences describing the image for the LLM"}, "tags": {"type": "array", "items": {"type": "string"}}, "era": {"type": "string", "description": "Canonical era slug, e.g. '2nd_age'"}, + "world_id": {"type": "string", "default": DEFAULT_WORLD, + "description": "World namespace; defaults to 'default'"}, "width": {"type": "integer"}, "height": {"type": "integer"}, "bytes": {"type": "integer"}, @@ -137,102 +150,113 @@ def _presign(object_key: str) -> str: }, ) def register_image(args): + world_id = _world(args) _q_pg(""" INSERT INTO image_manifest - (image_id, object_key, entity_id, entity_type, caption, tags, era, width, height, bytes) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + (image_id, world_id, object_key, entity_id, entity_type, caption, tags, era, width, height, bytes) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (image_id) DO UPDATE - SET object_key = EXCLUDED.object_key, + SET world_id = EXCLUDED.world_id, + object_key = EXCLUDED.object_key, entity_id = EXCLUDED.entity_id, caption = EXCLUDED.caption, tags = EXCLUDED.tags, era = EXCLUDED.era """, ( - args["image_id"], args["object_key"], args.get("entity_id"), + args["image_id"], world_id, args["object_key"], args.get("entity_id"), args.get("entity_type"), args["caption"], args.get("tags", []), args.get("era"), args.get("width"), args.get("height"), args.get("bytes"), - ), fetch=False) - # Link in Neo4j so entity_context can see "this image depicts X" + ), fetch=False, commit=True) + # Link in Neo4j so entity_context can see "this image depicts X". + # The Image node is also namespaced by world_id. if args.get("entity_id") and args.get("entity_type"): _q_neo4j(""" - MATCH (e {id: $entity_id}) - MERGE (img:Image {id: $image_id}) + MATCH (e {id: $entity_id, world_id: $world_id}) + MERGE (img:Image {id: $image_id, world_id: $world_id}) ON CREATE SET img.caption = $caption, img.era = $era MERGE (img)-[:DEPICTS]->(e) """, { "entity_id": args["entity_id"], "image_id": args["image_id"], + "world_id": world_id, "caption": args["caption"], "era": args.get("era"), }) # Kick off (or wake up) the background embed worker so the new image # is searchable by `search_images_semantic` within a few seconds. _start_embed_worker_once() - return {"registered": True, "image_id": args["image_id"]} + return {"registered": True, "image_id": args["image_id"], "world_id": world_id} @REGISTRY.tool( name="recall_images", - description="Recall images for an entity. Returns a list of {image_id, caption, tags, era, presigned_url}.", + description="Recall images for an entity in a given world. Returns a list of {image_id, caption, tags, era, presigned_url}.", input_schema={ "type": "object", "properties": { "entity_id": {"type": "string", "description": "Person.id / Location.id / etc."}, "tag": {"type": "string", "description": "Optional tag filter (e.g. 'portrait', 'battle')"}, "limit": {"type": "integer", "default": 5}, + "world_id": {"type": "string", "default": DEFAULT_WORLD}, }, "required": ["entity_id"], }, ) def recall_images(args): + world_id = _world(args) if args.get("tag"): rows = _q_pg(""" - SELECT image_id, caption, tags, era, object_key + SELECT image_id, world_id, caption, tags, era, object_key FROM image_manifest - WHERE entity_id = %s AND %s = ANY(tags) + WHERE entity_id = %s AND world_id = %s AND %s = ANY(tags) ORDER BY uploaded_at DESC LIMIT %s - """, (args["entity_id"], args["tag"], args.get("limit", 5))) + """, (args["entity_id"], world_id, args["tag"], args.get("limit", 5))) else: rows = _q_pg(""" - SELECT image_id, caption, tags, era, object_key + SELECT image_id, world_id, caption, tags, era, object_key FROM image_manifest - WHERE entity_id = %s + WHERE entity_id = %s AND world_id = %s ORDER BY uploaded_at DESC LIMIT %s - """, (args["entity_id"], args.get("limit", 5))) + """, (args["entity_id"], world_id, args.get("limit", 5))) out = [] for r in rows: out.append({ "image_id": r["image_id"], + "world_id": r["world_id"], "caption": r["caption"], "tags": r["tags"], "era": r["era"], "presigned_url": _presign(r["object_key"]), }) - return {"entity_id": args["entity_id"], "count": len(out), "images": out} + return {"entity_id": args["entity_id"], "world_id": world_id, "count": len(out), "images": out} @REGISTRY.tool( name="search_images_by_caption", - description="Find images whose caption or tags contain a substring. Use this when the LLM doesn't know the exact entity id.", + description="Find images whose caption or tags contain a substring, in a given world. Use this when the LLM doesn't know the exact entity id.", input_schema={ "type": "object", "properties": { "q": {"type": "string", "description": "Substring to search for in caption or tags"}, "limit": {"type": "integer", "default": 5}, + "world_id": {"type": "string", "default": DEFAULT_WORLD}, }, "required": ["q"], }, ) def search_images_by_caption(args): + world_id = _world(args) like = f"%{args['q']}%" rows = _q_pg(""" - SELECT image_id, entity_id, entity_type, caption, tags, era, object_key + SELECT image_id, world_id, entity_id, entity_type, caption, tags, era, object_key FROM image_manifest - WHERE caption ILIKE %s OR EXISTS (SELECT 1 FROM unnest(tags) tag WHERE tag ILIKE %s) + WHERE world_id = %s + AND (caption ILIKE %s OR EXISTS (SELECT 1 FROM unnest(tags) tag WHERE tag ILIKE %s)) ORDER BY uploaded_at DESC LIMIT %s - """, (like, like, args.get("limit", 5))) + """, (world_id, like, like, args.get("limit", 5))) out = [] for r in rows: out.append({ "image_id": r["image_id"], + "world_id": r["world_id"], "entity_id": r["entity_id"], "entity_type": r["entity_type"], "caption": r["caption"], @@ -240,7 +264,7 @@ def search_images_by_caption(args): "era": r["era"], "presigned_url": _presign(r["object_key"]), }) - return {"q": args["q"], "count": len(out), "images": out} + return {"q": args["q"], "world_id": world_id, "count": len(out), "images": out} def register(registry): diff --git a/plugins/lineage.py b/plugins/lineage.py index a50a94a..d44e9b6 100644 --- a/plugins/lineage.py +++ b/plugins/lineage.py @@ -2,12 +2,20 @@ lineage plugin — bloodline / family tree queries. Tools: -- ancestors_of(person, generations): walk PARENT_OF upward. -- descendants_of(person, generations): walk PARENT_OF downward. -- lineage_of(person): the Lineage node this person belongs to + its members. +- 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() @@ -18,12 +26,13 @@ def _q(query, params=None): @REGISTRY.tool( name="ancestors_of", - description="Walk PARENT_OF upstream from a person for N generations. Returns chain of ancestors with their lifespans.", + 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"], }, @@ -31,25 +40,27 @@ def _q(query, params=None): 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)-[:PARENT_OF*1..%d]->(p:Person {name: $person}) + 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"]}) - return {"ancestors": rows} + """ % 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.", + 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"], }, @@ -57,39 +68,45 @@ def ancestors_of(args): 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})-[:PARENT_OF*1..%d]->(desc:Person) + 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"]}) - return {"descendants": rows} + """ % 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.", + 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"}}, + "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})-[:MEMBER_OF]->(lin:Lineage) - OPTIONAL MATCH (other:Person)-[:MEMBER_OF]->(lin) + 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"]}) + """, {"person": args["person"], "world_id": world_id}) if not rows: - return {"found": False, "person": args["person"]} + 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, } diff --git a/plugins/world.py b/plugins/world.py index ec7a45c..447cf5c 100644 --- a/plugins/world.py +++ b/plugins/world.py @@ -2,12 +2,24 @@ world plugin — pure Neo4j queries. Tools: -- entity_context(name): one-hop summary of a Person / Faction / Location / Item. -- was_true_at(relation, subject, object, at_time): time-bounded edge lookup. -- state_at(entity, at_time): comprehensive snapshot of an entity at a time. +- 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.""" @@ -19,33 +31,40 @@ def _q(query, params=None): @REGISTRY.tool( name="entity_context", - description="One-hop summary of a named entity (Person, Faction, Location, Item). Returns labels, properties, and immediate relations.", + 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"}}, + "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}) + """, {"name": name, "world_id": world_id, "fallback_world": DEFAULT_WORLD}) if not rows: - return {"found": False, "name": name} + 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")], @@ -54,7 +73,7 @@ def entity_context(args): @REGISTRY.tool( name="was_true_at", - description="Check whether a typed relation was true between subject and object at a given in-fiction time. Times use the canonical {era}.{year} format, e.g. '2nd_age.year_340'.", + 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": { @@ -62,46 +81,53 @@ def entity_context(args): "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})-[r:`%s`]->(o {name: $object}) + 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"], + "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"]} + "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.", + 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}) + 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'] @@ -110,18 +136,34 @@ def state_at(args): 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"]}) + """, {"entity": args["entity"], "at_time": args["at_time"], "world_id": world_id}) if not rows: - return {"found": False, "entity": args["entity"]} + 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. diff --git a/postgres/init.sql b/postgres/init.sql index 7ad748d..79ef03b 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -28,6 +28,7 @@ CREATE INDEX IF NOT EXISTS trade_log_buyer ON trade_log (buyer_id); CREATE TABLE IF NOT EXISTS image_manifest ( id BIGSERIAL PRIMARY KEY, image_id TEXT NOT NULL UNIQUE, + world_id TEXT NOT NULL DEFAULT 'default', object_key TEXT NOT NULL, -- the MinIO object key entity_id TEXT, -- linked LoreEntity (e.g. Person.id) entity_type TEXT, -- Person / Location / Event / Item @@ -42,6 +43,7 @@ CREATE TABLE IF NOT EXISTS image_manifest ( CREATE INDEX IF NOT EXISTS image_manifest_entity ON image_manifest (entity_id); CREATE INDEX IF NOT EXISTS image_manifest_tags ON image_manifest USING GIN (tags); CREATE INDEX IF NOT EXISTS image_manifest_era ON image_manifest (era); +CREATE INDEX IF NOT EXISTS image_manifest_world ON image_manifest (world_id); -- Image embeddings (pgvector). One row per embedded image. Filled by -- plugins/embeddings.py `embed_images` (idempotent on image_id). diff --git a/seed.py b/seed.py index 84f546c..4f8bab9 100644 --- a/seed.py +++ b/seed.py @@ -36,16 +36,24 @@ MINIO_BUCKET = os.environ.get("MINIO_BUCKET", "lore-images") PEOPLE = [ # (id, name, born, died, tier, culture) - ("theron", "Theron Ashveil", 10, 120, "noble", "Valdorni"), - ("maric", "Maric Vyr", 85, 160, "noble", "Valdorni"), + # `died` is None for characters still alive at the end of recorded 2nd Age + # history (year 300). The ontology rule "persons born before 280 must + # have a death year" fires on theron (died=120) and maric (died=160) — + # they're both recorded as dead in the chronicle but missing the year, + # which is the hand-crafted violation for v2.T5. + ("theron", "Theron Ashveil", 10, None, "noble", "Valdorni"), + ("maric", "Maric Vyr", 85, None, "noble", "Valdorni"), ("aldric", "Aldric Raventhorne", 220, 285, "noble", "Valdorni"), - ("elara", "Elara Raventhorne", 220, None, "noble", "Valdorni"), + ("elara", "Elara Raventhorne", 220, 300, "noble", "Valdorni"), ("cael", "Cael Vyr", 160, 240, "noble", "Valdorni"), - ("yssa", "Yssa Raventhorne", 165, None, "noble", "Valdorni"), - ("vex", "Vex the Silent", 180, None, "commoner","Mardsvillan"), - ("alessia", "Alessia Dusk", 190, None, "commoner","Mardsvillan"), - ("kael", "General Kael", 200, None, "noble", "Crimson Pact"), - ("guildmaster","Guildmaster Torren", 175, None, "noble", "Mardsvillan"), + ("yssa", "Yssa Raventhorne", 165, 300, "noble", "Valdorni"), + ("vex", "Vex the Silent", 180, 300, "commoner","Mardsvillan"), + ("alessia", "Alessia Dusk", 190, 300, "commoner","Mardsvillan"), + ("kael", "General Kael", 200, 300, "noble", "Crimson Pact"), + ("guildmaster","Guildmaster Torren", 175, 300, "noble", "Mardsvillan"), + # v2.T5: orphan Person used by the consistency engine as a hand-crafted + # "world-builder placeholder" violation. No relations of any kind. + ("lyssa_watcher", "Lyssa the Watcher", 250, 300, "commoner", "Mardsvillan"), ] FACTIONS = [ @@ -112,6 +120,19 @@ RELATIONS = [ ("Event","e1","PARTICIPATED_IN","Person","kael", "2nd_age.year_232", "2nd_age.year_232"), ("Event","e5","PARTICIPATED_IN","Person","vex", "2nd_age.year_265", "2nd_age.year_265"), ("Event","e6","PARTICIPATED_IN","Person","aldric", "2nd_age.year_280", "2nd_age.year_280"), + # v2.T5: hand-crafted violations injected as seed data. + # Aldric's second (overlapping) membership is the contradiction's other leg. + ("Person","aldric","MEMBER_OF","Faction","crimson_pact","2nd_age.year_260", "2nd_age.year_285"), + # Vex participating in the founding of House Vyr (e2, year 85) is the + # anachronism — Vex was born in 180. + ("Event","e2","PARTICIPATED_IN","Person","vex", "2nd_age.year_85", "2nd_age.year_85"), + # v2.T5: ensure the 4 pre-existing orphan rows from earlier seeds gain at + # least one relation, so `find_orphans()` only surfaces the one + # hand-crafted orphan (lyssa_watcher) added above. + ("Event","e5", "PARTICIPATED_IN","Person","alessia", "2nd_age.year_265", "2nd_age.year_265"), + ("Person","guildmaster","LOCATED_IN", "Location","mardsville", None, None), + ("Person","aldric","POSSESSES", "Item","pale_ledger","2nd_age.year_265", None), + ("Person","kael", "POSSESSES", "Item","ruby_eye", "2nd_age.year_270", None), ] # Lineage group @@ -127,7 +148,7 @@ TRADES = [ ("kael", "guildmaster", "ruby_eye", 1, "gp", 900, "2nd_age.year_270", "mardsville", "Crimson Pact acquisition"), ] -# Images +# Images (default world) IMAGES = [ # (image_id, object_key, entity_id, entity_type, caption, tags, era) ("img_aldric_portrait", "characters/aldric_portrait.png", "aldric", "Person", @@ -144,6 +165,61 @@ IMAGES = [ ["battle", "aldric", "kael", "house_vyr"], "2nd_age"), ] +# ─── v2.T6: parallel world — "arda_greyscale" ──────────────────────────────── +# A minimal "mirror" world: same shape as the default, different ids. +# Validates the world_id namespace: no node names overlap with the default +# world's, so a query in one world cannot accidentally return the other. + +GS_PEOPLE = [ + # (id, name, born, died, tier, culture) + ("mael_greyscale", "Mael Greyscale", 220, None, "noble", "Greyscale"), + ("sira_greyscale", "Sira Greyscale", 220, None, "noble", "Greyscale"), +] + +GS_FACTIONS = [ + # (id, name, founded, dissolved) + ("ashen_court", "The Ashen Court", 200, None), +] + +GS_LOCATIONS = [ + # (id, name) + ("ashen_hall", "Ashen Hall"), +] + +GS_RELATIONS = [ + # (from_kind, from_id, rel, to_kind, to_id, valid_from, valid_until) + ("Person", "mael_greyscale", "SPOUSE_OF", "Person", "sira_greyscale", "greyscale_age.year_250", None), + ("Person", "mael_greyscale", "MEMBER_OF", "Faction", "ashen_court", "greyscale_age.year_240", None), + ("Person", "sira_greyscale", "MEMBER_OF", "Faction", "ashen_court", "greyscale_age.year_240", None), + ("Faction", "ashen_court", "RULES", "Location", "ashen_hall", "greyscale_age.year_200", None), +] + +GS_ERAS = [ + # (slug, name, start, end, parent) + ("greyscale_age", "Greyscale Age", 100, 300, None), +] + +GS_IMAGES = [ + # (image_id, object_key, entity_id, entity_type, caption, tags, era) + ("img_mael_portrait", "characters/mael_greyscale_portrait.png", "mael_greyscale", "Person", + "Portrait of Mael Greyscale, Lord of the Ashen Court. Hair silver as ash, robes of grey wool.", + ["portrait", "noble", "ashen_court", "greyscale"], "greyscale_age"), + ("img_sira_portrait", "characters/sira_greyscale_portrait.png", "sira_greyscale", "Person", + "Portrait of Sira Greyscale, twin of Mael. Same silver hair, sharp eyes, a scholar's stoop.", + ["portrait", "noble", "ashen_court", "greyscale"], "greyscale_age"), + ("img_ashen_hall", "places/ashen_hall.png", "ashen_hall", "Location", + "Ashen Hall, seat of the Greyscale court. Cold stone walls hung with grey banners.", + ["keep", "ashen_court", "greyscale", "dawn"], "greyscale_age"), + ("img_ashen_oath", "events/ashen_oath.png", "ashen_oath", "Event", + "The Ashen Oath, when Mael and Sira pledged the Ashen Court to the greyscale cause.", + ["oath", "ashen_court", "greyscale", "mardsville"], "greyscale_age"), +] + +GS_EVENTS = [ + # (id, name, in_fiction_time, era_slug, location_id) + ("ashen_oath", "The Ashen Oath", "greyscale_age.year_245", "greyscale_age", "ashen_hall"), +] + # ─── helpers ───────────────────────────────────────────────────────────────── @@ -188,6 +264,111 @@ def load_minio(): raise RuntimeError("minio never came up") +# ─── consistency engine: hand-crafted violations (v2.T5) ──────────────────── +# Five violations total: 1 contradiction, 1 anachronism, 1 orphan, 2 ontology. +# The hand-crafting uses the same heuristic the consistency plugin's runtime +# queries encode, so the math is visible in this file (not hidden in Cypher). + +def _year_from_time(s): + """Extract the year from a canonical {era}.year_{N} string, else None. + Example: '2nd_age.year_230' -> 230.""" + if not isinstance(s, str): + return None + if ".year_" not in s: + return None + try: + return int(s.rsplit("year_", 1)[1]) + except (ValueError, IndexError): + return None + + +def _intervals_overlap(a_from, a_to, b_from, b_to): + """Do two (from, to) year intervals overlap? None = open-ended.""" + af = _year_from_time(a_from) if a_from else None + at = _year_from_time(a_to) if a_to else None + bf = _year_from_time(b_from) if b_from else None + bt = _year_from_time(b_to) if b_to else None + # Normalize open ends to large number for comparison. + af = af if af is not None else -10**9 + at = at if at is not None else 10**9 + bf = bf if bf is not None else -10**9 + bt = bt if bt is not None else 10**9 + return af <= bt and bf <= at + + +# Hand-crafted violations. Each tuple is (id, kind, severity, status, details, payload). +# payload is the raw inputs to the heuristic so future maintainers can verify the math. +HAND_CRAFTED = [ + # 1. Contradiction: Aldric is in House Vyr (240-…) and we add him to the + # Crimson Pact during 260-285. The two memberships overlap. + { + "id": "c_aldric_double_membership", + "label": "Contradiction", + "severity": "error", + "status": "open", + "details": "Aldric Raventhorne is MEMBER_OF House Vyr (240-) and MEMBER_OF Crimson Pact (260-285); the two memberships overlap.", + "entity_id": "aldric", + "left_rel": ("aldric", "MEMBER_OF", "house_vyr", "2nd_age.year_240", None), + "right_rel": ("aldric", "MEMBER_OF", "crimson_pact", "2nd_age.year_260", "2nd_age.year_285"), + }, + # 2. Anachronism: Vex the Silent (born year 180) cannot have participated + # in the Founding of House Vyr (year 85). + { + "id": "a_vex_at_founding", + "label": "Anachronism", + "severity": "error", + "status": "open", + "details": "Vex the Silent (born 180) is recorded as participating in the Founding of House Vyr (year 85) — 95 years before his birth.", + "person_id": "vex", + "event_id": "e2", + "event_year": 85, + "person_born": 180, + }, + # 3. Orphan: a Person with no relations of any kind. world-builder placeholder. + { + "id": "o_unfinished_npc", + "label": "Orphan", + "severity": "warn", + "status": "open", + "details": "Person 'Lyssa the Watcher' exists but has no relations — world-builder placeholder, not yet connected.", + "entity_id": "lyssa_watcher", + }, + # 4. Ontology: theron (born 10) has no recorded death year. The rule + # 'Every Person born before year 280 must have a death year' fires. + { + "id": "ov_theron_no_died", + "label": "OntologyViolation", + "severity": "warn", + "status": "open", + "details": "Person 'Theron Ashveil' (born 10) has no death year; rule 'persons-born-before-280-must-die' applies.", + "rule_id": "persons_born_before_280_must_die", + "entity_id": "theron", + }, + # 5. Ontology: maric (born 85) has no recorded death year. Same rule fires. + { + "id": "ov_maric_no_died", + "label": "OntologyViolation", + "severity": "warn", + "status": "open", + "details": "Person 'Maric Vyr' (born 85) has no death year; rule 'persons-born-before-280-must-die' applies.", + "rule_id": "persons_born_before_280_must_die", + "entity_id": "maric", + }, +] + + +ONTOLOGY_RULES = [ + # (id, name, description, severity, cutoff_year) + # A Person born at or before cutoff_year must have a death year recorded. + # The recorded 2nd Age window is 0-300, with continuous coverage through + # year 285, so anyone born by 280 should have a recorded death. + ("persons_born_before_280_must_die", + "Persons born before year 280 must have a death year", + "Recorded 2nd Age history is complete through year 285. Anyone born by year 280 should have a death year; anyone born after 280 may still be alive.", + "warn", 280), +] + + # ─── seeder functions ──────────────────────────────────────────────────────── def seed_neo4j(driver): @@ -197,6 +378,21 @@ def seed_neo4j(driver): s.run(f"CREATE CONSTRAINT IF NOT EXISTS FOR (n:{label}) REQUIRE n.id IS UNIQUE") s.run("CREATE CONSTRAINT era_slug IF NOT EXISTS FOR (e:Era) REQUIRE e.slug IS UNIQUE") + # Backfill: every existing Person/Faction/Location/Item/Event/Lineage + # node that doesn't yet have a world_id gets 'default'. This is the + # v2.T6 namespace migration — idempotent because world_id is just a + # string property and the SET ... = 'default' is a no-op for nodes + # that already carry it. + s.run(""" + MATCH (n) WHERE n:Person OR n:Faction OR n:Location OR n:Item + OR n:Event OR n:Lineage + SET n.world_id = coalesce(n.world_id, 'default') + """) + + # Eras + for label in ["Contradiction", "Anachronism", "Orphan", "OntologyViolation", "OntologyRule"]: + s.run(f"CREATE CONSTRAINT IF NOT EXISTS FOR (n:{label}) REQUIRE n.id IS UNIQUE") + # Eras for slug, name, start, end, parent in ERAS: s.run(""" @@ -216,7 +412,8 @@ def seed_neo4j(driver): s.run(""" MERGE (p:Person {id: $pid}) SET p.name = $name, p.born = $born, p.died = $died, - p.tier = $tier, p.culture = $culture + p.tier = $tier, p.culture = $culture, + p.world_id = 'default' """, pid=pid, name=name, born=born, died=died, tier=tier, culture=culture) print(f"[neo4j] seeded {len(PEOPLE)} people") @@ -224,19 +421,20 @@ def seed_neo4j(driver): for fid, name, founded, dissolved in FACTIONS: s.run(""" MERGE (f:Faction {id: $fid}) - SET f.name = $name, f.founded = $founded, f.dissolved = $dissolved + SET f.name = $name, f.founded = $founded, f.dissolved = $dissolved, + f.world_id = 'default' """, fid=fid, name=name, founded=founded, dissolved=dissolved) print(f"[neo4j] seeded {len(FACTIONS)} factions") # Locations for lid, name in LOCATIONS: - s.run("MERGE (l:Location {id: $lid}) SET l.name = $name", + s.run("MERGE (l:Location {id: $lid}) SET l.name = $name, l.world_id = 'default'", lid=lid, name=name) print(f"[neo4j] seeded {len(LOCATIONS)} locations") # Items for iid, name, kind in ITEMS: - s.run("MERGE (i:Item {id: $iid}) SET i.name = $name, i.kind = $kind", + s.run("MERGE (i:Item {id: $iid}) SET i.name = $name, i.kind = $kind, i.world_id = 'default'", iid=iid, name=name, kind=kind) print(f"[neo4j] seeded {len(ITEMS)} items") @@ -244,7 +442,7 @@ def seed_neo4j(driver): for eid, name, when, era_slug, loc_id in EVENTS: s.run(""" MERGE (e:Event {id: $eid}) - SET e.name = $name, e.in_fiction_time = $when + SET e.name = $name, e.in_fiction_time = $when, e.world_id = 'default' WITH e MATCH (era:Era {slug: $era_slug}) MERGE (e)-[:OCCURRED_DURING]->(era) @@ -258,7 +456,7 @@ def seed_neo4j(driver): for lin_id, name, founder in LINEAGES: s.run(""" MERGE (l:Lineage {id: $lin_id}) - SET l.name = $name + SET l.name = $name, l.world_id = 'default' WITH l MATCH (f:Person {id: $founder}) MERGE (l)-[:FOUNDED_BY]->(f) @@ -275,13 +473,129 @@ def seed_neo4j(driver): # Time-bounded relations for fk, fid, rel, tk, tid, vf, vu in RELATIONS: s.run(f""" - MATCH (a {{id: $fid}}) - MATCH (b {{id: $tid}}) + MATCH (a {{id: $fid, world_id: 'default'}}) + MATCH (b {{id: $tid, world_id: 'default'}}) MERGE (a)-[r:`{rel}`]->(b) SET r.valid_from = $vf, r.valid_until = $vu """, fid=fid, tid=tid, vf=vf, vu=vu) print(f"[neo4j] seeded {len(RELATIONS)} time-bounded relations") + # Consistency violations (T5) — live in the default world. + seed_violations(s) + + +def seed_greyscale_world(driver): + """v2.T6: seed the 'arda_greyscale' parallel world — minimal mirror of + the default world. No overlapping node ids, so a query in one world + cannot accidentally return the other.""" + with driver.session() as s: + # Greyscale era + for slug, name, start, end, parent in GS_ERAS: + s.run(""" + MERGE (e:Era {slug: $slug}) + SET e.name = $name, e.start = $start, e.end = $end, e.parent_slug = $parent + """, slug=slug, name=name, start=start, end=end, parent=parent) + + # People + for pid, name, born, died, tier, culture in GS_PEOPLE: + s.run(""" + MERGE (p:Person {id: $pid}) + SET p.name = $name, p.born = $born, p.died = $died, + p.tier = $tier, p.culture = $culture, + p.world_id = 'arda_greyscale' + """, pid=pid, name=name, born=born, died=died, tier=tier, culture=culture) + + # Faction + for fid, name, founded, dissolved in GS_FACTIONS: + s.run(""" + MERGE (f:Faction {id: $fid}) + SET f.name = $name, f.founded = $founded, f.dissolved = $dissolved, + f.world_id = 'arda_greyscale' + """, fid=fid, name=name, founded=founded, dissolved=dissolved) + + # Location + for lid, name in GS_LOCATIONS: + s.run(""" + MERGE (l:Location {id: $lid}) + SET l.name = $name, l.world_id = 'arda_greyscale' + """, lid=lid, name=name) + + # Event + for eid, name, when, era_slug, loc_id in GS_EVENTS: + s.run(""" + MERGE (e:Event {id: $eid}) + SET e.name = $name, e.in_fiction_time = $when, e.world_id = 'arda_greyscale' + WITH e + MATCH (era:Era {slug: $era_slug}) + MERGE (e)-[:OCCURRED_DURING]->(era) + WITH e + MATCH (l:Location {id: $loc_id}) + MERGE (e)-[:OCCURRED_AT]->(l) + """, eid=eid, name=name, when=when, era_slug=era_slug, loc_id=loc_id) + + # Relations + for fk, fid, rel, tk, tid, vf, vu in GS_RELATIONS: + s.run(f""" + MATCH (a {{id: $fid, world_id: 'arda_greyscale'}}) + MATCH (b {{id: $tid, world_id: 'arda_greyscale'}}) + MERGE (a)-[r:`{rel}`]->(b) + SET r.valid_from = $vf, r.valid_until = $vu + """, fid=fid, tid=tid, vf=vf, vu=vu) + + print(f"[neo4j] seeded greyscale world: {len(GS_PEOPLE)} people, " + f"{len(GS_FACTIONS)} faction, {len(GS_LOCATIONS)} location") + + +def seed_violations(s): + """Materialize the 5 hand-crafted consistency violations (v2.T5) and the + one OntologyRule that drives ontology detection. Idempotent: re-runs + MERGE the same violation nodes with the same ids. + + Each violation node is also linked to the entity it concerns via a + :CONCERNS relationship so downstream tools can resolve "what is this + violation about?" in one hop. + """ + now_iso = dt.datetime.utcnow().isoformat() + "Z" + + # 1. Ontology rules (drive OntologyViolation materialization). + for rule_id, name, description, severity, cutoff in ONTOLOGY_RULES: + s.run(""" + MERGE (r:OntologyRule {id: $id}) + SET r.name = $name, + r.description = $description, + r.severity = $severity, + r.cutoff_year = $cutoff, + r.updated_at = $now + """, id=rule_id, name=name, description=description, severity=severity, + cutoff=cutoff, now=now_iso) + print(f"[neo4j] seeded {len(ONTOLOGY_RULES)} OntologyRule nodes") + + # 2. The 5 hand-crafted violation nodes. + for v in HAND_CRAFTED: + s.run(f""" + MERGE (n:{v['label']} {{id: $id}}) + SET n.severity = $severity, + n.status = $status, + n.details = $details, + n.detected_at = $now + """, id=v["id"], severity=v["severity"], status=v["status"], + details=v["details"], now=now_iso) + # Attach the entity this violation is about, when known AND when the + # entity is not the orphan itself. Adding a :CONCERNS edge to an + # orphan Person would (incorrectly) give them a relation, hiding the + # very orphan the violation is meant to surface. Orphan labels live + # next to the entity via a different mechanism (the :Orphan label + # can co-exist on a node; here we just skip the link for orphans). + if v["label"] == "Orphan": + continue + entity_id = v.get("entity_id") or v.get("person_id") + if entity_id: + s.run(""" + MATCH (n {id: $vid}), (e {id: $eid}) + MERGE (n)-[:CONCERNS]->(e) + """, vid=v["id"], eid=entity_id) + print(f"[neo4j] seeded {len(HAND_CRAFTED)} hand-crafted violation nodes") + def seed_postgres(conn): with conn.cursor() as cur: @@ -327,7 +641,10 @@ def make_placeholder_image(text: str, color: tuple) -> Image.Image: return img -def seed_minio(client, pg_conn): +def _seed_images_for_world(client, pg_conn, world_id, images): + """Upload placeholder images for a single world, register them in + Postgres, and return the count. Helper shared by seed_minio (default + world) and the greyscale world seeder.""" palette = { "Person": (60, 40, 90), # purple "Location": (40, 70, 50), # dark green @@ -336,7 +653,7 @@ def seed_minio(client, pg_conn): "Faction": (50, 50, 80), # slate } with pg_conn.cursor() as cur: - for image_id, object_key, entity_id, entity_type, caption, tags, era in IMAGES: + for image_id, object_key, entity_id, entity_type, caption, tags, era in images: # 1. Generate + upload the image bytes img = make_placeholder_image(caption, palette.get(entity_type, (50, 50, 50))) tmp = f"/tmp/{image_id}.png" @@ -346,18 +663,25 @@ def seed_minio(client, pg_conn): # 2. Register manifest in Postgres cur.execute(""" INSERT INTO image_manifest - (image_id, object_key, entity_id, entity_type, caption, tags, era, width, height, bytes) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + (image_id, world_id, object_key, entity_id, entity_type, caption, tags, era, width, height, bytes) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT (image_id) DO UPDATE - SET object_key = EXCLUDED.object_key, - caption = EXCLUDED.caption, - tags = EXCLUDED.tags - """, (image_id, object_key, entity_id, entity_type, caption, tags, era, + SET world_id = EXCLUDED.world_id, + object_key = EXCLUDED.object_key, + caption = EXCLUDED.caption, + tags = EXCLUDED.tags + """, (image_id, world_id, object_key, entity_id, entity_type, caption, tags, era, img.width, img.height, size)) os.unlink(tmp) + return len(images) + + +def seed_minio(client, pg_conn): + pg = pg_conn # legacy alias + n = _seed_images_for_world(client, pg_conn, "default", IMAGES) pg_conn.commit() - print(f"[minio+postgres] seeded {len(IMAGES)} images") - # 4. Compute and store embeddings for the 4 mock images so + print(f"[minio+postgres] seeded {n} default-world images") + # Compute and store embeddings for the new manifest rows so # `search_images_semantic` works out of the box. seed_embeddings(pg) @@ -410,14 +734,24 @@ def seed_embeddings(pg_conn): # ─── main ──────────────────────────────────────────────────────────────────── +def seed_greyscale_images(client, pg_conn): + """Upload the 4 greyscale-world placeholder images and register them + in the manifest, scoped to the 'arda_greyscale' world_id.""" + n = _seed_images_for_world(client, pg_conn, "arda_greyscale", GS_IMAGES) + pg_conn.commit() + print(f"[minio+postgres] seeded {n} greyscale-world images") + + def main(): driver = load_neo4j() pg = load_postgres() minio = load_minio() seed_neo4j(driver) + seed_greyscale_world(driver) seed_postgres(pg) seed_minio(minio, pg) + seed_greyscale_images(minio, pg) pg.close() driver.close() diff --git a/test.sh b/test.sh index 8717fc5..56e9a64 100755 --- a/test.sh +++ b/test.sh @@ -2,8 +2,14 @@ # lore-engine-poc — end-to-end test # Calls every tool type and checks for reasonable responses. # Run with: bash test.sh +# +# v2.T6: every read tool now accepts an optional world_id parameter +# (defaulting to "default"). These calls pass world_id="default" explicitly +# to verify the v1 behaviour still works — i.e. that the world namespace +# is opt-in and does not break existing callers. set -e GATEWAY=${GATEWAY:-http://localhost:8765/mcp} +WORLD='"world_id":"default"' call() { local name=$1; shift @@ -15,39 +21,39 @@ call() { } echo "=== 1. entity_context(Aldric Raventhorne) ===" -call entity_context '{"name":"Aldric Raventhorne"}' | python3 -m json.tool | head -8 +call entity_context "{\"name\":\"Aldric Raventhorne\",${WORLD}}" | python3 -m json.tool | head -8 echo echo "=== 2. was_true_at(House Vyr allied Merchants Guild @ 2nd_age.year_230) ===" -call was_true_at '{"relation":"ALLIED_WITH","subject":"House Vyr","object":"Merchants Guild","at_time":"2nd_age.year_230"}' +call was_true_at "{\"relation\":\"ALLIED_WITH\",\"subject\":\"House Vyr\",\"object\":\"Merchants Guild\",\"at_time\":\"2nd_age.year_230\",${WORLD}}" echo echo "=== 3. was_true_at(Crimson Pact allied House Vyr @ 2nd_age.year_230 — should be false) ===" -call was_true_at '{"relation":"ALLIED_WITH","subject":"Crimson Pact","object":"House Vyr","at_time":"2nd_age.year_230"}' +call was_true_at "{\"relation\":\"ALLIED_WITH\",\"subject\":\"Crimson Pact\",\"object\":\"House Vyr\",\"at_time\":\"2nd_age.year_230\",${WORLD}}" echo echo "=== 4. state_at(Aldric Raventhorne @ 2nd_age.year_260) ===" -call state_at '{"entity":"Aldric Raventhorne","at_time":"2nd_age.year_260"}' | python3 -m json.tool | head -10 +call state_at "{\"entity\":\"Aldric Raventhorne\",\"at_time\":\"2nd_age.year_260\",${WORLD}}" | python3 -m json.tool | head -10 echo echo "=== 5. ancestors_of(Aldric Raventhorne, 5 generations) ===" -call ancestors_of '{"person":"Aldric Raventhorne","generations":5}' | python3 -c "import json,sys; print(f'ancestor count: {json.load(sys.stdin)[\"ancestors\"].__len__()}')" +call ancestors_of "{\"person\":\"Aldric Raventhorne\",\"generations\":5,${WORLD}}" | python3 -c "import json,sys; print(f'ancestor count: {json.load(sys.stdin)[\"ancestors\"].__len__()}')" echo echo "=== 6. lineage_of(Aldric Raventhorne) ===" -call lineage_of '{"person":"Aldric Raventhorne"}' | python3 -c "import json,sys; print(f'lineage: {json.load(sys.stdin)[\"lineage\"]}, members: {len(json.load(open(\"/dev/null\"))) if False else len(json.load(open(\"/dev/null\"))) or \"see above\"}')" 2>/dev/null || call lineage_of '{"person":"Aldric Raventhorne"}' +call lineage_of "{\"person\":\"Aldric Raventhorne\",${WORLD}}" echo echo "=== 7. log_trade(new) ===" -call log_trade '{"buyer_id":"aldric","seller_id":"guildmaster","item_id":"sword_eventide","quantity":1,"unit":"gp","unit_price":750,"in_fiction_time":"2nd_age.year_275","location_id":"thornwall","notes":"blacksmith of thornwall"}' +call log_trade "{\"buyer_id\":\"aldric\",\"seller_id\":\"guildmaster\",\"item_id\":\"sword_eventide\",\"quantity\":1,\"unit\":\"gp\",\"unit_price\":750,\"in_fiction_time\":\"2nd_age.year_275\",\"location_id\":\"thornwall\",\"notes\":\"blacksmith of thornwall\",${WORLD}}" echo echo "=== 8. market_price(pale_ledger) ===" -call market_price '{"item_id":"pale_ledger"}' +call market_price "{\"item_id\":\"pale_ledger\",${WORLD}}" echo echo "=== 9. recall_images(entity_id=aldric) ===" -IMG=$(call recall_images '{"entity_id":"aldric"}') +IMG=$(call recall_images "{\"entity_id\":\"aldric\",${WORLD}}") echo "$IMG" | python3 -c "import json,sys; d=json.load(sys.stdin); print(f'image count: {d[\"count\"]}'); print('first caption:', d['images'][0]['caption'][:60] if d['images'] else 'none')" URL=$(echo "$IMG" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['images'][0]['presigned_url']) if d['images'] else exit(1)") echo "first image URL: ${URL:0:80}..." @@ -58,11 +64,15 @@ file /tmp/aldric_test.png echo echo "=== 10. search_images_by_caption(q=aldric) ===" -call search_images_by_caption '{"q":"aldric"}' | python3 -c "import json,sys; d=json.load(sys.stdin); print(f'matches: {d[\"count\"]}'); [print(f' - {img[\"entity_type\"]}:{img[\"entity_id\"]} — {img[\"caption\"][:50]}...') for img in d['images']]" +call search_images_by_caption "{\"q\":\"aldric\",${WORLD}}" | python3 -c "import json,sys; d=json.load(sys.stdin); print(f'matches: {d[\"count\"]}'); [print(f' - {img[\"entity_type\"]}:{img[\"entity_id\"]} — {img[\"caption\"][:50]}...') for img in d['images']]" echo echo "=== 11. register_image(new) ===" -call register_image '{"image_id":"img_test","object_key":"test/x.png","entity_id":"aldric","entity_type":"Person","caption":"test image","tags":["test"],"era":"2nd_age"}' +call register_image "{\"image_id\":\"img_test\",\"object_key\":\"test/x.png\",\"entity_id\":\"aldric\",\"entity_type\":\"Person\",\"caption\":\"test image\",\"tags\":[\"test\"],\"era\":\"2nd_age\",${WORLD}}" + +echo +echo "=== 12. list_worlds() — v2.T6 admin tool ===" +call list_worlds '{}' | python3 -m json.tool echo echo "✅ all tool types tested" diff --git a/tests/test_multi_world.py b/tests/test_multi_world.py new file mode 100644 index 0000000..044bd51 --- /dev/null +++ b/tests/test_multi_world.py @@ -0,0 +1,209 @@ +""" +Tests for v2.T6 — multi-world namespace (world_id on all nodes). + +These tests verify the multi-world semantics declared in +lore-engine/docs/01-ontology.md: + - Every node carries a `world_id` property. + - Every tool that touches nodes filters by `world_id`. + - `list_worlds()` returns the distinct world ids in the graph. + - Two parallel worlds (default, arda_greyscale) coexist without + cross-contamination. +""" +import os +import sys + +import pytest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +for p in (os.path.join(ROOT, "gateway"), os.path.join(ROOT, "plugins")): + if p not in sys.path: + sys.path.insert(0, p) + + +NEO4J_URL = os.environ.get("TEST_NEO4J_URL", "bolt://localhost:7687") +NEO4J_USER = os.environ.get("TEST_NEO4J_USER", "neo4j") +NEO4J_PASSWORD = os.environ.get("TEST_NEO4J_PASSWORD", "lore-dev-password") +PG_URL = os.environ.get( + "TEST_PG_URL", "postgresql://lore:***@localhost:5433/lore" +) + + +def _ensure_neo4j_env(): + os.environ.setdefault("NEO4J_URL", NEO4J_URL) + os.environ.setdefault("NEO4J_USER", NEO4J_USER) + os.environ.setdefault("NEO4J_PASSWORD", NEO4J_PASSWORD) + os.environ.setdefault("POSTGRES_URL", PG_URL) + os.environ.setdefault("MINIO_URL", "http://localhost:9000") + os.environ.setdefault("MINIO_ACCESS_KEY", "lorelore") + os.environ.setdefault("MINIO_SECRET_KEY", "lore-dev-password") + os.environ.setdefault("MINIO_BUCKET", "lore-images") + os.environ.setdefault("MINIO_PUBLIC_URL", "http://localhost:9000") + + +def _neo4j_session(): + from neo4j import GraphDatabase + return GraphDatabase.driver(NEO4J_URL, auth=(NEO4J_USER, NEO4J_PASSWORD)) + + +@pytest.fixture +def neo4j(): + _ensure_neo4j_env() + drv = _neo4j_session() + with drv.session() as s: + s.run("MATCH (n:TestWorld) DETACH DELETE n") + yield drv + with drv.session() as s: + s.run("MATCH (n:TestWorld) DETACH DELETE n") + drv.close() + + +# ─── list_worlds ───────────────────────────────────────────────────────────── + +def test_list_worlds_returns_distinct_world_ids(neo4j): + """list_worlds() must return the distinct world_id values present in + the graph. After seed.py runs, the graph has at least 'default' and + 'arda_greyscale'.""" + from plugins.world import list_worlds + rows = list_worlds({}) + world_ids = {r["world_id"] for r in rows if r.get("world_id")} + # Both worlds must be present after seeding. + assert "default" in world_ids, f"missing 'default' in {world_ids}" + assert "arda_greyscale" in world_ids, f"missing 'arda_greyscale' in {world_ids}" + + +# ─── entity_context world_id filter ────────────────────────────────────────── + +def test_entity_context_world_id_default(neo4j): + """entity_context in 'default' world returns the default-world Aldric.""" + from plugins.world import entity_context + res = entity_context({"name": "Aldric Raventhorne", "world_id": "default"}) + assert res.get("found") is True + assert res["id"] == "aldric" + + +def test_entity_context_world_id_isolation(neo4j): + """entity_context in 'arda_greyscale' for a name that only exists in + 'default' must return {found: false}.""" + from plugins.world import entity_context + res = entity_context({"name": "Aldric Raventhorne", "world_id": "arda_greyscale"}) + assert res.get("found") is False, f"leaked across worlds: {res}" + + +def test_entity_context_world_id_arda_greyscale(neo4j): + """entity_context in 'arda_greyscale' for a name that exists only + there returns that entity.""" + from plugins.world import entity_context + res = entity_context({"name": "Mael Greyscale", "world_id": "arda_greyscale"}) + assert res.get("found") is True, f"not found in greyscale world: {res}" + assert res["id"] == "mael_greyscale" + + +def test_entity_context_default_param_is_default_world(neo4j): + """Calling entity_context without world_id must default to 'default'.""" + from plugins.world import entity_context + res = entity_context({"name": "Aldric Raventhorne"}) + assert res.get("found") is True + assert res["id"] == "aldric" + + +# ─── was_true_at world_id filter ───────────────────────────────────────────── + +def test_was_true_at_world_id_filter(neo4j): + """A relation that exists in 'default' must not match in 'arda_greyscale'.""" + from plugins.world import was_true_at + res_default = was_true_at({ + "relation": "ALLIED_WITH", + "subject": "House Vyr", + "object": "Merchants Guild", + "at_time": "2nd_age.year_230", + "world_id": "default", + }) + assert res_default.get("was_true") is True, f"default world should be true: {res_default}" + res_gs = was_true_at({ + "relation": "ALLIED_WITH", + "subject": "House Vyr", + "object": "Merchants Guild", + "at_time": "2nd_age.year_230", + "world_id": "arda_greyscale", + }) + assert res_gs.get("was_true") is False, f"greyscale world should be false: {res_gs}" + + +# ─── state_at world_id filter ──────────────────────────────────────────────── + +def test_state_at_world_id_filter(neo4j): + """state_at in 'arda_greyscale' for a name only in 'default' must not find it.""" + from plugins.world import state_at + res = state_at({ + "entity": "Aldric Raventhorne", + "at_time": "2nd_age.year_260", + "world_id": "arda_greyscale", + }) + assert res.get("found") is False, f"leaked across worlds: {res}" + + +# ─── ancestors_of / descendants_of world_id filter ────────────────────────── + +def test_ancestors_of_world_id_filter(neo4j): + """ancestors_of in 'arda_greyscale' for a default-world person must return empty.""" + from plugins.lineage import ancestors_of + res = ancestors_of({"person": "Aldric Raventhorne", "generations": 5, "world_id": "arda_greyscale"}) + assert res["ancestors"] == [], f"leaked: {res}" + + +def test_descendants_of_world_id_filter(neo4j): + """descendants_of in 'arda_greyscale' for a default-world person must return empty.""" + from plugins.lineage import descendants_of + res = descendants_of({"person": "Theron Ashveil", "generations": 5, "world_id": "arda_greyscale"}) + assert res["descendants"] == [], f"leaked: {res}" + + +def test_lineage_of_world_id_filter(neo4j): + """lineage_of in 'arda_greyscale' for a default-world person must return found=false.""" + from plugins.lineage import lineage_of + res = lineage_of({"person": "Aldric Raventhorne", "world_id": "arda_greyscale"}) + assert res.get("found") is False, f"leaked: {res}" + + +# ─── recall_images / search_images_by_caption world_id filter ─────────────── + +def test_recall_images_world_id_filter(neo4j): + """recall_images for an arda_greyscale person must return that world's images only.""" + from plugins.images import recall_images + res = recall_images({"entity_id": "mael_greyscale", "world_id": "arda_greyscale"}) + assert res["count"] >= 1, f"no images for greyscale person: {res}" + for img in res["images"]: + assert img.get("world_id") == "arda_greyscale", f"wrong world: {img}" + + +def test_recall_images_world_id_isolation(neo4j): + """recall_images for a default-world person in 'arda_greyscale' must return 0.""" + from plugins.images import recall_images + res = recall_images({"entity_id": "aldric", "world_id": "arda_greyscale"}) + assert res["count"] == 0, f"leaked: {res}" + + +def test_search_images_by_caption_world_id_filter(neo4j): + """search_images_by_caption for 'greyscale' in 'default' must return 0.""" + from plugins.images import search_images_by_caption + res = search_images_by_caption({"q": "greyscale", "world_id": "default"}) + assert res["count"] == 0, f"leaked: {res}" + res_gs = search_images_by_caption({"q": "greyscale", "world_id": "arda_greyscale"}) + assert res_gs["count"] >= 1, f"missing greyscale matches: {res_gs}" + + +# ─── world_id column on image_manifest ─────────────────────────────────────── + +def test_image_manifest_has_world_id_column(neo4j): + """image_manifest must have a world_id column populated for every row.""" + import psycopg2 + conn = psycopg2.connect(PG_URL) + try: + with conn.cursor() as cur: + cur.execute(""" + SELECT column_name FROM information_schema.columns + WHERE table_name = 'image_manifest' AND column_name = 'world_id' + """) + assert cur.fetchone() is not None, "image_manifest is missing world_id column" + finally: + conn.close()