"""Interactive CLI adapter over the async QueryEngine. This module is deliberately thin: it collects human input, supplies an interactive approval policy, drives the engine, and renders the result. All SQL logic lives in the engine and the sql/* modules. Author: mohamedgamal04 """ from __future__ import annotations import asyncio import json import re from collections.abc import Callable from pathlib import Path import pandas as pd from rich.console import Console from rich import box from rich.panel import Panel from rich.prompt import Prompt from rich.syntax import Syntax from .cli import is_quit_command, normalize_prompt_input from .core.engine import QueryEngine from .core.models import EngineConfig, EngineResult, StatementResult, WritebackTarget from .core.policy import Policy from .logger import append_log from .sql.preview import print_dataframe_as_table, print_sql_statements_table def _print_user_prompt(console: Console, prompt: str) -> None: """Render the user's prompt in a colorful boxed panel.""" console.print( Panel( prompt, title="[bold bright_cyan]You[/bold bright_cyan]", border_style="bright_cyan", box=box.ROUNDED, padding=(0, 1), ) ) def _print_llm_response(console: Console, output: str, provider_name: str, model_name: str) -> None: """Render the model response in a colorful boxed panel.""" try: parsed_output = json.loads(output) renderable = Syntax( json.dumps(parsed_output, indent=2, ensure_ascii=False), "json", theme="monokai", word_wrap=True, ) except Exception: renderable = output console.print( Panel( renderable, title=f"[bold bright_magenta]{provider_name}[/bold bright_magenta] [dim]({model_name})[/dim]", border_style="bright_magenta", box=box.DOUBLE, padding=(0, 1), ) ) async def _ask(console: Console, *args, **kwargs) -> str: """Run a blocking rich prompt off the event loop.""" return await asyncio.to_thread(Prompt.ask, *args, console=console, **kwargs) class InteractivePolicy(Policy): """Approval policy that asks the human at the terminal.""" def __init__(self, console: Console) -> None: self._console = console async def approve_execution(self, statements: list[StatementResult]) -> bool: print_sql_statements_table([statement.sql for statement in statements], self._console) choice = await _ask( self._console, "Execute these SQL statements in the CLI now?", choices=["y", "n"], default="n", ) return choice.strip().lower() in {"y", "yes"} async def approve_writeback(self, target: WritebackTarget) -> bool: choice = await _ask( self._console, f"Save changes to {target.file_path.name} (sheet '{target.sheet_name}', " f"{target.affected_rows} row(s))?", choices=["y", "n"], default="n", ) return choice.strip().lower() in {"y", "yes"} def _render_result(console: Console, result: EngineResult, provider_name: str, model_name: str) -> None: """Render an EngineResult: raw output, per-statement previews, and errors.""" if result.raw_llm_output: _print_llm_response(console, result.raw_llm_output, provider_name, model_name) if result.error is not None: console.print(f"[red]Error:[/red] {result.error}") return for statement in result.statements: if statement.error is not None: console.print(f"[red]Refused/failed:[/red] {statement.error}") console.print(f"Skipped statement: {statement.sql}") continue if statement.kind == "select": dataframe = pd.DataFrame(statement.rows, columns=statement.columns or None) print_dataframe_as_table(dataframe, console) if statement.truncated: console.print(f"Showing first {len(statement.rows)} of {statement.row_count} rows.") elif statement.row_count == 0: if re.search(r"\bjoin\b", statement.sql, flags=re.IGNORECASE): console.print( "[yellow]The join matched no rows.[/yellow] The two sheets may not share a " "matching key column, so there is nothing to join on." ) else: console.print( "[yellow]The query ran successfully but matched no rows.[/yellow] " "Check the filter or column values." ) else: console.print(f"[green]{statement.kind.upper()}[/green] affected {statement.row_count} row(s).") if result.wrote_back: console.print("[green]Changes saved.[/green]") elif result.writeback_targets: console.print("[yellow]Changes not saved.[/yellow]") def run_chat_session( console: Console, provider_name: str, provider_base_url: str, model_name: str, api_key: str, initial_prompt: str, system_prompt_provider: Callable[[], str], excel_file_count_provider: Callable[[], int], excel_dir: str | Path, ) -> None: """Run the interactive prompt loop driven by the async engine.""" asyncio.run( _run_loop( console=console, provider_name=provider_name, provider_base_url=provider_base_url, model_name=model_name, api_key=api_key, initial_prompt=initial_prompt, system_prompt_provider=system_prompt_provider, excel_file_count_provider=excel_file_count_provider, excel_dir=excel_dir, ) ) async def _run_loop( console: Console, provider_name: str, provider_base_url: str, model_name: str, api_key: str, initial_prompt: str, system_prompt_provider: Callable[[], str], excel_file_count_provider: Callable[[], int], excel_dir: str | Path, ) -> None: """Async interactive loop: read prompt, run engine, render, repeat.""" policy = InteractivePolicy(console) prompt = initial_prompt while True: while not prompt: prompt = (await _ask(console, "You")).strip() if prompt.startswith("-"): console.print("Please prefix options with [cyan]qq[/cyan] or [cyan]QQ[/cyan] (example: [cyan]qq -q[/cyan]).") prompt = "" continue prompt, prompt_flag_only = normalize_prompt_input(prompt) if prompt_flag_only: console.print("Please provide prompt text after -p/--prompt.") prompt = "" continue if is_quit_command(prompt): append_log({"event": "quit", "provider": provider_name, "model": model_name}) console.print("Goodbye.") return _print_user_prompt(console, prompt) excel_file_count = excel_file_count_provider() if excel_file_count == 0: # No data source: never call the model or suggest SQL. console.print( "[yellow]No Excel files available[/yellow] in the selected directory. " "Add a workbook and try again." ) append_log( { "event": "llm_skipped_no_files", "provider": provider_name, "model": model_name, "input_chars": len(prompt), } ) prompt = "" continue system_prompt = system_prompt_provider() config = EngineConfig( base_url=provider_base_url, api_key=api_key, model=model_name, provider_name=provider_name, system_prompt=system_prompt, excel_dir=Path(excel_dir), excel_files_count=excel_file_count, ) engine = QueryEngine(config) result = await engine.run(prompt, policy) append_log( { "event": "llm_success" if result.error is None else "llm_error", "provider": provider_name, "model": model_name, "input_chars": len(prompt), "system_prompt_chars": len(system_prompt), "output_chars": len(result.raw_llm_output), "sql_statement_count": len(result.statements), "excel_file_count": excel_file_count, "executed": result.executed, "wrote_back": result.wrote_back, "error": result.error, } ) _render_result(console, result, provider_name, model_name) prompt = ""