AI-video-lifeplan-demo/backend/scripter.py

319 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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)