318 lines
13 KiB
Python
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 [])]
|