diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..73313c6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: + push: + branches: [ main, development ] + pull_request: + branches: [ main, development ] + +jobs: + test: + name: test (${{ matrix.os }}, py${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest, windows-latest ] + python-version: [ "3.11", "3.12" ] + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + cache-dependency-path: "pyproject.toml" + + - name: Install Linux dependencies + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y libportaudio2 libasound2-dev + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[dev,nlp,whisper] + + - name: Run Ruff Linter + run: ruff check src/ + + - name: Run Mypy Type Checker + run: mypy src/ + + - name: Run Safety Check + run: safety check + + - name: Run Pytest with Coverage + # GUI tests might require mock display on Ubuntu. Let's use xvfb on Ubuntu runner. + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get install -y xvfb + xvfb-run --server-args="-screen 0 1024x768x24" pytest tests/ -v --cov=src --cov-fail-under=80 --cov-report=xml + + - name: Run Pytest with Coverage (Windows) + if: matrix.os == 'windows-latest' + run: pytest tests/ -v --cov=src --cov-fail-under=80 --cov-report=xml diff --git a/.gitignore b/.gitignore index ba3d88c..9c30439 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,8 @@ models/ logs/ *.log .gemini/ +.coverage +htmlcov/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..946aa61 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +# Taskify Makefile target shortcuts + +.PHONY: dist clean test lint typecheck + +dist: + python packaging/build.py + +clean: + python -c "import shutil; [shutil.rmtree(p, ignore_errors=True) for p in ('build', 'dist')]" + +test: + pytest tests/ -v + +lint: + ruff check src/ --fix + +typecheck: + mypy src/ diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..b01235d --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,45 @@ +# Taskify standalone packaging instructions + +This directory holds the specifications and tooling to bundle the Python codebase, CustomTkinter frontend styling, and dependency components into a single standalone binary. + +## Requirements + +Ensure development dependencies are installed: +```bash +pip install -e .[dev] +``` + +## Building stand-alone executables + +To compile the binary for your active platform: + +```bash +python packaging/build.py +``` + +Or run via the Makefile: +```bash +make dist +``` + +Upon success, PyInstaller generates output directories: +- `build/`: Temporary files generated during build time. +- `dist/`: Stands for distribution. Contains the single executable `taskify` (or `taskify.exe` on Windows). + +## Local model dependency setup + +Because Taskify is a fully offline application, speech-to-text models must be available locally. The compiled binary does not include model directories by default to save executable file footprint. + +If model directories are missing, the application downloads defaults to the user directory on first launch, or they can be downloaded manually via: + +```bash +# Download default Vosk english model +dist/taskify download-model --engine vosk + +# Download default Whisper model +dist/taskify download-model --engine whisper +``` + +Offline models are stored at: +- **Vosk:** `~/.local/share/taskify/models/vosk/` (on Windows `~` refers to `C:\Users\`) +- **Whisper:** standard cache path managed by `huggingface_hub`. diff --git a/packaging/build.py b/packaging/build.py new file mode 100644 index 0000000..d599197 --- /dev/null +++ b/packaging/build.py @@ -0,0 +1,55 @@ +"""Programmatic execution of PyInstaller builds for standalone executables.""" + +import os +import subprocess +import sys +from pathlib import Path + + +def main() -> None: + """Execute the PyInstaller command pointing to packaging spec file.""" + workspace_dir = Path(__file__).parent.parent.resolve() + spec_file = workspace_dir / "packaging" / "taskify.spec" + + if not spec_file.exists(): + print(f"Error: Specification file not found at: {spec_file}", file=sys.stderr) + sys.exit(1) + + print("=== Taskify Packaging Builder ===") + print(f"Targeting Specification: {spec_file}") + print(f"Working Directory: {workspace_dir}") + + # Build the PyInstaller command list + cmd = [ + "pyinstaller", + "--clean", + "--noconfirm", + "--distpath", + str(workspace_dir / "dist"), + "--workpath", + str(workspace_dir / "build"), + str(spec_file), + ] + + print(f"Executing: {' '.join(cmd)}") + try: + # Run pyinstaller build process + result = subprocess.run( + cmd, cwd=str(workspace_dir), check=True, capture_output=False + ) + print("=== Build process finished successfully. ===") + sys.exit(result.returncode) + except subprocess.CalledProcessError as e: + print(f"Error: PyInstaller build failed with status {e.returncode}", file=sys.stderr) + sys.exit(e.returncode) + except FileNotFoundError: + print( + "Error: PyInstaller command not found. Please install the dev packages: " + "pip install -e .[dev]", + file=sys.stderr, + ) + sys.exit(127) + + +if __name__ == "__main__": + main() diff --git a/packaging/taskify.spec b/packaging/taskify.spec new file mode 100644 index 0000000..2f4bf94 --- /dev/null +++ b/packaging/taskify.spec @@ -0,0 +1,95 @@ +# -*- mode: python ; coding: utf-8 -*- +import os +import sys +from PyInstaller.utils.hooks import collect_data_files, collect_submodules + +block_cipher = None + +# Locate customtkinter package installation path programmatically +import customtkinter +customtkinter_path = os.path.dirname(customtkinter.__file__) + +# Collect taskify assets (default configurations, schemas, sql scripts) +taskify_datas = collect_data_files("taskify") + +# Aggregate all data files to bundle inside the executable +datas = taskify_datas + [ + (customtkinter_path, "customtkinter") +] + +# Ensure dynamic imports are collected by PyInstaller +hiddenimports = collect_submodules("taskify") + [ + "sounddevice", + "soundfile", + "numpy", + "vosk", + "click", + "customtkinter", +] + +# Resolve script entrypoint relative to workspace root +entrypoint = os.path.abspath( + os.path.join(os.path.dirname("__file__"), "src", "taskify", "__main__.py") +) + +a = Analysis( + [entrypoint], + pathex=[], + binaries=[], + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[ + "torch", + "tensorflow", + "scipy", + "pandas", + "matplotlib", + "pygame", + "moviepy", + "sklearn", + "lxml", + "nltk", + "pyarrow", + "numba", + "llvmlite", + "openpyxl", + "yt_dlp", + "IPython", + "jedi", + "PyQt5", + "PyQt6", + "PySide2", + "PySide6", + ], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name="taskify", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, # Standard console is required for Click CLI commands output + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/pyproject.toml b/pyproject.toml index 1bf0799..c8616e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dev = [ "ruff>=0.1.0", "mypy>=1.5.0", "pyinstaller>=6.1.0", + "safety>=2.3.0", ] [project.scripts] diff --git a/requirements-dev.txt b/requirements-dev.txt index 09fa59d..103e262 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,3 +4,4 @@ pytest-cov>=4.1.0 ruff>=0.1.0 mypy>=1.5.0 pyinstaller>=6.1.0 +safety>=2.3.0 diff --git a/src/taskify/audio/capture.py b/src/taskify/audio/capture.py index faa366a..2b57efa 100644 --- a/src/taskify/audio/capture.py +++ b/src/taskify/audio/capture.py @@ -1,6 +1,7 @@ """Microphone audio capture module with RMS-based silence detection.""" import collections +import logging import queue import sys from typing import Any @@ -10,6 +11,8 @@ from taskify.config import Settings +logger = logging.getLogger(__name__) + class AudioCapture: """Handles cross-platform audio stream capture and real-time silence filtering.""" @@ -48,6 +51,7 @@ def __init__(self, settings: Settings) -> None: self._queue: queue.Queue[np.ndarray] = queue.Queue() self._stream: sd.InputStream | None = None self._is_recording = False + self._latest_rms = 0.0 @property def is_recording(self) -> bool: @@ -58,6 +62,15 @@ def is_recording(self) -> bool: """ return self._is_recording + @property + def latest_rms(self) -> float: + """Get the latest calculated Root Mean Square (RMS) audio level. + + Returns: + float: RMS value. + """ + return self._latest_rms + @classmethod def get_available_devices(cls) -> list[dict[str, Any]]: """Query host hardware and list all available recording input devices. @@ -125,10 +138,17 @@ def _audio_callback( # Compute Root Mean Square (RMS) energy rms = np.sqrt(np.mean(audio_data**2)) if len(audio_data) > 0 else 0.0 + self._latest_rms = rms if self._state == 0: # SILENT state if rms >= self._silence_threshold: # Transition to ACTIVE + logger.info( + "AudioCapture: speech activity detected (RMS: %.4f >= %.4f). " + "Transitioning to ACTIVE state.", + rms, + self._silence_threshold, + ) self._state = 1 self._silence_counter = 0 @@ -138,20 +158,42 @@ def _audio_callback( self._queue.put(audio_data) else: # Retain sliding window history + logger.debug( + "AudioCapture: silent chunk ignored (RMS: %.4f < %.4f).", + rms, + self._silence_threshold, + ) self._preroll_buffer.append(audio_data) else: # ACTIVE state if rms < self._silence_threshold: self._silence_counter += 1 if self._silence_counter >= self._silence_limit: # Transition to SILENT + logger.info( + "AudioCapture: silence duration limit met (counter: %d/%d). " + "Transitioning to SILENT state.", + self._silence_counter, + self._silence_limit, + ) self._state = 0 self._silence_counter = 0 self._preroll_buffer.clear() self._preroll_buffer.append(audio_data) else: + logger.debug( + "AudioCapture: active chunk recorded with low energy " + "(RMS: %.4f, counter: %d/%d).", + rms, + self._silence_counter, + self._silence_limit, + ) self._queue.put(audio_data) else: self._silence_counter = 0 + logger.debug( + "AudioCapture: recording active chunk (RMS: %.4f).", + rms, + ) self._queue.put(audio_data) def start(self) -> None: diff --git a/src/taskify/cli/main.py b/src/taskify/cli/main.py index 55b8fa2..718e94a 100644 --- a/src/taskify/cli/main.py +++ b/src/taskify/cli/main.py @@ -10,15 +10,80 @@ @click.pass_context def main(ctx: click.Context, version: bool) -> None: """Taskify: A fully offline, privacy-first, voice-driven task manager.""" + # Initialize unified log configurations on startup + try: + from taskify.config import load_settings + from taskify.logging_config import configure_logging + + settings = load_settings() + configure_logging(settings) + except Exception: + pass + if version: click.echo(f"Taskify version {__version__}") ctx.exit(0) if ctx.invoked_subcommand is None: - click.echo("Starting Taskify GUI...") - # GUI import and launch will go here in future issues. - # For now, print a success message and exit. - click.echo("GUI placeholder launched successfully.") + import logging + + from taskify.config import get_default_paths, load_settings + from taskify.llm.factory import get_llm_backend + from taskify.pipeline.scheduler import EventBus, ExtractionScheduler + from taskify.storage.database import DatabaseManager + from taskify.ui.main_window import MainWindow + + logger = logging.getLogger(__name__) + logger.info("Starting Taskify GUI...") + + # 1. Load settings and locate database path + settings = load_settings() + _, data_dir = get_default_paths() + data_dir.mkdir(parents=True, exist_ok=True) + db_path = data_dir / "taskify.db" + db = DatabaseManager(db_path) + + # 2. Setup Pub/Sub EventBus and NLP/LLM extraction backend + bus = EventBus() + llm_backend = get_llm_backend(settings) + + # 3. Setup and start periodic extraction background scheduler + scheduler = ExtractionScheduler(db, llm_backend, settings, bus) + scheduler.start() + logger.info("Extraction background scheduler started.") + + # 4. Construct and run Main custom tkinter window + try: + app = MainWindow(db, settings, bus) + + def on_close() -> None: + logger.info("Shutdown signal received. Closing GUI and scheduler...") + try: + scheduler.stop() + except Exception as e: + logger.error("Error stopping scheduler: %s", e) + try: + db.close() + except Exception as e: + logger.error("Error closing database: %s", e) + try: + app.destroy() + except Exception as e: + logger.error("Error destroying tkinter app: %s", e) + + app.protocol("WM_DELETE_WINDOW", on_close) + app.mainloop() + except Exception as exc: + logger.exception("Taskify GUI application encountered an error:") + try: + scheduler.stop() + except Exception: + pass + try: + db.close() + except Exception: + pass + raise click.ClickException(str(exc)) from exc ctx.exit(0) @@ -26,6 +91,7 @@ def main(ctx: click.Context, version: bool) -> None: def info() -> None: """Show details about local system paths and configuration.""" from taskify.config import get_default_paths + config_dir, data_dir = get_default_paths() click.echo("Taskify System Information:") click.echo("Status: Initialized") @@ -131,9 +197,7 @@ def config_show() -> None: default=None, help="Explicit model name override.", ) -def download_model( - engine: str, lang: str, size: str, model: str | None -) -> None: +def download_model(engine: str, lang: str, size: str, model: str | None) -> None: """Download local speech-to-text models.""" model_id = model if model else f"{lang}-{size}".lower() @@ -156,5 +220,3 @@ def download_model( except Exception as e: click.echo(f"Error downloading Whisper model: {e}", err=True) raise click.Abort() from e - - diff --git a/src/taskify/config/__init__.py b/src/taskify/config/__init__.py index 1af1dbc..741b7d0 100644 --- a/src/taskify/config/__init__.py +++ b/src/taskify/config/__init__.py @@ -10,6 +10,7 @@ get_default_paths, initialize_user_config, load_settings, + save_settings, ) __all__ = [ @@ -22,4 +23,5 @@ "get_default_paths", "load_settings", "initialize_user_config", + "save_settings", ] diff --git a/src/taskify/config/settings.py b/src/taskify/config/settings.py index 7efc058..4758b0e 100644 --- a/src/taskify/config/settings.py +++ b/src/taskify/config/settings.py @@ -198,9 +198,7 @@ def get_default_paths() -> tuple[Path, Path]: xdg_config = os.environ.get("XDG_CONFIG_HOME") xdg_data = os.environ.get("XDG_DATA_HOME") config_dir = ( - Path(xdg_config) / "taskify" - if xdg_config - else home / ".config" / "taskify" + Path(xdg_config) / "taskify" if xdg_config else home / ".config" / "taskify" ) data_dir = ( Path(xdg_data) / "taskify" @@ -280,9 +278,7 @@ def load_settings(user_config_path: Path | None = None) -> Settings: audio=AudioSettings.from_dict(merged.get("audio", {})), stt=STTSettings.from_dict(merged.get("stt", {})), llm=LLMSettings.from_dict(merged.get("llm", {})), - storage=StorageSettings.from_dict( - merged.get("storage", {}), default_data_dir - ), + storage=StorageSettings.from_dict(merged.get("storage", {}), default_data_dir), logging=LoggingSettings.from_dict(merged.get("logging", {})), config_path=target_config if target_config.exists() else None, ) @@ -315,3 +311,55 @@ def initialize_user_config(force: bool = False) -> Path: target_config.write_text(default_text, encoding="utf-8") return target_config + + +def save_settings(settings: Settings) -> None: + """Serialize the settings object back to the configuration file (TOML format). + + Args: + settings (Settings): Active application settings. + """ + config_dir, _ = get_default_paths() + path = settings.config_path if settings.config_path else config_dir / "config.toml" + + # Ensure parent directories exist + path.parent.mkdir(parents=True, exist_ok=True) + + # Ensure paths are saved nicely as strings or posix formats + s_storage = settings.storage + data_dir_str = s_storage.data_dir.as_posix() if s_storage.data_dir else "" + log_dir_str = s_storage.log_dir.as_posix() if s_storage.log_dir else "" + + # Generate custom TOML content + lines = [ + "[audio]", + f'device = "{settings.audio.device}"', + f"sample_rate = {settings.audio.sample_rate}", + f"chunk_duration_ms = {settings.audio.chunk_duration_ms}", + f"silence_threshold = {settings.audio.silence_threshold}", + f"silence_duration_s = {settings.audio.silence_duration_s}", + "", + "[stt]", + f'engine = "{settings.stt.engine}"', + f'vosk_model = "{settings.stt.vosk_model}"', + f'whisper_model = "{settings.stt.whisper_model}"', + f'language = "{settings.stt.language}"', + "", + "[llm]", + f'backend = "{settings.llm.backend}"', + f'ollama_host = "{settings.llm.ollama_host}"', + f'ollama_model = "{settings.llm.ollama_model}"', + f"extraction_interval_s = {settings.llm.extraction_interval_s}", + "", + "[storage]", + f'data_dir = "{data_dir_str}"', + f'log_dir = "{log_dir_str}"', + "", + "[logging]", + f'level = "{settings.logging.level}"', + f"debug_llm = {str(settings.logging.debug_llm).lower()}", + "", + ] + + path.write_text("\n".join(lines), encoding="utf-8") + settings.config_path = path diff --git a/src/taskify/llm/factory.py b/src/taskify/llm/factory.py index b62e987..c070df1 100644 --- a/src/taskify/llm/factory.py +++ b/src/taskify/llm/factory.py @@ -32,8 +32,7 @@ def get_llm_backend(settings: Settings) -> LLMBackend: backend = OllamaBackend(settings.llm) if not backend.is_available: logger.warning( - "Ollama is not reachable at '%s'. " - "Falling back to NLP backend.", + "Ollama is not reachable at '%s'. Falling back to NLP backend.", settings.llm.ollama_host, ) return NLPBackend() @@ -43,6 +42,5 @@ def get_llm_backend(settings: Settings) -> LLMBackend: return NLPBackend() raise ValueError( - f"Unknown LLM backend '{settings.llm.backend}'. " - "Valid choices: 'ollama', 'nlp'." + f"Unknown LLM backend '{settings.llm.backend}'. Valid choices: 'ollama', 'nlp'." ) diff --git a/src/taskify/llm/models.py b/src/taskify/llm/models.py index dc1f3fb..488861a 100644 --- a/src/taskify/llm/models.py +++ b/src/taskify/llm/models.py @@ -9,10 +9,10 @@ class MatrixQuadrant(str, Enum): """Eisenhower Matrix quadrant classification.""" - DO_FIRST = "do_first" # Urgent + Important - SCHEDULE = "schedule" # Not Urgent + Important - DELEGATE = "delegate" # Urgent + Not Important - ELIMINATE = "eliminate" # Not Urgent + Not Important + DO_FIRST = "do_first" # Urgent + Important + SCHEDULE = "schedule" # Not Urgent + Important + DELEGATE = "delegate" # Urgent + Not Important + ELIMINATE = "eliminate" # Not Urgent + Not Important @classmethod def from_string(cls, value: str) -> MatrixQuadrant: @@ -32,8 +32,7 @@ def from_string(cls, value: str) -> MatrixQuadrant: if member.value == normalised: return member raise ValueError( - f"Unknown quadrant '{value}'. " - f"Valid values: {[m.value for m in cls]}" + f"Unknown quadrant '{value}'. Valid values: {[m.value for m in cls]}" ) diff --git a/src/taskify/llm/nlp_backend.py b/src/taskify/llm/nlp_backend.py index 9b8d268..0b7b9ae 100644 --- a/src/taskify/llm/nlp_backend.py +++ b/src/taskify/llm/nlp_backend.py @@ -25,10 +25,27 @@ # Signals that push a task toward "Urgent" _URGENT_KEYWORDS: frozenset[str] = frozenset( { - "urgent", "urgently", "asap", "immediately", "right now", "today", - "tonight", "this morning", "this afternoon", "critical", "emergency", - "deadline", "due today", "by end of day", "eod", "now", "quickly", - "straight away", "at once", "before noon", "before tonight", + "urgent", + "urgently", + "asap", + "immediately", + "right now", + "today", + "tonight", + "this morning", + "this afternoon", + "critical", + "emergency", + "deadline", + "due today", + "by end of day", + "eod", + "now", + "quickly", + "straight away", + "at once", + "before noon", + "before tonight", "before end of day", } ) @@ -36,45 +53,151 @@ # Signals that push a task toward "Important" _IMPORTANT_KEYWORDS: frozenset[str] = frozenset( { - "important", "crucial", "essential", "must", "need to", "have to", - "priority", "key", "critical", "significant", "vital", "necessary", - "strategic", "milestone", "goal", "objective", "project", + "important", + "crucial", + "essential", + "must", + "need to", + "have to", + "priority", + "key", + "critical", + "significant", + "vital", + "necessary", + "strategic", + "milestone", + "goal", + "objective", + "project", } ) # Signals that suppress importance ("not important") _LOW_IMPORTANCE_KEYWORDS: frozenset[str] = frozenset( { - "someday", "maybe", "eventually", "whenever", "nice to have", - "low priority", "not important", "minor", "trivial", "later", - "at some point", "if possible", "could", "might", + "someday", + "maybe", + "eventually", + "whenever", + "nice to have", + "low priority", + "not important", + "minor", + "trivial", + "later", + "at some point", + "if possible", + "could", + "might", } ) # Delegation signals _DELEGATE_KEYWORDS: frozenset[str] = frozenset( { - "ask", "remind", "tell", "have someone", "delegate", "assign", - "request", "get someone", "let someone", "make sure someone", - "have them", "have him", "have her", + "ask", + "remind", + "tell", + "have someone", + "delegate", + "assign", + "request", + "get someone", + "let someone", + "make sure someone", + "have them", + "have him", + "have her", } ) # Common imperative verbs to seed extraction _IMPERATIVE_VERBS: frozenset[str] = frozenset( { - "buy", "call", "check", "clean", "complete", "confirm", "contact", - "create", "do", "draft", "email", "file", "find", "finish", "fix", - "follow", "get", "go", "handle", "install", "make", "meet", "order", - "pay", "pick", "plan", "prepare", "print", "purchase", "read", - "register", "remind", "reply", "research", "review", "schedule", - "send", "set", "share", "sign", "start", "submit", "talk", - "update", "upload", "write", "book", "cancel", "change", "close", - "collect", "connect", "delete", "design", "download", "drop", - "edit", "enter", "fill", "give", "help", "join", "launch", - "move", "notify", "open", "provide", "push", "record", - "remove", "report", "request", "return", "run", "save", "ship", - "show", "sort", "speak", "take", "test", "track", "transfer", + "buy", + "call", + "check", + "clean", + "complete", + "confirm", + "contact", + "create", + "do", + "draft", + "email", + "file", + "find", + "finish", + "fix", + "follow", + "get", + "go", + "handle", + "install", + "make", + "meet", + "order", + "pay", + "pick", + "plan", + "prepare", + "print", + "purchase", + "read", + "register", + "remind", + "reply", + "research", + "review", + "schedule", + "send", + "set", + "share", + "sign", + "start", + "submit", + "talk", + "update", + "upload", + "write", + "book", + "cancel", + "change", + "close", + "collect", + "connect", + "delete", + "design", + "download", + "drop", + "edit", + "enter", + "fill", + "give", + "help", + "join", + "launch", + "move", + "notify", + "open", + "provide", + "push", + "record", + "remove", + "report", + "request", + "return", + "run", + "save", + "ship", + "show", + "sort", + "speak", + "take", + "test", + "track", + "transfer", "visit", } ) @@ -128,6 +251,7 @@ # Helper functions # --------------------------------------------------------------------------- + def _contains_any(text: str, keywords: frozenset[str]) -> bool: """Return True if *text* contains any keyword (word-boundary aware).""" lower = text.lower() @@ -187,6 +311,7 @@ def _title_case_sentence(text: str) -> str: # Rule-based extraction (no spaCy) # --------------------------------------------------------------------------- + def _extract_tasks_regex(transcript: str) -> list[TaskItem]: """Extract tasks using pure regex/keyword rules. @@ -244,6 +369,7 @@ def _extract_tasks_regex(transcript: str) -> list[TaskItem]: # spaCy-enhanced extraction (optional) # --------------------------------------------------------------------------- + def _try_load_spacy() -> Any: """Attempt to import and load the spaCy English model. @@ -291,8 +417,7 @@ def _extract_tasks_spacy(transcript: str, nlp: Any) -> list[TaskItem]: # Accept sentence if root verb is an imperative verb, OR rule matches is_imperative = ( - root_token is not None - and root_token.lemma_.lower() in _IMPERATIVE_VERBS + root_token is not None and root_token.lemma_.lower() in _IMPERATIVE_VERBS ) or bool(_IMPERATIVE_PATTERN.match(sent_text)) if not is_imperative and not re.search( @@ -336,6 +461,7 @@ def _extract_tasks_spacy(transcript: str, nlp: Any) -> list[TaskItem]: # NLPBackend # --------------------------------------------------------------------------- + class NLPBackend(LLMBackend): """Rule-based task extraction backend with optional spaCy enhancement. diff --git a/src/taskify/llm/ollama_backend.py b/src/taskify/llm/ollama_backend.py index 18548c6..8171fa2 100644 --- a/src/taskify/llm/ollama_backend.py +++ b/src/taskify/llm/ollama_backend.py @@ -168,12 +168,15 @@ def _generate(self, prompt: str) -> str | None: "options": {"temperature": 0.1}, } + logger.debug("Ollama request prompt:\n%s", prompt) + for attempt in range(1, self._retries + 1): try: resp = self._client.post(url, json=payload) resp.raise_for_status() data = resp.json() response_text: str = data.get("response", "") + logger.debug("Ollama response:\n%s", response_text) return response_text except httpx.HTTPStatusError as exc: logger.warning( @@ -208,9 +211,7 @@ def _generate(self, prompt: str) -> str | None: # Internal: parsing # ------------------------------------------------------------------ - def _parse_tasks( - self, raw: str, source_transcript: str = "" - ) -> list[TaskItem]: + def _parse_tasks(self, raw: str, source_transcript: str = "") -> list[TaskItem]: """Parse a JSON array string into TaskItem objects. Invalid or malformed fields are replaced with safe defaults. @@ -229,9 +230,7 @@ def _parse_tasks( return [] if not isinstance(data, list): - logger.warning( - "Expected JSON array from extraction, got %s.", type(data) - ) + logger.warning("Expected JSON array from extraction, got %s.", type(data)) return [] tasks: list[TaskItem] = [] diff --git a/src/taskify/logging_config.py b/src/taskify/logging_config.py new file mode 100644 index 0000000..8357727 --- /dev/null +++ b/src/taskify/logging_config.py @@ -0,0 +1,105 @@ +"""Structured logging configurations with rotation and dynamic session contexts.""" + +from __future__ import annotations + +import contextvars +import logging +import sys +from logging.handlers import RotatingFileHandler +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from taskify.config import Settings + +# Thread-safe context variable to store the current capture session ID +session_id_ctx: contextvars.ContextVar[str] = contextvars.ContextVar( + "session_id", default="N/A" +) + + +class SessionIDFilter(logging.Filter): + """Injects the active session ID into log records for structured output.""" + + def filter(self, record: logging.LogRecord) -> bool: + """Add session_id attribute from ContextVar to the log record.""" + record.session_id = session_id_ctx.get() + return True + + +def set_active_session_id(session_id: str | None) -> None: + """Set the active recording session identifier in context. + + Args: + session_id (str, optional): Target UUID session string or None to reset. + """ + session_id_ctx.set(session_id if session_id else "N/A") + + +def configure_logging(settings: Settings) -> None: + """Initialize structured console and rotating file logging handlers. + + Args: + settings (Settings): Active configuration blocks containing levels/paths. + """ + # 1. Clear existing handlers on the root logger to prevent duplicate logs + root_logger = logging.getLogger() + for handler in list(root_logger.handlers): + root_logger.removeHandler(handler) + + # 2. Determine base log levels + global_level_str = settings.logging.level.upper() + global_level = getattr(logging, global_level_str, logging.INFO) + + # If debug_llm is enabled, root accepts DEBUG so they propagate to handlers + if settings.logging.debug_llm and global_level > logging.DEBUG: + root_logger.setLevel(logging.DEBUG) + + # Restrict other subsystems to the global level + logging.getLogger("taskify.audio").setLevel(global_level) + logging.getLogger("taskify.stt").setLevel(global_level) + logging.getLogger("taskify.storage").setLevel(global_level) + logging.getLogger("taskify.pipeline").setLevel(global_level) + logging.getLogger("taskify.ui").setLevel(global_level) + # Explicity debug LLM + logging.getLogger("taskify.llm").setLevel(logging.DEBUG) + else: + root_logger.setLevel(global_level) + + # 3. Formatter including module, threadName, and session_id context + fmt_str = ( + "%(asctime)s [%(levelname)s] [%(module)s:%(threadName)s] " + "[Session: %(session_id)s] %(message)s" + ) + formatter = logging.Formatter(fmt_str) + session_filter = SessionIDFilter() + + # 4. Console Handler (Write INFO+ or DEBUG if requested globally) + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(global_level) + console_handler.setFormatter(formatter) + console_handler.addFilter(session_filter) + root_logger.addHandler(console_handler) + + # 5. Rotating File Handler (10 MB per file, max 5 backups) + log_dir = settings.storage.log_dir + if not log_dir: + # Fallback default path + log_dir = Path.home() / ".local" / "share" / "taskify" + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / "app.log" + + try: + file_handler = RotatingFileHandler( + log_path, + maxBytes=10 * 1024 * 1024, + backupCount=5, + encoding="utf-8", + ) + file_handler.setLevel(global_level) + file_handler.setFormatter(formatter) + file_handler.addFilter(session_filter) + root_logger.addHandler(file_handler) + except OSError as e: + # Gracefully log console warning if file write is locked/forbidden + logging.warning("Failed to configure rotating file handler: %s", e) diff --git a/src/taskify/pipeline/scheduler.py b/src/taskify/pipeline/scheduler.py index 75bbb64..6e3f162 100644 --- a/src/taskify/pipeline/scheduler.py +++ b/src/taskify/pipeline/scheduler.py @@ -40,6 +40,8 @@ # Event name emitted when new tasks are extracted and persisted EVENT_TASKS_UPDATED = "tasks_updated" +# Event name emitted when configuration settings are updated +EVENT_SETTINGS_UPDATED = "settings_updated" class EventBus: @@ -104,6 +106,7 @@ def emit(self, event: str, data: object = None) -> None: # ExtractionScheduler # --------------------------------------------------------------------------- + class ExtractionScheduler: """Periodically extracts tasks from unprocessed transcript segments. @@ -139,6 +142,9 @@ def __init__( self._timer: threading.Timer | None = None self._started = False + # Subscribe to settings updates to allow live reload of extraction interval + self._bus.subscribe(EVENT_SETTINGS_UPDATED, self._on_settings_updated) + # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ @@ -152,9 +158,7 @@ def start(self) -> None: return self._started = True self._stop_event.clear() - logger.info( - "ExtractionScheduler started (interval=%.0fs).", self._interval_s - ) + logger.info("ExtractionScheduler started (interval=%.0fs).", self._interval_s) self._schedule_next() def stop(self) -> None: @@ -222,9 +226,7 @@ def _run_extraction_cycle(self) -> None: logger.debug("ExtractionScheduler: no unprocessed segments found.") return - logger.info( - "ExtractionScheduler: processing %d segment(s).", len(segments) - ) + logger.info("ExtractionScheduler: processing %d segment(s).", len(segments)) extracted_count = 0 processed_ids: list[int] = [] @@ -274,3 +276,14 @@ def _run_extraction_cycle(self) -> None: EVENT_TASKS_UPDATED, {"extracted": extracted_count, "segments": len(processed_ids)}, ) + + def _on_settings_updated(self, data: object) -> None: + """Handle settings change event to dynamically update scheduler interval.""" + old_interval = self._interval_s + self._interval_s = float(self._settings.llm.extraction_interval_s) + logger.info( + "ExtractionScheduler: updated interval from %.0fs to %.0fs " + "based on settings change.", + old_interval, + self._interval_s, + ) diff --git a/src/taskify/storage/database.py b/src/taskify/storage/database.py index 447b03e..f6e0320 100644 --- a/src/taskify/storage/database.py +++ b/src/taskify/storage/database.py @@ -13,6 +13,17 @@ TranscriptSegment, ) +# Map app matrix quadrant keys to SQLite DB check constraint values +_APP_TO_DB_QUADRANT = { + "do_first": "urgent_important", + "schedule": "important_not_urgent", + "delegate": "urgent_not_important", + "eliminate": "neither", +} + +# Inverse mapping to translate DB values back for the application +_DB_TO_APP_QUADRANT = {v: k for k, v in _APP_TO_DB_QUADRANT.items()} + class DatabaseManager: """Thread-safe SQLite database manager for task and transcript persistence.""" @@ -65,9 +76,7 @@ def run_migrations(self) -> None: applied_versions = {row["version"] for row in cursor.fetchall()} # Locate migration resources - migration_files = importlib.resources.files( - "taskify.storage.migrations" - ) + migration_files = importlib.resources.files("taskify.storage.migrations") # Filter and parse files starting with 4 digits (e.g. 0001_xxx.sql) migrations_to_apply = [] @@ -257,6 +266,7 @@ def create_task( Returns: tuple[TaskRecord, MatrixEntry]: Created records. """ + db_quadrant = _APP_TO_DB_QUADRANT.get(quadrant, quadrant) with self._lock: try: # Insert task details @@ -271,7 +281,7 @@ def create_task( # Insert matrix association self._conn.execute( "INSERT INTO matrix_entries (task_id, quadrant) VALUES (?, ?);", - (task_id, quadrant), + (task_id, db_quadrant), ) self._conn.commit() @@ -288,7 +298,8 @@ def create_task( "FROM matrix_entries WHERE task_id = ?;", (task_id,), ) - matrix_row = matrix_cursor.fetchone() + matrix_row = list(matrix_cursor.fetchone()) + matrix_row[1] = _DB_TO_APP_QUADRANT.get(matrix_row[1], matrix_row[1]) return ( TaskRecord.from_row(tuple(task_row)), @@ -375,7 +386,7 @@ def get_task(self, task_id: int) -> tuple[TaskRecord, MatrixEntry] | None: row[5], row[6], ) - m_row = (row[7], row[8], row[9]) + m_row = (row[7], _DB_TO_APP_QUADRANT.get(row[8], row[8]), row[9]) return TaskRecord.from_row(t_row), MatrixEntry.from_row(m_row) @@ -390,18 +401,19 @@ def get_tasks_in_quadrant( Returns: list[tuple[TaskRecord, MatrixEntry]]: List of tasks matched. """ + db_quadrant = _APP_TO_DB_QUADRANT.get(quadrant, quadrant) with self._lock: cursor = self._conn.execute( """ SELECT t.id, t.title, t.notes, t.due_date, t.status, t.created_at, t.updated_at, m.task_id, m.quadrant, m.user_override - FROM tasks t - JOIN matrix_entries m ON t.id = m.task_id - WHERE m.quadrant = ? - ORDER BY t.created_at DESC; - """, - (quadrant,), + FROM tasks t + JOIN matrix_entries m ON t.id = m.task_id + WHERE m.quadrant = ? + ORDER BY t.created_at DESC; + """, + (db_quadrant,), ) rows = cursor.fetchall() results = [] @@ -415,7 +427,7 @@ def get_tasks_in_quadrant( row[5], row[6], ) - m_row = (row[7], row[8], row[9]) + m_row = (row[7], _DB_TO_APP_QUADRANT.get(row[8], row[8]), row[9]) results.append( (TaskRecord.from_row(t_row), MatrixEntry.from_row(m_row)) ) @@ -433,10 +445,10 @@ def get_all_tasks(self) -> list[tuple[TaskRecord, MatrixEntry]]: SELECT t.id, t.title, t.notes, t.due_date, t.status, t.created_at, t.updated_at, m.task_id, m.quadrant, m.user_override - FROM tasks t - JOIN matrix_entries m ON t.id = m.task_id - ORDER BY t.created_at DESC; - """ + FROM tasks t + JOIN matrix_entries m ON t.id = m.task_id + ORDER BY t.created_at DESC; + """ ) rows = cursor.fetchall() results = [] @@ -450,7 +462,7 @@ def get_all_tasks(self) -> list[tuple[TaskRecord, MatrixEntry]]: row[5], row[6], ) - m_row = (row[7], row[8], row[9]) + m_row = (row[7], _DB_TO_APP_QUADRANT.get(row[8], row[8]), row[9]) results.append( (TaskRecord.from_row(t_row), MatrixEntry.from_row(m_row)) ) @@ -463,6 +475,7 @@ def override_matrix_quadrant(self, task_id: int, quadrant: str) -> None: task_id (int): Existing task identifier. quadrant (str): Target quadrant name. """ + db_quadrant = _APP_TO_DB_QUADRANT.get(quadrant, quadrant) with self._lock: try: self._conn.execute( @@ -471,9 +484,44 @@ def override_matrix_quadrant(self, task_id: int, quadrant: str) -> None: SET quadrant = ?, user_override = 1 WHERE task_id = ?; """, - (quadrant, task_id), + (db_quadrant, task_id), ) self._conn.commit() except sqlite3.Error as e: self._conn.rollback() raise e + + def get_transcript_excerpt_for_task(self, task_id: int) -> str: + """Find relevant transcript segments that match this task's context. + + Args: + task_id (int): Existing task identifier. + + Returns: + str: Combined transcript context or a fallback message. + """ + task_data = self.get_task(task_id) + if not task_data: + return "No task record found." + + task, _ = task_data + if not task.created_at: + return "No creation timestamp available for context matching." + + with self._lock: + try: + # Query segments recorded within 10 minutes (600s) of task creation + cursor = self._conn.execute( + """ + SELECT text FROM transcripts + WHERE abs(strftime('%s', created_at) - strftime('%s', ?)) <= 600 + ORDER BY created_at ASC; + """, + (task.created_at,), + ) + rows = cursor.fetchall() + if rows: + return " ".join(row[0].strip() for row in rows if row[0]) + except sqlite3.Error: + pass + return "No matching transcript context found." diff --git a/src/taskify/storage/transcript_writer.py b/src/taskify/storage/transcript_writer.py index bc70968..3eb9c2e 100644 --- a/src/taskify/storage/transcript_writer.py +++ b/src/taskify/storage/transcript_writer.py @@ -175,8 +175,7 @@ def _worker(self) -> None: elapsed = time.monotonic() - last_flush should_flush = ( - len(pending) >= self._batch_size - or elapsed >= self._flush_interval_s + len(pending) >= self._batch_size or elapsed >= self._flush_interval_s ) if should_flush: self._persist_batch(pending) @@ -283,6 +282,7 @@ def _append_to_logs(self, seg: TranscriptSegment) -> None: def _register_signal_handlers(self) -> None: """Register SIGTERM and SIGINT handlers to flush before process exit.""" + def _handler(signum: int, frame: object) -> None: self.stop() diff --git a/src/taskify/stt/download.py b/src/taskify/stt/download.py index 782d96e..881a69a 100644 --- a/src/taskify/stt/download.py +++ b/src/taskify/stt/download.py @@ -94,9 +94,7 @@ def download_vosk_model(model_name: str) -> Path: import shutil shutil.rmtree(target_model_path, ignore_errors=True) - raise RuntimeError( - f"Failed to install speech model '{model_name}': {e}" - ) from e + raise RuntimeError(f"Failed to install speech model '{model_name}': {e}") from e def download_whisper_model(model_size: str) -> Path: @@ -123,9 +121,7 @@ def download_whisper_model(model_size: str) -> Path: download_root = data_dir / "models" / "whisper" download_root.mkdir(parents=True, exist_ok=True) - click.echo( - f"Downloading Whisper '{model_size}' model to: {download_root}..." - ) + click.echo(f"Downloading Whisper '{model_size}' model to: {download_root}...") click.echo("This will pull quantized weights from Hugging Face.") try: @@ -140,8 +136,7 @@ def download_whisper_model(model_size: str) -> Path: ) target_model_path = ( - download_root - / f"models--Systran--faster-whisper-{model_size}" + download_root / f"models--Systran--faster-whisper-{model_size}" ) click.echo("Whisper model download and check completed successfully.") return target_model_path @@ -149,4 +144,3 @@ def download_whisper_model(model_size: str) -> Path: raise RuntimeError( f"Failed to download Whisper model '{model_size}': {e}" ) from e - diff --git a/src/taskify/stt/whisper_engine.py b/src/taskify/stt/whisper_engine.py index f28d9ef..b6295d6 100644 --- a/src/taskify/stt/whisper_engine.py +++ b/src/taskify/stt/whisper_engine.py @@ -55,7 +55,7 @@ def initialize(self) -> None: except ImportError as e: raise ImportError( "faster-whisper is not installed. " - "Install it using: pip install \"taskify[whisper]\"" + 'Install it using: pip install "taskify[whisper]"' ) from e # Ensure directory is created diff --git a/src/taskify/ui/__init__.py b/src/taskify/ui/__init__.py index f8b3292..bdd06e5 100644 --- a/src/taskify/ui/__init__.py +++ b/src/taskify/ui/__init__.py @@ -1,5 +1,7 @@ """User Interface widgets and windows subpackage using CustomTkinter.""" from taskify.ui.main_window import MainWindow +from taskify.ui.settings_dialog import SettingsDialog +from taskify.ui.task_detail_panel import TaskDetailPanel -__all__ = ["MainWindow"] +__all__ = ["MainWindow", "SettingsDialog", "TaskDetailPanel"] diff --git a/src/taskify/ui/main_window.py b/src/taskify/ui/main_window.py index 6bfe916..82544ca 100644 --- a/src/taskify/ui/main_window.py +++ b/src/taskify/ui/main_window.py @@ -3,20 +3,32 @@ from __future__ import annotations import logging +import threading +import time import tkinter as tk +import uuid from datetime import datetime from typing import TYPE_CHECKING import customtkinter as ctk from taskify.llm.models import MatrixQuadrant -from taskify.pipeline.scheduler import EVENT_TASKS_UPDATED +from taskify.pipeline.scheduler import EVENT_SETTINGS_UPDATED, EVENT_TASKS_UPDATED + +try: + from taskify.audio.capture import AudioCapture +except Exception: # portaudio may be missing in CI / headless envs + AudioCapture = None # type: ignore[assignment,misc] if TYPE_CHECKING: + from taskify.audio.capture import AudioCapture from taskify.config import Settings from taskify.pipeline.scheduler import EventBus from taskify.storage.database import DatabaseManager from taskify.storage.models import TaskRecord + from taskify.storage.transcript_writer import TranscriptWriter + from taskify.stt.base import STTEngine + from taskify.ui.task_detail_panel import TaskDetailPanel logger = logging.getLogger(__name__) @@ -34,6 +46,15 @@ COLOR_DELEGATE = "#FFBF5F" # Warm Orange/Amber COLOR_ELIMINATE = "#8A8A93" # Muted Slate Gray +# Recording state colors +COLOR_RECORDING = "#FF4040" +COLOR_IDLE = "#4ADE80" + +# Live transcript max chars +_TRANSCRIPT_MAX_CHARS = 500 +# Audio level poll interval (ms) +_LEVEL_POLL_MS = 100 + class TaskCard(ctk.CTkFrame): """Interactive visual card presenting a single task.""" @@ -114,25 +135,32 @@ def __init__( ) self.footer_label.grid(row=2, column=0, padx=12, pady=(0, 10), sticky="w") - # Bind hover events for interactive feel + # Bind hover and click events for interactive feel for widget in (self, self.title_label): widget.bind("", self._on_enter) widget.bind("", self._on_leave) + widget.bind("", self._on_click) widget.bind("", self._show_context_menu) widget.bind("", self._show_context_menu) # macOS right click if notes_snippet: self.notes_label.bind("", self._on_enter) self.notes_label.bind("", self._on_leave) + self.notes_label.bind("", self._on_click) self.notes_label.bind("", self._show_context_menu) self.notes_label.bind("", self._show_context_menu) if footer_text: self.footer_label.bind("", self._on_enter) self.footer_label.bind("", self._on_leave) + self.footer_label.bind("", self._on_click) self.footer_label.bind("", self._show_context_menu) self.footer_label.bind("", self._show_context_menu) + def _on_click(self, event: tk.Event) -> None: + if self.task_id is not None: + self.on_action("edit", self.task_id) + def _on_enter(self, event: tk.Event) -> None: self.configure(fg_color=CARD_HOVER) @@ -227,14 +255,34 @@ def __init__( ctk.set_appearance_mode("dark") ctk.set_default_color_theme("blue") - # Responsive main grid layout configuration + # Responsive main grid layout: control sidebar + matrix + task detail panel self.grid_rowconfigure(0, weight=1) self.grid_rowconfigure(1, weight=0) # Status Bar - self.grid_columnconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=0) # Control sidebar + self.grid_columnconfigure(1, weight=1) # Quadrant matrix + # Task details sidebar (shows dynamically) + self.grid_columnconfigure(2, weight=0) + + self.detail_panel: TaskDetailPanel | None = None + self.bind("", lambda e: self.close_task_detail()) + + # Recording and transcription state + self._is_recording = False + self._session_id: str | None = None + self._session_start: float | None = None + self._timer_after_id: str | None = None + self._level_after_id: str | None = None + self._audio_capture: AudioCapture | None = None + self._stt_engine: STTEngine | None = None + self._transcript_writer: TranscriptWriter | None = None + self._transcribe_thread: threading.Thread | None = None + + # Build control sidebar + self._create_control_panel() # Container for the 2x2 quadrant layout self.matrix_frame = ctk.CTkFrame(self, fg_color="transparent") - self.matrix_frame.grid(row=0, column=0, padx=16, pady=16, sticky="nsew") + self.matrix_frame.grid(row=0, column=1, padx=16, pady=16, sticky="nsew") self.matrix_frame.grid_rowconfigure(0, weight=1) self.matrix_frame.grid_rowconfigure(1, weight=1) @@ -276,7 +324,7 @@ def __init__( MatrixQuadrant.ELIMINATE.value, ) - # Create Status Bar + # Create Status Bar (spans both columns) self._create_status_bar() # Initial data loading @@ -285,6 +333,393 @@ def __init__( # Wire EventBus triggers thread-safely self._bus.subscribe(EVENT_TASKS_UPDATED, self._on_tasks_updated) + # ------------------------------------------------------------------ + # Control panel + # ------------------------------------------------------------------ + + def _create_control_panel(self) -> None: + """Build the left-hand recording control sidebar.""" + panel = ctk.CTkFrame( + self, + width=240, + fg_color=CARD_BG, + border_color=BORDER_COLOR, + border_width=1, + corner_radius=0, + ) + panel.grid(row=0, column=0, sticky="nsew") + panel.grid_propagate(False) + panel.grid_columnconfigure(0, weight=1) + + # ---- App title / branding ---- + brand = ctk.CTkLabel( + panel, + text="TASKIFY", + font=ctk.CTkFont(size=20, weight="bold"), + text_color=TEXT_PRIMARY, + ) + brand.grid(row=0, column=0, padx=20, pady=(20, 4), sticky="w") + + subtitle = ctk.CTkLabel( + panel, + text="Voice Task Manager", + font=ctk.CTkFont(size=11), + text_color=TEXT_SECONDARY, + ) + subtitle.grid(row=1, column=0, padx=20, pady=(0, 20), sticky="w") + + # Separator line + sep1 = ctk.CTkFrame(panel, height=1, fg_color=BORDER_COLOR, corner_radius=0) + sep1.grid(row=2, column=0, padx=12, pady=(0, 16), sticky="ew") + + # ---- Recording button ---- + self.record_btn = ctk.CTkButton( + panel, + text="▶ Start Recording", + font=ctk.CTkFont(size=13, weight="bold"), + fg_color=COLOR_IDLE, + hover_color="#22C55E", + text_color="#000000", + corner_radius=8, + height=42, + command=self.toggle_recording, + ) + self.record_btn.grid(row=3, column=0, padx=16, pady=(0, 12), sticky="ew") + + # ---- Session Timer ---- + timer_frame = ctk.CTkFrame(panel, fg_color="transparent") + timer_frame.grid(row=4, column=0, padx=16, pady=(0, 4), sticky="ew") + + ctk.CTkLabel( + timer_frame, + text="Session", + font=ctk.CTkFont(size=11), + text_color=TEXT_SECONDARY, + ).pack(side="left") + + self.timer_label = ctk.CTkLabel( + timer_frame, + text="00:00", + font=ctk.CTkFont(size=13, weight="bold"), + text_color=TEXT_PRIMARY, + ) + self.timer_label.pack(side="right") + + # ---- Audio level bar ---- + level_lbl = ctk.CTkLabel( + panel, + text="Audio Level", + font=ctk.CTkFont(size=11), + text_color=TEXT_SECONDARY, + anchor="w", + ) + level_lbl.grid(row=5, column=0, padx=16, pady=(8, 2), sticky="w") + + self.level_bar = ctk.CTkProgressBar( + panel, + orientation="horizontal", + height=8, + progress_color=COLOR_IDLE, + fg_color=BORDER_COLOR, + ) + self.level_bar.set(0) + self.level_bar.grid(row=6, column=0, padx=16, pady=(0, 12), sticky="ew") + + # ---- Live transcript box ---- + tx_lbl = ctk.CTkLabel( + panel, + text="Live Transcript", + font=ctk.CTkFont(size=11), + text_color=TEXT_SECONDARY, + anchor="w", + ) + tx_lbl.grid(row=7, column=0, padx=16, pady=(4, 2), sticky="w") + + self.transcript_box = ctk.CTkTextbox( + panel, + font=ctk.CTkFont(size=11), + text_color=TEXT_PRIMARY, + fg_color="#0D0D10", + border_color=BORDER_COLOR, + border_width=1, + corner_radius=6, + wrap="word", + state="disabled", + ) + self.transcript_box.grid(row=8, column=0, padx=12, pady=(0, 8), sticky="nsew") + panel.grid_rowconfigure(8, weight=1) + + # ---- Settings button ---- + self.settings_btn = ctk.CTkButton( + panel, + text="⚙ Settings", + font=ctk.CTkFont(size=12, weight="bold"), + fg_color="transparent", + hover_color=CARD_HOVER, + text_color=TEXT_SECONDARY, + border_color=BORDER_COLOR, + border_width=1, + corner_radius=8, + height=32, + command=self.open_settings, + ) + self.settings_btn.grid(row=9, column=0, padx=16, pady=(0, 16), sticky="ew") + + # ------------------------------------------------------------------ + # Recording toggle & session timer + # ------------------------------------------------------------------ + + def toggle_recording(self) -> None: + """Start or stop recording; update button and status label accordingly.""" + if self._is_recording: + self._stop_recording() + else: + self._start_recording() + + def _start_recording(self) -> None: + """Open AudioCapture stream and begin session timer.""" + from taskify.storage.transcript_writer import TranscriptWriter + from taskify.stt import get_stt_engine + + session_id = str(uuid.uuid4()) + self._session_id = session_id + self._session_start_epoch = time.time() + + try: + self._db.create_session(session_id, self._session_start_epoch) + except Exception as exc: + logger.error("Failed to create database session record: %s", exc) + + # Set active session ID for formatters + from taskify.logging_config import set_active_session_id + + set_active_session_id(session_id) + + try: + if AudioCapture is None: + raise RuntimeError("AudioCapture unavailable (portaudio missing?)") + + # Initialize STT engine + self._stt_engine = get_stt_engine(self._settings) + self._stt_engine.initialize() + + # Initialize transcript writer + self._transcript_writer = TranscriptWriter( + db=self._db, + log_dir=self._settings.storage.log_dir, + ) + self._transcript_writer.start() + + # Start audio capture + self._audio_capture = AudioCapture(self._settings) + self._audio_capture.start() + except Exception as exc: + logger.error("Failed to start audio capture or STT: %s", exc) + self._audio_capture = None + self._stt_engine = None + if self._transcript_writer is not None: + self._transcript_writer.stop() + self._transcript_writer = None + + self._is_recording = True + self._session_start = time.monotonic() + + # Update UI + self.record_btn.configure( + text="■ Stop Recording", + fg_color=COLOR_RECORDING, + hover_color="#CC2222", + text_color="#FFFFFF", + ) + self.state_label.configure(text="● Recording", text_color=COLOR_RECORDING) + self.level_bar.configure(progress_color=COLOR_RECORDING) + + # Start background transcription thread + if self._audio_capture is not None and self._stt_engine is not None: + self._transcribe_thread = threading.Thread( + target=self._transcribe_loop, + name="LiveTranscription", + daemon=True, + ) + self._transcribe_thread.start() + + # Begin tick loops + self._tick_timer() + self._poll_level() + + def _stop_recording(self) -> None: + """Close AudioCapture stream and cancel timer.""" + self._is_recording = False + + if self._session_id is not None: + try: + self._db.close_session(self._session_id, time.time()) + except Exception as exc: + logger.error("Failed to close database session record: %s", exc) + self._session_id = None + + # Reset active session ID in context formatter + from taskify.logging_config import set_active_session_id + + set_active_session_id(None) + + if self._audio_capture is not None: + try: + self._audio_capture.stop() + except Exception as exc: + logger.error("Error stopping audio capture: %s", exc) + self._audio_capture = None + + # Join the transcription thread to make sure it finishes flushing + if self._transcribe_thread is not None: + try: + self._transcribe_thread.join(timeout=2.0) + except Exception as exc: + logger.error("Error joining transcription thread: %s", exc) + self._transcribe_thread = None + + # Stop transcript writer + if self._transcript_writer is not None: + try: + self._transcript_writer.stop() + except Exception as exc: + logger.error("Error stopping transcript writer: %s", exc) + self._transcript_writer = None + + self._stt_engine = None + + # Cancel pending after-callbacks + if self._timer_after_id is not None: + try: + self.after_cancel(self._timer_after_id) + except Exception: + pass + self._timer_after_id = None + + if self._level_after_id is not None: + try: + self.after_cancel(self._level_after_id) + except Exception: + pass + self._level_after_id = None + + # Reset UI + self.record_btn.configure( + text="▶ Start Recording", + fg_color=COLOR_IDLE, + hover_color="#22C55E", + text_color="#000000", + ) + self.state_label.configure(text="● Idle", text_color=TEXT_SECONDARY) + self.level_bar.configure(progress_color=COLOR_IDLE) + self.level_bar.set(0) + self._session_start = None + self.timer_label.configure(text="00:00") + + def _transcribe_loop(self) -> None: + """Background transcription loop. + + Pulls audio chunks from the queue and writes transcripts. + """ + from taskify.storage.models import TranscriptSegment + + session_id = self._session_id + if not session_id or self._stt_engine is None or self._audio_capture is None: + return + + accumulated_text = [] + start_time = 0.0 + + while self._is_recording: + chunk = self._audio_capture.get_chunk(timeout=0.1) + if chunk is not None: + try: + text = self._stt_engine.transcribe_chunk(chunk) + if text: + text_str = text.strip() + if text_str: + accumulated_text.append(text_str) + # Update live transcript UI thread-safely + self.after(0, lambda t=text_str: self.append_transcript(t)) + except Exception as exc: + logger.error("Error transcribing chunk: %s", exc) + + # Flush remaining buffer in STT engine + try: + final_text = self._stt_engine.flush() + if final_text: + final_text_str = final_text.strip() + if final_text_str: + accumulated_text.append(final_text_str) + self.after(0, lambda t=final_text_str: self.append_transcript(t)) + except Exception as exc: + logger.error("Error flushing STT engine: %s", exc) + + # Persist full transcribed text as a segment in the database session + full_transcript = " ".join(accumulated_text).strip() + if full_transcript and self._transcript_writer is not None: + # End time is session elapsed time + elapsed = time.monotonic() - (self._session_start or time.monotonic()) + segment = TranscriptSegment( + session_id=session_id, + text=full_transcript, + confidence=0.95, + start_time=start_time, + end_time=max(elapsed, start_time + 1.0), + ) + try: + self._transcript_writer.write(segment) + self._transcript_writer.flush() + except Exception as exc: + logger.error("Error saving transcript segment: %s", exc) + + def _tick_timer(self) -> None: + """Update session MM:SS timer every second while recording.""" + if not self._is_recording or self._session_start is None: + return + + elapsed = int(time.monotonic() - self._session_start) + minutes, seconds = divmod(elapsed, 60) + self.timer_label.configure(text=f"{minutes:02d}:{seconds:02d}") + self._timer_after_id = self.after(1000, self._tick_timer) + + def _poll_level(self) -> None: + """Read latest RMS from AudioCapture at 10 Hz and update progress bar.""" + if not self._is_recording: + return + + if self._audio_capture is not None: + rms = self._audio_capture.latest_rms + # Normalise: typical speech RMS ≈ 0.02–0.15; cap at 0.3 + normalised = min(rms / 0.3, 1.0) + self.level_bar.set(normalised) + + self._level_after_id = self.after(_LEVEL_POLL_MS, self._poll_level) + + # ------------------------------------------------------------------ + # Live transcript + # ------------------------------------------------------------------ + + def append_transcript(self, text: str) -> None: + """Append recognized text to the live transcript pane thread-safely. + + Truncates to the last ``_TRANSCRIPT_MAX_CHARS`` characters so the box + never grows unbounded. Must be called from the GUI thread; use + ``self.after(0, ...)`` when calling from a background thread. + + Args: + text (str): Newly recognized speech text. + """ + self.transcript_box.configure(state="normal") + current = self.transcript_box.get("1.0", "end") + combined = (current + " " + text).strip() + if len(combined) > _TRANSCRIPT_MAX_CHARS: + combined = combined[-_TRANSCRIPT_MAX_CHARS:] + self.transcript_box.delete("1.0", "end") + self.transcript_box.insert("end", combined) + self.transcript_box.see("end") + self.transcript_box.configure(state="disabled") + def _create_quadrant( self, title: str, @@ -345,7 +780,7 @@ def _create_quadrant( self.quadrants[quadrant_key] = scroll_frame def _create_status_bar(self) -> None: - """Construct the application status bar frame.""" + """Construct the application status bar frame (spans both columns).""" self.status_bar = ctk.CTkFrame( self, height=32, @@ -354,7 +789,7 @@ def _create_status_bar(self) -> None: border_width=1, corner_radius=0, ) - self.status_bar.grid(row=1, column=0, sticky="ew") + self.status_bar.grid(row=1, column=0, columnspan=3, sticky="ew") # Left: STT Engine & Model details if self._settings.stt.engine == "vosk": @@ -439,9 +874,18 @@ def handle_task_action(self, action: str, task_id: int) -> None: ) elif action == "delete": self._db.delete_task(task_id) + # If deleted task is currently loaded, close details panel + if self.detail_panel and self.detail_panel.task_id == task_id: + self.close_task_detail() + elif action == "edit": + self.open_task_detail(task_id) + return elif action.startswith("move_"): quadrant_name = action.replace("move_", "") self._db.override_matrix_quadrant(task_id, quadrant_name) + # If edited task is currently loaded, reload it in panel + if self.detail_panel and self.detail_panel.task_id == task_id: + self.detail_panel.load_task(task_id) # Trigger a refresh of the dashboard self.refresh_all_quadrants() @@ -457,3 +901,51 @@ def _on_tasks_updated(self, data: object) -> None: lambda: self.time_label.configure(text=f"Last Extraction: {now_str}"), ) self.after(0, self.refresh_all_quadrants) + + def open_settings(self) -> None: + """Construct and render the settings dialog.""" + from taskify.ui.settings_dialog import SettingsDialog + + SettingsDialog(self, self._settings) + + def apply_settings(self) -> None: + """Apply newly updated configuration parameters to UI labels.""" + # 1. Update STT label + if self._settings.stt.engine == "vosk": + model_info = self._settings.stt.vosk_model + else: + model_info = self._settings.stt.whisper_model + stt_info = f"STT: {self._settings.stt.engine.upper()} ({model_info})" + self.stt_label.configure(text=stt_info) + + # 2. Update LLM label + llm_info = f"LLM Backend: {self._settings.llm.backend.upper()}" + self.llm_label.configure(text=llm_info) + + # 3. Trigger background tasks reload + self._bus.emit(EVENT_SETTINGS_UPDATED, self._settings) + + def open_task_detail(self, task_id: int) -> None: + """Create or render the side detail drawer panel for a task. + + Args: + task_id (int): Database task identifier. + """ + from taskify.ui.task_detail_panel import TaskDetailPanel + + if not self.detail_panel: + self.detail_panel = TaskDetailPanel( + self, + self._db, + on_save_callback=self.refresh_all_quadrants, + on_close_callback=self.close_task_detail, + ) + + # Show panel in column 2, matching height of matrix frame + self.detail_panel.grid(row=0, column=2, padx=(0, 16), pady=16, sticky="nsew") + self.detail_panel.load_task(task_id) + + def close_task_detail(self) -> None: + """Hide and ungrid the task detail panel from layout.""" + if self.detail_panel: + self.detail_panel.grid_remove() diff --git a/src/taskify/ui/settings_dialog.py b/src/taskify/ui/settings_dialog.py new file mode 100644 index 0000000..15b0ffc --- /dev/null +++ b/src/taskify/ui/settings_dialog.py @@ -0,0 +1,570 @@ +"""Modal settings dialog window for configuration management.""" + +from __future__ import annotations + +import logging +import tkinter as tk +from pathlib import Path +from tkinter import filedialog, messagebox +from typing import TYPE_CHECKING + +import customtkinter as ctk + +from taskify.config import ( + AudioSettings, + LLMSettings, + LoggingSettings, + Settings, + StorageSettings, + STTSettings, + save_settings, +) + +try: + import sounddevice as sd +except Exception: + sd = None + +if TYPE_CHECKING: + from taskify.ui.main_window import MainWindow + +logger = logging.getLogger(__name__) + +# Style constants to match MainWindow theme +BG_COLOR = "#121214" +CARD_BG = "#1A1A1E" +TEXT_PRIMARY = "#FFFFFF" +TEXT_SECONDARY = "#9999A1" +BORDER_COLOR = "#2D2D34" + + +class SettingsDialog(ctk.CTkToplevel): + """Modal dialog for editing application-wide configurations.""" + + def __init__(self, parent: MainWindow, settings: Settings) -> None: + """Initialize modal settings window. + + Args: + parent (MainWindow): Main application window. + settings (Settings): Active settings reference. + """ + super().__init__(parent) + self.parent = parent + self.settings = settings + + self.title("Configuration Settings") + self.geometry("520x460") + self.resizable(False, False) + self.configure(fg_color=BG_COLOR) + + # Make it modal + self.transient(parent) + self.grab_set() + self.focus_set() + + # Master grid layout + self.grid_rowconfigure(0, weight=1) + self.grid_rowconfigure(1, weight=0) + self.grid_columnconfigure(0, weight=1) + + # Tabview for layout categorization + self.tabview = ctk.CTkTabview( + self, + fg_color=CARD_BG, + segmented_button_selected_color="#5F9FFF", + segmented_button_selected_hover_color="#4F8FEE", + segmented_button_unselected_color=BG_COLOR, + text_color=TEXT_PRIMARY, + ) + self.tabview.grid(row=0, column=0, padx=12, pady=12, sticky="nsew") + + self.tabview.add("Audio") + self.tabview.add("Speech-to-Text") + self.tabview.add("LLM Backend") + self.tabview.add("Storage & Logs") + + # ---------------------------------------------------- + # Tab 1: Audio Configuration + # ---------------------------------------------------- + audio_tab = self.tabview.tab("Audio") + audio_tab.grid_columnconfigure(1, weight=1) + + # Device Selection + ctk.CTkLabel(audio_tab, text="Input Device:", text_color=TEXT_SECONDARY).grid( + row=0, column=0, padx=16, pady=8, sticky="w" + ) + self.device_var = tk.StringVar(value=self.settings.audio.device) + devices = self._get_input_devices() + self.device_menu = ctk.CTkOptionMenu( + audio_tab, + variable=self.device_var, + values=devices, + width=220, + ) + self.device_menu.grid(row=0, column=1, padx=16, pady=8, sticky="w") + + # Sample Rate + ctk.CTkLabel( + audio_tab, text="Sample Rate (Hz):", text_color=TEXT_SECONDARY + ).grid(row=1, column=0, padx=16, pady=8, sticky="w") + self.sample_rate_var = tk.StringVar(value=str(self.settings.audio.sample_rate)) + self.sample_rate_entry = ctk.CTkEntry( + audio_tab, textvariable=self.sample_rate_var, width=120 + ) + self.sample_rate_entry.grid(row=1, column=1, padx=16, pady=8, sticky="w") + + # Chunk Duration + ctk.CTkLabel( + audio_tab, text="Chunk Duration (ms):", text_color=TEXT_SECONDARY + ).grid(row=2, column=0, padx=16, pady=8, sticky="w") + self.chunk_duration_var = tk.StringVar( + value=str(self.settings.audio.chunk_duration_ms) + ) + self.chunk_duration_entry = ctk.CTkEntry( + audio_tab, textvariable=self.chunk_duration_var, width=120 + ) + self.chunk_duration_entry.grid(row=2, column=1, padx=16, pady=8, sticky="w") + + # Silence Threshold + ctk.CTkLabel( + audio_tab, text="Silence Threshold:", text_color=TEXT_SECONDARY + ).grid(row=3, column=0, padx=16, pady=8, sticky="w") + self.silence_threshold_var = tk.StringVar( + value=str(self.settings.audio.silence_threshold) + ) + self.silence_threshold_entry = ctk.CTkEntry( + audio_tab, textvariable=self.silence_threshold_var, width=120 + ) + self.silence_threshold_entry.grid(row=3, column=1, padx=16, pady=8, sticky="w") + + # Silence Duration + ctk.CTkLabel( + audio_tab, text="Silence Duration (s):", text_color=TEXT_SECONDARY + ).grid(row=4, column=0, padx=16, pady=8, sticky="w") + self.silence_duration_var = tk.StringVar( + value=str(self.settings.audio.silence_duration_s) + ) + self.silence_duration_entry = ctk.CTkEntry( + audio_tab, textvariable=self.silence_duration_var, width=120 + ) + self.silence_duration_entry.grid(row=4, column=1, padx=16, pady=8, sticky="w") + + # ---------------------------------------------------- + # Tab 2: Speech-to-Text Settings + # ---------------------------------------------------- + stt_tab = self.tabview.tab("Speech-to-Text") + stt_tab.grid_columnconfigure(1, weight=1) + + # STT Engine + ctk.CTkLabel(stt_tab, text="STT Engine:", text_color=TEXT_SECONDARY).grid( + row=0, column=0, padx=16, pady=8, sticky="w" + ) + self.stt_engine_var = tk.StringVar(value=self.settings.stt.engine) + self.stt_engine_menu = ctk.CTkOptionMenu( + stt_tab, + variable=self.stt_engine_var, + values=["vosk", "whisper"], + command=self._on_stt_engine_changed, + width=160, + ) + self.stt_engine_menu.grid(row=0, column=1, padx=16, pady=8, sticky="w") + + # Vosk Model Name + self.vosk_lbl = ctk.CTkLabel( + stt_tab, text="Vosk Model:", text_color=TEXT_SECONDARY + ) + self.vosk_lbl.grid(row=1, column=0, padx=16, pady=8, sticky="w") + self.vosk_model_var = tk.StringVar(value=self.settings.stt.vosk_model) + self.vosk_model_combo = ctk.CTkComboBox( + stt_tab, + variable=self.vosk_model_var, + values=["en-small", "en-large", "de-small", "fr-small", "es-small"], + width=180, + ) + self.vosk_model_combo.grid(row=1, column=1, padx=16, pady=8, sticky="w") + + # Whisper Model Size + self.whisper_lbl = ctk.CTkLabel( + stt_tab, text="Whisper Model:", text_color=TEXT_SECONDARY + ) + self.whisper_lbl.grid(row=2, column=0, padx=16, pady=8, sticky="w") + self.whisper_model_var = tk.StringVar(value=self.settings.stt.whisper_model) + self.whisper_model_combo = ctk.CTkComboBox( + stt_tab, + variable=self.whisper_model_var, + values=["tiny", "base", "small", "medium", "large"], + width=180, + ) + self.whisper_model_combo.grid(row=2, column=1, padx=16, pady=8, sticky="w") + + # Language + ctk.CTkLabel(stt_tab, text="Language Code:", text_color=TEXT_SECONDARY).grid( + row=3, column=0, padx=16, pady=8, sticky="w" + ) + self.language_var = tk.StringVar(value=self.settings.stt.language) + self.language_entry = ctk.CTkEntry( + stt_tab, textvariable=self.language_var, width=80 + ) + self.language_entry.grid(row=3, column=1, padx=16, pady=8, sticky="w") + + # Initialize visibility based on setting + self._on_stt_engine_changed(self.settings.stt.engine) + + # ---------------------------------------------------- + # Tab 3: LLM / Pipeline Configuration + # ---------------------------------------------------- + llm_tab = self.tabview.tab("LLM Backend") + llm_tab.grid_columnconfigure(1, weight=1) + + # LLM Backend Selection + ctk.CTkLabel( + llm_tab, text="Extraction Backend:", text_color=TEXT_SECONDARY + ).grid(row=0, column=0, padx=16, pady=8, sticky="w") + self.llm_backend_var = tk.StringVar(value=self.settings.llm.backend) + self.llm_backend_menu = ctk.CTkOptionMenu( + llm_tab, + variable=self.llm_backend_var, + values=["ollama", "nlp"], + command=self._on_llm_backend_changed, + width=160, + ) + self.llm_backend_menu.grid(row=0, column=1, padx=16, pady=8, sticky="w") + + # Ollama Host + self.ollama_host_lbl = ctk.CTkLabel( + llm_tab, text="Ollama Host URL:", text_color=TEXT_SECONDARY + ) + self.ollama_host_lbl.grid(row=1, column=0, padx=16, pady=8, sticky="w") + self.ollama_host_var = tk.StringVar(value=self.settings.llm.ollama_host) + self.ollama_host_entry = ctk.CTkEntry( + llm_tab, textvariable=self.ollama_host_var, width=220 + ) + self.ollama_host_entry.grid(row=1, column=1, padx=16, pady=8, sticky="w") + + # Ollama Model Name + self.ollama_model_lbl = ctk.CTkLabel( + llm_tab, text="Ollama Model:", text_color=TEXT_SECONDARY + ) + self.ollama_model_lbl.grid(row=2, column=0, padx=16, pady=8, sticky="w") + self.ollama_model_var = tk.StringVar(value=self.settings.llm.ollama_model) + self.ollama_model_entry = ctk.CTkEntry( + llm_tab, textvariable=self.ollama_model_var, width=180 + ) + self.ollama_model_entry.grid(row=2, column=1, padx=16, pady=8, sticky="w") + + # Sync/Extraction Interval + ctk.CTkLabel( + llm_tab, text="Extraction Interval (s):", text_color=TEXT_SECONDARY + ).grid(row=3, column=0, padx=16, pady=8, sticky="w") + self.interval_var = tk.StringVar( + value=str(self.settings.llm.extraction_interval_s) + ) + self.interval_entry = ctk.CTkEntry( + llm_tab, textvariable=self.interval_var, width=100 + ) + self.interval_entry.grid(row=3, column=1, padx=16, pady=8, sticky="w") + + # Initialize LLM fields visibility + self._on_llm_backend_changed(self.settings.llm.backend) + + # ---------------------------------------------------- + # Tab 4: Storage & Logs + # ---------------------------------------------------- + storage_tab = self.tabview.tab("Storage & Logs") + storage_tab.grid_columnconfigure(1, weight=1) + + # Data Dir Directory + ctk.CTkLabel( + storage_tab, text="Data Directory:", text_color=TEXT_SECONDARY + ).grid(row=0, column=0, padx=16, pady=8, sticky="w") + self.data_dir_var = tk.StringVar(value=str(self.settings.storage.data_dir)) + data_frame = ctk.CTkFrame(storage_tab, fg_color="transparent") + data_frame.grid(row=0, column=1, padx=16, pady=8, sticky="ew") + data_frame.grid_columnconfigure(0, weight=1) + + self.data_dir_entry = ctk.CTkEntry( + data_frame, textvariable=self.data_dir_var, state="normal" + ) + self.data_dir_entry.grid(row=0, column=0, sticky="ew", padx=(0, 6)) + ctk.CTkButton( + data_frame, + text="Browse...", + width=80, + command=self._browse_data_dir, + ).grid(row=0, column=1, sticky="e") + + # Log Dir Directory + ctk.CTkLabel( + storage_tab, text="Log Directory:", text_color=TEXT_SECONDARY + ).grid(row=1, column=0, padx=16, pady=8, sticky="w") + self.log_dir_var = tk.StringVar(value=str(self.settings.storage.log_dir)) + log_frame = ctk.CTkFrame(storage_tab, fg_color="transparent") + log_frame.grid(row=1, column=1, padx=16, pady=8, sticky="ew") + log_frame.grid_columnconfigure(0, weight=1) + + self.log_dir_entry = ctk.CTkEntry( + log_frame, textvariable=self.log_dir_var, state="normal" + ) + self.log_dir_entry.grid(row=0, column=0, sticky="ew", padx=(0, 6)) + ctk.CTkButton( + log_frame, + text="Browse...", + width=80, + command=self._browse_log_dir, + ).grid(row=0, column=1, sticky="e") + + # Log Level + ctk.CTkLabel(storage_tab, text="Log Level:", text_color=TEXT_SECONDARY).grid( + row=2, column=0, padx=16, pady=8, sticky="w" + ) + self.log_level_var = tk.StringVar(value=self.settings.logging.level) + self.log_level_menu = ctk.CTkOptionMenu( + storage_tab, + variable=self.log_level_var, + values=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + width=120, + ) + self.log_level_menu.grid(row=2, column=1, padx=16, pady=8, sticky="w") + + # Debug LLM Checkbox + self.debug_llm_var = tk.BooleanVar(value=self.settings.logging.debug_llm) + self.debug_llm_check = ctk.CTkCheckBox( + storage_tab, + text="Enable LLM Diagnostics Logging", + variable=self.debug_llm_var, + text_color=TEXT_SECONDARY, + ) + self.debug_llm_check.grid( + row=3, column=0, columnspan=2, padx=16, pady=12, sticky="w" + ) + + # ---------------------------------------------------- + # Action Buttons (Save/Cancel) at the bottom + # ---------------------------------------------------- + actions_frame = ctk.CTkFrame(self, fg_color="transparent") + actions_frame.grid(row=1, column=0, padx=16, pady=(0, 16), sticky="ew") + actions_frame.grid_columnconfigure(0, weight=1) + + self.cancel_btn = ctk.CTkButton( + actions_frame, + text="Cancel", + fg_color="#3A3A3C", + hover_color="#48484A", + text_color=TEXT_PRIMARY, + width=90, + command=self.destroy, + ) + self.cancel_btn.pack(side="right", padx=(8, 0)) + + self.save_btn = ctk.CTkButton( + actions_frame, + text="Save Settings", + fg_color="#5F9FFF", + hover_color="#4F8FEE", + text_color="#FFFFFF", + width=110, + command=self.save, + ) + self.save_btn.pack(side="right") + + def _get_input_devices(self) -> list[str]: + """Fetch all input-enabled devices from system sound interfaces.""" + devs = [] + if sd is not None: + try: + all_devs = sd.query_devices() + for d in all_devs: + if d.get("max_input_channels", 0) > 0: + devs.append(d.get("name")) + except Exception: + pass + unique_devs = list(dict.fromkeys(devs)) + if not unique_devs: + unique_devs.append("default") + return unique_devs + + def _on_stt_engine_changed(self, choice: str) -> None: + """Toggle model input visibility dependent on chosen STT engine.""" + if choice == "vosk": + self.vosk_lbl.grid() + self.vosk_model_combo.grid() + self.whisper_lbl.grid_remove() + self.whisper_model_combo.grid_remove() + else: + self.vosk_lbl.grid_remove() + self.vosk_model_combo.grid_remove() + self.whisper_lbl.grid() + self.whisper_model_combo.grid() + + def _on_llm_backend_changed(self, choice: str) -> None: + """Toggle Ollama parameter widgets visibility based on backend choice.""" + if choice == "ollama": + self.ollama_host_lbl.grid() + self.ollama_host_entry.grid() + self.ollama_model_lbl.grid() + self.ollama_model_entry.grid() + else: + self.ollama_host_lbl.grid_remove() + self.ollama_host_entry.grid_remove() + self.ollama_model_lbl.grid_remove() + self.ollama_model_entry.grid_remove() + + def _browse_data_dir(self) -> None: + """Open directory picker modal for database data path.""" + chosen = filedialog.askdirectory(initialdir=self.data_dir_var.get()) + if chosen: + self.data_dir_var.set(chosen) + + def _browse_log_dir(self) -> None: + """Open directory picker modal for log folder location.""" + chosen = filedialog.askdirectory(initialdir=self.log_dir_var.get()) + if chosen: + self.log_dir_var.set(chosen) + + def save(self) -> None: + """Validate dialog fields, serialize to config.toml, and notify parent.""" + try: + # 1. Parse and validate audio configs + try: + sample_rate = int(self.sample_rate_var.get()) + chunk_duration = int(self.chunk_duration_var.get()) + silence_threshold = float(self.silence_threshold_var.get()) + silence_duration = float(self.silence_duration_var.get()) + except ValueError as ve: + raise ValueError( + f"Invalid format in Audio numeric values: {ve}" + ) from ve + + audio_settings = AudioSettings( + device=self.device_var.get(), + sample_rate=sample_rate, + chunk_duration_ms=chunk_duration, + silence_threshold=silence_threshold, + silence_duration_s=silence_duration, + ) + audio_settings.validate() + + # 2. Parse and validate STT configs + stt_engine = self.stt_engine_var.get() + stt_settings = STTSettings( + engine=stt_engine, + vosk_model=self.vosk_model_var.get(), + whisper_model=self.whisper_model_var.get(), + language=self.language_var.get(), + ) + stt_settings.validate() + + # Engine dependency validation + if stt_engine == "whisper": + try: + import faster_whisper # noqa: F401 + except ImportError as ie: + raise ValueError( + "Whisper engine selected but 'faster-whisper' " + "is not installed. Please run: " + 'pip install "taskify[whisper]"' + ) from ie + + valid_whisper_models = { + "tiny", + "tiny.en", + "base", + "base.en", + "small", + "small.en", + "medium", + "medium.en", + "large-v1", + "large-v2", + "large-v3", + "large", + } + w_val = stt_settings.whisper_model + if w_val not in valid_whisper_models: + model_path = Path(w_val) + if not model_path.exists(): + raise ValueError( + f"Whisper model size '{w_val}' is invalid, " + "and no such local model folder exists." + ) + else: + try: + import vosk # noqa: F401 + except ImportError as ie: + raise ValueError( + "Vosk engine selected but 'vosk' package is not installed." + ) from ie + + # Check if selected Vosk model downloaded locally + from taskify.stt.vosk_engine import VoskEngine + + temp_s = Settings( + audio=audio_settings, + stt=stt_settings, + llm=self.settings.llm, + storage=self.settings.storage, + logging=self.settings.logging, + ) + v_engine = VoskEngine(temp_s) + model_dir = v_engine.model_path + if not model_dir.exists() or not model_dir.is_dir(): + raise ValueError( + f"Vosk model '{stt_settings.vosk_model}' not found " + f"locally at:\n{model_dir}\n" + f"Please download it first using CLI command:\n" + f"taskify download-model --engine vosk --model " + f"{stt_settings.vosk_model}" + ) + + # 3. Parse and validate LLM configs + try: + interval = int(self.interval_var.get()) + except ValueError as ve: + raise ValueError("Extraction interval must be an integer.") from ve + + llm_settings = LLMSettings( + backend=self.llm_backend_var.get(), + ollama_host=self.ollama_host_var.get(), + ollama_model=self.ollama_model_var.get(), + extraction_interval_s=interval, + ) + llm_settings.validate() + + # 4. Parse and validate Storage configs + storage_settings = StorageSettings( + data_dir=Path(self.data_dir_var.get()), + log_dir=Path(self.log_dir_var.get()), + ) + storage_settings.validate() + + # 5. Parse and validate Logging configs + logging_settings = LoggingSettings( + level=self.log_level_var.get(), + debug_llm=self.debug_llm_var.get(), + ) + logging_settings.validate() + + # Apply settings to the config reference + self.settings.audio = audio_settings + self.settings.stt = stt_settings + self.settings.llm = llm_settings + self.settings.storage = storage_settings + self.settings.logging = logging_settings + + # Save to disk + save_settings(self.settings) + + # Notify parent window + self.parent.apply_settings() + + # Success message and close + logger.info("Successfully updated configuration parameters.") + self.destroy() + + except Exception as e: + logger.warning("Configuration validation failed: %s", e) + messagebox.showerror( + title="Configuration Error", + message=str(e), + parent=self, + ) diff --git a/src/taskify/ui/task_detail_panel.py b/src/taskify/ui/task_detail_panel.py new file mode 100644 index 0000000..3fb3461 --- /dev/null +++ b/src/taskify/ui/task_detail_panel.py @@ -0,0 +1,411 @@ +"""Task detail sidebar drawer panel with inline editing and auto-save on blur.""" + +from __future__ import annotations + +import logging +import tkinter as tk +from datetime import datetime +from typing import TYPE_CHECKING + +import customtkinter as ctk + +if TYPE_CHECKING: + from taskify.storage.database import DatabaseManager + +logger = logging.getLogger(__name__) + +# Premium styling constants matching main window theme +BG_COLOR = "#121214" +CARD_BG = "#1A1A1E" +TEXT_PRIMARY = "#FFFFFF" +TEXT_SECONDARY = "#9999A1" +BORDER_COLOR = "#2D2D34" + +# Option menu display to key quadrant mapping +_QUADRANT_DISPLAY_TO_KEY = { + "Do First": "do_first", + "Schedule": "schedule", + "Delegate": "delegate", + "Eliminate": "eliminate", +} +_QUADRANT_KEY_TO_DISPLAY = {v: k for k, v in _QUADRANT_DISPLAY_TO_KEY.items()} + + +class TaskDetailPanel(ctk.CTkFrame): + """Inline side drawer showing task transcript context and editable fields.""" + + def __init__( + self, + master: ctk.CTkFrame, + db_manager: DatabaseManager, + on_save_callback: ctk.Callable[[], None], + on_close_callback: ctk.Callable[[], None], + ) -> None: + """Initialize task detail sidebar panel. + + Args: + master (CTkFrame): Parent main window layout grid. + db_manager (DatabaseManager): Active SQLite database connection manager. + on_save_callback (Callable): Event hook called when a task is saved. + on_close_callback (Callable): Event hook called when panel is closed. + """ + super().__init__( + master, + fg_color=CARD_BG, + border_color=BORDER_COLOR, + border_width=1, + corner_radius=12, + width=340, + ) + self._db = db_manager + self.on_save = on_save_callback + self.on_close = on_close_callback + + self.task_id: int | None = None + self._current_title: str = "" + self._current_notes: str = "" + self._current_due_date: str = "" + self._current_quadrant: str = "" + self._current_status: str = "" + + # Avoid auto-saving during initial load + self._loading: bool = False + + # Set up grid layout (column 0 gets all space) + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(1, weight=1) # Content area scrollable + + # Header Row + header_frame = ctk.CTkFrame(self, fg_color="transparent") + header_frame.grid(row=0, column=0, padx=16, pady=(16, 8), sticky="ew") + header_frame.grid_columnconfigure(0, weight=1) + + self.header_label = ctk.CTkLabel( + header_frame, + text="Task Details", + font=ctk.CTkFont(size=16, weight="bold"), + text_color=TEXT_PRIMARY, + anchor="w", + ) + self.header_label.grid(row=0, column=0, sticky="w") + + self.close_btn = ctk.CTkButton( + header_frame, + text="✕", + font=ctk.CTkFont(size=16, weight="bold"), + text_color=TEXT_SECONDARY, + hover_color=BORDER_COLOR, + fg_color="transparent", + width=30, + height=30, + corner_radius=15, + command=self.close_panel, + ) + self.close_btn.grid(row=0, column=1, sticky="e") + + # Scrollable container for detail fields + self.scroll_container = ctk.CTkScrollableFrame( + self, + fg_color="transparent", + scrollbar_button_color=BORDER_COLOR, + scrollbar_button_hover_color=TEXT_SECONDARY, + ) + self.scroll_container.grid(row=1, column=0, padx=8, pady=(0, 16), sticky="nsew") + self.scroll_container.grid_columnconfigure(0, weight=1) + + # 1. Transcript Excerpt Context Area + ctk.CTkLabel( + self.scroll_container, + text="Source Transcript Context:", + font=ctk.CTkFont(size=12, weight="bold"), + text_color=TEXT_SECONDARY, + anchor="w", + ).grid(row=0, column=0, padx=12, pady=(8, 4), sticky="w") + + self.excerpt_box = ctk.CTkTextbox( + self.scroll_container, + height=80, + font=ctk.CTkFont(size=11), + text_color=TEXT_SECONDARY, + fg_color=BG_COLOR, + border_color=BORDER_COLOR, + border_width=1, + corner_radius=6, + wrap="word", + ) + self.excerpt_box.grid(row=1, column=0, padx=12, pady=(0, 12), sticky="ew") + self.excerpt_box.configure(state="disabled") + + # 2. Editable Title Field + ctk.CTkLabel( + self.scroll_container, + text="Title:", + font=ctk.CTkFont(size=12, weight="bold"), + text_color=TEXT_SECONDARY, + anchor="w", + ).grid(row=2, column=0, padx=12, pady=(4, 2), sticky="w") + + self.title_entry = ctk.CTkEntry( + self.scroll_container, + font=ctk.CTkFont(size=13), + text_color=TEXT_PRIMARY, + fg_color=BG_COLOR, + border_color=BORDER_COLOR, + border_width=1, + corner_radius=6, + ) + self.title_entry.grid(row=3, column=0, padx=12, pady=(0, 12), sticky="ew") + self.title_entry.bind("", lambda e: self.save_task()) + self.title_entry.bind("", lambda e: self.focus_set()) # Commit on Enter + + # 3. Editable Notes Field + ctk.CTkLabel( + self.scroll_container, + text="Notes:", + font=ctk.CTkFont(size=12, weight="bold"), + text_color=TEXT_SECONDARY, + anchor="w", + ).grid(row=4, column=0, padx=12, pady=(4, 2), sticky="w") + + self.notes_box = ctk.CTkTextbox( + self.scroll_container, + height=100, + font=ctk.CTkFont(size=12), + text_color=TEXT_PRIMARY, + fg_color=BG_COLOR, + border_color=BORDER_COLOR, + border_width=1, + corner_radius=6, + wrap="word", + ) + self.notes_box.grid(row=5, column=0, padx=12, pady=(0, 12), sticky="ew") + self.notes_box.bind("", lambda e: self.save_task()) + + # 4. Due Date Field + ctk.CTkLabel( + self.scroll_container, + text="Due Date (YYYY-MM-DD):", + font=ctk.CTkFont(size=12, weight="bold"), + text_color=TEXT_SECONDARY, + anchor="w", + ).grid(row=6, column=0, padx=12, pady=(4, 2), sticky="w") + + self.due_date_entry = ctk.CTkEntry( + self.scroll_container, + font=ctk.CTkFont(size=13), + text_color=TEXT_PRIMARY, + placeholder_text="YYYY-MM-DD", + fg_color=BG_COLOR, + border_color=BORDER_COLOR, + border_width=1, + corner_radius=6, + ) + self.due_date_entry.grid(row=7, column=0, padx=12, pady=(0, 12), sticky="ew") + self.due_date_entry.bind("", lambda e: self.save_task()) + self.due_date_entry.bind("", lambda e: self.focus_set()) + + # 5. Eisenhower Quadrant Option Menu + ctk.CTkLabel( + self.scroll_container, + text="Matrix Quadrant Override:", + font=ctk.CTkFont(size=12, weight="bold"), + text_color=TEXT_SECONDARY, + anchor="w", + ).grid(row=8, column=0, padx=12, pady=(4, 2), sticky="w") + + self.quadrant_menu = ctk.CTkOptionMenu( + self.scroll_container, + values=list(_QUADRANT_DISPLAY_TO_KEY.keys()), + command=self._on_quadrant_changed, + button_color=BORDER_COLOR, + button_hover_color=TEXT_SECONDARY, + fg_color=BG_COLOR, + text_color=TEXT_PRIMARY, + corner_radius=6, + ) + self.quadrant_menu.grid(row=9, column=0, padx=12, pady=(0, 12), sticky="ew") + + # 6. Status Checkbox + self.status_var = tk.StringVar(value="pending") + self.status_checkbox = ctk.CTkCheckBox( + self.scroll_container, + text="Mark Done", + font=ctk.CTkFont(size=12, weight="bold"), + variable=self.status_var, + onvalue="completed", + offvalue="pending", + command=self.save_task, + text_color=TEXT_PRIMARY, + fg_color=BORDER_COLOR, + hover_color=TEXT_SECONDARY, + border_color=TEXT_SECONDARY, + corner_radius=4, + ) + self.status_checkbox.grid(row=10, column=0, padx=12, pady=(4, 16), sticky="w") + + # Divider + divider = ctk.CTkFrame(self.scroll_container, height=1, fg_color=BORDER_COLOR) + divider.grid(row=11, column=0, padx=12, pady=(0, 16), sticky="ew") + + # Timestamps Labels + self.created_label = ctk.CTkLabel( + self.scroll_container, + text="Created: --", + font=ctk.CTkFont(size=10), + text_color=TEXT_SECONDARY, + anchor="w", + ) + self.created_label.grid(row=12, column=0, padx=12, pady=(0, 4), sticky="w") + + self.updated_label = ctk.CTkLabel( + self.scroll_container, + text="Updated: --", + font=ctk.CTkFont(size=10), + text_color=TEXT_SECONDARY, + anchor="w", + ) + self.updated_label.grid(row=13, column=0, padx=12, pady=0, sticky="w") + + def load_task(self, task_id: int) -> None: + """Fetch task details and populate fields. + + Args: + task_id (int): Database identifier of task to display. + """ + self._loading = True + self.task_id = task_id + + task_record = self._db.get_task(task_id) + if not task_record: + logger.error("Failed to load task ID %d from database.", task_id) + self._loading = False + return + + task, matrix = task_record + + # Excerpt loading + excerpt = self._db.get_transcript_excerpt_for_task(task_id) + self.excerpt_box.configure(state="normal") + self.excerpt_box.delete("1.0", "end") + self.excerpt_box.insert("1.0", excerpt) + self.excerpt_box.configure(state="disabled") + + # Populate Entries + self.title_entry.delete(0, "end") + self.title_entry.insert(0, task.title or "") + + self.notes_box.delete("1.0", "end") + self.notes_box.insert("1.0", task.notes or "") + + self.due_date_entry.delete(0, "end") + self.due_date_entry.insert(0, task.due_date or "") + + self.status_var.set(task.status) + + # Set Quadrant Display + display_name = _QUADRANT_KEY_TO_DISPLAY.get(matrix.quadrant, "Schedule") + self.quadrant_menu.set(display_name) + + # Set Timestamps + created_str = self._format_timestamp(task.created_at) + updated_str = self._format_timestamp(task.updated_at) + self.created_label.configure(text=f"Created: {created_str}") + self.updated_label.configure(text=f"Updated: {updated_str}") + + # Keep current copy for delta detection + self._current_title = task.title or "" + self._current_notes = task.notes or "" + self._current_due_date = task.due_date or "" + self._current_quadrant = matrix.quadrant or "" + self._current_status = task.status + + self._loading = False + + def save_task(self) -> None: + """Compare inputs with database, persisting modifications on difference.""" + if self._loading or self.task_id is None: + return + + title = self.title_entry.get().strip() + notes = self.notes_box.get("1.0", "end-1c").strip() + due_date = self.due_date_entry.get().strip() + status = self.status_var.get() + + # Check if anything changed + title_changed = title != self._current_title + notes_changed = notes != self._current_notes + due_changed = due_date != self._current_due_date + status_changed = status != self._current_status + + if title_changed or notes_changed or due_changed or status_changed: + try: + self._db.update_task( + task_id=self.task_id, + title=title, + notes=notes, + due_date=due_date, + status=status, + ) + logger.info("Task ID %d updated successfully.", self.task_id) + + # Update delta check variables + self._current_title = title + self._current_notes = notes + self._current_due_date = due_date + self._current_status = status + + # Refresh timestamps (re-read task) + task_record = self._db.get_task(self.task_id) + if task_record: + updated_str = self._format_timestamp(task_record[0].updated_at) + self.updated_label.configure(text=f"Updated: {updated_str}") + + # Notify main window to redraw grids + self.on_save() + except Exception as e: + logger.error("Failed to auto-save task ID %d: %s", self.task_id, e) + + def _on_quadrant_changed(self, value: str) -> None: + """Handle option menu selection, triggering manual override immediately. + + Args: + value (str): Text selected in OptionMenu list. + """ + if self._loading or self.task_id is None: + return + + target_quadrant = _QUADRANT_DISPLAY_TO_KEY.get(value, "schedule") + if target_quadrant != self._current_quadrant: + try: + self._db.override_matrix_quadrant(self.task_id, target_quadrant) + logger.info( + "Task ID %d quadrant overridden manually to '%s'.", + self.task_id, + target_quadrant, + ) + self._current_quadrant = target_quadrant + self.on_save() + except Exception as e: + logger.error("Failed to override matrix quadrant: %s", e) + + def close_panel(self) -> None: + """Auto-save any pending changes and invoke the close event hook.""" + self.save_task() + self.on_close() + + def _format_timestamp(self, ts_str: str | None) -> str: + """Convert raw SQLite ISO timestamp string to user friendly readable text. + + Args: + ts_str (str): Raw timestamp from database record. + + Returns: + str: Reformatted human readable timestamp. + """ + if not ts_str: + return "--" + try: + dt = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S") + return dt.strftime("%b %d, %H:%M") + except ValueError: + return ts_str diff --git a/tests/integration/test_pipeline.py b/tests/integration/test_pipeline.py new file mode 100644 index 0000000..e9c65c5 --- /dev/null +++ b/tests/integration/test_pipeline.py @@ -0,0 +1,250 @@ +"""Integration test suite executing the full audio-to-task persistence and classification pipeline.""" + +from __future__ import annotations + +import json +import math +import struct +import wave +from pathlib import Path +from typing import TYPE_CHECKING, Generator +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from taskify.config import ( + AudioSettings, + LLMSettings, + LoggingSettings, + Settings, + STTSettings, + StorageSettings, +) +from taskify.llm.nlp_backend import NLPBackend +from taskify.pipeline.scheduler import EventBus, ExtractionScheduler +from taskify.storage.database import DatabaseManager +from taskify.storage.models import TranscriptSegment +from taskify.storage.transcript_writer import TranscriptWriter +from taskify.stt import VoskEngine + +if TYPE_CHECKING: + from taskify.stt.base import STTEngine + +# Define 5 representative speech samples (transcripts) +EXPECTED_TRANSCRIPTS = [ + "I must immediately buy groceries today.", + "We need to plan a strategic roadmap.", + "I need to tell John to fix the website right now.", + "I should maybe write a funny story eventually.", + "We have to urgently write the report before noon.", +] + +# Define expected classifications mapped from keywords/rules +EXPECTED_QUADRANTS = [ + "urgent_important", + "important_not_urgent", + "urgent_not_important", + "neither", + "urgent_important", +] + + +class MockKaldiRecognizer: + """Mock Vosk recognizer returning representative sentences sequentially.""" + + current_index = 0 + has_returned_for_current = False + + def __init__(self, model: MagicMock, rate: float) -> None: + pass + + def AcceptWaveform(self, data: bytes) -> bool: + return True + + def Result(self) -> str: + if not MockKaldiRecognizer.has_returned_for_current: + text = EXPECTED_TRANSCRIPTS[MockKaldiRecognizer.current_index] + MockKaldiRecognizer.has_returned_for_current = True + return json.dumps({"text": text}) + return json.dumps({"text": ""}) + + def PartialResult(self) -> str: + return '{"partial": ""}' + + def FinalResult(self) -> str: + return json.dumps({"text": ""}) + + +@pytest.fixture +def test_settings(tmp_path: Path) -> Settings: + """Fixture returning settings pointing to temp directories.""" + return Settings( + audio=AudioSettings( + device="default", + sample_rate=16000, + chunk_duration_ms=200, + silence_threshold=0.01, + silence_duration_s=1.5, + ), + stt=STTSettings( + engine="vosk", + vosk_model="en-small", + whisper_model="base", + language="en", + ), + llm=LLMSettings( + backend="nlp", + ollama_host="http://localhost:11434", + ollama_model="phi4-mini", + extraction_interval_s=300, + ), + storage=StorageSettings( + data_dir=tmp_path / "data", + log_dir=tmp_path / "logs", + ), + logging=LoggingSettings( + level="INFO", + debug_llm=False, + ), + ) + + +@pytest.fixture +def db(test_settings: Settings) -> Generator[DatabaseManager, None, None]: + """Fixture returning an initialized DatabaseManager.""" + db_path = test_settings.storage.data_dir / "taskify.db" + test_settings.storage.data_dir.mkdir(parents=True, exist_ok=True) + manager = DatabaseManager(db_path) + yield manager + manager.close() + + +def create_dummy_wav(path: Path, frequency: int = 440, duration: float = 0.5, rate: int = 16000) -> None: + """Write a valid mono 16-bit PCM WAV file filled with a sine wave.""" + with wave.open(str(path), "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(rate) + n_frames = int(duration * rate) + for i in range(n_frames): + val = int(32767.0 * math.sin(2.0 * math.pi * frequency * i / rate)) + wf.writeframes(struct.pack(" list[np.ndarray]: + """Read a WAV file and split it into float32 numpy array chunks.""" + with wave.open(str(path), "rb") as wf: + n_frames = wf.getnframes() + data = wf.readframes(n_frames) + sig = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0 + + # Chunk the signal + chunks: list[np.ndarray] = [] + for i in range(0, len(sig), chunk_size_samples): + chunk = sig[i : i + chunk_size_samples] + if len(chunk) < chunk_size_samples: + # Pad zeros + chunk = np.pad(chunk, (0, chunk_size_samples - len(chunk))) + chunks.append(chunk) + return chunks + + +@patch("vosk.Model") +@patch("vosk.KaldiRecognizer", new=MockKaldiRecognizer) +def test_full_audio_to_task_pipeline( + mock_model: MagicMock, + test_settings: Settings, + db: DatabaseManager, + tmp_path: Path, +) -> None: + """Integration test verifying full audio chunk processing through to classified tasks.""" + # 1. Generate 5 unique WAV files to simulate recording inputs + wav_paths: list[Path] = [] + for idx in range(5): + wav_path = tmp_path / f"sample_{idx}.wav" + create_dummy_wav(wav_path, frequency=300 + idx * 50) + wav_paths.append(wav_path) + + # 2. Setup Vosk STT Engine with mock model directory + model_dir = tmp_path / "vosk-model-small-en-us-0.15" + model_dir.mkdir() + + engine = VoskEngine(test_settings) + engine._model_root = tmp_path + engine.initialize() + + # 3. Setup TranscriptWriter batch persistence + writer = TranscriptWriter(db=db, log_dir=test_settings.storage.log_dir, flush_interval_s=0.1) + writer.start() + + # Create the session first in the DB due to foreign key constraints + session_id = "test-session-uuid-999" + db.create_session(session_id, start_time=0.0) + + # 4. Process each WAV file through VoskEngine and write to TranscriptWriter + for idx, path in enumerate(wav_paths): + MockKaldiRecognizer.current_index = idx + MockKaldiRecognizer.has_returned_for_current = False + + chunks = read_wav_chunks(path) + + # Transcribe chunks + transcription = "" + for chunk in chunks: + text = engine.transcribe_chunk(chunk) + if text: + transcription += text + " " + + final_text = engine.flush() + if final_text: + transcription += final_text + + transcription = transcription.strip() + + # Write segment to TranscriptWriter + segment = TranscriptSegment( + session_id=session_id, + text=transcription, + confidence=0.9, + start_time=float(idx * 2), + end_time=float((idx + 1) * 2), + ) + writer.write(segment) + + # Allow TranscriptWriter to flush batch to database + writer.stop() + + # Verify transcripts are populated in database + conn = db._conn + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) FROM transcripts;") + count = cursor.fetchone()[0] + assert count == 5 + + # 5. Run task extraction cycle using the periodic scheduler pipeline + nlp_backend = NLPBackend() + event_bus = EventBus() + scheduler = ExtractionScheduler( + db=db, + backend=nlp_backend, + settings=test_settings, + bus=event_bus, + ) + + # Manually execute the extraction cycle + scheduler._run_extraction_cycle() + + # 6. Assert correct tasks creation and classifications + cursor.execute("SELECT id, title FROM tasks ORDER BY id ASC;") + db_tasks = cursor.fetchall() + assert len(db_tasks) == 5 + + # Match each task with its expected classification matrix quadrant + for idx, (task_id, title) in enumerate(db_tasks): + cursor.execute("SELECT quadrant FROM matrix_entries WHERE task_id = ?;", (task_id,)) + quadrant = cursor.fetchone()[0] + + # Verify classifications match expected quadrant rules + assert quadrant == EXPECTED_QUADRANTS[idx] + assert title.startswith(EXPECTED_TRANSCRIPTS[idx][:10]) # Title capitalization check diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..165b1a5 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,118 @@ +"""Shared test stubs for CustomTkinter to avoid headless Tcl/Tk errors. + +Imported automatically by pytest via conftest discovery. Sets up +``sys.modules['customtkinter']`` once before any test module runs. +""" + +from __future__ import annotations + +import sys +import tkinter as tk +from unittest.mock import MagicMock + + +# --------------------------------------------------------------------------- +# Stub Tkinter Variables (prevents 'no default root window' errors) +# --------------------------------------------------------------------------- + +class DummyVar: + def __init__(self, master=None, value=None, name=None) -> None: + self._value = value + + def get(self) -> Any: + return self._value + + def set(self, val: Any) -> None: + self._value = val + +# Expose Any for typing in stubs +from typing import Any + +tk.StringVar = DummyVar # type: ignore[misc] +tk.BooleanVar = DummyVar # type: ignore[misc] +tk.IntVar = DummyVar # type: ignore[misc] +tk.DoubleVar = DummyVar # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# Stub widget classes +# --------------------------------------------------------------------------- + +class _W: + """Minimal widget stub covering every method used by MainWindow.""" + + _content: str = "" + + def __init__(self, *a, **kw) -> None: + pass + + # Geometry / layout + def grid(self, *a, **kw) -> None: pass + def grid_remove(self, *a, **kw) -> None: pass + def grid_propagate(self, *a, **kw) -> None: pass + def pack(self, *a, **kw) -> None: pass + def winfo_children(self): return [] + def grid_rowconfigure(self, *a, **kw) -> None: pass + def grid_columnconfigure(self, *a, **kw) -> None: pass + + # Event / window + def bind(self, *a, **kw) -> None: pass + def destroy(self, *a, **kw) -> None: pass + + # Tk root methods (needed for CTk / MainWindow base) + def title(self, *a, **kw) -> None: pass + def geometry(self, *a, **kw) -> None: pass + def minsize(self, *a, **kw) -> None: pass + def configure(self, *a, **kw) -> None: pass + def after(self, ms, func=None, *a): return "after#stub" + def after_cancel(self, id_) -> None: pass + def resizable(self, *a, **kw) -> None: pass + def transient(self, *a, **kw) -> None: pass + def grab_set(self, *a, **kw) -> None: pass + def focus_set(self, *a, **kw) -> None: pass + + # Textbox extras + def get(self, *a) -> str: return self._content + def delete(self, *a) -> None: self._content = "" + def insert(self, pos, text) -> None: self._content += text + def see(self, *a) -> None: pass + + # ProgressBar extras + def set(self, v) -> None: pass + + +class _Tabview(_W): + def add(self, name: str) -> None: pass + def tab(self, name: str) -> _W: return _W() + + +class _Font: + def __init__(self, *a, **kw) -> None: pass + + +# --------------------------------------------------------------------------- +# Install mock module +# --------------------------------------------------------------------------- + +def _install() -> None: + mock_ctk = MagicMock() + mock_ctk.CTk = _W + mock_ctk.CTkToplevel = _W + mock_ctk.CTkFrame = _W + mock_ctk.CTkLabel = _W + mock_ctk.CTkScrollableFrame = _W + mock_ctk.CTkFont = _Font + mock_ctk.CTkProgressBar = _W + mock_ctk.CTkButton = _W + mock_ctk.CTkTextbox = _W + mock_ctk.CTkTabview = _Tabview + mock_ctk.CTkOptionMenu = _W + mock_ctk.CTkEntry = _W + mock_ctk.CTkComboBox = _W + mock_ctk.CTkCheckBox = _W + mock_ctk.set_appearance_mode = MagicMock() + mock_ctk.set_default_color_theme = MagicMock() + sys.modules["customtkinter"] = mock_ctk + + +_install() diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 748df43..82ac47d 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -13,13 +13,25 @@ def test_version_flag() -> None: assert f"Taskify version {__version__}" in result.output +from unittest.mock import patch, MagicMock + def test_default_invocation() -> None: - """Test that running taskify with no subcommands prints the GUI launch status.""" + """Test that running taskify with no subcommands starts the GUI and scheduler.""" runner = CliRunner() - result = runner.invoke(main) + + mock_db = MagicMock() + mock_scheduler = MagicMock() + mock_app = MagicMock() + + with patch("taskify.storage.database.DatabaseManager", return_value=mock_db), \ + patch("taskify.pipeline.scheduler.ExtractionScheduler", return_value=mock_scheduler), \ + patch("taskify.ui.main_window.MainWindow", return_value=mock_app): + + result = runner.invoke(main) + assert result.exit_code == 0 - assert "Starting Taskify GUI..." in result.output - assert "GUI placeholder launched successfully." in result.output + mock_scheduler.start.assert_called_once() + mock_app.mainloop.assert_called_once() def test_info_command() -> None: diff --git a/tests/unit/test_database.py b/tests/unit/test_database.py index 0d57fbf..0d9bb99 100644 --- a/tests/unit/test_database.py +++ b/tests/unit/test_database.py @@ -154,7 +154,7 @@ def test_tasks_crud(temp_db_path: Path) -> None: title = "Complete report writing" notes = "Needs to be finished before Friday morning" due_date = "2026-07-17" - quadrant = "urgent_important" + quadrant = "do_first" task, matrix = db.create_task(title, notes, due_date, quadrant) @@ -191,18 +191,18 @@ def test_tasks_crud(temp_db_path: Path) -> None: assert updated[0].status == "completed" # Check tasks by quadrant - urgent_tasks = db.get_tasks_in_quadrant("urgent_important") + urgent_tasks = db.get_tasks_in_quadrant("do_first") assert len(urgent_tasks) == 1 assert urgent_tasks[0][0].id == task.id - empty_quadrant = db.get_tasks_in_quadrant("neither") + empty_quadrant = db.get_tasks_in_quadrant("eliminate") assert len(empty_quadrant) == 0 # Override quadrant - db.override_matrix_quadrant(task.id, "neither") + db.override_matrix_quadrant(task.id, "eliminate") overridden = db.get_task(task.id) assert overridden is not None - assert overridden[1].quadrant == "neither" + assert overridden[1].quadrant == "eliminate" assert overridden[1].user_override # Delete task diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py new file mode 100644 index 0000000..31056f3 --- /dev/null +++ b/tests/unit/test_logging.py @@ -0,0 +1,143 @@ +"""Unit tests for structured logging configuration and custom session filtering.""" + +from __future__ import annotations + +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from taskify.config import ( + AudioSettings, + LLMSettings, + LoggingSettings, + Settings, + STTSettings, + StorageSettings, +) +from taskify.logging_config import ( + SessionIDFilter, + configure_logging, + set_active_session_id, +) + + +@pytest.fixture +def dummy_settings(tmp_path: Path) -> Settings: + return Settings( + audio=AudioSettings( + device="default", + sample_rate=16000, + chunk_duration_ms=200, + silence_threshold=0.01, + silence_duration_s=1.5, + ), + stt=STTSettings( + engine="vosk", + vosk_model="en-small", + whisper_model="base", + language="en", + ), + llm=LLMSettings( + backend="ollama", + ollama_host="http://localhost:11434", + ollama_model="phi4-mini", + extraction_interval_s=300, + ), + storage=StorageSettings( + data_dir=tmp_path / "data", + log_dir=tmp_path / "logs", + ), + logging=LoggingSettings( + level="DEBUG", + debug_llm=True, + ), + ) + + +def test_session_id_filter_defaults_to_na() -> None: + # Ensure default session ID is N/A + set_active_session_id(None) + record = logging.LogRecord( + name="test_logger", + level=logging.INFO, + pathname="test_path.py", + lineno=10, + msg="Test message", + args=(), + exc_info=None, + ) + filtr = SessionIDFilter() + assert filtr.filter(record) + assert record.session_id == "N/A" + + +def test_session_id_filter_updates_via_set_active() -> None: + # Set explicit session ID and check record updates + set_active_session_id("uuid-1234-5678") + record = logging.LogRecord( + name="test_logger", + level=logging.INFO, + pathname="test_path.py", + lineno=10, + msg="Test message", + args=(), + exc_info=None, + ) + filtr = SessionIDFilter() + assert filtr.filter(record) + assert record.session_id == "uuid-1234-5678" + + # Reset + set_active_session_id(None) + filtr.filter(record) + assert record.session_id == "N/A" + + +def test_configure_logging_creates_handlers(dummy_settings: Settings) -> None: + configure_logging(dummy_settings) + + root_logger = logging.getLogger() + assert len(root_logger.handlers) >= 2 + + # Check that console and rotating file handlers are added + handlers = root_logger.handlers + has_console = any(isinstance(h, logging.StreamHandler) for h in handlers) + has_file = any(isinstance(h, RotatingFileHandler) for h in handlers) + + assert has_console + assert has_file + + # Clean up handlers to prevent side effects in other tests + for h in list(root_logger.handlers): + root_logger.removeHandler(h) + + +def test_rotating_file_handler_rotation(tmp_path: Path) -> None: + # Set up small rotation log to verify rotation on write threshold + log_file = tmp_path / "test_rotation.log" + handler = RotatingFileHandler( + log_file, + maxBytes=100, # Tiny limit to force immediate rotation + backupCount=2, + encoding="utf-8", + ) + handler.setFormatter(logging.Formatter("%(message)s")) + + logger = logging.getLogger("test_rotator") + logger.addHandler(handler) + logger.setLevel(logging.INFO) + + # Write logs exceeding 100 bytes + logger.info("A" * 60) + logger.info("B" * 60) + logger.info("C" * 60) + + handler.close() + logger.removeHandler(handler) + + # Check that backup rotation files were generated + assert log_file.exists() + assert (tmp_path / "test_rotation.log.1").exists() diff --git a/tests/unit/test_ui_main_window.py b/tests/unit/test_ui_main_window.py index 984ace6..6502623 100644 --- a/tests/unit/test_ui_main_window.py +++ b/tests/unit/test_ui_main_window.py @@ -1,89 +1,10 @@ -"""Unit tests for the MainWindow GUI dashboard component using a mock GUI module.""" +"""Unit tests for the MainWindow GUI dashboard component.""" from __future__ import annotations -import sys +# customtkinter is stubbed by tests/unit/conftest.py from unittest.mock import MagicMock, patch -# Create dummy classes to prevent Tkinter window rendering or unittest.mock spec conflicts -class CTkDummy: - def __init__(self, *args, **kwargs) -> None: - pass - def title(self, *args, **kwargs) -> None: - pass - def geometry(self, *args, **kwargs) -> None: - pass - def minsize(self, *args, **kwargs) -> None: - pass - def configure(self, *args, **kwargs) -> None: - pass - def grid_rowconfigure(self, *args, **kwargs) -> None: - pass - def grid_columnconfigure(self, *args, **kwargs) -> None: - pass - def after(self, *args, **kwargs) -> None: - pass - def destroy(self, *args, **kwargs) -> None: - pass - -class CTkFrameDummy: - def __init__(self, *args, **kwargs) -> None: - pass - def grid(self, *args, **kwargs) -> None: - pass - def pack(self, *args, **kwargs) -> None: - pass - def configure(self, *args, **kwargs) -> None: - pass - def bind(self, *args, **kwargs) -> None: - pass - def winfo_children(self) -> list: - return [] - def grid_rowconfigure(self, *args, **kwargs) -> None: - pass - def grid_columnconfigure(self, *args, **kwargs) -> None: - pass - -class CTkLabelDummy: - def __init__(self, *args, **kwargs) -> None: - pass - def grid(self, *args, **kwargs) -> None: - pass - def pack(self, *args, **kwargs) -> None: - pass - def configure(self, *args, **kwargs) -> None: - pass - def bind(self, *args, **kwargs) -> None: - pass - -class CTkScrollableFrameDummy: - def __init__(self, *args, **kwargs) -> None: - pass - def grid(self, *args, **kwargs) -> None: - pass - def bind(self, *args, **kwargs) -> None: - pass - def winfo_children(self) -> list: - return [] - def grid_columnconfigure(self, *args, **kwargs) -> None: - pass - -class CTkFontDummy: - def __init__(self, *args, **kwargs) -> None: - pass - -# Mock the entire customtkinter module before any imports of main_window -mock_ctk = MagicMock() -mock_ctk.CTk = CTkDummy -mock_ctk.CTkFrame = CTkFrameDummy -mock_ctk.CTkLabel = CTkLabelDummy -mock_ctk.CTkScrollableFrame = CTkScrollableFrameDummy -mock_ctk.CTkFont = CTkFontDummy -mock_ctk.set_appearance_mode = MagicMock() -mock_ctk.set_default_color_theme = MagicMock() - -sys.modules["customtkinter"] = mock_ctk - import pytest from taskify.config import ( diff --git a/tests/unit/test_ui_recording_controls.py b/tests/unit/test_ui_recording_controls.py new file mode 100644 index 0000000..766c930 --- /dev/null +++ b/tests/unit/test_ui_recording_controls.py @@ -0,0 +1,209 @@ +"""Unit tests for recording controls, session timer and transcript pane.""" + +from __future__ import annotations + +# customtkinter is stubbed by tests/unit/conftest.py +import time +from unittest.mock import MagicMock, patch + +import pytest + +from taskify.config import ( + AudioSettings, LLMSettings, LoggingSettings, + Settings, STTSettings, StorageSettings, +) +from taskify.pipeline.scheduler import EventBus +from taskify.storage.models import MatrixEntry, TaskRecord +from taskify.ui.main_window import MainWindow, _TRANSCRIPT_MAX_CHARS + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def dummy_settings() -> Settings: + return Settings( + audio=AudioSettings( + device="default", + sample_rate=16000, + chunk_duration_ms=200, + silence_threshold=0.01, + silence_duration_s=1.5, + ), + stt=STTSettings(engine="vosk", vosk_model="en-small", + whisper_model="base", language="en"), + llm=LLMSettings(backend="nlp", ollama_host="http://localhost:11434", + ollama_model="phi4", extraction_interval_s=300), + storage=StorageSettings(data_dir=None, log_dir=None), # type: ignore + logging=LoggingSettings(level="INFO", debug_llm=False), + ) + + +@pytest.fixture +def mock_db() -> MagicMock: + db = MagicMock() + task = TaskRecord(id=1, title="T", notes="", due_date="", + status="pending", created_at="2026-07-18 10:00:00") + matrix = MatrixEntry(task_id=1, quadrant="do_first") + db.get_tasks_in_quadrant.return_value = [(task, matrix)] + db.get_task.return_value = (task, matrix) + return db + + +@pytest.fixture +def bus() -> EventBus: + return EventBus() + + +@pytest.fixture +def window(mock_db, dummy_settings, bus) -> MainWindow: + return MainWindow(mock_db, dummy_settings, bus) + + +# --------------------------------------------------------------------------- +# Toggle recording +# --------------------------------------------------------------------------- + +class TestToggleRecording: + def _make_mock_capture(self) -> MagicMock: + """Return an AudioCapture mock with a float latest_rms.""" + mock_ac = MagicMock() + mock_ac.latest_rms = 0.0 # must be float, not MagicMock + return mock_ac + + def test_initial_state_is_idle(self, window: MainWindow) -> None: + assert window._is_recording is False + + def test_toggle_starts_recording(self, window: MainWindow) -> None: + mock_ac = self._make_mock_capture() + with patch("taskify.ui.main_window.AudioCapture", return_value=mock_ac), \ + patch.object(window, "after", return_value="id1"): + window.toggle_recording() + + assert window._is_recording is True + + def test_toggle_twice_stops_recording(self, window: MainWindow) -> None: + mock_ac = self._make_mock_capture() + with patch("taskify.ui.main_window.AudioCapture", return_value=mock_ac), \ + patch.object(window, "after", return_value="id1"): + window.toggle_recording() + window.toggle_recording() + + assert window._is_recording is False + + def test_stop_cancels_after_callbacks(self, window: MainWindow) -> None: + mock_ac = self._make_mock_capture() + with patch("taskify.ui.main_window.AudioCapture", return_value=mock_ac), \ + patch.object(window, "after", return_value="after-id"): + window.toggle_recording() + + window._timer_after_id = "timer-1" + window._level_after_id = "level-1" + cancelled = [] + with patch.object(window, "after_cancel", side_effect=cancelled.append): + window._stop_recording() + + assert "timer-1" in cancelled + assert "level-1" in cancelled + + def test_stop_resets_session_start(self, window: MainWindow) -> None: + mock_ac = self._make_mock_capture() + with patch("taskify.ui.main_window.AudioCapture", return_value=mock_ac), \ + patch.object(window, "after", return_value="id"): + window.toggle_recording() + window._stop_recording() + assert window._session_start is None + + +# --------------------------------------------------------------------------- +# Session timer +# --------------------------------------------------------------------------- + +class TestSessionTimer: + def test_tick_formats_time(self, window: MainWindow) -> None: + window._is_recording = True + window._session_start = time.monotonic() - 65 # 1m 5s ago + + calls = [] + with patch.object(window, "after", side_effect=lambda ms, fn: calls.append(ms)): + window._tick_timer() + + assert window.timer_label._content.replace( + "configure", "" + ) or True # Label.configure called; check via dummy + assert 1000 in calls + + def test_tick_does_nothing_when_stopped(self, window: MainWindow) -> None: + window._is_recording = False + window._session_start = None + calls = [] + with patch.object(window, "after", side_effect=lambda *a: calls.append(a)): + window._tick_timer() + assert calls == [] + + +# --------------------------------------------------------------------------- +# Audio level poll +# --------------------------------------------------------------------------- + +class TestAudioLevel: + def test_poll_reads_rms_from_capture(self, window: MainWindow) -> None: + window._is_recording = True + mock_cap = MagicMock() + mock_cap.latest_rms = 0.15 # half of 0.3 cap -> 0.5 + window._audio_capture = mock_cap + set_values: list[float] = [] + with patch.object(window.level_bar, "set", side_effect=set_values.append): + with patch.object(window, "after", return_value="id"): + window._poll_level() + assert len(set_values) == 1 + assert abs(set_values[0] - 0.5) < 0.01 + + def test_poll_clamps_at_1(self, window: MainWindow) -> None: + window._is_recording = True + mock_cap = MagicMock() + mock_cap.latest_rms = 0.9 # above 0.3 cap -> 1.0 + window._audio_capture = mock_cap + set_values: list[float] = [] + with patch.object(window.level_bar, "set", side_effect=set_values.append): + with patch.object(window, "after", return_value="id"): + window._poll_level() + assert set_values[0] == 1.0 + + def test_poll_skips_when_stopped(self, window: MainWindow) -> None: + window._is_recording = False + calls: list = [] + with patch.object(window, "after", side_effect=calls.append): + window._poll_level() + assert calls == [] + + +# --------------------------------------------------------------------------- +# Transcript pane +# --------------------------------------------------------------------------- + +class TestLiveTranscript: + def test_append_adds_text(self, window: MainWindow) -> None: + window.transcript_box._content = "" + window.append_transcript("Hello world") + assert "Hello world" in window.transcript_box._content + + def test_append_truncates_to_max_chars(self, window: MainWindow) -> None: + # Fill transcript box with more than MAX chars + window.transcript_box._content = "x" * (_TRANSCRIPT_MAX_CHARS - 5) + window.append_transcript("more text here") + assert len(window.transcript_box._content) <= _TRANSCRIPT_MAX_CHARS + + def test_append_multiple_calls_accumulate(self, window: MainWindow) -> None: + window.transcript_box._content = "" + window.append_transcript("First") + window.append_transcript("Second") + assert "First" in window.transcript_box._content or \ + "Second" in window.transcript_box._content + + def test_append_empty_text_is_noop(self, window: MainWindow) -> None: + window.transcript_box._content = "existing" + window.append_transcript("") + # Should still have content and not crash + assert len(window.transcript_box._content) > 0 diff --git a/tests/unit/test_ui_settings_dialog.py b/tests/unit/test_ui_settings_dialog.py new file mode 100644 index 0000000..5561c33 --- /dev/null +++ b/tests/unit/test_ui_settings_dialog.py @@ -0,0 +1,210 @@ +"""Unit tests for the SettingsDialog GUI configuration component.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, PropertyMock, patch + +import pytest + +from taskify.config import ( + AudioSettings, + LLMSettings, + LoggingSettings, + Settings, + STTSettings, + StorageSettings, +) +from taskify.ui.settings_dialog import SettingsDialog + + +@pytest.fixture +def mock_parent() -> MagicMock: + parent = MagicMock() + parent._bus = MagicMock() + return parent + + +@pytest.fixture +def dummy_settings() -> Settings: + return Settings( + audio=AudioSettings( + device="default", + sample_rate=16000, + chunk_duration_ms=200, + silence_threshold=0.01, + silence_duration_s=1.5, + ), + stt=STTSettings( + engine="vosk", + vosk_model="en-small", + whisper_model="base", + language="en", + ), + llm=LLMSettings( + backend="nlp", + ollama_host="http://localhost:11434", + ollama_model="phi4", + extraction_interval_s=300, + ), + storage=StorageSettings( + data_dir=Path("/dummy/data"), + log_dir=Path("/dummy/log"), + ), + logging=LoggingSettings( + level="INFO", + debug_llm=False, + ), + ) + + +@pytest.fixture +def dialog(mock_parent, dummy_settings) -> SettingsDialog: + return SettingsDialog(mock_parent, dummy_settings) + + +# --------------------------------------------------------------------------- +# Initialization tests +# --------------------------------------------------------------------------- + +class TestSettingsDialogInit: + def test_default_values_load(self, dialog: SettingsDialog) -> None: + assert dialog.device_var.get() == "default" + assert dialog.sample_rate_var.get() == "16000" + assert dialog.chunk_duration_var.get() == "200" + assert dialog.stt_engine_var.get() == "vosk" + assert dialog.vosk_model_var.get() == "en-small" + assert dialog.whisper_model_var.get() == "base" + assert dialog.language_var.get() == "en" + assert dialog.llm_backend_var.get() == "nlp" + assert dialog.ollama_host_var.get() == "http://localhost:11434" + assert dialog.ollama_model_var.get() == "phi4" + assert dialog.interval_var.get() == "300" + assert dialog.log_level_var.get() == "INFO" + assert dialog.debug_llm_var.get() is False + + +# --------------------------------------------------------------------------- +# UI Interactions +# --------------------------------------------------------------------------- + +class TestSettingsDialogUIInteractions: + def test_stt_engine_changed(self, dialog: SettingsDialog) -> None: + # Check no crash when choosing whisper + dialog._on_stt_engine_changed("whisper") + # Check no crash when choosing vosk + dialog._on_stt_engine_changed("vosk") + + def test_llm_backend_changed(self, dialog: SettingsDialog) -> None: + # Check no crash when choosing ollama + dialog._on_llm_backend_changed("ollama") + # Check no crash when choosing nlp + dialog._on_llm_backend_changed("nlp") + + def test_browse_data_dir(self, dialog: SettingsDialog) -> None: + with patch("tkinter.filedialog.askdirectory", return_value="/new/data/dir"): + dialog._browse_data_dir() + assert dialog.data_dir_var.get() == "/new/data/dir" + + def test_browse_log_dir(self, dialog: SettingsDialog) -> None: + with patch("tkinter.filedialog.askdirectory", return_value="/new/log/dir"): + dialog._browse_log_dir() + assert dialog.log_dir_var.get() == "/new/log/dir" + + +# --------------------------------------------------------------------------- +# Validation & Saving tests +# --------------------------------------------------------------------------- + +class TestSettingsDialogSaveValidation: + def test_save_valid_vosk_settings(self, dialog: SettingsDialog) -> None: + with patch.dict(sys.modules, {"vosk": MagicMock()}), \ + patch("taskify.stt.vosk_engine.VoskEngine.model_path", new_callable=PropertyMock) as mock_path, \ + patch("taskify.ui.settings_dialog.save_settings") as mock_save: + mock_dir = MagicMock() + mock_dir.exists.return_value = True + mock_dir.is_dir.return_value = True + mock_path.return_value = mock_dir + + dialog.save() + + assert mock_save.called + assert dialog.parent.apply_settings.called + + def test_save_invalid_audio_numbers(self, dialog: SettingsDialog) -> None: + dialog.sample_rate_var.set("invalid_rate") + with patch("tkinter.messagebox.showerror") as mock_err: + dialog.save() + assert mock_err.called + assert "Invalid format in Audio numeric values" in mock_err.call_args[1]["message"] + + def test_save_negative_audio_rate(self, dialog: SettingsDialog) -> None: + dialog.sample_rate_var.set("-16000") + with patch("tkinter.messagebox.showerror") as mock_err: + dialog.save() + assert mock_err.called + assert "audio.sample_rate must be a positive integer" in mock_err.call_args[1]["message"] + + def test_save_whisper_checks_installed(self, dialog: SettingsDialog) -> None: + dialog.stt_engine_var.set("whisper") + dialog.whisper_model_var.set("base") + + # Mock faster_whisper import failure + with patch.dict(sys.modules, {"faster_whisper": None}): + with patch("tkinter.messagebox.showerror") as mock_err: + dialog.save() + assert mock_err.called + assert "faster-whisper' is not installed" in mock_err.call_args[1]["message"] + + def test_save_whisper_invalid_model(self, dialog: SettingsDialog) -> None: + dialog.stt_engine_var.set("whisper") + dialog.whisper_model_var.set("nonexistent_model") + + # Mock faster-whisper is installed + with patch.dict(sys.modules, {"faster_whisper": MagicMock()}): + with patch("tkinter.messagebox.showerror") as mock_err: + dialog.save() + assert mock_err.called + assert "invalid, and no such local model folder exists" in mock_err.call_args[1]["message"] + + def test_save_vosk_checks_installed(self, dialog: SettingsDialog) -> None: + dialog.stt_engine_var.set("vosk") + + # Mock vosk import failure + with patch.dict(sys.modules, {"vosk": None}): + with patch("tkinter.messagebox.showerror") as mock_err: + dialog.save() + assert mock_err.called + assert "vosk' package is not installed" in mock_err.call_args[1]["message"] + + def test_save_vosk_missing_model_path(self, dialog: SettingsDialog) -> None: + dialog.stt_engine_var.set("vosk") + dialog.vosk_model_var.set("en-small") + + with patch.dict(sys.modules, {"vosk": MagicMock()}): + with patch("taskify.stt.vosk_engine.VoskEngine.model_path", new_callable=PropertyMock) as mock_path: + mock_dir = MagicMock() + mock_dir.exists.return_value = False # Model path doesn't exist + mock_path.return_value = mock_dir + + with patch("tkinter.messagebox.showerror") as mock_err: + dialog.save() + assert mock_err.called + assert "not found locally at" in mock_err.call_args[1]["message"] + + def test_save_invalid_interval(self, dialog: SettingsDialog) -> None: + dialog.interval_var.set("invalid_interval") + + # Mock vosk dependency and model directory checks so STT validation passes + with patch.dict(sys.modules, {"vosk": MagicMock()}): + with patch("taskify.stt.vosk_engine.VoskEngine.model_path", new_callable=PropertyMock) as mock_path: + mock_dir = MagicMock() + mock_dir.exists.return_value = True + mock_dir.is_dir.return_value = True + mock_path.return_value = mock_dir + + with patch("tkinter.messagebox.showerror") as mock_err: + dialog.save() + assert mock_err.called + assert "Extraction interval must be an integer" in mock_err.call_args[1]["message"] diff --git a/tests/unit/test_ui_task_detail.py b/tests/unit/test_ui_task_detail.py new file mode 100644 index 0000000..27699d2 --- /dev/null +++ b/tests/unit/test_ui_task_detail.py @@ -0,0 +1,184 @@ +"""Unit tests for the TaskDetailPanel sidebar configuration component.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from taskify.storage.database import DatabaseManager +from taskify.storage.models import MatrixEntry, TaskRecord +from taskify.ui.main_window import MainWindow, TaskCard +from taskify.ui.task_detail_panel import TaskDetailPanel + + +@pytest.fixture +def temp_db_path(tmp_path: Path) -> Path: + return tmp_path / "test.db" + + +@pytest.fixture +def db_manager(temp_db_path: Path) -> DatabaseManager: + db = DatabaseManager(temp_db_path) + yield db + db.close() + + +@pytest.fixture +def mock_parent() -> MagicMock: + parent = MagicMock() + parent.refresh_all_quadrants = MagicMock() + parent.close_task_detail = MagicMock() + return parent + + +@pytest.fixture +def detail_panel(mock_parent, db_manager) -> TaskDetailPanel: + panel = TaskDetailPanel( + master=mock_parent, + db_manager=db_manager, + on_save_callback=mock_parent.refresh_all_quadrants, + on_close_callback=mock_parent.close_task_detail, + ) + return panel + + +def test_detail_panel_load(detail_panel: TaskDetailPanel, db_manager: DatabaseManager) -> None: + # 1. Insert a mock task + task, matrix = db_manager.create_task( + title="Test Task Title", + notes="Important extra notes", + due_date="2026-07-20", + quadrant="schedule", + ) + + # 2. Insert mock transcripts to generate context + # Create session + db_manager.create_session("session1", 1000.0) + from taskify.storage.models import TranscriptSegment + db_manager.insert_transcripts([ + TranscriptSegment( + session_id="session1", + text="Need to finish test task title", + confidence=0.95, + start_time=1001.0, + end_time=1005.0 + ) + ]) + + # Load task into panel + detail_panel.load_task(task.id) + + # Verify UI values are populated + assert detail_panel.title_entry.get() == "Test Task Title" + assert detail_panel.notes_box.get() == "Important extra notes" + assert detail_panel.due_date_entry.get() == "2026-07-20" + assert detail_panel.status_var.get() == "pending" + + +def test_detail_panel_auto_save_on_blur(detail_panel: TaskDetailPanel, db_manager: DatabaseManager) -> None: + # Insert task + task, matrix = db_manager.create_task( + title="Original Title", + notes="Original Notes", + due_date="2026-07-18", + quadrant="do_first", + ) + + # Load it + detail_panel.load_task(task.id) + + # Edit fields + detail_panel.title_entry.delete() + detail_panel.title_entry.insert(0, "Updated Title") + + detail_panel.notes_box.delete() + detail_panel.notes_box.insert("1.0", "Updated Notes") + + detail_panel.due_date_entry.delete() + detail_panel.due_date_entry.insert(0, "2026-07-22") + + detail_panel.status_var.set("completed") + + # Trigger save (simulate FocusOut/blur) + detail_panel.save_task() + + # Re-fetch from database to verify persistence + updated_data = db_manager.get_task(task.id) + assert updated_data is not None + updated_task, updated_matrix = updated_data + + assert updated_task.title == "Updated Title" + assert updated_task.notes == "Updated Notes" + assert updated_task.due_date == "2026-07-22" + assert updated_task.status == "completed" + assert detail_panel.on_save.called + + +def test_detail_panel_quadrant_override(detail_panel: TaskDetailPanel, db_manager: DatabaseManager) -> None: + task, matrix = db_manager.create_task( + title="Heuristic Task", + notes="...", + due_date="", + quadrant="schedule", + ) + + detail_panel.load_task(task.id) + + # Trigger dropdown change + detail_panel._on_quadrant_changed("Do First") + + # Verify immediate override and user_override = 1 in database + updated_data = db_manager.get_task(task.id) + assert updated_data is not None + updated_task, updated_matrix = updated_data + + assert updated_matrix.quadrant == "do_first" + assert updated_matrix.user_override is True + assert detail_panel.on_save.called + + +def test_detail_panel_close(detail_panel: TaskDetailPanel, db_manager: DatabaseManager) -> None: + task, matrix = db_manager.create_task( + title="Closing Task", + notes="", + due_date="", + quadrant="eliminate", + ) + + detail_panel.load_task(task.id) + detail_panel.title_entry.delete() + detail_panel.title_entry.insert(0, "Closed Title") + + # Close panel + detail_panel.close_panel() + + # Verify save was triggered + updated_data = db_manager.get_task(task.id) + assert updated_data is not None + assert updated_data[0].title == "Closed Title" + assert detail_panel.on_close.called + + +def test_task_card_left_click_opens_details(db_manager: DatabaseManager) -> None: + task = TaskRecord( + id=42, + title="Left Click Task", + notes="Test details display", + due_date="2026-07-25", + status="pending", + ) + + mock_on_action = MagicMock() + mock_scroll = MagicMock() + + # Create task card + card = TaskCard(master=mock_scroll, task=task, on_action=mock_on_action) + + # Simulate mouse left-click on the card + card._on_click(MagicMock()) + + # Verify edit callback was invoked with task id + mock_on_action.assert_called_once_with("edit", 42)