File size: 8,845 Bytes
0cac9cf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
"""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 = ""