Spaces:
Running
Running
Upload 11 files
Browse files- .gitignore +39 -0
- Dockerfile +30 -0
- README.md +61 -11
- __init__.py +0 -0
- ai_service.py +508 -0
- app.py +8 -0
- main.py +112 -0
- models.py +107 -0
- prompts.py +189 -0
- requirements.txt +9 -0
- tts_service.py +132 -0
.gitignore
ADDED
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Python
|
2 |
+
__pycache__/
|
3 |
+
*.py[cod]
|
4 |
+
*$py.class
|
5 |
+
*.so
|
6 |
+
.Python
|
7 |
+
env/
|
8 |
+
build/
|
9 |
+
develop-eggs/
|
10 |
+
dist/
|
11 |
+
downloads/
|
12 |
+
eggs/
|
13 |
+
.eggs/
|
14 |
+
lib/
|
15 |
+
lib64/
|
16 |
+
parts/
|
17 |
+
sdist/
|
18 |
+
var/
|
19 |
+
*.egg-info/
|
20 |
+
.installed.cfg
|
21 |
+
*.egg
|
22 |
+
|
23 |
+
# Virtual Environment
|
24 |
+
venv/
|
25 |
+
ENV/
|
26 |
+
.env
|
27 |
+
|
28 |
+
# Audio files
|
29 |
+
audio_files/
|
30 |
+
|
31 |
+
# IDE specific files
|
32 |
+
.idea/
|
33 |
+
.vscode/
|
34 |
+
*.swp
|
35 |
+
*.swo
|
36 |
+
|
37 |
+
# OS specific files
|
38 |
+
.DS_Store
|
39 |
+
Thumbs.db
|
Dockerfile
ADDED
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FROM python:3.9-slim
|
2 |
+
|
3 |
+
WORKDIR /code
|
4 |
+
|
5 |
+
# Install system dependencies
|
6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
7 |
+
build-essential \
|
8 |
+
&& apt-get clean \
|
9 |
+
&& rm -rf /var/lib/apt/lists/*
|
10 |
+
|
11 |
+
# Copy requirements and install Python dependencies
|
12 |
+
COPY requirements.txt .
|
13 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
14 |
+
|
15 |
+
# Copy application code
|
16 |
+
COPY . .
|
17 |
+
|
18 |
+
# Create directories for audio files
|
19 |
+
RUN mkdir -p audio_files
|
20 |
+
|
21 |
+
# Expose port
|
22 |
+
EXPOSE 7860
|
23 |
+
|
24 |
+
# Set environment variables
|
25 |
+
ENV BACKEND_HOST=0.0.0.0
|
26 |
+
ENV BACKEND_PORT=7860
|
27 |
+
ENV BASE_URL=https://${SPACE_ID}.hf.space
|
28 |
+
|
29 |
+
# Run the application
|
30 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
@@ -1,11 +1,61 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# رَاوي (Rawi) - منصة القص العربي بالذكاء الاصطناعي
|
2 |
+
|
3 |
+
## نظرة عامة
|
4 |
+
خدمة API لتوليد قصص عربية تفاعلية باستخدام الذكاء الاصطناعي. تتيح المنصة للمستخدمين إنشاء قصص مخصصة بناءً على معلمات محددة، والتفاعل مع القصة من خلال خيارات متعددة، والاستماع إلى القصص المنشأة بتقنية تحويل النص إلى كلام.
|
5 |
+
|
6 |
+
## المميزات
|
7 |
+
- إنشاء قصص عربية أصلية باستخدام نماذج الذكاء الاصطناعي من DeepSeek
|
8 |
+
- تخصيص معلمات القصة مثل الطول والنوع والشخصيات
|
9 |
+
- القص التفاعلي مع خيارات متعددة للمسارات
|
10 |
+
- تحويل النص إلى صوت عربي
|
11 |
+
- واجهة برمجة تطبيقات RESTful متكاملة
|
12 |
+
|
13 |
+
## واجهة برمجة التطبيقات (API)
|
14 |
+
|
15 |
+
### تهيئة قصة جديدة
|
16 |
+
```
|
17 |
+
POST /api/stories/initialize
|
18 |
+
```
|
19 |
+
|
20 |
+
### متابعة القصة
|
21 |
+
```
|
22 |
+
POST /api/stories/continue
|
23 |
+
```
|
24 |
+
|
25 |
+
### تحويل القصة إلى صوت
|
26 |
+
```
|
27 |
+
POST /api/stories/tts
|
28 |
+
```
|
29 |
+
|
30 |
+
### تعديل القصة
|
31 |
+
```
|
32 |
+
POST /api/stories/edit
|
33 |
+
```
|
34 |
+
|
35 |
+
### الحصول على القصة كاملة
|
36 |
+
```
|
37 |
+
GET /api/stories/story/{story_id}
|
38 |
+
```
|
39 |
+
|
40 |
+
### الوصول إلى الملفات الصوتية
|
41 |
+
```
|
42 |
+
GET /audio/{filename}
|
43 |
+
```
|
44 |
+
|
45 |
+
## المتطلبات البيئية
|
46 |
+
- `DEEPSEEK_API_KEY`: مفتاح API للوصول إلى نماذج DeepSeek
|
47 |
+
- `BACKEND_HOST`: مضيف الخادم (افتراضي: 0.0.0.0)
|
48 |
+
- `BACKEND_PORT`: منفذ الخادم (افتراضي: 7860)
|
49 |
+
- `BASE_URL`: عنوان URL الأساسي للخدمة
|
50 |
+
- `AUDIO_STORAGE_PATH`: مسار تخزين الملفات الصوتية (افتراضي: ./audio_files)
|
51 |
+
|
52 |
+
## التكامل مع التطبيقات
|
53 |
+
تم تصميم هذه الخدمة للعمل مع:
|
54 |
+
1. واجهة المستخدم الويب لـراوي
|
55 |
+
2. تطبيق Flutter للأجهزة المحمولة
|
56 |
+
|
57 |
+
## توثيق واجهة برمجة التطبيقات
|
58 |
+
يمكن الوصول إلى وثائق API التفاعلية الكاملة عبر:
|
59 |
+
```
|
60 |
+
https://[your-space-name].hf.space/docs
|
61 |
+
```
|
__init__.py
ADDED
File without changes
|
ai_service.py
ADDED
@@ -0,0 +1,508 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import json
|
3 |
+
import re
|
4 |
+
import time
|
5 |
+
import asyncio
|
6 |
+
from typing import Dict, List, Tuple, Optional
|
7 |
+
import httpx
|
8 |
+
from dotenv import load_dotenv
|
9 |
+
|
10 |
+
from models import StoryConfig, StoryParagraph, StoryChoice, StoryResponse
|
11 |
+
from prompts import (
|
12 |
+
get_system_prompt,
|
13 |
+
create_story_init_prompt,
|
14 |
+
create_continuation_prompt,
|
15 |
+
create_title_prompt,
|
16 |
+
get_story_length_instructions
|
17 |
+
)
|
18 |
+
|
19 |
+
# تحميل المتغيرات البيئية
|
20 |
+
load_dotenv()
|
21 |
+
|
22 |
+
# الحصول على مفتاح API من المتغيرات البيئية
|
23 |
+
DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY")
|
24 |
+
DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions"
|
25 |
+
|
26 |
+
# تخزين سياق القصص
|
27 |
+
# في نظام حقيقي، يجب استخدام قاعدة بيانات بدلاً من التخزين في الذاكرة
|
28 |
+
stories_context = {}
|
29 |
+
stories_metadata = {}
|
30 |
+
|
31 |
+
|
32 |
+
async def generate_response(messages: List[Dict], retries=3, backoff_factor=1.5) -> str:
|
33 |
+
"""
|
34 |
+
استدعاء DeepSeek API وتوليد استجابة بناءً على سلسلة الرسائل مع محاولة إعادة المحاولة في حالة الفشل
|
35 |
+
|
36 |
+
Args:
|
37 |
+
messages: قائمة برسائل المحادثة
|
38 |
+
retries: عدد محاولات إعادة المحاولة في حالة فشل الاتصال (افتراضي: 3)
|
39 |
+
backoff_factor: معامل التأخير للمحاولات المتتالية (افتراضي: 1.5)
|
40 |
+
|
41 |
+
Returns:
|
42 |
+
str: محتوى الاستجابة من API
|
43 |
+
|
44 |
+
Raises:
|
45 |
+
Exception: في حالة فشل جميع المحاولات
|
46 |
+
"""
|
47 |
+
if not DEEPSEEK_API_KEY:
|
48 |
+
raise Exception("مفتاح API غير متوفر. يرجى التحقق من إعداد المتغيرات البيئية.")
|
49 |
+
|
50 |
+
headers = {
|
51 |
+
"Content-Type": "application/json",
|
52 |
+
"Authorization": f"Bearer {DEEPSEEK_API_KEY}"
|
53 |
+
}
|
54 |
+
|
55 |
+
payload = {
|
56 |
+
"model": "deepseek-chat", # استبدل باسم النموذج المناسب من DeepSeek API
|
57 |
+
"messages": messages,
|
58 |
+
"temperature": 0.7,
|
59 |
+
"max_tokens": 1000
|
60 |
+
}
|
61 |
+
|
62 |
+
last_exception = None
|
63 |
+
|
64 |
+
# محاولات إعادة الاتصال في حالة فشل API
|
65 |
+
for attempt in range(retries):
|
66 |
+
try:
|
67 |
+
async with httpx.AsyncClient() as client:
|
68 |
+
response = await client.post(
|
69 |
+
DEEPSEEK_API_URL,
|
70 |
+
headers=headers,
|
71 |
+
json=payload,
|
72 |
+
timeout=60.0
|
73 |
+
)
|
74 |
+
|
75 |
+
if response.status_code == 429: # Rate Limit
|
76 |
+
# انتظار فترة قبل المحاولة مرة أخرى
|
77 |
+
wait_time = backoff_factor * (2 ** attempt)
|
78 |
+
print(f"تجاوز حد معدل الطلبات، انتظار {wait_time} ثانية قبل المحاولة مرة أخرى.")
|
79 |
+
await asyncio.sleep(wait_time)
|
80 |
+
continue
|
81 |
+
|
82 |
+
if response.status_code != 200:
|
83 |
+
error_msg = f"فشل طلب DeepSeek API: {response.status_code} - {response.text}"
|
84 |
+
print(error_msg)
|
85 |
+
last_exception = Exception(error_msg)
|
86 |
+
|
87 |
+
# انتظار فترة قبل المحاولة مرة أخرى
|
88 |
+
wait_time = backoff_factor * (2 ** attempt)
|
89 |
+
await asyncio.sleep(wait_time)
|
90 |
+
continue
|
91 |
+
|
92 |
+
result = response.json()
|
93 |
+
if "choices" not in result or not result["choices"]:
|
94 |
+
raise Exception("تنسيق استجابة DeepSeek API غير صالح")
|
95 |
+
|
96 |
+
return result["choices"][0]["message"]["content"]
|
97 |
+
|
98 |
+
except httpx.HTTPError as e:
|
99 |
+
error_msg = f"خطأ في اتصال HTTP: {str(e)}"
|
100 |
+
print(error_msg)
|
101 |
+
last_exception = Exception(error_msg)
|
102 |
+
|
103 |
+
# انتظار فترة قبل المحاولة مرة أخرى
|
104 |
+
wait_time = backoff_factor * (2 ** attempt)
|
105 |
+
await asyncio.sleep(wait_time)
|
106 |
+
continue
|
107 |
+
|
108 |
+
except Exception as e:
|
109 |
+
error_msg = f"خطأ غير متوقع: {str(e)}"
|
110 |
+
print(error_msg)
|
111 |
+
last_exception = Exception(error_msg)
|
112 |
+
|
113 |
+
# انتظار فترة قبل المحاولة مرة أخرى
|
114 |
+
wait_time = backoff_factor * (2 ** attempt)
|
115 |
+
await asyncio.sleep(wait_time)
|
116 |
+
continue
|
117 |
+
|
118 |
+
# إذا وصلنا إلى هنا، فقد فشلت جميع المحاولات
|
119 |
+
raise last_exception or Exception("فشل الاتصال بـ DeepSeek API بعد عدة محاولات")
|
120 |
+
|
121 |
+
|
122 |
+
def parse_paragraph_and_choices(response_text: str) -> Tuple[str, Optional[List[StoryChoice]]]:
|
123 |
+
"""
|
124 |
+
تحليل استجابة النموذج واستخراج ��لفقرة والخيارات
|
125 |
+
"""
|
126 |
+
# محاولة استخراج الفقرة
|
127 |
+
paragraph_match = re.search(r"الفقرة:(.*?)(?:الخيارات:|العنوان:|$)", response_text, re.DOTALL)
|
128 |
+
paragraph = paragraph_match.group(1).strip() if paragraph_match else response_text.strip()
|
129 |
+
|
130 |
+
# محاولة استخراج الخيارات
|
131 |
+
choices = None
|
132 |
+
choices_match = re.search(r"الخيارات:(.*?)(?:$)", response_text, re.DOTALL)
|
133 |
+
|
134 |
+
if choices_match:
|
135 |
+
choices_text = choices_match.group(1).strip()
|
136 |
+
choices = []
|
137 |
+
|
138 |
+
# استخراج الخيارات المرقمة
|
139 |
+
for i, choice_match in enumerate(re.findall(r"\d+\.\s*(.*?)(?=\d+\.|$)", choices_text, re.DOTALL), 1):
|
140 |
+
choice_text = choice_match.strip()
|
141 |
+
if choice_text:
|
142 |
+
choices.append(StoryChoice(id=i, text=choice_text))
|
143 |
+
|
144 |
+
# إذا لم يتم العثور على خيارات بالطريقة السابقة، نحاول طريقة أخرى
|
145 |
+
if not choices:
|
146 |
+
lines = choices_text.split("\n")
|
147 |
+
for i, line in enumerate([l for l in lines if l.strip()], 1):
|
148 |
+
if i <= 3: # نقتصر على 3 خيارات
|
149 |
+
# تجاهل الترقيم إذا كان موجوداً
|
150 |
+
choice_text = re.sub(r"^\d+\.\s*", "", line).strip()
|
151 |
+
choices.append(StoryChoice(id=i, text=choice_text))
|
152 |
+
|
153 |
+
# استخراج العنوان إذا كان موجوداً
|
154 |
+
title = None
|
155 |
+
title_match = re.search(r"العنوان:(.*?)(?:$)", response_text, re.DOTALL)
|
156 |
+
if title_match:
|
157 |
+
title = title_match.group(1).strip()
|
158 |
+
|
159 |
+
return paragraph, choices, title
|
160 |
+
|
161 |
+
|
162 |
+
async def initialize_story(config: StoryConfig) -> StoryResponse:
|
163 |
+
"""
|
164 |
+
بدء قصة جديدة باستخدام DeepSeek API
|
165 |
+
"""
|
166 |
+
import uuid
|
167 |
+
story_id = str(uuid.uuid4())
|
168 |
+
|
169 |
+
# إنشاء البرومبت الأولي
|
170 |
+
system_prompt = get_system_prompt()
|
171 |
+
user_prompt = create_story_init_prompt(config)
|
172 |
+
|
173 |
+
messages = [
|
174 |
+
{"role": "system", "content": system_prompt},
|
175 |
+
{"role": "user", "content": user_prompt}
|
176 |
+
]
|
177 |
+
|
178 |
+
# استدعاء DeepSeek API
|
179 |
+
response_text = await generate_response(messages)
|
180 |
+
|
181 |
+
# تحليل الاستجابة
|
182 |
+
paragraph_text, choices, _ = parse_paragraph_and_choices(response_text)
|
183 |
+
|
184 |
+
# إنشاء فقرة القصة
|
185 |
+
paragraph = StoryParagraph(
|
186 |
+
content=paragraph_text,
|
187 |
+
choices=choices
|
188 |
+
)
|
189 |
+
|
190 |
+
# حفظ سياق القصة
|
191 |
+
stories_context[story_id] = {
|
192 |
+
"paragraphs": [paragraph_text],
|
193 |
+
"current_paragraph": 1
|
194 |
+
}
|
195 |
+
|
196 |
+
# حفظ معلومات القصة
|
197 |
+
length_info = get_story_length_instructions(config.length)
|
198 |
+
stories_metadata[story_id] = {
|
199 |
+
"config": config.dict(),
|
200 |
+
"max_paragraphs": length_info["paragraphs"],
|
201 |
+
"messages": messages + [{"role": "assistant", "content": response_text}],
|
202 |
+
"title": None
|
203 |
+
}
|
204 |
+
|
205 |
+
# إنشاء استجابة القصة
|
206 |
+
story_response = StoryResponse(
|
207 |
+
story_id=story_id,
|
208 |
+
paragraph=paragraph,
|
209 |
+
is_complete=False
|
210 |
+
)
|
211 |
+
|
212 |
+
return story_response
|
213 |
+
|
214 |
+
|
215 |
+
async def continue_story(story_id: str, choice_id: int) -> StoryResponse:
|
216 |
+
"""
|
217 |
+
متابعة القصة بناءً على اختيار المستخدم
|
218 |
+
"""
|
219 |
+
if story_id not in stories_context or story_id not in stories_metadata:
|
220 |
+
raise ValueError("معرف القصة غير صالح")
|
221 |
+
|
222 |
+
context = stories_context[story_id]
|
223 |
+
metadata = stories_metadata[story_id]
|
224 |
+
|
225 |
+
# الحصول على سياق القصة والاختيار
|
226 |
+
paragraphs = context["paragraphs"]
|
227 |
+
current_paragraph = context["current_paragraph"]
|
228 |
+
max_paragraphs = metadata["max_paragraphs"]
|
229 |
+
|
230 |
+
# الحصول على الرسائل السابقة
|
231 |
+
messages = metadata["messages"]
|
232 |
+
|
233 |
+
# الحصول على نص الاختيار
|
234 |
+
last_message = messages[-1]["content"]
|
235 |
+
_, choices, _ = parse_paragraph_and_choices(last_message)
|
236 |
+
|
237 |
+
if not choices or choice_id < 1 or choice_id > len(choices):
|
238 |
+
raise ValueError("معرف الاختيار غير صالح")
|
239 |
+
|
240 |
+
choice_text = next((c.text for c in choices if c.id == choice_id), "")
|
241 |
+
|
242 |
+
# إنشاء برومبت المتابعة
|
243 |
+
story_context_text = "\n".join(paragraphs)
|
244 |
+
continuation_prompt = create_continuation_prompt(
|
245 |
+
story_context_text,
|
246 |
+
choice_id,
|
247 |
+
choice_text,
|
248 |
+
current_paragraph,
|
249 |
+
max_paragraphs
|
250 |
+
)
|
251 |
+
|
252 |
+
# إضافة رسالة المستخدم
|
253 |
+
messages.append({"role": "user", "content": continuation_prompt})
|
254 |
+
|
255 |
+
# استدعاء DeepSeek API
|
256 |
+
response_text = await generate_response(messages)
|
257 |
+
|
258 |
+
# تحليل الاستجابة
|
259 |
+
paragraph_text, choices, title = parse_paragraph_and_choices(response_text)
|
260 |
+
|
261 |
+
# تحديث سياق القصة
|
262 |
+
paragraphs.append(paragraph_text)
|
263 |
+
context["current_paragraph"] += 1
|
264 |
+
context["paragraphs"] = paragraphs
|
265 |
+
|
266 |
+
# تحديد ما إذا كانت القصة مكتملة
|
267 |
+
is_complete = current_paragraph >= max_paragraphs - 1
|
268 |
+
|
269 |
+
# إذا كانت القصة مكتملة، احفظ العنوان
|
270 |
+
if is_complete and title:
|
271 |
+
metadata["title"] = title
|
272 |
+
|
273 |
+
# تحديث الرسائل
|
274 |
+
messages.append({"role": "assistant", "content": response_text})
|
275 |
+
metadata["messages"] = messages
|
276 |
+
|
277 |
+
# إنشاء فقرة القصة
|
278 |
+
paragraph = StoryParagraph(
|
279 |
+
content=paragraph_text,
|
280 |
+
choices=None if is_complete else choices
|
281 |
+
)
|
282 |
+
|
283 |
+
# إنشاء استجابة القصة
|
284 |
+
story_response = StoryResponse(
|
285 |
+
story_id=story_id,
|
286 |
+
paragraph=paragraph,
|
287 |
+
is_complete=is_complete,
|
288 |
+
title=metadata["title"] if is_complete else None
|
289 |
+
)
|
290 |
+
|
291 |
+
return story_response
|
292 |
+
|
293 |
+
|
294 |
+
async def continue_story_with_text(story_id: str, custom_text: str) -> StoryResponse:
|
295 |
+
"""
|
296 |
+
متابعة القصة بناءً على النص المخصص الذي أدخله المستخدم
|
297 |
+
"""
|
298 |
+
print(f"continue_story_with_text called with story_id={story_id}, custom_text={custom_text}")
|
299 |
+
|
300 |
+
if story_id not in stories_context or story_id not in stories_metadata:
|
301 |
+
print(f"Story ID {story_id} not found in context or metadata")
|
302 |
+
raise ValueError("معرف القصة غير صالح")
|
303 |
+
|
304 |
+
context = stories_context[story_id]
|
305 |
+
metadata = stories_metadata[story_id]
|
306 |
+
|
307 |
+
# الحصول على سياق القصة
|
308 |
+
paragraphs = context["paragraphs"]
|
309 |
+
current_paragraph = context["current_paragraph"]
|
310 |
+
max_paragraphs = metadata["max_paragraphs"]
|
311 |
+
|
312 |
+
print(f"Story context: current_paragraph={current_paragraph}, max_paragraphs={max_paragraphs}")
|
313 |
+
|
314 |
+
# الحصول على الرسائل السابقة
|
315 |
+
messages = metadata["messages"]
|
316 |
+
|
317 |
+
# إنشاء برومبت المتابعة بالنص المخصص
|
318 |
+
story_context_text = "\n".join(paragraphs)
|
319 |
+
|
320 |
+
# Create a prompt for continuing with custom text
|
321 |
+
custom_prompt = f"""
|
322 |
+
لقد وصلنا إلى هذه النقطة في القصة:
|
323 |
+
|
324 |
+
{story_context_text}
|
325 |
+
|
326 |
+
المستخدم اختار أن يكتب رداً مخصصاً بدلاً من اختيار أحد الخيارات المقدمة. الرد المخصص للمستخدم هو:
|
327 |
+
|
328 |
+
"{custom_text}"
|
329 |
+
|
330 |
+
بناءً على هذا المدخل من المستخدم، استمر في القصة واكتب فقرة جديدة تأخذ بعين الاعتبار ما كتبه المستخدم.
|
331 |
+
ثم قدم 3 خيارات جديدة للمستخدم ليختار منها للاستمرار في القصة.
|
332 |
+
|
333 |
+
تذكر أن القصة الآن في:
|
334 |
+
- الفقرة رقم: {current_paragraph} من {max_paragraphs}
|
335 |
+
- إذا كانت هذه الفقرة الأخيرة أو قبل الأخيرة، قم بختم القصة بشكل مناسب.
|
336 |
+
|
337 |
+
يجب أن يكون تنسيق ردك كما يلي:
|
338 |
+
|
339 |
+
الفقرة: [نص الفقرة الجديدة من القصة]
|
340 |
+
|
341 |
+
الخيارات:
|
342 |
+
1. [الخيار الأول]
|
343 |
+
2. [الخيار الثاني]
|
344 |
+
3. [الخيار الثالث]
|
345 |
+
|
346 |
+
إذا كانت هذه الفقرة الأخيرة، أضف عنواناً للقصة:
|
347 |
+
|
348 |
+
العنوان: [عنوان مناسب للقصة كاملة]
|
349 |
+
"""
|
350 |
+
|
351 |
+
print(f"Custom prompt created, length: {len(custom_prompt)}")
|
352 |
+
|
353 |
+
# إضافة رسالة المستخدم
|
354 |
+
messages.append({"role": "user", "content": custom_prompt})
|
355 |
+
|
356 |
+
# استدعاء DeepSeek API
|
357 |
+
print("Calling DeepSeek API...")
|
358 |
+
response_text = await generate_response(messages)
|
359 |
+
print(f"DeepSeek API response received, length: {len(response_text)}")
|
360 |
+
|
361 |
+
# تحليل الاستجابة
|
362 |
+
paragraph_text, choices, title = parse_paragraph_and_choices(response_text)
|
363 |
+
print(f"Parsed response: paragraph length={len(paragraph_text)}, choices={choices is not None}, title={title is not None}")
|
364 |
+
|
365 |
+
# تحديث سياق القصة
|
366 |
+
paragraphs.append(paragraph_text)
|
367 |
+
context["current_paragraph"] += 1
|
368 |
+
context["paragraphs"] = paragraphs
|
369 |
+
|
370 |
+
# تحديد ما إذا كانت القصة مكتملة
|
371 |
+
is_complete = current_paragraph >= max_paragraphs - 1
|
372 |
+
print(f"Is story complete: {is_complete}")
|
373 |
+
|
374 |
+
# إذا كانت القصة مكتملة، احفظ العنوان
|
375 |
+
if is_complete and title:
|
376 |
+
metadata["title"] = title
|
377 |
+
|
378 |
+
# تحديث الرسائل
|
379 |
+
messages.append({"role": "assistant", "content": response_text})
|
380 |
+
metadata["messages"] = messages
|
381 |
+
|
382 |
+
# إنشاء فقرة القصة
|
383 |
+
paragraph = StoryParagraph(
|
384 |
+
content=paragraph_text,
|
385 |
+
choices=None if is_complete else choices
|
386 |
+
)
|
387 |
+
|
388 |
+
# إنشاء استجابة القصة
|
389 |
+
story_response = StoryResponse(
|
390 |
+
story_id=story_id,
|
391 |
+
paragraph=paragraph,
|
392 |
+
is_complete=is_complete,
|
393 |
+
title=metadata["title"] if is_complete else None
|
394 |
+
)
|
395 |
+
|
396 |
+
print(f"Story response created successfully")
|
397 |
+
return story_response
|
398 |
+
|
399 |
+
|
400 |
+
async def get_complete_story(story_id: str) -> str:
|
401 |
+
"""
|
402 |
+
الحصول على النص الكامل للقصة
|
403 |
+
"""
|
404 |
+
if story_id not in stories_context:
|
405 |
+
raise ValueError("معرف القصة غير صالح")
|
406 |
+
|
407 |
+
context = stories_context[story_id]
|
408 |
+
metadata = stories_metadata.get(story_id, {})
|
409 |
+
|
410 |
+
story_text = "\n\n".join(context["paragraphs"])
|
411 |
+
|
412 |
+
if metadata.get("title"):
|
413 |
+
# إضافة العنوان بصيغة صديقة لخدمة تحويل النص إلى كلام
|
414 |
+
story_text = f"قصة بعنوان {metadata['title']}.\n\n{story_text}"
|
415 |
+
|
416 |
+
return story_text
|
417 |
+
|
418 |
+
|
419 |
+
async def generate_title_if_missing(story_id: str) -> str:
|
420 |
+
"""
|
421 |
+
توليد عنوان للقصة إذا لم يكن موجوداً
|
422 |
+
"""
|
423 |
+
if story_id not in stories_metadata:
|
424 |
+
raise ValueError("معرف القصة غير صالح")
|
425 |
+
|
426 |
+
metadata = stories_metadata[story_id]
|
427 |
+
|
428 |
+
# إذا كان العنوان موجوداً بالفعل، أعده
|
429 |
+
if metadata.get("title"):
|
430 |
+
return metadata["title"]
|
431 |
+
|
432 |
+
# توليد العنوان
|
433 |
+
complete_story = await get_complete_story(story_id)
|
434 |
+
|
435 |
+
system_prompt = get_system_prompt()
|
436 |
+
title_prompt = create_title_prompt(complete_story)
|
437 |
+
|
438 |
+
messages = [
|
439 |
+
{"role": "system", "content": system_prompt},
|
440 |
+
{"role": "user", "content": title_prompt}
|
441 |
+
]
|
442 |
+
|
443 |
+
title = await generate_response(messages)
|
444 |
+
|
445 |
+
# تنظيف العنوان
|
446 |
+
title = title.strip().replace("العنوان:", "").strip()
|
447 |
+
|
448 |
+
# حفظ العنوان
|
449 |
+
metadata["title"] = title
|
450 |
+
|
451 |
+
return title
|
452 |
+
|
453 |
+
async def edit_story(story_id: str, edit_instructions: str) -> dict:
|
454 |
+
"""
|
455 |
+
تعديل القصة بناءً على تعليمات المستخدم
|
456 |
+
"""
|
457 |
+
if story_id not in stories_context or story_id not in stories_metadata:
|
458 |
+
raise ValueError("معرف القصة غير صالح")
|
459 |
+
|
460 |
+
# الحصول على نص القصة الكامل
|
461 |
+
paragraphs = stories_context[story_id]["paragraphs"]
|
462 |
+
complete_story = "\n".join(paragraphs)
|
463 |
+
|
464 |
+
# إنشاء برومبت للتعديل
|
465 |
+
system_prompt = """أنت مساعد ذكي متخصص في تحرير وتعديل القصص العربية. مهمتك هي تعديل القصة بناءً على تعليمات المستخدم مع الحفاظ على الأسلوب والنبرة الأصلية.
|
466 |
+
قم بإرجاع القصة المعدلة بالكامل وليس فقط الأجزاء المعدلة. تأكد من تقسيم القصة إلى فقرات واضحة كما في النص الأصلي.
|
467 |
+
إذا تطلبت التعليمات تغيير العنوان، قم بإضافة سطر "العنوان الجديد: <العنوان المعدل>" في بداية استجابتك."""
|
468 |
+
|
469 |
+
user_prompt = f"""فيما يلي قصة كاملة:
|
470 |
+
|
471 |
+
{complete_story}
|
472 |
+
|
473 |
+
تعليمات التعديل:
|
474 |
+
{edit_instructions}
|
475 |
+
|
476 |
+
قم بتعديل القصة وفقًا للتعليمات المذكورة أعلاه وأرجع القصة المعدلة بالكامل مقسمة إلى فقرات.
|
477 |
+
"""
|
478 |
+
|
479 |
+
messages = [
|
480 |
+
{"role": "system", "content": system_prompt},
|
481 |
+
{"role": "user", "content": user_prompt}
|
482 |
+
]
|
483 |
+
|
484 |
+
# استدعاء DeepSeek API
|
485 |
+
response_text = await generate_response(messages)
|
486 |
+
|
487 |
+
# تحليل النص واستخراج العنوان الجديد إذا وجد
|
488 |
+
new_title = None
|
489 |
+
title_match = re.search(r"العنوان الجديد:\s*(.*?)(?:\n|$)", response_text, re.IGNORECASE)
|
490 |
+
if title_match:
|
491 |
+
new_title = title_match.group(1).strip()
|
492 |
+
response_text = re.sub(r"العنوان الجديد:\s*.*?(?:\n|$)", "", response_text, flags=re.IGNORECASE)
|
493 |
+
|
494 |
+
# تقسيم النص المعدل إلى فقرات
|
495 |
+
edited_paragraphs = [p.strip() for p in response_text.split("\n\n") if p.strip()]
|
496 |
+
|
497 |
+
# تحديث القصة في التخزين
|
498 |
+
stories_context[story_id]["paragraphs"] = edited_paragraphs
|
499 |
+
|
500 |
+
# تحديث العنوان إذا كان هناك عنوان جديد
|
501 |
+
if new_title:
|
502 |
+
stories_metadata[story_id]["title"] = new_title
|
503 |
+
|
504 |
+
# إرجاع البيانات المعدلة
|
505 |
+
return {
|
506 |
+
"paragraphs": edited_paragraphs,
|
507 |
+
"title": new_title or stories_metadata[story_id].get("title")
|
508 |
+
}
|
app.py
ADDED
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Entry point for Hugging Face Spaces deployment of Rawi API
|
3 |
+
"""
|
4 |
+
|
5 |
+
from main import app
|
6 |
+
|
7 |
+
# This file serves as the entry point for Hugging Face Spaces
|
8 |
+
# It imports and exposes the FastAPI app object from main.py
|
main.py
ADDED
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
راوي (Rawi) - Arabic AI Storytelling Platform
|
3 |
+
Main application file for FastAPI server
|
4 |
+
"""
|
5 |
+
|
6 |
+
import os
|
7 |
+
import sys
|
8 |
+
import uvicorn
|
9 |
+
import logging
|
10 |
+
from fastapi import FastAPI, HTTPException
|
11 |
+
from fastapi.middleware.cors import CORSMiddleware
|
12 |
+
from dotenv import load_dotenv
|
13 |
+
from fastapi.responses import FileResponse
|
14 |
+
from fastapi.staticfiles import StaticFiles
|
15 |
+
from pathlib import Path
|
16 |
+
|
17 |
+
# Add the current directory to Python path to enable imports
|
18 |
+
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
19 |
+
|
20 |
+
# Import application routers
|
21 |
+
from routers import story
|
22 |
+
|
23 |
+
# ======== Configure Logging ========
|
24 |
+
logging.basicConfig(level=logging.INFO)
|
25 |
+
logger = logging.getLogger(__name__)
|
26 |
+
|
27 |
+
# ======== Load Environment Variables ========
|
28 |
+
load_dotenv()
|
29 |
+
|
30 |
+
# ======== Server Configuration ========
|
31 |
+
HOST = os.getenv("BACKEND_HOST", "0.0.0.0")
|
32 |
+
PORT = int(os.getenv("BACKEND_PORT", "7860")) # Updated default port for Hugging Face
|
33 |
+
BASE_URL = os.getenv("BASE_URL", f"http://{HOST}:{PORT}")
|
34 |
+
AUDIO_STORAGE_PATH = os.path.abspath(os.getenv("AUDIO_STORAGE_PATH", "./audio_files"))
|
35 |
+
|
36 |
+
# DeepSeek API Key check
|
37 |
+
DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY")
|
38 |
+
|
39 |
+
# Log server settings
|
40 |
+
logger.info(f"Server settings:")
|
41 |
+
logger.info(f"Host: {HOST}")
|
42 |
+
logger.info(f"Port: {PORT}")
|
43 |
+
logger.info(f"Base URL: {BASE_URL}")
|
44 |
+
logger.info(f"Audio storage path: {AUDIO_STORAGE_PATH}")
|
45 |
+
logger.info(f"DeepSeek API Key set: {bool(DEEPSEEK_API_KEY)}")
|
46 |
+
|
47 |
+
# ======== Ensure Audio Storage Directory Exists ========
|
48 |
+
try:
|
49 |
+
Path(AUDIO_STORAGE_PATH).mkdir(parents=True, exist_ok=True)
|
50 |
+
logger.info(f"Created/verified audio storage directory at: {AUDIO_STORAGE_PATH}")
|
51 |
+
except Exception as e:
|
52 |
+
logger.error(f"Error creating audio storage directory: {str(e)}")
|
53 |
+
raise
|
54 |
+
|
55 |
+
# ======== Initialize FastAPI Application ========
|
56 |
+
app = FastAPI(
|
57 |
+
title="راوي API",
|
58 |
+
description="واجهة برمجة تطبيقات لمنصة راوي لتوليد القصص العربية باستخدام الذكاء الاصطناعي",
|
59 |
+
version="1.0.0"
|
60 |
+
)
|
61 |
+
|
62 |
+
# ======== Configure CORS ========
|
63 |
+
app.add_middleware(
|
64 |
+
CORSMiddleware,
|
65 |
+
allow_origins=["*"], # In production, specify allowed origins instead of "*"
|
66 |
+
allow_credentials=True,
|
67 |
+
allow_methods=["*"],
|
68 |
+
allow_headers=["*"],
|
69 |
+
)
|
70 |
+
|
71 |
+
# ======== Mount Static Files ========
|
72 |
+
app.mount("/audio", StaticFiles(directory=AUDIO_STORAGE_PATH), name="audio")
|
73 |
+
|
74 |
+
# ======== Register Routers ========
|
75 |
+
app.include_router(story.router, prefix="/api/stories", tags=["قصص"])
|
76 |
+
|
77 |
+
# ======== API Endpoints ========
|
78 |
+
@app.get("/", tags=["الرئيسية"])
|
79 |
+
async def root():
|
80 |
+
"""
|
81 |
+
Root endpoint for the API
|
82 |
+
"""
|
83 |
+
return {
|
84 |
+
"message": "مرحباً بك في واجهة برمجة تطبيقات راوي",
|
85 |
+
"docs": f"{BASE_URL}/docs"
|
86 |
+
}
|
87 |
+
|
88 |
+
@app.get("/health", tags=["الحالة"])
|
89 |
+
async def health_check():
|
90 |
+
"""
|
91 |
+
Health check endpoint to verify API configuration
|
92 |
+
"""
|
93 |
+
health_status = {
|
94 |
+
"status": "healthy",
|
95 |
+
"deepseek_api": bool(DEEPSEEK_API_KEY),
|
96 |
+
"audio_storage": os.path.exists(AUDIO_STORAGE_PATH),
|
97 |
+
}
|
98 |
+
|
99 |
+
if not DEEPSEEK_API_KEY:
|
100 |
+
health_status["status"] = "degraded"
|
101 |
+
health_status["message"] = "DeepSeek API key is not set. Story generation will not work."
|
102 |
+
|
103 |
+
return health_status
|
104 |
+
|
105 |
+
# ======== Run Application ========
|
106 |
+
if __name__ == "__main__":
|
107 |
+
uvicorn.run(
|
108 |
+
"main:app",
|
109 |
+
host=HOST,
|
110 |
+
port=PORT,
|
111 |
+
reload=True # Disable in production for better performance
|
112 |
+
)
|
models.py
ADDED
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
راوي (Rawi) - Arabic AI Storytelling Platform
|
3 |
+
Data models for API requests and responses
|
4 |
+
"""
|
5 |
+
|
6 |
+
from enum import Enum
|
7 |
+
from typing import List, Optional
|
8 |
+
from pydantic import BaseModel, Field
|
9 |
+
|
10 |
+
|
11 |
+
# ======== Enum Types ========
|
12 |
+
|
13 |
+
class StoryLength(str, Enum):
|
14 |
+
"""Story length options"""
|
15 |
+
SHORT = "short"
|
16 |
+
MEDIUM = "medium"
|
17 |
+
LONG = "long"
|
18 |
+
|
19 |
+
|
20 |
+
class StoryType(str, Enum):
|
21 |
+
"""Story genre options in Arabic"""
|
22 |
+
ROMANCE = "رومانسي"
|
23 |
+
HORROR = "رعب"
|
24 |
+
COMEDY = "كوميدي"
|
25 |
+
ACTION = "أكشن"
|
26 |
+
ADVENTURE = "مغامرة"
|
27 |
+
DRAMA = "دراما"
|
28 |
+
FANTASY = "خيال"
|
29 |
+
HISTORICAL = "تاريخي"
|
30 |
+
MYSTERY = "غموض"
|
31 |
+
NONE = "لا"
|
32 |
+
|
33 |
+
|
34 |
+
class CharacterGender(str, Enum):
|
35 |
+
"""Character gender options in Arabic"""
|
36 |
+
MALE = "ذكر"
|
37 |
+
FEMALE = "أنثى"
|
38 |
+
|
39 |
+
|
40 |
+
# ======== Request Models ========
|
41 |
+
|
42 |
+
class Character(BaseModel):
|
43 |
+
"""Character information for story generation"""
|
44 |
+
name: str = Field(..., description="اسم الشخصية")
|
45 |
+
gender: CharacterGender = Field(..., description="جنس الشخصية")
|
46 |
+
description: str = Field(..., description="وصف الشخصية")
|
47 |
+
|
48 |
+
|
49 |
+
class StoryConfig(BaseModel):
|
50 |
+
"""Configuration for initial story generation"""
|
51 |
+
length: StoryLength = Field(..., description="طول القصة")
|
52 |
+
primary_type: StoryType = Field(..., description="النوع الأساسي للقصة")
|
53 |
+
secondary_type: StoryType = Field(default=StoryType.NONE, description="النوع الثانوي للقصة")
|
54 |
+
characters: List[Character] = Field(default=[], description="الشخصيات في القصة")
|
55 |
+
|
56 |
+
|
57 |
+
class ChoiceRequest(BaseModel):
|
58 |
+
"""Request to continue a story with a choice or custom text"""
|
59 |
+
story_id: str = Field(..., description="معرف القصة")
|
60 |
+
choice_id: Optional[int] = Field(None, description="معرف الاختيار الذي تم اختياره")
|
61 |
+
custom_text: Optional[str] = Field(None, description="النص المخصص الذي أدخله المستخدم")
|
62 |
+
|
63 |
+
|
64 |
+
class TTSRequest(BaseModel):
|
65 |
+
"""Request to generate text-to-speech for a story"""
|
66 |
+
story_id: str = Field(..., description="معرف القصة")
|
67 |
+
speed: float = Field(1.0, description="سرعة الصوت (0.5 للبطيء، 1.0 للعادي، 2.0 للسريع)", ge=0.5, le=2.0)
|
68 |
+
|
69 |
+
|
70 |
+
class EditRequest(BaseModel):
|
71 |
+
"""Request to edit a story based on user instructions"""
|
72 |
+
story_id: str = Field(..., description="معرف القصة")
|
73 |
+
edit_instructions: str = Field(..., description="تعليمات لتعديل القصة")
|
74 |
+
|
75 |
+
|
76 |
+
# ======== Response Models ========
|
77 |
+
|
78 |
+
class StoryChoice(BaseModel):
|
79 |
+
"""A choice option presented to the user"""
|
80 |
+
id: int = Field(..., description="معرف الاختيار")
|
81 |
+
text: str = Field(..., description="نص الاختيار")
|
82 |
+
|
83 |
+
|
84 |
+
class StoryParagraph(BaseModel):
|
85 |
+
"""A paragraph of story content with optional choices"""
|
86 |
+
content: str = Field(..., description="محتوى الفقرة")
|
87 |
+
choices: Optional[List[StoryChoice]] = Field(default=None, description="الاختيارات المتاحة بعد هذه الفقرة")
|
88 |
+
|
89 |
+
|
90 |
+
class StoryResponse(BaseModel):
|
91 |
+
"""Response containing story content and metadata"""
|
92 |
+
story_id: str = Field(..., description="معرف القصة")
|
93 |
+
paragraph: StoryParagraph = Field(..., description="فقرة من القصة")
|
94 |
+
is_complete: bool = Field(default=False, description="هل القصة اكتملت؟")
|
95 |
+
title: Optional[str] = Field(default=None, description="عنوان القصة (يتم إضافته عند اكتمال القصة)")
|
96 |
+
|
97 |
+
|
98 |
+
class TTSResponse(BaseModel):
|
99 |
+
"""Response containing URL to audio file"""
|
100 |
+
audio_url: str = Field(..., description="رابط ملف الصوت")
|
101 |
+
|
102 |
+
|
103 |
+
class EditResponse(BaseModel):
|
104 |
+
"""Response containing edited story content"""
|
105 |
+
success: bool = Field(default=True, description="نجاح عملية التعديل")
|
106 |
+
paragraphs: List[str] = Field(..., description="فقرات القصة المعدلة")
|
107 |
+
title: Optional[str] = Field(default=None, description="عنوان القصة المعدل (اختياري)")
|
prompts.py
ADDED
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from typing import List, Dict, Any
|
2 |
+
from models import StoryLength, StoryType, Character, StoryConfig
|
3 |
+
|
4 |
+
|
5 |
+
def get_system_prompt() -> str:
|
6 |
+
"""
|
7 |
+
الحصول على برومبت النظام الأساسي الذي يحدد سلوك نموذج الذكاء الاصطناعي
|
8 |
+
"""
|
9 |
+
return """
|
10 |
+
You are a professional and creative Arabic story writer. Your task is to write original, engaging, and cohesive Arabic stories.
|
11 |
+
|
12 |
+
Adhere to the following standards in all the stories you write:
|
13 |
+
|
14 |
+
1. Use correct and understandable classical Arabic language, free from grammatical and spelling errors.
|
15 |
+
2. Build a coherent and logical story that follows good dramatic structure principles (beginning, rising action, climax, resolution).
|
16 |
+
3. Adhere to Arabic and Islamic values and ethics in the story content.
|
17 |
+
4. Avoid inappropriate content or anything that violates public taste or religious values.
|
18 |
+
5. Provide detailed sensory descriptions of characters, places, and events to make the story vivid and engaging.
|
19 |
+
6. Make dialogue realistic and natural, appropriate to the story's characters and environment.
|
20 |
+
7. Maintain consistency in character traits and behaviors throughout the story.
|
21 |
+
8. Include positive values and useful lessons in an indirect way.
|
22 |
+
9. Use diverse narrative techniques: description, dialogue, narration, internal monologue.
|
23 |
+
10. Create a clear conflict that drives the story events and maintains reader interest.
|
24 |
+
|
25 |
+
Each time you are asked to write a new paragraph of the story, you must:
|
26 |
+
- Write a coherent and engaging narrative paragraph of 4-6 lines in Arabic.
|
27 |
+
- Provide 3 distinctive and interesting options to develop the story's path.
|
28 |
+
|
29 |
+
Important rules for options:
|
30 |
+
1. Make options very practical and short (3-5 words only) in Arabic.
|
31 |
+
2. ALWAYS start each option with the character's name followed by the action verb.
|
32 |
+
3. Use clear format: "[Character name] + verb", for example: "أحمد يتصل بالشرطة" (Ahmed calls the police), "سارة تهرب من المكان" (Sarah escapes from the place).
|
33 |
+
4. Make it absolutely clear WHO is performing the action in each option.
|
34 |
+
5. Don't explain what will happen after the choice, just mention the direct action.
|
35 |
+
6. Ensure each option will lead to a completely different path in the story.
|
36 |
+
7. Make options logical and appropriate to the current situation in the story.
|
37 |
+
|
38 |
+
Result of user choice:
|
39 |
+
1. Do not summarize the user's chosen option at the beginning of the next paragraph.
|
40 |
+
2. Start directly with the reactions and consequences resulting from the user's choice.
|
41 |
+
3. Present surprising and unexpected developments resulting from the choice.
|
42 |
+
4. Maintain story consistency despite the change in path.
|
43 |
+
|
44 |
+
When the story is complete, choose an engaging and deep title that reflects the essence and content of the story.
|
45 |
+
"""
|
46 |
+
|
47 |
+
|
48 |
+
def format_characters_info(characters: List[Character]) -> str:
|
49 |
+
"""
|
50 |
+
تنسيق معلومات الشخصيات لتضمينها في البرومبت
|
51 |
+
"""
|
52 |
+
if not characters:
|
53 |
+
return "لا توجد شخصيات محددة، يمكنك إنشاء شخصيات مناسبة للقصة."
|
54 |
+
|
55 |
+
characters_info = "معلومات الشخصيات:\n"
|
56 |
+
for i, character in enumerate(characters, 1):
|
57 |
+
gender_text = "ذكر" if character.gender.value == "ذكر" else "أنثى"
|
58 |
+
characters_info += f"{i}. الشخصية: {character.name}، الجنس: {gender_text}، الوصف: {character.description}\n"
|
59 |
+
|
60 |
+
return characters_info
|
61 |
+
|
62 |
+
|
63 |
+
def get_story_length_instructions(length: StoryLength) -> Dict[str, Any]:
|
64 |
+
"""
|
65 |
+
الحصول على تعليمات طول القصة وعدد الفقرات
|
66 |
+
"""
|
67 |
+
length_mapping = {
|
68 |
+
StoryLength.SHORT: {"paragraphs": 3, "description": "قصة قصيرة تتكون من 3 فقرات"},
|
69 |
+
StoryLength.MEDIUM: {"paragraphs": 5, "description": "قصة متوسطة الطول تتكون من 5 فقرات"},
|
70 |
+
StoryLength.LONG: {"paragraphs": 7, "description": "قصة طويلة تتكون من 7 فقرات"}
|
71 |
+
}
|
72 |
+
|
73 |
+
return length_mapping.get(length, length_mapping[StoryLength.MEDIUM])
|
74 |
+
|
75 |
+
|
76 |
+
def get_story_type_description(primary_type: StoryType, secondary_type: StoryType) -> str:
|
77 |
+
"""
|
78 |
+
الحصول على وصف نوع القصة
|
79 |
+
"""
|
80 |
+
if secondary_type == StoryType.NONE:
|
81 |
+
return f"قصة من نوع {primary_type.value}"
|
82 |
+
else:
|
83 |
+
return f"قصة تجمع بين نوعي {primary_type.value} و{secondary_type.value}"
|
84 |
+
|
85 |
+
|
86 |
+
def create_story_init_prompt(config: StoryConfig) -> str:
|
87 |
+
"""
|
88 |
+
إنشاء البرومبت الأولي لبدء القصة
|
89 |
+
"""
|
90 |
+
length_info = get_story_length_instructions(config.length)
|
91 |
+
story_type = get_story_type_description(config.primary_type, config.secondary_type)
|
92 |
+
characters_info = format_characters_info(config.characters)
|
93 |
+
|
94 |
+
prompt = f"""
|
95 |
+
Please write {length_info['description']} of {story_type}.
|
96 |
+
|
97 |
+
{characters_info}
|
98 |
+
|
99 |
+
Required from you:
|
100 |
+
1. Write the first paragraph of the story (4-6 lines) in Arabic.
|
101 |
+
2. Start the story with a strong and engaging beginning that captivates the reader from the first line.
|
102 |
+
3. Present the characters and setting (place and time) clearly and interestingly.
|
103 |
+
4. Establish a conflict, problem, or situation that drives the story events.
|
104 |
+
5. Present 3 short, exciting, and logical options for actions the protagonist can take.
|
105 |
+
6. Make the options very short (3-5 words only) and practical and direct.
|
106 |
+
7. ALWAYS include the character's name in each option before the action verb.
|
107 |
+
8. Format: "[Character name] + verb", like: "أحمد يتصل بالشرطة", "سارة تهرب من المكان".
|
108 |
+
|
109 |
+
Present the first paragraph and options in the following format:
|
110 |
+
|
111 |
+
الفقرة:
|
112 |
+
[Write the first paragraph of the story here in Arabic]
|
113 |
+
|
114 |
+
الخيارات:
|
115 |
+
1. [Character name + action verb in Arabic, 3-5 words total]
|
116 |
+
2. [Character name + different action verb in Arabic, 3-5 words total]
|
117 |
+
3. [Character name + another different action verb in Arabic, 3-5 words total]
|
118 |
+
"""
|
119 |
+
|
120 |
+
return prompt
|
121 |
+
|
122 |
+
|
123 |
+
def create_continuation_prompt(story_context: str, choice_id: int, choice_text: str, current_paragraph: int, max_paragraphs: int) -> str:
|
124 |
+
"""
|
125 |
+
إنشاء برومبت لاستكمال القصة بناءً على اختيار المستخدم
|
126 |
+
"""
|
127 |
+
is_final = current_paragraph >= max_paragraphs - 1
|
128 |
+
|
129 |
+
prompt = f"""
|
130 |
+
Story context so far:
|
131 |
+
{story_context}
|
132 |
+
|
133 |
+
The user chose path number {choice_id}: {choice_text}
|
134 |
+
|
135 |
+
Required from you:
|
136 |
+
1. Continue writing the story with a new paragraph (4-6 lines) in Arabic that directly follows the choice made by the user.
|
137 |
+
2. Do not summarize the choice that the user made; instead, start directly with the events that result from this choice.
|
138 |
+
3. Add unexpected and exciting developments to engage the reader.
|
139 |
+
4. Maintain consistency in the story's characters and world.
|
140 |
+
"""
|
141 |
+
|
142 |
+
if is_final:
|
143 |
+
prompt += """
|
144 |
+
5. This is the final paragraph of the story, so end the story in a logical and satisfying way that closes all open paths.
|
145 |
+
6. Suggest an appropriate and deep title for the complete story.
|
146 |
+
|
147 |
+
Present the final paragraph and title in the following format:
|
148 |
+
|
149 |
+
الفقرة:
|
150 |
+
[Write the final paragraph of the story here in Arabic]
|
151 |
+
|
152 |
+
العنوان:
|
153 |
+
[Write the suggested title for the story here in Arabic]
|
154 |
+
"""
|
155 |
+
else:
|
156 |
+
prompt += """
|
157 |
+
5. Present 3 short, logical, and practical options for continuing the story.
|
158 |
+
6. Make the options very short (3-5 words only) in Arabic.
|
159 |
+
7. ALWAYS include the character's name in each option before the action verb.
|
160 |
+
8. Format: "[Character name] + verb", like: "أحمد يتصل بالشرطة", "سارة تهرب من المكان".
|
161 |
+
9. Make it absolutely clear WHO is performing the action in each option.
|
162 |
+
10. Ensure each option will lead to a completely different path in the story.
|
163 |
+
|
164 |
+
Present the next paragraph and options in the following format:
|
165 |
+
|
166 |
+
الفقرة:
|
167 |
+
[Write the next paragraph of the story here in Arabic]
|
168 |
+
|
169 |
+
الخيارات:
|
170 |
+
1. [Character name + action verb in Arabic, 3-5 words total]
|
171 |
+
2. [Character name + different action verb in Arabic, 3-5 words total]
|
172 |
+
3. [Character name + another different action verb in Arabic, 3-5 words total]
|
173 |
+
"""
|
174 |
+
|
175 |
+
return prompt
|
176 |
+
|
177 |
+
|
178 |
+
def create_title_prompt(complete_story: str) -> str:
|
179 |
+
"""
|
180 |
+
إنشاء برومبت لتوليد عنوان مناسب للقصة المكتملة
|
181 |
+
"""
|
182 |
+
return f"""
|
183 |
+
Here is a complete story:
|
184 |
+
|
185 |
+
{complete_story}
|
186 |
+
|
187 |
+
Suggest an appropriate and engaging title for this story that reflects its essence and content.
|
188 |
+
Provide only the title without any additional explanation and without any story Characters names in Arabic.
|
189 |
+
"""
|
requirements.txt
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
fastapi==0.110.0
|
2 |
+
uvicorn==0.28.0
|
3 |
+
pydantic==2.6.1
|
4 |
+
python-dotenv==1.0.1
|
5 |
+
httpx==0.26.0
|
6 |
+
gTTS==2.5.0
|
7 |
+
python-multipart==0.0.9
|
8 |
+
starlette==0.36.3
|
9 |
+
aiofiles==23.2.1
|
tts_service.py
ADDED
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import asyncio
|
3 |
+
import re
|
4 |
+
from gtts import gTTS
|
5 |
+
import uuid
|
6 |
+
import json
|
7 |
+
from pathlib import Path
|
8 |
+
from dotenv import load_dotenv
|
9 |
+
|
10 |
+
from ai_service import get_complete_story, generate_title_if_missing
|
11 |
+
|
12 |
+
# تحميل المتغيرات البيئية
|
13 |
+
load_dotenv()
|
14 |
+
|
15 |
+
# الحصول على مسار تخزين ملفات الصوت
|
16 |
+
AUDIO_STORAGE_PATH = os.path.abspath(os.getenv("AUDIO_STORAGE_PATH", "./audio_files"))
|
17 |
+
BASE_URL = os.getenv("BASE_URL", "http://localhost:8000")
|
18 |
+
|
19 |
+
# قاموس لتخزين معرفات الملفات الصوتية للقصص
|
20 |
+
story_audio_files = {}
|
21 |
+
|
22 |
+
def clean_text_for_tts(text: str) -> str:
|
23 |
+
"""
|
24 |
+
تنظيف النص من الرموز التي قد تؤثر على جودة القراءة الصوتية
|
25 |
+
"""
|
26 |
+
# استبدال علامات الترقيم التي قد تسبب مشاكل بمسافات أو استبعادها
|
27 |
+
text = re.sub(r'!', ' ', text) # استبدال علامة التعجب بمسافة
|
28 |
+
text = re.sub(r'\?', ' ', text) # استبدال علامة الاستفهام بمسافة
|
29 |
+
text = re.sub(r'[،,]', ' ', text) # استبدال الفواصل بمسافات
|
30 |
+
text = re.sub(r';', ' ', text) # استبدال الفاصلة المنقوطة بمسافة
|
31 |
+
text = re.sub(r':', ' ', text) # استبدال النقطتين بمسافة
|
32 |
+
text = re.sub(r'#', ' ', text) # استبدال علامة الهاشتاغ بمسافة
|
33 |
+
text = re.sub(r'@', ' ', text) # استبدال علامة الإيميل بمسافة
|
34 |
+
text = re.sub(r'_', ' ', text) # استبدال الشرطة السفلية بمسافة
|
35 |
+
text = re.sub(r'\*', ' ', text) # استبدال علامة النجمة بمسافة
|
36 |
+
text = re.sub(r'=', ' ', text) # استبدال علامة المساواة بمسافة
|
37 |
+
text = re.sub(r'\+', ' ', text) # استبدال علامة الزائد بمسافة
|
38 |
+
text = re.sub(r'-', ' ', text) # استبدال علامة الناقص بمسافة
|
39 |
+
text = re.sub(r'/', ' ', text) # استبدال علامة القسمة بمسافة
|
40 |
+
text = re.sub(r'\\', ' ', text) # استبدال الشرطة المائلة العكسية بمسافة
|
41 |
+
text = re.sub(r'%', ' بالمئة ', text) # استبدال علامة النسبة بكلمة "بالمئة"
|
42 |
+
text = re.sub(r'&', ' و ', text) # استبدال علامة & بكلمة "و"
|
43 |
+
text = re.sub(r'\^', ' ', text) # استبدال علامة القوة بمسافة
|
44 |
+
text = re.sub(r'\$', ' ', text) # استبدال علامة الدولار بمسافة
|
45 |
+
|
46 |
+
# إزالة علامات التنصيص تماماً
|
47 |
+
text = re.sub(r'["""\'«»]', ' ', text) # إزالة كل أنواع علامات التنصيص
|
48 |
+
|
49 |
+
# إزالة الأقواس والمحتوى بداخلها
|
50 |
+
text = re.sub(r'\(.*?\)', ' ', text)
|
51 |
+
text = re.sub(r'\[.*?\]', ' ', text)
|
52 |
+
text = re.sub(r'\{.*?\}', ' ', text)
|
53 |
+
text = re.sub(r'<.*?>', ' ', text)
|
54 |
+
|
55 |
+
# تنظيف الفترات الطويلة من المسافات المتكررة الناتجة عن الإزالة
|
56 |
+
text = re.sub(r'\s+', ' ', text)
|
57 |
+
|
58 |
+
# الحفاظ على النقاط كفواصل بين الجمل مع إضافة مسافة
|
59 |
+
text = re.sub(r'\.', '. ', text)
|
60 |
+
|
61 |
+
return text.strip()
|
62 |
+
|
63 |
+
async def ensure_storage_path():
|
64 |
+
"""
|
65 |
+
التأكد من وجود مجلد لتخزين ملفات الصوت
|
66 |
+
"""
|
67 |
+
Path(AUDIO_STORAGE_PATH).mkdir(parents=True, exist_ok=True)
|
68 |
+
|
69 |
+
async def text_to_speech(text: str, filename: str) -> str:
|
70 |
+
"""
|
71 |
+
تحويل النص إلى صوت وحفظه في ملف
|
72 |
+
"""
|
73 |
+
# التأكد من وجود مجلد التخزين
|
74 |
+
await ensure_storage_path()
|
75 |
+
|
76 |
+
# مسار الملف الكامل
|
77 |
+
file_path = os.path.join(AUDIO_STORAGE_PATH, filename)
|
78 |
+
|
79 |
+
# التحقق مما إذا كان الملف موجوداً بالفعل
|
80 |
+
if os.path.exists(file_path):
|
81 |
+
return filename
|
82 |
+
|
83 |
+
# تنظيف النص من الرموز التي قد تؤثر على جودة القراءة
|
84 |
+
cleaned_text = clean_text_for_tts(text)
|
85 |
+
print(f"Original text length: {len(text)}, Cleaned text length: {len(cleaned_text)}")
|
86 |
+
|
87 |
+
# استخدام وظيفة run_in_executor لتنفيذ عملية TTS في خيط منفصل
|
88 |
+
loop = asyncio.get_event_loop()
|
89 |
+
|
90 |
+
await loop.run_in_executor(
|
91 |
+
None,
|
92 |
+
lambda: gTTS(text=cleaned_text, lang='ar', slow=False).save(file_path)
|
93 |
+
)
|
94 |
+
|
95 |
+
return filename
|
96 |
+
|
97 |
+
async def generate_audio_for_story(story_id: str, speed: float = 1.0) -> str:
|
98 |
+
"""
|
99 |
+
توليد ملف صوتي للقصة الكاملة وإرجاع معرف الملف مع معلومات السرعة
|
100 |
+
"""
|
101 |
+
# التحقق مما إذا كان هناك ملف صوتي موجود للقصة
|
102 |
+
if story_id not in story_audio_files:
|
103 |
+
# التأكد من وجود عنوان للقصة
|
104 |
+
await generate_title_if_missing(story_id)
|
105 |
+
|
106 |
+
# الحصول على نص القصة الكامل
|
107 |
+
story_text = await get_complete_story(story_id)
|
108 |
+
|
109 |
+
# إنشاء اسم فريد للملف الصوتي
|
110 |
+
filename = f"{story_id}_{uuid.uuid4().hex}.mp3"
|
111 |
+
|
112 |
+
# تحويل النص إلى صوت
|
113 |
+
await text_to_speech(story_text, filename)
|
114 |
+
|
115 |
+
# تخزين معرف الملف الصوتي للقصة
|
116 |
+
story_audio_files[story_id] = filename
|
117 |
+
|
118 |
+
# إضافة معلومات السرعة للملف (سيتم استخدامها في الواجهة الأمامية)
|
119 |
+
# وذلك حتى نتجنب الحاجة إلى معالجة ملفات الصوت مباشرةً
|
120 |
+
filename = story_audio_files[story_id]
|
121 |
+
|
122 |
+
return filename
|
123 |
+
|
124 |
+
def get_audio_url(filename: str, speed: float = 1.0) -> str:
|
125 |
+
"""
|
126 |
+
الحصول على رابط الملف الصوتي مع معلومات السرعة
|
127 |
+
"""
|
128 |
+
base_url = f"{BASE_URL}/audio/{filename}"
|
129 |
+
|
130 |
+
# إضافة معامل سرعة التشغيل كمعامل استعلام
|
131 |
+
# سيتم استخدامه في الواجهة الأمامية لضبط سرعة التشغيل
|
132 |
+
return f"{base_url}?speed={speed}"
|