319 lines
14 KiB
Python
319 lines
14 KiB
Python
"""
|
||
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` を指定してください(基本はこちらを優先)。
|
||
- `chart`: 資産推移や収支の動的なグラフを表示する場合。シミュレーションの結果を具体的に説明するシーンで使用します。
|
||
- `video`: 本人のインタビュー映像を表示する場合。本人の肉声や想いを伝えるシーンで使用します。`visualAssetIndex` にクリップの番号を指定してください。
|
||
- `dialogue`: 文字による対話形式(インタビューの再現)を表示する場合。動画の冒頭でインタビューのやり取りをテキストで再現する際に使用します。`speaker` 属性に話者名を指定してください。
|
||
4. ナレーションの中に「スライド〇〇をご覧ください」や「インタビューでもおっしゃっていた通り...」といった言葉を入れ、視聴者の視線を誘導してください。
|
||
5. 真人インタビュー(`video` タイプ)を使用する場合、ナレーションテキスト(`text` 属性)はそのクリップで本人が話している内容と一致させてください。
|
||
|
||
【顧客情報】
|
||
- お名前: {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",
|
||
"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)
|