|
|
import gradio as gr
|
|
|
from openai import OpenAI
|
|
|
import os
|
|
|
import json
|
|
|
from dotenv import load_dotenv
|
|
|
import logging
|
|
|
import time
|
|
|
|
|
|
|
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
|
logger = logging.getLogger(__name__)
|
|
|
load_dotenv()
|
|
|
|
|
|
|
|
|
RATE_LIMIT_MAX_REQUESTS = 15
|
|
|
RATE_LIMIT_IN_SECONDS = 60
|
|
|
MAX_INPUT_LENGTH = 1000
|
|
|
MAX_HISTORY_TURNS = 50
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
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」。
|
|
|
でも、少しだけ「信じたい」と思っている。
|
|
|
その感情の揺らぎを、会話の中で演じてください。
|
|
|
"""
|
|
|
|
|
|
|
|
|
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):
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
def respond(message, chat_history, affection, scene_params, limiter_state):
|
|
|
try:
|
|
|
|
|
|
internal_history = []
|
|
|
if chat_history and isinstance(chat_history, list):
|
|
|
|
|
|
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:
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
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))) |