hadith-api/app/routers/narrators.py

318 lines
13 KiB
Python

"""
Narrator endpoints — profiles, teacher/student network, relationships, who met who.
"""
from fastapi import APIRouter, Query, HTTPException
from typing import Optional
from app.services.database import db
from app.models.schemas import (
NarratorProfile, NarratorSummary, HadithSummary,
NarratorInteraction, PlaceRelation,
PaginatedResponse, PaginationMeta,
)
router = APIRouter(prefix="/narrators", tags=["Narrators"])
@router.get("/search", response_model=list[NarratorSummary],
summary="Search narrators by name",
description="Full-text search across narrator names in both Arabic and Latin transliteration. "
"Uses Neo4j full-text index for fast matching.")
async def search_narrators(
q: str = Query(
..., min_length=2,
description="Narrator name in Arabic or transliteration. Examples: أبو هريرة, الزهري, Anas, Bukhari",
examples=["أبو هريرة", "الزهري", "Anas ibn Malik"],
),
limit: int = Query(20, ge=1, le=100, description="Maximum results to return"),
):
"""Search narrators by name (Arabic or transliterated)."""
rows = db.neo4j_query("""
CALL db.index.fulltext.queryNodes('narrator_names', $query)
YIELD node, score
WITH node AS n, score
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(h) AS hadith_count,
score
ORDER BY score DESC
LIMIT $limit
""", {"query": q, "limit": limit})
return [NarratorSummary(**r) for r in rows]
@router.get("/profile/{name_arabic}", response_model=NarratorProfile,
summary="Get full narrator profile",
description="Complete narrator profile for the mobile app. Includes biography from classical "
"scholarship (Tahdhib al-Kamal, Taqrib al-Tahdhib), teacher/student network, "
"hadiths narrated, places, and tribal affiliations. "
"Example: `/narrators/profile/أبو هريرة`")
async def get_narrator_profile(name_arabic: str):
"""
Full narrator profile — biography, hadiths, teachers, students,
places, tribes. Powers the mobile app profile page.
"""
# Basic info
narrator = db.neo4j_query_one("""
MATCH (n:Narrator {name_arabic: $name})
RETURN n.name_arabic AS name_arabic,
n.name_transliterated AS name_transliterated,
n.entity_type AS entity_type,
n.full_nasab AS full_nasab,
n.kunya AS kunya,
n.nisba AS nisba,
n.laqab AS laqab,
n.generation AS generation,
n.reliability_grade AS reliability_grade,
n.reliability_detail AS reliability_detail,
n.birth_year_hijri AS birth_year_hijri,
n.death_year_hijri AS death_year_hijri,
n.birth_year_ce AS birth_year_ce,
n.death_year_ce AS death_year_ce,
n.biography_summary_arabic AS biography_summary_arabic,
n.biography_summary_english AS biography_summary_english,
n.total_hadiths_narrated_approx AS total_hadiths_narrated_approx,
n.bio_verified AS bio_verified
""", {"name": name_arabic})
if not narrator:
raise HTTPException(status_code=404, detail="Narrator not found")
# Hadiths
hadiths = 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,
left(h.matn_text, 200) AS matn_text
ORDER BY h.collection, h.hadith_number
LIMIT 50
""", {"name": name_arabic})
# Teachers (who taught this narrator)
teachers = db.neo4j_query("""
MATCH (teacher:Narrator)-[:TEACHER_OF]->(n:Narrator {name_arabic: $name})
OPTIONAL MATCH (teacher)-[:APPEARS_IN]->(h:Hadith)
RETURN teacher.name_arabic AS name_arabic,
teacher.name_transliterated AS name_transliterated,
teacher.entity_type AS entity_type,
teacher.generation AS generation,
teacher.reliability_grade AS reliability_grade,
count(h) AS hadith_count
""", {"name": name_arabic})
# Students (who this narrator taught)
students = db.neo4j_query("""
MATCH (n:Narrator {name_arabic: $name})-[:TEACHER_OF]->(student:Narrator)
OPTIONAL MATCH (student)-[:APPEARS_IN]->(h:Hadith)
RETURN student.name_arabic AS name_arabic,
student.name_transliterated AS name_transliterated,
student.entity_type AS entity_type,
student.generation AS generation,
student.reliability_grade AS reliability_grade,
count(h) AS hadith_count
""", {"name": name_arabic})
# Places
places = db.neo4j_query("""
MATCH (n:Narrator {name_arabic: $name})-[r:BORN_IN|LIVED_IN|DIED_IN|TRAVELED_TO]->(p:Place)
RETURN p.name_arabic AS place, type(r) AS relation
""", {"name": name_arabic})
# Tribes
tribes_rows = db.neo4j_query("""
MATCH (n:Narrator {name_arabic: $name})-[:BELONGS_TO_TRIBE]->(t:Tribe)
RETURN t.name_arabic AS tribe
""", {"name": name_arabic})
return NarratorProfile(
**narrator,
hadith_count=len(hadiths),
hadiths=[HadithSummary(
id=str(h["id"]), collection=h["collection"] or "",
hadith_number=h["hadith_number"] or 0, grade=h["grade"],
matn_text=h["matn_text"],
) for h in hadiths],
teachers=[NarratorSummary(**t) for t in teachers],
students=[NarratorSummary(**s) for s in students],
places=[PlaceRelation(**p) for p in places],
tribes=[t["tribe"] for t in tribes_rows],
)
@router.get("/by-generation/{generation}", response_model=list[NarratorSummary])
async def narrators_by_generation(
generation: str,
limit: int = Query(50, ge=1, le=200),
):
"""List narrators by generation (صحابي, تابعي, etc.)."""
rows = db.neo4j_query("""
MATCH (n:Narrator)
WHERE n.generation CONTAINS $gen
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(h) AS hadith_count
ORDER BY hadith_count DESC
LIMIT $limit
""", {"gen": generation, "limit": limit})
return [NarratorSummary(**r) for r in rows]
@router.get("/by-place/{place_name}", response_model=list[NarratorSummary])
async def narrators_by_place(
place_name: str,
limit: int = Query(50, ge=1, le=200),
):
"""Find narrators associated with a place."""
rows = db.neo4j_query("""
MATCH (n:Narrator)-[:BORN_IN|LIVED_IN|DIED_IN|TRAVELED_TO]->(p:Place)
WHERE p.name_arabic CONTAINS $place
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(h) AS hadith_count
ORDER BY hadith_count DESC
LIMIT $limit
""", {"place": place_name, "limit": limit})
return [NarratorSummary(**r) for r in rows]
@router.get("/interactions/{name_arabic}", response_model=list[NarratorInteraction],
summary="Get all narrator interactions",
description="Lists all relationships for a narrator: who they narrated from, "
"who narrated from them, their teachers, and their students. "
"Each interaction includes shared hadith count. "
"Example: `/narrators/interactions/الزهري`")
async def get_interactions(
name_arabic: str,
limit: int = Query(50, ge=1, le=200, description="Maximum interactions to return"),
):
"""
Get all interactions of a narrator — who they narrated from,
who narrated from them, teachers, students.
"""
rows = db.neo4j_query("""
MATCH (n:Narrator {name_arabic: $name})
OPTIONAL MATCH (n)-[r1:NARRATED_FROM]->(other1:Narrator)
WITH n, collect(DISTINCT {
narrator_b: other1.name_arabic,
narrator_b_trans: other1.name_transliterated,
type: 'NARRATED_FROM',
hadith_ids: r1.hadith_ids
}) AS outgoing
OPTIONAL MATCH (other2:Narrator)-[r2:NARRATED_FROM]->(n)
WITH n, outgoing, collect(DISTINCT {
narrator_b: other2.name_arabic,
narrator_b_trans: other2.name_transliterated,
type: 'HEARD_BY',
hadith_ids: r2.hadith_ids
}) AS incoming
OPTIONAL MATCH (teacher:Narrator)-[r3:TEACHER_OF]->(n)
WITH n, outgoing, incoming, collect(DISTINCT {
narrator_b: teacher.name_arabic,
narrator_b_trans: teacher.name_transliterated,
type: 'TEACHER_OF',
hadith_ids: []
}) AS teacher_rels
OPTIONAL MATCH (n)-[r4:TEACHER_OF]->(student:Narrator)
WITH n, outgoing, incoming, teacher_rels, collect(DISTINCT {
narrator_b: student.name_arabic,
narrator_b_trans: student.name_transliterated,
type: 'STUDENT_OF',
hadith_ids: []
}) AS student_rels
RETURN n.name_arabic AS narrator_a,
n.name_transliterated AS narrator_a_trans,
outgoing + incoming + teacher_rels + student_rels AS interactions
""", {"name": name_arabic})
if not rows:
raise HTTPException(status_code=404, detail="Narrator not found")
result = []
row = rows[0]
for interaction in row["interactions"]:
if not interaction.get("narrator_b"):
continue
hadith_ids = interaction.get("hadith_ids") or []
result.append(NarratorInteraction(
narrator_a=row["narrator_a"],
narrator_a_transliterated=row.get("narrator_a_trans") or "",
narrator_b=interaction["narrator_b"],
narrator_b_transliterated=interaction.get("narrator_b_trans") or "",
relationship_type=interaction["type"],
shared_hadith_count=len(hadith_ids),
hadith_ids=[str(h) for h in hadith_ids[:20]],
))
return result[:limit]
@router.get("/who-met-who", response_model=list[NarratorInteraction],
summary="Check if two narrators are connected",
description="Finds the shortest path between two narrators in the knowledge graph. "
"Reveals whether they had a direct or indirect relationship through "
"narration chains, teacher/student bonds, or shared connections. "
"Example: `/narrators/who-met-who?narrator_a=الزهري&narrator_b=أنس بن مالك`")
async def who_met_who(
narrator_a: str = Query(
..., description="First narrator name (Arabic). Example: الزهري",
examples=["الزهري", "أبو هريرة"],
),
narrator_b: str = Query(
..., description="Second narrator name (Arabic). Example: أنس بن مالك",
examples=["أنس بن مالك", "عمر بن الخطاب"],
),
):
"""
Check if two narrators had a relationship — did they meet,
narrate from each other, or share a teacher/student bond?
"""
rows = db.neo4j_query("""
MATCH (a:Narrator), (b:Narrator)
WHERE a.name_arabic CONTAINS $name_a
AND b.name_arabic CONTAINS $name_b
OPTIONAL MATCH path = shortestPath((a)-[*..6]-(b))
WITH a, b, path,
[r IN relationships(path) | {
type: type(r),
from: startNode(r).name_arabic,
from_trans: startNode(r).name_transliterated,
to: endNode(r).name_arabic,
to_trans: endNode(r).name_transliterated
}] AS rels
RETURN a.name_arabic AS narrator_a,
a.name_transliterated AS narrator_a_trans,
b.name_arabic AS narrator_b,
b.name_transliterated AS narrator_b_trans,
length(path) AS distance,
rels
""", {"name_a": narrator_a, "name_b": narrator_b})
if not rows or rows[0].get("distance") is None:
return []
row = rows[0]
return [NarratorInteraction(
narrator_a=rel["from"],
narrator_a_transliterated=rel.get("from_trans") or "",
narrator_b=rel["to"],
narrator_b_transliterated=rel.get("to_trans") or "",
relationship_type=rel["type"],
) for rel in (row.get("rels") or [])]