437 lines
17 KiB
Python
437 lines
17 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)
|
|
RETURN count(n) AS count
|
|
""", {"gen": q_norm})
|
|
|
|
rows = db.neo4j_query("""
|
|
MATCH (n:Narrator)
|
|
WHERE toLower(n.generation) CONTAINS toLower($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(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)
|
|
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)
|
|
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", []),
|
|
)
|