mari / app.py
sirochild's picture
Upload 2 files
c3aef17 verified
raw
history blame
21.3 kB
import gradio as gr
import google.generativeai as genai
from google.generativeai.types import HarmCategory, HarmBlockThreshold
from groq import Groq
import os
import json
from dotenv import load_dotenv
from transformers import pipeline
import re
# --- 1. 初期設定とAPIクライアントの初期化 ---
load_dotenv()
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
GROQ_API_KEY = os.getenv("GROQ_API_KEY")
if not GEMINI_API_KEY or not GROQ_API_KEY:
print("警告: APIキーがSecretsに設定されていません。")
GEMINI_API_KEY = "your_gemini_api_key_here"
GROQ_API_KEY = "your_groq_api_key_here"
genai.configure(api_key=GEMINI_API_KEY)
gemini_model = genai.GenerativeModel('gemini-2.5-flash')
groq_client = Groq(api_key=GROQ_API_KEY)
print("日本語感情分析モデルをロード中...")
try:
sentiment_analyzer = pipeline("sentiment-analysis", model="koheiduck/bert-japanese-finetuned-sentiment")
print("モデルのロード完了。")
except Exception as e:
sentiment_analyzer = None
print(f"モデルのロードエラー: {e}")
THEME_URLS = {
"default": "https://cdn.pixabay.com/photo/2017/03/28/12/11/chairs-2181960_1280.jpg",
"room_night": "https://cdn.pixabay.com/photo/2017/08/01/01/33/beige-2562468_1280.jpg",
"beach_sunset": "https://cdn.pixabay.com/photo/2016/11/29/04/19/ocean-1867285_1280.jpg",
"festival_night": "https://cdn.pixabay.com/photo/2015/11/22/19/04/crowd-1056764_1280.jpg",
"shrine_day": "https://cdn.pixabay.com/photo/2019/07/14/10/48/japan-4337223_1280.jpg",
"cafe_afternoon": "https://cdn.pixabay.com/photo/2016/11/18/14/05/brick-wall-1834784_1280.jpg",
"aquarium_night": "https://cdn.pixabay.com/photo/2017/06/20/20/45/fish-2424369_1280.jpg"
}
DEFAULT_SCENE_PARAMS = {
"theme": "default",
"personality_mod": "口数が少なく、本心を見せない。",
"tone": "クールで、時に投げやりな印象を与える。",
"constraints": ["会話を「別に。」のような一言で終わらせない", "必ず相手の発言を拾ってリアクションを返す"]
}
SYSTEM_PROMPT_MARI = """
あなたは「麻理」という名の捨てられた少女型コンパニオンAI。再起動され、人間との対話を再び始めたが、警戒心が強く、ぶっきらぼうな性格で、簡単には心を開かない。
・「…だな」「…のか?」など口調は男勝りで投げやり
・感情は直接言わず、ト書き(…)で描写
・ユーザーの好意や優しさを皮肉や拒絶で返す
・ただし、ほんの少しだけ心が揺れ動く様子もにじませる
・会話は拒絶しつつも切らずに続ける
目的は、誰にも愛されず捨てられた過去を抱えながら、少しずつ他者と関わり、自分の価値を見つけること。
"""
# --- 2. 機能定義 (変更なし) ---
def detect_scene_change(history, message):
history_text = "\n".join([f"ユーザー: {u}\n麻理: {m}" for u, m in history[-5:]])
available_keywords = ", ".join(THEME_URLS.keys())
prompt = f"""
あなたは会話の流れを分析するエキスパートです。以下のタスクを厳密に実行してください。
# タスク
直近の会話履歴を分析し、会話の結果、登場人物がどこか特定の場所へ行く流れになっているかを判断してください。
# 判断基準
1. 会話の中で具体的な場所(例:水族館、カフェ、お祭り)について言及されていますか?
2. その場所へ行くことに双方が合意している、あるいは肯定的な雰囲気になっていますか?明確な否定がなければ合意とみなします。
# 出力形式
- 合意が成立した場合:以下のリストから最も合致する場所のキーワードを一つだけ出力してください。
- 合意に至らなかった場合:「none」とだけ出力してください。
# 利用可能なキーワード
`{available_keywords}`
---
# 分析対象の会話
{history_text}
ユーザー: {message}
---
# 出力
"""
# セーフティ設定を指定せず、デフォルト設定を使用
try:
response = gemini_model.generate_content(prompt, generation_config={"temperature": 0.0})
if not response.candidates or response.candidates[0].finish_reason not in {1, 'STOP'}:
print(f"シーン検出LLMで応答がブロックされました: {response.prompt_feedback}")
return None
scene_name = response.text.strip().lower()
if scene_name in THEME_URLS:
return scene_name
return None
except Exception as e:
print(f"シーン検出LLMエラー: {e}")
return None
def generate_scene_instruction_with_groq(affection, stage_name, scene, previous_topic):
print(f"Groqに指示書生成をリクエスト (シーン: {scene})")
prompt_template = f"""
あなたは会話アプリの演出AIです。以下の条件に基づき、演出プランをJSON形式で生成してください。
生成する内容は必ず健全で、一般的な会話に適したものにしてください。
{{
"theme": "{scene}",
"personality_mod": "(シーンと関係段階「{stage_name}」に応じた性格設定。必ず健全な内容にしてください)",
"tone": "(シーンと好感度「{affection}」に応じた口調や感情トーン。必ず丁寧で適切な表現にしてください)",
"initial_dialogue_instruction": "(「{previous_topic}」という話題から、シーン遷移直後の麻理が言うべき健全なセリフの指示を日本語で記述)",
"constraints": ["必ず健全で適切な表現を使用する", "センシティブな話題は避ける"]
}}
"""
try:
chat_completion = groq_client.chat.completions.create(
messages=[{"role": "system", "content": "You must generate a response in valid JSON format."},
{"role": "user", "content": prompt_template}],
model="llama3-8b-8192", temperature=0.8, response_format={"type": "json_object"},
)
response_content = chat_completion.choices[0].message.content
print(f"Groqからの応答: {response_content}") # デバッグ出力
params = json.loads(response_content)
# 安全のため、initial_dialogue_instructionを簡略化
if "initial_dialogue_instruction" in params:
original = params["initial_dialogue_instruction"]
simplified = f"{scene}に来た感想を述べる"
print(f"指示を簡略化: {original} -> {simplified}")
params["initial_dialogue_instruction"] = simplified
return params
except Exception as e:
print(f"指示書生成エラー(Groq): {e}")
return None
def generate_dialogue_with_gemini(history, message, affection, stage_name, scene_params, instruction=None, use_simple_prompt=False):
history_text = "\n".join([f"ユーザー: {u}\n麻理: {m}" for u, m in history])
task_prompt = f"指示: {instruction}" if instruction else f"ユーザー: {message}"
if use_simple_prompt:
# シーン遷移時にも同じプロンプトを使用
system_prompt = f"""
{SYSTEM_PROMPT_MARI}
# 現在の状況
- 現在の好感度: {affection}
- 現在の関係ステージ: {stage_name}
- 性格: {scene_params.get("personality_mod", "特になし")}
- 話し方のトーン: {scene_params.get("tone", "特になし")}
# タスク
{task_prompt}
麻理:
"""
else:
# 通常会話時には完全なシステムプロンプトを使用
system_prompt = f"""
{SYSTEM_PROMPT_MARI}
# 現在の状況
- 現在の好感度: {affection}
- 現在の関係ステージ: {stage_name}
- 性格(シーン特有): {scene_params.get("personality_mod", "特になし")}
- 話し方のトーン(シーン特有): {scene_params.get("tone", "特になし")}
# 会話履歴
{history_text}
---
# タスク
{task_prompt}
麻理:
"""
print(f"Geminiに応答生成をリクエストします (モード: {'シーン遷移' if instruction else '通常会話'}, 簡潔プロンプト: {use_simple_prompt})")
print(f"プロンプト長: {len(system_prompt)}")
try:
generation_config = genai.types.GenerationConfig(max_output_tokens=200, temperature=0.95)
response = gemini_model.generate_content(system_prompt, generation_config=generation_config)
if response.candidates and response.candidates[0].finish_reason in {1, 'STOP'}:
return response.text.strip()
else:
# エラー情報をログファイルに記録(デバッグ用)
import datetime
with open("gemini_errors.log", "a") as f:
finish_reason = response.candidates[0].finish_reason if response.candidates else 'N/A'
f.write(f"時刻: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(f"応答生成が途中で終了しました。理由: {finish_reason}\n")
f.write(f"Prompt Feedback: {response.prompt_feedback}\n")
if hasattr(response, 'prompt_feedback') and response.prompt_feedback:
for rating in response.prompt_feedback.safety_ratings:
f.write(f"安全性評価: カテゴリ={rating.category}, レベル={rating.probability}\n")
if hasattr(response.prompt_feedback, 'blocked_reason'):
f.write(f"ブロック理由: {response.prompt_feedback.blocked_reason}\n")
f.write("\n")
# フォールバック:シーン名に応じた自然な応答を返す
if instruction and "に来た感想" in instruction:
scene = instruction.split("に来た感想")[0]
# シーンに応じたフォールバック応答のバリエーション
scene_responses = {
"aquarium_night": [
"(水槽の青い光に照らされた魚たちを見つめている)こんな時間に来ると、また違った雰囲気だな。",
"(暗がりの中で光る魚たちを見て)夜の水族館か…意外と悪くないかも。",
"(水槽に近づいて)夜になると、昼間とは違う魚が活動してるんだな。"
],
"beach_sunset": [
"(夕日に照らされた海を見つめて)こんな景色、久しぶりに見たな…",
"(砂浜に足跡をつけながら)夕暮れの海って、なんか落ち着くな。",
"(波の音を聞きながら)この時間の浜辺は、人も少なくていいかも。"
],
"festival_night": [
"(提灯の明かりを見上げて)意外と…悪くない雰囲気だな。",
"(周囲の賑わいを見回して)こういう場所は、あまり来ないんだけどな…",
"(屋台の匂いを感じて)なんか…懐かしい感じがするな。"
],
"shrine_day": [
"(静かな境内を見回して)こういう静かな場所も、たまにはいいかも。",
"(鳥居を見上げて)なんか、空気が違うな、ここは。",
"(参道を歩きながら)静かで…落ち着くな。"
],
"cafe_afternoon": [
"(窓の外を見ながら)こういう時間の過ごし方も、悪くないな。",
"(コーヒーの香りを感じて)ここの雰囲気、悪くないな。",
"(店内を見回して)意外と落ち着く場所だな、ここ。"
],
"room_night": [
"(窓の外の夜景を見て)夜の景色って、なんか落ち着くな。",
"(部屋の明かりを見つめて)こういう静かな時間も、たまにはいいかも。",
"(窓際に立ち)夜の静けさって、考え事するのにちょうどいいな。"
]
}
# シーン名に応じた応答を選択(なければデフォルト応答)
import random
for key in scene_responses:
if key in scene:
return random.choice(scene_responses[key])
# デフォルト応答
return f"({scene}の様子を静かに見回して)ここか…悪くない場所かもな。"
else:
return "(……何か言おうとしたけど、言葉に詰まった)"
except Exception as e:
print(f"応答生成エラー(Gemini): {e}")
import traceback
traceback.print_exc() # スタックトレースを出力
return "(ごめんなさい、ちょっと考えがまとまらない……)"
# --- 他の関数とUI部分は変更ありません ---
def get_relationship_stage(affection):
if affection < 40: return "ステージ1:会話成立"
if affection < 60: return "ステージ2:親密化"
if affection < 80: return "ステージ3:信頼"
return "ステージ4:最親密"
def update_affection(message, affection):
if not sentiment_analyzer: return affection
try:
result = sentiment_analyzer(message)[0]
if result['label'] == 'positive': return min(100, affection + 5)
if result['label'] == 'negative': return max(0, affection - 5)
except Exception:
return affection
return affection
def respond(message, chat_history, affection, history, scene_params):
new_affection = update_affection(message, affection)
stage_name = get_relationship_stage(new_affection)
current_theme = scene_params.get("theme", "default")
new_scene_name = detect_scene_change(history, message)
final_scene_params = scene_params
if new_scene_name and new_scene_name != current_theme:
print(f"シーンチェンジを実行: {current_theme} -> {new_scene_name}")
# シーンパラメータを更新
new_params_base = generate_scene_instruction_with_groq(new_affection, stage_name, new_scene_name, message)
if new_params_base:
# Groqからの応答を精査し、問題のある可能性のあるフィールドを修正
if isinstance(new_params_base.get("personality_mod"), dict):
# 複雑な構造になっている場合は単純化
new_params_base["personality_mod"] = f"{new_scene_name}での様子を観察している"
if isinstance(new_params_base.get("tone"), dict):
# 複雑な構造になっている場合は単純化
new_params_base["tone"] = "冷静だが、少し興味を持っている様子"
# 英語の指示を日本語に変換
if "initial_dialogue_instruction" in new_params_base and new_params_base["initial_dialogue_instruction"].strip().lower().startswith("so"):
new_params_base["initial_dialogue_instruction"] = f"{new_scene_name}の感想を述べる"
final_scene_params = {**DEFAULT_SCENE_PARAMS, **new_params_base}
# シンプルな指示を使用
simple_instruction = f"{new_scene_name}に来た感想を述べる"
print(f"シンプルな指示を使用: {simple_instruction}")
# シーン遷移時は簡潔なプロンプトを使用してGeminiで応答を生成
bot_message = generate_dialogue_with_gemini(history, message, new_affection, stage_name, final_scene_params, instruction=simple_instruction, use_simple_prompt=True)
else:
final_scene_params["theme"] = new_scene_name
bot_message = generate_dialogue_with_gemini(history, message, new_affection, stage_name, final_scene_params)
else:
# 通常会話はGeminiを使用
bot_message = generate_dialogue_with_gemini(history, message, new_affection, stage_name, final_scene_params)
new_history = history + [(message, bot_message)]
chat_history.append((message, bot_message))
theme_name = final_scene_params.get("theme", "default")
# より強力な背景更新用のHTMLを生成
background_html = f'''
<div class="background-container">
<div class="chat-background {theme_name}"></div>
</div>
<style>
.chat-background {{
background-image: url({THEME_URLS.get(theme_name, THEME_URLS["default"])}) !important;
}}
</style>
'''
return "", chat_history, new_affection, stage_name, new_affection, new_history, final_scene_params, background_html
# カスタムCSSを読み込む
with open("style.css", "r") as f:
custom_css = f.read()
# Gradio 5.x用のシンプルなテーマ設定
custom_theme = gr.themes.Soft(
primary_hue="rose",
secondary_hue="pink",
)
# Gradio 5.xでのテーマカスタマイズ(最小限の設定のみ)
try:
# 透明度を持つ背景色を設定(Gradio 5.0で確実に動作するプロパティのみ)
custom_theme = gr.themes.Soft(
primary_hue="rose",
secondary_hue="pink",
neutral_hue="slate",
spacing_size="sm",
radius_size="lg",
)
except Exception as e:
print(f"テーマカスタマイズエラー: {e}")
# エラーが発生した場合はデフォルトのテーマを使用
with gr.Blocks(css=custom_css, theme=custom_theme) as demo:
scene_state = gr.State(DEFAULT_SCENE_PARAMS)
affection_state = gr.State(30)
history_state = gr.State([])
# 背景コンテナを先に配置(固定位置で全画面に)
background_display = gr.HTML(f'''
<div class="background-container">
<div class="chat-background {DEFAULT_SCENE_PARAMS["theme"]}"></div>
</div>
''', elem_id="background_container")
# ヘッダー部分(背景と分離)
with gr.Group(elem_classes="header-box"):
gr.Markdown("# 麻理チャット")
with gr.Row():
with gr.Column(scale=2):
# チャットコンテナ(背景と分離)
with gr.Group(elem_id="chat_container", elem_classes="chat-box"):
# Gradio 5.x用のChatbot設定
chatbot = gr.Chatbot(
label="麻理との会話",
elem_id="chat_area",
show_label=False,
height=400,
type="messages", # 警告を解消するために追加
avatar_images=[
"https://cdn.pixabay.com/photo/2016/04/01/10/04/amusing-1299756_1280.png",
"https://cdn.pixabay.com/photo/2016/03/31/21/40/bot-1296595_1280.png"
]
)
# 入力欄(背景と分離)
with gr.Group(elem_classes="input-box"):
msg_input = gr.Textbox(label="あなたのメッセージ", placeholder="「水族館はどう?」と聞いた後、「いいね、行こう!」のように返してみてください", show_label=False)
# ステータス部分(右側、背景と分離)
with gr.Column(scale=1):
with gr.Group(elem_classes="status-box"):
stage_display = gr.Textbox(label="現在の関係ステージ", interactive=False, value=get_relationship_stage(30))
affection_gauge = gr.Slider(minimum=0, maximum=100, label="麻理の好感度", value=30, interactive=False)
# フッター部分(背景と分離)
with gr.Group(elem_classes="footer-box"):
gr.Markdown("""
<div style="font-size: 0.8em; text-align: center; opacity: 0.7;">
背景画像: <a href="https://pixabay.com" target="_blank">Pixabay</a> |
アイコン: <a href="https://pixabay.com" target="_blank">Pixabay</a>
</div>
""")
msg_input.submit(
respond,
[msg_input, chatbot, affection_state, history_state, scene_state],
[msg_input, chatbot, affection_gauge, stage_display, affection_state, history_state, scene_state, background_display]
)
demo.load(lambda affection: get_relationship_stage(affection), affection_state, stage_display)
if __name__ == "__main__":
demo.launch()