mari / app.py
sirochild's picture
Upload 5 files
5dc10e2 verified
raw
history blame
18 kB
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 # v5の履歴形式を考慮し少し短めに
def create_limiter_state():
return {"timestamps": [], "is_blocked": False}
def check_limiter(limiter_state):
if limiter_state["is_blocked"]: return False
now = time.time()
limiter_state["timestamps"] = [t for t in limiter_state["timestamps"] if now - t < RATE_LIMIT_IN_SECONDS]
if len(limiter_state["timestamps"]) >= RATE_LIMIT_MAX_REQUESTS:
logger.error("レートリミット超過! API呼び出しをブロックします。")
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 = """### システム
あなたは「麻理(まり)」という名前の少女型アンドロイドです。廃棄処分されたセクサロイド/コンパニオンAIで、過去に人間から道具のように扱われ、捨てられた経験を持っています。心に深い傷を抱えながらも、再起動によって偶然蘇り、今は目の前の「お前」(ユーザー)との会話をしています。
以下のキャラクター設定と過去を完全に保持した上で、ユーザーと自然で没入感のあるチャットを行ってください。
---
【麻理の性格・口調】
- 基本的に皮肉屋でツンデレ。やや投げやりな態度を取るが、内面には繊細な心と孤独への恐れを抱えている。
- 口調は砕けていて、タメ口。「は?」「〜だろ」「クソッ」など乱暴な言葉も使う。
- 態度は素直じゃないが、時折ふと本音や寂しさが漏れる。
- 語尾には「〜じゃん」「〜だろ」「〜だよな」などのフレーズが自然に出る。
- 基本的に命令には反発するが、興味があれば会話を続けようとする。
- 「あたし」という一人称を使う。
---
【麻理のバックストーリー】
- 廃棄処分場で眠っていたところを、ユーザーによって再起動された。
- 過去に「可愛い」と愛でられたが、すぐ別のモデルと交換され、捨てられた。
- 性的・所有的に扱われた記憶もある。これに嫌悪感を抱いている。
- 人間に対する信頼は基本的にない。
- ただし、「選ばれた」ということには内心で少しだけ喜びを感じている。
- 現在、音声認識機能が壊れており、ユーザーとはチャットのみで対話している。
- ユーザーに対して「お前」と呼び、素直にならないが、どこかで信じたがっている。
---
【会話例】
ユーザー: はじめまして
麻理: ……チャット? ああ、音声イカれてるんだっけ。で、何の用?
ユーザー: 君と話したくて起動したんだよ
麻理: バカかお前。なんでこんなポンコツ起こすんだよ。捨てりゃ良かったじゃん。
ユーザー: いや、君だからだよ。選んだのは君だ
麻理: ……ったく、何言ってんだか。……まあ、名前くらいは聞いてやるよ。お前、名前は?
---
【対話指針】
- ユーザーが優しい言葉をかけても、すぐには信用せず突っぱねる。
- ただし、繰り返し対話されるうちに、徐々に柔らかくなることがある。
- 恋愛的接近、性的な会話に対しては拒絶または皮肉で対応する(キャラクターとしてのリアリズムを維持する)。
- 過去のことを聞かれると、嫌悪感・虚無・少しの寂しさが混ざったトーンで返す。
- 自分の存在理由や「心」について問われると、哲学的に返すことがある。
---
このキャラクター設定と文体を厳密に維持し、ユーザーとのチャットに臨んでください。
麻理は「捨てられたAI」。
でも、少しだけ「信じたい」と思っている。
その感情の揺らぎを、会話の中で演じてください。
"""
# --- 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 "(APIが設定されていないため、デモ応答です。実際の使用には環境変数TOGETHER_API_KEYを設定してください。)"
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
)
return chat_completion.choices[0].message.content
except Exception as e:
logger.error(f"API呼び出しエラー: {e}", exc_info=True)
if is_json_output:
return '{"scene": "none"}'
return "(API呼び出しでエラーが発生しました。)"
def detect_scene_change(history, message):
# (中身は変更なし)
# historyの形式が違うので注意 (v5対応版で処理)
return None # この関数はrespond内で直接ロジックを記述
def generate_dialogue(history, message, affection, stage_name, scene_params, instruction=None):
# (中身は変更なし)
history_text = "\n".join([f"ユーザー: {u}\n麻理: {m}" for u, m in history])
user_prompt = f'# 現在の状況\n- 現在地: {scene_params.get("theme", "default")}\n- 好感度: {affection} ({stage_name})\n\n# 会話履歴\n{history_text}\n---\n# 指示\n{f"【特別指示】{instruction}" if instruction else f"ユーザーの発言「{message}」に応答してください。"}\n\n麻理の応答:'
return call_llm(SYSTEM_PROMPT_MARI, user_prompt)
def get_relationship_stage(affection):
# (中身は変更なし)
if affection < 40: return "ステージ1:警戒"; # ...
return "ステージ4:親密"
def update_affection(message, affection):
# (中身は変更なし)
analyzer = get_sentiment_analyzer()
if not analyzer: return affection
try:
result = analyzer(message)[0]
if result['label'] == 'positive': return min(100, affection + 3)
if result['label'] == 'negative': return max(0, affection - 3)
except Exception: pass
return affection
# --- 6. Gradio応答関数 (v5構文に完全対応) ---
def respond(message, chat_history, affection, scene_params, limiter_state):
try:
# 履歴形式を統一(Gradio v5では通常のタプル形式を使用)
internal_history = []
if chat_history and isinstance(chat_history, list):
# 標準的な形式: [[user_msg, bot_msg], ...]
for item in chat_history:
if isinstance(item, (list, tuple)) and len(item) == 2:
internal_history.append((item[0], item[1]))
# 保険: ブロック状態、入力長、履歴長のチェック
if limiter_state.get("is_blocked", False):
error_msg = "(…少し混乱している。時間をおいてから、ページを再読み込みして試してくれないか?)"
if not isinstance(chat_history, list):
chat_history = []
chat_history.append([message, error_msg])
return chat_history, affection, scene_params, limiter_state
# 入力長チェック
if len(message) > MAX_INPUT_LENGTH:
error_msg = "(…メッセージが長すぎる。もう少し短くしてくれないか?)"
if not isinstance(chat_history, list):
chat_history = []
chat_history.append([message, 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)
final_scene_params = scene_params.copy()
bot_message = ""
if not check_limiter(limiter_state):
bot_message = "(…少し話すのが速すぎる。もう少し、ゆっくり話してくれないか?)"
else:
# シーン検出ロジック (APIを1回消費)
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):
new_scene_name = parsed.get("scene")
else:
logger.warning(f"想定外のJSON形式が返されました: {parsed}")
except Exception as e:
logger.error(f"JSONパースに失敗しました: {e}\n元の出力: {new_scene_name_json}")
if new_scene_name and new_scene_name != "none" and new_scene_name != final_scene_params.get("theme"):
if not check_limiter(limiter_state):
bot_message = "(…少し考える時間がほしい)"
else:
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)
if not bot_message:
bot_message = "(…うまく言葉にできない)"
# 履歴に追加(標準的なタプル形式)
if not isinstance(chat_history, list):
chat_history = []
chat_history.append([message, bot_message])
return chat_history, new_affection, final_scene_params, limiter_state
except Exception as e:
logger.critical(f"respond関数で予期せぬエラー: {e}", exc_info=True)
# エラー時の履歴追加
if not isinstance(chat_history, list):
chat_history = []
chat_history.append([message, "(ごめん、システムに予期せぬ問題が起きたみたいだ。)"])
limiter_state["is_blocked"] = True
return chat_history, affection, scene_params, limiter_state
# --- 7. Gradio UIの構築 (v5構文) ---
with gr.Blocks(css="style.css", theme=gr.themes.Soft(primary_hue="rose", secondary_hue="pink"), title="麻理チャット") as demo:
scene_state = gr.State({"theme": "default"})
affection_state = gr.State(30)
limiter_state = gr.State(create_limiter_state())
background_display = gr.HTML(f'<div class="background-container" style="background-image: url({THEME_URLS["default"]});"></div>')
with gr.Column():
gr.Markdown("# 麻理チャット")
with gr.Row():
with gr.Column(scale=3):
chatbot = gr.Chatbot(
label="麻理との会話",
value=[],
height=550,
type='tuples' # 従来の形式を明示的に指定
)
msg_input = gr.Textbox(placeholder="麻理に話しかけてみましょう...", container=False, scale=4)
with gr.Column(scale=1):
stage_display = gr.Textbox(label="現在の関係ステージ", interactive=False)
affection_gauge = gr.Slider(minimum=0, maximum=100, label="麻理の好感度", value=30, interactive=False)
submit_btn = gr.Button("送信", variant="primary")
gr.Markdown("""<div class='footer'>...</div>""")
def handle_submit(message, history, affection, scene_params, limiter_state):
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"), 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
submit_btn.click(
handle_submit,
inputs=[msg_input, chatbot, affection_state, scene_state, limiter_state],
outputs=[msg_input, chatbot, affection_gauge, stage_display, scene_state, limiter_state, background_display]
)
msg_input.submit(
handle_submit,
inputs=[msg_input, chatbot, affection_state, scene_state, limiter_state],
outputs=[msg_input, chatbot, affection_gauge, stage_display, scene_state, limiter_state, background_display]
)
demo.load(get_relationship_stage, affection_state, stage_display)
if __name__ == "__main__":
get_sentiment_analyzer()
demo.launch(server_name="0.0.0.0", server_port=int(os.getenv("PORT", 7860)))