import os, re, html, pickle
import numpy as np
import torch
from collections import Counter
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from transformers import AutoTokenizer, AutoModelForSequenceClassification
from openai import OpenAI
# ── Config ────────────────────────────────────────────────────────────────────
METER_MODEL_ID = "Rahaf2001/Lassen-meter-classifier"
ERA_MODEL_ID = "Rahaf2001/LassenEraClassifier"
TOPIC_MODEL_ID = "Rahaf2001/Lassen-topic-classifier"
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# ── Global model holders ──────────────────────────────────────────────────────
models = {}
# ── Arabic text cleaning ──────────────────────────────────────────────────────
ARABIC_DIACRITICS = re.compile(r'[\u0617-\u061A\u064B-\u0652\u0670\u06D6-\u06ED]')
def clean_arabic(text: str) -> str:
if not text:
return ""
text = html.unescape(str(text))
text = re.sub(r"<.*?>", " ", text)
text = text.replace("\u0640", "")
text = ARABIC_DIACRITICS.sub("", text)
text = re.sub(r'[\u0623\u0625\u0622\u0671]', '\u0627', text)
text = text.replace("\u0629", "\u0647")
text = re.sub(r"[0-9\u0660-\u0669]", " ", text)
text = re.sub(r"[^\u0600-\u06FF\s]", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text
# ── Meter labels ──────────────────────────────────────────────────────────────
LABELS_METER = ['saree', 'kamel', 'mutakareb', 'mutadarak', 'munsareh',
'madeed', 'mujtath', 'ramal', 'baseet', 'khafeef',
'taweel', 'wafer', 'hazaj', 'rajaz']
METER_ARABIC = {
'saree': 'السريع', 'kamel': 'الكامل', 'mutakareb': 'المتقارب',
'mutadarak': 'المتدارك', 'munsareh': 'المنسرح', 'madeed': 'المديد',
'mujtath': 'المجتث', 'ramal': 'الرمل', 'baseet': 'البسيط',
'khafeef': 'الخفيف', 'taweel': 'الطويل', 'wafer': 'الوافر',
'hazaj': 'الهزج', 'rajaz': 'الرجز'
}
# ── Meter taf'ila patterns ────────────────────────────────────────────────────
METER_PATTERNS = {
'الطويل': 'فَعُولُنْ مَفَاعِيلُنْ فَعُولُنْ مَفَاعِلُنْ',
'الكامل': 'مُتَفَاعِلُنْ مُتَفَاعِلُنْ مُتَفَاعِلُنْ',
'البسيط': 'مُسْتَفْعِلُنْ فَاعِلُنْ مُسْتَفْعِلُنْ فَاعِلُنْ',
'الوافر': 'مُفَاعَلَتُنْ مُفَاعَلَتُنْ فَعُولُنْ',
'الخفيف': 'فَاعِلَاتُنْ مُسْتَفْعِلُنْ فَاعِلَاتُنْ',
'الرجز': 'مُسْتَفْعِلُنْ مُسْتَفْعِلُنْ مُسْتَفْعِلُنْ',
'الرمل': 'فَاعِلَاتُنْ فَاعِلَاتُنْ فَاعِلَاتُنْ',
'السريع': 'مُسْتَفْعِلُنْ مُسْتَفْعِلُنْ مَفْعُولَاتُ',
'المنسرح': 'مُسْتَفْعِلُنْ مَفْعُولَاتُ مُسْتَفْعِلُنْ',
'الهزج': 'مَفَاعِيلُنْ مَفَاعِيلُنْ',
'المتقارب': 'فَعُولُنْ فَعُولُنْ فَعُولُنْ فَعُولُنْ',
'المتدارك': 'فَاعِلُنْ فَاعِلُنْ فَاعِلُنْ فَاعِلُنْ',
'المديد': 'فَاعِلَاتُنْ فَاعِلُنْ فَاعِلَاتُنْ',
}
# ── Lifespan: load models once at startup ────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
print("Loading models...")
# Meter
models["meter_tokenizer"] = AutoTokenizer.from_pretrained(METER_MODEL_ID)
models["meter_model"] = AutoModelForSequenceClassification.from_pretrained(METER_MODEL_ID)
models["meter_model"].to(device).eval()
# Era
models["era_tokenizer"] = AutoTokenizer.from_pretrained(ERA_MODEL_ID)
models["era_model"] = AutoModelForSequenceClassification.from_pretrained(ERA_MODEL_ID)
models["era_model"].to(device).eval()
# Topic
models["topic_tokenizer"] = AutoTokenizer.from_pretrained(TOPIC_MODEL_ID)
models["topic_model"] = AutoModelForSequenceClassification.from_pretrained(TOPIC_MODEL_ID)
models["topic_model"].to(device).eval()
# Topic labels — loaded from HF model config
topic_cfg = models["topic_model"].config
if hasattr(topic_cfg, "id2label"):
models["id2label_topic"] = {int(k): v for k, v in topic_cfg.id2label.items()}
else:
models["id2label_topic"] = {i: str(i) for i in range(topic_cfg.num_labels)}
# OpenAI client
models["openai"] = OpenAI(api_key=OPENAI_API_KEY)
print(f"All models loaded on {device} ✓")
yield
models.clear()
# ── App ───────────────────────────────────────────────────────────────────────
app = FastAPI(title="Bayan API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# ── Inference helpers ─────────────────────────────────────────────────────────
def predict_meter(poem_text: str) -> dict:
verses = [v.replace("#", " ").strip() for v in poem_text.strip().split("\n") if v.strip()]
if not verses:
raise ValueError("القصيدة فارغة")
predictions = []
for verse in verses:
inputs = models["meter_tokenizer"](verse, return_tensors="pt", truncation=True,
max_length=32, padding="max_length")
inputs = {k: v.to(device) for k, v in inputs.items()}
with torch.no_grad():
probs = torch.softmax(models["meter_model"](**inputs).logits, dim=-1)[0]
pred_id = torch.argmax(probs).item()
predictions.append((LABELS_METER[pred_id], probs[pred_id].item()))
top_meter = Counter(p[0] for p in predictions).most_common(1)[0][0]
avg_conf = sum(c for _, c in predictions) / len(predictions)
return {"meter_ar": METER_ARABIC[top_meter], "meter_en": top_meter,
"confidence": round(avg_conf, 3)}
def predict_era(poem_text: str) -> dict:
cleaned = clean_arabic(poem_text)
enc = models["era_tokenizer"](cleaned, padding="max_length", truncation=True,
max_length=256, return_tensors="pt")
enc = {k: v.to(device) for k, v in enc.items()}
with torch.no_grad():
probs = torch.softmax(models["era_model"](**enc).logits, dim=-1).cpu().numpy()[0]
label_names = ["قديم", "حديث"]
pred_idx = int(np.argmax(probs))
return {"era": label_names[pred_idx],
"classical_probability": round(float(probs[0]), 4),
"modern_probability": round(float(probs[1]), 4)}
def predict_topic(poem_text: str) -> dict:
cleaned = clean_arabic(poem_text)
inputs = models["topic_tokenizer"](cleaned, truncation=True, max_length=512,
return_tensors="pt", padding=True)
inputs = {k: v.to(device) for k, v in inputs.items()}
with torch.no_grad():
probs = torch.softmax(models["topic_model"](**inputs).logits, dim=-1)[0].cpu().numpy()
top3 = np.argsort(probs)[::-1][:3]
id2label = models["id2label_topic"]
return {"topic": id2label[int(top3[0])],
"confidence": round(float(probs[top3[0]]), 3),
"top3": [{"label": id2label[int(i)], "prob": round(float(probs[i]), 3)} for i in top3]}
# ── Request / Response schemas ────────────────────────────────────────────────
class PoemRequest(BaseModel):
poem: str
class GenerateRequest(BaseModel):
idea: str
meter: str
num_verses: int = 4
# ── Routes ────────────────────────────────────────────────────────────────────
@app.get("/")
def root():
return {"status": "ok", "service": "Bayan API"}
@app.get("/health")
def health():
return {"status": "healthy", "device": str(device)}
@app.post("/fasserha")
def fasserha(req: PoemRequest):
"""فسّرها لي — classify meter, era, topic then generate literary analysis."""
if not req.poem.strip():
raise HTTPException(400, "القصيدة فارغة")
try:
meter = predict_meter(req.poem)
era = predict_era(req.poem)
topic = predict_topic(req.poem)
except Exception as e:
raise HTTPException(500, f"خطأ في التصنيف: {str(e)}")
system_prompt = """أنت ناقد أدبي متخصص في الشعر العربي الكلاسيكي والحديث.
تحلل القصائد بأسلوب أكاديمي راقٍ، وتستخدم المصطلحات البلاغية والعروضية بدقة.
ردك دائماً بالعربية الفصحى."""
user_prompt = f"""حلّل هذه القصيدة:
{req.poem}
معطيات النماذج (حقائق مؤكدة):
- البحر الشعري: {meter['meter_ar']} (ثقة: {meter['confidence']*100:.0f}%)
- العصر: {era['era']} (كلاسيكي: {era['classical_probability']*100:.0f}% | حديث: {era['modern_probability']*100:.0f}%)
- الموضوع: {topic['topic']} (ثقة: {topic['confidence']*100:.0f}%)
اكتب تحليلاً أدبياً شاملاً يتضمن:
1. الفكرة العامة والمعنى الكلي
2. المعنى التفصيلي للأبيات
3. الجماليات البلاغية والأسلوبية
4. البحر والإيقاع وأثرهما في المعنى
5. لمسة نقدية تقييمية
التزم بالترتيب أعلاه. لا تكرر المعلومات."""
try:
response = models["openai"].chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
max_tokens=1200,
temperature=0.7
)
explanation = response.choices[0].message.content
except Exception as e:
raise HTTPException(500, f"خطأ في التفسير: {str(e)}")
return {
"success": True,
"data": {
"meter": meter,
"era": era,
"topic": topic,
"explanation": explanation
}
}
@app.post("/generate")
def generate(req: GenerateRequest):
"""ساعدني في الكتابة — generate classical Arabic verses."""
if not req.idea.strip():
raise HTTPException(400, "الفكرة فارغة")
if req.meter not in METER_PATTERNS and req.meter not in METER_ARABIC.values():
raise HTTPException(400, f"البحر غير معروف: {req.meter}")
if not 1 <= req.num_verses <= 12:
raise HTTPException(400, "عدد الأبيات بين 1 و 12")
pattern = METER_PATTERNS.get(req.meter, "")
pattern_line = f"تفعيلة البحر: {pattern}" if pattern else ""
prompt = f"""أنت شاعر عربي متخصص في العروض الكلاسيكي.
الموضوع: "{req.idea}"
البحر: {req.meter}
{pattern_line}
المطلوب: اكتب {req.num_verses} أبيات شعرية بالفصحى الكلاسيكية.
القواعد الصارمة:
- كل بيت من شطرين صحيحين عروضياً
- قافية موحدة في جميع الأبيات
- فصحى كلاسيكية فقط
- الأبيات متصلة كقصيدة واحدة
- اكتب الأبيات فقط، بدون ترقيم أو شرح
- سطر واحد لكل بيت، {req.num_verses} سطور فقط"""
try:
response = models["openai"].chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "أنت شاعر عربي كلاسيكي. اكتب الأبيات فقط، سطر لكل بيت."},
{"role": "user", "content": prompt}
],
temperature=0.75,
max_tokens=600
)
raw = response.choices[0].message.content.strip()
verses = [l.strip() for l in raw.split("\n") if l.strip() and len(l.strip()) > 10]
return {
"success": True,
"data": {
"verses": verses[:req.num_verses],
"meter": req.meter,
"pattern": pattern
}
}
except Exception as e:
raise HTTPException(500, f"خطأ في التوليد: {str(e)}")
@app.get("/meters")
def list_meters():
"""Return all supported meters."""
return {"meters": list(METER_PATTERNS.keys())}