Skip to content

Commit 132a1db

Browse files
authored
Merge pull request #227 from dbcli/amjith/llm-improvements
Modify \llm to use either + or - modifiers.
2 parents 5b26c0a + c9900a5 commit 132a1db

7 files changed

Lines changed: 133 additions & 84 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44

55
* Replace sqlite3 library with [sqlean](https://antonz.org/sqlean/). It's a drop-in replacement for sqlite3.
66
* Add support for `.output` to write the results to a file.
7+
* The 'llm' library is now a default dependency not installed on demand.
8+
* The `\llm` command now has three modes. Succinct, Regular and Verbose.
9+
10+
Succinct = `\llm-` - This will return just the sql query. No explanation.
11+
Regular = `\llm` - This will return just the sql query and the explanation.
12+
Verbose = `\llm+` - This will print the prompt sent to the LLM and the sql query and the explanation.
713

814
### Bug Fixes
915

litecli/packages/special/llm.py

Lines changed: 64 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,29 @@
22
import io
33
import logging
44
import os
5+
import pprint
56
import re
67
import shlex
78
import sys
89
from runpy import run_module
9-
from typing import Optional, Tuple
1010
from time import time
11+
from typing import Optional, Tuple
1112

1213
import click
13-
14-
try:
15-
import llm
16-
from llm.cli import cli
17-
18-
LLM_CLI_COMMANDS = list(cli.commands.keys())
19-
MODELS = {x.model_id: None for x in llm.get_models()}
20-
except ImportError:
21-
llm = None
22-
cli = None
23-
LLM_CLI_COMMANDS = []
24-
MODELS = {}
14+
import llm
15+
from llm.cli import cli
2516

2617
from . import export
27-
from .main import parse_special_command
18+
from .main import Verbosity, parse_special_command
2819

2920
log = logging.getLogger(__name__)
3021

22+
LLM_TEMPLATE_NAME = "litecli-llm-template"
23+
LLM_CLI_COMMANDS = list(cli.commands.keys())
24+
MODELS = {x.model_id: None for x in llm.get_models()}
25+
3126

32-
def run_external_cmd(cmd, *args, capture_output=False, restart_cli=False, raise_exception=True):
27+
def run_external_cmd(cmd, *args, capture_output=False, restart_cli=False, raise_exception=True) -> Tuple[int, str]:
3328
original_exe = sys.executable
3429
original_args = sys.argv
3530

@@ -55,6 +50,13 @@ def run_external_cmd(cmd, *args, capture_output=False, restart_cli=False, raise_
5550
raise RuntimeError(buffer.getvalue())
5651
else:
5752
raise RuntimeError(f"Command {cmd} failed with exit code {code}.")
53+
except Exception as e:
54+
code = 1
55+
if raise_exception:
56+
if capture_output:
57+
raise RuntimeError(buffer.getvalue())
58+
else:
59+
raise RuntimeError(f"Command {cmd} failed: {e}")
5860

5961
if restart_cli and code == 0:
6062
os.execv(original_exe, [original_exe] + original_args)
@@ -153,45 +155,46 @@ def __init__(self, results=None):
153155
"""
154156

155157
_SQL_CODE_FENCE = r"```sql\n(.*?)\n```"
156-
PROMPT = """A SQLite database has the following schema:
158+
PROMPT = """
159+
You are a helpful assistant who is a SQLite expert. You are embedded in a SQLite
160+
cli tool called litecli.
157161
158-
$db_schema
162+
Answer this question:
163+
164+
$question
159165
160-
Here is a sample row of data from each table: $sample_data
166+
Use the following context if it is relevant to answering the question. If the
167+
question is not about the current database then ignore the context.
161168
162-
Use the provided schema and the sample data to construct a SQL query that
163-
can be run in SQLite3 to answer
169+
You are connected to a SQLite database with the following schema:
164170
165-
$question
171+
$db_schema
172+
173+
Here is a sample row of data from each table:
174+
175+
$sample_data
166176
167-
Explain the reason for choosing each table in the SQL query you have
168-
written. Keep the explanation concise.
169-
Finally include a sql query in a code fence such as this one:
177+
If the answer can be found using a SQL query, include a sql query in a code
178+
fence such as this one:
170179
171180
```sql
172181
SELECT count(*) FROM table_name;
173182
```
183+
Keep your explanation concise and focused on the question asked.
174184
"""
175185

176186

177-
def initialize_llm():
178-
# Initialize the LLM library.
179-
if click.confirm("This feature requires additional libraries. Install LLM library?", default=True):
180-
click.echo("Installing LLM library. Please wait...")
181-
run_external_cmd("pip", "install", "--quiet", "llm", restart_cli=True)
182-
183-
184187
def ensure_litecli_template(replace=False):
185188
"""
186189
Create a template called litecli with the default prompt.
187190
"""
188191
if not replace:
189192
# Check if it already exists.
190-
code, _ = run_external_cmd("llm", "templates", "show", "litecli", capture_output=True, raise_exception=False)
193+
code, _ = run_external_cmd("llm", "templates", "show", LLM_TEMPLATE_NAME, capture_output=True, raise_exception=False)
191194
if code == 0: # Template already exists. No need to create it.
192195
return
193196

194-
run_external_cmd("llm", PROMPT, "--save", "litecli")
197+
run_external_cmd("llm", PROMPT, "--save", LLM_TEMPLATE_NAME)
195198
return
196199

197200

@@ -205,12 +208,10 @@ def handle_llm(text, cur) -> Tuple[str, Optional[str], float]:
205208
FinishIteration() which will be caught by the main loop AND print any
206209
output that was supplied (or None).
207210
"""
208-
_, verbose, arg = parse_special_command(text)
209-
210-
# LLM is not installed.
211-
if llm is None:
212-
initialize_llm()
213-
raise FinishIteration(None)
211+
# Determine invocation mode: regular, verbose (+), or succinct (-)
212+
_, mode, arg = parse_special_command(text)
213+
is_verbose = mode is Verbosity.VERBOSE
214+
is_succinct = mode is Verbosity.SUCCINCT
214215

215216
if not arg.strip(): # No question provided. Print usage and bail.
216217
output = [(None, None, None, USAGE)]
@@ -268,20 +269,23 @@ def handle_llm(text, cur) -> Tuple[str, Optional[str], float]:
268269
output = [(None, None, None, result)]
269270
raise FinishIteration(output)
270271

271-
return result if verbose else "", sql, end - start
272+
context = "" if is_succinct else result
273+
return context, sql, end - start
272274
else:
273275
run_external_cmd("llm", *args, restart_cli=restart)
274276
raise FinishIteration(None)
275277

276278
try:
277279
ensure_litecli_template()
278-
# Measure end to end llm command invocation.
279-
# This measures the internal DB command to pull the schema and llm command
280+
# Measure end-to-end LLM command invocation (schema gathering and LLM call)
280281
start = time()
281-
context, sql = sql_using_llm(cur=cur, question=arg, verbose=verbose)
282+
result, sql, prompt_text = sql_using_llm(cur=cur, question=arg, verbose=is_verbose)
282283
end = time()
283-
if not verbose:
284-
context = ""
284+
context = "" if is_succinct else result
285+
if is_verbose and prompt_text is not None:
286+
click.echo("LLM Prompt:")
287+
click.echo(prompt_text)
288+
click.echo("---")
285289
return context, sql, end - start
286290
except Exception as e:
287291
# Something went wrong. Raise an exception and bail.
@@ -298,7 +302,7 @@ def is_llm_command(command) -> bool:
298302

299303

300304
@export
301-
def sql_using_llm(cur, question=None, verbose=False) -> Tuple[str, Optional[str]]:
305+
def sql_using_llm(cur, question=None, verbose=False) -> Tuple[str, Optional[str], Optional[str]]:
302306
if cur is None:
303307
raise RuntimeError("Connect to a datbase and try again.")
304308
schema_query = """
@@ -331,7 +335,7 @@ def sql_using_llm(cur, question=None, verbose=False) -> Tuple[str, Optional[str]
331335

332336
args = [
333337
"--template",
334-
"litecli",
338+
LLM_TEMPLATE_NAME,
335339
"--param",
336340
"db_schema",
337341
db_schema,
@@ -347,9 +351,16 @@ def sql_using_llm(cur, question=None, verbose=False) -> Tuple[str, Optional[str]
347351
_, result = run_external_cmd("llm", *args, capture_output=True)
348352
click.echo("Received response from the llm command")
349353
match = re.search(_SQL_CODE_FENCE, result, re.DOTALL)
350-
if match:
351-
sql = match.group(1).strip()
352-
else:
353-
sql = ""
354-
355-
return result, sql
354+
sql = match.group(1).strip() if match else ""
355+
356+
# When verbose, build and return the rendered prompt text
357+
prompt_text = None
358+
if verbose:
359+
# Render the prompt by substituting schema, sample_data, and question
360+
prompt_text = PROMPT
361+
prompt_text = prompt_text.replace("$db_schema", db_schema)
362+
prompt_text = prompt_text.replace("$sample_data", pprint.pformat(sample_data))
363+
prompt_text = prompt_text.replace("$question", question or "")
364+
if verbose:
365+
return result, sql, prompt_text
366+
return result, sql, None

litecli/packages/special/main.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from collections import namedtuple
44

55
from . import export
6+
from enum import Enum
67

78
log = logging.getLogger(__name__)
89

@@ -36,12 +37,29 @@ class CommandNotFound(Exception):
3637
pass
3738

3839

40+
class Verbosity(Enum):
41+
"""Invocation verbosity: succinct (-), normal, or verbose (+)."""
42+
43+
SUCCINCT = "succinct"
44+
NORMAL = "normal"
45+
VERBOSE = "verbose"
46+
47+
3948
@export
4049
def parse_special_command(sql):
50+
"""
51+
Parse a special command, extracting the base command name, verbosity
52+
(normal, verbose (+), or succinct (-)), and the remaining argument.
53+
Mirrors mycli's behavior.
54+
"""
4155
command, _, arg = sql.partition(" ")
42-
verbose = "+" in command
43-
command = command.strip().replace("+", "")
44-
return (command, verbose, arg.strip())
56+
verbosity = Verbosity.NORMAL
57+
if "+" in command:
58+
verbosity = Verbosity.VERBOSE
59+
elif "-" in command:
60+
verbosity = Verbosity.SUCCINCT
61+
command = command.strip().strip("+-")
62+
return (command, verbosity, arg.strip())
4563

4664

4765
@export
@@ -101,7 +119,7 @@ def execute(cur, sql):
101119
"""Execute a special command and return the results. If the special command
102120
is not supported a KeyError will be raised.
103121
"""
104-
command, verbose, arg = parse_special_command(sql)
122+
command, verbosity, arg = parse_special_command(sql)
105123

106124
if (command not in COMMANDS) and (command.lower() not in COMMANDS):
107125
raise CommandNotFound
@@ -116,7 +134,7 @@ def execute(cur, sql):
116134
if special_cmd.arg_type == NO_QUERY:
117135
return special_cmd.handler()
118136
elif special_cmd.arg_type == PARSED_QUERY:
119-
return special_cmd.handler(cur=cur, arg=arg, verbose=verbose)
137+
return special_cmd.handler(cur=cur, arg=arg, verbose=(verbosity == Verbosity.VERBOSE))
120138
elif special_cmd.arg_type == RAW_QUERY:
121139
return special_cmd.handler(cur=cur, query=sql)
122140

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies = [
1616
"sqlparse>=0.4.4",
1717
"setuptools", # Required by llm commands to install models
1818
"pip",
19+
"llm>=0.25.0",
1920
]
2021

2122
[build-system]

tests/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from __future__ import print_function
22

33
import os
4+
45
import pytest
56
from utils import create_db, db_connection, drop_tables
7+
68
import litecli.sqlexecute
79

810

tests/test_llm_special.py

Lines changed: 7 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,6 @@
33
from litecli.packages.special.llm import handle_llm, FinishIteration, USAGE
44

55

6-
@patch("litecli.packages.special.llm.initialize_llm")
7-
@patch("litecli.packages.special.llm.llm", new=None)
8-
def test_llm_command_without_install(mock_initialize_llm, executor):
9-
"""
10-
Test that handle_llm initializes llm when it is None and raises FinishIteration.
11-
"""
12-
test_text = r"\llm"
13-
cur_mock = executor
14-
15-
with pytest.raises(FinishIteration) as exc_info:
16-
handle_llm(test_text, cur_mock)
17-
18-
mock_initialize_llm.assert_called_once()
19-
assert exc_info.value.args[0] is None
20-
21-
226
@patch("litecli.packages.special.llm.llm")
237
def test_llm_command_without_args(mock_llm, executor):
248
r"""
@@ -61,11 +45,8 @@ def test_llm_command_with_c_flag_and_fenced_sql(mock_run_cmd, mock_llm, executor
6145

6246
result, sql, duration = handle_llm(test_text, executor)
6347

64-
# We expect the function to return (result, sql), but result might be "" if verbose is not set
65-
# By default, `verbose` is false unless text has something like \llm --verbose?
66-
# The function code: return result if verbose else "", sql
67-
# Our test_text doesn't set verbose => we expect "" for the returned context.
68-
assert result == ""
48+
# In regular mode, context is returned
49+
assert result == return_text
6950
assert sql == "SELECT * FROM table;"
7051
assert isinstance(duration, float)
7152

@@ -133,7 +114,7 @@ def test_llm_command_with_prompt(mock_sql_using_llm, mock_ensure_template, mock_
133114
Should use context, capture output, and call sql_using_llm.
134115
"""
135116
# Mock out the return from sql_using_llm
136-
mock_sql_using_llm.return_value = ("context from LLM", "SELECT 1;")
117+
mock_sql_using_llm.return_value = ("context from LLM", "SELECT 1;", None)
137118

138119
test_text = r"\llm prompt 'Magic happening here?'"
139120
context, sql, duration = handle_llm(test_text, executor)
@@ -144,7 +125,7 @@ def test_llm_command_with_prompt(mock_sql_using_llm, mock_ensure_template, mock_
144125
# Actually, the question is the entire "prompt 'Magic happening here?'" minus the \llm
145126
# But in the function we do parse shlex.split.
146127
mock_sql_using_llm.assert_called()
147-
assert context == ""
128+
assert context == "context from LLM"
148129
assert sql == "SELECT 1;"
149130
assert isinstance(duration, float)
150131

@@ -156,14 +137,14 @@ def test_llm_command_question_with_context(mock_sql_using_llm, mock_ensure_templ
156137
"""
157138
If arg doesn't contain any known command, it's treated as a question => capture output + context.
158139
"""
159-
mock_sql_using_llm.return_value = ("You have context!", "SELECT 2;")
140+
mock_sql_using_llm.return_value = ("You have context!", "SELECT 2;", None)
160141

161142
test_text = r"\llm 'Top 10 downloads by size.'"
162143
context, sql, duration = handle_llm(test_text, executor)
163144

164145
mock_ensure_template.assert_called_once()
165146
mock_sql_using_llm.assert_called()
166-
assert context == ""
147+
assert context == "You have context!"
167148
assert sql == "SELECT 2;"
168149
assert isinstance(duration, float)
169150

@@ -175,7 +156,7 @@ def test_llm_command_question_verbose(mock_sql_using_llm, mock_ensure_template,
175156
r"""
176157
Invoking \llm+ returns the context and the SQL query.
177158
"""
178-
mock_sql_using_llm.return_value = ("Verbose context, oh yeah!", "SELECT 42;")
159+
mock_sql_using_llm.return_value = ("Verbose context, oh yeah!", "SELECT 42;", None)
179160

180161
test_text = r"\llm+ 'Top 10 downloads by size.'"
181162
context, sql, duration = handle_llm(test_text, executor)

0 commit comments

Comments
 (0)