from collections.abc import Sequence from typing import Any, Protocol class GraphDriver(Protocol): async def execute_query(self, query: str, **parameters: Any) -> Sequence[Any]: ... class GraphDegradedResult: def __init__(self, reason: str) -> None: self.degraded = True self.reason = reason class RelationshipService: """Controlled Neo4j boundary; callers cannot submit arbitrary labels or Cypher.""" ALLOWED_RELATIONSHIPS = frozenset({ "PREFERS", "HAS_GOAL", "INTERESTED_IN", "TRADED", "HOLDS", "TRIGGERED_RISK", "BELONGS_TO_CATEGORY", "EXPOSED_TO_INDUSTRY", }) def __init__(self, driver: GraphDriver) -> None: self.driver = driver async def neighbors( self, customer_id: int, relationship: str, *, limit: int = 50 ) -> Sequence[Any]: if relationship not in self.ALLOWED_RELATIONSHIPS: raise ValueError("relationship is not allowed") safe_limit = max(1, min(limit, 100)) query = ( "MATCH (c:Customer {customer_id: $customer_id})" f"-[r:{relationship}]->(n) RETURN n, r LIMIT $limit" ) try: return await self.driver.execute_query(query, customer_id=customer_id, limit=safe_limit) except Exception as exc: return [GraphDegradedResult(f"neo4j_unavailable:{type(exc).__name__}")] async def paths(self, customer_id: int, *, max_hops: int = 2, limit: int = 50) -> Sequence[Any]: hops = max(1, min(max_hops, 2)) safe_limit = max(1, min(limit, 100)) query = ( "MATCH p=(c:Customer {customer_id: $customer_id})-[*1.." f"{hops}]->(n) RETURN p LIMIT $limit" ) try: return await self.driver.execute_query(query, customer_id=customer_id, limit=safe_limit) except Exception as exc: return [GraphDegradedResult(f"neo4j_unavailable:{type(exc).__name__}")] async def portfolio_industry_context(self, customer_id: int) -> dict[str, object]: """Return only relationship enrichment; no position value comes from Neo4j.""" query = ( "MATCH (c:Customer {customer_id: $customer_id})-[:HOLDS]->" "(h:Holding)-[:EXPOSED_TO_INDUSTRY]->(i:Industry) " "RETURN i.industry_name AS industry_name, count(DISTINCT h) AS product_count " "ORDER BY product_count DESC LIMIT $limit" ) try: rows = await self.driver.execute_query(query, customer_id=customer_id, limit=20) except Exception as exc: return {"degraded": True, "reason": f"neo4j_unavailable:{type(exc).__name__}"} if any(isinstance(row, GraphDegradedResult) for row in rows): degraded = next(row for row in rows if isinstance(row, GraphDegradedResult)) return {"degraded": True, "reason": degraded.reason} return {"degraded": False, "overlaps": list(rows)}