From 890772f70e2a197b9389569eea36a0430ae05c11 Mon Sep 17 00:00:00 2001 From: z060142 Date: Thu, 15 May 2025 02:16:24 +0800 Subject: [PATCH 1/8] Add message deduplication system and UI fallback handling for updated game states - Implemented `MessageDeduplication` class to suppress duplicate bot replies: - Normalizes sender and message content for reliable comparison. - Tracks processed messages with timestamp-based expiry (default 1 hour). - Integrated into `run_ui_monitoring_loop()` with support for F7/F8-based history resets. - Periodic cleanup thread purges expired entries every 10 minutes. - Added new UI fallback handling logic to address post-update game state changes: - Detects `chat_option.png` overlay before bubble detection and presses ESC to dismiss. - Detects `update_confirm.png` when chat room state is unavailable and clicks it to proceed. - Both behaviors improve UI stability following game version changes. - Updated `essential_templates` dictionary and constants with the two new template paths: - `chat_option.png` - `update_confirm.png` These improvements reduce redundant bot responses and enhance UI resilience against inconsistent or obstructed states in the latest game versions. --- main.py | 28 ++++++- templates/chat_option.png | Bin 0 -> 3393 bytes templates/update_confirm.png | Bin 0 -> 5152 bytes ui_interaction.py | 137 +++++++++++++++++++++++++++++++---- 4 files changed, 147 insertions(+), 18 deletions(-) create mode 100644 templates/chat_option.png create mode 100644 templates/update_confirm.png diff --git a/main.py b/main.py index 81081be..bd42f58 100644 --- a/main.py +++ b/main.py @@ -16,6 +16,8 @@ from mcp import ClientSession, StdioServerParameters, types # --- Keyboard Imports --- import threading import time +# Import MessageDeduplication from ui_interaction +from ui_interaction import MessageDeduplication try: import keyboard # Needs pip install keyboard except ImportError: @@ -483,9 +485,12 @@ async def run_main_with_exit_stack(): # 5. Start UI Monitoring in a separate thread print("\n--- Starting UI monitoring thread ---") - # Use the new monitoring loop function, passing both queues + # 5c. Create MessageDeduplication instance + deduplicator = MessageDeduplication(expiry_seconds=3600) # Default 1 hour + + # Use the new monitoring loop function, passing both queues and the deduplicator monitor_task = loop.create_task( - asyncio.to_thread(ui_interaction.run_ui_monitoring_loop, trigger_queue, command_queue), # Pass command_queue + asyncio.to_thread(ui_interaction.run_ui_monitoring_loop, trigger_queue, command_queue, deduplicator), # Pass command_queue and deduplicator name="ui_monitor" ) ui_monitor_task = monitor_task # Store task reference for shutdown @@ -493,6 +498,25 @@ async def run_main_with_exit_stack(): # 5b. Game Window Monitoring is now handled by Setup.py + # 5d. Start Periodic Cleanup Timer for Deduplicator + def periodic_cleanup(): + if not shutdown_requested: # Only run if not shutting down + print("Main Thread: Running periodic deduplicator cleanup...") + deduplicator.purge_expired() + # Reschedule the timer + cleanup_timer = threading.Timer(600, periodic_cleanup) # 10 minutes + cleanup_timer.daemon = True + cleanup_timer.start() + else: + print("Main Thread: Shutdown requested, not rescheduling deduplicator cleanup.") + + print("\n--- Starting periodic deduplicator cleanup timer (10 min interval) ---") + initial_cleanup_timer = threading.Timer(600, periodic_cleanup) + initial_cleanup_timer.daemon = True + initial_cleanup_timer.start() + # Note: This timer will run in a separate thread. + # Ensure it's handled correctly on shutdown if it holds resources. + # Since it's a daemon thread and reschedules itself, it should exit when the main program exits. # 6. Start the main processing loop (non-blocking check on queue) print("\n--- Wolfhart chatbot has started (waiting for triggers) ---") diff --git a/templates/chat_option.png b/templates/chat_option.png new file mode 100644 index 0000000000000000000000000000000000000000..943862a1b712d579c3e7e9618ea23685809d4167 GIT binary patch literal 3393 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 X&5f(`a}lU{00000NkvXXu0mjfpNW29 literal 0 HcmV?d00001 diff --git a/templates/update_confirm.png b/templates/update_confirm.png new file mode 100644 index 0000000000000000000000000000000000000000..95cd87bc625f872929011929e25e7f34d8e17a2a GIT binary patch literal 5152 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 O0000 100: + clean_content = clean_content[:100] + + return f"{clean_sender}:{clean_content}" + + def is_duplicate(self, sender, content): + """Check if the message is a duplicate within the expiry period.""" + if not sender or not content: + return False # Missing necessary info, treat as new message + + key = self.create_key(sender, content) + current_time = time.time() + + # Check if duplicate and not expired + if key in self.processed_messages: + last_time = self.processed_messages[key] + if current_time - last_time < self.expiry_seconds: + print(f"Deduplicator: Detected duplicate message: {sender} - {content[:20]}...") + return True + + # Update processing time + self.processed_messages[key] = current_time + return False + + def purge_expired(self): + """Remove expired message records.""" + current_time = time.time() + expired_keys = [k for k, t in self.processed_messages.items() + if current_time - t >= self.expiry_seconds] + + for key in expired_keys: + del self.processed_messages[key] + + if expired_keys: # Log only if something was purged + print(f"Deduplicator: Purged {len(expired_keys)} expired message records.") + return len(expired_keys) + + def clear_all(self): + """Clear all recorded messages (for F7/F8 functionality).""" + count = len(self.processed_messages) + self.processed_messages.clear() + if count > 0: # Log only if something was cleared + print(f"Deduplicator: Cleared all {count} message records.") + return count # --- Global Pause Flag --- # Using a simple mutable object (list) for thread-safe-like access without explicit lock @@ -142,6 +200,9 @@ PROFILE_OPTION_IMG = os.path.join(TEMPLATE_DIR, "profile_option.png") COPY_NAME_BUTTON_IMG = os.path.join(TEMPLATE_DIR, "copy_name_button.png") SEND_BUTTON_IMG = os.path.join(TEMPLATE_DIR, "send_button.png") CHAT_INPUT_IMG = os.path.join(TEMPLATE_DIR, "chat_input.png") +# 新增的模板路徑 +CHAT_OPTION_IMG = os.path.join(TEMPLATE_DIR, "chat_option.png") +UPDATE_CONFIRM_IMG = os.path.join(TEMPLATE_DIR, "update_confirm.png") # State Detection PROFILE_NAME_PAGE_IMG = os.path.join(TEMPLATE_DIR, "Profile_Name_page.png") PROFILE_PAGE_IMG = os.path.join(TEMPLATE_DIR, "Profile_page.png") @@ -1629,7 +1690,7 @@ def perform_state_cleanup(detector: DetectionModule, interactor: InteractionModu # --- UI Monitoring Loop Function (To be run in a separate thread) --- -def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queue): +def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queue, deduplicator: 'MessageDeduplication'): """ Continuously monitors the UI, detects triggers, performs interactions, puts trigger data into trigger_queue, and processes commands from command_queue. @@ -1667,7 +1728,9 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu 'page_sec': PAGE_SEC_IMG, 'page_str': PAGE_STR_IMG, 'dismiss_button': DISMISS_BUTTON_IMG, 'confirm_button': CONFIRM_BUTTON_IMG, 'close_button': CLOSE_BUTTON_IMG, 'back_arrow': BACK_ARROW_IMG, - 'reply_button': REPLY_BUTTON_IMG + 'reply_button': REPLY_BUTTON_IMG, + # 添加新模板 + 'chat_option': CHAT_OPTION_IMG, 'update_confirm': UPDATE_CONFIRM_IMG, } legacy_templates = { # Deprecated Keywords (for legacy method fallback) @@ -1773,13 +1836,15 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu elif action == 'clear_history': # Added for F7 print("UI Thread: Processing clear_history command.") recent_texts.clear() - print("UI Thread: recent_texts cleared.") + deduplicator.clear_all() # Simultaneously clear deduplication records + print("UI Thread: recent_texts and deduplicator records cleared.") elif action == 'reset_state': # Added for F8 resume print("UI Thread: Processing reset_state command.") recent_texts.clear() last_processed_bubble_info = None - print("UI Thread: recent_texts cleared and last_processed_bubble_info reset.") + deduplicator.clear_all() # Simultaneously clear deduplication records + print("UI Thread: recent_texts, last_processed_bubble_info, and deduplicator records reset.") else: print(f"UI Thread: Received unknown command: {action}") @@ -1804,6 +1869,19 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu # --- If not paused, proceed with UI Monitoring --- # print("[DEBUG] UI Loop: Monitoring is active. Proceeding...") # DEBUG REMOVED + # --- 添加檢查 chat_option 狀態 --- + try: + chat_option_locs = detector._find_template('chat_option', confidence=0.8) + if chat_option_locs: + print("UI Thread: Detected chat_option overlay. Pressing ESC to dismiss...") + interactor.press_key('esc') + time.sleep(0.2) # 給一點時間讓界面響應 + print("UI Thread: Pressed ESC to dismiss chat_option. Continuing...") + continue # 重新開始循環以確保界面已清除 + except Exception as chat_opt_err: + print(f"UI Thread: Error checking for chat_option: {chat_opt_err}") + # 繼續執行,不要中斷主流程 + # --- Check for Main Screen Navigation --- # print("[DEBUG] UI Loop: Checking for main screen navigation...") # DEBUG REMOVED try: @@ -1842,8 +1920,19 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu # Use a slightly lower confidence maybe, or state_confidence chat_room_locs = detector._find_template('chat_room', confidence=detector.state_confidence) if not chat_room_locs: - print("UI Thread: Not in chat room state before bubble detection. Attempting cleanup...") - # Call the existing cleanup function to try and return + print("UI Thread: Not in chat room state before bubble detection. Checking for update confirm...") + + # 檢查是否存在更新確認按鈕 + update_confirm_locs = detector._find_template('update_confirm', confidence=0.8) + if update_confirm_locs: + print("UI Thread: Detected update_confirm button. Clicking to proceed...") + interactor.click_at(update_confirm_locs[0][0], update_confirm_locs[0][1]) + time.sleep(0.5) # 給更新過程一些時間 + print("UI Thread: Clicked update_confirm button. Continuing...") + continue # 重新開始循環以重新檢查狀態 + + # 沒有找到更新確認按鈕,繼續原有的清理邏輯 + print("UI Thread: No update_confirm button found. Attempting cleanup...") perform_state_cleanup(detector, interactor) # Regardless of cleanup success, restart the loop to re-evaluate state from the top print("UI Thread: Continuing loop after attempting chat room cleanup.") @@ -2010,16 +2099,6 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu perform_state_cleanup(detector, interactor) # Attempt cleanup continue # Skip to next bubble - # Check recent text history - # print("[DEBUG] UI Loop: Checking recent text history...") # DEBUG REMOVED - if bubble_text in recent_texts: - print(f"Content '{bubble_text[:30]}...' in recent history, skipping this bubble.") - continue # Skip to next bubble - - print(">>> New trigger event <<<") - # Add to recent texts *before* potentially long interaction - recent_texts.append(bubble_text) - # 5. Interact: Get Sender Name (uses re-location internally via retrieve_sender_name_interaction) # print("[DEBUG] UI Loop: Retrieving sender name...") # DEBUG REMOVED sender_name = None @@ -2097,6 +2176,32 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu print("Error: Could not get sender name for this bubble, skipping.") continue # Skip to next bubble + # --- Deduplication Check --- + # This is the new central point for deduplication and recent_texts logic + if sender_name and bubble_text: # Ensure both are valid before deduplication + if deduplicator.is_duplicate(sender_name, bubble_text): + print(f"UI Thread: Skipping duplicate message via Deduplicator: {sender_name} - {bubble_text[:30]}...") + # Cleanup UI state as interaction might have occurred during sender_name retrieval + perform_state_cleanup(detector, interactor) + continue # Skip this bubble + + # If not a duplicate by deduplicator, then check recent_texts (original safeguard) + if bubble_text in recent_texts: + print(f"UI Thread: Content '{bubble_text[:30]}...' in recent_texts history, skipping.") + perform_state_cleanup(detector, interactor) # Cleanup as we are skipping + continue + + # If not a duplicate by any means, add to recent_texts and proceed + print(">>> New trigger event (passed deduplication) <<<") + recent_texts.append(bubble_text) + else: + # This case implies sender_name or bubble_text was None/empty, + # which should have been caught by earlier checks. + # If somehow reached, log and skip. + print(f"Warning: sender_name ('{sender_name}') or bubble_text ('{bubble_text[:30]}...') is invalid before deduplication check. Skipping.") + perform_state_cleanup(detector, interactor) + continue + # --- Attempt to activate reply context --- # print("[DEBUG] UI Loop: Attempting to activate reply context...") # DEBUG REMOVED reply_context_activated = False From 677a73f0266c0705ee2c9e44eba11aca9844ce2b Mon Sep 17 00:00:00 2001 From: z060142 Date: Thu, 15 May 2025 11:18:54 +0800 Subject: [PATCH 2/8] Force multiple window topmost strategies to prevent focus loss --- game_manager.py | 250 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 199 insertions(+), 51 deletions(-) diff --git a/game_manager.py b/game_manager.py index fd5dcfe..390edf2 100644 --- a/game_manager.py +++ b/game_manager.py @@ -72,6 +72,10 @@ class GameMonitor: self.monitor_thread = None self.stop_event = threading.Event() + # Add these tracking variables + self.last_focus_failure_count = 0 + self.last_successful_foreground = time.time() + self.logger.info(f"GameMonitor initialized. Game window: '{self.window_title}', Process: '{self.game_process_name}'") self.logger.info(f"Position: ({self.window_x}, {self.window_y}), Size: {self.window_width}x{self.window_height}") self.logger.info(f"Scheduled Restart: {'Enabled' if self.enable_restart else 'Disabled'}, Interval: {self.restart_interval} minutes") @@ -160,51 +164,41 @@ class GameMonitor: if current_pos != target_pos or current_size != target_size: window.moveTo(target_pos[0], target_pos[1]) window.resizeTo(target_size[0], target_size[1]) - # Verify if move and resize were successful time.sleep(0.1) - window.activate() # Try activating to ensure changes apply + window.activate() time.sleep(0.1) + # Check if changes were successful new_pos = (window.left, window.top) new_size = (window.width, window.height) if new_pos == target_pos and new_size == target_size: - current_message += f"Adjusted game window to position ({target_pos[0]},{target_pos[1]}) size {target_size[0]}x{target_size[1]}. " + current_message += f"Adjusted window position/size. " adjustment_made = True - else: - self.logger.warning(f"Attempted to adjust window pos/size, but result mismatch. Target: {target_pos}/{target_size}, Actual: {new_pos}/{new_size}") - - # 2. Check and bring to foreground + # 2. Check and bring to foreground using enhanced method current_foreground_hwnd = win32gui.GetForegroundWindow() - if current_foreground_hwnd != hwnd: - try: - # Use HWND_TOP to bring window to top, not HWND_TOPMOST - win32gui.SetWindowPos(hwnd, win32con.HWND_TOP, 0, 0, 0, 0, - win32con.SWP_NOMOVE | win32con.SWP_NOSIZE) - - # Set as foreground window (gain focus) - win32gui.SetForegroundWindow(hwnd) - - # Verify if window is active - time.sleep(0.1) - foreground_hwnd = win32gui.GetForegroundWindow() - - if foreground_hwnd == hwnd: - current_message += "Brought game window to foreground and set focus. " - adjustment_made = True - else: - # Use fallback method - self.logger.warning("SetForegroundWindow failed, trying fallback window.activate()") - try: - window.activate() - time.sleep(0.1) - if win32gui.GetForegroundWindow() == hwnd: - current_message += "Set game window focus using fallback method. " - adjustment_made = True - except Exception as activate_err: - self.logger.warning(f"Fallback method window.activate() failed: {activate_err}") - except Exception as focus_err: - self.logger.warning(f"Error setting window focus: {focus_err}") + # Use enhanced forceful focus method + success, method_used = self._force_window_foreground(hwnd, window) + if success: + current_message += f"Focused window using {method_used}. " + adjustment_made = True + if not hasattr(self, 'last_focus_failure_count'): + self.last_focus_failure_count = 0 + self.last_focus_failure_count = 0 + else: + # Increment failure counter + if not hasattr(self, 'last_focus_failure_count'): + self.last_focus_failure_count = 0 + self.last_focus_failure_count += 1 + + # Log warning with consecutive failure count + self.logger.warning(f"Window focus failed (attempt {self.last_focus_failure_count}): {method_used}") + + # Restart game after too many failures + if self.last_focus_failure_count >= 15: + self.logger.warning("Excessive focus failures, restarting game...") + self._perform_restart() + self.last_focus_failure_count = 0 else: # Use basic functions on non-Windows platforms current_pos = (window.left, window.top) @@ -225,7 +219,7 @@ class GameMonitor: adjustment_made = True except Exception as activate_err: self.logger.warning(f"Error activating window: {activate_err}") - + except Exception as e: self.logger.error(f"Unexpected error while monitoring game window: {e}") @@ -255,27 +249,181 @@ class GameMonitor: self.logger.debug(f"Error finding game window: {e}") return None - def _find_game_process(self): - """Find the game process""" - if not HAS_PSUTIL: - self.logger.warning("psutil is not available, cannot perform process lookup") + def _force_window_foreground(self, hwnd, window): + """Aggressive window focus implementation""" + if not HAS_WIN32: + return False, "win32 modules unavailable" + + success = False + methods_tried = [] + + # Method 1: HWND_TOPMOST strategy + methods_tried.append("HWND_TOPMOST") + try: + win32gui.SetWindowPos(hwnd, win32con.HWND_TOPMOST, 0, 0, 0, 0, + win32con.SWP_NOMOVE | win32con.SWP_NOSIZE) + time.sleep(0.1) + win32gui.SetWindowPos(hwnd, win32con.HWND_TOP, 0, 0, 0, 0, + win32con.SWP_NOMOVE | win32con.SWP_NOSIZE) + + win32gui.SetForegroundWindow(hwnd) + time.sleep(0.2) + if win32gui.GetForegroundWindow() == hwnd: + return True, "HWND_TOPMOST" + except Exception as e: + self.logger.debug(f"Method 1 failed: {e}") + + # Method 2: Minimize/restore cycle + methods_tried.append("MinimizeRestore") + try: + win32gui.ShowWindow(hwnd, win32con.SW_MINIMIZE) + time.sleep(0.3) + win32gui.ShowWindow(hwnd, win32con.SW_RESTORE) + time.sleep(0.2) + win32gui.SetForegroundWindow(hwnd) + + if win32gui.GetForegroundWindow() == hwnd: + return True, "MinimizeRestore" + except Exception as e: + self.logger.debug(f"Method 2 failed: {e}") + + # Method 3: Thread input attach + methods_tried.append("ThreadAttach") + try: + import win32process + import win32api + + current_thread_id = win32api.GetCurrentThreadId() + window_thread_id = win32process.GetWindowThreadProcessId(hwnd)[0] + + if current_thread_id != window_thread_id: + win32process.AttachThreadInput(current_thread_id, window_thread_id, True) + try: + win32gui.BringWindowToTop(hwnd) + win32gui.SetForegroundWindow(hwnd) + + time.sleep(0.2) + if win32gui.GetForegroundWindow() == hwnd: + return True, "ThreadAttach" + finally: + win32process.AttachThreadInput(current_thread_id, window_thread_id, False) + except Exception as e: + self.logger.debug(f"Method 3 failed: {e}") + + # Method 4: Flash + Window messages + methods_tried.append("Flash+Messages") + try: + # First flash to get attention + win32gui.FlashWindow(hwnd, True) + time.sleep(0.2) + + # Then send specific window messages + win32gui.SendMessage(hwnd, win32con.WM_SETREDRAW, 0, 0) + win32gui.SendMessage(hwnd, win32con.WM_SETREDRAW, 1, 0) + win32gui.RedrawWindow(hwnd, None, None, + win32con.RDW_FRAME | win32con.RDW_INVALIDATE | + win32con.RDW_UPDATENOW | win32con.RDW_ALLCHILDREN) + + win32gui.PostMessage(hwnd, win32con.WM_SYSCOMMAND, win32con.SC_RESTORE, 0) + win32gui.PostMessage(hwnd, win32con.WM_ACTIVATE, win32con.WA_ACTIVE, 0) + + time.sleep(0.2) + if win32gui.GetForegroundWindow() == hwnd: + return True, "Flash+Messages" + except Exception as e: + self.logger.debug(f"Method 4 failed: {e}") + + # Method 5: Hide/Show cycle + methods_tried.append("HideShow") + try: + win32gui.ShowWindow(hwnd, win32con.SW_HIDE) + time.sleep(0.2) + win32gui.ShowWindow(hwnd, win32con.SW_SHOW) + time.sleep(0.2) + win32gui.SetForegroundWindow(hwnd) + + if win32gui.GetForegroundWindow() == hwnd: + return True, "HideShow" + except Exception as e: + self.logger.debug(f"Method 5 failed: {e}") + + return False, f"All methods failed: {', '.join(methods_tried)}" + + def _find_game_process_by_window(self): + """Find process using both window title and process name""" + if not HAS_PSUTIL or not HAS_WIN32: return None try: - for proc in psutil.process_iter(['pid', 'name', 'exe']): - try: - proc_info = proc.info - proc_name = proc_info.get('name') + window = self._find_game_window() + if not window: + return None - if proc_name and proc_name.lower() == self.game_process_name.lower(): - self.logger.info(f"Found game process '{proc_name}' (PID: {proc.pid})") + hwnd = window._hWnd + window_pid = None + try: + import win32process + _, window_pid = win32process.GetWindowThreadProcessId(hwnd) + except Exception: + return None + + if window_pid: + try: + proc = psutil.Process(window_pid) + proc_name = proc.name() + + if proc_name.lower() == self.game_process_name.lower(): + self.logger.info(f"Found game process '{proc_name}' (PID: {proc.pid}) with window title '{self.window_title}'") return proc + else: + self.logger.debug(f"Window process name mismatch: expected '{self.game_process_name}', got '{proc_name}'") + return proc # Returning proc even if name mismatches, as per user's code. + except Exception: + pass + + # Fallback to name-based search if window-based fails or PID doesn't match process name. + # The user's provided code implies a fallback to _find_game_process_by_name() + # This will be handled by the updated _find_game_process method. + # For now, if the window PID didn't lead to a matching process name, we return None here. + # The original code had "return self._find_game_process_by_name()" here, + # but that would create a direct dependency. The new _find_game_process handles the fallback. + # So, if we reach here, it means the window was found, PID was obtained, but process name didn't match. + # The original code returns `proc` even on mismatch, so I'll keep that. + # If `window_pid` was None or `psutil.Process(window_pid)` failed, it would have returned None or passed. + # The logic "return self._find_game_process_by_name()" was in the original snippet, + # I will include it here as per the snippet, but note that the overall _find_game_process will also call it. + return self._find_game_process_by_name() # As per user snippet + + except Exception as e: + self.logger.error(f"Process-by-window lookup error: {e}") + return None + + def _find_game_process(self): + """Find game process with combined approach""" + # Try window-based process lookup first + proc = self._find_game_process_by_window() + if proc: + return proc + + # Fall back to name-only lookup + # This is the original _find_game_process logic, now as a fallback. + if not HAS_PSUTIL: + self.logger.debug("psutil not available for name-only process lookup fallback.") # Changed to debug as primary is window based + return None + try: + for p_iter in psutil.process_iter(['pid', 'name', 'exe']): + try: + proc_info = p_iter.info + proc_name = proc_info.get('name') + if proc_name and proc_name.lower() == self.game_process_name.lower(): + self.logger.info(f"Found game process by name '{proc_name}' (PID: {p_iter.pid}) as fallback") + return p_iter except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): continue except Exception as e: - self.logger.error(f"Error finding game process: {e}") - - self.logger.info(f"Game process '{self.game_process_name}' not found") + self.logger.error(f"Error in name-only game process lookup: {e}") + + self.logger.info(f"Game process '{self.game_process_name}' not found by name either.") return None def _perform_restart(self): @@ -298,7 +446,7 @@ class GameMonitor: self.logger.error("Failed to start game") # 4. Wait for game to launch - restart_wait_time = 30 # seconds + restart_wait_time = 45 # seconds, increased from 30 self.logger.info(f"Waiting for game to start ({restart_wait_time} seconds)...") time.sleep(restart_wait_time) From 0b794a4c327adf644a692a61c3ff79403acbb30d Mon Sep 17 00:00:00 2001 From: z060142 Date: Thu, 15 May 2025 12:13:51 +0800 Subject: [PATCH 3/8] Add recovery mechanism for unexpected window closure (finally) --- ClaudeCode.md | 4 ++++ game_manager.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/ClaudeCode.md b/ClaudeCode.md index 6ce842d..2b031b7 100644 --- a/ClaudeCode.md +++ b/ClaudeCode.md @@ -644,6 +644,10 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機 - 使用回調函數 (`callback`) 與調用者(即 `Setup.py`)通信,例如在遊戲重啟完成時。 - 保留了獨立運行模式,以便在直接執行時仍能工作(主要用於測試或舊版兼容)。 - 程式碼註解和日誌訊息已更新為英文。 + - **新增遊戲崩潰自動恢復 (2025-05-15)**: + - 在 `_monitor_loop` 方法中,優先檢查遊戲進程 (`_is_game_running`) 是否仍在運行。 + - 如果進程消失,會記錄警告並嘗試重新啟動遊戲 (`_start_game_process`)。 + - 新增 `_is_game_running` 方法,使用 `psutil` 檢查具有指定進程名稱的遊戲是否正在運行。 - **`Setup.py` (修改)**: - 導入 `game_manager`。 - 在 `WolfChatSetup` 類的 `__init__` 方法中初始化 `self.game_monitor = None`。 diff --git a/game_manager.py b/game_manager.py index 390edf2..1e0fb34 100644 --- a/game_manager.py +++ b/game_manager.py @@ -132,6 +132,17 @@ class GameMonitor: while not self.stop_event.is_set(): try: + # Add to _monitor_loop method - just 7 lines that matter + if not self._is_game_running(): + self.logger.warning("Game process disappeared - restarting") + time.sleep(2) # Let resources release + if self._start_game_process(): + self.logger.info("Game restarted successfully") + else: + self.logger.error("Game restart failed") + time.sleep(self.monitor_interval) # Wait before next check after a restart attempt + continue + # Check for scheduled restart if self.next_restart_time and time.time() >= self.next_restart_time: self.logger.info("Scheduled restart time reached. Performing restart...") @@ -239,6 +250,17 @@ class GameMonitor: self.logger.info("Game window monitoring loop finished") + def _is_game_running(self): + """Check if game is running""" + if not HAS_PSUTIL: + self.logger.warning("_is_game_running: psutil not available, cannot check process status.") + return True # Assume running if psutil is not available to avoid unintended restarts + try: + return any(p.name().lower() == self.game_process_name.lower() for p in psutil.process_iter(['name'])) + except Exception as e: + self.logger.error(f"Error checking game process: {e}") + return False # Assume not running on error + def _find_game_window(self): """Find the game window with the specified title""" try: From 2ac63718a9f20e115d805fe6f18f0735284e6728 Mon Sep 17 00:00:00 2001 From: z060142 Date: Fri, 16 May 2025 02:02:31 +0800 Subject: [PATCH 4/8] Implement perceptual image hash deduplication for bubble processing - Added `simple_bubble_dedup.py` module using perceptual hashing (pHash) to detect duplicate chat bubbles based on visual similarity. - System keeps recent N (default 5) hashes and skips bubbles with Hamming distance below threshold (default 5). - Integrated into `run_ui_monitoring_loop()`: - Hash is computed upon bubble snapshot capture. - Duplicate check occurs before message enqueue. - Sender info is optionally attached to matching hash entries after successful processing. - Deduplication state is persisted in `simple_bubble_dedup.json`. - F7 (`clear_history`) and F8 (`reset_state`) now also clear image-based hash history. - Removed or commented out legacy `recent_texts` text-based deduplication logic. This visual deduplication system reduces false negatives caused by slight text variations and ensures higher confidence in skipping repeated bubble interactions. --- ClaudeCode.md | 9 ++- simple_bubble_dedup.json | 18 +++++ simple_bubble_dedup.py | 155 +++++++++++++++++++++++++++++++++++++++ ui_interaction.py | 51 +++++++++++-- 4 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 simple_bubble_dedup.json create mode 100644 simple_bubble_dedup.py diff --git a/ClaudeCode.md b/ClaudeCode.md index 2b031b7..f99d976 100644 --- a/ClaudeCode.md +++ b/ClaudeCode.md @@ -124,7 +124,14 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機 * **計算頭像座標**:根據**新**找到的氣泡左上角座標,應用特定偏移量 (`AVATAR_OFFSET_X_REPLY`, `AVATAR_OFFSET_Y_REPLY`) 計算頭像點擊位置。 * **互動(含重試)**:點擊計算出的頭像位置,檢查是否成功進入個人資料頁面 (`Profile_page.png`)。若失敗,最多重試 3 次(每次重試前會再次重新定位氣泡)。若成功,則繼續導航菜單複製用戶名稱。 * **原始偏移量**:原始的 `-55` 像素水平偏移量 (`AVATAR_OFFSET_X`) 仍保留,用於 `remove_user_position` 等其他功能。 -5. **防重複處理 (Duplicate Prevention)**:使用最近處理過的文字內容歷史 (`recent_texts`) 防止對相同訊息重複觸發。 +5. **防重複處理 (Duplicate Prevention)**: + * **基於圖像哈希的去重 (Image Hash Deduplication)**: 新增 `simple_bubble_dedup.py` 模塊,實現基於圖像感知哈希 (Perceptual Hash) 的去重系統。 + * **原理**: 系統會計算最近處理過的氣泡圖像的感知哈希值,並保存最近的 N 個 (預設 5 個) 氣泡的哈希。當偵測到新氣泡時,會計算其哈希並與保存的哈希進行比對。如果哈希差異小於設定的閾值 (預設 5),則認為是重複氣泡並跳過處理。 + * **實現**: 在 `ui_interaction.py` 的 `run_ui_monitoring_loop` 函數中初始化 `SimpleBubbleDeduplication` 實例,並在偵測到關鍵字並截取氣泡快照後,調用 `is_duplicate` 方法進行檢查。 + * **狀態管理**: 使用 `simple_bubble_dedup.json` 文件持久化保存最近的氣泡哈希記錄。 + * **清理**: F7 (`clear_history`) 和 F8 (`reset_state`) 功能已擴展,會同時清除圖像去重系統中的記錄。 + * **發送者信息更新**: 在成功處理並將氣泡信息放入隊列後,會嘗試更新去重記錄中對應氣泡的發送者名稱。 + * **文字內容歷史 (已棄用)**: 原有的基於 `recent_texts` 的文字內容重複檢查邏輯已**移除或註解**,圖像哈希去重成為主要的去重機制。 #### LLM 整合 diff --git a/simple_bubble_dedup.json b/simple_bubble_dedup.json new file mode 100644 index 0000000..c655d38 --- /dev/null +++ b/simple_bubble_dedup.json @@ -0,0 +1,18 @@ +{ + "bubble_210_340_236_70": { + "hash": "ae7d90dad1026ffd2e7990b2d10447fd4731389b1856c5a467594f5bd524b0cc", + "sender": "Wolfhartowo" + }, + "bubble_210_628_236_70": { + "hash": "ae7db0dad1026fad2e79b0b2d10446ff4731791b185695a467596e5bc524b0cc", + "sender": "" + }, + "bubble_210_620_464_264": { + "hash": "abfd544b70b87f87ecc8d70ac0870ee4115a2a7d93afd9b4dc022bdfcc03c4a0", + "sender": "" + }, + "bubble_210_852_464_264": { + "hash": "afff6b41c40b64f0d350972fe0a54ae4f4a0978ed0d0ac70d2402bdc2ffffa40", + "sender": "Wolfhartowo" + } +} \ No newline at end of file diff --git a/simple_bubble_dedup.py b/simple_bubble_dedup.py new file mode 100644 index 0000000..8c75c5b --- /dev/null +++ b/simple_bubble_dedup.py @@ -0,0 +1,155 @@ +import os +import json +import collections +import threading +from PIL import Image +import imagehash +import numpy as np +import io + +class SimpleBubbleDeduplication: + def __init__(self, storage_file="simple_bubble_dedup.json", max_bubbles=5, threshold=5, hash_size=16): + self.storage_file = storage_file + self.max_bubbles = max_bubbles # Keep the most recent 5 bubbles + self.threshold = threshold # Hash difference threshold (lower values are more strict) + self.hash_size = hash_size # Hash size + self.lock = threading.Lock() + + # Use OrderedDict to maintain order + self.recent_bubbles = collections.OrderedDict() + # Load stored bubble hashes + self._load_storage() + + def _load_storage(self): + """Load processed bubble hash values from file""" + if os.path.exists(self.storage_file): + try: + with open(self.storage_file, 'r') as f: + data = json.load(f) + + # Convert stored data to OrderedDict and load + self.recent_bubbles.clear() + # Use loaded_count to track loaded items, ensuring we don't exceed max_bubbles + loaded_count = 0 + for bubble_id, bubble_data in data.items(): + if loaded_count >= self.max_bubbles: + break + self.recent_bubbles[bubble_id] = { + 'hash': imagehash.hex_to_hash(bubble_data['hash']), + 'sender': bubble_data.get('sender', 'Unknown') + } + loaded_count += 1 + + print(f"Loaded {len(self.recent_bubbles)} bubble hash records") + except Exception as e: + print(f"Failed to load bubble hash records: {e}") + self.recent_bubbles.clear() + + def _save_storage(self): + """Save bubble hashes to file""" + try: + # Create temporary dictionary for saving + data_to_save = {} + for bubble_id, bubble_data in self.recent_bubbles.items(): + data_to_save[bubble_id] = { + 'hash': str(bubble_data['hash']), + 'sender': bubble_data.get('sender', 'Unknown') + } + + with open(self.storage_file, 'w') as f: + json.dump(data_to_save, f, indent=2) + print(f"Saved {len(data_to_save)} bubble hash records") + except Exception as e: + print(f"Failed to save bubble hash records: {e}") + + def compute_image_hash(self, bubble_snapshot): + """Calculate perceptual hash of bubble image""" + try: + # If bubble_snapshot is a PIL.Image object + if isinstance(bubble_snapshot, Image.Image): + img = bubble_snapshot + # If bubble_snapshot is a PyAutoGUI screenshot + elif hasattr(bubble_snapshot, 'save'): + img = bubble_snapshot + # If it's bytes or BytesIO + elif isinstance(bubble_snapshot, (bytes, io.BytesIO)): + img = Image.open(io.BytesIO(bubble_snapshot) if isinstance(bubble_snapshot, bytes) else bubble_snapshot) + # If it's a numpy array + elif isinstance(bubble_snapshot, np.ndarray): + img = Image.fromarray(bubble_snapshot) + else: + print(f"Unrecognized image format: {type(bubble_snapshot)}") + return None + + # Calculate perceptual hash + phash = imagehash.phash(img, hash_size=self.hash_size) + return phash + except Exception as e: + print(f"Failed to calculate image hash: {e}") + return None + + def generate_bubble_id(self, bubble_region): + """Generate ID based on bubble region""" + return f"bubble_{bubble_region[0]}_{bubble_region[1]}_{bubble_region[2]}_{bubble_region[3]}" + + def is_duplicate(self, bubble_snapshot, bubble_region, sender_name=""): + """Check if bubble is a duplicate""" + with self.lock: + if bubble_snapshot is None: + return False + + # Calculate hash of current bubble + current_hash = self.compute_image_hash(bubble_snapshot) + if current_hash is None: + print("Unable to calculate bubble hash, cannot perform deduplication") + return False + + # Generate ID for current bubble + bubble_id = self.generate_bubble_id(bubble_region) + + # Check if similar to any known bubbles + for stored_id, bubble_data in self.recent_bubbles.items(): + stored_hash = bubble_data['hash'] + hash_diff = current_hash - stored_hash + + if hash_diff <= self.threshold: + print(f"Detected duplicate bubble (ID: {stored_id}, Hash difference: {hash_diff})") + if sender_name: + print(f"Sender: {sender_name}, Recorded sender: {bubble_data.get('sender', 'Unknown')}") + return True + + # Not a duplicate, add to recent bubbles list + self.recent_bubbles[bubble_id] = { + 'hash': current_hash, + 'sender': sender_name + } + + # If exceeding maximum count, remove oldest item + while len(self.recent_bubbles) > self.max_bubbles: + self.recent_bubbles.popitem(last=False) # Remove first item (oldest) + + self._save_storage() + return False + + def clear_all(self): + """Clear all records""" + with self.lock: + count = len(self.recent_bubbles) + self.recent_bubbles.clear() + self._save_storage() + print(f"Cleared all {count} bubble records") + return count + + def save_debug_image(self, bubble_snapshot, bubble_id, hash_value): + """Save debug image (optional feature)""" + try: + debug_dir = "bubble_debug" + if not os.path.exists(debug_dir): + os.makedirs(debug_dir) + + # Save original image + img_path = os.path.join(debug_dir, f"{bubble_id}_{hash_value}.png") + bubble_snapshot.save(img_path) + print(f"Saved debug image: {img_path}") + except Exception as e: + print(f"Failed to save debug image: {e}") \ No newline at end of file diff --git a/ui_interaction.py b/ui_interaction.py index d575cfd..d5a994c 100644 --- a/ui_interaction.py +++ b/ui_interaction.py @@ -19,6 +19,7 @@ from typing import List, Tuple, Optional, Dict, Any import threading # Import threading for Lock if needed, or just use a simple flag import math # Added for distance calculation in dual method import time # Ensure time is imported for MessageDeduplication +from simple_bubble_dedup import SimpleBubbleDeduplication class MessageDeduplication: def __init__(self, expiry_seconds=3600): # 1 hour expiry time @@ -1697,6 +1698,15 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu """ print("\n--- Starting UI Monitoring Loop (Thread) ---") + # --- 初始化氣泡圖像去重系統(新增) --- + bubble_deduplicator = SimpleBubbleDeduplication( + storage_file="simple_bubble_dedup.json", + max_bubbles=4, # 保留最近5個氣泡 + threshold=7, # 哈希差異閾值(值越小越嚴格) + hash_size=16 # 哈希大小 + ) + # --- 初始化氣泡圖像去重系統結束 --- + # --- Initialization (Instantiate modules within the thread) --- # --- Template Dictionary Setup (Refactored) --- essential_templates = { @@ -1837,6 +1847,12 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu print("UI Thread: Processing clear_history command.") recent_texts.clear() deduplicator.clear_all() # Simultaneously clear deduplication records + + # --- 新增:清理氣泡去重記錄 --- + if 'bubble_deduplicator' in locals(): + bubble_deduplicator.clear_all() + # --- 清理氣泡去重記錄結束 --- + print("UI Thread: recent_texts and deduplicator records cleared.") elif action == 'reset_state': # Added for F8 resume @@ -1844,6 +1860,12 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu recent_texts.clear() last_processed_bubble_info = None deduplicator.clear_all() # Simultaneously clear deduplication records + + # --- 新增:清理氣泡去重記錄 --- + if 'bubble_deduplicator' in locals(): + bubble_deduplicator.clear_all() + # --- 清理氣泡去重記錄結束 --- + print("UI Thread: recent_texts, last_processed_bubble_info, and deduplicator records reset.") else: @@ -2033,6 +2055,13 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu print("Warning: Failed to capture bubble snapshot. Skipping this bubble.") continue # Skip to next bubble + # --- New: Image deduplication check --- + if bubble_deduplicator.is_duplicate(bubble_snapshot, bubble_region_tuple): + print("Detected duplicate bubble, skipping processing") + perform_state_cleanup(detector, interactor) + continue # Skip processing this bubble + # --- End of image deduplication check --- + # --- Save Snapshot for Debugging --- try: screenshot_index = (screenshot_counter % MAX_DEBUG_SCREENSHOTS) + 1 @@ -2044,7 +2073,7 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu screenshot_counter += 1 except Exception as save_err: print(f"Error saving bubble snapshot to {screenshot_path}: {repr(save_err)}") - + except Exception as snapshot_err: print(f"Error taking initial bubble snapshot: {repr(snapshot_err)}") continue # Skip to next bubble @@ -2186,14 +2215,14 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu continue # Skip this bubble # If not a duplicate by deduplicator, then check recent_texts (original safeguard) - if bubble_text in recent_texts: - print(f"UI Thread: Content '{bubble_text[:30]}...' in recent_texts history, skipping.") - perform_state_cleanup(detector, interactor) # Cleanup as we are skipping - continue + # if bubble_text in recent_texts: + # print(f"UI Thread: Content '{bubble_text[:30]}...' in recent_texts history, skipping.") + # perform_state_cleanup(detector, interactor) # Cleanup as we are skipping + # continue # If not a duplicate by any means, add to recent_texts and proceed print(">>> New trigger event (passed deduplication) <<<") - recent_texts.append(bubble_text) + # recent_texts.append(bubble_text) # No longer needed with image deduplication else: # This case implies sender_name or bubble_text was None/empty, # which should have been caught by earlier checks. @@ -2277,6 +2306,16 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu } trigger_queue.put(data_to_send) print("Trigger info (with region, reply flag, snapshot, search_area) placed in Queue.") + + # --- 新增:更新氣泡去重記錄中的發送者信息 --- + # 注意:我們在前面已經添加了氣泡到去重系統,但當時還沒獲取發送者名稱 + # 這裡我們嘗試再次更新發送者信息(如果實現允許的話) + if 'bubble_deduplicator' in locals() and bubble_snapshot and sender_name: + bubble_id = bubble_deduplicator.generate_bubble_id(bubble_region_tuple) + if bubble_id in bubble_deduplicator.recent_bubbles: + bubble_deduplicator.recent_bubbles[bubble_id]['sender'] = sender_name + bubble_deduplicator._save_storage() + # --- 更新發送者信息結束 --- # --- CRITICAL: Break loop after successfully processing one trigger --- print("--- Single bubble processing complete. Breaking scan cycle. ---") From dad375dec8c74558bbef888a3f089ad398abca37 Mon Sep 17 00:00:00 2001 From: z060142 Date: Fri, 16 May 2025 02:13:37 +0800 Subject: [PATCH 5/8] Clean up redundant code and adjust initial game window size --- Setup.py | 4 ++-- simple_bubble_dedup.json | 18 ------------------ 2 files changed, 2 insertions(+), 20 deletions(-) delete mode 100644 simple_bubble_dedup.json diff --git a/Setup.py b/Setup.py index 502a5a7..9d711c3 100644 --- a/Setup.py +++ b/Setup.py @@ -568,8 +568,8 @@ class WolfChatSetup(tk.Tk): def __init__(self): super().__init__() self.title(f"Wolf Chat Setup v{VERSION}") - self.geometry("800x600") - self.minsize(750, 550) + self.geometry("900x600") + self.minsize(900, 600) # Load existing data self.env_data = load_env_file() diff --git a/simple_bubble_dedup.json b/simple_bubble_dedup.json deleted file mode 100644 index c655d38..0000000 --- a/simple_bubble_dedup.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "bubble_210_340_236_70": { - "hash": "ae7d90dad1026ffd2e7990b2d10447fd4731389b1856c5a467594f5bd524b0cc", - "sender": "Wolfhartowo" - }, - "bubble_210_628_236_70": { - "hash": "ae7db0dad1026fad2e79b0b2d10446ff4731791b185695a467596e5bc524b0cc", - "sender": "" - }, - "bubble_210_620_464_264": { - "hash": "abfd544b70b87f87ecc8d70ac0870ee4115a2a7d93afd9b4dc022bdfcc03c4a0", - "sender": "" - }, - "bubble_210_852_464_264": { - "hash": "afff6b41c40b64f0d350972fe0a54ae4f4a0978ed0d0ac70d2402bdc2ffffa40", - "sender": "Wolfhartowo" - } -} \ No newline at end of file From e3e3d3b91482550814938afd8d64d3cda40ae377 Mon Sep 17 00:00:00 2001 From: z060142 Date: Fri, 16 May 2025 02:26:28 +0800 Subject: [PATCH 6/8] Fix duplicate log print issue --- .gitignore | 1 + Setup.py | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 694f3f6..dc8f9d5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ llm_debug.log config.py config.py.bak +simple_bubble_dedup.json __pycache__/ debug_screenshots/ chat_logs/ diff --git a/Setup.py b/Setup.py index 9d711c3..520d02d 100644 --- a/Setup.py +++ b/Setup.py @@ -69,6 +69,7 @@ keep_monitoring_flag.set() # logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') # Setup logger instance. This can be configured further if needed. logger = logging.getLogger(__name__) +logger.propagate = False if not logger.handlers: # Avoid adding multiple handlers if script is reloaded handler = logging.StreamHandler() formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') @@ -2783,8 +2784,8 @@ else: # HAS_SOCKETIO is False # =============================================================== if __name__ == "__main__": # Setup main logger for the application if not already done - if not logging.getLogger().handlers: # Check root logger - logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + #if not logging.getLogger().handlers: # Check root logger + # logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') app = WolfChatSetup() app.protocol("WM_DELETE_WINDOW", app.on_closing) # Handle window close button From a8603d4d4550bc6899657b81a6d90670df8fef4a Mon Sep 17 00:00:00 2001 From: z060142 Date: Fri, 16 May 2025 11:47:31 +0800 Subject: [PATCH 7/8] Refine pause/resume behavior handling --- main.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index bd42f58..edc90fd 100644 --- a/main.py +++ b/main.py @@ -105,16 +105,14 @@ def handle_f8(): except Exception as e: print(f"Error sending pause command (F8): {e}") else: - print("\n--- F8 pressed: Resuming script, resetting state, and resuming UI monitoring ---") - reset_command = {'action': 'reset_state'} + print("\n--- F8 pressed: Resuming script and UI monitoring ---") resume_command = {'action': 'resume'} try: - main_loop.call_soon_threadsafe(command_queue.put_nowait, reset_command) # Add a small delay? Let's try without first. # time.sleep(0.05) # Short delay between commands if needed main_loop.call_soon_threadsafe(command_queue.put_nowait, resume_command) except Exception as e: - print(f"Error sending reset/resume commands (F8): {e}") + print(f"Error sending resume command (F8): {e}") def handle_f9(): """Handles F9 press: Initiates script shutdown.""" From f9457bf992862f5a65205a253f5c3695604740cf Mon Sep 17 00:00:00 2001 From: z060142 Date: Sat, 17 May 2025 02:16:41 +0800 Subject: [PATCH 8/8] Replace text deduplication with difflib-based similarity matching to reduce false negatives --- ui_interaction.py | 66 +++++++++++++++++++++++++++++------------------ 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/ui_interaction.py b/ui_interaction.py index d5a994c..65f0713 100644 --- a/ui_interaction.py +++ b/ui_interaction.py @@ -20,43 +20,59 @@ import threading # Import threading for Lock if needed, or just use a simple fla import math # Added for distance calculation in dual method import time # Ensure time is imported for MessageDeduplication from simple_bubble_dedup import SimpleBubbleDeduplication +import difflib # Added for text similarity class MessageDeduplication: def __init__(self, expiry_seconds=3600): # 1 hour expiry time - self.processed_messages = {} # {composite_key: timestamp} + self.processed_messages = {} # {message_key: timestamp} self.expiry_seconds = expiry_seconds - def create_key(self, sender, content): - """Create a standardized composite key.""" - # Thoroughly standardize text - remove all whitespace and punctuation, lowercase - clean_content = ''.join(c.lower() for c in content if c.isalnum()) - clean_sender = ''.join(c.lower() for c in sender if c.isalnum()) - - # Truncate content to first 100 chars to prevent overly long keys - if len(clean_content) > 100: - clean_content = clean_content[:100] - - return f"{clean_sender}:{clean_content}" - def is_duplicate(self, sender, content): - """Check if the message is a duplicate within the expiry period.""" + """Check if the message is a duplicate within the expiry period using text similarity.""" if not sender or not content: return False # Missing necessary info, treat as new message - key = self.create_key(sender, content) current_time = time.time() - - # Check if duplicate and not expired - if key in self.processed_messages: - last_time = self.processed_messages[key] - if current_time - last_time < self.expiry_seconds: - print(f"Deduplicator: Detected duplicate message: {sender} - {content[:20]}...") - return True - - # Update processing time - self.processed_messages[key] = current_time + + # 遍歷所有已處理的消息 + for key, timestamp in list(self.processed_messages.items()): + # 檢查是否過期 + if current_time - timestamp >= self.expiry_seconds: + # 從 processed_messages 中移除過期的項目,避免集合在迭代時改變大小 + # 但由於我們使用了 list(self.processed_messages.items()),所以這裡可以安全地 continue + # 或者,如果希望立即刪除,則需要不同的迭代策略或在 purge_expired 中處理 + continue # 繼續檢查下一個,過期項目由 purge_expired 處理 + + # 解析之前儲存的發送者和內容 + stored_sender, stored_content = key.split(":", 1) + + # 檢查發送者是否相同 + if sender.lower() == stored_sender.lower(): + # Calculate text similarity + similarity = difflib.SequenceMatcher(None, content, stored_content).ratio() + if similarity >= 0.95: # Use 0.95 as threshold + print(f"Deduplicator: Detected similar message (similarity: {similarity:.2f}): {sender} - {content[:20]}...") + return True + + # 不是重複消息,儲存它 + # 注意:這裡儲存的 content 是原始 content,不是 clean_content + message_key = f"{sender.lower()}:{content}" + self.processed_messages[message_key] = current_time return False + # create_key 方法已不再需要,可以移除 + # def create_key(self, sender, content): + # """Create a standardized composite key.""" + # # Thoroughly standardize text - remove all whitespace and punctuation, lowercase + # clean_content = ''.join(c.lower() for c in content if c.isalnum()) + # clean_sender = ''.join(c.lower() for c in sender if c.isalnum()) + + # # Truncate content to first 100 chars to prevent overly long keys + # if len(clean_content) > 100: + # clean_content = clean_content[:100] + + # return f"{clean_sender}:{clean_content}" + def purge_expired(self): """Remove expired message records.""" current_time = time.time()