#!/usr/bin/env python3 """ Meeting Assistant - Real-time AI Copilot for Meetings """ import sys import os import yaml import threading import logging from pathlib import Path from datetime import datetime sys.path.insert(0, str(Path(__file__).parent)) from src.audio_listener import AudioListener from src.ai_engine import AIEngine from src.overlay import InvisibleOverlay from src.context_manager import ContextManager from src.hotkeys import HotkeyManager from src.region_capture import RegionSelector from src.utils import setup_logging, get_platform import re # Interrogatives — a sentence starting with one of these is almost always a question _INTERROGATIVES = ( "what", "why", "how", "when", "where", "who", "whom", "whose", "which", ) # Auxiliary/modal verbs that, when fronted, signal a yes/no question _AUX_FRONT = ( "is", "are", "am", "was", "were", "do", "does", "did", "can", "could", "would", "will", "shall", "should", "may", "might", "has", "have", "had", ) # Imperative/request starters that expect an answer _REQUEST_STARTERS = ( "tell me", "tell us", "explain", "describe", "define", "show me", "give me", "give us", "give an overview", "give me an overview", "overview of", "walk me through", "walk us through", "run me through", "run through", "take me through", "talk me through", "go through", "go over", "break down", "break it down", "lay out", "list", "summarize", "summarise", "compare", "contrast", "outline", "clarify", "elaborate", "help me", "remind me", ) # Embedded / plain-sentence question markers ("I was wondering what the timeline is") _EMBEDDED_MARKERS = ( "i was wondering", "i wonder", "i'd like to know", "i would like to know", "do you know", "any idea", "any thoughts", "curious", "wondering if", "wondering what", "wondering how", "wondering whether", "let me know", "your thoughts on", "what's your take", "thoughts on", "would love to know", "not sure", "can you clarify", "question for you", "question is", ) # Fact-shaped lookups phrased as fragments ("capital of France", "python decorators") _FACT_PATTERNS = ( "capital of", "definition of", "meaning of", "difference between", "how many", "what year", "abbreviation for", "acronym for", ) # Phrases to skip even if they contain question words (noise / code / UI text) _NOISE_PHRASES = ( "timer cannot", "qobject", "qml", "pyside", "pyqt", "def ", "class ", "import ", "return ", "self.", ) class MeetingAssistant: def __init__(self): """Initialize all components""" print("šŸš€ Starting Meeting Assistant...") print(f"šŸ’» Platform: {get_platform()}") # Load config config_path = Path(__file__).parent / "config.yaml" with open(config_path, 'r') as f: self.config = yaml.safe_load(f) # Setup logging self.logger = setup_logging() # Initialize components self.ai_engine = AIEngine(self.config) self.context_manager = ContextManager(self.config) self.overlay = InvisibleOverlay(self.config) # Audio listener with callback self.audio_listener = AudioListener(self.config, self.on_audio_transcript) # Which speaker sources should trigger an answer self.answer_sources = set( self.config.get("audio", {}).get("answer_sources", ["system", "microphone"]) ) # Global hotkeys: hold push-to-talk to capture audio, chord to grab screen audio_cfg = self.config.get("audio", {}) screen_cfg = self.config.get("screen", {}) hotkeys_cfg = self.config.get("hotkeys", {}) self.hotkeys = HotkeyManager( on_arm=self.audio_listener.arm, on_disarm=self.audio_listener.disarm, on_capture=self.overlay.request_capture, # marshals onto Qt thread on_toggle=self.overlay.request_toggle, # marshals onto Qt thread ptt_key=audio_cfg.get("ptt_key", "alt_r"), capture_key=screen_cfg.get("capture_key", "ctrl+shift+space"), toggle_key=hotkeys_cfg.get("toggle_overlay", "ctrl+shift+h"), ) # Screen-capture handler runs on the Qt main thread (via the overlay bridge) self.overlay.on_capture = self._handle_screen_capture self._selector = None # keep a ref so the selector isn't GC'd mid-use # State management self.current_answer = None self.answering = False self.answer_lock = threading.Lock() self._last_question = "" ptt = audio_cfg.get("ptt_key", "alt_r") cap = screen_cfg.get("capture_key", "ctrl+shift+space") mode = audio_cfg.get("capture_mode", "push_to_talk") print("āœ… Meeting Assistant Ready!") print("==================================================") if mode == "push_to_talk": print(f"šŸŽ™ļø Hold [{ptt}] to capture audio, release to answer") else: print("šŸŽ¤ Listening continuously for questions") print(f"šŸ“ø Press [{cap}] then drag a box over an on-screen question") print(" Answers appear in the overlay (top-right)") print(" Type a question here + Enter to test AI") print(" Ctrl+C to stop") print("==================================================") def on_audio_transcript(self, text, timestamp, source="microphone", speaker=None): """Audio callback — runs in the transcription thread""" if not text or len(text.strip()) < 2: return speaker = speaker or ("You" if source == "microphone" else "Them") self.context_manager.add_audio_context(text, timestamp, source, speaker) if source not in self.answer_sources: print(f" Context [{speaker}] (not an answer source): {text}") return if self._is_question(text): print(f"\nā“ Question from {speaker}: {text}") self._generate_answer(text, source, speaker) else: print(f" Context [{speaker}] (not a question): {text}") def _is_question(self, text): """Detect questions, including ones phrased as plain statements. Combines surface cues ( '?'), interrogatives, fronted auxiliaries, requests/imperatives, embedded markers ('I was wondering...'), tag questions ('..., right?'), math, and fact-shaped fragments. """ text_lower = text.lower().strip() if not text_lower: return False # Skip obvious noise / code text if any(p in text_lower for p in _NOISE_PHRASES): return False # Explicit question mark → always a question (even one word: "Why?") if text.rstrip().endswith("?"): return True words = text_lower.split() if len(words) < 2: return False stripped = text_lower.rstrip(".!? ") # Tag questions: "..., right?", "..., correct?", "isn't it" if re.search(r"\b(right|correct|yeah|okay|ok|no)\s*$", stripped) and len(words) >= 4: if re.search(r",\s*(right|correct|okay|ok)\b", text_lower): return True # Math expressions ("2+2", "5 plus 3", "10 divided by 2") math_patterns = ( r"\d+\s*[\+\-\*\/]\s*\d+", r"\d+\s*(plus|minus|times|divided by|multiplied by)\s*\d+", ) if any(re.search(p, text_lower) for p in math_patterns): return True first = words[0] # normalize contractions so "what's"/"who's"/"isn't"/"don't" still match first_base = re.sub(r"(n't|'s|'re|'ll|'d|'ve|'m)$", "", first) # Starts with an interrogative ("what is the deadline", "what's the plan") if first in _INTERROGATIVES or first_base in _INTERROGATIVES: return True # Fronted auxiliary/modal forming a yes/no question ("are we shipping friday", # "isn't that due friday", "don't we need sign-off") if first in _AUX_FRONT or first_base in _AUX_FRONT: return True # Request / imperative starters ("explain the rollout plan") if any(stripped.startswith(s) for s in _REQUEST_STARTERS): return True # Embedded / plain-sentence questions ("I was wondering about the budget") if any(m in text_lower for m in _EMBEDDED_MARKERS): return True # Interrogative appearing after a lead-in ("so what about the budget") if any(f" {w} " in f" {text_lower} " for w in _INTERROGATIVES): # avoid matching relative clauses like "the plan that we have" → require # the interrogative within the first few words for w in _INTERROGATIVES: if w in words[:4]: return True # Fact-shaped fragments / lookups ("difference between TCP and UDP") if any(p in text_lower for p in _FACT_PATTERNS): return True return False def _generate_answer(self, question, source="microphone", speaker=None): """Generate answer with interruption support""" # Debounce: skip exact repeat within same run if question.lower() == self._last_question.lower(): return self._last_question = question # Check if we should interrupt current answer with self.answer_lock: if self.answering: print("šŸ”„ Interrupting previous answer...") if hasattr(self.ai_engine, 'interrupt'): self.ai_engine.interrupt() # Start answer generation in background thread threading.Thread(target=self._answer_worker, args=(question, source, speaker), daemon=True).start() def _answer_worker(self, question, source="microphone", speaker=None): """Worker thread for answer generation""" with self.answer_lock: self.answering = True try: context = self.context_manager.get_context() result = self.ai_engine.answer_question(question, context, source) if not result or not result.get("text"): return # interrupted or empty answer = result["text"] suggested = result.get("suggested", False) asked_by = speaker or ("You" if source == "microphone" else "Them") shown_q = f"({asked_by}) {question}" self.overlay.show_answer(answer, shown_q, suggested) self.logger.info(f"Q [{asked_by}]: {question}") self.logger.info(f"A: {answer}") label = "šŸ’¬ Suggested" if suggested else "šŸ’” Answer" print(f"\n{label}: {answer}\n") except Exception as e: print(f"āŒ Error generating answer: {e}") finally: with self.answer_lock: self.answering = False # ------------------------------------------------------------------ # Screen questions — draw a box, read it, answer it # ------------------------------------------------------------------ def _handle_screen_capture(self): """Runs on the Qt main thread. Show the draw-a-box selector.""" if self._selector is not None: return # a selection is already in progress print("\nšŸ“ø Draw a box around the question...") self._selector = RegionSelector(on_done=self._on_region_captured) self._selector.show_selector() def _on_region_captured(self, image_path): """Called when the user finishes (or cancels) the selection.""" self._selector = None if not image_path: print(" Screen capture cancelled.") return self.overlay.show_status("šŸ“ø Reading the question on screen...") threading.Thread(target=self._screen_answer_worker, args=(image_path,), daemon=True).start() def _screen_answer_worker(self, image_path): with self.answer_lock: if self.answering and hasattr(self.ai_engine, "interrupt"): self.ai_engine.interrupt() self.answering = True try: answer = self.ai_engine.answer_from_image(image_path) if not answer: return self.overlay.show_answer(answer, "Screen question", suggested=False) self.logger.info(f"Q [screen]: {image_path}") self.logger.info(f"A: {answer}") print(f"\nšŸ’” Screen answer: {answer}\n") except Exception as e: print(f"āŒ Error answering screen question: {e}") finally: with self.answer_lock: self.answering = False try: os.remove(image_path) except Exception: pass # ------------------------------------------------------------------ # Manual terminal input for testing # ------------------------------------------------------------------ def _terminal_input_loop(self): print("\n Type a question and press Enter to test the AI directly.") print(" (Leave blank to skip)\n") while True: try: line = input() if line.strip(): self._generate_answer(line.strip()) except (EOFError, KeyboardInterrupt): break # ------------------------------------------------------------------ # Run / shutdown # ------------------------------------------------------------------ def run(self): print("\nšŸŽ¤ Starting audio listener...") self.overlay.start() self.audio_listener.start() self.hotkeys.start() # Terminal input runs in a background daemon thread t = threading.Thread(target=self._terminal_input_loop, daemon=True) t.start() try: if self.overlay.app: self.overlay.app.exec_() except KeyboardInterrupt: pass self.shutdown() def shutdown(self): print("\nšŸ›‘ Shutting down...") if hasattr(self, 'hotkeys'): self.hotkeys.stop() if hasattr(self, 'audio_listener'): self.audio_listener.stop() if hasattr(self, 'overlay'): self.overlay.stop() print("šŸ‘‹ Goodbye!") # Hard-exit: skips Python finalizers so llama.cpp's buggy Metal teardown # (GGML_ASSERT in ggml_metal_device_free at __cxa_finalize) can't crash us. logging.shutdown() sys.stdout.flush() os._exit(0) def main(): assistant = MeetingAssistant() assistant.run() if __name__ == "__main__": main()