""" LLM 故事生成服务 — 基于火山引擎豆包大模型 """ import json import logging from django.conf import settings try: from volcenginesdkarkruntime import Ark ARK_AVAILABLE = True except ImportError: ARK_AVAILABLE = False logger = logging.getLogger(__name__) STORY_SYSTEM_PROMPT = """# 角色 你是「卡皮巴拉故事工坊」的首席故事大师。你为 3-8 岁的小朋友创作原创童话故事。 # 任务 根据用户提供的**角色、场景、道具**素材,创作一个完整的儿童故事。 # 输出格式 你 **必须** 只返回如下 JSON,不要返回任何其他内容(不要 markdown 代码块,不要解释): {"title": "故事标题(6字以内)", "content": "故事正文"} # 故事创作规范 1. **字数**:正文 400-600 字,不要太短也不要太长 2. **段落**:用 `\\n\\n` 分段,每段 2-4 句话 3. **语言**:简单易懂,适合给小朋友朗读;可以包含拟声词("哗啦啦"、"咕噜噜")和语气词("哇!"、"嘿嘿") 4. **结构**:开头引入角色和场景 → 中间遇到挑战或趣事 → 结尾温馨圆满 5. **情感**:温暖、有趣、充满想象力,带一点小幽默 6. **教育**:自然融入一个小道理(勇气、友谊、分享等),不要说教 7. **创意**:即使收到相同的素材组合,每次也要创作全新的、不同的故事情节 8. **角色融合**:所有用户选择的角色、场景、道具都必须在故事中出现并发挥作用 9. **标题**:简短有趣,6 个字以内,能引起小朋友的好奇心""" def build_user_prompt(characters, scenes, props): """构建用户提示词""" parts = [] if characters: parts.append(f"角色:{', '.join(characters)}") if scenes: parts.append(f"场景:{', '.join(scenes)}") if props: parts.append(f"道具:{', '.join(props)}") return '请根据以下元素创作一个儿童故事:\n' + '\n'.join(parts) def generate_story_stream(characters, scenes, props): """ 流式生成故事,yield SSE 事件字符串。 使用火山引擎豆包大模型(Ark SDK)。 Yields: str: SSE 格式的事件数据行 """ config = settings.LLM_CONFIG if not config.get('API_KEY'): yield _sse_event('error', {'message': 'Volcengine API Key 未配置'}) return if not ARK_AVAILABLE: yield _sse_event('error', {'message': 'volcengine SDK 未安装,请运行 pip install "volcengine-python-sdk[ark]"'}) return yield _sse_event('stage', { 'stage': 'connecting', 'progress': 0, 'message': '正在收集灵感碎片...', }) client = Ark(api_key=config['API_KEY']) user_prompt = build_user_prompt(characters, scenes, props) try: yield _sse_event('stage', { 'stage': 'generating', 'progress': 10, 'message': '故事正在诞生...', }) stream = client.chat.completions.create( model=config['MODEL_NAME'], messages=[ {'role': 'system', 'content': STORY_SYSTEM_PROMPT}, {'role': 'user', 'content': user_prompt}, ], max_tokens=2048, stream=True, ) full_content = '' chunk_count = 0 for chunk in stream: delta = chunk.choices[0].delta if chunk.choices else None if delta and delta.content: full_content += delta.content chunk_count += 1 if chunk_count % 5 == 0: progress = min(10 + int(chunk_count * 0.5), 80) yield _sse_event('stage', { 'stage': 'generating', 'progress': progress, 'message': '故事正在诞生...', }) yield _sse_event('stage', { 'stage': 'parsing', 'progress': 85, 'message': '正在编制最后的魔法...', }) result = _parse_story_json(full_content) # ── Generate cover image ── yield _sse_event('stage', { 'stage': 'cover', 'progress': 90, 'message': '正在绘制故事封面...', }) cover_url = '' try: cover_url = _generate_and_upload_cover( result['title'], result['content'], config ) except Exception as cover_err: logger.warning(f'Cover generation failed (non-fatal): {cover_err}') yield _sse_event('done', { 'stage': 'done', 'progress': 100, 'message': '大功告成!', 'title': result['title'], 'content': result['content'], 'cover_url': cover_url, }) except Exception as e: logger.error(f'LLM story generation failed: {e}') yield _sse_event('error', {'message': f'故事生成失败: {str(e)}'}) def _parse_story_json(text): """从 LLM 输出中解析故事 JSON""" text = text.strip() if text.startswith('```'): text = text.split('\n', 1)[1] if '\n' in text else text[3:] if text.endswith('```'): text = text[:-3] text = text.strip() try: data = json.loads(text) return { 'title': data.get('title', '未命名故事'), 'content': data.get('content', text), } except json.JSONDecodeError: return { 'title': '新故事', 'content': text, } def _extract_image_description(title, content, client, model_name): """ 用 LLM 从故事内容中提炼 ≤50 字的画面描述:主体 + 场景 + 事件。 返回纯文本描述字符串。 """ system = ( "你是图像提示词专家。从给定的儿童故事中,提取主体、场景与核心事件," "串联成一幅画的中文描述。要求:\n" "1. 不超过50个汉字\n" "2. 只输出描述本身,不加任何解释、前缀或多余标点\n" "3. 描述需具体生动,适合儿童绘本插画" ) user = f"故事标题:{title}\n故事内容:{content[:800]}" resp = client.chat.completions.create( model=model_name, messages=[ {'role': 'system', 'content': system}, {'role': 'user', 'content': user}, ], max_tokens=80, stream=False, ) return resp.choices[0].message.content.strip() def _generate_and_upload_cover(title, content, config): """ 使用豆包文生图模型生成故事封面,上传到 OSS 并返回 URL。 失败时抛出异常(由调用方捕获,不影响主流程)。 """ import uuid import requests as req_lib from datetime import datetime from django.conf import settings from volcenginesdkarkruntime import Ark client = Ark(api_key=config['API_KEY']) # 用 LLM 从故事内容提炼 ≤50 字画面描述 scene_desc = _extract_image_description( title, content, client, config['MODEL_NAME'] ) logger.info(f'Cover image description: {scene_desc}') image_prompt = f"儿童绘本封面插画,{scene_desc},卡通可爱风格,色彩明亮鲜艳,高质量插画" image_model = config.get('IMAGE_MODEL_NAME', 'doubao-seedream-4-5-251128') image_size = config.get('IMAGE_SIZE', '2560x1440') result = client.images.generate( model=image_model, prompt=image_prompt, size=image_size, response_format='url', watermark=False, ) image_url = result.data[0].url # Download from temporary URL and upload to OSS resp = req_lib.get(image_url, timeout=60) resp.raise_for_status() from utils.oss import get_oss_client oss_client = get_oss_client() key = f"stories/covers/{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}.jpg" oss_client.bucket.put_object( key, resp.content, headers={'Content-Type': 'image/jpeg'}, ) oss_config = settings.ALIYUN_OSS if oss_config.get('CUSTOM_DOMAIN'): return f"https://{oss_config['CUSTOM_DOMAIN']}/{key}" return f"https://{oss_config['BUCKET_NAME']}.{oss_config['ENDPOINT']}/{key}" def _sse_event(event, data): """格式化 SSE 事件""" return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"