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