RAWI-Story-Generator / ai_service.py
walker11's picture
Upload 2 files
48215a8 verified
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
)