File size: 5,383 Bytes
6cb74c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
"""Mode B: ask a language model a question, then verify its answer.

Supported providers (the user supplies their own API key; nothing is stored by this module):

* ``gemini``       Google Gemini ``generateContent`` REST endpoint
* ``openai``       OpenAI chat completions
* ``huggingface``  Hugging Face Inference Providers (OpenAI-compatible router)

Request building and response parsing are pure functions so they can be tested without network access. The same
request shapes are implemented in JavaScript in the static page (``build_static_space.py``).
"""
from __future__ import annotations

import json
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Dict, Optional, Tuple

SYSTEM_PROMPT = (
    "أنت مساعد معرفي في العلوم الإسلامية. أجب بالعربية بإيجاز ودقة. عند الاستشهاد بآية قرآنية أو حديث نبوي اكتب نصه كاملًا "
    "بين علامتي تنصيص مزدوجتين \"...\" بعد عبارة تمهيدية مثل: قال الله تعالى: أو قال رسول الله ﷺ:. "
    "لا تضع بين علامات التنصيص إلا نص الآية أو الحديث، واذكر السورة ورقم الآية أو مصدر الحديث بعد الاقتباس."
)

PROVIDERS: Dict[str, Dict[str, str]] = {
    "gemini": {"label": "Google Gemini", "model": "gemini-2.5-flash",
               "base_url": "https://generativelanguage.googleapis.com/v1beta"},
    "openai": {"label": "OpenAI", "model": "gpt-4o-mini", "base_url": "https://api.openai.com/v1"},
    "huggingface": {"label": "Hugging Face", "model": "Qwen/Qwen2.5-72B-Instruct", "base_url": "https://router.huggingface.co/v1"},
}


class LLMError(RuntimeError):
    """Raised with a user-presentable Arabic message."""


@dataclass
class LLMSettings:
    provider: str
    api_key: str
    model: str = ""
    base_url: str = ""          # override for tests or compatible gateways
    timeout: float = 60.0


def build_request(settings: LLMSettings, prompt: str) -> Tuple[str, Dict[str, str], dict]:
    """``(url, headers, json_body)`` for the chosen provider."""
    if settings.provider not in PROVIDERS:
        raise LLMError("مزوّد النموذج غير مدعوم.")
    info = PROVIDERS[settings.provider]
    base = (settings.base_url or info["base_url"]).rstrip("/")
    model = settings.model.strip() or info["model"]
    if settings.provider == "gemini":
        url = f"{base}/models/{model}:generateContent"
        headers = {"Content-Type": "application/json", "x-goog-api-key": settings.api_key}
        body = {"systemInstruction": {"parts": [{"text": SYSTEM_PROMPT}]}, "contents": [{"role": "user", "parts": [{"text": prompt}]}]}
        return url, headers, body
    url = f"{base}/chat/completions"
    headers = {"Content-Type": "application/json", "Authorization": f"Bearer {settings.api_key}"}
    body = {"model": model, "messages": [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": prompt}]}
    return url, headers, body


def parse_response(provider: str, payload: dict) -> str:
    try:
        if provider == "gemini":
            text = "".join(part.get("text", "") for part in payload["candidates"][0]["content"]["parts"])
        else:
            text = payload["choices"][0]["message"]["content"]
    except (KeyError, IndexError, TypeError) as exc:
        raise LLMError("وصلت استجابة غير متوقعة من النموذج.") from exc
    if not text or not text.strip():
        raise LLMError("لم يُرجع النموذج أي نص.")
    return text.strip()


_HTTP_MESSAGES = {
    400: "رفض المزوّد الطلب؛ تحقق من اسم النموذج.",
    401: "مفتاح الواجهة البرمجية غير صالح.",
    403: "المفتاح غير مصرّح له باستخدام هذا النموذج.",
    404: "النموذج غير موجود عند المزوّد؛ تحقق من اسمه.",
    429: "تجاوزت حد الاستخدام المسموح؛ حاول لاحقًا.",
}


def generate(settings: LLMSettings, prompt: str) -> str:
    """Return the model's answer. Raises ``LLMError`` with an Arabic message on any failure."""
    if not prompt or not prompt.strip():
        raise LLMError("اكتب سؤالًا أولًا.")
    if not settings.api_key or not settings.api_key.strip():
        raise LLMError("أدخل مفتاح الواجهة البرمجية للمزوّد.")
    url, headers, body = build_request(settings, prompt.strip())
    request = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"), headers=headers, method="POST")
    try:
        with urllib.request.urlopen(request, timeout=settings.timeout) as response:
            payload = json.loads(response.read().decode("utf-8"))
    except urllib.error.HTTPError as exc:
        raise LLMError(_HTTP_MESSAGES.get(exc.code, f"فشل الطلب (الرمز {exc.code}).")) from exc
    except (urllib.error.URLError, TimeoutError) as exc:
        raise LLMError("تعذّر الاتصال بالمزوّد؛ تحقق من الإنترنت.") from exc
    except json.JSONDecodeError as exc:
        raise LLMError("وصلت استجابة غير مقروءة من المزوّد.") from exc
    return parse_response(settings.provider, payload)