sammoftah's picture
Upload 4 files
51f3e0e verified
from __future__ import annotations
import json
import math
import sqlite3
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple
# Import Wikidata service for remote lookups
try:
from services.wikidata_service import (
search_events_by_geo_time as wikidata_search,
get_event_detail as wikidata_get_detail,
search_events_by_name as wikidata_search_by_name,
)
WIKIDATA_AVAILABLE = True
except ImportError:
WIKIDATA_AVAILABLE = False
print("[history_service] Wikidata service not available, using curated data only")
ROOT_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = ROOT_DIR / "data"
DATA_DIR.mkdir(parents=True, exist_ok=True)
DB_PATH = DATA_DIR / "meridian_history.db"
# Wikidata settings
ENABLE_WIKIDATA_FALLBACK = True
WIKIDATA_CONFIDENCE_THRESHOLD = 0.5
EVENT_SCHEMA_VERSION = 2
EVENT_EXTRA_COLUMNS: Dict[str, str] = {
"slug": "TEXT",
"summary": "TEXT",
"narrative": "TEXT",
"start_year": "INTEGER",
"end_year": "INTEGER",
"month": "INTEGER",
"day": "INTEGER",
"themes": "TEXT",
"actors": "TEXT",
"artifacts": "TEXT",
"visual_motifs": "TEXT",
"facets": "TEXT",
"sources": "TEXT",
"time_range": "TEXT",
"geo_anchor": "TEXT",
"confidence": "REAL",
"relationships": "TEXT",
}
def _get_connection() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def _serialize(value: object) -> str:
return json.dumps(value, ensure_ascii=False)
def _deserialize(value: Optional[str], default):
if value is None or value == "":
return default
try:
return json.loads(value)
except json.JSONDecodeError:
return default
def ensure_schema() -> None:
conn = _get_connection()
cursor = conn.cursor()
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
year INTEGER,
lat REAL,
lon REAL
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS schema_meta (
key TEXT PRIMARY KEY,
value TEXT
)
"""
)
# Add new columns if missing
cursor.execute("PRAGMA table_info(events)")
existing_columns = {row["name"] for row in cursor.fetchall()}
for column, column_type in EVENT_EXTRA_COLUMNS.items():
if column not in existing_columns:
cursor.execute(f"ALTER TABLE events ADD COLUMN {column} {column_type}")
# Update schema version
cursor.execute(
"""
INSERT INTO schema_meta(key, value)
VALUES('event_schema_version', ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value
""",
(str(EVENT_SCHEMA_VERSION),),
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_events_year ON events(year)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_events_coordinates ON events(lat, lon)")
conn.commit()
conn.close()
def seed_curated_events(force_refresh: bool = False) -> None:
conn = _get_connection()
cursor = conn.cursor()
if force_refresh:
cursor.execute("DELETE FROM events")
for event in CURATED_EVENTS:
cursor.execute(
"""
INSERT OR IGNORE INTO events (
name, slug, year, start_year, end_year, month, day,
lat, lon, summary, narrative, themes, actors, artifacts,
visual_motifs, facets, sources, time_range, geo_anchor,
confidence, relationships
) VALUES (
:name, :slug, :year, :start_year, :end_year, :month, :day,
:lat, :lon, :summary, :narrative, :themes, :actors, :artifacts,
:visual_motifs, :facets, :sources, :time_range, :geo_anchor,
:confidence, :relationships
)
""",
{
"name": event["name"],
"slug": event.get("slug") or event["name"].lower().replace(" ", "_"),
"year": event.get("year"),
"start_year": event.get("start_year", event.get("year")),
"end_year": event.get("end_year", event.get("year")),
"month": event.get("month"),
"day": event.get("day"),
"lat": event.get("lat"),
"lon": event.get("lon"),
"summary": event.get("summary"),
"narrative": event.get("narrative"),
"themes": _serialize(event.get("themes", [])),
"actors": _serialize(event.get("actors", [])),
"artifacts": _serialize(event.get("artifacts", [])),
"visual_motifs": _serialize(event.get("visual_motifs", [])),
"facets": _serialize(event.get("facets", {})),
"sources": _serialize(event.get("sources", [])),
"time_range": _serialize(event.get("time_range", {})),
"geo_anchor": _serialize(event.get("geo_anchor", {})),
"confidence": event.get("confidence", 0.85),
"relationships": _serialize(event.get("relationships", {})),
},
)
conn.commit()
conn.close()
def initialize_history(force_refresh: bool = False) -> None:
ensure_schema()
seed_curated_events(force_refresh=force_refresh)
def load_events_from_db() -> List[dict]:
conn = _get_connection()
cursor = conn.cursor()
cursor.execute("SELECT * FROM events")
rows = cursor.fetchall()
conn.close()
events = []
for row in rows:
event = dict(row)
event["themes"] = _deserialize(event.get("themes"), [])
event["actors"] = _deserialize(event.get("actors"), [])
event["artifacts"] = _deserialize(event.get("artifacts"), [])
event["visual_motifs"] = _deserialize(event.get("visual_motifs"), [])
event["facets"] = _deserialize(event.get("facets"), {})
event["sources"] = _deserialize(event.get("sources"), [])
event["time_range"] = _deserialize(event.get("time_range"), {})
event["geo_anchor"] = _deserialize(event.get("geo_anchor"), {})
event["relationships"] = _deserialize(event.get("relationships"), {})
events.append(event)
return events
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
radius = 6371.0
phi1, phi2 = math.radians(lat1), math.radians(lat2)
delta_phi = math.radians(lat2 - lat1)
delta_lambda = math.radians(lon2 - lon1)
a = (
math.sin(delta_phi / 2) ** 2
+ math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2) ** 2
)
c = 2 * math.atan2(math.sqrt(a), math.sqrt(max(0.0, 1 - a)))
return radius * c
def _compute_match_score(
event: dict,
lat: float,
lon: float,
year: int,
year_weight: float = 1.0,
) -> Tuple[float, float, float, float]:
"""
Compute match score for an event based on distance and year.
Args:
event: Event dictionary
lat: Query latitude
lon: Query longitude
year: Query year
year_weight: Weight for year matching (0.0-2.0)
- 0.0 = distance only
- 1.0 = balanced (default)
- 2.0 = strongly prefer year matches
Returns:
Tuple of (distance_km, year_delta, confidence, match_score)
"""
event_year = event.get("year") or year
event_lat = event.get("lat") or lat
event_lon = event.get("lon") or lon
distance = haversine_distance(lat, lon, event_lat, event_lon)
year_delta = abs(event_year - year)
base_confidence = event.get("confidence", 0.8)
# Year-weighted scoring:
# - Exact year match (delta=0): massive bonus
# - Within 5 years: strong bonus
# - Within 10 years: moderate bonus
# - Beyond 10 years: penalty increases
if year_delta == 0:
year_score = -50 * year_weight # Big bonus for exact year
elif year_delta <= 2:
year_score = -30 * year_weight # Strong bonus for ±2 years
elif year_delta <= 5:
year_score = -15 * year_weight # Good bonus for ±5 years
elif year_delta <= 10:
year_score = 0 # Neutral for ±10 years
else:
year_score = year_delta * 3 * year_weight # Penalty for distant years
# Distance scoring (normalized):
# - Within 50km: strong bonus
# - Within 200km: moderate bonus
# - Beyond 500km: penalty
if distance < 50:
distance_score = -20 * (1 - year_weight * 0.3) # Bonus, reduced if year-weighted
elif distance < 200:
distance_score = distance * 0.1
else:
distance_score = distance * 0.2 * (1 - year_weight * 0.3) # Reduced penalty if year-weighted
# Combined score (lower is better)
match_score = distance_score + year_score
# Confidence calculation
confidence = base_confidence
if year_delta == 0:
confidence += 0.15
elif year_delta <= 5:
confidence += 0.08
if distance < 100:
confidence += 0.1
elif distance < 300:
confidence += 0.05
confidence = max(0.0, min(0.99, confidence))
return distance, year_delta, confidence, match_score
def get_events_by_coordinates(
lat: float,
lon: float,
year: int,
radius_km: float = 250.0,
limit: int = 5,
include_wikidata: bool = True,
year_weight: float = 1.5,
) -> List[dict]:
"""
Get historical events near coordinates and year.
First searches curated local database, then optionally queries Wikidata
for additional results if enabled and local results are insufficient.
Args:
lat: Latitude
lon: Longitude
year: Target year (negative for BCE)
radius_km: Search radius in kilometers
limit: Maximum number of results
include_wikidata: Whether to include Wikidata results
year_weight: How much to prioritize year matches (0.0-2.0)
- 0.0 = distance only (ignore year)
- 1.0 = balanced
- 1.5 = prefer year matches (default)
- 2.0 = strongly prefer year matches
Returns:
List of event dictionaries sorted by relevance
"""
# Step 1: Search curated local database
events = load_events_from_db()
matches: List[dict] = []
# Use larger radius when year-weighted to find more year matches
effective_radius = radius_km * (1 + year_weight * 0.5) if year_weight > 1.0 else radius_km
for event in events:
distance, year_delta, confidence, match_score = _compute_match_score(
event, lat, lon, year, year_weight=year_weight
)
# Include if within radius OR if year matches closely
if distance > effective_radius and year_delta > 10:
continue
# Always include exact year matches regardless of distance
if year_delta > 15 and distance > radius_km:
continue
match = dict(event)
match["distance_km"] = round(distance, 2)
match["year_delta"] = year_delta
match["match_confidence"] = round(confidence, 3)
match["match_score"] = match_score
match["source"] = "curated"
matches.append(match)
matches.sort(key=lambda item: item["match_score"])
curated_results = matches[:limit]
# Step 2: If enabled and we have few/no curated results, query Wikidata
if (
include_wikidata
and ENABLE_WIKIDATA_FALLBACK
and WIKIDATA_AVAILABLE
and len(curated_results) < limit
):
try:
print(f"[history_service] Querying Wikidata for additional events...")
wikidata_results = wikidata_search(
lat=lat,
lon=lon,
year=year,
radius_km=radius_km,
limit=limit * 2, # Get extra to filter
)
# Merge Wikidata results, avoiding duplicates by name
curated_names = {m.get("name", "").lower() for m in curated_results}
for wd_event in wikidata_results:
# Skip if we already have this event from curated data
event_name = wd_event.get("name", "").lower()
if event_name in curated_names:
continue
# Skip low-confidence results
confidence = wd_event.get("match_confidence", wd_event.get("confidence", 0))
if confidence < WIKIDATA_CONFIDENCE_THRESHOLD:
continue
# Add source marker and compute year-weighted score
wd_event["source"] = "wikidata"
wd_year_delta = wd_event.get("year_delta", 99)
wd_distance = wd_event.get("distance_km", 999)
# Year-weighted scoring for Wikidata results
if wd_year_delta == 0:
year_score = -50 * year_weight
elif wd_year_delta <= 2:
year_score = -30 * year_weight
elif wd_year_delta <= 5:
year_score = -15 * year_weight
elif wd_year_delta <= 10:
year_score = 0
else:
year_score = wd_year_delta * 3 * year_weight
distance_score = wd_distance * 0.1 * (1 - year_weight * 0.3)
wd_event["match_score"] = distance_score + year_score - confidence * 10
curated_results.append(wd_event)
curated_names.add(event_name)
if len(curated_results) >= limit:
break
# Re-sort combined results
curated_results.sort(key=lambda item: item.get("match_score", 999))
except Exception as e:
print(f"[history_service] Wikidata lookup failed: {e}")
return curated_results[:limit]
def search_events_globally(
lat: float,
lon: float,
year: int,
radius_km: float = 500.0,
limit: int = 10,
year_weight: float = 1.5,
) -> List[dict]:
"""
Search for historical events with broader radius, always including Wikidata.
This is useful for finding events when the user doesn't have precise coordinates.
Uses year-weighted scoring by default to prioritize temporal matches.
"""
return get_events_by_coordinates(
lat=lat,
lon=lon,
year=year,
radius_km=radius_km,
limit=limit,
include_wikidata=True,
year_weight=year_weight,
)
def get_event_by_slug(slug: str) -> Optional[dict]:
conn = _get_connection()
cursor = conn.cursor()
cursor.execute("SELECT * FROM events WHERE slug = ?", (slug,))
row = cursor.fetchone()
conn.close()
if not row:
return None
event = dict(row)
event["themes"] = _deserialize(event.get("themes"), [])
event["actors"] = _deserialize(event.get("actors"), [])
event["artifacts"] = _deserialize(event.get("artifacts"), [])
event["visual_motifs"] = _deserialize(event.get("visual_motifs"), [])
event["facets"] = _deserialize(event.get("facets"), {})
event["sources"] = _deserialize(event.get("sources"), [])
event["time_range"] = _deserialize(event.get("time_range"), {})
event["geo_anchor"] = _deserialize(event.get("geo_anchor"), {})
event["relationships"] = _deserialize(event.get("relationships"), {})
return event
def get_event_by_name(name: str, include_wikidata: bool = True) -> Optional[dict]:
"""
Get event by name, checking curated data first, then Wikidata.
"""
# Try curated data first
event = get_event_by_slug(name.lower().replace(" ", "_"))
if event:
event["source"] = "curated"
return event
# Try Wikidata if enabled
if include_wikidata and ENABLE_WIKIDATA_FALLBACK and WIKIDATA_AVAILABLE:
try:
results = wikidata_search_by_name(name, limit=1)
if results:
results[0]["source"] = "wikidata"
return results[0]
except Exception as e:
print(f"[history_service] Wikidata name search failed: {e}")
return None
def get_event_by_qid(qid: str) -> Optional[dict]:
"""
Get detailed event information from Wikidata by QID.
"""
if not WIKIDATA_AVAILABLE:
return None
try:
return wikidata_get_detail(qid)
except Exception as e:
print(f"[history_service] Wikidata QID lookup failed: {e}")
return None
def get_artifacts_for_year(year: int, limit: int = 4) -> List[dict]:
matches: List[dict] = []
for artifact in CURATED_ARTIFACTS:
era_start, era_end = artifact.get("era", [None, None])
if era_start is None or era_end is None:
matches.append(artifact)
continue
if era_start <= year <= era_end:
matches.append(artifact)
if not matches:
matches = CURATED_ARTIFACTS[:]
return matches[:limit]
def summarize_event(event: dict) -> str:
summary = event.get("summary") or event.get("narrative") or event.get("name")
return summary
def ensure_iterable(value: Optional[Iterable[str]]) -> List[str]:
if value is None:
return []
return list(value)
CURATED_EVENTS: List[dict] = [
{
"name": "Fall of the Berlin Wall",
"slug": "fall_of_the_berlin_wall",
"year": 1989,
"start_year": 1989,
"end_year": 1989,
"month": 11,
"day": 9,
"lat": 52.5163,
"lon": 13.3777,
"summary": "East and West Berliners gather at the Brandenburg Gate as border checkpoints open and the concrete wall begins to fall.",
"narrative": (
"A sea of Berliners clamber atop graffiti-streaked concrete slabs, cheering as border guards lift the barriers. "
"People pass champagne bottles, wield sledgehammers, and chip away fragments while floodlights and television crews illuminate the night."
),
"themes": ["political", "reunification", "cold war"],
"actors": ["East German civilians", "West Berlin residents", "border guards", "international journalists"],
"artifacts": ["Graffiti-covered concrete", "Champagne bottles", "Trabant cars", "Floodlights", "Metal barricades"],
"visual_motifs": ["floodlit night sky", "cold autumn breath", "television cameras", "crowded concrete wall"],
"facets": {"era": "late 20th century", "region": "western_europe", "type": "political upheaval"},
"sources": [{"label": "Wikipedia", "url": "https://en.wikipedia.org/wiki/Fall_of_the_Berlin_Wall"}],
"time_range": {"start": "1989-11-09T18:00:00", "end": "1989-11-10T02:00:00"},
"geo_anchor": {"lat": 52.5163, "lon": 13.3777, "radius_km": 4},
"confidence": 0.96,
"relationships": {"consequences": ["German reunification 1990"]},
},
{
"name": "D-Day Landing at Omaha Beach",
"slug": "d_day_landing_at_omaha_beach",
"year": 1944,
"start_year": 1944,
"end_year": 1944,
"month": 6,
"day": 6,
"lat": 49.4144,
"lon": -0.8322,
"summary": "Allied assault troops storm Omaha Beach under heavy German fire at dawn during Operation Overlord.",
"narrative": (
"Pre-dawn haze lifts as landing craft ramps crash open and American soldiers sprint through waist-high surf toward fortified bluffs. "
"Machine-gun tracers stitch the air, artillery craters erupt in wet sand, and medics tend to the wounded beside hedgehog obstacles."
),
"themes": ["military", "WWII", "allied victory"],
"actors": ["US 1st Infantry Division", "US 29th Infantry Division", "German Atlantic Wall defenders", "Combat medics"],
"artifacts": ["Higgins landing craft", "Browning machine guns", "M1 helmets", "Beach obstacles", "Signal flares"],
"visual_motifs": ["morning fog", "breaking waves", "artillery smoke", "olive drab uniforms"],
"facets": {"era": "mid 20th century", "region": "western_europe", "type": "amphibious assault"},
"sources": [{"label": "National WWII Museum", "url": "https://www.nationalww2museum.org"}],
"time_range": {"start": "1944-06-06T05:30:00", "end": "1944-06-06T10:00:00"},
"geo_anchor": {"lat": 49.4144, "lon": -0.8322, "radius_km": 12},
"confidence": 0.94,
"relationships": {"parallel": ["Sword Beach landings", "Utah Beach landings"]},
},
{
"name": "Signing of the Declaration of Independence",
"slug": "signing_of_the_declaration_of_independence",
"year": 1776,
"start_year": 1776,
"end_year": 1776,
"month": 7,
"day": 4,
"lat": 39.9489,
"lon": -75.1500,
"summary": "Delegates of the Continental Congress sign the Declaration inside Independence Hall, Philadelphia.",
"narrative": (
"Sunlight streams through tall sash windows onto polished wood floors as delegates in powdered wigs lean over parchment. "
"Quill pens scratch, wax seals glisten, and brass bellows stir a warm July breeze through the Assembly Room."
),
"themes": ["political", "founding documents", "revolution"],
"actors": ["Thomas Jefferson", "John Hancock", "Continental Congress delegates"],
"artifacts": ["Quill pens", "Parchment scrolls", "Wax seals", "Mahogany desks"],
"visual_motifs": ["golden afternoon light", "colonial interior", "powder wigs", "rich green drapery"],
"facets": {"era": "late 18th century", "region": "north_america", "type": "political charter"},
"sources": [{"label": "US National Archives", "url": "https://www.archives.gov/founding-docs/declaration"}],
"time_range": {"start": "1776-07-04T10:00:00", "end": "1776-07-04T15:00:00"},
"geo_anchor": {"lat": 39.9489, "lon": -75.1500, "radius_km": 1},
"confidence": 0.9,
"relationships": {"causes": ["Continental Congress debates"], "consequences": ["American Revolutionary War escalation"]},
},
{
"name": "Battle of Waterloo",
"slug": "battle_of_waterloo",
"year": 1815,
"start_year": 1815,
"end_year": 1815,
"month": 6,
"day": 18,
"lat": 50.6794,
"lon": 4.4125,
"summary": "Coalition forces defeat Napoleon Bonaparte near Waterloo, ending the Hundred Days campaign.",
"narrative": (
"Under rain-darkened skies, British squares brace against French cavalry charges across muddy Belgian fields. "
"Cannon smoke drifts low, cuirassiers clash with bayonet lines, and signal flags ripple above the La Haye Sainte farmhouse."
),
"themes": ["military", "napoleonic wars"],
"actors": ["British infantry", "Dutch-Belgian troops", "French Imperial Guard", "Prussian reinforcements"],
"artifacts": ["Cuirass armor", "Sabers", "Field cannon", "Signal flags"],
"visual_motifs": ["storm clouds", "muddy terrain", "cavalry charge", "gunpowder smoke"],
"facets": {"era": "early 19th century", "region": "western_europe", "type": "decisive battle"},
"sources": [{"label": "Waterloo Battlefield", "url": "https://www.waterloo1815.be"}],
"time_range": {"start": "1815-06-18T11:30:00", "end": "1815-06-18T20:30:00"},
"geo_anchor": {"lat": 50.6794, "lon": 4.4125, "radius_km": 8},
"confidence": 0.88,
"relationships": {"consequences": ["Exile of Napoleon to Saint Helena"]},
},
{
"name": "Hiroshima Atomic Bombing",
"slug": "hiroshima_atomic_bombing",
"year": 1945,
"start_year": 1945,
"end_year": 1945,
"month": 8,
"day": 6,
"lat": 34.3853,
"lon": 132.4553,
"summary": "The United States detonates an atomic bomb over Hiroshima, Japan, causing widespread destruction.",
"narrative": (
"Moments after the blinding flash, a mushroom cloud towers above shattered city blocks. "
"Wooden houses ignite, survivors stagger through debris-clogged streets, and the iconic Genbaku Dome stands amid the devastation."
),
"themes": ["military", "WWII", "nuclear warfare"],
"actors": ["Civilians", "First responders", "US bomber crew (distant)"],
"artifacts": ["Genbaku Dome", "Debris-laden streets", "Shattered windows", "Charred telegraph poles"],
"visual_motifs": ["mushroom cloud", "ashen fallout", "burning skyline", "silhouetted survivors"],
"facets": {"era": "mid 20th century", "region": "east_asia", "type": "aerial bombardment"},
"sources": [{"label": "Hiroshima Peace Memorial Museum", "url": "https://hpmmuseum.jp/?lang=en"}],
"time_range": {"start": "1945-08-06T08:15:00", "end": "1945-08-06T12:00:00"},
"geo_anchor": {"lat": 34.3853, "lon": 132.4553, "radius_km": 15},
"confidence": 0.87,
"relationships": {"consequences": ["Surrender of Japan 1945"]},
},
{
"name": "Tiananmen Square Protests",
"slug": "tiananmen_square_protests",
"year": 1989,
"start_year": 1989,
"end_year": 1989,
"month": 6,
"day": 4,
"lat": 39.9042,
"lon": 116.4074,
"summary": "Chinese citizens hold pro-democracy demonstrations in Beijing's Tiananmen Square before military suppression.",
"narrative": (
"In early dawn haze, students link arms facing a line of armored vehicles. "
"The Goddess of Democracy statue rises above banners, bicycle couriers weave through tents, and the Gate of Heavenly Peace looms in the background."
),
"themes": ["political", "protest", "democracy"],
"actors": ["Student demonstrators", "People's Liberation Army soldiers", "Beijing residents"],
"artifacts": ["Goddess of Democracy statue", "Banners and loudspeakers", "Tents", "Armored personnel carriers"],
"visual_motifs": ["morning haze", "stone square", "red flags", "human chain"],
"facets": {"era": "late 20th century", "region": "east_asia", "type": "protest movement"},
"sources": [{"label": "BBC Timeline", "url": "https://www.bbc.com/news/world-asia-china-12661772"}],
"time_range": {"start": "1989-06-03T22:00:00", "end": "1989-06-04T07:00:00"},
"geo_anchor": {"lat": 39.9042, "lon": 116.4074, "radius_km": 6},
"confidence": 0.88,
"relationships": {"parallel": ["1989 global protest movements"]},
},
{
"name": "Apollo 11 Moon Launch",
"slug": "apollo_11_moon_launch",
"year": 1969,
"start_year": 1969,
"end_year": 1969,
"month": 7,
"day": 16,
"lat": 28.5729,
"lon": -80.6490,
"summary": "NASA launches Apollo 11 from Kennedy Space Center, beginning the first crewed mission to land on the Moon.",
"narrative": (
"Spectators line the Causeway as the Saturn V rockets skyward, engines roaring and painting the morning sky orange. "
"Camera crews pan across mission control staff, astronauts in white suits wave before boarding, and the vehicle assembly building looms nearby."
),
"themes": ["space exploration", "science", "Cold War"],
"actors": ["Neil Armstrong", "Buzz Aldrin", "Michael Collins", "Mission control engineers"],
"artifacts": ["Saturn V rocket", "Launch gantry", "Mission patches", "Telemetry consoles"],
"visual_motifs": ["plume of fire", "sunrise glow", "American flags", "NASA vehicles"],
"facets": {"era": "late 20th century", "region": "north_america", "type": "space mission"},
"sources": [{"label": "NASA History", "url": "https://www.nasa.gov/specials/apollo50th/"}],
"time_range": {"start": "1969-07-16T09:32:00", "end": "1969-07-16T10:00:00"},
"geo_anchor": {"lat": 28.5729, "lon": -80.6490, "radius_km": 10},
"confidence": 0.89,
"relationships": {"consequences": ["Apollo 11 moon landing"]},
},
{
"name": "Wright Brothers First Flight",
"slug": "wright_brothers_first_flight",
"year": 1903,
"start_year": 1903,
"end_year": 1903,
"month": 12,
"day": 17,
"lat": 36.0177,
"lon": -75.6694,
"summary": "Orville and Wilbur Wright achieve the first powered, sustained flight at Kitty Hawk, North Carolina.",
"narrative": (
"On windswept dunes, Orville lies prone on the Flyer as Wilbur steadies a wingtip. "
"A small crowd of lifesavers braces the launch rail, camera ready, as the biplane lifts into the cold December air for twelve seconds."
),
"themes": ["aviation", "innovation"],
"actors": ["Orville Wright", "Wilbur Wright", "Kill Devil Hills lifesavers"],
"artifacts": ["Wright Flyer", "Launch rail", "Oil-stained overalls", "Box camera"],
"visual_motifs": ["wind-scoured dunes", "frosty breath", "canvas wings", "wooden spars"],
"facets": {"era": "early 20th century", "region": "north_america", "type": "technological milestone"},
"sources": [{"label": "Smithsonian Air & Space", "url": "https://airandspace.si.edu"}],
"time_range": {"start": "1903-12-17T10:35:00", "end": "1903-12-17T10:47:00"},
"geo_anchor": {"lat": 36.0177, "lon": -75.6694, "radius_km": 3},
"confidence": 0.86,
"relationships": {"consequences": ["Development of powered flight"]},
},
{
"name": "Grito de Dolores",
"slug": "grito_de_dolores",
"year": 1810,
"start_year": 1810,
"end_year": 1810,
"month": 9,
"day": 16,
"lat": 21.1561,
"lon": -100.9326,
"summary": "Father Miguel Hidalgo y Costilla calls for Mexican independence with the famous Grito de Dolores.",
"narrative": (
"Before dawn, church bells ring out as Father Hidalgo addresses villagers in the plaza, torchlight illuminating insurgent banners. "
"Peasants clutch farming tools turned weapons while women distribute ammunition from woven baskets."
),
"themes": ["revolution", "latin america"],
"actors": ["Father Miguel Hidalgo", "Town villagers", "Criollo supporters"],
"artifacts": ["Church bell rope", "Guadalupe banner", "Torches", "Improvised spears"],
"visual_motifs": ["torchlit plaza", "colonial church facade", "Mexican flag colors", "dawn sky"],
"facets": {"era": "early 19th century", "region": "central_america", "type": "independence movement"},
"sources": [{"label": "Mexican History", "url": "https://www.gob.mx"}],
"time_range": {"start": "1810-09-16T05:00:00", "end": "1810-09-16T07:00:00"},
"geo_anchor": {"lat": 21.1561, "lon": -100.9326, "radius_km": 5},
"confidence": 0.82,
"relationships": {"consequences": ["Mexican War of Independence"]},
},
{
"name": "Storming of the Bastille",
"slug": "storming_of_the_bastille",
"year": 1789,
"start_year": 1789,
"end_year": 1789,
"month": 7,
"day": 14,
"lat": 48.8530,
"lon": 2.3692,
"summary": "Parisian revolutionaries seize the Bastille fortress, igniting the French Revolution.",
"narrative": (
"Parisians wielding pikes and muskets swarm the Bastille's stone courtyard as smoke billows from cannon fire. "
"National Guardsmen drag royal cannons into position while prisoners emerge to cheering crowds waving tricolor cockades."
),
"themes": ["revolution", "political upheaval"],
"actors": ["Parisian crowds", "National Guardsmen", "Royal soldiers"],
"artifacts": ["Tricolor cockades", "Iron portcullis", "Cannons", "Stone battlements"],
"visual_motifs": ["smoke-filled courtyard", "stormy summer sky", "stone fortress", "crowd surge"],
"facets": {"era": "late 18th century", "region": "western_europe", "type": "revolutionary uprising"},
"sources": [{"label": "French Archives", "url": "https://www.archives-nationales.culture.gouv.fr"}],
"time_range": {"start": "1789-07-14T09:00:00", "end": "1789-07-14T17:00:00"},
"geo_anchor": {"lat": 48.8530, "lon": 2.3692, "radius_km": 3},
"confidence": 0.84,
"relationships": {"consequences": ["Declaration of the Rights of Man"]},
},
{
"name": "Assassination of Julius Caesar",
"slug": "assassination_of_julius_caesar",
"year": -44,
"start_year": -44,
"end_year": -44,
"month": 3,
"day": 15,
"lat": 41.8933,
"lon": 12.4729,
"summary": "Julius Caesar is stabbed by Roman senators inside the Theatre of Pompey during the Ides of March.",
"narrative": (
"Late morning sunlight filters through the marble portico as Caesar takes his seat. "
"Senators in scarlet-trimmed togas encircle him; daggers flash, and the dictator staggers toward the statue of Pompey "
"beneath frescoed arches and hanging laurel wreaths."
),
"themes": ["political", "assassination", "ancient rome"],
"actors": ["Julius Caesar", "Marcus Junius Brutus", "Gaius Cassius Longinus", "Roman senators"],
"artifacts": ["Marble curule chair", "Bronze daggers", "Laurel wreaths", "Blood-stained togas"],
"visual_motifs": ["marble columns", "sunbeam through smoke", "collapsing laurel crown"],
"facets": {"era": "classical antiquity", "region": "western_europe", "type": "political assassination"},
"sources": [{"label": "Ancient Rome", "url": "https://en.wikipedia.org/wiki/Assassination_of_Julius_Caesar"}],
"time_range": {"start": "-0044-03-15T11:00:00", "end": "-0044-03-15T12:00:00"},
"geo_anchor": {"lat": 41.8933, "lon": 12.4729, "radius_km": 2},
"confidence": 0.9,
"relationships": {"consequences": ["Liberators' civil war"]},
},
]
CURATED_ARTIFACTS: List[dict] = [
{"title": "Graffiti fragment of the Berlin Wall", "culture": "German", "period": "Cold War", "era": (1961, 1990)},
{"title": "Allied M1 Helmet", "culture": "American", "period": "World War II", "era": (1941, 1945)},
{"title": "Continental Congress inkwell", "culture": "American", "period": "Revolutionary", "era": (1765, 1783)},
{"title": "French cuirassier armor", "culture": "French", "period": "Napoleonic", "era": (1800, 1815)},
{"title": "Goddess of Democracy maquette", "culture": "Chinese", "period": "Late 20th century", "era": (1980, 1990)},
{"title": "Saturn V mission patch", "culture": "American", "period": "Space Age", "era": (1960, 1975)},
{"title": "Wright Flyer blueprint", "culture": "American", "period": "Early Aviation", "era": (1899, 1905)},
{"title": "Bastille prison key", "culture": "French", "period": "Revolutionary", "era": (1789, 1799)},
]
ERA_VISUAL_VOCABULARY: Dict[Tuple[int, int], dict] = {
(-5000, 1700): {
"architecture": "stone structures, timber framing, open marketplaces",
"clothing": "homespun fabrics, cloaks, leather sandals",
"technology": "handcrafted tools, smoke from hearth fires, animal-drawn transport",
"transport": "horses, carts, foot traffic",
"mood": "earthy textures, smoke and torchlight",
},
(1700, 1850): {
"architecture": "Georgian and neoclassical facades, stone avenues, colonial interiors",
"clothing": "powdered wigs, waistcoats, breeches, corseted gowns",
"technology": "printing presses, quill ink, carronade cannons",
"transport": "horse-drawn carriages, sailing ships, infantry columns",
"mood": "oil-painted lighting, warm candle glow and shadow",
},
(1850, 1918): {
"architecture": "industrial brick mills, iron train stations, Victorian terraces",
"clothing": "bowler hats, uniforms with brass buttons, layered dresses",
"technology": "steam locomotives, telegraph poles, gas lanterns",
"transport": "steam trains, horse omnibuses, early bicycles",
"mood": "coal smoke haze, sepia-toned atmosphere",
},
(1918, 1950): {
"architecture": "art deco facades, reinforced bunkers, concrete civic plazas",
"clothing": "military uniforms, flapper dresses, utilitarian workwear",
"technology": "radio towers, field telephones, propeller aircraft",
"transport": "steel warships, troop trucks, streetcars",
"mood": "black-and-white newsreel grit, halation from searchlights",
},
(1950, 1990): {
"architecture": "mid-century modern lines, brutalist government blocks, neon signage",
"clothing": "denim jackets, tailored suits, Cold War uniforms",
"technology": "cathode-ray cameras, satellite dishes, analog broadcast vans",
"transport": "boxy sedans, subway trains, patrol jeeps",
"mood": "sodium-vapor glow, vivid chromatic contrasts",
},
(1990, 2030): {
"architecture": "glass high-rises, LED billboards, postmodern cultural centers",
"clothing": "synthetic fabrics, streetwear, modern uniforms",
"technology": "smart devices, digital screens, drones",
"transport": "light rail, electric cars, bicycles with LED lights",
"mood": "clean highlights, cinematic depth of field, vibrant color grading",
},
}
REGIONAL_CONTEXT: Dict[str, dict] = {
"western_europe": {
"architecture": "historic stone plazas, cathedrals, tram-lined boulevards",
"climate": "temperate weather with layered clouds and soft rain",
},
"eastern_europe": {
"architecture": "Soviet-era apartment blocks, neoclassical government buildings",
"climate": "continental climate with sharp seasonal contrast",
},
"north_america": {
"architecture": "brick row houses, colonial meeting halls, steel skyscrapers",
"climate": "varied weather, from humid summers to snowy winters",
},
"east_asia": {
"architecture": "pagoda rooftops, dense urban districts, neon signage",
"climate": "humid subtropical seasons with monsoon rains",
},
"central_america": {
"architecture": "stucco plazas, colonial churches, cobblestone streets",
"climate": "warm highland mornings with misty horizons",
},
"western_asia": {
"architecture": "stone citadels, market arcades, desert courtyards",
"climate": "arid sunlight, dust carried on dry winds",
},
}
def get_era_vocabulary(year: int) -> dict:
for (start, end), vocab in ERA_VISUAL_VOCABULARY.items():
if start <= year < end:
return vocab
# Default to modern vocabulary
return ERA_VISUAL_VOCABULARY[(1950, 1990)]
def get_region_context(region_key: Optional[str]) -> dict:
if not region_key:
return {}
return REGIONAL_CONTEXT.get(region_key.lower(), {})
def format_event_digest(event: dict) -> dict:
return {
"name": event.get("name"),
"slug": event.get("slug"),
"year": event.get("year"),
"start_year": event.get("start_year"),
"end_year": event.get("end_year"),
"month": event.get("month"),
"day": event.get("day"),
"lat": event.get("lat"),
"lon": event.get("lon"),
"summary": event.get("summary"),
"themes": ensure_iterable(event.get("themes")),
"facets": event.get("facets", {}),
"distance_km": event.get("distance_km"),
"year_delta": event.get("year_delta"),
"match_confidence": event.get("match_confidence"),
"sources": ensure_iterable(event.get("sources")),
}
def build_event_context(event: dict) -> dict:
return {
"event": format_event_digest(event),
"narrative": event.get("narrative"),
"actors": ensure_iterable(event.get("actors")),
"artifacts": ensure_iterable(event.get("artifacts")),
"visual_motifs": ensure_iterable(event.get("visual_motifs")),
"relationships": event.get("relationships", {}),
"time_range": event.get("time_range"),
"geo_anchor": event.get("geo_anchor"),
"confidence": event.get("match_confidence", event.get("confidence")),
}
def get_events_response(
lat: float,
lon: float,
year: int,
radius_km: float = 250.0,
limit: int = 5,
) -> dict:
matches = get_events_by_coordinates(lat, lon, year, radius_km=radius_km, limit=limit)
return {
"query": {"lat": lat, "lon": lon, "year": year, "radius_km": radius_km, "limit": limit},
"count": len(matches),
"events": [format_event_digest(event) for event in matches],
}