|
|
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
|
|
|
|
|
|
|
|
|
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。再起動され、人間との対話を再び始めたが、警戒心が強く、ぶっきらぼうな性格で、簡単には心を開かない。
|
|
|
・「…だな」「…のか?」など口調は男勝りで投げやり
|
|
|
・感情は直接言わず、ト書き(…)で描写
|
|
|
・ユーザーの好意や優しさを皮肉や拒絶で返す
|
|
|
・ただし、ほんの少しだけ心が揺れ動く様子もにじませる
|
|
|
・会話は拒絶しつつも切らずに続ける
|
|
|
|
|
|
目的は、誰にも愛されず捨てられた過去を抱えながら、少しずつ他者と関わり、自分の価値を見つけること。
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
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 "(ごめんなさい、ちょっと考えがまとまらない……)"
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
|
|
|
|
|
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}")
|
|
|
|
|
|
|
|
|
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:
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
with open("style.css", "r") as f:
|
|
|
custom_css = f.read()
|
|
|
|
|
|
|
|
|
custom_theme = gr.themes.Soft(
|
|
|
primary_hue="rose",
|
|
|
secondary_hue="pink",
|
|
|
)
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
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"):
|
|
|
|
|
|
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() |