from typing import List, Dict, Any from neo4j import Driver _UC_SKOS_REL = "IS_BROADER_CONCEPT|IS_NARROWER_CONCEPT|IS_RELATED_CONCEPT" _LINK_QUERY_HOPS_1 = """ MATCH (b1:Breakthrough {name: $breakthrough_1})-[:REQUIRES|ADVANCES]->(uc:UNESCOconcept) <-[:REQUIRES|ADVANCES]-(b2:Breakthrough {name: $breakthrough_2}) RETURN b1.name AS breakthrough_1, b2.name AS breakthrough_2, 1 AS hops, [uc.pref_label_en] AS concept_chain, count(*) AS path_count ORDER BY path_count DESC """ _LINK_QUERY_HOPS_2 = f""" MATCH (b1:Breakthrough {{name: $breakthrough_1}})-[:REQUIRES|ADVANCES]->(uc:UNESCOconcept) <-[:REQUIRES|ADVANCES]-(b2:Breakthrough {{name: $breakthrough_2}}) RETURN b1.name AS breakthrough_1, b2.name AS breakthrough_2, 1 AS hops, [uc.pref_label_en] AS concept_chain, count(*) AS path_count UNION ALL MATCH (b1:Breakthrough {{name: $breakthrough_1}})-[:REQUIRES|ADVANCES]->(uc1:UNESCOconcept) -[:{_UC_SKOS_REL}]-(uc2:UNESCOconcept) <-[:REQUIRES|ADVANCES]-(b2:Breakthrough {{name: $breakthrough_2}}) WHERE uc1 <> uc2 RETURN b1.name AS breakthrough_1, b2.name AS breakthrough_2, 2 AS hops, [uc1.pref_label_en, uc2.pref_label_en] AS concept_chain, count(*) AS path_count ORDER BY hops ASC, path_count DESC """ _LINK_QUERY_HOPS_3 = f""" MATCH (b1:Breakthrough {{name: $breakthrough_1}})-[:REQUIRES|ADVANCES]->(uc:UNESCOconcept) <-[:REQUIRES|ADVANCES]-(b2:Breakthrough {{name: $breakthrough_2}}) RETURN b1.name AS breakthrough_1, b2.name AS breakthrough_2, 1 AS hops, [uc.pref_label_en] AS concept_chain, count(*) AS path_count UNION ALL MATCH (b1:Breakthrough {{name: $breakthrough_1}})-[:REQUIRES|ADVANCES]->(uc1:UNESCOconcept) -[:{_UC_SKOS_REL}]-(uc2:UNESCOconcept) <-[:REQUIRES|ADVANCES]-(b2:Breakthrough {{name: $breakthrough_2}}) WHERE uc1 <> uc2 RETURN b1.name AS breakthrough_1, b2.name AS breakthrough_2, 2 AS hops, [uc1.pref_label_en, uc2.pref_label_en] AS concept_chain, count(*) AS path_count UNION ALL MATCH (b1:Breakthrough {{name: $breakthrough_1}})-[:REQUIRES|ADVANCES]->(uc1:UNESCOconcept) -[:{_UC_SKOS_REL}]-(uc2:UNESCOconcept) -[:{_UC_SKOS_REL}]-(uc3:UNESCOconcept) <-[:REQUIRES|ADVANCES]-(b2:Breakthrough {{name: $breakthrough_2}}) WHERE uc1 <> uc2 AND uc2 <> uc3 AND uc1 <> uc3 RETURN b1.name AS breakthrough_1, b2.name AS breakthrough_2, 3 AS hops, [uc1.pref_label_en, uc2.pref_label_en, uc3.pref_label_en] AS concept_chain, count(*) AS path_count ORDER BY hops ASC, path_count DESC """ # Concept importance queries - unified logic with optional platform weighting # Only consider breakthroughs with is_latest flag to avoid counting duplicates _CONCEPT_IMPORTANCE_REL_PATTERNS = { "all": "REQUIRES|ADVANCES", "requires_only": "REQUIRES", "advances_only": "ADVANCES", } def _build_concept_importance_query(rel_pattern: str, hops: int) -> str: rel = _CONCEPT_IMPORTANCE_REL_PATTERNS[rel_pattern] if hops == 0: return f""" MATCH (p:Platform)-[:CONTAINS]->(et:`Emerging topic`)-[:CONTAINS]-> (b:Breakthrough)-[:{rel}]->(uc:UNESCOconcept) WHERE ($latest_only = false OR (b.is_latest = true)) WITH uc, count(DISTINCT p) AS platform_span, count(DISTINCT b) AS breakthrough_count RETURN uc.pref_label_en AS concept_name, breakthrough_count, platform_span, breakthrough_count * platform_span AS importance_score ORDER BY breakthrough_count DESC, importance_score DESC LIMIT $limit """ else: return f""" MATCH (p:Platform)-[:CONTAINS]->(et:`Emerging topic`)-[:CONTAINS]-> (b:Breakthrough)-[:{rel}]->(uc:UNESCOconcept) -[:{_UC_SKOS_REL}*0..{hops}]-(uc_final:UNESCOconcept) WHERE ($latest_only = false OR (b.is_latest = true)) WITH uc_final, count(DISTINCT p) AS platform_span, count(DISTINCT b) AS breakthrough_count RETURN uc_final.pref_label_en AS concept_name, breakthrough_count, platform_span, breakthrough_count * platform_span AS importance_score ORDER BY breakthrough_count DESC, importance_score DESC LIMIT $limit """ # SDG-based concept importance: rank concepts by CONTRIBUTES_TO count, optionally weighted by SDGgoal span def _build_concept_sdg_importance_query(hops: int) -> str: if hops == 0: return """ MATCH (uc:UNESCOconcept)-[:CONTRIBUTES_TO]->(t:SDGtarget)<-[:HAS_TARGET]-(g:SDGgoal) WITH uc, count(DISTINCT t) AS n_sdgTargets_contributed, count(DISTINCT g) AS n_sdgGoals_contributed RETURN uc.pref_label_en AS concept_name, n_sdgTargets_contributed, n_sdgGoals_contributed, n_sdgTargets_contributed * n_sdgGoals_contributed AS importance_score ORDER BY n_sdgTargets_contributed DESC, importance_score DESC LIMIT $limit """ else: return f""" MATCH (uc:UNESCOconcept)-[:CONTRIBUTES_TO]->(t:SDGtarget)<-[:HAS_TARGET]-(g:SDGgoal) MATCH (uc)-[:{_UC_SKOS_REL}*0..{hops}]-(uc_final:UNESCOconcept) WITH uc_final, count(DISTINCT t) AS n_sdgTargets_contributed, count(DISTINCT g) AS n_sdgGoals_contributed RETURN uc_final.pref_label_en AS concept_name, n_sdgTargets_contributed, n_sdgGoals_contributed, n_sdgTargets_contributed * n_sdgGoals_contributed AS importance_score ORDER BY n_sdgTargets_contributed DESC, importance_score DESC LIMIT $limit """ _BREAKTHROUGH_SPAN_PROFILE_RELS = { "all": ("REQUIRES|ADVANCES", "REQUIRES|ADVANCES"), "producer": ("ADVANCES", "REQUIRES"), "receiver": ("REQUIRES", "ADVANCES"), } def _build_breakthrough_platform_span_query(profile: str, hops: int) -> str: b_rel, other_rel = _BREAKTHROUGH_SPAN_PROFILE_RELS[profile] if hops == 0: return f""" MATCH (b:Breakthrough)-[:{b_rel}]->(uc:UNESCOconcept) <-[:{other_rel}]-(b_other:Breakthrough) <-[:CONTAINS]-(:`Emerging topic`)<-[:CONTAINS]-(p_other:Platform) WHERE ($latest_only = false OR (b.is_latest = true AND b_other.is_latest = true)) WITH b, count(DISTINCT b_other) AS reached_breakthroughs, count(DISTINCT p_other) AS platform_span RETURN b.name AS breakthrough_name, b.radar_version AS radar_version, reached_breakthroughs, platform_span, reached_breakthroughs * platform_span AS span_score ORDER BY reached_breakthroughs DESC, span_score DESC, breakthrough_name ASC LIMIT $limit """ else: return f""" MATCH (b:Breakthrough)-[:{b_rel}]->(uc:UNESCOconcept) -[:{_UC_SKOS_REL}*0..{hops}]-(uc_final:UNESCOconcept) <-[:{other_rel}]-(b_other:Breakthrough) <-[:CONTAINS]-(:`Emerging topic`)<-[:CONTAINS]-(p_other:Platform) WHERE ($latest_only = false OR (b.is_latest = true AND b_other.is_latest = true)) WITH b, count(DISTINCT b_other) AS reached_breakthroughs, count(DISTINCT p_other) AS platform_span RETURN b.name AS breakthrough_name, b.radar_version AS radar_version, reached_breakthroughs, platform_span, reached_breakthroughs * platform_span AS span_score ORDER BY reached_breakthroughs DESC, span_score DESC, breakthrough_name ASC LIMIT $limit """ def _build_rank_breakthroughs_sdg_query(hops: int) -> str: if hops == 0: return """ MATCH (b:Breakthrough)-[:REQUIRES|ADVANCES]->(uc:UNESCOconcept) -[:CONTRIBUTES_TO]->(t:SDGtarget)<-[:HAS_TARGET]-(g:SDGgoal) WHERE b.is_latest = true WITH b, count(DISTINCT t) AS n_sdg_targets, count(DISTINCT g) AS n_sdg_goals RETURN b.name AS breakthrough_name, b.radar_version AS radar_version, n_sdg_targets, n_sdg_goals, n_sdg_targets * n_sdg_goals AS importance_score ORDER BY n_sdg_targets DESC, importance_score DESC LIMIT $limit """ else: return f""" MATCH (b:Breakthrough)-[:REQUIRES|ADVANCES]->(uc:UNESCOconcept) -[:{_UC_SKOS_REL}*0..{hops}]-(uc2:UNESCOconcept) -[:CONTRIBUTES_TO]->(t:SDGtarget)<-[:HAS_TARGET]-(g:SDGgoal) WHERE b.is_latest = true WITH b, count(DISTINCT t) AS n_sdg_targets, count(DISTINCT g) AS n_sdg_goals RETURN b.name AS breakthrough_name, b.radar_version AS radar_version, n_sdg_targets, n_sdg_goals, n_sdg_targets * n_sdg_goals AS importance_score ORDER BY n_sdg_targets DESC, importance_score DESC LIMIT $limit """ # --- Entity resolution queries (Phase 1) --- _RESOLVE_BREAKTHROUGH = """ MATCH (n:Breakthrough) WHERE toLower(n.name) CONTAINS toLower($keyword) WITH n, CASE WHEN toLower(n.name) = toLower($keyword) THEN 1.0 WHEN toLower(n.name) STARTS WITH toLower($keyword) THEN 0.9 ELSE 0.7 END AS score RETURN n.name AS name, score ORDER BY score DESC, n.name ASC LIMIT $limit """ _RESOLVE_CONCEPT = """ MATCH (n:UNESCOconcept) WHERE toLower(n.pref_label_en) CONTAINS toLower($keyword) WITH n, CASE WHEN toLower(n.pref_label_en) = toLower($keyword) THEN 1.0 WHEN toLower(n.pref_label_en) STARTS WITH toLower($keyword) THEN 0.9 ELSE 0.7 END AS score RETURN n.pref_label_en AS name, score ORDER BY score DESC, n.pref_label_en ASC LIMIT $limit """ _RESOLVE_PLATFORM = """ MATCH (n:Platform {is_latest: true}) WHERE toLower(n.name) CONTAINS toLower($keyword) WITH n, CASE WHEN toLower(n.name) = toLower($keyword) THEN 1.0 WHEN toLower(n.name) STARTS WITH toLower($keyword) THEN 0.9 ELSE 0.7 END AS score RETURN n.name AS name, score ORDER BY score DESC LIMIT $limit """ _RESOLVE_OECD_FIELD = """ MATCH (n:OECDfield) WHERE toLower(n.name) CONTAINS toLower($keyword) WITH n, CASE WHEN toLower(n.name) = toLower($keyword) THEN 1.0 WHEN toLower(n.name) STARTS WITH toLower($keyword) THEN 0.9 ELSE 0.7 END AS score RETURN n.name AS name, score ORDER BY score DESC, n.name ASC LIMIT $limit """ _RESOLVE_EMERGING_TOPIC = """ MATCH (n:`Emerging topic`) WHERE toLower(n.name) CONTAINS toLower($keyword) WITH n, CASE WHEN toLower(n.name) = toLower($keyword) THEN 1.0 WHEN toLower(n.name) STARTS WITH toLower($keyword) THEN 0.9 ELSE 0.7 END AS score RETURN n.name AS name, score ORDER BY score DESC, n.name ASC LIMIT $limit """ # --- Cross-domain bridge queries (Phase 3) --- class QueryExecutor: """Executes parameterized queries for breakthrough linking analysis.""" def __init__(self, driver: Driver): """ Initialize executor with Neo4j driver. Args: driver: neo4j.Driver instance """ self.driver = driver self.last_query: str = "" self.last_params: Dict[str, Any] = {} def _execute_query(self, query: str, params: Dict[str, Any]) -> List[Dict]: """ Execute parameterized query and return results as list of dicts. Args: query: Cypher query with $parameter placeholders params: Dictionary of parameters Returns: List of dictionaries with query results """ self.last_query = query self.last_params = params with self.driver.session() as session: result = session.run(query, params) return [dict(record) for record in result] # ------------------------------------------------------------------------- # Phase 1 — Entity resolution # ------------------------------------------------------------------------- def resolve_entities( self, keyword: str, node_label: str = "Breakthrough", limit: int = 10, ) -> List[Dict]: """ Translate a keyword fragment into candidate graph node names. Scores matches: exact=1.0, prefix=0.9, substring=0.7. This is the entity resolution layer that underpins all keyword-based queries — call it first to map natural-language terms to exact names. Args: keyword: Search term (case-insensitive substring match) node_label: One of "Breakthrough", "UNESCOconcept", "Platform", "OECDfield", "Emerging topic" limit: Maximum candidates to return Returns: List of dicts with keys: name, score """ queries = { "Breakthrough": _RESOLVE_BREAKTHROUGH, "UNESCOconcept": _RESOLVE_CONCEPT, "Platform": _RESOLVE_PLATFORM, "OECDfield": _RESOLVE_OECD_FIELD, "Emerging topic": _RESOLVE_EMERGING_TOPIC, } if node_label not in queries: raise ValueError( f"node_label must be one of {list(queries)}; got {node_label!r}" ) return self._execute_query(queries[node_label], {"keyword": keyword, "limit": limit}) def list_platforms(self, latest_only: bool = True) -> List[Dict]: """ List all platform names. Args: latest_only: Restrict to 2026 radar platforms if True Returns: List of dicts with keys: name, radar_index """ query = """ MATCH (p:Platform) WHERE ($latest_only = false OR p.is_latest = true) RETURN DISTINCT p.name AS name, p.radar_index AS radar_index ORDER BY p.radar_index """ return self._execute_query(query, {"latest_only": latest_only}) def list_oecd_fields(self, limit: int = None) -> List[Dict]: """ List all OECD research fields. Returns: List of dicts with key: name """ query = "MATCH (of:OECDfield) RETURN DISTINCT of.name AS name ORDER BY of.name" if limit: query += f" LIMIT {limit}" return self._execute_query(query, {}) # ------------------------------------------------------------------------- # Phase 2 — Domain keyword discovery # ------------------------------------------------------------------------- def get_entity_by_keyword( self, keyword: str, node_label: str = "Breakthrough", latest_only: bool = True, limit: int = 20, ) -> List[Dict]: """ Find graph entities matching a keyword, enriched with context. Uses resolve_entities internally for fuzzy matching, then fetches context appropriate to the node type. The score from the fuzzy match is included in every result. Args: keyword: Case-insensitive substring node_label: One of "Breakthrough", "UNESCOconcept", "Platform", "OECDfield", "Emerging topic" latest_only: Restrict to 2026 radar where applicable limit: Maximum results Returns: Breakthrough → name, platform_name, emerging_topic, score Platform → name, radar_index, breakthrough_count, score UNESCOconcept → name, breakthrough_count, platform_span, score OECDfield → name, breakthrough_count, concept_count, score Emerging topic → name, platform_name, breakthrough_count, score """ candidates = self.resolve_entities(keyword, node_label, limit=limit) if not candidates: return [] names = [r["name"] for r in candidates] score_map = {r["name"]: r["score"] for r in candidates} if node_label == "Breakthrough": query = """ MATCH (p:Platform)-[:CONTAINS]-> (et:`Emerging topic`)-[:CONTAINS]->(b:Breakthrough) WHERE ($latest_only = false OR (b.is_latest = true)) AND b.name IN $names RETURN DISTINCT b.name AS name, p.name AS platform_name, et.name AS emerging_topic ORDER BY p.name, b.name """ elif node_label == "Platform": query = """ MATCH (p:Platform) WHERE ($latest_only = false OR p.is_latest = true) AND p.name IN $names OPTIONAL MATCH (p)-[:CONTAINS]->(:`Emerging topic`)-[:CONTAINS]->(b:Breakthrough) WITH p, count(DISTINCT b) AS breakthrough_count RETURN p.name AS name, p.radar_index AS radar_index, breakthrough_count ORDER BY p.radar_index """ elif node_label == "UNESCOconcept": query = """ MATCH (uc:UNESCOconcept) WHERE uc.pref_label_en IN $names OPTIONAL MATCH (p:Platform)-[:CONTAINS]-> (:`Emerging topic`)-[:CONTAINS]->(b:Breakthrough)-[:REQUIRES|ADVANCES]->(uc) WHERE ($latest_only = false OR (b.is_latest = true)) WITH uc, count(DISTINCT b) AS breakthrough_count, count(DISTINCT p) AS platform_span RETURN uc.pref_label_en AS name, breakthrough_count, platform_span ORDER BY breakthrough_count DESC """ elif node_label == "OECDfield": query = """ MATCH (of:OECDfield) WHERE of.name IN $names OPTIONAL MATCH (of)-[:IS_BROAD_MATCH|IS_EXACT_MATCH|IS_RELATED_CONCEPT]-(uc:UNESCOconcept) <-[:REQUIRES|ADVANCES]-(b:Breakthrough) <-[:CONTAINS]-(:`Emerging topic`)<-[:CONTAINS]-(:Platform) WHERE ($latest_only = false OR (b.is_latest = true)) WITH of, count(DISTINCT b) AS breakthrough_count, count(DISTINCT uc) AS concept_count RETURN of.name AS name, breakthrough_count, concept_count ORDER BY breakthrough_count DESC """ elif node_label == "Emerging topic": query = """ MATCH (p:Platform)-[:CONTAINS]->(et:`Emerging topic`) WHERE et.name IN $names OPTIONAL MATCH (et)-[:CONTAINS]->(b:Breakthrough) WHERE ($latest_only = false OR (b.is_latest = true AND b_other.is_latest = true)) WITH et, p, count(DISTINCT b) AS breakthrough_count RETURN DISTINCT et.name AS name, p.name AS platform_name, breakthrough_count ORDER BY et.name """ results = self._execute_query( query, {"names": names, "latest_only": latest_only} ) for r in results: r["score"] = score_map.get(r["name"], 0.7) return results def get_breakthroughs_by_platform( self, platform_name: str, latest_only: bool = True, limit: int = 50, ) -> List[Dict]: """ List all breakthroughs belonging to a named platform. Platform name is matched as a case-insensitive substring, so "AI" matches "AI & Machine Learning", etc. Args: platform_name: Platform name keyword latest_only: Restrict to is_latest breakthroughs if True limit: Maximum results Returns: List of dicts with keys: breakthrough_name, emerging_topic, platform_name """ query = """ MATCH (p:Platform)-[:CONTAINS]-> (et:`Emerging topic`)-[:CONTAINS]->(b:Breakthrough) WHERE ($latest_only = false OR (b.is_latest = true)) AND toLower(p.name) CONTAINS toLower($platform_name) RETURN DISTINCT b.name AS breakthrough_name, et.name AS emerging_topic, p.name AS platform_name ORDER BY et.name, b.name LIMIT $limit """ return self._execute_query( query, {"platform_name": platform_name, "latest_only": latest_only, "limit": limit}, ) def get_new_topics_in_edition(self) -> List[Dict]: """ Return breakthroughs present in the latest edition but absent in previous editions. Answers "which topics are new in the current edition vs previous editions?" using is_latest property. Returns: List of dicts with keys: breakthrough_name, emerging_topic, platform_name """ query = """ MATCH (p:Platform)-[:CONTAINS]->(et:`Emerging topic`)-[:CONTAINS]->(b_current:Breakthrough) WHERE b_current.is_latest = true AND NOT EXISTS { MATCH (b_previous:Breakthrough) WHERE b_previous.name = b_current.name AND b_previous.is_latest = false } RETURN b_current.name AS breakthrough_name, et.name AS emerging_topic, p.name AS platform_name ORDER BY p.name, b_current.name """ return self._execute_query(query, {}) # ------------------------------------------------------------------------- # Phase 3 — Cross-domain concept bridges # ------------------------------------------------------------------------- def get_cross_domain_bridges( self, keyword_1: str, keyword_2: str, latest_only: bool = False, limit: int = 20, path_length: int = None, hops: int = 2, ) -> List[Dict]: """ Find paths between two breakthroughs via UNESCOconcepts only. If path_length is specified, returns all paths of exactly that length (in relationships). Otherwise, returns all paths of the same shortest length. Args: keyword_1: Keyword matched against breakthrough names for side 1 keyword_2: Keyword matched against breakthrough names for side 2 latest_only: Restrict to is_latest breakthroughs if True (kept for compatibility) limit: Maximum number of paths to return path_length: If set, return paths of exactly this length (number of relationships). If None, return all shortest paths. Returns: List of dicts with path structure: b1_name, rel_type_1, concept_1, skos_rel_1, ..., rel_type_2, b2_name, distance """ path_length = hops+2 if path_length is not None: query = f""" MATCH p=((b1:Breakthrough {{name: $keyword_1}})-[*{path_length}..{path_length}]-(b2:Breakthrough {{name: $keyword_2}})) WHERE all(n IN nodes(p) WHERE (n = b1 OR n = b2) OR labels(n)[0] = 'UNESCOconcept') RETURN p LIMIT $limit """ else: query = """ MATCH p=allShortestPaths((b1:Breakthrough {name: $keyword_1})-[*]-(b2:Breakthrough {name: $keyword_2})) WHERE all(n IN nodes(p) WHERE (n = b1 OR n = b2) OR labels(n)[0] = 'UNESCOconcept') RETURN p LIMIT $limit """ params = {"keyword_1": keyword_1, "keyword_2": keyword_2, "limit": limit} self.last_query = query self.last_params = params formatted_results = [] with self.driver.session() as session: result = session.run(query, params) for record in result: path = record["p"] nodes = path.nodes rels = path.relationships row = {} row["b1_name"] = nodes[0]["name"] row["b1_radar_version"] = nodes[0].get("radar_version") row["rel_type_1"] = rels[0].type # Add intermediate concepts and relationships for i in range(1, len(nodes) - 1): row[f"concept_{i}"] = nodes[i].get("pref_label_en", nodes[i].get("name")) if i < len(nodes) - 2: row[f"skos_rel_{i}"] = rels[i].type row["rel_type_2"] = rels[-1].type row["b2_name"] = nodes[-1]["name"] row["b2_radar_version"] = nodes[-1].get("radar_version") row["distance"] = len(rels) formatted_results.append(row) return formatted_results def get_platform_overlap( self, platform_1: str, platform_2: str, latest_only: bool = True, limit: int = 20, ) -> List[Dict]: """ Find UNESCOconcepts shared between two named platforms. Returns concepts that appear in breakthroughs from both platforms, with the breakthrough lists from each side. Args: platform_1: First platform name keyword platform_2: Second platform name keyword latest_only: Restrict to is_latest breakthroughs if True limit: Maximum results Returns: List of dicts with keys: concept_name, p1_breakthroughs, p2_breakthroughs, overlap_score """ query = """ MATCH (p1:Platform)-[:CONTAINS]-> (:`Emerging topic`)-[:CONTAINS]->(b1:Breakthrough) -[:REQUIRES|ADVANCES]->(uc:UNESCOconcept) <-[:REQUIRES|ADVANCES]-(b2:Breakthrough) <-[:CONTAINS]-(:`Emerging topic`)<-[:CONTAINS]-(p2:Platform) WHERE ($latest_only = false OR (b1.is_latest = true AND b2.is_latest = true)) AND toLower(p1.name) CONTAINS toLower($platform_1) AND toLower(p2.name) CONTAINS toLower($platform_2) AND p1 <> p2 WITH uc, collect(DISTINCT b1.name) AS p1_breakthroughs, collect(DISTINCT b2.name) AS p2_breakthroughs, count(DISTINCT b1) * count(DISTINCT b2) AS overlap_score RETURN uc.pref_label_en AS concept_name, p1_breakthroughs, p2_breakthroughs, overlap_score ORDER BY overlap_score DESC LIMIT $limit """ return self._execute_query( query, { "platform_1": platform_1, "platform_2": platform_2, "latest_only": latest_only, "limit": limit, }, ) # ------------------------------------------------------------------------- # Phase 4 — Impact scoring # ------------------------------------------------------------------------- def get_breakthrough_sdg_impact( self, breakthrough_keyword: str, latest_only: bool = True, limit: int = 20, hops: int = 1, ) -> List[Dict]: """ Score breakthroughs by how many distinct SDG targets they address. Answers "which renewable energy breakthroughs impact multiple SDG targets?". Args: breakthrough_keyword: Case-insensitive keyword matched against breakthrough names latest_only: Restrict to is_latest breakthroughs if True limit: Maximum results hops: Max concept hops via SKOS graph (1–3). Default 1. Returns: List of dicts with keys: breakthrough_name, platform_name, sdg_count, sdg_targets """ if not isinstance(hops, int) or hops < 0: raise ValueError(f"hops must be a non-negative integer; got {hops}") if hops == 0: query = """ MATCH (p:Platform)-[:CONTAINS]-> (:`Emerging topic`)-[:CONTAINS]->(b:Breakthrough) -[:REQUIRES|ADVANCES]->(uc:UNESCOconcept) -[:CONTRIBUTES_TO]->(t:SDGtarget) WHERE ($latest_only = false OR (b.is_latest = true)) AND toLower(b.name) CONTAINS toLower($keyword) WITH b, p, count(DISTINCT t) AS sdg_count, collect(DISTINCT t.target_id) AS sdg_targets RETURN b.name AS breakthrough_name, p.name AS platform_name, sdg_count, sdg_targets ORDER BY sdg_count DESC, b.name ASC LIMIT $limit """ else: query = f""" MATCH (p:Platform)-[:CONTAINS]-> (:`Emerging topic`)-[:CONTAINS]->(b:Breakthrough) -[:REQUIRES|ADVANCES]->(uc1:UNESCOconcept) -[:{_UC_SKOS_REL}*0..{hops}]-(uc2:UNESCOconcept) -[:CONTRIBUTES_TO]->(t:SDGtarget) WHERE ($latest_only = false OR (b.is_latest = true)) AND toLower(b.name) CONTAINS toLower($keyword) WITH b, p, count(DISTINCT t) AS sdg_count, collect(DISTINCT t.target_id) AS sdg_targets RETURN b.name AS breakthrough_name, p.name AS platform_name, sdg_count, sdg_targets ORDER BY sdg_count DESC, b.name ASC LIMIT $limit """ return self._execute_query( query, { "keyword": breakthrough_keyword, "latest_only": latest_only, "limit": limit, }, ) def rank_breakthroughs_by_sdg_impact( self, limit: int = 30, hops: int = 1, weight_by_ngoals: bool = True, ) -> List[Dict]: """ Rank all latest-radar breakthroughs by how many SDG targets they address. weight_by_ngoals controls the scoring: True (default): importance_score = n_sdg_targets * n_sdg_goals False: rank by n_sdg_targets only Args: limit: Maximum results hops: Max SKOS concept hops (1–3) weight_by_ngoals: Weight by distinct SDG goals reached (default True) Returns: Ranked list with keys: breakthrough_name, n_sdg_targets, n_sdg_goals, importance_score """ if not isinstance(hops, int) or hops < 0: raise ValueError(f"hops must be a non-negative integer; got {hops}") results = self._execute_query( _build_rank_breakthroughs_sdg_query(hops), {"limit": limit} ) if not weight_by_ngoals: results.sort(key=lambda x: x.get("n_sdg_targets", 0), reverse=True) else: results.sort(key=lambda x: x.get("importance_score", 0), reverse=True) return results[:limit] def get_breakthrough_platform_span( self, latest_only: bool = True, limit: int = 20, hops: int = 1, breakthrough_profile: str = "producer", weight_by_nplatforms: bool = True, ) -> List[Dict]: """ Rank breakthroughs by how many distinct platforms their concepts connect to. breakthrough_profile controls the relationship direction: 'all': b -[:REQUIRES|ADVANCES]-> uc <-[:REQUIRES|ADVANCES]- b_other 'producer': b -[:ADVANCES]-> uc <-[:REQUIRES]- b_other 'receiver': b -[:REQUIRES]-> uc <-[:ADVANCES]- b_other hops expands concept matching via SKOS (IS_BROADER|IS_NARROWER) before checking for b_other connections. Args: latest_only: Restrict to is_latest breakthroughs if True limit: Maximum results hops: Max SKOS concept hops (1–3) breakthrough_profile: 'all', 'producer', or 'receiver' weight_by_platform_span: If True (default), rank by reached_breakthroughs * platform_span. If False, rank by reached_breakthroughs only. Returns: List of dicts with keys: breakthrough_name, reached_breakthroughs, platform_span, span_score """ if not isinstance(hops, int) or hops < 0: raise ValueError(f"hops must be a non-negative integer; got {hops}") if breakthrough_profile not in _BREAKTHROUGH_SPAN_PROFILE_RELS: raise ValueError(f"breakthrough_profile must be 'all', 'producer', or 'receiver'; got {breakthrough_profile!r}") results = self._execute_query( _build_breakthrough_platform_span_query(breakthrough_profile, hops), {"latest_only": latest_only, "limit": limit}, ) if not weight_by_nplatforms: results.sort(key=lambda x: x.get("reached_breakthroughs", 0), reverse=True) else: results.sort(key=lambda x: x.get("span_score", 0), reverse=True) return results[:limit] def get_oecd_field_representation( self, latest_only: bool = True, limit: int = 30, ) -> List[Dict]: """ Rank OECD research fields by how many radar breakthroughs map to them. Connection path: Breakthrough -[:REQUIRES|ADVANCES]-> UNESCOconcept <-[:IS_BROAD_MATCH|IS_EXACT_MATCH|IS_RELATED_CONCEPT]- OECDfield Args: latest_only: Restrict to is_latest breakthroughs if True limit: Maximum results Returns: List of dicts with keys: oecd_field, breakthrough_count, concept_count """ query = """ MATCH (p:Platform)-[:CONTAINS]-> (:`Emerging topic`)-[:CONTAINS]->(b:Breakthrough) -[:REQUIRES|ADVANCES]->(uc:UNESCOconcept) <-[:IS_BROAD_MATCH|IS_EXACT_MATCH|IS_RELATED_CONCEPT]-(of:OECDfield) WHERE ($latest_only = false OR (b.is_latest = true)) WITH of, count(DISTINCT b) AS breakthrough_count, count(DISTINCT uc) AS concept_count RETURN of.name AS oecd_field, breakthrough_count, concept_count ORDER BY breakthrough_count DESC LIMIT $limit """ return self._execute_query( query, {"latest_only": latest_only, "limit": limit} ) # ------------------------------------------------------------------------- # Phase 5 — Semantic enrichment # ------------------------------------------------------------------------- def get_concept_neighborhood( self, concept_keyword: str, hops: int = 1, limit: int = 30, ) -> List[Dict]: """ Return the local SKOS semantic neighborhood of a concept. Finds the concept matching the keyword (first result) then returns its broader, narrower, and cross-vocabulary neighbors. Args: concept_keyword: Case-insensitive keyword matched against concept pref_label_en hops: Neighborhood radius via IS_BROADER/IS_NARROWER (1–3) limit: Maximum neighbor results Returns: List of dicts with positional columns per hop depth, e.g. for hops=2: center_concept, relationship_1, neighbor_concept_1, relationship_2, neighbor_concept_2, distance """ if not isinstance(hops, int) or hops < 0: raise ValueError(f"hops must be a non-negative integer; got {hops}") _center = """ MATCH (uc_center:UNESCOconcept) WHERE toLower(uc_center.pref_label_en) CONTAINS toLower($concept_keyword) WITH uc_center ORDER BY uc_center.pref_label_en ASC LIMIT 1 """ _rel = f":{_UC_SKOS_REL}" if hops == 1: query = _center + f""" MATCH (uc_center)-[r1{_rel}]->(n1:UNESCOconcept) RETURN uc_center.pref_label_en AS center_concept, type(r1) AS relationship_1, n1.pref_label_en AS neighbor_concept_1, 1 AS distance ORDER BY neighbor_concept_1 ASC LIMIT $limit """ elif hops == 2: query = _center + f""" MATCH (uc_center)-[r1{_rel}]->(n1:UNESCOconcept) -[r2{_rel}]->(n2:UNESCOconcept) WHERE n2 <> uc_center RETURN uc_center.pref_label_en AS center_concept, type(r1) AS relationship_1, n1.pref_label_en AS neighbor_concept_1, type(r2) AS relationship_2, n2.pref_label_en AS neighbor_concept_2, 2 AS distance ORDER BY neighbor_concept_1 ASC, neighbor_concept_2 ASC LIMIT $limit """ else: # hops == 3 query = _center + f""" MATCH (uc_center)-[r1{_rel}]->(n1:UNESCOconcept) -[r2{_rel}]->(n2:UNESCOconcept) -[r3{_rel}]->(n3:UNESCOconcept) WHERE n3 <> uc_center AND n3 <> n1 RETURN uc_center.pref_label_en AS center_concept, type(r1) AS relationship_1, n1.pref_label_en AS neighbor_concept_1, type(r2) AS relationship_2, n2.pref_label_en AS neighbor_concept_2, type(r3) AS relationship_3, n3.pref_label_en AS neighbor_concept_3, 3 AS distance ORDER BY neighbor_concept_1 ASC, neighbor_concept_2 ASC, neighbor_concept_3 ASC LIMIT $limit """ return self._execute_query(query, {"concept_keyword": concept_keyword, "limit": limit}) # ------------------------------------------------------------------------- # Existing methods (preserved + enhanced) # ------------------------------------------------------------------------- def get_breakthrough_links( self, breakthrough_1: str, breakthrough_2: str, hops: int = 1, ) -> List[Dict]: """ Find concept paths between two breakthroughs via UNESCOconcept graph. hops is a maximum: hops=2 includes direct (1-hop) results too, with shorter paths returned first. Args: breakthrough_1: Name of first breakthrough breakthrough_2: Name of second breakthrough hops: Max concept hops (1–3). Default 1 for backward compatibility. Returns: List of dicts with keys: breakthrough_1, breakthrough_2, hops, concept_chain (list of concept labels), path_count """ if not isinstance(hops, int) or hops < 0: raise ValueError(f"hops must be a non-negative integer; got {hops}") queries = {1: _LINK_QUERY_HOPS_1, 2: _LINK_QUERY_HOPS_2, 3: _LINK_QUERY_HOPS_3} return self._execute_query( queries[hops], {"breakthrough_1": breakthrough_1, "breakthrough_2": breakthrough_2}, ) def get_all_linked_breakthroughs(self, breakthrough_name: str) -> List[Dict]: """ Find all breakthroughs linked to a given one via shared concepts. Args: breakthrough_name: Name of the reference breakthrough Returns: List of related breakthroughs with shared concept counts, platform context, and concept list """ query = """ MATCH (b1:Breakthrough {name: $breakthrough_name})-[:REQUIRES|ADVANCES]->(uc:UNESCOconcept) <-[:REQUIRES|ADVANCES]-(b2:Breakthrough) <-[:CONTAINS]-(:`Emerging topic`)<-[:CONTAINS]-(p:Platform) WHERE b1 <> b2 RETURN DISTINCT b2.name AS related_breakthrough, p.name AS platform_name, count(uc) AS shared_concepts, collect(DISTINCT uc.pref_label_en) AS concepts ORDER BY shared_concepts DESC """ return self._execute_query(query, {"breakthrough_name": breakthrough_name}) def get_concept_importance( self, limit: int = 30, latest_only: bool = True, hops: int = 1, weight_by_nplatforms: bool = True, relationships: str = "all", ) -> List[Dict]: """ Get concepts ranked by importance metric. hops: maximum concept hops (1–3). Concepts reachable via up to n SKOS hops. weight_by_nplatforms: If True, rank by breakthrough_count * platform_span (importance). If False, rank by breakthrough_count (centrality) only. relationships: Which relationship types to count — 'all' (REQUIRES + ADVANCES), 'requires_only', or 'advances_only'. Args: limit: Number of concepts to return latest_only: Filter to 2026 radar (is_latest=true) if True hops: Max concept hops (1–3). Default 1. weight_by_nplatforms: Weight by platform span (default True). relationships: Relationship filter — 'all', 'requires_only', or 'advances_only'. Returns: Ranked list of concepts with breakthrough_count and platform_span """ if not isinstance(hops, int) or hops < 0: raise ValueError(f"hops must be a non-negative integer; got {hops}") if relationships not in _CONCEPT_IMPORTANCE_REL_PATTERNS: raise ValueError(f"relationships must be 'all', 'requires_only', or 'advances_only'; got {relationships!r}") query = _build_concept_importance_query(relationships, hops) results = self._execute_query(query, {"limit": limit, "latest_only": latest_only}) # Sort by appropriate metric if weight_by_nplatforms is False if not weight_by_nplatforms: results.sort(key=lambda x: x.get("breakthrough_count", 0), reverse=True) else: results.sort(key=lambda x: x.get("importance_score", 0), reverse=True) return results[:limit] def get_top_concepts( self, limit: int = 30, latest_only: bool = True, hops: int = 1 ) -> List[Dict]: """Legacy alias for get_concept_importance with weight_by_nplatforms=True.""" return self.get_concept_importance( limit=limit, latest_only=latest_only, hops=hops, weight_by_nplatforms=True ) def get_concept_betweenness(self, limit: int = 30, hops: int = 1) -> List[Dict]: """Legacy alias for get_concept_importance with weight_by_nplatforms=False.""" return self.get_concept_importance( limit=limit, latest_only=True, hops=hops, weight_by_nplatforms=False ) def get_concept_importance_by_sdg( self, limit: int = 30, hops: int = 1, weight_by_nsdggoals: bool = True, ) -> List[Dict]: """ Get concepts ranked by how many SDG targets they directly contribute to. Counts CONTRIBUTES_TO relationships per concept (n_sdgTargets_contributed). Optional weighting multiplies by the number of distinct SDGgoals reached via those targets (n_sdgGoals_contributed). SKOS hops expand which concepts aggregate contributions from their neighbourhood, mirroring the hops logic in get_concept_importance. Args: limit: Number of concepts to return hops: Max SKOS hops (1–3) weight_by_nsdggoals: If True (default), rank by n_sdgTargets_contributed * n_sdgGoals_contributed. If False, rank by n_sdgTargets_contributed only. Returns: Ranked list of concepts with n_sdgTargets_contributed, n_sdgGoals_contributed, and importance_score """ if not isinstance(hops, int) or hops < 0: raise ValueError(f"hops must be a non-negative integer; got {hops}") results = self._execute_query(_build_concept_sdg_importance_query(hops), {"limit": limit}) if not weight_by_nsdggoals: results.sort(key=lambda x: x.get("n_sdgTargets_contributed", 0), reverse=True) else: results.sort(key=lambda x: x.get("importance_score", 0), reverse=True) return results[:limit] def get_concept_combined_importance( self, limit: int = 30, latest_only: bool = True, hops: int = 1, relationships: str = "all", ) -> List[Dict]: """ Rank concepts by both their breakthrough connectivity and SDG target coverage. Combines get_concept_importance and get_concept_importance_by_sdg into a single query: for each concept, counts distinct breakthroughs connected via REQUIRES/ADVANCES and distinct SDG targets connected via CONTRIBUTES_TO. SKOS hops expand the concept neighbourhood for both signals. Both counts are normalized to [0, 1] by the max value in the result set, then summed into normalized_total which is the ranking metric. Args: limit: Number of concepts to return latest_only: Filter breakthroughs to is_latest=true if True hops: Max SKOS hops (0–3) relationships: Breakthrough relationship filter — 'all' (REQUIRES + ADVANCES), 'requires_only', or 'advances_only'. Returns: Ranked list with keys: concept_name, breakthrough_count, n_sdg_targets, combined_total, norm_breakthrough, norm_sdg_targets, normalized_total """ if not isinstance(hops, int) or hops < 0: raise ValueError(f"hops must be a non-negative integer; got {hops}") if relationships not in _CONCEPT_IMPORTANCE_REL_PATTERNS: raise ValueError(f"relationships must be 'all', 'requires_only', or 'advances_only'; got {relationships!r}") rel = _CONCEPT_IMPORTANCE_REL_PATTERNS[relationships] if hops == 0: query = f""" MATCH (uc:UNESCOconcept) OPTIONAL MATCH (p:Platform)-[:CONTAINS]->(:`Emerging topic`)-[:CONTAINS]-> (b:Breakthrough)-[:{rel}]->(uc) WHERE ($latest_only = false OR b.is_latest = true) WITH uc, count(DISTINCT b) AS breakthrough_count OPTIONAL MATCH (uc)-[:CONTRIBUTES_TO]->(t:SDGtarget) WITH uc, breakthrough_count, count(DISTINCT t) AS n_sdg_targets RETURN uc.pref_label_en AS concept_name, breakthrough_count, n_sdg_targets """ total_query_b = f""" MATCH (uc:UNESCOconcept) MATCH (b:Breakthrough)-[:{rel}]->(uc) WHERE ($latest_only = false OR b.is_latest = true) RETURN count(DISTINCT b) AS total """ total_query_s = """ MATCH (uc:UNESCOconcept)-[:CONTRIBUTES_TO]->(t:SDGtarget) RETURN count(DISTINCT t) AS total """ else: query = f""" MATCH (uc:UNESCOconcept)-[:{_UC_SKOS_REL}*0..{hops}]-(uc_final:UNESCOconcept) WITH uc_final OPTIONAL MATCH (p:Platform)-[:CONTAINS]->(:`Emerging topic`)-[:CONTAINS]-> (b:Breakthrough)-[:{rel}]->(uc_final) WHERE ($latest_only = false OR b.is_latest = true) WITH uc_final, count(DISTINCT b) AS breakthrough_count OPTIONAL MATCH (uc_final)-[:CONTRIBUTES_TO]->(t:SDGtarget) WITH uc_final, breakthrough_count, count(DISTINCT t) AS n_sdg_targets RETURN uc_final.pref_label_en AS concept_name, breakthrough_count, n_sdg_targets """ total_query_b = f""" MATCH (uc:UNESCOconcept)-[:{_UC_SKOS_REL}*0..{hops}]-(uc_final:UNESCOconcept) MATCH (b:Breakthrough)-[:{rel}]->(uc_final) WHERE ($latest_only = false OR b.is_latest = true) RETURN count(DISTINCT b) AS total """ total_query_s = f""" MATCH (uc:UNESCOconcept)-[:{_UC_SKOS_REL}*0..{hops}]-(uc_final:UNESCOconcept) MATCH (uc_final)-[:CONTRIBUTES_TO]->(t:SDGtarget) RETURN count(DISTINCT t) AS total """ all_results = self._execute_query(query, {"latest_only": latest_only}) if not all_results: return all_results total_b = (self._execute_query(total_query_b, {"latest_only": latest_only}) or [{}])[0].get("total", 1) or 1 total_s = (self._execute_query(total_query_s, {}) or [{}])[0].get("total", 1) or 1 for r in all_results: r["norm_breakthrough"] = round(r["breakthrough_count"] / total_b, 4) r["norm_sdg_targets"] = round(r["n_sdg_targets"] / total_s, 4) r["combined_total"] = r["breakthrough_count"] + r["n_sdg_targets"] r["normalized_total"] = round(r["norm_breakthrough"] + r["norm_sdg_targets"], 4) all_results.sort(key=lambda x: x["normalized_total"], reverse=True) return all_results[:limit] def get_concept_importance_for_sdgtarget( self, target_id: str, limit: int = 20, latest_only: bool = True, hops: int = 1 ) -> List[Dict]: """ Get top concepts for a specific SDG target. Args: target_id: SDGtarget identifier (e.g., "3.1" for SDG 3 target 1) limit: Number of concepts to return latest_only: Filter to 2026 radar (is_latest=true) if True hops: Max concept hops via SKOS graph (1–3) Returns: Ranked list of concepts contributing to that target, with breakthrough_count """ if not isinstance(hops, int) or hops < 0: raise ValueError(f"hops must be a non-negative integer; got {hops}") queries = { 1: f""" MATCH (:Platform)-[:CONTAINS]->(et:`Emerging topic`)-[:CONTAINS]-> (b:Breakthrough)-[:REQUIRES|ADVANCES]->(uc:UNESCOconcept) -[:CONTRIBUTES_TO]->(t:SDGtarget {{target_id: $target_id}}) WHERE ($latest_only = false OR (b.is_latest = true AND b_other.is_latest = true)) WITH uc, count(DISTINCT b) AS breakthrough_count RETURN uc.pref_label_en AS concept_name, breakthrough_count ORDER BY breakthrough_count DESC LIMIT $limit """, 2: f""" MATCH (:Platform)-[:CONTAINS]->(et:`Emerging topic`)-[:CONTAINS]-> (b:Breakthrough)-[:REQUIRES|ADVANCES]->(uc:UNESCOconcept) -[:{_UC_SKOS_REL}*0..1]-(uc_final:UNESCOconcept) -[:CONTRIBUTES_TO]->(t:SDGtarget {{target_id: $target_id}}) WHERE ($latest_only = false OR (b.is_latest = true AND b_other.is_latest = true)) WITH uc_final, count(DISTINCT b) AS breakthrough_count RETURN uc_final.pref_label_en AS concept_name, breakthrough_count ORDER BY breakthrough_count DESC LIMIT $limit """, 3: f""" MATCH (:Platform)-[:CONTAINS]->(et:`Emerging topic`)-[:CONTAINS]-> (b:Breakthrough)-[:REQUIRES|ADVANCES]->(uc:UNESCOconcept) -[:{_UC_SKOS_REL}*0..2]-(uc_final:UNESCOconcept) -[:CONTRIBUTES_TO]->(t:SDGtarget {{target_id: $target_id}}) WHERE ($latest_only = false OR (b.is_latest = true AND b_other.is_latest = true)) WITH uc_final, count(DISTINCT b) AS breakthrough_count RETURN uc_final.pref_label_en AS concept_name, breakthrough_count ORDER BY breakthrough_count DESC LIMIT $limit """, } return self._execute_query( queries[hops], {"target_id": target_id, "limit": limit, "latest_only": latest_only}, ) def get_concept_evolution(self, hops: int = 2) -> List[Dict]: """ Compare concept importance across 2021, 2023, and 2026 radar editions. hops expands concept matching via SKOS (IS_BROADER|IS_NARROWER) before grouping, mirroring the hops logic in get_concept_importance. Args: hops: Max SKOS concept hops (1–3). Default 2. Returns: List of concepts appearing in all three editions with per-edition breakthrough counts (2026 first), pairwise deltas, and trend directions for 2023→2026, 2021→2023, and overall 2021→2026 """ if not isinstance(hops, int) or hops < 0: raise ValueError(f"hops must be a non-negative integer; got {hops}") depth = hops query = f""" MATCH (b21:Breakthrough {{radar_version: 2021}})-[:REQUIRES|ADVANCES]->(uc21:UNESCOconcept) -[:{_UC_SKOS_REL}*0..{depth}]-(uc21f:UNESCOconcept) WITH uc21f.pref_label_en AS concept_name, count(DISTINCT b21) AS count_2021 MATCH (b23:Breakthrough {{radar_version: 2023}})-[:REQUIRES|ADVANCES]->(uc23:UNESCOconcept) -[:{_UC_SKOS_REL}*0..{depth}]-(uc23f:UNESCOconcept) WHERE uc23f.pref_label_en = concept_name WITH concept_name, count_2021, count(DISTINCT b23) AS count_2023 MATCH (b26:Breakthrough {{radar_version: 2026}})-[:REQUIRES|ADVANCES]->(uc26:UNESCOconcept) -[:{_UC_SKOS_REL}*0..{depth}]-(uc26f:UNESCOconcept) WHERE uc26f.pref_label_en = concept_name WITH concept_name, count_2021, count_2023, count(DISTINCT b26) AS count_2026 RETURN concept_name, count_2026, count_2023, count_2021, count_2026 - count_2023 AS delta_2023_2026, count_2023 - count_2021 AS delta_2021_2023, count_2026 - count_2021 AS delta_overall, CASE WHEN count_2026 > count_2023 THEN 'RISING' WHEN count_2026 < count_2023 THEN 'FALLING' ELSE 'STABLE' END AS trend_2023_2026, CASE WHEN count_2023 > count_2021 THEN 'RISING' WHEN count_2023 < count_2021 THEN 'FALLING' ELSE 'STABLE' END AS trend_2021_2023, CASE WHEN count_2026 > count_2021 THEN 'RISING' WHEN count_2026 < count_2021 THEN 'FALLING' ELSE 'STABLE' END AS trend_overall ORDER BY delta_overall DESC """ return self._execute_query(query, {}) def query_custom(self, query: str, params: Dict[str, Any] = None) -> List[Dict]: """ Execute custom Cypher query. Args: query: Raw Cypher query params: Query parameters (optional) Returns: Query results as list of dicts """ return self._execute_query(query, params or {}) def list_breakthroughs(self, limit: int = None) -> List[Dict]: """ List all breakthroughs (optionally filtered to latest edition). Args: limit: Maximum number to return Returns: List of breakthrough nodes with all properties """ query = "MATCH (b:Breakthrough) RETURN b" if limit: query += f" LIMIT {limit}" return self._execute_query(query, {}) def list_concepts(self, limit: int = None) -> List[Dict]: """ List all UNESCO concepts. Args: limit: Maximum number to return Returns: List of UNESCO concept nodes with all properties """ query = "MATCH (uc:UNESCOconcept) RETURN uc" if limit: query += f" LIMIT {limit}" return self._execute_query(query, {}) def list_sdg_goals(self) -> List[Dict]: """ List all SDG goals. Returns: List of SDG goal nodes with all properties """ query = "MATCH (g:SDGgoal) RETURN g ORDER BY g.goal_id" return self._execute_query(query, {}) def list_sdg_indicators(self) -> List[Dict]: """ List all SDG indicators. Returns: List of SDG indicator nodes with all properties """ query = "MATCH (i:SDGindicator) RETURN i ORDER BY i.indicator_id" return self._execute_query(query, {}) def list_sdg_targets(self, goal_id: str = None) -> List[Dict]: """ List SDG targets, optionally filtered by goal. Args: goal_id: Optional SDGgoal ID to filter targets (e.g., "3" for SDG 3) Returns: List of SDG target nodes with all properties """ if goal_id: query = """ MATCH (g:SDGgoal {goal_id: $goal_id})-[:HAS_TARGET]->(t:SDGtarget) RETURN t ORDER BY t.target_id """ return self._execute_query(query, {"goal_id": goal_id}) else: query = "MATCH (t:SDGtarget) RETURN t ORDER BY t.target_id" return self._execute_query(query, {}) def list_emerging_topics(self, limit: int = None) -> List[Dict]: """ List all emerging topics. Args: limit: Maximum number to return Returns: List of emerging topic nodes with all properties """ query = "MATCH (et:`Emerging topic`) RETURN et ORDER BY et.name" if limit: query += f" LIMIT {limit}" return self._execute_query(query, {}) def list_platforms(self, limit: int = None) -> List[Dict]: """ List all emerging topics. Args: limit: Maximum number to return Returns: List of emerging topic nodes with all properties """ query = "MATCH (p:Platform) RETURN p ORDER BY p.name" if limit: query += f" LIMIT {limit}" return self._execute_query(query, {}) def get_breakthrough_contributors_for_sdgtarget( self, target_id: str, limit: int = 10, latest_only: bool = True, ) -> List[Dict]: """ Find breakthroughs that most directly contribute to a given SDG target. Path traversed (undirected shortestPath, no hop cap): (t:SDGtarget) <-[CONTRIBUTES_TO]- (uc) -[SKOS*]- (uc2) <-[REQUIRES|ADVANCES]- (b) Returns one row per shortest path, ranked by ascending distance (number of relationships). The result columns match the sdg_contributor shape detected by the UI: sdg_target_id, concept_1, [skos_rel_1, concept_2, ...], b_rel, breakthrough_name, radar_version, distance. Args: target_id: The SDGtarget target_id property (e.g. "3.1") limit: Number of paths to return (default 10) latest_only: Restrict to is_latest breakthroughs if True """ # Build UNION of explicit hop patterns (0 SKOS hops = 2 rels total, up to 3 SKOS hops = 5 rels). # Each branch returns a consistent column set; unused concept/skos_rel columns are NULL. query = f""" MATCH (t:SDGtarget {{target_id: $target_id}}) MATCH (t)<-[:CONTRIBUTES_TO]-(uc1:UNESCOconcept)<-[r1:ADVANCES]-(b:Breakthrough) WHERE ($latest_only = false OR b.is_latest = true) RETURN t.target_id AS sdg_target_id, uc1.pref_label_en AS concept_1, null AS skos_rel_1, null AS concept_2, null AS skos_rel_2, null AS concept_3, null AS skos_rel_3, null AS concept_4, type(r1) AS b_rel, b.name AS breakthrough_name, b.radar_version AS radar_version, 2 AS distance UNION ALL MATCH (t:SDGtarget {{target_id: $target_id}}) MATCH (t)<-[:CONTRIBUTES_TO]-(uc1:UNESCOconcept) -[sr1:{_UC_SKOS_REL}]-(uc2:UNESCOconcept) <-[r1:ADVANCES]-(b:Breakthrough) WHERE ($latest_only = false OR b.is_latest = true) AND uc1 <> uc2 RETURN t.target_id AS sdg_target_id, uc1.pref_label_en AS concept_1, type(sr1) AS skos_rel_1, uc2.pref_label_en AS concept_2, null AS skos_rel_2, null AS concept_3, null AS skos_rel_3, null AS concept_4, type(r1) AS b_rel, b.name AS breakthrough_name, b.radar_version AS radar_version, 3 AS distance UNION ALL MATCH (t:SDGtarget {{target_id: $target_id}}) MATCH (t)<-[:CONTRIBUTES_TO]-(uc1:UNESCOconcept) -[sr1:{_UC_SKOS_REL}]-(uc2:UNESCOconcept) -[sr2:{_UC_SKOS_REL}]-(uc3:UNESCOconcept) <-[r1:ADVANCES]-(b:Breakthrough) WHERE ($latest_only = false OR b.is_latest = true) AND uc1 <> uc2 AND uc2 <> uc3 RETURN t.target_id AS sdg_target_id, uc1.pref_label_en AS concept_1, type(sr1) AS skos_rel_1, uc2.pref_label_en AS concept_2, type(sr2) AS skos_rel_2, uc3.pref_label_en AS concept_3, null AS skos_rel_3, null AS concept_4, type(r1) AS b_rel, b.name AS breakthrough_name, b.radar_version AS radar_version, 4 AS distance UNION ALL MATCH (t:SDGtarget {{target_id: $target_id}}) MATCH (t)<-[:CONTRIBUTES_TO]-(uc1:UNESCOconcept) -[sr1:{_UC_SKOS_REL}]-(uc2:UNESCOconcept) -[sr2:{_UC_SKOS_REL}]-(uc3:UNESCOconcept) -[sr3:{_UC_SKOS_REL}]-(uc4:UNESCOconcept) <-[r1:ADVANCES]-(b:Breakthrough) WHERE ($latest_only = false OR b.is_latest = true) AND uc1 <> uc2 AND uc2 <> uc3 AND uc3 <> uc4 RETURN t.target_id AS sdg_target_id, uc1.pref_label_en AS concept_1, type(sr1) AS skos_rel_1, uc2.pref_label_en AS concept_2, type(sr2) AS skos_rel_2, uc3.pref_label_en AS concept_3, type(sr3) AS skos_rel_3, uc4.pref_label_en AS concept_4, type(r1) AS b_rel, b.name AS breakthrough_name, b.radar_version AS radar_version, 5 AS distance """ params = {"target_id": target_id, "latest_only": latest_only, "limit": limit} self.last_query = query self.last_params = params # Deduplicate: keep only the shortest path per breakthrough seen: dict[str, dict] = {} with self.driver.session() as session: result = session.run(query, params) for record in result: row = dict(record) name = row["breakthrough_name"] if name not in seen or row["distance"] < seen[name]["distance"]: seen[name] = row formatted = sorted(seen.values(), key=lambda r: (r["distance"], r["breakthrough_name"])) formatted = formatted[:limit] return formatted def get_breakthroughs_from_sdgtargets( self, target_ids: List[str], hops: int = 3, cost_function: str = "mean", limit: int = 50, ) -> List[Dict]: """ Find breakthroughs connected to multiple SDGtarget nodes via up to n hops. Traverses from SDGtargets to UNESCOconcepts (via CONTRIBUTES_TO), then through the concept graph (IS_BROADER|IS_NARROWER|IS_RELATED_CONCEPT) up to the specified hops, then returns all breakthroughs connected to reached concepts. Each breakthrough is ranked by cost, which aggregates distances from all input targets using either mean or sum. Args: target_ids: List of SDGtarget IDs (e.g., "3.1", "5.2") hops: Maximum hops in concept graph (1-3). Default 3. cost_function: "mean" (avg distance) or "sum" (total distance). Default "mean". limit: Maximum breakthroughs to return Returns: List of dicts with keys: breakthrough_name, concept_count, distance_list, cost, platform_name """ if not isinstance(hops, int) or hops < 0: raise ValueError(f"hops must be a non-negative integer; got {hops}") if cost_function not in ("mean", "sum"): raise ValueError(f"cost_function must be 'mean' or 'sum'; got {cost_function}") depth = hops query = f""" WITH $target_ids AS input_target_ids MATCH (input_target:SDGtarget) WHERE input_target.target_id IN input_target_ids MATCH (input_target)<-[:CONTRIBUTES_TO]-(input_uc:UNESCOconcept) MATCH path = (input_uc)-[:{_UC_SKOS_REL}*0..{depth}]-(nearby_uc:UNESCOconcept) MATCH (b:Breakthrough)-[:REQUIRES|ADVANCES]-(nearby_uc) MATCH (p:Platform)-[:CONTAINS]-(:`Emerging topic`)-[:CONTAINS]-(b) WITH b.name AS breakthrough_name, p.name AS platform_name, input_target.target_id AS input_target_id, length(path) AS distance_to_input RETURN DISTINCT breakthrough_name, platform_name, input_target_id, distance_to_input """ raw_results = self._execute_query(query, {"target_ids": target_ids}) # Aggregate distances by breakthrough, tracking which targets are reached breakthrough_map = {} max_unreachable_distance = hops + 1 for row in raw_results: b_name = row["breakthrough_name"] platform = row["platform_name"] input_target = row["input_target_id"] distance = row["distance_to_input"] if b_name not in breakthrough_map: breakthrough_map[b_name] = { "breakthrough_name": b_name, "platform_name": platform, "distances": {}, } if input_target not in breakthrough_map[b_name]["distances"]: breakthrough_map[b_name]["distances"][input_target] = distance else: breakthrough_map[b_name]["distances"][input_target] = min( breakthrough_map[b_name]["distances"][input_target], distance ) # Ensure every breakthrough has a distance for every input target results = [] for b_name, data in breakthrough_map.items(): distance_list = [] for target_id in target_ids: if target_id in data["distances"]: distance_list.append(data["distances"][target_id]) else: distance_list.append(max_unreachable_distance) if cost_function == "mean": cost = sum(distance_list) / len(distance_list) if distance_list else 0 else: # sum cost = sum(distance_list) results.append( { "breakthrough_name": data["breakthrough_name"], "platform_name": data["platform_name"], "concept_count": len(target_ids), "distance_list": distance_list, "cost": cost, } ) results.sort(key=lambda x: x["cost"]) return results[:limit] def get_breakthroughs_from_concepts( self, concept_names: List[str], hops: int = 3, cost_function: str = "mean", filter_by: str = None, limit: int = 50, ) -> List[Dict]: """ Find breakthroughs connected to multiple UNESCOconcept nodes via up to n hops. Traverses the concept graph (IS_BROADER|IS_NARROWER|IS_RELATED_CONCEPT) up to the specified hops, then returns all breakthroughs connected to reached concepts. Each breakthrough is ranked by cost, which aggregates distances from all input concepts using either mean or sum. Args: concept_names: List of UNESCO concept pref_label_ens to start from hops: Maximum hops in concept graph (1-3). Default 3. cost_function: "mean" (avg distance) or "sum" (total distance). Default "mean". filter_by: Filter by relationship type - None (both), "advances", or "requires". Default None. limit: Maximum breakthroughs to return Returns: List of dicts with keys: breakthrough_name, concept_count, distance_list, cost, platform_name """ if not isinstance(hops, int) or hops < 0: raise ValueError(f"hops must be a non-negative integer; got {hops}") if cost_function not in ("mean", "sum"): raise ValueError(f"cost_function must be 'mean' or 'sum'; got {cost_function}") if filter_by not in (None, "advances", "requires"): raise ValueError(f"filter_by must be None, 'advances', or 'requires'; got {filter_by}") depth = hops # Build the relationship filter if filter_by == "advances": rel_filter = "ADVANCES" elif filter_by == "requires": rel_filter = "REQUIRES" else: rel_filter = "REQUIRES|ADVANCES" query = f""" WITH $concept_names AS input_concept_names MATCH (input_uc:UNESCOconcept) WHERE input_uc.pref_label_en IN input_concept_names MATCH path = (input_uc)-[:{_UC_SKOS_REL}*0..{depth}]-(nearby_uc:UNESCOconcept) MATCH (b:Breakthrough)-[:{rel_filter}]-(nearby_uc) MATCH (p:Platform)-[:CONTAINS]-(:`Emerging topic`)-[:CONTAINS]-(b) WITH b.name AS breakthrough_name, p.name AS platform_name, input_uc.pref_label_en AS input_concept, length(path) AS distance_to_input RETURN DISTINCT breakthrough_name, platform_name, input_concept, distance_to_input """ raw_results = self._execute_query(query, {"concept_names": concept_names}) # Aggregate distances by breakthrough, tracking which concepts are reached breakthrough_map = {} max_unreachable_distance = hops + 1 for row in raw_results: b_name = row["breakthrough_name"] platform = row["platform_name"] input_concept = row["input_concept"] distance = row["distance_to_input"] if b_name not in breakthrough_map: breakthrough_map[b_name] = { "breakthrough_name": b_name, "platform_name": platform, "distances": {}, } if input_concept not in breakthrough_map[b_name]["distances"]: breakthrough_map[b_name]["distances"][input_concept] = distance else: breakthrough_map[b_name]["distances"][input_concept] = min( breakthrough_map[b_name]["distances"][input_concept], distance ) # Ensure every breakthrough has a distance for every input concept results = [] for b_name, data in breakthrough_map.items(): distance_list = [] for concept_name in concept_names: if concept_name in data["distances"]: distance_list.append(data["distances"][concept_name]) else: distance_list.append(max_unreachable_distance) if cost_function == "mean": cost = sum(distance_list) / len(distance_list) if distance_list else 0 else: # sum cost = sum(distance_list) results.append( { "breakthrough_name": data["breakthrough_name"], "platform_name": data["platform_name"], "concept_count": len(concept_names), "distance_list": distance_list, "cost": cost, } ) results.sort(key=lambda x: x["cost"]) return results[:limit]