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}]
This commit is contained in:
Kay
2026-06-16 23:09:40 +00:00
parent cfc555925d
commit 4f922899af
7 changed files with 731 additions and 93 deletions

View File

@@ -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):

View File

@@ -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,
}

View File

@@ -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.

View File

@@ -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).

388
seed.py
View File

@@ -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()

32
test.sh
View File

@@ -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"

209
tests/test_multi_world.py Normal file
View File

@@ -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()