1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富 统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」; 同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。 2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本), 新增《文档规整方案与开发前待决事项-2026-09-17》。 3) 客服agent 四份交付文档首次纳入本分支。
69 lines
2.9 KiB
Python
69 lines
2.9 KiB
Python
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)}
|