hadith-api/app/routers/chains.py

138 lines
5.5 KiB
Python

"""
Isnad chain endpoints — chain visualization data for hadith detail views.
"""
from fastapi import APIRouter, Query, HTTPException
from app.services.database import db
from app.models.schemas import IsnadChain, IsnadNode, IsnadLink
router = APIRouter(prefix="/chains", tags=["Isnad Chains"])
@router.get("/hadith/{hadith_id}", response_model=IsnadChain,
summary="Get isnad chain for a hadith",
description="Returns the complete isnad (chain of narration) as a graph structure "
"with nodes (narrators) and links (transmission relationships). "
"Ready for visualization with D3.js, vis.js, Cytoscape.js, or any graph library. "
"Each node includes narrator metadata (generation, reliability); "
"each link includes the transmission verb (حدثنا، عن، أخبرنا).")
async def get_isnad_chain(hadith_id: str):
"""
Get the full isnad chain for a hadith as a graph (nodes + links)
ready for visualization (D3.js, vis.js, etc.).
"""
# Get hadith info
hadith = db.neo4j_query_one("""
MATCH (h:Hadith {id: $hid})
RETURN h.id AS id, h.collection AS collection, h.hadith_number AS hadith_number
""", {"hid": hadith_id})
if not hadith:
raise HTTPException(status_code=404, detail="Hadith not found in graph")
# Get chain nodes
nodes = db.neo4j_query("""
MATCH (n:Narrator)-[r:APPEARS_IN]->(h:Hadith {id: $hid})
RETURN n.name_arabic AS name_arabic,
n.name_transliterated AS name_transliterated,
n.entity_type AS entity_type,
n.generation AS generation,
n.reliability_grade AS reliability_grade,
r.chain_order AS chain_order
ORDER BY r.chain_order
""", {"hid": hadith_id})
# Get chain links (NARRATED_FROM within this hadith's narrators)
links = db.neo4j_query("""
MATCH (a:Narrator)-[r1:APPEARS_IN]->(h:Hadith {id: $hid})
MATCH (b:Narrator)-[r2:APPEARS_IN]->(h)
MATCH (a)-[nf:NARRATED_FROM]->(b)
WHERE $hid IN nf.hadith_ids
RETURN a.name_arabic AS source,
b.name_arabic AS target,
nf.transmission_verb AS transmission_verb
""", {"hid": hadith_id})
# If no NARRATED_FROM edges with hadith_id, fall back to chain order
if not links and len(nodes) > 1:
sorted_nodes = sorted(nodes, key=lambda n: n.get("chain_order") or 999)
links = []
for i in range(len(sorted_nodes) - 1):
links.append({
"source": sorted_nodes[i]["name_arabic"],
"target": sorted_nodes[i + 1]["name_arabic"],
"transmission_verb": None,
})
return IsnadChain(
hadith_id=str(hadith["id"]),
collection=hadith["collection"] or "",
hadith_number=hadith["hadith_number"] or 0,
nodes=[IsnadNode(**n) for n in nodes],
links=[IsnadLink(**l) for l in links],
)
@router.get("/narrator/{name_arabic}", response_model=list[IsnadChain],
summary="Get all chains for a narrator",
description="Returns all isnad chains that include a specific narrator. "
"Useful for visualizing how a narrator connects to the Prophet ﷺ "
"through different transmission paths. "
"Example: `/chains/narrator/الزهري`")
async def get_narrator_chains(
name_arabic: str,
limit: int = Query(10, ge=1, le=50, description="Maximum chains to return"),
):
"""
Get all isnad chains that include a specific narrator.
Useful for seeing how a narrator connects to the Prophet ﷺ.
"""
hadith_ids = db.neo4j_query("""
MATCH (n:Narrator {name_arabic: $name})-[:APPEARS_IN]->(h:Hadith)
RETURN h.id AS id
LIMIT $limit
""", {"name": name_arabic, "limit": limit})
chains = []
for row in hadith_ids:
chain = await get_isnad_chain(str(row["id"]))
chains.append(chain)
return chains
@router.get("/common-chains", response_model=list[dict],
summary="Find shared chains between two narrators",
description="Find hadiths where both narrators appear in the same isnad chain. "
"Useful for verifying narrator relationships and finding corroborating chains. "
"Example: `/chains/common-chains?narrator_a=الزهري&narrator_b=أنس بن مالك`")
async def find_common_chains(
narrator_a: str = Query(
..., description="First narrator (Arabic). Example: الزهري",
examples=["الزهري"],
),
narrator_b: str = Query(
..., description="Second narrator (Arabic). Example: أنس بن مالك",
examples=["أنس بن مالك"],
),
limit: int = Query(10, ge=1, le=50, description="Maximum results"),
):
"""
Find hadiths where both narrators appear in the same chain.
Useful for verifying narrator relationships.
"""
rows = db.neo4j_query("""
MATCH (a:Narrator)-[:APPEARS_IN]->(h:Hadith)<-[:APPEARS_IN]-(b:Narrator)
WHERE a.name_arabic CONTAINS $name_a
AND b.name_arabic CONTAINS $name_b
AND a <> b
RETURN h.id AS hadith_id,
h.collection AS collection,
h.hadith_number AS hadith_number,
a.name_arabic AS narrator_a,
b.name_arabic AS narrator_b
LIMIT $limit
""", {"name_a": narrator_a, "name_b": narrator_b, "limit": limit})
return [dict(r) for r in rows]