""" 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 [])]