File size: 18,024 Bytes
87a7987 6c88771 a569292 bb95357 6c88771 87a7987 6c88771 a569292 d207861 1f20e09 55ea99e 6c88771 1f20e09 1b3d9b9 55ea99e 1f20e09 55ea99e 6c88771 7c8b666 6c88771 5dc10e2 7c8b666 5dc10e2 7c8b666 6c88771 5dc10e2 e0e8193 1f20e09 6c88771 1f20e09 6c88771 53dab97 5dc10e2 1f20e09 6c88771 a569292 5dc10e2 6c88771 a569292 1f20e09 5dc10e2 a569292 53dab97 1f20e09 6c88771 53dab97 1f20e09 55ea99e 1f20e09 79916a4 a569292 1f20e09 6c88771 a569292 1f20e09 6c88771 fb20ed4 6c88771 1f20e09 6c88771 5dc10e2 1f20e09 5dc10e2 1f20e09 5dc10e2 1f20e09 5dc10e2 6c88771 1f20e09 6c88771 55ea99e fb20ed4 1f20e09 529d749 5dc10e2 529d749 5dc10e2 529d749 5dc10e2 9abd0f3 1f20e09 55ea99e 1f20e09 55ea99e 1f20e09 55ea99e 1f20e09 b43a003 d21a39b 5dc10e2 1f20e09 d207861 6c88771 1f20e09 5dc10e2 55ea99e 1f20e09 e99eb43 1f20e09 6c88771 a569292 55ea99e 662ffa8 6c88771 1f20e09 6c88771 5dc10e2 1f20e09 6c88771 1f20e09 c3b07e1 1f20e09 6c88771 a569292 b43a003 6c88771 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 |
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))) |