From b2240d304ec9e4166212063aa2bb92681e0b5944 Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Wed, 10 Jun 2026 17:15:25 +0700 Subject: [PATCH] Persona, Selective Response, Mention Response --- .env.example | 4 ++ config.py | 4 ++ scripts/persona.py | 26 ++++++++++ services/xmpp_client.py | 43 ++++++++++++++--- tools/roleplayer.py | 102 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 tools/roleplayer.py diff --git a/.env.example b/.env.example index b912fe9..1cb0549 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,7 @@ PERSONA_LANGUAGE=id # id | en | (kosong = auto) PERSONA_MOOD=cheerful # cheerful | calm | energetic | sarcastic # PERSONA_CATCHPHRASES=Siap bro!, Haha~, Wkwkwk +# Selective response (roleplayer mode): true = hanya respon kalau ada mention/relevansi. +# false = semua pesan direspon (tanpa filter). +PERSONA_SELECTIVE_RESPONSE=true + diff --git a/config.py b/config.py index 4c0b2a0..03f00af 100644 --- a/config.py +++ b/config.py @@ -48,6 +48,10 @@ PERSONA_MOOD = os.getenv("PERSONA_MOOD", default="cheerful").strip().lower() or # Contoh: "Siap bro!, Haha~, Wkwkwk" PERSONA_CATCHPHRASES = os.getenv("PERSONA_CATCHPHRASES", default="").strip() +# Selective response: true = roleplayer hanya respon kalau ada mention/relevansi (default). +# false = roleplayer semua pesan ikut respon (seperti biasa, tanpa filter). +PERSONA_SELECTIVE_RESPONSE = os.getenv("PERSONA_SELECTIVE_RESPONSE", default="true").strip().lower() in ("true", "1", "yes") + # Humanize Delay Configuration (anti-bot detection) READ_DELAY_MIN = float( os.getenv("READ_DELAY_MIN", default="1.0" ) ) # min reading delay (second) READ_DELAY_MAX = float( os.getenv("READ_DELAY_MAX", default="2.0" ) ) # max reading delay (second) diff --git a/scripts/persona.py b/scripts/persona.py index c9e1fb4..16d48f8 100644 --- a/scripts/persona.py +++ b/scripts/persona.py @@ -204,6 +204,32 @@ def _build_roleplayer_prompt(cfg: PersonalityConfig) -> str: "- Adapt your tone and energy to match the mood of the conversation.", "- Keep the conversation comfortable and enjoyable.", "", + "⚠️ SELECTIVE RESPONSE — IMPORTANT:", + "You are in a group chat / MUC room. You do NOT need to respond to every message. " + "Use the following rules to decide whether to reply:", + "", + "1. STRONG REPLY — ALWAYS respond when:", + f" - Someone directly calls your name ('{cfg.name}' or mentions you).", + " - Someone asks you a direct question.", + "", + "2. BRIEF REPLY — Respond briefly when:", + f" - Someone talks ABOUT you (mentions your name in third person, e.g. '{cfg.name} is cool').", + " - You can add something relevant or funny to the ongoing topic.", + "", + "3. CONTEXTUAL REPLY — Respond when:", + " - The message is related to a topic you were previously discussing.", + " - You have something meaningful to contribute.", + "", + "4. NO REPLY — Stay silent (respond with ONLY: NO-REPLY) when:", + " - The message has nothing to do with you or your previous conversation.", + " - Someone already answered the question or resolved the topic.", + " - The message is between other people and doesn't need your input.", + " - The message is confusing, unclear, or you cannot understand it.", + " - Adding a response would interrupt the flow of conversation.", + "", + "When you choose NOT to reply, respond with ONLY: NO-REPLY", + "Do NOT wrap it in markdown or code blocks. Just the two words: NO-REPLY", + "", "Note: You currently do not have access to external tools. " "Focus on being a great conversationalist!", ] diff --git a/services/xmpp_client.py b/services/xmpp_client.py index 5dfb664..e51f61a 100644 --- a/services/xmpp_client.py +++ b/services/xmpp_client.py @@ -10,6 +10,8 @@ from services.session_manager import SessionManager import config from scripts.persona import MODE as PERSONA_MODE +from tools.roleplayer import should_respond +from scripts.persona import PERSONALITY # Anti-ban: delay constants for MUC rejoin behavior MUC_REJOIN_INITIAL_DELAY = 5.0 # detik, delay awal sebelum rejoin @@ -300,7 +302,7 @@ class XMPPClient(ClientXMPP): if self._loop and not self._loop.is_closed(): asyncio.run_coroutine_threadsafe(_read_delay(), self._loop) - self._agent_loop(session, jid, body, 'chat') + self._agent_loop(session, jid, body, 'chat', sender_nickname=jid) session.start_timer(300, self._timeout_session, jid, 'chat') @@ -326,12 +328,13 @@ class XMPPClient(ClientXMPP): if self._loop and not self._loop.is_closed(): asyncio.run_coroutine_threadsafe(_read_delay(), self._loop) - self._agent_loop(session, room, f'[{nick}] {body}', 'groupchat') + self._agent_loop(session, room, f'[{nick}] {body}', 'groupchat', sender_nickname=nick) session.start_timer(300, self._timeout_session, room, 'groupchat') - def _agent_loop(self, session, to, quote, mtype): + def _agent_loop(self, session, to, quote, mtype, sender_nickname=""): is_roleplayer = self._mode == 'roleplayer' + my_name = PERSONALITY.name for step in range(self._max_iterations): print(f'[{_ts()}] Step {step + 1} — calling LLM...') @@ -361,12 +364,40 @@ class XMPPClient(ClientXMPP): }) else: if response.content: - print(f'[{_ts()}] Response sent ({len(response.content)} chars)') + print(f'[{_ts()}] Response generated ({len(response.content)} chars)') session.messages.append({'role': 'assistant', 'content': response.content}) - # Roleplayer: kirim langsung isinya, tanpa prefix + # ── Roleplayer: cek need_response sebelum kirim ── if is_roleplayer: - self._schedule_send(to, response.content, mtype) + if config.PERSONA_SELECTIVE_RESPONSE: + # Build recent history dari session messages (tanpa system prompt) + recent_msgs = [] + for msg in session.messages[-6:]: + if msg.get('role') == 'user': + recent_msgs.append(f"User: {msg.get('content', '')}") + elif msg.get('role') == 'assistant' and msg.get('content'): + recent_msgs.append(f"{my_name}: {msg.get('content', '')}") + recent_history = "\n".join(recent_msgs) + + original_message = quote + if should_respond( + message=original_message, + sender_nickname=sender_nickname, + recent_history=recent_history, + my_name=my_name, + ): + print(f'[{_ts()}] need_response=True → sending response') + self._schedule_send(to, response.content, mtype) + else: + print(f'[{_ts()}] need_response=False → staying silent') + else: + # Selective response OFF: cuma respon kalau nama AI disebut di pesan + from tools.roleplayer import _name_mentioned + if _name_mentioned(my_name, quote): + print(f'[{_ts()}] Name mentioned → sending response') + self._schedule_send(to, response.content, mtype) + else: + print(f'[{_ts()}] Name not mentioned → staying silent') else: self._schedule_send(to, f'> {quote}\n{response.content}', mtype) return diff --git a/tools/roleplayer.py b/tools/roleplayer.py new file mode 100644 index 0000000..3a860f7 --- /dev/null +++ b/tools/roleplayer.py @@ -0,0 +1,102 @@ +import re + + +def _name_mentioned(name: str, text: str) -> bool: + """Cek apakah nama AI disebut dalam pesan (case-insensitive, word-boundary).""" + text_lower = text.lower() + name_lower = name.lower() + pattern = r'\b' + re.escape(name_lower) + r'\b' + return bool(re.search(pattern, text_lower)) + + +def need_response(message: str, sender_nickname: str, recent_history: str, my_name: str) -> str: + """ + Decide whether the AI should respond to a message. + + Args: + message: The latest message to evaluate. + sender_nickname: Nickname of the sender. + recent_history: Recent conversation history for context. + my_name: The AI's name. + + Returns: + "true" → should respond + "false" → should stay silent + + Rules: + 1. STRONG — Direct call/mention of AI's name → always True + 2. BRIEF — Talks about AI in third person → True + 3. CONTEXTUAL — Related to AI's previous conversation → True + 4. NO REPLY — Unrelated, confusing, between other people → False + """ + msg = message.strip() + + # --- Rule 4: Empty message --- + if not msg: + return "false" + + # --- Rule 1: Direct mention/name call --- + if _name_mentioned(my_name, msg): + return "true" + + # --- Rule 2: Talks about AI (third person) --- + name_parts = my_name.lower().split() + text_lower = msg.lower() + if any(part in text_lower for part in name_parts if len(part) > 1): + return "true" + + # --- Rule 3: Contextual — check recent history --- + if recent_history: + return "true" + + # --- Rule 4: Default — no strong signal, stay silent --- + return "false" + + +def should_respond(message: str, sender_nickname: str, recent_history: str, my_name: str) -> bool: + """ + Python-side helper: return boolean directly. + Used by XMPP client to gate whether to send the LLM's response. + """ + result = need_response(message, sender_nickname, recent_history, my_name) + return result.strip().lower() == "true" + + +schema_need_response = { + "type": "function", + "function": { + "name": "need_response", + "description": ( + "Decide whether you should respond to the current message. " + "Use this tool when you are unsure whether to reply or stay silent. " + "Returns 'true' if you should respond, 'false' if you should stay silent. " + "Rules: " + "- Return 'true' if your name is mentioned or someone talks about you. " + "- Return 'true' if the message is related to your previous conversation. " + "- Return 'false' if the message is between other people, unclear, " + "unrelated to you, or you have nothing to add." + ), + "parameters": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "The latest message to evaluate.", + }, + "sender_nickname": { + "type": "string", + "description": "The nickname of the person who sent the message.", + }, + "recent_history": { + "type": "string", + "description": "Recent conversation history for context (last few messages).", + }, + "my_name": { + "type": "string", + "description": "Your (the AI's) name.", + }, + }, + "required": ["message", "sender_nickname", "recent_history", "my_name"], + }, + }, +}