Spaces:
Running
Running
import os | |
import json | |
import re | |
import time | |
import asyncio | |
import uuid | |
import functools | |
import logging | |
from typing import Dict, List, Tuple, Optional | |
import httpx | |
from dotenv import load_dotenv | |
from functools import lru_cache | |
from models import StoryConfig, StoryParagraph, StoryChoice, StoryResponse, CompleteStoryResponse | |
from prompts import ( | |
get_system_prompt, | |
create_story_init_prompt, | |
create_continuation_prompt, | |
create_title_prompt, | |
get_story_length_instructions, | |
create_complete_story_prompt | |
) | |
# تحميل المتغيرات البيئية | |
load_dotenv() | |
# الحصول على مفتاح API من المتغيرات البيئية | |
DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY") | |
DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions" | |
# تخزين سياق القصص | |
# في نظام حقيقي، يجب استخدام قاعدة بيانات بدلاً من التخزين في الذاكرة | |
stories_context = {} | |
stories_metadata = {} | |
async def generate_response(messages: List[Dict], retries=3, backoff_factor=1.5) -> str: | |
""" | |
استدعاء DeepSeek API وتوليد استجابة بناءً على سلسلة الرسائل مع محاولة إعادة المحاولة في حالة الفشل | |
Args: | |
messages: قائمة برسائل المحادثة | |
retries: عدد محاولات إعادة المحاولة في حالة فشل الاتصال (افتراضي: 3) | |
backoff_factor: معامل التأخير للمحاولات المتتالية (افتراضي: 1.5) | |
Returns: | |
str: محتوى الاستجابة من API | |
Raises: | |
Exception: في حالة فشل جميع المحاولات | |
""" | |
if not DEEPSEEK_API_KEY: | |
raise Exception("مفتاح API غير متوفر. يرجى التحقق من إعداد المتغيرات البيئية.") | |
headers = { | |
"Content-Type": "application/json", | |
"Authorization": f"Bearer {DEEPSEEK_API_KEY}" | |
} | |
payload = { | |
"model": "deepseek-chat", # استبدل باسم النموذج المناسب من DeepSeek API | |
"messages": messages, | |
"temperature": 0.7, | |
"max_tokens": 1000 | |
} | |
last_exception = None | |
# محاولات إعادة الاتصال في حالة فشل API | |
for attempt in range(retries): | |
try: | |
async with httpx.AsyncClient() as client: | |
response = await client.post( | |
DEEPSEEK_API_URL, | |
headers=headers, | |
json=payload, | |
timeout=60.0 | |
) | |
if response.status_code == 429: # Rate Limit | |
# انتظار فترة قبل المحاولة مرة أخرى | |
wait_time = backoff_factor * (2 ** attempt) | |
print(f"تجاوز حد معدل الطلبات، انتظار {wait_time} ثانية قبل المحاولة مرة أخرى.") | |
await asyncio.sleep(wait_time) | |
continue | |
if response.status_code != 200: | |
error_msg = f"فشل طلب DeepSeek API: {response.status_code} - {response.text}" | |
print(error_msg) | |
last_exception = Exception(error_msg) | |
# انتظار فترة قبل المحاولة مرة أخرى | |
wait_time = backoff_factor * (2 ** attempt) | |
await asyncio.sleep(wait_time) | |
continue | |
result = response.json() | |
if "choices" not in result or not result["choices"]: | |
raise Exception("تنسيق استجابة DeepSeek API غير صالح") | |
return result["choices"][0]["message"]["content"] | |
except httpx.HTTPError as e: | |
error_msg = f"خطأ في اتصال HTTP: {str(e)}" | |
print(error_msg) | |
last_exception = Exception(error_msg) | |
# انتظار فترة قبل المحاولة مرة أخرى | |
wait_time = backoff_factor * (2 ** attempt) | |
await asyncio.sleep(wait_time) | |
continue | |
except Exception as e: | |
error_msg = f"خطأ غير متوقع: {str(e)}" | |
print(error_msg) | |
last_exception = Exception(error_msg) | |
# انتظار فترة قبل المحاولة مرة أخرى | |
wait_time = backoff_factor * (2 ** attempt) | |
await asyncio.sleep(wait_time) | |
continue | |
# إذا وصلنا إلى هنا، فقد فشلت جميع المحاولات | |
raise last_exception or Exception("فشل الاتصال بـ DeepSeek API بعد عدة محاولات") | |
def parse_paragraph_and_choices(response_text: str) -> Tuple[str, Optional[List[StoryChoice]]]: | |
""" | |
تحليل استجابة النموذج واستخراج الفقرة والخيارات | |
""" | |
# محاولة استخراج الفقرة | |
paragraph_match = re.search(r"الفقرة:(.*?)(?:الخيارات:|العنوان:|$)", response_text, re.DOTALL) | |
paragraph = paragraph_match.group(1).strip() if paragraph_match else response_text.strip() | |
# محاولة استخراج الخيارات | |
choices = None | |
choices_match = re.search(r"الخيارات:(.*?)(?:$)", response_text, re.DOTALL) | |
if choices_match: | |
choices_text = choices_match.group(1).strip() | |
choices = [] | |
# استخراج الخيارات المرقمة | |
for i, choice_match in enumerate(re.findall(r"\d+\.\s*(.*?)(?=\d+\.|$)", choices_text, re.DOTALL), 1): | |
choice_text = choice_match.strip() | |
if choice_text: | |
choices.append(StoryChoice(id=i, text=choice_text)) | |
# إذا لم يتم العثور على خيارات بالطريقة السابقة، نحاول طريقة أخرى | |
if not choices: | |
lines = choices_text.split("\n") | |
for i, line in enumerate([l for l in lines if l.strip()], 1): | |
if i <= 3: # نقتصر على 3 خيارات | |
# تجاهل الترقيم إذا كان موجوداً | |
choice_text = re.sub(r"^\d+\.\s*", "", line).strip() | |
choices.append(StoryChoice(id=i, text=choice_text)) | |
# استخراج العنوان إذا كان موجوداً | |
title = None | |
title_match = re.search(r"العنوان:(.*?)(?:$)", response_text, re.DOTALL) | |
if title_match: | |
title = title_match.group(1).strip() | |
return paragraph, choices, title | |
async def initialize_story(config: StoryConfig) -> StoryResponse: | |
""" | |
بدء قصة جديدة بناءً على التكوين المقدم | |
""" | |
# If this is a non-interactive story, generate the whole story at once | |
if hasattr(config, 'interactive') and not config.interactive: | |
return await generate_complete_story(config) | |
# For interactive stories, continue with the normal flow | |
story_id = str(uuid.uuid4()) | |
# إنشاء سياق أولي للقصة | |
stories_context[story_id] = { | |
"paragraphs": [], | |
"current_paragraph": 0 | |
} | |
# حفظ معلومات القصة | |
length_info = get_story_length_instructions(config.length) | |
stories_metadata[story_id] = { | |
"config": config.dict(), | |
"max_paragraphs": length_info["paragraphs"], | |
"title": None | |
} | |
# إنشاء البرومبت الأولي | |
system_prompt = get_system_prompt() | |
user_prompt = create_story_init_prompt(config) | |
messages = [ | |
{"role": "system", "content": system_prompt}, | |
{"role": "user", "content": user_prompt} | |
] | |
# استدعاء DeepSeek API | |
response_text = await generate_response(messages) | |
# تحليل الاستجابة | |
paragraph_text, choices, _ = parse_paragraph_and_choices(response_text) | |
# إنشاء فقرة القصة | |
paragraph = StoryParagraph( | |
content=paragraph_text, | |
choices=choices | |
) | |
# حفظ سياق القصة | |
stories_context[story_id] = { | |
"paragraphs": [paragraph_text], | |
"current_paragraph": 1 | |
} | |
# حفظ معلومات القصة | |
length_info = get_story_length_instructions(config.length) | |
stories_metadata[story_id] = { | |
"config": config.dict(), | |
"max_paragraphs": length_info["paragraphs"], | |
"messages": messages + [{"role": "assistant", "content": response_text}], | |
"title": None | |
} | |
# إنشاء استجابة القصة | |
story_response = StoryResponse( | |
story_id=story_id, | |
paragraph=paragraph, | |
is_complete=False | |
) | |
return story_response | |
async def continue_story(story_id: str, choice_id: int) -> StoryResponse: | |
""" | |
متابعة القصة بناءً على اختيار المستخدم | |
""" | |
if story_id not in stories_context or story_id not in stories_metadata: | |
raise ValueError("معرف القصة غير صالح") | |
context = stories_context[story_id] | |
metadata = stories_metadata[story_id] | |
# الحصول على سياق القصة والاختيار | |
paragraphs = context["paragraphs"] | |
current_paragraph = context["current_paragraph"] | |
max_paragraphs = metadata["max_paragraphs"] | |
# الحصول على الرسائل السابقة | |
messages = metadata["messages"] | |
# الحصول على نص الاختيار | |
last_message = messages[-1]["content"] | |
_, choices, _ = parse_paragraph_and_choices(last_message) | |
if not choices or choice_id < 1 or choice_id > len(choices): | |
raise ValueError("معرف الاختيار غير صالح") | |
choice_text = next((c.text for c in choices if c.id == choice_id), "") | |
# إنشاء برومبت المتابعة | |
story_context_text = "\n".join(paragraphs) | |
continuation_prompt = create_continuation_prompt( | |
story_context_text, | |
choice_id, | |
choice_text, | |
current_paragraph, | |
max_paragraphs | |
) | |
# إضافة رسالة المستخدم | |
messages.append({"role": "user", "content": continuation_prompt}) | |
# استدعاء DeepSeek API | |
response_text = await generate_response(messages) | |
# تحليل الاستجابة | |
paragraph_text, choices, title = parse_paragraph_and_choices(response_text) | |
# تحديث سياق القصة | |
paragraphs.append(paragraph_text) | |
context["current_paragraph"] += 1 | |
context["paragraphs"] = paragraphs | |
# تحديد ما إذا كانت القصة مكتملة | |
is_complete = current_paragraph >= max_paragraphs - 1 | |
# إذا كانت القصة مكتملة، احفظ العنوان | |
if is_complete and title: | |
metadata["title"] = title | |
# تحديث الرسائل | |
messages.append({"role": "assistant", "content": response_text}) | |
metadata["messages"] = messages | |
# إنشاء فقرة القصة | |
paragraph = StoryParagraph( | |
content=paragraph_text, | |
choices=None if is_complete else choices | |
) | |
# إنشاء استجابة القصة | |
story_response = StoryResponse( | |
story_id=story_id, | |
paragraph=paragraph, | |
is_complete=is_complete, | |
title=metadata["title"] if is_complete else None | |
) | |
return story_response | |
async def continue_story_with_text(story_id: str, custom_text: str) -> StoryResponse: | |
""" | |
متابعة القصة بناءً على النص المخصص الذي أدخله المستخدم | |
""" | |
print(f"continue_story_with_text called with story_id={story_id}, custom_text={custom_text}") | |
if story_id not in stories_context or story_id not in stories_metadata: | |
print(f"Story ID {story_id} not found in context or metadata") | |
raise ValueError("معرف القصة غير صالح") | |
context = stories_context[story_id] | |
metadata = stories_metadata[story_id] | |
# الحصول على سياق القصة | |
paragraphs = context["paragraphs"] | |
current_paragraph = context["current_paragraph"] | |
max_paragraphs = metadata["max_paragraphs"] | |
print(f"Story context: current_paragraph={current_paragraph}, max_paragraphs={max_paragraphs}") | |
# الحصول على الرسائل السابقة | |
messages = metadata["messages"] | |
# إنشاء برومبت المتابعة بالنص المخصص | |
story_context_text = "\n".join(paragraphs) | |
# Create a prompt for continuing with custom text | |
custom_prompt = f""" | |
لقد وصلنا إلى هذه النقطة في القصة: | |
{story_context_text} | |
المستخدم اختار أن يكتب رداً مخصصاً بدلاً من اختيار أحد الخيارات المقدمة. الرد المخصص للمستخدم هو: | |
"{custom_text}" | |
بناءً على هذا المدخل من المستخدم، استمر في القصة واكتب فقرة جديدة تأخذ بعين الاعتبار ما كتبه المستخدم. | |
ثم قدم 3 خيارات جديدة للمستخدم ليختار منها للاستمرار في القصة. | |
تذكر أن القصة الآن في: | |
- الفقرة رقم: {current_paragraph} من {max_paragraphs} | |
- إذا كانت هذه الفقرة الأخيرة أو قبل الأخيرة، قم بختم القصة بشكل مناسب. | |
يجب أن يكون تنسيق ردك كما يلي: | |
الفقرة: [نص الفقرة الجديدة من القصة] | |
الخيارات: | |
1. [الخيار الأول] | |
2. [الخيار الثاني] | |
3. [الخيار الثالث] | |
إذا كانت هذه الفقرة الأخيرة، أضف عنواناً للقصة: | |
العنوان: [عنوان مناسب للقصة كاملة] | |
""" | |
print(f"Custom prompt created, length: {len(custom_prompt)}") | |
# إضافة رسالة المستخدم | |
messages.append({"role": "user", "content": custom_prompt}) | |
# استدعاء DeepSeek API | |
print("Calling DeepSeek API...") | |
response_text = await generate_response(messages) | |
print(f"DeepSeek API response received, length: {len(response_text)}") | |
# تحليل الاستجابة | |
paragraph_text, choices, title = parse_paragraph_and_choices(response_text) | |
print(f"Parsed response: paragraph length={len(paragraph_text)}, choices={choices is not None}, title={title is not None}") | |
# تحديث سياق القصة | |
paragraphs.append(paragraph_text) | |
context["current_paragraph"] += 1 | |
context["paragraphs"] = paragraphs | |
# تحديد ما إذا كانت القصة مكتملة | |
is_complete = current_paragraph >= max_paragraphs - 1 | |
print(f"Is story complete: {is_complete}") | |
# إذا كانت القصة مكتملة، احفظ العنوان | |
if is_complete and title: | |
metadata["title"] = title | |
# تحديث الرسائل | |
messages.append({"role": "assistant", "content": response_text}) | |
metadata["messages"] = messages | |
# إنشاء فقرة القصة | |
paragraph = StoryParagraph( | |
content=paragraph_text, | |
choices=None if is_complete else choices | |
) | |
# إنشاء استجابة القصة | |
story_response = StoryResponse( | |
story_id=story_id, | |
paragraph=paragraph, | |
is_complete=is_complete, | |
title=metadata["title"] if is_complete else None | |
) | |
print(f"Story response created successfully") | |
return story_response | |
async def get_complete_story(story_id: str) -> str: | |
""" | |
الحصول على النص الكامل للقصة | |
""" | |
if story_id not in stories_context: | |
raise ValueError("معرف القصة غير صالح") | |
context = stories_context[story_id] | |
metadata = stories_metadata.get(story_id, {}) | |
story_text = "\n\n".join(context["paragraphs"]) | |
if metadata.get("title"): | |
# إضافة العنوان بصيغة صديقة لخدمة تحويل النص إلى كلام | |
story_text = f"قصة بعنوان {metadata['title']}.\n\n{story_text}" | |
return story_text | |
async def generate_title_if_missing(story_id: str) -> str: | |
""" | |
توليد عنوان للقصة إذا لم يكن موجوداً | |
""" | |
if story_id not in stories_metadata: | |
raise ValueError("معرف القصة غير صالح") | |
metadata = stories_metadata[story_id] | |
# إذا كان العنوان موجوداً بالفعل، أعده | |
if metadata.get("title"): | |
return metadata["title"] | |
# توليد العنوان | |
complete_story = await get_complete_story(story_id) | |
system_prompt = get_system_prompt() | |
title_prompt = create_title_prompt(complete_story) | |
messages = [ | |
{"role": "system", "content": system_prompt}, | |
{"role": "user", "content": title_prompt} | |
] | |
title = await generate_response(messages) | |
# تنظيف العنوان | |
title = title.strip().replace("العنوان:", "").strip() | |
# حفظ العنوان | |
metadata["title"] = title | |
return title | |
async def edit_story(story_id: str, edit_instructions: str) -> dict: | |
""" | |
تعديل القصة بناءً على تعليمات المستخدم | |
""" | |
if story_id not in stories_context or story_id not in stories_metadata: | |
raise ValueError("معرف القصة غير صالح") | |
# الحصول على نص القصة الكامل | |
paragraphs = stories_context[story_id]["paragraphs"] | |
complete_story = "\n".join(paragraphs) | |
# إنشاء برومبت للتعديل | |
system_prompt = """أنت مساعد ذكي متخصص في تحرير وتعديل القصص العربية. مهمتك هي تعديل القصة بناءً على تعليمات المستخدم مع الحفاظ على الأسلوب والنبرة الأصلية. | |
قم بإرجاع القصة المعدلة بالكامل وليس فقط الأجزاء المعدلة. تأكد من تقسيم القصة إلى فقرات واضحة كما في النص الأصلي. | |
إذا تطلبت التعليمات تغيير العنوان، قم بإضافة سطر "العنوان الجديد: <العنوان المعدل>" في بداية استجابتك.""" | |
user_prompt = f"""فيما يلي قصة كاملة: | |
{complete_story} | |
تعليمات التعديل: | |
{edit_instructions} | |
قم بتعديل القصة وفقًا للتعليمات المذكورة أعلاه وأرجع القصة المعدلة بالكامل مقسمة إلى فقرات. | |
""" | |
messages = [ | |
{"role": "system", "content": system_prompt}, | |
{"role": "user", "content": user_prompt} | |
] | |
# استدعاء DeepSeek API | |
response_text = await generate_response(messages) | |
# تحليل النص واستخراج العنوان الجديد إذا وجد | |
new_title = None | |
title_match = re.search(r"العنوان الجديد:\s*(.*?)(?:\n|$)", response_text, re.IGNORECASE) | |
if title_match: | |
new_title = title_match.group(1).strip() | |
response_text = re.sub(r"العنوان الجديد:\s*.*?(?:\n|$)", "", response_text, flags=re.IGNORECASE) | |
# تقسيم النص المعدل إلى فقرات | |
edited_paragraphs = [p.strip() for p in response_text.split("\n\n") if p.strip()] | |
# تحديث القصة في التخزين | |
stories_context[story_id]["paragraphs"] = edited_paragraphs | |
# تحديث العنوان إذا كان هناك عنوان جديد | |
if new_title: | |
stories_metadata[story_id]["title"] = new_title | |
# إرجاع البيانات المعدلة | |
return { | |
"paragraphs": edited_paragraphs, | |
"title": new_title or stories_metadata[story_id].get("title") | |
} | |
async def generate_complete_story(config: StoryConfig) -> CompleteStoryResponse: | |
""" | |
إنشاء قصة كاملة دون تفاعل من المستخدم | |
Args: | |
config: تكوين القصة المطلوبة | |
Returns: | |
CompleteStoryResponse: استجابة تحتوي على القصة الكاملة والعنوان | |
""" | |
story_id = str(uuid.uuid4()) | |
# إنشاء سياق أولي للقصة | |
stories_context[story_id] = { | |
"paragraphs": [], | |
"current_paragraph": 0 | |
} | |
# حفظ معلومات القصة | |
length_info = get_story_length_instructions(config.length) | |
stories_metadata[story_id] = { | |
"config": config.dict(), | |
"max_paragraphs": length_info["paragraphs"], | |
"title": None | |
} | |
# إنشاء البرومبت للقصة الكاملة | |
system_prompt = get_system_prompt() | |
user_prompt = create_complete_story_prompt(config) | |
messages = [ | |
{"role": "system", "content": system_prompt}, | |
{"role": "user", "content": user_prompt} | |
] | |
# استدعاء DeepSeek API | |
response_text = await generate_response(messages) | |
# تحليل الاستجابة واستخراج الفقرات | |
paragraphs = [p.strip() for p in response_text.split("\n\n") if p.strip()] | |
# تحديث سياق القصة | |
stories_context[story_id]["paragraphs"] = paragraphs | |
stories_context[story_id]["current_paragraph"] = len(paragraphs) | |
# إنشاء عنوان للقصة | |
title_prompt = create_title_prompt("\n\n".join(paragraphs)) | |
title_messages = [ | |
{"role": "system", "content": system_prompt}, | |
{"role": "user", "content": title_prompt} | |
] | |
title_response = await generate_response(title_messages) | |
title = title_response.strip() | |
# تحديث عنوان القصة في البيانات الوصفية | |
stories_metadata[story_id]["title"] = title | |
# إنشاء كائن الاستجابة | |
return CompleteStoryResponse( | |
story_id=story_id, | |
paragraphs=paragraphs, | |
title=title | |
) |