feat: add portfolio graph enrichment
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
"""Bounded Neo4j adapter used only behind RelationshipService."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from neo4j import AsyncGraphDatabase
|
||||
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
class Neo4jGraphDriver:
|
||||
def __init__(self, settings: Settings, *, timeout_seconds: float = 2.0) -> None:
|
||||
self.settings = settings
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self._driver: Any = None
|
||||
|
||||
async def execute_query(self, query: str, **parameters: Any) -> Sequence[Any]:
|
||||
if self._driver is None:
|
||||
self._driver = AsyncGraphDatabase.driver(
|
||||
self.settings.neo4j_uri,
|
||||
auth=(self.settings.neo4j_username, self.settings.neo4j_password),
|
||||
)
|
||||
async with asyncio.timeout(self.timeout_seconds):
|
||||
async with self._driver.session(database=self.settings.neo4j_database) as session:
|
||||
result = await session.run(query, parameters)
|
||||
return [record.data() async for record in result]
|
||||
@@ -6,20 +6,31 @@ from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.contracts import RequestContext
|
||||
from app.core.portfolio_analysis_contracts import PortfolioAnalysisQuery
|
||||
from app.infrastructure.db import SessionFactory
|
||||
from app.infrastructure.neo4j_graph_driver import Neo4jGraphDriver
|
||||
from app.model.advisor_product import AdvisorProductIndustryExposure, AdvisorProductMetricSnapshot
|
||||
from app.model.fund import FundHolding, FundProduct
|
||||
from app.repository.portfolio_analysis_repository import PortfolioAnalysisRepository
|
||||
from app.service.authorization_service import AuthorizationService
|
||||
from app.service.relationship_service import RelationshipService
|
||||
|
||||
HUNDRED = Decimal("100")
|
||||
|
||||
|
||||
class PortfolioAnalysisService:
|
||||
def __init__(self, *, session_factory: Callable[[], Any] = SessionFactory) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session_factory: Callable[[], Any] = SessionFactory,
|
||||
graph_service: RelationshipService | None = None,
|
||||
) -> None:
|
||||
self.session_factory = session_factory
|
||||
self.graph_service = graph_service or RelationshipService(
|
||||
Neo4jGraphDriver(get_settings())
|
||||
)
|
||||
|
||||
async def analyze_for_agent(
|
||||
self, _arguments: PortfolioAnalysisQuery, context: RequestContext
|
||||
@@ -32,12 +43,29 @@ class PortfolioAnalysisService:
|
||||
exposures = await repository.latest_industry_exposures(ids, date.today())
|
||||
metrics = await repository.latest_metrics(ids, date.today())
|
||||
result = self._analyze(positions, exposures, metrics)
|
||||
result["graph_context"] = {
|
||||
"degraded": True,
|
||||
"reason": "neo4j_not_configured",
|
||||
}
|
||||
result["graph_context"] = await self._graph_context(int(context.user_id))
|
||||
return result
|
||||
|
||||
async def _graph_context(self, customer_id: int) -> dict[str, object]:
|
||||
try:
|
||||
context = await self.graph_service.portfolio_industry_context(customer_id)
|
||||
except Exception as exc:
|
||||
return {"degraded": True, "reason": f"neo4j_unavailable:{type(exc).__name__}"}
|
||||
if bool(context.get("degraded")):
|
||||
return {"degraded": True, "reason": str(context.get("reason", "neo4j_unavailable"))}
|
||||
raw = context.get("overlaps")
|
||||
if not isinstance(raw, list):
|
||||
return {"degraded": True, "reason": "neo4j_invalid_result"}
|
||||
overlaps = [
|
||||
{"industry_name": item["industry_name"], "product_count": item["product_count"]}
|
||||
for item in raw
|
||||
if isinstance(item, dict)
|
||||
and isinstance(item.get("industry_name"), str)
|
||||
and isinstance(item.get("product_count"), int)
|
||||
and item["product_count"] >= 2
|
||||
]
|
||||
return {"degraded": False, "overlaps": overlaps[:5]}
|
||||
|
||||
@classmethod
|
||||
def _analyze(
|
||||
cls,
|
||||
|
||||
@@ -49,3 +49,20 @@ class RelationshipService:
|
||||
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)}
|
||||
|
||||
Reference in New Issue
Block a user