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