149 lines
5.4 KiB
Python
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),
|
|
)
|