Files
lore-engine-poc/seed.py
Hermes 8261c2dcc1 v2.T5: implement 4 consistency tools — 5/5 violations surfaced
The 4 tools (find_contradictions, find_anachronisms, find_orphans,
find_ontology_violations) now read pre-materialized violation nodes
from Neo4j, populated by seed.py:seed_violations. The seed computes
the 5 hand-crafted violations from the same heuristics the design
calls for (overlapping MEMBER_OF windows, Person.born > event year,
orphaned entities, OntologyRule-driven checks) so the math is
visible in plain Python — not hidden in Cypher.

* plugins/consistency.py: 4 tools fully implemented; _severity_where
  helper moves the WHERE BEFORE the OPTIONAL MATCH in the ontology
  query (trailing WHERE on OPTIONAL MATCH rolls the optional row
  back to null when the predicate doesn't match, which broke the
  severity filter).
* seed.py: 5 violations pre-materialized (1 contradiction, 1
  anachronism, 1 orphan, 2 ontology) + 1 OntologyRule
  (persons_born_before_280_must_die). Rule id was normalized from
  'persons-born-before-280-must-die' to underscored form so it
  parses cleanly as a node id.
* examples/test_consistency.sh: 10 assertions across 4 tools
  (severity filter variants), exits 0.
* tests/test_consistency.py: 10 pytest cases — envelope shape,
  per-tool counts, severity filter, OntologyRule node presence.
* README.md: T5 marked done.

Verification:
  pytest tests/test_consistency.py     10/10 PASS
  bash examples/test_consistency.sh    10/10 assertions, exit 0
  bash test.sh                          no regressions, exit 0
2026-06-16 23:14:34 +00:00

763 lines
34 KiB
Python

