hadith-api/app/routers/narrators.py

441 lines
18 KiB
Python

"""
Narrator endpoints — search, profiles, network queries.
All queries normalize Arabic input to match post-dedup graph data.
"""
from fastapi import APIRouter, Query, Path, HTTPException
from typing import Optional
from app.services.database import db
from app.utils.arabic import normalize_query, normalize_name
from app.models.schemas import (
NarratorSummary, NarratorProfile, NarratorInteraction,
NarratorConnection, NarratorNetwork,
WhoMetWhoResult, PathNode, PlaceRelation, NameForm, FamilyInfo,
HadithSummary, PaginatedResponse, PaginationMeta,
)
router = APIRouter(prefix="/narrators", tags=["Narrators"])
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)
# ── Search narrators by name (paginated, normalized) ───────────────────────
@router.get("/search", response_model=PaginatedResponse)
async def search_narrators(
q: str = Query(..., min_length=2, description="Narrator name (Arabic). Diacritics stripped automatically."),
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
):
"""
Search narrators by Arabic name. Input is normalized to match
the deduplicated graph (diacritics stripped, characters unified).
"""
q_norm = normalize_query(q)
skip = (page - 1) * per_page
total = db.neo4j_count("""
MATCH (n:Narrator)
WHERE toLower(n.name_arabic) CONTAINS toLower($q)
RETURN count(n) AS count
""", {"q": q_norm})
rows = db.neo4j_query("""
MATCH (n:Narrator)
WHERE toLower(n.name_arabic) CONTAINS toLower($q)
OPTIONAL MATCH (n)-[:APPEARS_IN]->(h:Hadith)
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,
count(DISTINCT h) AS hadith_count
ORDER BY hadith_count DESC
SKIP $skip LIMIT $limit
""", {"q": q_norm, "skip": skip, "limit": per_page})
data = [NarratorSummary(**r) for r in rows]
return PaginatedResponse(data=data, meta=_paginate(total, page, per_page))
# ── Full narrator profile ──────────────────────────────────────────────────
@router.get("/profile/{name_arabic}", response_model=NarratorProfile)
async def get_narrator_profile(
name_arabic: str = Path(..., description="Narrator Arabic name (exact or close match)"),
):
"""
Complete narrator profile — biography, hadiths, teachers, students, places, tribes.
This is the mobile app profile page query.
"""
q_norm = normalize_name(name_arabic)
# Find the narrator node — exact first, then CONTAINS fallback
narrator = db.neo4j_query_one("""
MATCH (n:Narrator)
WHERE toLower(n.name_arabic) = toLower($q)
RETURN n
""", {"q": q_norm})
if not narrator:
narrator = db.neo4j_query_one("""
MATCH (n:Narrator)
WHERE toLower(n.name_arabic) CONTAINS toLower($q)
RETURN n
""", {"q": q_norm})
if not narrator:
raise HTTPException(status_code=404, detail=f"Narrator not found: {name_arabic}")
n = narrator.get("n", {})
actual_name = n.get("name_arabic", q_norm)
# Hadith count + collections
stats = db.neo4j_query_one("""
MATCH (n:Narrator {name_arabic: $name})-[:APPEARS_IN]->(h:Hadith)
RETURN count(DISTINCT h) AS hadith_count,
collect(DISTINCT h.collection) AS collections
""", {"name": actual_name}) or {}
# Teachers: narrator NARRATED_FROM teacher + teacher TEACHER_OF narrator
teachers_nf = db.neo4j_query("""
MATCH (n:Narrator {name_arabic: $name})-[:NARRATED_FROM]->(t:Narrator)
OPTIONAL MATCH (t)-[:APPEARS_IN]->(h:Hadith)
RETURN t.name_arabic AS name_arabic,
t.name_transliterated AS name_transliterated,
t.entity_type AS entity_type,
t.generation AS generation,
t.reliability_grade AS reliability_grade,
count(DISTINCT h) AS hadith_count
""", {"name": actual_name})
teachers_to = db.neo4j_query("""
MATCH (t:Narrator)-[:TEACHER_OF]->(n:Narrator {name_arabic: $name})
OPTIONAL MATCH (t)-[:APPEARS_IN]->(h:Hadith)
RETURN t.name_arabic AS name_arabic,
t.name_transliterated AS name_transliterated,
t.entity_type AS entity_type,
t.generation AS generation,
t.reliability_grade AS reliability_grade,
count(DISTINCT h) AS hadith_count
""", {"name": actual_name})
# Deduplicate teachers
seen_teachers = set()
teachers = []
for r in teachers_nf + teachers_to:
if r["name_arabic"] not in seen_teachers:
seen_teachers.add(r["name_arabic"])
teachers.append(NarratorSummary(**r))
# Students: student NARRATED_FROM narrator + narrator TEACHER_OF student
students_nf = db.neo4j_query("""
MATCH (s:Narrator)-[:NARRATED_FROM]->(n:Narrator {name_arabic: $name})
OPTIONAL MATCH (s)-[:APPEARS_IN]->(h:Hadith)
RETURN s.name_arabic AS name_arabic,
s.name_transliterated AS name_transliterated,
s.entity_type AS entity_type,
s.generation AS generation,
s.reliability_grade AS reliability_grade,
count(DISTINCT h) AS hadith_count
""", {"name": actual_name})
students_to = db.neo4j_query("""
MATCH (n:Narrator {name_arabic: $name})-[:TEACHER_OF]->(s:Narrator)
OPTIONAL MATCH (s)-[:APPEARS_IN]->(h:Hadith)
RETURN s.name_arabic AS name_arabic,
s.name_transliterated AS name_transliterated,
s.entity_type AS entity_type,
s.generation AS generation,
s.reliability_grade AS reliability_grade,
count(DISTINCT h) AS hadith_count
""", {"name": actual_name})
seen_students = set()
students = []
for r in students_nf + students_to:
if r["name_arabic"] not in seen_students:
seen_students.add(r["name_arabic"])
students.append(NarratorSummary(**r))
# Places
places_rows = db.neo4j_query("""
MATCH (n:Narrator {name_arabic: $name})-[r]->(p:Place)
WHERE type(r) IN ['BORN_IN', 'LIVED_IN', 'DIED_IN', 'TRAVELED_TO']
RETURN p.name_arabic AS place, type(r) AS relation
""", {"name": actual_name})
# Tribes
tribe_rows = db.neo4j_query("""
MATCH (n:Narrator {name_arabic: $name})-[:BELONGS_TO_TRIBE]->(t:Tribe)
RETURN t.name_arabic AS name
""", {"name": actual_name})
# Name forms (alternative names via RELATED_TO)
name_form_rows = db.neo4j_query("""
MATCH (n:Narrator {name_arabic: $name})-[:RELATED_TO]-(alt:Narrator)
WHERE alt.name_arabic <> $name
RETURN alt.name_arabic AS name, alt.entity_type AS type
""", {"name": actual_name})
# Family info
family_row = db.neo4j_query_one("""
MATCH (n:Narrator {name_arabic: $name})
RETURN n.father AS father, n.mother AS mother,
n.spouse AS spouse, n.children AS children
""", {"name": actual_name})
family = None
if family_row and any(family_row.get(k) for k in ["father", "mother", "spouse", "children"]):
family = FamilyInfo(
father=family_row.get("father"),
mother=family_row.get("mother"),
spouse=family_row.get("spouse"),
children=family_row.get("children") or [],
)
# Sample hadiths
hadith_rows = db.neo4j_query("""
MATCH (n:Narrator {name_arabic: $name})-[:APPEARS_IN]->(h:Hadith)
RETURN h.id AS id,
h.collection AS collection,
h.hadith_number AS hadith_number,
h.grade AS grade,
substring(h.arabic_text, 0, 300) AS arabic_text
ORDER BY h.collection, h.hadith_number
LIMIT 50
""", {"name": actual_name})
return NarratorProfile(
name_arabic=n.get("name_arabic", actual_name),
name_transliterated=n.get("name_transliterated", ""),
entity_type=n.get("entity_type", ""),
full_nasab=n.get("full_nasab"),
kunya=n.get("kunya"),
nisba=n.get("nisba"),
laqab=n.get("laqab"),
generation=n.get("generation"),
reliability_grade=n.get("reliability_grade"),
reliability_detail=n.get("reliability_detail"),
birth_year_hijri=n.get("birth_year_hijri"),
death_year_hijri=n.get("death_year_hijri"),
birth_year_ce=n.get("birth_year_ce"),
death_year_ce=n.get("death_year_ce"),
biography_summary_arabic=n.get("biography_summary_arabic"),
biography_summary_english=n.get("biography_summary_english"),
total_hadiths_narrated_approx=n.get("total_hadiths_narrated_approx"),
hadith_count=stats.get("hadith_count", 0),
hadiths=[HadithSummary(**r) for r in hadith_rows],
teachers=teachers,
students=students,
name_forms=[NameForm(**r) for r in name_form_rows],
family=family,
places=[PlaceRelation(**r) for r in places_rows],
tribes=[r["name"] for r in tribe_rows],
bio_verified=n.get("bio_verified", False),
)
# ── Narrators by generation (paginated, normalized) ────────────────────────
@router.get("/by-generation/{generation}", response_model=PaginatedResponse)
async def narrators_by_generation(
generation: str = Path(..., description="Generation: صحابي, تابعي, تابع التابعين, نبي"),
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
):
"""List narrators by generation (e.g. Companions, Successors)."""
q_norm = normalize_query(generation)
skip = (page - 1) * per_page
total = db.neo4j_count("""
MATCH (n:Narrator)
WHERE toLower(n.generation) CONTAINS toLower($gen)
AND n.name_arabic IS NOT NULL
RETURN count(n) AS count
""", {"gen": q_norm})
rows = db.neo4j_query("""
MATCH (n:Narrator)
WHERE toLower(n.generation) CONTAINS toLower($gen)
AND n.name_arabic IS NOT NULL
OPTIONAL MATCH (n)-[:APPEARS_IN]->(h:Hadith)
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,
count(DISTINCT h) AS hadith_count
ORDER BY hadith_count DESC
SKIP $skip LIMIT $limit
""", {"gen": q_norm, "skip": skip, "limit": per_page})
data = [NarratorSummary(**r) for r in rows]
return PaginatedResponse(data=data, meta=_paginate(total, page, per_page))
# ── Narrators by place (paginated, normalized) ─────────────────────────────
@router.get("/by-place/{place_name}", response_model=PaginatedResponse)
async def narrators_by_place(
place_name: str = Path(..., description="Place name in Arabic (e.g. مكة)"),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=100),
):
"""
Narrators associated with a place (born, lived, died, traveled).
Input is normalized — مكة المكرمة matches مكه المكرمه.
"""
q_norm = normalize_query(place_name)
skip = (page - 1) * per_page
total = db.neo4j_count("""
MATCH (n:Narrator)-[r]->(p:Place)
WHERE type(r) IN ['BORN_IN', 'LIVED_IN', 'DIED_IN', 'TRAVELED_TO']
AND toLower(p.name_arabic) CONTAINS toLower($place)
AND n.name_arabic IS NOT NULL
RETURN count(DISTINCT n) AS count
""", {"place": q_norm})
rows = db.neo4j_query("""
MATCH (n:Narrator)-[r]->(p:Place)
WHERE type(r) IN ['BORN_IN', 'LIVED_IN', 'DIED_IN', 'TRAVELED_TO']
AND toLower(p.name_arabic) CONTAINS toLower($place)
AND n.name_arabic IS NOT NULL
OPTIONAL MATCH (n)-[:APPEARS_IN]->(h:Hadith)
RETURN DISTINCT 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,
count(DISTINCT h) AS hadith_count
ORDER BY hadith_count DESC
SKIP $skip LIMIT $limit
""", {"place": q_norm, "skip": skip, "limit": per_page})
data = [NarratorSummary(**r) for r in rows]
return PaginatedResponse(data=data, meta=_paginate(total, page, per_page))
# ── Narrator interactions ──────────────────────────────────────────────────
@router.get("/interactions/{name_arabic}", response_model=list[NarratorInteraction])
async def narrator_interactions(
name_arabic: str = Path(..., description="Narrator Arabic name"),
limit: int = Query(50, ge=1, le=200),
):
"""All direct relationships for a narrator — who they narrated from/to."""
q_norm = normalize_name(name_arabic)
rows = db.neo4j_query("""
MATCH (a:Narrator)-[r]-(b:Narrator)
WHERE toLower(a.name_arabic) CONTAINS toLower($name)
AND type(r) IN ['NARRATED_FROM', 'TEACHER_OF']
WITH a, b, type(r) AS rel_type
OPTIONAL MATCH (a)-[:APPEARS_IN]->(h:Hadith)<-[:APPEARS_IN]-(b)
RETURN a.name_arabic AS narrator_a,
a.name_transliterated AS narrator_a_transliterated,
b.name_arabic AS narrator_b,
b.name_transliterated AS narrator_b_transliterated,
rel_type AS relationship_type,
count(DISTINCT h) AS shared_hadith_count,
collect(DISTINCT h.id)[..20] AS hadith_ids
ORDER BY shared_hadith_count DESC
LIMIT $limit
""", {"name": q_norm, "limit": limit})
return [NarratorInteraction(**r) for r in rows]
# ── Narrator network (graph visualization) ─────────────────────────────────
@router.get("/network/{name_arabic}", response_model=NarratorNetwork)
async def narrator_network(
name_arabic: str = Path(..., description="Narrator Arabic name"),
limit: int = Query(50, ge=1, le=200),
):
"""
Get a narrator's connection network — all incoming/outgoing relationships.
Useful for network visualization.
"""
q_norm = normalize_name(name_arabic)
# Center narrator
center_row = db.neo4j_query_one("""
MATCH (n:Narrator)
WHERE toLower(n.name_arabic) CONTAINS toLower($name)
OPTIONAL MATCH (n)-[:APPEARS_IN]->(h:Hadith)
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,
count(DISTINCT h) AS hadith_count
""", {"name": q_norm})
if not center_row:
raise HTTPException(status_code=404, detail=f"Narrator not found: {name_arabic}")
# Connections
conn_rows = db.neo4j_query("""
MATCH (a:Narrator)-[r]-(b:Narrator)
WHERE toLower(a.name_arabic) CONTAINS toLower($name)
AND type(r) IN ['NARRATED_FROM', 'TEACHER_OF']
RETURN b.name_arabic AS narrator,
b.name_transliterated AS narrator_transliterated,
type(r) AS connection_type,
CASE WHEN startNode(r) = a THEN 'outgoing' ELSE 'incoming' END AS direction
LIMIT $limit
""", {"name": q_norm, "limit": limit})
return NarratorNetwork(
center=NarratorSummary(**center_row),
connections=[NarratorConnection(**r) for r in conn_rows],
total_connections=len(conn_rows),
)
# ── Who met who (shortest path) ────────────────────────────────────────────
@router.get("/who-met-who", response_model=WhoMetWhoResult)
async def who_met_who(
narrator_a: str = Query(..., description="First narrator (Arabic)"),
narrator_b: str = Query(..., description="Second narrator (Arabic)"),
):
"""
Shortest path between two narrators in the knowledge graph.
Useful to see how a narrator connects to the Prophet ﷺ.
"""
a_norm = normalize_name(narrator_a)
b_norm = normalize_name(narrator_b)
row = db.neo4j_query_one("""
MATCH (a:Narrator), (b:Narrator)
WHERE toLower(a.name_arabic) CONTAINS toLower($a)
AND toLower(b.name_arabic) CONTAINS toLower($b)
WITH a, b LIMIT 1
MATCH path = shortestPath((a)-[*..10]-(b))
RETURN [n IN nodes(path) |
{name_arabic: n.name_arabic,
name_transliterated: n.name_transliterated,
generation: n.generation}] AS path_nodes,
[r IN relationships(path) | type(r)] AS rel_types,
length(path) AS path_length
""", {"a": a_norm, "b": b_norm})
if not row:
raise HTTPException(
status_code=404,
detail=f"No path found between '{narrator_a}' and '{narrator_b}'",
)
return WhoMetWhoResult(
narrator_a=narrator_a,
narrator_b=narrator_b,
path=[PathNode(**n) for n in (row.get("path_nodes") or [])],
path_length=row.get("path_length"),
relationship_types=row.get("rel_types", []),
)