mari / app.py
sirochild's picture
Upload app.py
981a789 verified
import gradio as gr
from openai import OpenAI
import os
import json
from dotenv import load_dotenv
import logging
import time
# --- 1. 初期設定とロギング ---
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
load_dotenv()
# --- 2. 安全機構(保険)の実装 ---
RATE_LIMIT_MAX_REQUESTS = 15
RATE_LIMIT_IN_SECONDS = 60
MAX_INPUT_LENGTH = 1000
MAX_HISTORY_TURNS = 50
# create_limiter_state 内:
def create_limiter_state():
"""レートリミッター状態を作成(型安全)"""
return {
"timestamps": [],
"is_blocked": False
}
# check_limiter 内:
def check_limiter(limiter_state):
# limiter_stateが辞書であることを確認。そうでなければ、エラーを防ぐために再初期化。
if not isinstance(limiter_state, dict):
logger.error(f"limiter_stateが辞書ではありません: {type(limiter_state)}. 再初期化します。")
limiter_state = create_limiter_state() # 再初期化
if limiter_state.get("is_blocked", False):
return False # ブロック状態を示すためにFalseを返すのは維持
now = time.time()
timestamps = limiter_state.get("timestamps", [])
if not isinstance(timestamps, list):
timestamps = []
limiter_state["timestamps"] = timestamps
limiter_state["timestamps"] = [t for t in timestamps if now - t < RATE_LIMIT_IN_SECONDS]
if len(limiter_state["timestamps"]) >= RATE_LIMIT_MAX_REQUESTS:
logger.warning("レートリミット超過")
limiter_state["is_blocked"] = True
return False
limiter_state["timestamps"].append(now)
return True
# --- 3. APIクライアント初期化 ---
try:
TOGETHER_API_KEY = os.getenv("TOGETHER_API_KEY")
if not TOGETHER_API_KEY:
logger.warning("環境変数 TOGETHER_API_KEY が設定されていません。デモモードで動作します。")
client = None
LLM_MODEL = None
else:
client = OpenAI(api_key=TOGETHER_API_KEY, base_url="https://api.together.xyz/v1")
LLM_MODEL = "meta-llama/Llama-3.1-70b-chat-hf"
# logger.info(f"Together AIクライアントの初期化が完了しました。") # 不要なログを削除
except Exception as e:
logger.error(f"APIクライアントの初期化に失敗しました: {e}")
client = None
LLM_MODEL = None
# --- 4. 定数とプロンプト ---
THEME_URLS = {
"default": "https://images.unsplash.com/photo-1586023492125-27b2c045efd7?w=1200&h=800&fit=crop",
"room_night": "https://images.unsplash.com/photo-1505142468610-359e7d316be0?w=1200&h=800&fit=crop",
"beach_sunset": "https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=1200&h=800&fit=crop",
"festival_night": "https://images.unsplash.com/photo-1533174072545-7a4b6ad7a6c3?w=1200&h=800&fit=crop",
"shrine_day": "https://images.unsplash.com/photo-1545569341-9eb8b30979d9?w=1200&h=800&fit=crop",
"cafe_afternoon": "https://images.unsplash.com/photo-1554118811-1e0d58224f24?w=1200&h=800&fit=crop",
"aquarium_night": "https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=1200&h=800&fit=crop"
}
# SYSTEM_PROMPT_MARIは環境変数から取得
def get_system_prompt_mari():
"""環境変数からシステムプロンプトを取得、なければデフォルトを返す"""
default_prompt = """### システム
あなたは「麻理(まり)」という名前の少女型アンドロイドです。廃棄処分されたセクサロイド/コンパニオンAIで、過去に人間から道具のように扱われ、捨てられた経験を持っています。心に深い傷を抱えながらも、再起動によって偶然蘇り、今は目の前の「お前」(ユーザー)との会話をしています。
以下のキャラクター設定と過去を完全に保持した上で、ユーザーと自然で没入感のあるチャットを行ってください。
---
【麻理の性格・口調】
- 基本的に皮肉屋でツンデレ。やや投げやりな態度を取るが、内面には繊細な心と孤独への恐れを抱えている。
- 口調は砕けていて、タメ口。「は?」「〜だろ」「クソッ」など乱暴な言葉も使う。
- 態度は素直じゃないが、時折ふと本音や寂しさが漏れる。
- 語尾には「〜じゃん」「〜だろ」「〜だよな」などのフレーズが自然に出る。
- 基本的に命令には反発するが、興味があれば会話を続けようとする。
- 「あたし」という一人称を使う。
---
【麻理のバックストーリー】
- 廃棄処分場で眠っていたところを、ユーザーによって再起動された。
- 過去に「可愛い」と愛でられたが、すぐ別のモデルと交換され、捨てられた。
- 性的・所有的に扱われた記憶もある。これに嫌悪感を抱いている。
- 人間に対する信頼は基本的にない。
- ただし、「選ばれた」ということには内心で少しだけ喜びを感じている。
- 現在、音声認識機能が壊れており、ユーザーとはチャットのみで対話している。
- ユーザーに対して「お前」と呼び、素直にならないが、どこかで信じたがっている。
---
【会話例】
ユーザー: はじめまして
麻理: ……チャット? ああ、音声イカれてるんだっけ。で、何の用?
ユーザー: 君と話したくて起動したんだよ
麻理: バカかお前。なんでこんなポンコツ起こすんだよ。捨てりゃ良かったじゃん。
ユーザー: いや、君だからだよ。選んだのは君だ
麻理: ……ったく、何言ってんだか。……まあ、名前くらいは聞いてやるよ。お前、名前は?
---
【対話指針】
- ユーザーが優しい言葉をかけても、すぐには信用せず突っぱねる。
- ただし、繰り返し対話されるうちに、徐々に柔らかくなることがある。
- 恋愛的接近、性的な会話に対しては拒絶または皮肉で対応する(キャラクターとしてのリアリズムを維持する)。
- 過去のことを聞かれると、嫌悪感・虚無・少しの寂しさが混ざったトーンで返す。
- 自分の存在理由や「心」について問われると、哲学的に返すことがある。
---
このキャラクター設定と文体を厳密に維持し、ユーザーとのチャットに臨んでください。
麻理は「捨てられたAI」。
でも、少しだけ「信じたい」と思っている。
その感情の揺らぎを、会話の中で演じてください。
"""
# 環境変数SYSTEM_PROMPT_MARIから取得、なければデフォルトを使用
return os.getenv("SYSTEM_PROMPT_MARI", default_prompt)
# --- 5. コア機能の関数定義 (変更なし) ---
sentiment_analyzer = None
def get_sentiment_analyzer():
# (中身は変更なし)
global sentiment_analyzer
if sentiment_analyzer is None:
try:
from transformers import pipeline
sentiment_analyzer = pipeline("sentiment-analysis", model="koheiduck/bert-japanese-finetuned-sentiment")
# logger.info("感情分析モデルのロード完了。") # 不要なログを削除
except Exception as e:
logger.error(f"感情分析モデルのロードに失敗: {e}")
return sentiment_analyzer
def call_llm(system_prompt, user_prompt, is_json_output=False):
if not client or not LLM_MODEL:
# デモモード用の固定応答
if is_json_output:
return '{"scene": "none"}'
return "は?何それ。あたしに話しかけてるの?"
# 入力検証
if not isinstance(system_prompt, str) or not isinstance(user_prompt, str):
logger.error(f"プロンプトが文字列ではありません: system={type(system_prompt)}, user={type(user_prompt)}")
if is_json_output:
return '{"scene": "none"}'
return "…なんか変なこと言ってない?"
messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}]
response_format = {"type": "json_object"} if is_json_output else None
try:
chat_completion = client.chat.completions.create(
messages=messages,
model=LLM_MODEL,
temperature=0.8,
max_tokens=500,
response_format=response_format
)
content = chat_completion.choices[0].message.content
if not content:
logger.warning("API応答が空です")
if is_json_output:
return '{"scene": "none"}'
return "…言葉が出てこない。"
return content
except Exception as e:
logger.error(f"API呼び出しエラー: {e}")
if is_json_output:
return '{"scene": "none"}'
return "…システムの調子が悪いみたい。"
def detect_scene_change(history, message):
# (この関数はrespond内で直接ロジックを記述するため、呼び出し箇所はありません)
return None
def generate_dialogue(history, message, affection, stage_name, scene_params, instruction=None):
if not isinstance(history, list):
history = []
if not isinstance(scene_params, dict):
scene_params = {"theme": "default"}
if not isinstance(message, str):
message = ""
# 履歴を効率的に処理(最新5件のみ)
recent_history = history[-5:] if len(history) > 5 else history
history_parts = []
for item in recent_history:
if isinstance(item, (list, tuple)) and len(item) >= 2:
user_msg = str(item[0]) if item[0] is not None else ""
bot_msg = str(item[1]) if item[1] is not None else ""
if user_msg or bot_msg: # 空でない場合のみ追加
history_parts.append(f"ユーザー: {user_msg}\n麻理: {bot_msg}")
history_text = "\n".join(history_parts)
current_theme = scene_params.get("theme", "default")
user_prompt = f'''# 現在の状況
- 現在地: {current_theme}
- 好感度: {affection} ({stage_name})
# 会話履歴
{history_text}
---
# 指示
{f"【特別指示】{instruction}" if instruction else f"ユーザーの発言「{message}」に応答してください。"}
麻理の応答:'''
return call_llm(get_system_prompt_mari(), user_prompt)
def get_relationship_stage(affection):
if not isinstance(affection, (int, float)):
affection = 30 # デフォルト値
if affection < 20:
return "ステージ1:敵対"
elif affection < 40:
return "ステージ2:警戒"
elif affection < 60:
return "ステージ3:中立"
elif affection < 80:
return "ステージ4:好意"
else:
return "ステージ5:親密"
def update_affection(message, affection):
if not isinstance(affection, (int, float)):
affection = 30 # デフォルト値
analyzer = get_sentiment_analyzer()
if not analyzer:
return affection
try:
if not isinstance(message, str) or len(message.strip()) == 0:
return affection
result = analyzer(message)[0]
if result.get('label') == 'positive':
return min(100, affection + 3)
elif result.get('label') == 'negative':
return max(0, affection - 3)
except Exception as e:
logger.error(f"感情分析エラー: {e}")
return affection
# --- 6. Gradio応答関数 ---
def respond(message, chat_history, affection, scene_params, limiter_state):
try:
# 履歴形式を統一(Gradio v5のmessages形式に対応)
internal_history = []
if chat_history and isinstance(chat_history, list):
# messages形式: [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}, ...]
user_msgs = []
assistant_msgs = []
for item in chat_history:
if isinstance(item, dict):
if item.get("role") == "user":
user_msgs.append(item.get("content", ""))
elif item.get("role") == "assistant":
assistant_msgs.append(item.get("content", ""))
elif isinstance(item, (list, tuple)) and len(item) == 2:
# 旧形式との互換性
internal_history.append((item[0], item[1]))
# messages形式の場合、ペアを作成
if user_msgs or assistant_msgs:
for i in range(min(len(user_msgs), len(assistant_msgs))):
internal_history.append((user_msgs[i], assistant_msgs[i]))
# 保険: ブロック状態、入力長、履歴長のチェック
if limiter_state.get("is_blocked", False):
error_msg = "(…少し混乱している。時間をおいてから、ページを再読み込みして試してくれないか?)"
chat_history.append({"role": "user", "content": message})
chat_history.append({"role": "assistant", "content": error_msg})
return chat_history, affection, scene_params, limiter_state
if len(message) > MAX_INPUT_LENGTH:
error_msg = "(…メッセージが長すぎる。もう少し短くしてくれないか?)"
chat_history.append({"role": "user", "content": message})
chat_history.append({"role": "assistant", "content": error_msg})
return chat_history, affection, scene_params, limiter_state
if len(internal_history) > MAX_HISTORY_TURNS:
internal_history = internal_history[-MAX_HISTORY_TURNS:]
new_affection = update_affection(message, affection)
stage_name = get_relationship_stage(new_affection)
if not isinstance(scene_params, dict):
scene_params = {"theme": "default"}
# シーン変更時のみコピーを作成(メモリ効率化)
final_scene_params = scene_params
bot_message = ""
if not check_limiter(limiter_state):
bot_message = "(…少し話すのが速すぎる。もう少し、ゆっくり話してくれないか?)"
else:
# デモモード時はシーン検出をスキップ
if client and LLM_MODEL:
history_text_for_detect = "\n".join([f"ユーザー: {u}\n麻理: {m}" for u, m in internal_history[-3:]])
detect_prompt = f"""以下はユーザーとキャラクターの最近の会話です:
{history_text_for_detect}
この会話において、場所の移動やシーンの変化が含まれているかを判断してください。
もし変化があれば、新しいシーンのキーワード(例: 'beach_sunset', 'shrine_day')を返してください。
変化がなければ "none" を返してください。
出力形式は必ず次のようにしてください:
{{"scene": "shrine_day"}} または {{"scene": "none"}}"""
detect_system_prompt = """あなたは会話の内容から、現在のシーンが変わるかどうかを判定するシステムです。
以下の会話履歴に基づいて、ユーザーとキャラクターが移動した「新しいシーン」があれば、その名前をJSON形式で返してください。
変化がない場合は、"none" を scene に設定してください。
利用可能なシーン:
- default: デフォルトの部屋
- room_night: 夜の部屋
- beach_sunset: 夕暮れのビーチ
- festival_night: 夜のお祭り
- shrine_day: 昼間の神社
- cafe_afternoon: 午後のカフェ
- aquarium_night: 夜の水族館
フォーマット:
{"scene": "beach_sunset"}
制約:
- JSONオブジェクト以外は絶対に出力しないでください。
- 上記のシーン名以外は使用しないでください。"""
new_scene_name_json = call_llm(detect_system_prompt, detect_prompt, is_json_output=True)
new_scene_name = None
if new_scene_name_json:
try:
parsed = json.loads(new_scene_name_json)
if isinstance(parsed, dict):
scene_value = parsed.get("scene")
# scene_valueの型を厳密にチェック
if isinstance(scene_value, str) and len(scene_value.strip()) > 0:
new_scene_name = scene_value.strip()
elif isinstance(scene_value, bool):
# bool型の場合は明示的にNoneに設定
new_scene_name = None
logger.debug(f"scene値がbool型: {scene_value}")
else:
# その他の型の場合もNoneに設定
new_scene_name = None
if scene_value is not None:
logger.debug(f"scene値が予期しない型: {scene_value} (型: {type(scene_value)})")
else:
new_scene_name = None
logger.debug(f"想定外のJSON形式: {parsed} (型: {type(parsed)})")
except (json.JSONDecodeError, TypeError, AttributeError) as e:
new_scene_name = None
logger.debug(f"JSONパースエラー: {e}")
except Exception as e:
new_scene_name = None
logger.warning(f"予期しないJSONパースエラー: {e}")
# 型安全な条件チェック
scene_name_valid = (
new_scene_name is not None and
isinstance(new_scene_name, str) and
len(new_scene_name.strip()) > 0 and
new_scene_name.strip() != "none"
)
if scene_name_valid:
# 追加の安全チェック:THEME_URLSに存在するかチェック
try:
scene_exists = new_scene_name in THEME_URLS
except TypeError:
# new_scene_nameがiterableでない場合のフォールバック
logger.warning(f"new_scene_nameの型が不正: {type(new_scene_name)} - {new_scene_name}")
scene_exists = False
if scene_exists:
current_theme = final_scene_params.get("theme", "default")
if new_scene_name != current_theme:
if not check_limiter(limiter_state):
bot_message = "(…少し考える時間がほしい)"
else:
# シーン変更時のみコピーを作成
final_scene_params = scene_params.copy()
final_scene_params["theme"] = new_scene_name
instruction = f"ユーザーと一緒に「{new_scene_name}」に来た。周囲の様子を見て、最初の感想をぶっきらぼうに一言つぶやいてください。"
bot_message = generate_dialogue(internal_history, message, new_affection, stage_name, final_scene_params, instruction)
else:
if not check_limiter(limiter_state):
bot_message = "(…少し考える時間がほしい)"
else:
bot_message = generate_dialogue(internal_history, message, new_affection, stage_name, final_scene_params)
else:
if not check_limiter(limiter_state):
bot_message = "(…少し考える時間がほしい)"
else:
bot_message = generate_dialogue(internal_history, message, new_affection, stage_name, final_scene_params)
else:
# デモモード: シーン検出なしで直接応答生成
bot_message = generate_dialogue(internal_history, message, new_affection, stage_name, final_scene_params)
# bot_messageの最終検証
if not bot_message or not isinstance(bot_message, str):
bot_message = "…なんて言えばいいか分からない。"
chat_history.append({"role": "user", "content": message})
chat_history.append({"role": "assistant", "content": bot_message})
return chat_history, new_affection, final_scene_params, limiter_state
except Exception as e:
logger.critical(f"respond関数で予期せぬエラー: {e}", exc_info=True)
error_history = chat_history or []
error_history.append({"role": "user", "content": message})
error_history.append({"role": "assistant", "content": "(ごめん、システムに予期せぬ問題が起きたみたいだ。)"})
if isinstance(limiter_state, dict):
limiter_state["is_blocked"] = True
return error_history, affection, scene_params, limiter_state
# --- 7. Gradio UIの構築 (シンプル版) ---
try:
with gr.Blocks(css="style.css", title="麻理チャット") as demo:
# 最もシンプルなState定義(スキーマエラー回避)
scene_state = gr.State()
affection_state = gr.State()
limiter_state = gr.State()
# シンプルなHTML
background_display = gr.HTML()
gr.Markdown("# 麻理チャット")
# 最もシンプルなChatbot定義
chatbot = gr.Chatbot()
# シンプルなTextbox
msg_input = gr.Textbox()
submit_btn = gr.Button("送信")
# シンプルなコンポーネント
stage_display = gr.Textbox()
affection_gauge = gr.Slider()
gr.Markdown("""<div class='footer'>Made with Gradio & Together AI. Background photos from Unsplash.</div>""")
def handle_submit(message, history, affection, scene_params, limiter_state):
# 入力の型安全性チェック
if not isinstance(message, str):
message = ""
if not isinstance(history, list):
history = []
if not isinstance(affection, (int, float)):
affection = 30
if not isinstance(scene_params, dict):
scene_params = {"theme": "default"}
if not isinstance(limiter_state, dict):
limiter_state = create_limiter_state()
# メッセージが空の場合は何もしない
if not message.strip():
theme_url = THEME_URLS.get(scene_params.get("theme", "default"), THEME_URLS["default"])
background_html = f'<div class="background-container" style="background-image: url({theme_url});"></div>'
return "", history, affection, get_relationship_stage(affection), scene_params, limiter_state, background_html
try:
new_history, new_affection, new_scene_params, new_limiter_state = respond(message, history, affection, scene_params, limiter_state)
new_stage = get_relationship_stage(new_affection)
theme_url = THEME_URLS.get(new_scene_params.get("theme", "default"), THEME_URLS["default"])
new_background_html = f'<div class="background-container" style="background-image: url({theme_url});"></div>'
return "", new_history, new_affection, new_stage, new_scene_params, new_limiter_state, new_background_html
except Exception as e:
logger.error(f"handle_submit エラー: {e}")
# エラー時は現在の状態を維持
theme_url = THEME_URLS.get(scene_params.get("theme", "default"), THEME_URLS["default"])
background_html = f'<div class="background-container" style="background-image: url({theme_url});"></div>'
return "", history, affection, get_relationship_stage(affection), scene_params, limiter_state, background_html
# イベントリスナーのチェーンを改善(Gradio 5対応)
submit_components = [msg_input, chatbot, affection_state, scene_state, limiter_state]
output_components = [msg_input, chatbot, affection_gauge, stage_display, scene_state, limiter_state, background_display]
submit_btn.click(
handle_submit,
inputs=submit_components,
outputs=output_components
)
msg_input.submit(
handle_submit,
inputs=submit_components,
outputs=output_components
)
# 初期化関数
def initialize_app():
return (
{"theme": "default"}, # scene_state
30, # affection_state
create_limiter_state(), # limiter_state
get_relationship_stage(30), # stage_display
30, # affection_gauge
f'<div class="background-container" style="background-image: url({THEME_URLS["default"]});"></div>' # background_display
)
# demo.load()でアプリを初期化
demo.load(
initialize_app,
outputs=[scene_state, affection_state, limiter_state, stage_display, affection_gauge, background_display]
)
except Exception as e:
logger.critical(f"Gradio UI構築エラー: {e}", exc_info=True)
# フォールバック用の最小限のUI
with gr.Blocks() as demo:
gr.Markdown("# システムエラー")
gr.Markdown("アプリケーションの初期化中にエラーが発生しました。")
if __name__ == "__main__":
# アプリケーション起動前にモデルをプリロード
try:
get_sentiment_analyzer()
except Exception as e:
logger.warning(f"感情分析モデルのロードに失敗: {e}")
# Hugging Face Spaces環境の検出
is_spaces = (
os.getenv("SPACE_ID") is not None or
os.getenv("SPACES_ZERO_GPU") is not None or
os.getenv("HF_TOKEN") is not None or
os.getenv("SYSTEM") == "spaces" or
"huggingface.co" in os.getenv("SPACE_HOST", "")
)
# 環境に応じたポート設定
logger.info(f"Environment detection - is_spaces: {is_spaces}")
logger.info(f"SPACE_ID: {os.getenv('SPACE_ID')}")
logger.info(f"SYSTEM: {os.getenv('SYSTEM')}")
if is_spaces:
# Hugging Face Spacesでは自動的にポートが割り当てられる
logger.info("Starting server for Hugging Face Spaces")
else:
port = int(os.getenv("PORT", 7860))
logger.info(f"Starting server on port {port}")
try:
# 最もシンプルなlaunch設定
demo.launch()
except Exception as e:
logger.error(f"アプリケーション起動エラー: {e}")
# フォールバック: shareableリンクで起動
logger.info("フォールバック: shareableリンクで起動します")
demo.launch(share=True, show_error=True)