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
763 lines
34 KiB
Python
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()
|