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)))