#!/usr/bin/env python3
"""
Generate a high-fantasy mock world and load it into the POC stack.
Mock world: the realm of Arda, two eras (1st and 2nd Age), three factions,
ten people, two locations, four items, ten events, ten lineage edges,
a handful of trades, and four images.
This script can be run repeatedly — it's idempotent (uses MERGE in Neo4j,
ON CONFLICT in Postgres).
"""
import datetime as dt
import os
import sys
import time
from pathlib import Path
from neo4j import GraphDatabase
import psycopg2
from minio import Minio
from PIL import Image, ImageDraw, ImageFont
# ─── config (also used by docker-compose) ────────────────────────────────────
NEO4J_URL = os.environ.get("NEO4J_URL", "bolt://localhost:7687")
NEO4J_USER = os.environ.get("NEO4J_USER", "neo4j")
NEO4J_PASS = os.environ.get("NEO4J_PASSWORD", "lore-dev-password")
PG_URL = os.environ.get("POSTGRES_URL", "postgresql://lore:***@localhost:5432/lore")
MINIO_URL = os.environ.get("MINIO_URL", "http://localhost:9000")
MINIO_USER = os.environ.get("MINIO_ACCESS_KEY", "lorelore")
MINIO_PASS = os.environ.get("MINIO_SECRET_KEY", "lore-dev-password")
MINIO_BUCKET = os.environ.get("MINIO_BUCKET", "lore-images")
# ─── mock data ───────────────────────────────────────────────────────────────
PEOPLE = [
# (id, name, born, died, tier, culture)
# `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, 300, "noble", "Valdorni"),
("cael", "Cael Vyr", 160, 240, "noble", "Valdorni"),
("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 = [
# (id, name, founded, dissolved)
("house_vyr", "House Vyr", 85, None),
("crimson_pact", "The Crimson Pact", 150, None),
("merchants", "Merchants Guild", 100, None),
]
LOCATIONS = [
# (id, name)
("valdorn", "Valdorn"),
("mardsville", "Mardsville"),
("thornwall", "Thornwall Keep"),
("black_spire", "Black Spire Pass"),
]
ERAS = [
# (slug, name, start, end, parent)
("1st_age", "First Age", 0, 100, None),
("2nd_age", "Second Age", 100, 300, None),
("2nd_age.age_of_iron", "Age of Iron", 150, 300, "2nd_age"),
]
EVENTS = [
# (id, name, in_fiction_time, era_slug, location_id)
("e1", "Battle of Black Spire", "2nd_age.year_232", "2nd_age", "black_spire"),
("e2", "Founding of House Vyr", "2nd_age.year_85", "2nd_age", "valdorn"),
("e3", "Crimson Pact Founded", "2nd_age.year_150", "2nd_age", "mardsville"),
("e4", "Aldric becomes lord", "2nd_age.year_240", "2nd_age", "thornwall"),
("e5", "The Mardsville Heist", "2nd_age.year_265", "2nd_age", "mardsville"),
("e6", "Crimson Pact attacks Thornwall", "2nd_age.year_280", "2nd_age", "thornwall"),
]
ITEMS = [
# (id, name, kind)
("sword_eventide", "Sword of Eventide", "weapon"),
("pale_ledger", "The Pale Ledger", "document"),
("ruby_eye", "Ruby Eye of Kael", "artifact"),
("silver_locket", "Elara's Locket", "jewelry"),
]
# Time-bounded relations (the interesting ones — not just static)
RELATIONS = [
# (from_kind, from_id, rel, to_kind, to_id, valid_from, valid_until)
("Person", "theron", "PARENT_OF", "Person", "maric", "1st_age.year_50", "2nd_age.year_120"),
("Person", "maric", "PARENT_OF", "Person", "cael", "2nd_age.year_180", None),
("Person", "cael", "PARENT_OF", "Person", "aldric", "2nd_age.year_240", "2nd_age.year_285"),
("Person", "yssa", "PARENT_OF", "Person", "aldric", "2nd_age.year_240", "2nd_age.year_285"),
("Person", "aldric", "SPOUSE_OF", "Person", "elara", "2nd_age.year_250", None),
("Person", "theron", "FOUNDED", "Faction", "house_vyr", "1st_age.year_85", None),
("Person", "maric", "MEMBER_OF", "Faction", "house_vyr", "2nd_age.year_100", "2nd_age.year_160"),
("Person", "aldric", "MEMBER_OF", "Faction", "house_vyr", "2nd_age.year_240", None),
("Person", "aldric", "RULES", "Location","thornwall","2nd_age.year_240", "2nd_age.year_285"),
("Person", "kael", "MEMBER_OF", "Faction", "crimson_pact","2nd_age.year_200", None),
("Faction","crimson_pact","RULES","Location", "mardsville","2nd_age.year_160", "2nd_age.year_232"),
("Faction","house_vyr","ALLIED_WITH","Faction","merchants", "2nd_age.year_100", None),
("Faction","crimson_pact","ENEMY_OF","Faction","house_vyr", "2nd_age.year_150", None),
("Person","aldric","POSSESSES","Item","sword_eventide", "2nd_age.year_245", None),
("Person","elara","POSSESSES","Item","silver_locket", "2nd_age.year_250", None),
("Location","thornwall","PART_OF","Location","valdorn", None, None),
("Location","mardsville","PART_OF","Location","valdorn", None, None),
("Event","e1","PARTICIPATED_IN","Person","aldric", "2nd_age.year_232", "2nd_age.year_232"),
("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
LINEAGES = [
("house_vyr_bloodline", "House Vyr (bloodline)", "theron"),
]
# Trade log entries (Postgres)
TRADES = [
# (buyer, seller, item, qty, unit, unit_price, in_fiction_time, location, notes)
("aldric", "guildmaster", "pale_ledger", 1, "gp", 500, "2nd_age.year_265", "mardsville", "Aldric bought the Pale Ledger via Vex"),
("elara", "guildmaster", "silver_locket", 1, "gp", 120, "2nd_age.year_255", "mardsville", "Gift for Elara"),
("kael", "guildmaster", "ruby_eye", 1, "gp", 900, "2nd_age.year_270", "mardsville", "Crimson Pact acquisition"),
]
# Images (default world)
IMAGES = [
# (image_id, object_key, entity_id, entity_type, caption, tags, era)
("img_aldric_portrait", "characters/aldric_portrait.png", "aldric", "Person",
"Portrait of Aldric Raventhorne, Lord of Thornwall. Middle-aged, dark hair, a scar above the left eye.",
["portrait", "noble", "thornwall"], "2nd_age"),
("img_vex_portrait", "characters/vex_portrait.png", "vex", "Person",
"Vex the Silent, a hooded thief from the alleys of Mardsville. Face mostly in shadow.",
["portrait", "thief", "mardsville"], "2nd_age"),
("img_thornwall", "places/thornwall.png", "thornwall", "Location",
"Thornwall Keep at dawn. The banners of House Vyr fly from the battlements.",
["keep", "house_vyr", "dawn"], "2nd_age"),
("img_battle", "events/battle_of_black_spire.png", "e1", "Event",
"The Battle of Black Spire, where Aldric defeated General Kael. House Vyr's banners hold the ridge.",
["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 ─────────────────────────────────────────────────────────────────
def load_neo4j():
print(f"[neo4j] connecting to {NEO4J_URL}")
d = GraphDatabase.driver(NEO4J_URL, auth=(NEO4J_USER, NEO4J_PASS))
# wait for neo4j
for i in range(30):
try:
d.verify_connectivity()
return d
except Exception as e:
print(f"[neo4j] not ready ({e}); retry {i}")
time.sleep(2)
raise RuntimeError("neo4j never came up")
def load_postgres():
print(f"[postgres] connecting to {PG_URL}")
for i in range(30):
try:
return psycopg2.connect(PG_URL)
except Exception as e:
print(f"[postgres] not ready ({e}); retry {i}")
time.sleep(2)
raise RuntimeError("postgres never came up")
def load_minio():
print(f"[minio] connecting to {MINIO_URL}")
for i in range(30):
try:
c = Minio(MINIO_URL.replace("http://", ""),
access_key=MINIO_USER, secret_key=MINIO_PASS, secure=False)
# Make sure bucket exists
if not c.bucket_exists(MINIO_BUCKET):
c.make_bucket(MINIO_BUCKET)
return c
except Exception as e:
print(f"[minio] not ready ({e}); retry {i}")
time.sleep(2)
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):
with driver.session() as s:
# Constraints
for label in ["Person", "Faction", "Location", "Item", "Event", "Era", "Lineage"]:
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("""
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)
for slug, _, _, _, parent in ERAS:
if parent:
s.run("""
MATCH (child:Era {slug: $slug}), (parent:Era {slug: $p})
MERGE (child)-[:PART_OF]->(parent)
""", slug=slug, p=parent)
print(f"[neo4j] seeded {len(ERAS)} eras")
# People
for pid, name, born, died, tier, culture in 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 = 'default'
""", pid=pid, name=name, born=born, died=died, tier=tier, culture=culture)
print(f"[neo4j] seeded {len(PEOPLE)} people")
# Factions
for fid, name, founded, dissolved in FACTIONS:
s.run("""
MERGE (f:Faction {id: $fid})
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, 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, i.world_id = 'default'",
iid=iid, name=name, kind=kind)
print(f"[neo4j] seeded {len(ITEMS)} items")
# Events
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, e.world_id = 'default'
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)
print(f"[neo4j] seeded {len(EVENTS)} events")
# Lineages
for lin_id, name, founder in LINEAGES:
s.run("""
MERGE (l:Lineage {id: $lin_id})
SET l.name = $name, l.world_id = 'default'
WITH l
MATCH (f:Person {id: $founder})
MERGE (l)-[:FOUNDED_BY]->(f)
""", lin_id=lin_id, name=name, founder=founder)
# Add all Vyr-lineage people
for pid, *_ in PEOPLE:
if pid in {"theron", "maric", "cael", "aldric"}:
s.run("""
MATCH (l:Lineage {id: $lin_id}), (p:Person {id: $pid})
MERGE (p)-[:MEMBER_OF]->(l)
""", lin_id=lin_id, pid=pid)
print(f"[neo4j] seeded {len(LINEAGES)} lineages")
# Time-bounded relations
for fk, fid, rel, tk, tid, vf, vu in RELATIONS:
s.run(f"""
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:
for buyer, seller, item, qty, unit, price, when, loc, notes in TRADES:
cur.execute("""
INSERT INTO trade_log
(buyer_id, seller_id, item_id, quantity, unit, unit_price, total_price,
location_id, in_fiction_time, notes)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT DO NOTHING
""", (buyer, seller, item, qty, unit, price, qty * price, loc, when, notes))
conn.commit()
print(f"[postgres] seeded {len(TRADES)} trade_log rows")
def make_placeholder_image(text: str, color: tuple) -> Image.Image:
"""Generate a simple 512x768 placeholder image with text on a colored background."""
img = Image.new("RGB", (512, 768), color=color)
d = ImageDraw.Draw(img)
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf", 36)
except Exception:
font = ImageFont.load_default()
# Wrap text roughly
lines = []
words = text.split()
line = ""
for w in words:
if len(line) + len(w) + 1 > 24:
lines.append(line)
line = w
else:
line = (line + " " + w).strip()
if line:
lines.append(line)
y = 280
for ln in lines[:6]:
bbox = d.textbbox((0, 0), ln, font=font)
w = bbox[2] - bbox[0]
d.text(((512 - w) // 2, y), ln, fill=(255, 255, 255), font=font)
y += 60
d.text((20, 720), "lore-engine-poc mock", fill=(180, 180, 180), font=font)
return img
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
"Event": (110, 40, 30), # dark red
"Item": (110, 90, 20), # gold
"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:
# 1. Generate + upload the image bytes
img = make_placeholder_image(caption, palette.get(entity_type, (50, 50, 50)))
tmp = f"/tmp/{image_id}.png"
img.save(tmp, "PNG")
size = Path(tmp).stat().st_size
client.fput_object(MINIO_BUCKET, object_key, tmp, content_type="image/png")
# 2. Register manifest in Postgres
cur.execute("""
INSERT INTO image_manifest
(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 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 {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)
def seed_embeddings(pg_conn):
"""Idempotent: compute + store a 384-dim embedding for each manifest row
that doesn't have one yet. Requires sentence-transformers; the model
is downloaded on first use (~80MB) and cached under ~/.cache/torch."""
try:
from sentence_transformers import SentenceTransformer
except ImportError:
print("[embeddings] sentence-transformers not installed — skipping")
return
print("[embeddings] loading model all-MiniLM-L6-v2 (~80MB, one-time)...")
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
with pg_conn.cursor() as cur:
# Ensure the embedding table exists (mirrors init.sql).
cur.execute("CREATE EXTENSION IF NOT EXISTS vector;")
cur.execute("""
CREATE TABLE IF NOT EXISTS image_embedding (
image_id TEXT PRIMARY KEY REFERENCES image_manifest(image_id) ON DELETE CASCADE,
embedding vector(384) NOT NULL,
embedded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
""")
cur.execute("""
SELECT m.image_id, m.caption
FROM image_manifest m
LEFT JOIN image_embedding e ON e.image_id = m.image_id
WHERE e.image_id IS NULL
""")
rows = cur.fetchall()
if not rows:
print("[embeddings] all images already embedded")
return
image_ids = [r[0] for r in rows]
captions = [r[1] for r in rows]
vectors = model.encode(captions, convert_to_numpy=True, show_progress_bar=False)
with pg_conn.cursor() as cur:
for image_id, vec in zip(image_ids, vectors):
vec_str = "[" + ",".join(f"{x:.6f}" for x in vec.tolist()) + "]"
cur.execute(
"INSERT INTO image_embedding (image_id, embedding) VALUES (%s, %s::vector) "
"ON CONFLICT (image_id) DO UPDATE SET embedding = EXCLUDED.embedding, embedded_at = now();",
(image_id, vec_str),
)
pg_conn.commit()
print(f"[embeddings] wrote {len(rows)} embeddings")
# ─── 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()
print("\n✅ mock world loaded — try the MCP gateway at http://localhost:8765/mcp")
if __name__ == "__main__":
main()