""" Script generation module using Google Gemini Pro This module generates natural Japanese narration scripts based on: - Extracted PDF data - Video transcription - Template constraints """ import os import json import re from typing import Dict, Any, List, Optional try: from google import genai from google.genai import types except ImportError: genai = None class ScriptGenerator: """Generate narration scripts using Gemini Pro""" def __init__(self, api_key: Optional[str] = None): self.api_key = api_key or os.getenv("GEMINI_API_KEY") if not self.api_key: raise ValueError("GEMINI_API_KEY not found in environment") if genai is None: raise ImportError( "google-genai not installed. " "Run: pip install google-genai" ) self.client = genai.Client(api_key=self.api_key) self.model = 'gemini-2.0-flash-exp' def generate( self, pdf_data: Dict[str, Any], video_data: Optional[Dict[str, Any]] = None, style: str = "friendly" ) -> Dict[str, Any]: """Generate narration script""" print(f" Generating script with Gemini Pro for client: {pdf_data.get('client_name', 'Unknown')}...") # Merge Video Data into PDF Data for prompt building merged_data = {**pdf_data} if video_data: merged_data["transcription"] = video_data.get("transcription", "") merged_data["clips"] = video_data.get("clips", []) # Build the prompt prompt = self._build_prompt(merged_data, style) # DEBUG: Print the first part of the prompt # print(f"--- DEBUG PROMPT START ---\n{prompt[:500]}\n--- DEBUG PROMPT END ---") try: # Call Gemini API response = self.client.models.generate_content( model=self.model, contents=prompt ) # Parse the response script_data = self._parse_response(response.text) print(f" ✓ Generated {len(script_data['segments'])} script segments") return script_data except Exception as e: print(f" ⚠️ Gemini API error: {e}") print(" Falling back to template-based script...") return self._generate_fallback_script(pdf_data) def _build_prompt(self, pdf_data: Dict, style: str) -> str: """Build the prompt for Gemini Pro""" client_name = pdf_data.get("client_name", "お客様") current_age = pdf_data.get("current_age", 47) retirement_age = pdf_data.get("retirement_age", 60) current_assets = pdf_data.get("current_assets", 907) annual_income = pdf_data.get("annual_income", 703) annual_expenses = pdf_data.get("annual_expenses", 288) life_events = pdf_data.get("life_events", []) # Transcription and Clips transcription = pdf_data.get("transcription", "") clips = pdf_data.get("clips", []) # Include PPT data if available ppt_info = "" presentation = pdf_data.get("presentation", {}) if presentation and "slides" in presentation: ppt_info = "\n【PPTスライド内容】\n" for slide in presentation["slides"]: ppt_info += f"- スライド {slide['index']}: タイトル: {slide['title']}\n" if slide.get("text"): ppt_info += f" 内容: {slide['text'][:400]}\n" if slide.get("notes"): ppt_info += f" ノート: {slide['notes'][:400]}\n" # Life events info events_info = "" if life_events: events_info = "\n【ライフイベント予定】\n" for ev in life_events: events_info += f"- {ev.get('year')}年 ({ev.get('age')}歳): {ev.get('description')} (費用: {ev.get('cost', 0)}万円)\n" # Video Transcription and Clips info video_info = "" if transcription: video_info = f"\n【インタビュー動画の内容】\n{transcription[:2000]}\n" if clips: video_info += "\n【利用可能なビデオクリップ】\n" for i, clip in enumerate(clips): video_info += f"- クリップ {i}: 「{clip.get('text', '')[:100]}...」\n" prompt = f""" あなたはプロのファイナンシャルプランナーであり、親しみやすい動画解説者です。 以下の顧客情報、ライフイベント、PPTスライド、そしてインタビュー動画の転記内容に基づいて、解説動画用の完結なナレーション原稿を作成してください。 【厳格な指示】 1. 抽象的な表現(「〇〇」や「△△」)や、メタ発言(「スライドの内容がありません」など)は絶対に出力しないでください。 2. インタビュー内容やPDFの数値を最大限に活用し、事実に基づいた具体的な解説を行ってください。 3. 各セグメントの视覚素材(`visualType`)は、以下から適切に選択してください: - `ppt`: PPTスライドを表示する場合。`visualAssetIndex` を指定してください(基本はこちらを優先)。 - `broll`: 抽象的なイメージ映像を表示する場合。`brollKeyword` を必ず指定してください。 - `chart`: 資産推移や収支の動的なグラフを表示する場合。シミュレーションの結果を具体的に説明するシーンで使用します。 - `video`: 本人のインタビュー映像を表示する場合。本人の肉声や想いを伝えるシーンで使用します。`visualAssetIndex` にクリップの番号を指定してください。 - `dialogue`: 文字による対話形式(インタビューの再現)を表示する場合。動画の冒端でインタビューのやり取りをテキストで再現する際に使用します。`speaker` 属性に话者名を指定してください。 4. ナレーションの中に「スライド〇〇をご覧ください」や「インタビューでもおっしゃっていた通り...」といった言葉を入れ、視聴者の視線を誘導してください。 5. 真人インタビュー(`video` タイプ)を使用する場合、ナレーションテキスト(`text` 属性)はそのクリップで本人が話している内容と一致させてください。 6. `brollKeyword` についての指示: - 単一の単語(例:「家族」)ではなく、**セグメントの文脈を反映した具体的な英語のフレーズ**(例:「Japanese family having dinner」、「Japanese businessman planning career」)を生成してください。 - 人物が登場する場合は、必ず**「Japanese」または「Asian」**というキーワードをフレーズに含めてください。 【顧客情報】 - お名前: {client_name}様 - 現在年齢: {current_age}歳 - 退職予定年齢: {retirement_age}歳 - 現在の資産: {current_assets}万円 - 年収: {annual_income}万円 - 年間支出: {annual_expenses}万円 {events_info} {ppt_info} {video_info} 【原稿構成のフロー】 動画全体で少なくとも 12〜18 のセグメントを作成し、以下の流れで構成してください: 1. 文字対話オープニング(visualType: dialogue):インタビュー動画から、ライフプラン相談のきっかけや想いがわかる重要なやり取りを 3〜5 セグメントほど抽出して配置してください。 2. 動画全体の導入(Slide 0):インパクトのある挨拶。 3. 自己紹介と現状(Slide 1, 2...):家計の現状をスライドに沿って。 3. ライフイベント(Slide 3, 4...):具体的なイベント内容の解説。 4. インタビュー挿入(Video):本人の想いや不安、决意を语るシーン。 5. シミュレーション詳細(Chart):動的なグラフ(資産推移・収支明細)を使い、何年後に資産がどうなるかを具体的に説明。 6. 対策とアドバイス(Slide 5, 6...):改善策や提案をスライドで。 7. クロージング(Slide 7...):まとめと個別相談の案内。 【素材使用の鉄則 - 必ず守ること】 - **PPTスライドの全消化**: 提供されたスライド(Slide 0 から最後のリファレンスまで)を、内容に合わせて**必ず全て最低1回は表示**してください。スライドを飛ばさないでください。 - **ハイブリッド構成**: 「PPTで事実を伝え、Chartでデータを動かし、Videoで本人の声を伝える」というリズムを意識してください。 【出力形式】 純粋なJSON形式のみで返してください。コードブロックは不要です。 {{ "segments": [ {{ "id": 1, "text": "読み上げる文章", "insight": "画面に表示する短い要約・洞察(20文字程度)", "targetYear": 2025, "duration": 秒数, "type": "narration" | "dialogue" | "chart_intro" | "chart_highlight" | "video_clip", "visualType": "ppt" | "video" | "chart" | "dialogue" | "broll", "visualAssetIndex": 0, "speaker": "話者名", "brollKeyword": "キーワード" }} ] }} """ return prompt def _parse_response(self, response_text: str) -> Dict[str, Any]: """Parse Gemini's JSON response""" # Remove markdown code blocks and any extra text text = response_text.strip() # Try to find JSON block if "{" in text and "}" in text: start = text.find("{") end = text.rfind("}") + 1 text = text[start:end] # Parse JSON try: data = json.loads(text) except json.JSONDecodeError as e: print(f" ❌ JSON Parse Error: {e}") print(f" Raw text: {text[:500]}...") raise # Post-process segments to remove hallucinations/meta-talk for seg in data["segments"]: # --- CRITICAL FIX FOR TEXT/AUDIO SYNC --- # Ensure we don't have lingering brackets or meta-tags that might # be processed differently by TTS vs Subtitles # 1. Clean up meta-patterns from text (for both TTS and Subtitles) meta_patterns = [ r'\(スライド\d+.*?\)', r'(スライド\d+.*?)', r'\[スライド\d+.*?\]', r'【スライド\d+.*?】' ] for pattern in meta_patterns: seg["text"] = re.sub(pattern, '', seg["text"]) # 2. Standardize whitespace and remove hidden characters seg["text"] = " ".join(seg["text"].split()) # 3. Replace placeholders seg["text"] = seg["text"].replace("〇〇", "こちらの") seg["text"] = seg["text"].strip() # 4. Add duration buffer (give more breathing room for visuals/HUD) try: duration = float(seg.get("duration", 5.0)) seg["duration"] = duration * 1.1 + 1.0 except (ValueError, TypeError): seg["duration"] = 6.5 # Calculate total duration total_duration = sum(seg.get("duration", 0) for seg in data["segments"]) return { "segments": data["segments"], "total_duration": total_duration, } def _generate_fallback_script(self, pdf_data: Dict) -> Dict[str, Any]: """Generate a template-based script as fallback""" client_name = pdf_data.get("client_name", "お客様") current_age = pdf_data.get("current_age", 35) retirement_age = pdf_data.get("retirement_age", 65) current_year = 2025 # TODO: Make dynamic retirement_year = current_year + (retirement_age - current_age) segments = [ { "id": 1, "text": f"{client_name}様、こんにちは。これから、あなたの人生設計について、詳しくご説明させていただきます。", "duration": 5.0, "type": "narration", }, { "id": 2, "text": f"現在{current_age}歳の{client_name}様の資産状況を見てみましょう。", "duration": 3.5, "type": "chart_intro", "chartSync": { "chartId": "cashFlow", "highlightYear": current_year, }, }, { "id": 3, "text": f"年間の収入と支出のバランスを確認します。現在は安定した黒字が続いています。", "duration": 4.5, "type": "chart_highlight", "chartSync": { "chartId": "cashFlow", "highlightYear": current_year, }, }, { "id": 4, "text": f"{retirement_age}歳での退職を想定した場合、将来の資産推移はこのようになります。", "duration": 4.0, "type": "chart_highlight", "chartSync": { "chartId": "assetDepletion", "highlightYear": retirement_year, }, }, { "id": 5, "text": "退職後の生活資金については、計画的な準備が大切です。定期的に見直しを行いましょう。", "duration": 5.0, "type": "narration", }, { "id": 6, "text": "ご不明な点がございましたら、いつでもご相談ください。ありがとうございました。", "duration": 4.0, "type": "narration", }, ] return { "segments": segments, "total_duration": sum(s["duration"] for s in segments), } def generate_script( pdf_data: Dict[str, Any], video_data: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """Convenience function for script generation""" try: generator = ScriptGenerator() return generator.generate(pdf_data, video_data) except (ValueError, ImportError) as e: print(f" ⚠️ Cannot initialize ScriptGenerator: {e}") print(" Using fallback template script...") generator_fallback = ScriptGenerator.__new__(ScriptGenerator) return generator_fallback._generate_fallback_script(pdf_data)