|
|
from __future__ import annotations |
|
|
|
|
|
import json |
|
|
import math |
|
|
import sqlite3 |
|
|
from pathlib import Path |
|
|
from typing import Dict, Iterable, List, Optional, Tuple |
|
|
|
|
|
|
|
|
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" |
|
|
|
|
|
|
|
|
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 |
|
|
) |
|
|
""" |
|
|
) |
|
|
|
|
|
|
|
|
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}") |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if year_delta == 0: |
|
|
year_score = -50 * year_weight |
|
|
elif year_delta <= 2: |
|
|
year_score = -30 * year_weight |
|
|
elif year_delta <= 5: |
|
|
year_score = -15 * year_weight |
|
|
elif year_delta <= 10: |
|
|
year_score = 0 |
|
|
else: |
|
|
year_score = year_delta * 3 * year_weight |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if distance < 50: |
|
|
distance_score = -20 * (1 - year_weight * 0.3) |
|
|
elif distance < 200: |
|
|
distance_score = distance * 0.1 |
|
|
else: |
|
|
distance_score = distance * 0.2 * (1 - year_weight * 0.3) |
|
|
|
|
|
|
|
|
match_score = distance_score + year_score |
|
|
|
|
|
|
|
|
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 |
|
|
""" |
|
|
|
|
|
events = load_events_from_db() |
|
|
matches: List[dict] = [] |
|
|
|
|
|
|
|
|
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 |
|
|
) |
|
|
|
|
|
|
|
|
if distance > effective_radius and year_delta > 10: |
|
|
continue |
|
|
|
|
|
|
|
|
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] |
|
|
|
|
|
|
|
|
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, |
|
|
) |
|
|
|
|
|
|
|
|
curated_names = {m.get("name", "").lower() for m in curated_results} |
|
|
|
|
|
for wd_event in wikidata_results: |
|
|
|
|
|
event_name = wd_event.get("name", "").lower() |
|
|
if event_name in curated_names: |
|
|
continue |
|
|
|
|
|
|
|
|
confidence = wd_event.get("match_confidence", wd_event.get("confidence", 0)) |
|
|
if confidence < WIKIDATA_CONFIDENCE_THRESHOLD: |
|
|
continue |
|
|
|
|
|
|
|
|
wd_event["source"] = "wikidata" |
|
|
wd_year_delta = wd_event.get("year_delta", 99) |
|
|
wd_distance = wd_event.get("distance_km", 999) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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. |
|
|
""" |
|
|
|
|
|
event = get_event_by_slug(name.lower().replace(" ", "_")) |
|
|
if event: |
|
|
event["source"] = "curated" |
|
|
return event |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
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], |
|
|
} |
|
|
|
|
|
|
|
|
|