hadith-api/app/routers/chains.py

149 lines
5.4 KiB
Python

"""
Isnad chain endpoints — chain visualization data (D3-ready nodes + links).
"""
from fastapi import APIRouter, Query, Path, HTTPException
from app.services.database import db
from app.utils.arabic import normalize_name
from app.models.schemas import (
IsnadChain, IsnadNode, IsnadLink,
PaginatedResponse, PaginationMeta,
)
router = APIRouter(prefix="/chains", tags=["Isnad Chains"])
def _paginate(total: int, page: int, per_page: int) -> PaginationMeta:
pages = max(1, (total + per_page - 1) // per_page)
return PaginationMeta(total=total, page=page, per_page=per_page, pages=pages)
# ── Chain for a single hadith ──────────────────────────────────────────────
@router.get("/hadith/{hadith_id}", response_model=IsnadChain)
async def get_isnad_chain(
hadith_id: str = Path(..., description="Hadith UUID"),
):
"""
Get the isnad chain for a hadith as a directed graph (nodes + links).
Returns D3-compatible format for frontend visualization.
"""
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")
# Narrator nodes in the chain
nodes_rows = db.neo4j_query("""
MATCH (n:Narrator)-[a: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
ORDER BY a.chain_order
""", {"hid": hadith_id})
# Transmission links — NARRATED_FROM edges store hadith_ids as array
links_rows = db.neo4j_query("""
MATCH (a:Narrator)-[nf:NARRATED_FROM]->(b:Narrator)
WHERE $hid IN nf.hadith_ids
RETURN a.name_arabic AS source,
b.name_arabic AS target,
nf.transmission_verb AS transmission_verb
ORDER BY a.name_arabic
""", {"hid": hadith_id})
return IsnadChain(
hadith_id=hadith_id,
collection=hadith.get("collection"),
hadith_number=hadith.get("hadith_number"),
nodes=[IsnadNode(**r) for r in nodes_rows],
links=[IsnadLink(**r) for r in links_rows],
)
# ── All chains containing a narrator (paginated) ──────────────────────────
@router.get("/narrator/{name_arabic}", response_model=PaginatedResponse)
async def chains_by_narrator(
name_arabic: str = Path(..., description="Narrator Arabic name"),
page: int = Query(1, ge=1),
per_page: int = Query(10, ge=1, le=50),
):
"""
All isnad chains containing a narrator.
Useful for seeing how a narrator connects to the Prophet ﷺ across collections.
"""
q_norm = normalize_name(name_arabic)
skip = (page - 1) * per_page
total = db.neo4j_count("""
MATCH (n:Narrator)-[:APPEARS_IN]->(h:Hadith)
WHERE toLower(n.name_arabic) CONTAINS toLower($name)
RETURN count(DISTINCT h) AS count
""", {"name": q_norm})
hadith_ids = db.neo4j_query("""
MATCH (n:Narrator)-[:APPEARS_IN]->(h:Hadith)
WHERE toLower(n.name_arabic) CONTAINS toLower($name)
RETURN DISTINCT h.id AS id
ORDER BY h.id
SKIP $skip LIMIT $limit
""", {"name": q_norm, "skip": skip, "limit": per_page})
chains = []
for row in hadith_ids:
chain = await get_isnad_chain(str(row["id"]))
chains.append(chain)
return PaginatedResponse(
data=chains,
meta=_paginate(total, page, per_page),
)
# ── Common chains between two narrators (paginated) ───────────────────────
@router.get("/common", response_model=PaginatedResponse)
async def find_common_chains(
narrator_a: str = Query(..., description="First narrator (Arabic)"),
narrator_b: str = Query(..., description="Second narrator (Arabic)"),
page: int = Query(1, ge=1),
per_page: int = Query(10, ge=1, le=50),
):
"""Find hadiths where both narrators appear in the same chain."""
a_norm = normalize_name(narrator_a)
b_norm = normalize_name(narrator_b)
skip = (page - 1) * per_page
total = db.neo4j_count("""
MATCH (a:Narrator)-[:APPEARS_IN]->(h:Hadith)<-[:APPEARS_IN]-(b:Narrator)
WHERE toLower(a.name_arabic) CONTAINS toLower($a)
AND toLower(b.name_arabic) CONTAINS toLower($b)
AND a <> b
RETURN count(DISTINCT h) AS count
""", {"a": a_norm, "b": b_norm})
rows = db.neo4j_query("""
MATCH (a:Narrator)-[:APPEARS_IN]->(h:Hadith)<-[:APPEARS_IN]-(b:Narrator)
WHERE toLower(a.name_arabic) CONTAINS toLower($a)
AND toLower(b.name_arabic) CONTAINS toLower($b)
AND a <> b
RETURN DISTINCT 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
ORDER BY h.collection, h.hadith_number
SKIP $skip LIMIT $limit
""", {"a": a_norm, "b": b_norm, "skip": skip, "limit": per_page})
return PaginatedResponse(
data=[dict(r) for r in rows],
meta=_paginate(total, page, per_page),
)