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:
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
388
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()
|
||||
|
||||
32
test.sh
32
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"
|
||||
|
||||
209
tests/test_multi_world.py
Normal file
209
tests/test_multi_world.py
Normal 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()
|
||||
Reference in New Issue
Block a user