diff --git a/cognee/cli/_cognee.py b/cognee/cli/_cognee.py index 1539d1acf..32400fb85 100644 --- a/cognee/cli/_cognee.py +++ b/cognee/cli/_cognee.py @@ -92,6 +92,7 @@ def _discover_commands() -> List[Type[SupportsCliCommand]]: ("cognee.cli.commands.cognify_command", "CognifyCommand"), ("cognee.cli.commands.delete_command", "DeleteCommand"), ("cognee.cli.commands.config_command", "ConfigCommand"), + ("cognee.cli.commands.tui_command", "TuiCommand"), ] for module_path, class_name in command_modules: diff --git a/cognee/cli/commands/tui_command.py b/cognee/cli/commands/tui_command.py new file mode 100644 index 000000000..3ff313be9 --- /dev/null +++ b/cognee/cli/commands/tui_command.py @@ -0,0 +1,56 @@ +import argparse +from cognee.cli import SupportsCliCommand +from cognee.cli.config import DEFAULT_DOCS_URL +import cognee.cli.echo as fmt +from cognee.cli.exceptions import CliCommandException + + +class TuiCommand(SupportsCliCommand): + @property + def command_string(self) -> str: + return "tui" + + @property + def help_string(self) -> str: + return "Launch the interactive Textual TUI for cognee commands" + + @property + def docs_url(self) -> str: + return f"{DEFAULT_DOCS_URL}/usage/tui" + + def configure_parser(self, parser: argparse.ArgumentParser) -> None: + # No additional arguments for now + pass + + def execute(self, args: argparse.Namespace) -> None: + try: + from textual.app import App + from cognee.cli.tui.home_screen import HomeScreen + from cognee.shared.logging_utils import setup_logging + class CogneeTUI(App): + """Main TUI application for cognee.""" + + CSS = """ + Screen { + background: $surface; + } + """ + + def on_mount(self) -> None: + """Push the home screen on mount.""" + self.push_screen(HomeScreen()) + + setup_logging(enable_console_logging=False) + app = CogneeTUI() + app.run() + except ImportError: + raise CliCommandException( + "Textual is not installed. Install with: pip install textual", + docs_url=self.docs_url, + ) + except Exception as ex: + raise CliCommandException( + f"Failed to launch TUI: {str(ex)}", + docs_url=self.docs_url, + raiseable_exception=ex, + ) diff --git a/cognee/cli/tui/__init__.py b/cognee/cli/tui/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/cognee/cli/tui/__init__.py @@ -0,0 +1 @@ + diff --git a/cognee/cli/tui/add_screen.py b/cognee/cli/tui/add_screen.py new file mode 100644 index 000000000..371023baa --- /dev/null +++ b/cognee/cli/tui/add_screen.py @@ -0,0 +1,126 @@ +import asyncio +from textual.app import ComposeResult +from textual.widgets import Input, Label, Static, TextArea +from textual.containers import Container, Vertical +from textual.binding import Binding + +from cognee.cli.tui.base_screen import BaseTUIScreen + + +class AddTUIScreen(BaseTUIScreen): + """TUI screen for adding data to cognee.""" + + BINDINGS = [ + Binding("q", "quit_app", "Quit"), + Binding("escape", "back", "Back"), + Binding("ctrl+s", "submit", "Submit"), + Binding("ctrl+v", "paste", "Paste", show=False), + ] + + CSS = ( + BaseTUIScreen.CSS + + """ + #data-input { + height: 8; + min-height: 8; + } + """ + ) + + def __init__(self): + super().__init__() + self.is_processing = False + + def compose_content(self) -> ComposeResult: + with Container(classes="tui-main-container"): + with Container(classes="tui-title-wrapper"): + yield Static("📥 Add Data to Cognee", classes="tui-title-bordered") + with Vertical(classes="tui-form"): + yield Label( + "Data (text, file path (/path/to/doc), URL, or S3 path (s3://bucket)):", + classes="tui-label-spaced", + ) + yield TextArea( + "", + id="data-input", + ) + + yield Label("Dataset Name:", classes="tui-label-spaced") + yield Input(placeholder="main_dataset", value="main_dataset", id="dataset-input") + yield Static("", classes="tui-status") + + def compose_footer(self) -> ComposeResult: + yield Static("Ctrl+S: Add • Esc: Back • q: Quit", classes="tui-footer") + + def on_mount(self) -> None: + """Focus the data input on mount.""" + data_input = self.query_one("#data-input", TextArea) + data_input.focus() + + def action_back(self) -> None: + """Go back to home screen.""" + if not self.is_processing: + self.app.pop_screen() + + def action_quit_app(self) -> None: + """Quit the entire application.""" + self.app.exit() + + def action_paste(self) -> None: + """Handle paste action - Textual handles this automatically.""" + pass + + def action_submit(self) -> None: + """Submit the form.""" + if not self.is_processing: + self._submit_data() + + def _submit_data(self) -> None: + """Process and submit the data.""" + data_input = self.query_one("#data-input", TextArea) + dataset_input = self.query_one("#dataset-input", Input) + status = self.query_one(".tui-status", Static) + + data = data_input.text.strip() + dataset_name = dataset_input.value.strip() or "main_dataset" + + if not data: + status.update("[red]✗ Please enter data to add[/red]") + return + + self.is_processing = True + status.update("[yellow]⏳ Processing...[/yellow]") + + # Disable inputs during processing + data_input.disabled = True + dataset_input.disabled = True + + # Run async add operation + asyncio.create_task(self._add_data_async(data, dataset_name)) + + async def _add_data_async(self, data: str, dataset_name: str) -> None: + """Async function to add data to cognee.""" + status = self.query_one(".tui-status", Static) + + try: + import cognee + + await cognee.add(data=data, dataset_name=dataset_name) + + status.update(f"[green]✓ Successfully added data to dataset '{dataset_name}'[/green]") + + # Clear the data input after successful add + data_input = self.query_one("#data-input", TextArea) + data_input.clear() + + except Exception as e: + status.update(f"[red]✗ Failed to add data: {str(e)}[/red]") + + finally: + # Re-enable inputs + self.is_processing = False + data_input = self.query_one("#data-input", TextArea) + dataset_input = self.query_one("#dataset-input", Input) + data_input.disabled = False + dataset_input.disabled = False + data_input.focus() diff --git a/cognee/cli/tui/base_screen.py b/cognee/cli/tui/base_screen.py new file mode 100644 index 000000000..f03688ff6 --- /dev/null +++ b/cognee/cli/tui/base_screen.py @@ -0,0 +1,45 @@ +from textual.screen import Screen +from textual.app import ComposeResult +from textual.widgets import Static + +from cognee.version import get_cognee_version +from cognee.cli.tui.common_styles import COMMON_STYLES + + +class BaseTUIScreen(Screen): + """Base screen class with constant header for all TUI screens.""" + + # Subclasses should override this CSS and add their own styles + CSS = ( + COMMON_STYLES + + """ + #header { + dock: top; + background: $boost; + color: $text; + content-align: center middle; + border: solid $primary; + text-style: bold; + padding: 1; + } + """ + ) + + def compose_header(self) -> ComposeResult: + """Compose the constant header widget.""" + version = get_cognee_version() + yield Static(f"🧠 cognee v{version}", id="header") + + def compose_content(self) -> ComposeResult: + """Override this method in subclasses to provide screen-specific content.""" + yield from () + + def compose_footer(self) -> ComposeResult: + """Override this method in subclasses to provide screen-specific footer.""" + yield from () + + def compose(self) -> ComposeResult: + """Compose the screen with header, content, and footer.""" + yield from self.compose_header() + yield from self.compose_content() + yield from self.compose_footer() diff --git a/cognee/cli/tui/cognify_screen.py b/cognee/cli/tui/cognify_screen.py new file mode 100644 index 000000000..e2f6e916c --- /dev/null +++ b/cognee/cli/tui/cognify_screen.py @@ -0,0 +1,169 @@ +import asyncio +from textual.app import ComposeResult +from textual.widgets import Input, Label, Static, Checkbox, RadioSet, RadioButton +from textual.containers import Container, Vertical +from textual.binding import Binding +from cognee.cli.tui.base_screen import BaseTUIScreen +from cognee.cli.config import CHUNKER_CHOICES + + +class CognifyTUIScreen(BaseTUIScreen): + """TUI screen for cognifying data in cognee.""" + + BINDINGS = [ + Binding("q", "quit_app", "Quit"), + Binding("escape", "back", "Back"), + Binding("ctrl+s", "submit", "Submit"), + ] + + CSS = ( + BaseTUIScreen.CSS + + """ + Checkbox { + margin-top: 1; + margin-bottom: 1; + } + + RadioSet { + margin-top: 0; + margin-bottom: 1; + height: auto; + } + + RadioButton { + height: 1; + } + """ + ) + + def __init__(self): + super().__init__() + self.is_processing = False + + def compose_content(self) -> ComposeResult: + with Container(classes="tui-main-container"): + with Container(classes="tui-title-wrapper"): + yield Static("⚡ Cognify Data", classes="tui-title-bordered") + with Vertical(classes="tui-form"): + yield Label( + "Dataset Name:", classes="tui-label-spaced" + ) + yield Input( + placeholder="Enter the dataset name here.", value="", id="dataset-input" + ) + + yield Label("Chunker Type:", classes="tui-label-spaced") + with RadioSet(id="chunker-radio"): + for chunker in CHUNKER_CHOICES: + yield RadioButton(chunker, value=(chunker == "TextChunker")) + + yield Checkbox("Run in background", id="background-checkbox") + yield Static("", classes="tui-status") + + def compose_footer(self) -> ComposeResult: + yield Static("Ctrl+S: Start • Esc: Back • q: Quit", classes="tui-footer") + + def on_mount(self) -> None: + """Focus the dataset input on mount.""" + dataset_input = self.query_one("#dataset-input", Input) + dataset_input.focus() + + def action_back(self) -> None: + """Go back to home screen.""" + if not self.is_processing: + self.app.pop_screen() + + def action_quit_app(self) -> None: + """Quit the entire application.""" + self.app.exit() + + def action_submit(self) -> None: + """Submit the form.""" + if not self.is_processing: + self._submit_cognify() + + def _submit_cognify(self) -> None: + """Process and submit the cognify request.""" + dataset_input = self.query_one("#dataset-input", Input) + chunker_radio = self.query_one("#chunker-radio", RadioSet) + background_checkbox = self.query_one("#background-checkbox", Checkbox) + status = self.query_one(".tui-status", Static) + + dataset_name = dataset_input.value.strip() or None + chunker_type = ( + str(chunker_radio.pressed_button.label) + if chunker_radio.pressed_button + else "TextChunker" + ) + run_background = background_checkbox.value + + self.is_processing = True + status.update("[yellow]⏳ Starting cognification...[/yellow]") + + # Disable inputs during processing + dataset_input.disabled = True + chunker_radio.disabled = True + background_checkbox.disabled = True + + # Run async cognify operation + asyncio.create_task(self._cognify_async(dataset_name, chunker_type, run_background)) + + async def _cognify_async( + self, dataset_name: str | None, chunker_type: str, run_background: bool + ) -> None: + """Async function to cognify data.""" + status = self.query_one(".tui-status", Static) + from cognee.modules.chunking.TextChunker import TextChunker + + try: + # Get chunker class + chunker_class = TextChunker + if chunker_type == "LangchainChunker": + try: + from cognee.modules.chunking.LangchainChunker import LangchainChunker + except ImportError: + LangchainChunker = None + if LangchainChunker is not None: + chunker_class = LangchainChunker + else: + status.update( + "[yellow]⚠ LangchainChunker not available, using TextChunker[/yellow]" + ) + elif chunker_type == "CsvChunker": + try: + from cognee.modules.chunking.CsvChunker import CsvChunker + except ImportError: + CsvChunker = None + if CsvChunker is not None: + chunker_class = CsvChunker + else: + status.update("[yellow]⚠ CsvChunker not available, using TextChunker[/yellow]") + + # Prepare datasets parameter + datasets = [dataset_name] if dataset_name else None + import cognee + + await cognee.cognify( + datasets=datasets, + chunker=chunker_class, + run_in_background=run_background, + ) + + if run_background: + status.update("[green]✓ Cognification started in background![/green]") + else: + status.update("[green]✓ Cognification completed successfully![/green]") + + except Exception as e: + status.update(f"[red]✗ Failed to cognify: {str(e)}[/red]") + + finally: + # Re-enable inputs + self.is_processing = False + dataset_input = self.query_one("#dataset-input", Input) + chunker_radio = self.query_one("#chunker-radio", RadioSet) + background_checkbox = self.query_one("#background-checkbox", Checkbox) + dataset_input.disabled = False + chunker_radio.disabled = False + background_checkbox.disabled = False + dataset_input.focus() diff --git a/cognee/cli/tui/common_styles.py b/cognee/cli/tui/common_styles.py new file mode 100644 index 000000000..4cd97ce7c --- /dev/null +++ b/cognee/cli/tui/common_styles.py @@ -0,0 +1,135 @@ +"""Common CSS styles for TUI screens to reduce repetition.""" + +COMMON_STYLES = """ +/* Common screen background */ +Screen { + background: $surface; +} + +/* Common container styles */ +.tui-container { + height: 100%; + padding: 1; +} + +.tui-bordered-wrapper { + border: solid $primary; +} + +.tui-content-container { + height: auto; + padding: 1; + content-align: center middle; +} + +/* Main container wrapper - used across all screens */ +.tui-main-container { + height: 100%; + background: $surface; +} + +/* Title wrapper - centers title elements */ +.tui-title-wrapper { + width: 100%; + height: auto; + align: center middle; + content-align: center middle; +} + +/* Styled title with border */ +.tui-title-bordered { + text-align: center; + width: auto; + color: $accent; + text-style: bold; + padding: 0 10; + border: solid $accent; +} + +.tui-form { + width: 100%; + height: auto; + border: solid $primary; + padding: 2; + background: $surface; +} + +/* Common title styles */ +.tui-title { + text-align: center; + text-style: bold; + color: $accent; + margin-bottom: 2; + width: 100%; +} + +/* Common label styles */ +.tui-label { + color: $text-muted; + margin-bottom: 1; +} + +.tui-label-spaced { + color: $text-muted; + margin-top: 1; + margin-bottom: 1; +} + +/* Common input styles */ +Input { + width: 100%; + margin-bottom: 1; +} + +/* Common button styles */ +Button { + margin: 0 1; +} + +/* Common status message styles */ +.tui-status { + text-align: center; + margin-top: 2; + height: auto; +} + +/* Common footer styles */ +.tui-footer { + dock: bottom; + padding: 1 0; + background: $boost; + color: $text-muted; + content-align: center middle; + border: solid $primary; +} + +/* Common dialog/modal styles */ +.tui-dialog { + border: thick $warning; + background: $surface; + padding: 2; +} + +.tui-dialog-title { + text-align: center; + text-style: bold; + color: $warning; + margin-bottom: 1; +} + +.tui-dialog-message { + text-align: center; + margin-bottom: 1; +} + +.tui-dialog-buttons { + align: center middle; + height: 3; +} + +/* Common input group styles */ +.tui-input-group { + height: auto; + margin-bottom: 2; +} +""" diff --git a/cognee/cli/tui/config_screen.py b/cognee/cli/tui/config_screen.py new file mode 100644 index 000000000..508d6d9b3 --- /dev/null +++ b/cognee/cli/tui/config_screen.py @@ -0,0 +1,404 @@ +import argparse +import json +from typing import Any, Optional + +from cognee.cli.reference import SupportsCliCommand +from cognee.cli import DEFAULT_DOCS_URL +from cognee.cli.exceptions import CliCommandException + +try: + from textual.app import App, ComposeResult + from textual.screen import Screen + from textual.widgets import DataTable, Input, Label, Button, Static + from textual.containers import Container, Horizontal + from textual.binding import Binding + from cognee.cli.tui.base_screen import BaseTUIScreen +except ImportError: + # Handle case where textual is not installed to prevent import errors at module level + BaseTUIScreen = object + + +class ConfirmModal(Screen): + """Modal screen for confirming reset action.""" + + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + ] + + CSS = """ + ConfirmModal { + align: center middle; + } + + #confirm-dialog { + width: 60; + height: auto; + border: thick $warning; + background: $surface; + padding: 1 2; + } + + #confirm-title { + text-align: center; + text-style: bold; + margin-bottom: 1; + } + + #confirm-message { + text-align: center; + margin-bottom: 2; + } + + .tui-dialog-buttons { + align: center middle; + height: auto; + } + + Button { + margin: 0 1; + } + """ + + def __init__(self, key: str, default_value: str): + super().__init__() + self.key = key + self.default_value = default_value + + def compose(self) -> ComposeResult: + with Container(id="confirm-dialog"): + yield Label("⚠ Reset Configuration", id="confirm-title") + yield Label(f"Are you sure you want to reset '{self.key}'?", id="confirm-message") + yield Label(f"It will revert to: {self.default_value}", classes="dim-text") + + with Horizontal(classes="tui-dialog-buttons"): + yield Button("Reset", variant="error", id="confirm-btn") + yield Button("Cancel", variant="default", id="cancel-btn") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "confirm-btn": + self.dismiss(True) + else: + self.dismiss(False) + + def action_cancel(self) -> None: + self.dismiss(False) + + +class ConfigTUIScreen(BaseTUIScreen): + """Main config TUI screen with inline editing and live data fetching.""" + + BINDINGS = [ + Binding("q", "quit_app", "Quit"), + Binding("escape", "cancel_or_back", "Back/Cancel"), + Binding("e", "edit", "Edit"), + Binding("enter", "confirm_edit", "Confirm", show=False), + Binding("r", "reset", "Reset"), + Binding("up", "cursor_up", "Up", show=False), + Binding("down", "cursor_down", "Down", show=False), + ] + + CSS = ( + BaseTUIScreen.CSS + + """ + DataTable { + height: 1fr; + text-align: center; + } + + #inline-edit-container { + display: none; + height: auto; + padding: 0 1; + margin-top: 1; + } + + #inline-edit-container.visible { + display: block; + } + + #edit-label { + color: $text-muted; + margin-bottom: 1; + } + + #inline-input { + width: 100%; + } + + .dim-text { + color: $text-muted; + text-align: center; + margin-bottom: 1; + } + """ + ) + + # Config key mappings: Key -> (Reset Method Name, Default Value) + CONFIG_MAP = { + "llm_provider": ("set_llm_provider", "openai"), + "llm_model": ("set_llm_model", "gpt-5-mini"), + "llm_api_key": ("set_llm_api_key", ""), + "llm_endpoint": ("set_llm_endpoint", ""), + "graph_database_provider": ("set_graph_database_provider", "kuzu"), + "vector_db_provider": ("set_vector_db_provider", "lancedb"), + "vector_db_url": ("set_vector_db_url", ""), + "vector_db_key": ("set_vector_db_key", ""), + "chunk_size": ("set_chunk_size", 1500), + "chunk_overlap": ("set_chunk_overlap", 10), + } + + def __init__(self): + super().__init__() + self.editing_key = None # Track which key is being edited + + def compose_content(self) -> ComposeResult: + with Container(classes="tui-main-container"): + with Container(classes="tui-title-wrapper"): + yield Static("⚙️ Configuration Manager", classes="tui-title-bordered") + + with Container(classes="tui-bordered-wrapper"): + table = DataTable(id="config-table") + table.cursor_type = "row" + table.zebra_stripes = True + yield table + + with Container(id="inline-edit-container"): + yield Label("", id="edit-label") + yield Input(placeholder="Enter new value", id="inline-input") + + def compose_footer(self) -> ComposeResult: + yield Static( + "↑↓: Navigate • e: Edit • Enter: Save • r: Reset • Esc: Back • q: Quit", + classes="tui-footer", + ) + + def on_mount(self) -> None: + """Initialize the table with columns and current data.""" + table = self.query_one(DataTable) + table.add_columns("Configuration Key", "Current Value") + + self._load_table_data() + table.focus() + + def _load_table_data(self) -> None: + """Fetch real config values and populate the table.""" + table = self.query_one(DataTable) + table.clear() + + try: + import cognee + + # Check if get method exists, otherwise warn + has_get = hasattr(cognee.config, "get") + except ImportError: + has_get = False + self.notify("Could not import cognee config", severity="error") + + for key, (_, default_val) in self.CONFIG_MAP.items(): + value_display = "N/A" + + if has_get: + try: + raw_val = cognee.config.get(key) + if raw_val is None: + raw_val = default_val + value_display = str(raw_val) if raw_val is not None else "(empty)" + except Exception: + value_display = "Error fetching value" + + table.add_row(key, value_display, key=key) + + def action_cursor_up(self) -> None: + if self.editing_key: + return + self.query_one(DataTable).action_cursor_up() + + def action_cursor_down(self) -> None: + if self.editing_key: + return + self.query_one(DataTable).action_cursor_down() + + def action_cancel_or_back(self) -> None: + if self.editing_key: + self._cancel_edit() + else: + self.app.pop_screen() + + def action_quit_app(self) -> None: + self.app.exit() + + def action_edit(self) -> None: + """Start inline editing for the selected config value.""" + if self.editing_key: + return + + table = self.query_one(DataTable) + if table.cursor_row < 0: + return + + # Get row data using the cursor + row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key + current_val = table.get_cell(row_key, list(table.columns.keys())[1]) # Get value column + + self.editing_key = str(row_key.value) + + # Show edit container + edit_container = self.query_one("#inline-edit-container") + edit_container.add_class("visible") + + # Update UI + label = self.query_one("#edit-label", Label) + label.update(f"Editing: [bold]{self.editing_key}[/bold]") + + input_widget = self.query_one("#inline-input", Input) + input_widget.value = "" + # Don't put "empty" or "N/A" into the input box to save user deleting it + if current_val not in ["(empty)", "N/A", "Error fetching value"]: + input_widget.value = str(current_val) + + input_widget.placeholder = f"Enter new value for {self.editing_key}" + input_widget.focus() + + def action_confirm_edit(self) -> None: + """Confirm the inline edit and save the value.""" + if not self.editing_key: + return + + input_widget = self.query_one("#inline-input", Input) + value = input_widget.value.strip() + + # Allow saving even if empty (might mean unset/empty string) + self._save_config(self.editing_key, value) + self._cancel_edit() + + def _cancel_edit(self) -> None: + self.editing_key = None + edit_container = self.query_one("#inline-edit-container") + edit_container.remove_class("visible") + self.query_one("#inline-input", Input).value = "" + self.query_one(DataTable).focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id == "inline-input" and self.editing_key: + self.action_confirm_edit() + + def action_reset(self) -> None: + """Reset the selected config to default.""" + table = self.query_one(DataTable) + if table.cursor_row < 0: + return + + row_key_obj = table.coordinate_to_cell_key(table.cursor_coordinate).row_key + key = str(row_key_obj.value) + + if key not in self.CONFIG_MAP: + self.notify(f"Cannot reset '{key}'", severity="warning") + return + + _, default_value = self.CONFIG_MAP[key] + display_default = "(empty)" if default_value == "" else str(default_value) + + def handle_confirm_result(confirmed: bool) -> None: + if confirmed: + self._reset_config(key) + + self.app.push_screen(ConfirmModal(key, display_default), handle_confirm_result) + + def _save_config(self, key: str, value: str) -> None: + """Save config value and update UI.""" + try: + import cognee + + # Parse value types (restore JSON behavior) + try: + parsed_value = json.loads(value) + except (json.JSONDecodeError, TypeError): + # If it looks like a boolean but json didn't catch it + if value.lower() == "true": + parsed_value = True + elif value.lower() == "false": + parsed_value = False + else: + parsed_value = value + + cognee.config.set(key, parsed_value) + self._update_table_row(key, parsed_value) + self.notify(f"✓ Set {key}", severity="information") + + except Exception as e: + self.notify(f"✗ Error setting {key}: {str(e)}", severity="error") + + def _reset_config(self, key: str) -> None: + """Reset config to default using mapped method and update UI.""" + try: + import cognee + + method_name, default_value = self.CONFIG_MAP[key] + + if hasattr(cognee.config, method_name): + method = getattr(cognee.config, method_name) + method(default_value) + + # IMPROVEMENT: Update table immediately + self._update_table_row(key, default_value) + self.notify(f"✓ Reset {key}", severity="information") + else: + self.notify(f"✗ Reset method '{method_name}' not found", severity="error") + + except Exception as e: + self.notify(f"✗ Failed to reset {key}: {str(e)}", severity="error") + + def _update_table_row(self, key: str, value: Any) -> None: + """Helper to update a specific row's value column visually.""" + table = self.query_one(DataTable) + display_val = str(value) if value != "" else "(empty)" + + # 'key' was used as the row_key in add_row, so we can address it directly + # The value column is at index 1 (0 is key, 1 is value) + try: + col_key = list(table.columns.keys())[1] + table.update_cell(key, col_key, display_val) + except Exception: + # Fallback if key update fails, reload all + self._load_table_data() + + +class ConfigTUICommand(SupportsCliCommand): + """TUI command for config management.""" + + command_string = "config-tui" + help_string = "Launch interactive TUI for managing cognee configuration" + docs_url = f"{DEFAULT_DOCS_URL}/usage/config-tui" + + def configure_parser(self, parser: argparse.ArgumentParser) -> None: + pass + + def execute(self, args: argparse.Namespace) -> None: + try: + # Import here to check if Textual is actually installed + from textual.app import App + + class ConfigTUIApp(App): + """Simple app wrapper for config TUI.""" + + CSS = """ + Screen { background: $surface; } + """ + + def on_mount(self) -> None: + self.push_screen(ConfigTUIScreen()) + + app = ConfigTUIApp() + app.run() + + except ImportError: + raise CliCommandException( + "Textual is not installed. Install with: pip install textual", + docs_url=self.docs_url, + ) + except Exception as ex: + raise CliCommandException( + f"Failed to launch config TUI: {str(ex)}", + docs_url=self.docs_url, + raiseable_exception=ex, + ) diff --git a/cognee/cli/tui/delete_screen.py b/cognee/cli/tui/delete_screen.py new file mode 100644 index 000000000..e3abb263b --- /dev/null +++ b/cognee/cli/tui/delete_screen.py @@ -0,0 +1,247 @@ +import asyncio +from uuid import UUID +from textual.app import ComposeResult +from textual.widgets import Input, Button, Static, Label +from textual.containers import Container, Vertical, Horizontal +from textual.binding import Binding +from cognee.cli.tui.base_screen import BaseTUIScreen +from cognee.modules.data.methods.delete_dataset_by_name import delete_dataset_by_name +from cognee.modules.data.methods.delete_data_by_user import delete_data_by_user +from cognee.modules.users.methods import get_default_user + + +class DeleteTUIScreen(BaseTUIScreen): + """Simple delete screen with input fields for dataset name, user ID, or delete all.""" + + BINDINGS = [ + Binding("q", "quit_app", "Quit"), + Binding("escape", "back", "Back"), + Binding("ctrl+s", "delete", "Delete"), + Binding("ctrl+a", "delete_all", "Delete All", priority=True), + ] + + CSS = ( + BaseTUIScreen.CSS + + """ + #button-group { + height: auto; + align: center middle; + margin-top: 2; + } + """ + ) + + def __init__(self): + super().__init__() + self.is_processing = False + + def compose_content(self) -> ComposeResult: + with Container(classes="tui-main-container"): + with Container(classes="tui-title-wrapper"): + yield Static("🗑 Delete Data", classes="tui-title-bordered") + with Vertical(id="delete-form", classes="tui-form"): + with Vertical(classes="tui-input-group"): + yield Label("Dataset Name (optional):", classes="tui-label") + yield Input( + placeholder="Enter dataset name to delete specific dataset", + id="dataset-input", + ) + + with Vertical(classes="tui-input-group"): + yield Label("User ID (optional):", classes="tui-label") + yield Input( + placeholder="Enter user ID to delete user's data or leave empty for default user.", + id="user-input", + ) + + with Horizontal(id="button-group"): + yield Button("Delete", variant="error", id="delete-btn") + yield Button("Delete All", variant="error", id="delete-all-btn") + + yield Static("", classes="tui-status") + + def compose_footer(self) -> ComposeResult: + yield Static( + "Ctrl+s: Delete • Ctrl+a: Delete All • Esc: Back • q: Quit", classes="tui-footer" + ) + + def on_mount(self) -> None: + """Focus the dataset input on mount.""" + dataset_input = self.query_one("#dataset-input", Input) + dataset_input.focus() + + def action_back(self) -> None: + """Go back to home screen.""" + if not self.is_processing: + self.app.pop_screen() + + def action_quit_app(self) -> None: + """Quit the entire application.""" + self.app.exit() + + def action_delete(self) -> None: + """Delete the dataset.""" + if not self.is_processing: + self._handle_delete() + + def action_delete_all(self) -> None: + """Delete all data.""" + if not self.is_processing: + self._handle_delete_all() + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button presses.""" + if self.is_processing: + return + if event.button.id == "delete-btn": + self._handle_delete() + elif event.button.id == "delete-all-btn": + self._handle_delete_all() + elif event.button.id == "cancel-btn": + self.app.pop_screen() + + def _handle_delete(self) -> None: + status = self.query_one(".tui-status", Static) + status.update("🔍 Starting the deletion process...") + """Handle delete operation for dataset or user.""" + if self.is_processing: + return + + dataset_input = self.query_one("#dataset-input", Input) + user_input = self.query_one("#user-input", Input) + + dataset_name = dataset_input.value.strip() or None + user_id = user_input.value.strip() or None + + if not dataset_name and not user_id: + status.update("⚠️ Please enter a dataset name or user ID") + return + + self.is_processing = True + status.update("🔍 Checking data to delete...") + # Run async delete operation + asyncio.create_task(self._delete_async(dataset_name, user_id)) + + async def _delete_async(self, dataset_name: str | None, user_id: str | None) -> None: + """Async function to delete data.""" + status = self.query_one(".tui-status", Static) + try: + if user_id is None: + user = await get_default_user() + resolved_user_id = user.id + else: + resolved_user_id = UUID(user_id) + + if dataset_name: + await delete_dataset_by_name(dataset_name, resolved_user_id) + status.update(f"✓ Successfully deleted dataset '{dataset_name}'.") + else: + await delete_data_by_user(resolved_user_id) + status.update(f"✓ Successfully deleted all data for user {resolved_user_id}.") + except Exception as e: + status.update(f"✗ Error: {str(e)}") + finally: + self.is_processing = False + self.clear_input() + + def _handle_delete_all(self) -> None: + """Handle delete all operation with confirmation.""" + if self.is_processing: + return + user_input = self.query_one("#user-input", Input) + user_id = user_input.value.strip() or None + + def handle_confirm(confirmed: bool) -> None: + if confirmed: + asyncio.create_task(self._perform_delete_all(user_id)) + + self.app.push_screen(DeleteAllConfirmModal(), handle_confirm) + + async def _perform_delete_all(self, user_id: str | None) -> None: + """Perform the actual delete all operation.""" + status = self.query_one(".tui-status", Static) + self.is_processing = True + + try: + status.update("🔍 Deleting all data...") + if user_id is None: + user = await get_default_user() + resolved_user_id = user.id + else: + resolved_user_id = UUID(user_id) + await delete_data_by_user(resolved_user_id) + status.update(f"✓ Successfully deleted all data by user {resolved_user_id}") + + # Clear inputs + dataset_input = self.query_one("#dataset-input", Input) + user_input = self.query_one("#user-input", Input) + dataset_input.value = "" + user_input.value = "" + + except Exception as e: + status.update(f"✗ Error: {str(e)}") + finally: + self.is_processing = False + + def clear_input(self) -> None: + dataset_input = self.query_one("#dataset-input", Input) + user_input = self.query_one("#user-input", Input) + dataset_input.value = "" + user_input.value = "" + + +class DeleteAllConfirmModal(BaseTUIScreen): + """Modal screen for confirming delete all action.""" + + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + ] + + CSS = ( + BaseTUIScreen.CSS + + """ + DeleteAllConfirmModal { + align: center middle; + } + + #confirm-dialog { + width: 60; + height: 20; + border: thick $error; + background: $surface; + padding: 2; + } + + #confirm-title { + text-align: center; + text-style: bold; + color: $error; + margin-bottom: 1; + } + + #confirm-warning { + text-align: center; + color: $warning; + text-style: bold; + margin-bottom: 2; + } + """ + ) + + def compose_content(self) -> ComposeResult: + with Container(id="confirm-dialog"): + yield Label("⚠️ DELETE ALL DATA", id="confirm-title") + yield Label("This will delete ALL data from cognee", classes="tui-dialog-message") + yield Label("This operation is IRREVERSIBLE!", id="confirm-warning") + with Horizontal(classes="tui-dialog-buttons"): + yield Button("Delete All", variant="error", id="confirm-btn") + yield Button("Cancel", variant="default", id="cancel-btn") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "confirm-btn": + self.dismiss(True) + else: + self.dismiss(False) + + def action_cancel(self) -> None: + self.dismiss(False) diff --git a/cognee/cli/tui/home_screen.py b/cognee/cli/tui/home_screen.py new file mode 100644 index 000000000..22165bd1d --- /dev/null +++ b/cognee/cli/tui/home_screen.py @@ -0,0 +1,188 @@ +from textual.app import ComposeResult +from textual.widgets import ListView, ListItem, Static +from textual.containers import Container, Horizontal +from textual.binding import Binding + +from cognee.cli.tui.base_screen import BaseTUIScreen +from cognee.cli.tui.config_screen import ConfigTUIScreen +from cognee.cli.tui.add_screen import AddTUIScreen +from cognee.cli.tui.cognify_screen import CognifyTUIScreen +from cognee.cli.tui.search_screen import SearchTUIScreen +from cognee.cli.tui.delete_screen import DeleteTUIScreen + + +def make_item(icon: str, command: str, description: str) -> ListItem: + """Compose a ListItem that contains a Horizontal container with 3 children.""" + return ListItem( + Horizontal( + Static(icon, classes="cmd-icon"), + Static(command, classes="cmd-name"), + Static(description, classes="cmd-desc"), + classes="cmd-row", + ) + ) + + +class HomeScreen(BaseTUIScreen): + """Home screen with command selection menu.""" + + BINDINGS = [ + Binding("q", "quit_app", "Quit"), + Binding("escape", "quit_app", "Quit"), + Binding("enter", "select", "Select"), + Binding("up", "nav_up", "Up", priority=True), + Binding("down", "nav_down", "Down", priority=True), + ] + + CSS = ( + BaseTUIScreen.CSS + + """ + ListView > ListItem { + width: 100%; + padding: 0; + margin: 0; + } + + .menu-list > ListItem { + width: 100%; + padding: 0; + margin: 0; + } + + .menu-list { + height: auto; + background: $surface; + border: none; + padding: 0 0; + } + + ListView { + height: auto; + background: $surface; + border: none; + padding: 0 0; + } + + ListItem { + background: $surface; + color: $text; + width: 100%; + height: 3; + } + + ListItem:focus { + outline: none; + } + + ListItem.highlighted { + background: $primary-darken-3; + color: $text; + } + ListItem.highlighted .cmd-name { + text-style: bold; + color: $accent; + } + + .cmd-row { + width: 100%; + height: auto; + align-horizontal: left; + align-vertical: middle; + height: 1fr; + } + + .cmd-icon { + width: 4; + text-align: center; + color: $text-muted; + } + + .cmd-name { + width: 14; + padding-left: 1; + text-style: bold; + } + + .cmd-desc { + width: 1fr; + overflow: auto; + padding-left: 1; + color: $text-muted; + } + """ + ) + + def __init__(self): + super().__init__() + self.lv = None + self.current_index = 0 + + def compose_content(self) -> ComposeResult: + with Container(classes="tui-main-container"): + with Container(classes="tui-title-wrapper"): + yield Static("Select Command", classes="tui-title-bordered") + with Container(classes="tui-bordered-wrapper"): + yield ListView( + make_item("📥", "add", "Add data to cognee"), + make_item("🔍", "search", "Search data in cognee"), + make_item("⚡", "cognify", "Process data in cognee"), + make_item("🗑️", "delete", "Delete data from cognee"), + make_item("⚙️", "config", "Configure cognee settings"), + id="menu-list", + classes="menu-list", + ) + + def compose_footer(self) -> ComposeResult: + yield Static("↑↓: Navigate • Enter: Select • q/Esc: Quit", classes="tui-footer") + + def on_mount(self) -> None: + """Focus the list view on mount.""" + self.lv = self.query_one(ListView) + self.current_index = 0 + self.set_focus(self.lv) + self._apply_highlight() + + def _apply_highlight(self) -> None: + lv = self.lv + children = list(lv.children) + self.lv.index = self.current_index + for idx, item in enumerate(children): + if idx == self.current_index: + item.add_class("highlighted") + else: + item.remove_class("highlighted") + + def action_nav_up(self) -> None: + self.current_index = max(0, self.current_index - 1) + self._apply_highlight() + + def action_nav_down(self) -> None: + children = list(self.lv.children) + self.current_index = min(len(children) - 1, self.current_index + 1) + self._apply_highlight() + + def on_list_view_selected(self, event: ListView.Selected) -> None: + selected_index = event.index + self.current_index = selected_index + self._apply_highlight() + if selected_index == 0: # add + self.app.push_screen(AddTUIScreen()) + elif selected_index == 1: # search + self.app.push_screen(SearchTUIScreen()) + elif selected_index == 2: # cognify + self.app.push_screen(CognifyTUIScreen()) + elif selected_index == 3: # delete + self.app.push_screen(DeleteTUIScreen()) + elif selected_index == 4: # config + self.app.push_screen(ConfigTUIScreen()) + else: + self.app.exit() + + def action_select(self) -> None: + """Select the current item.""" + list_view = self.query_one(ListView) + list_view.action_select_cursor() + + def action_quit_app(self) -> None: + """Quit the entire application.""" + self.app.exit() diff --git a/cognee/cli/tui/search_screen.py b/cognee/cli/tui/search_screen.py new file mode 100644 index 000000000..d70d29bd1 --- /dev/null +++ b/cognee/cli/tui/search_screen.py @@ -0,0 +1,179 @@ +import asyncio +from textual.app import ComposeResult +from textual.widgets import Input, Label, Static, Select +from textual.containers import Container, Vertical, ScrollableContainer +from textual.binding import Binding +from cognee.cli.tui.base_screen import BaseTUIScreen + + +class SearchTUIScreen(BaseTUIScreen): + """Simple search screen with query input and results display.""" + + BINDINGS = [ + Binding("q", "quit_app", "Quit"), + Binding("escape", "back", "Back"), + Binding("ctrl+s", "search", "Search"), + ] + + CSS = ( + BaseTUIScreen.CSS + + """ + #search-form { + height: auto; + border: solid $primary; + padding: 1; + margin-bottom: 1; + } + + #search-form Label { + margin-bottom: 0; + color: $text-muted; + } + + #search-form Input, #search-form Select { + margin-bottom: 1; + } + + #results-container { + height: 1fr; + border: solid $primary; + padding: 1; + } + + #results-title { + text-style: bold; + color: $accent; + margin-bottom: 1; + } + + #results-content { + height: 1fr; + overflow-y: auto; + } + """ + ) + + def __init__(self): + super().__init__() + self.is_searching = False + + def compose_content(self) -> ComposeResult: + with Container(classes="tui-main-container"): + with Container(classes="tui-title-wrapper"): + yield Static("🔍 Search Data", classes="tui-title-bordered") + with Vertical(id="search-form"): + yield Label("Query:", classes="tui-label-spaced") + yield Input(placeholder="Enter your search query...", id="query-input") + yield Label("Search Type:", classes="tui-label-spaced") + yield Select( + [ + ("Graph Completion (Recommended)", "GRAPH_COMPLETION"), + ("RAG Completion", "RAG_COMPLETION"), + ("Chunks", "CHUNKS"), + ("Summaries", "SUMMARIES"), + ("Coding Rules", "CODING_RULES"), + ], + value="GRAPH_COMPLETION", + id="query-type-select", + ) + with Container(id="results-container"): + yield Static("Results", id="results-title") + with ScrollableContainer(id="results-content"): + yield Static( + "Enter a query and click Search to see results.", id="results-text" + ) + + def compose_footer(self) -> ComposeResult: + yield Static("Ctrl+S: Search • Esc: Back • q: Quit", classes="tui-footer") + + def on_mount(self) -> None: + """Focus the query input on mount.""" + query_input = self.query_one("#query-input", Input) + query_input.focus() + + def action_back(self) -> None: + """Go back to home screen.""" + self.app.pop_screen() + + def action_quit_app(self) -> None: + """Quit the entire application.""" + self.app.exit() + + def action_search(self) -> None: + """Trigger search action.""" + if not self.is_searching: + self._perform_search() + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Handle Enter key in query input.""" + if event.input.id == "query-input": + self._perform_search() + + def _perform_search(self) -> None: + """Perform the search operation.""" + if self.is_searching: + return + + query_input = self.query_one("#query-input", Input) + query_text = query_input.value.strip() + + if not query_text: + self.notify("Please enter a search query", severity="warning") + return + + query_type_select = self.query_one("#query-type-select", Select) + query_type = str(query_type_select.value) + + self.is_searching = True + self.notify(f"Searching for: {query_text}", severity="information") + + # Update results to show loading + results_text = self.query_one("#results-text", Static) + results_text.update("🔍 Searching...") + + # Run async search + asyncio.create_task(self._async_search(query_text, query_type)) + + async def _async_search(self, query_text: str, query_type: str) -> None: + """Async search operation.""" + try: + import cognee + from cognee.modules.search.types import SearchType + + # Convert string to SearchType enum + search_type = SearchType[query_type] + # Perform search + results = await cognee.search( + query_text=query_text, + query_type=search_type, + system_prompt_path="answer_simple_question.txt", + top_k=10, + ) + + # Update results display + results_text = self.query_one("#results-text", Static) + + if not results: + results_text.update("No results found for your query.") + else: + # Format results based on type + if query_type in ["GRAPH_COMPLETION", "RAG_COMPLETION"]: + formatted = "\n\n".join([f"📝 {result}" for result in results]) + elif query_type == "CHUNKS": + formatted = "\n\n".join( + [f"📄 Chunk {i + 1}:\n{result}" for i, result in enumerate(results)] + ) + else: + formatted = "\n\n".join([f"• {result}" for result in results]) + + results_text.update(formatted) + + self.notify(f"✓ Found {len(results)} result(s)", severity="information") + + except Exception as e: + results_text = self.query_one("#results-text", Static) + results_text.update(f"❌ Error: {str(e)}") + self.notify(f"Search failed: {str(e)}", severity="error") + + finally: + self.is_searching = False diff --git a/cognee/infrastructure/databases/relational/sqlalchemy/SqlAlchemyAdapter.py b/cognee/infrastructure/databases/relational/sqlalchemy/SqlAlchemyAdapter.py index 37ceb170d..6908694d4 100644 --- a/cognee/infrastructure/databases/relational/sqlalchemy/SqlAlchemyAdapter.py +++ b/cognee/infrastructure/databases/relational/sqlalchemy/SqlAlchemyAdapter.py @@ -3,8 +3,7 @@ import asyncio from os import path import tempfile from uuid import UUID -from typing import Optional -from typing import AsyncGenerator, List +from typing import Optional, AsyncGenerator, List, Union from contextlib import asynccontextmanager from sqlalchemy.orm import joinedload from sqlalchemy.exc import NoResultFound @@ -257,35 +256,46 @@ class SQLAlchemyAdapter: return [schema[0] for schema in result.fetchall()] return [] - async def delete_entity_by_id( - self, table_name: str, data_id: UUID, schema_name: Optional[str] = "public" + async def delete_entities_by_id( + self, + table_name: str, + data_id: Union[UUID, List[UUID]], # Supports a single UUID or a List of UUIDs + schema_name: Optional[str] = "public", ): """ - Delete an entity from the specified table based on its unique ID. + Delete one or more entities from the specified table based on their ID(s). Parameters: ----------- - - - table_name (str): The name of the table from which to delete the entity. - - data_id (UUID): The unique identifier of the entity to be deleted. - - schema_name (Optional[str]): The name of the schema where the table resides, - defaults to 'public'. (default 'public') + - table_name (str): The name of the table from which to delete the entities. + - data_id (Union[UUID, List[UUID]]): The unique identifier(s) to be deleted. + - schema_name (Optional[str]): The name of the schema where the table resides. """ - if self.engine.dialect.name == "sqlite": - async with self.get_async_session() as session: - TableModel = await self.get_table(table_name, schema_name) - # Foreign key constraints are disabled by default in SQLite (for backwards compatibility), - # so must be enabled for each database connection/session separately. + # Ensure data_ids is a list for the WHERE clause logic + if isinstance(data_id, list): + data_ids_to_delete = data_id + else: + data_ids_to_delete = [data_id] + + if not data_ids_to_delete: + return + + async with self.get_async_session() as session: + TableModel = await self.get_table(table_name, schema_name) + + # Handle SQLite's foreign key requirement + if self.engine.dialect.name == "sqlite": + from sqlalchemy import text + await session.execute(text("PRAGMA foreign_keys = ON;")) - await session.execute(TableModel.delete().where(TableModel.c.id == data_id)) - await session.commit() - else: - async with self.get_async_session() as session: - TableModel = await self.get_table(table_name, schema_name) - await session.execute(TableModel.delete().where(TableModel.c.id == data_id)) - await session.commit() + # Construct the DELETE statement using the 'in_()' operator + stmt = TableModel.delete().where(TableModel.c.id.in_(data_ids_to_delete)) + + # Execute and commit + await session.execute(stmt) + await session.commit() async def delete_data_entity(self, data_id: UUID): """ diff --git a/cognee/modules/data/methods/__init__.py b/cognee/modules/data/methods/__init__.py index 7936a9afd..da8d64dea 100644 --- a/cognee/modules/data/methods/__init__.py +++ b/cognee/modules/data/methods/__init__.py @@ -16,6 +16,8 @@ from .get_dataset_ids import get_dataset_ids # Delete from .delete_dataset import delete_dataset +from .delete_dataset_by_name import delete_dataset_by_name +from .delete_data_by_user import delete_data_by_user from .delete_data import delete_data # Create diff --git a/cognee/modules/data/methods/delete_data_by_user.py b/cognee/modules/data/methods/delete_data_by_user.py new file mode 100644 index 000000000..4358bd8e1 --- /dev/null +++ b/cognee/modules/data/methods/delete_data_by_user.py @@ -0,0 +1,33 @@ +from uuid import UUID +from sqlalchemy import select +from cognee.infrastructure.databases.relational import get_relational_engine +from cognee.modules.data.models import Dataset +from cognee.modules.users.methods import get_user +from cognee.shared.logging_utils import get_logger + +logger = get_logger() + + +async def delete_data_by_user(user_id: UUID): + """ + Delete all datasets and their associated data for a specific user. + + This function performs a comprehensive deletion of all data owned by a user, + including datasets, data entries, and all related records in the database. + + Args: + user_id: UUID of the user whose data should be deleted + + Raises: + EntityNotFoundError: If user is not found + """ + db_engine = get_relational_engine() + + async with db_engine.get_async_session() as session: + # Verify user exists + await get_user(user_id) + # Get all datasets owned by this user + datasets_query = select(Dataset.id).where(Dataset.owner_id == user_id) + user_datasets_ids = (await session.execute(datasets_query)).scalars().all() + if user_datasets_ids: + await db_engine.delete_entities_by_id(Dataset.__table__.name, user_datasets_ids) diff --git a/cognee/modules/data/methods/delete_dataset_by_name.py b/cognee/modules/data/methods/delete_dataset_by_name.py new file mode 100644 index 000000000..7c5ed06b8 --- /dev/null +++ b/cognee/modules/data/methods/delete_dataset_by_name.py @@ -0,0 +1,28 @@ +from uuid import UUID +from sqlalchemy import select +from cognee.infrastructure.databases.relational import get_relational_engine +from ..models import Dataset + + +async def delete_dataset_by_name(dataset_name: str, user_id: UUID): + """ + Delete a single dataset by name for a specific user. + + Args: + dataset_name: The name of the dataset to delete (must be a single string). + user_id: UUID of the dataset owner. + + """ + db_engine = get_relational_engine() + + async with db_engine.get_async_session() as session: + dataset_id = ( + await session.scalars( + select(Dataset.id) + .filter(Dataset.owner_id == user_id) + .filter(Dataset.name == dataset_name) + ) + ).first() + # Keeping this out of the first session, since delete_entities_by_id creates another session. + if dataset_id: + await db_engine.delete_entities_by_id(Dataset.__table__.name, dataset_id) diff --git a/cognee/shared/logging_utils.py b/cognee/shared/logging_utils.py index 70a0bd37e..e2a72cc0e 100644 --- a/cognee/shared/logging_utils.py +++ b/cognee/shared/logging_utils.py @@ -285,7 +285,7 @@ def cleanup_old_logs(logs_dir, max_files): return False -def setup_logging(log_level=None, name=None): +def setup_logging(log_level=None, name=None, enable_console_logging=True): """Sets up the logging configuration with structlog integration. Args: @@ -465,7 +465,8 @@ def setup_logging(log_level=None, name=None): root_logger = logging.getLogger() if root_logger.hasHandlers(): root_logger.handlers.clear() - root_logger.addHandler(stream_handler) + if enable_console_logging: + root_logger.addHandler(stream_handler) # Note: root logger needs to be set at NOTSET to allow all messages through and specific stream and file handlers # can define their own levels. diff --git a/pyproject.toml b/pyproject.toml index 1fff69c85..18bb73b10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -161,6 +161,7 @@ redis = ["redis>=5.0.3,<6.0.0"] monitoring = ["sentry-sdk[fastapi]>=2.9.0,<3", "langfuse>=2.32.0,<3"] docling = ["docling>=2.54", "transformers>=4.55"] +textual = ["textual>=6.6.0"] [project.urls] Homepage = "https://www.cognee.ai" diff --git a/uv.lock b/uv.lock index 812315288..bb7753bd5 100644 --- a/uv.lock +++ b/uv.lock @@ -1093,6 +1093,9 @@ scraping = [ { name = "protego" }, { name = "tavily-python" }, ] +textual = [ + { name = "textual" }, +] [package.metadata] requires-dist = [ @@ -1193,6 +1196,7 @@ requires-dist = [ { name = "structlog", specifier = ">=25.2.0,<26" }, { name = "tavily-python", marker = "extra == 'scraping'", specifier = ">=0.7.12" }, { name = "tenacity", specifier = ">=9.0.0" }, + { name = "textual", marker = "extra == 'textual'", specifier = ">=6.6.0" }, { name = "tiktoken", specifier = ">=0.8.0,<1.0.0" }, { name = "transformers", marker = "extra == 'codegraph'", specifier = ">=4.46.3,<5" }, { name = "transformers", marker = "extra == 'docling'", specifier = ">=4.55" }, @@ -1207,7 +1211,7 @@ requires-dist = [ { name = "uvicorn", specifier = ">=0.34.0,<1.0.0" }, { name = "websockets", specifier = ">=15.0.1,<16.0.0" }, ] -provides-extras = ["api", "distributed", "scraping", "neo4j", "neptune", "postgres", "postgres-binary", "notebook", "langchain", "llama-index", "huggingface", "ollama", "mistral", "anthropic", "deepeval", "posthog", "groq", "llama-cpp", "chromadb", "docs", "codegraph", "evals", "graphiti", "aws", "dlt", "baml", "dev", "debug", "redis", "monitoring", "docling"] +provides-extras = ["api", "distributed", "scraping", "neo4j", "neptune", "postgres", "postgres-binary", "notebook", "langchain", "llama-index", "huggingface", "ollama", "mistral", "anthropic", "deepeval", "posthog", "groq","llama-cpp", "chromadb", "docs", "codegraph", "evals", "graphiti", "aws", "dlt", "baml", "dev", "debug", "redis", "monitoring", "docling", "textual"] [[package]] name = "colorama" @@ -3802,6 +3806,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/c9/556846b9d112a3387397850d5560f5ec63464508c6aa068257f0516159d0/limits-4.8.0-py3-none-any.whl", hash = "sha256:de43d24969a0050b859dd29bbd61bd807a5de3ed9255f666aec1ea3dd3fc407e", size = 62028, upload-time = "2025-04-23T21:00:26.017Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, +] + [[package]] name = "litellm" version = "1.80.0" @@ -4157,6 +4173,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + [[package]] name = "marko" version = "2.2.2" @@ -4323,6 +4344,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -8622,6 +8655,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, ] +[[package]] +name = "textual" +version = "6.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/2f/f0b408f227edca21d1996c1cd0b65309f0cbff44264aa40aded3ff9ce2e1/textual-6.6.0.tar.gz", hash = "sha256:53345166d6b0f9fd028ed0217d73b8f47c3a26679a18ba3b67616dcacb470eec", size = 1579327, upload-time = "2025-11-10T17:50:00.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/b3/95ab646b0c908823d71e49ab8b5949ec9f33346cee3897d1af6be28a8d91/textual-6.6.0-py3-none-any.whl", hash = "sha256:5a9484bd15ee8a6fd8ac4ed4849fb25ee56bed2cecc7b8a83c4cd7d5f19515e5", size = 712606, upload-time = "2025-11-10T17:49:58.391Z" }, +] + [[package]] name = "threadpoolctl" version = "3.6.0" @@ -9149,6 +9199,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, ] +[[package]] +name = "uc-micro-py" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, +] + [[package]] name = "unstructured" version = "0.18.27"