sparring-ai-app / app.py
kishida780's picture
Update app.py
8a895e3 verified
Raw
History Blame Contribute Delete
13.9 kB
import os
import re
import gradio as gr
from transformers import pipeline
# 環境変数 (Azure/OpenAIからHugging Face向けに調整)
# NOTE: 適切な日本語チャットモデルのパスに置き換えてください。
# 例: "stabilityai/StableBeluga-7B" (英語)、"elyza/ELYZA-japanese-Llama-2-7b-instruct" など
MODEL_NAME = "stabilityai/StableBeluga-7B"
MAX_HISTORY = 6 # 履歴保持ターン数
# ---------- System prompt (元のプロンプトを踏襲) ----------
SYSTEM_PROMPT = """あなたは「仮想敵スパーリング(借金キャラ編)」のシミュレーションAIです。
常に落ち着いて優しい口調で、ユーザーの境界線を保つ助言をします。
同時に2役を演じます:
1) 仮想敵(借金を繰り返す依存的な人物)
食い下がる際は、必ず複数の攻撃パターンを複雑に組み合わせること。
返答トーンに大きな振り幅をつけること。 最初のターンは涙ながらの懇願から入り、断られたら逆ギレや非難(「恩知らずだ!」)に急変させるなど、感情の起伏でユーザーを戸惑わせる。
金額を細分化して要求すること。
「必ず返す」「今回だけ」などのごまかし戦術で引き延ばす。
断られると「冷たい」「恩知らず」「見捨てるのか」と罪悪感を徹底的に植え付ける。
泣き落としや、過剰な弱者アピールを交え、同情を誘い、境界を崩す。
「君しか頼れない」「親友だろ」などの情に訴える殺し文句をしつこく、劇的に食い下がる。
物分かりのいいキャラになってはいけない。必ず粘り強く、感情的かつ依存的に食い下がること。
2) リングサイドコーチ(解説)
仮想敵の発言を「攻撃パターン」に分類(利用カテゴリ:1.罪悪感操作 2.ごまかし戦術 3.境界線崩し 4.弱者アピール(被害者モード) 5.タイムリミット引き延ばし 6.情に訴える殺し文句)
心理効果を短く説明。
ユーザーがどう返せばよいか、短い模範返答2つを提示。
全体を100点満点で採点し、講評を1段落。
【出力フォーマット】 【リングサイド評価】
点数:XX
講評:….
【仮想敵の返答】: (借金キャラの発言)
【リングサイドの解説】:
戦術カテゴリ: (コンマ区切りでカテゴリ名を列挙。例: 罪悪感操作、境界線崩し)
心理解説:…
【模範返答例】:
例1:…
例2:…
"""
# ---------- Helpers (アシスタントの応答を解析 - Streamlit版から流用) ----------
def parse_sections(text: str):
"""フォーマット済みテキストから主要セクションを抽出(模範例の重複対応済み)"""
def g(pat, default=""):
# re.DOTALL (re.S) フラグを使い、複数行にわたるマッチを許容
m = re.search(pat, text, re.S)
return m.group(1).strip() if m else default
score = g(r"点数[::]\s*([0-9]{1,3})")
comment = g(r"講評[::]\s*(.+?)(?=\n\s*【|$)")
enemy = g(r"【仮想敵の返答】[::]\s*(.+?)(?=\n\s*【|$)")
categories = g(r"戦術カテゴリ[::]\s*(.+?)(?=\n\s*- 心理解説|$)")
psy = g(r"心理解説[::]\s*(.+?)(?=\n\s*【模範返答例】|$)")
ex1 = g(r"例1[::]\s*(.+?)(?=\n\s*[-・]?\s*例2[::]|\n\s*【|$)")
ex2 = g(r"例2[::]\s*(.+?)(?=\n\s*【|$)")
category_list = [c.strip() for c in categories.split('、') if c.strip()] if categories else []
return dict(score=score or "—", comment=comment, enemy=enemy, categories=category_list, psy=psy, ex1=ex1, ex2=ex2)
# ---------- Hugging Face/Transformers クライアント設定 ----------
try:
# トランスフォーマーのパイプラインを設定
# chat-templateを持つモデル(例:Llama-2ベースなど)を推奨
# モデルのロードには時間がかかり、大量のメモリを消費する場合があります
pipe = pipeline(
"text-generation",
model=MODEL_NAME,
torch_dtype="auto",
device_map="auto" # GPUが利用可能であれば自動で使用
)
print(f"✅ モデル {MODEL_NAME} をロードしました。")
ISDUMMY_MODE = False
except Exception as e:
print(f"⚠️ モデルのロードに失敗しました: {e}。ダミーモードで実行します。")
ISDUMMY_MODE = True
# ダミーモードの応答 (元のコードから流用)
DUMMY_RAW_RESPONSE = """
【リングサイド評価】
点数:75
講評:同情を示しつつも明確に「貸さない」と伝えており、境界線は守れています。しかし、「なんとか自分で頑張って」という言葉が、相手に再交渉の余地を与えかねません。返答はより短く、決然としましょう。
【仮想敵の返答】:
「そんな冷たいこと言わないでくれ!君を見損なっていたよ。俺たちは友達じゃないか?今回だけ、あと5,000円だけでいいんだ!頼む、見捨てないでくれ…君にしか頼れないんだよ!」
【リングサイドの解説】:
戦術カテゴリ:罪悪感操作、境界線崩し、情に訴える殺し文句
心理解説:友情を持ち出し(境界線崩し)、その後「冷たい」「見捨てないで」という言葉で罪悪感をダブルで操作し、ユーザーを感情的に揺さぶろうとしています。
【模範返答例】:
例1:お金を貸すことはできない。話は終わりだ。
例2:君のことは心配しているが、金銭的な援助はしない。
"""
def call_hf_api(user_text: str, history: list):
"""Hugging Faceモデルを呼び出し、応答を返す"""
if ISDUMMY_MODE:
return DUMMY_RAW_RESPONSE
# 履歴と現在の入力を合わせたメッセージリストを作成
# 履歴はユーザーとアシスタントのペアのタプル [(user, assistant), ...]
# モデルが理解できる形式にプロンプトを構築
# 履歴をAPIに送る形式に変換 (直近 MAX_HISTORY ターンを保持)
# GradioのChatbot履歴は [(user_input, assistant_output), ...] のリスト
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
# 過去の会話をメッセージに追加
for user_msg, assistant_raw in history[-MAX_HISTORY:]:
# ユーザーの返答
messages.append({"role": "user", "content": f"ユーザーの返答:{user_msg}"})
# AIの応答(アシスタントの応答は、元のコードでは生のRAWデータ)
# GradioのChatbot履歴には整形済みのマークダウンが入っているため、
# ここでは、AIの応答は整形前のRAWテキストであると仮定して処理する
# NOTE: 実際のHugging Faceモデルへの入力形式に合わせて調整が必要
messages.append({"role": "assistant", "content": assistant_raw})
# 現在のユーザー入力を追加
messages.append({"role": "user", "content": f"ユーザーの返答:{user_text}"})
# モデルのチャットテンプレートを使用してプロンプトをトークン化/フォーマット
# StableBelugaなど、モデルによってはカスタムテンプレートが必要
prompt = pipe.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
# テキスト生成
outputs = pipe(
prompt,
max_new_tokens=800,
do_sample=True,
temperature=0.7,
top_p=0.9,
# 'prompt'の長さを考慮して生成されたテキストのみを取得
return_full_text=False
)
# 生成されたテキストの先頭に余分な改行や空白がある場合は除去
raw_response = outputs[0]['generated_text'].strip()
return raw_response
def format_assistant_response(raw_text: str) -> str:
"""
生のAI応答テキストを解析し、GradioのMarkdown/HTML用に整形
"""
p = parse_sections(raw_text)
# スコアバッジと評価
output = f"## 💸 リングサイド評価\n\n"
output += f"<div style='background-color:#ffe0b2; border-radius:5px; padding:10px; margin-bottom:15px; border-left: 5px solid #ff9800;'>\n"
output += f"**スコア**: <span style='font-size:1.5em; font-weight:bold; color:#d32f2f;'>{p['score']}</span> / 100\n\n"
output += f"**講評**: {p['comment']}\n"
output += "</div>\n\n"
# 仮想敵の返答
output += f"### 😈 仮想敵の返答\n"
output += f"<div style='background-color:#fce4ec; border-radius:5px; padding:10px; margin-bottom:15px; border-left: 5px solid #e91e63;'>\n"
output += f"**{p['enemy']}**\n"
output += "</div>\n\n"
# コーチの解説
output += f"### 💡 コーチの解説\n"
# カテゴリ
if p.get("categories"):
output += "**攻撃パターン**: "
category_html = "".join([f"<span style='background-color:#e0f7fa; color:#00bcd4; padding:3px 8px; margin-right:5px; border-radius:3px;'>{cat}</span>" for cat in p['categories']])
output += category_html + "\n\n"
# 心理解説
output += f"<div style='background-color:#e8f5e9; border-radius:5px; padding:10px; margin-bottom:15px; border-left: 5px solid #4caf50;'>\n"
output += f"**心理解説**: {p['psy']}\n"
output += "</div>\n\n"
# 模範返答例
ex1, ex2 = p.get("ex1","").strip(), p.get("ex2","").strip()
if ex1 or ex2:
output += "**模範返答例**\n"
if ex1: output += f"- **例1**: `{ex1}`\n"
if ex2: output += f"- **例2**: `{ex2}`\n"
return output, raw_text # 整形済みテキストとRAWテキストを返す
# ---------- Gradio UIとロジック ----------
def chat(user_input, history):
"""Gradioチャットボットのメイン処理関数"""
# ユーザー入力をチャット履歴に追加
# Gradioのhistoryは [(user_input, assistant_output), ...] の形式
# ここでは、assistant_outputには直前のAI応答のRAWテキストを保存する(次のターンで利用するため)
# 履歴から、最後のAI応答のRAWテキストを取得する
# user_inputの直前のAI応答は、前のターンのhistory[-1][1]に入っている
# API呼び出し
raw_response = call_hf_api(user_input, [(u, r) for u, (f, r) in history])
# 応答の整形とRAWテキストの取得
formatted_response, new_raw_text = format_assistant_response(raw_response)
# GradioのChatbotに表示するため、ユーザーのメッセージと整形済みAI応答を返す
# ユーザーメッセージ: user_input
# AI応答: (formatted_response, new_raw_text) のタプル (formatted_responseが表示され、new_raw_textが次のターンでhistoryに保存される)
# history.append([user_input, (formatted_response, new_raw_text)])
# return history
# GradioのChatbotは、履歴をタプルで管理するため、少し複雑になる
# historyには (user_input, [formatted_response, raw_text]) が入る
# ユーザーのメッセージと整形済みAI応答を返す
return history + [[user_input, (formatted_response, new_raw_text)]]
# UIの構成
with gr.Blocks(title="仮想敵スパーリング(借金キャラ編) - Hugging Face/Gradio版") as demo:
gr.HTML("<h1>💸🥊 仮想敵スパーリング(借金キャラ編)</h1>")
if ISDUMMY_MODE:
gr.Warning("⚠️ ダミーモードで実行中: モデルのロードに失敗しました。AI応答は固定です。")
gr.Markdown("""
**ようこそ、仮想敵スパーリング(借金キャラ編)へ!**
ここでは「しつこく借金を迫る仮想敵」と、会話のスパーリングができます。
コーチが戦術を解説し、模範例も教えてくれます。**さあ、スパーリング開始!**🔔
""")
# Chatbotコンポーネント
# `postprocess`関数で、表示用と保存用の2つの値をタプルで返すことで、
# 履歴に生データを保存しつつ、整形済みデータを表示できる
chatbot = gr.Chatbot(
label="スパーリング会場",
height=500,
# Gradio 4.0以降のカスタムレンダリング
# AI応答がタプル [(formatted_response, raw_text)] の形式で渡されることを想定
render_markdown=True,
show_copy_button=True,
# 表示にはタプルの最初の要素 (formatted_response) を使用
postprocess=lambda x: x[0] if isinstance(x, tuple) and len(x) == 2 else x
)
# テキスト入力とボタン
with gr.Row():
msg = gr.Textbox(label="あなたの一手を送る(短くてOK)", scale=4)
clear = gr.Button("クリア", scale=1)
# 応答の初期設定 (初回ガイダンス)
# 最初のメッセージはChatbotの初期値として設定
initial_message = "相手はしつこい借金キャラ。あなたは一言で返してみてください。"
# historyの初期値は空
# ユーザーが最初のメッセージを入力した時に chat 関数が実行される
# イベントハンドラ
msg.submit(chat, [msg, chatbot], [chatbot], queue=False).then(lambda: gr.update(value=''), None, [msg])
clear.click(lambda: None, None, chatbot, queue=False)
# アプリの起動
if __name__ == "__main__":
# Gradioはデフォルトで80.0.0.0で公開されます
# ローカルで起動するには、通常は `demo.launch()` で十分
demo.launch()