File size: 4,939 Bytes
0dff1a5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Ask-then-verify backend: one pre-configured OpenAI (ChatGPT) client.

There is no provider or model choice for the user. The API key is **never** part of the source code, the page or the
repository (the competition rules forbid secrets in the repository, and a key shipped to a browser is public): it is read
from the ``OPENAI_API_KEY`` environment variable on the server side. The browser page talks to a same-origin proxy
(``functions/api/ask.js``, a Cloudflare Pages Function) that holds the key as a platform secret; this module serves the
local Gradio app and the tests with the same request shapes.

Environment:  OPENAI_API_KEY (required)   OPENAI_MODEL (optional, default below)   OPENAI_BASE_URL (optional, tests / gateways)
"""
from __future__ import annotations

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

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

DEFAULT_MODEL = "gpt-4o-mini"
DEFAULT_BASE_URL = "https://api.openai.com/v1"
MAX_PROMPT_CHARS = 1500


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


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

    @classmethod
    def from_env(cls) -> "LLMSettings":
        return cls(api_key=os.environ.get("OPENAI_API_KEY", ""), model=os.environ.get("OPENAI_MODEL", ""),
                   base_url=os.environ.get("OPENAI_BASE_URL", ""))


def build_request(settings: LLMSettings, prompt: str) -> Tuple[str, Dict[str, str], dict]:
    """``(url, headers, json_body)`` for OpenAI chat completions."""
    base = (settings.base_url or DEFAULT_BASE_URL).rstrip("/")
    body = {"model": settings.model.strip() or DEFAULT_MODEL,
            "messages": [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": prompt}]}
    headers = {"Content-Type": "application/json", "Authorization": f"Bearer {settings.api_key}"}
    return f"{base}/chat/completions", headers, body


def parse_response(payload: dict) -> str:
    try:
        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: Optional[LLMSettings], prompt: str) -> str:
    """Return the model's answer. Raises ``LLMError`` with an Arabic message on any failure."""
    settings = settings or LLMSettings.from_env()
    if not prompt or not prompt.strip():
        raise LLMError("اكتب سؤالًا أولًا.")
    if len(prompt) > MAX_PROMPT_CHARS:
        raise LLMError(f"السؤال طويل جدًا (الحد الأقصى {MAX_PROMPT_CHARS} حرف).")
    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(payload)