dbagent-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dbagent/__init__.py +5 -0
- dbagent/agent/generator.py +264 -0
- dbagent/agent/pipeline.py +259 -0
- dbagent/agent/validator.py +116 -0
- dbagent/cli.py +717 -0
- dbagent/config.py +89 -0
- dbagent/connectors/base.py +75 -0
- dbagent/connectors/factory.py +25 -0
- dbagent/connectors/mongo.py +237 -0
- dbagent/connectors/relational.py +472 -0
- dbagent/llm/auto_setup.py +269 -0
- dbagent/llm/base.py +37 -0
- dbagent/llm/factory.py +73 -0
- dbagent/llm/gemini_provider.py +110 -0
- dbagent/llm/groq_provider.py +99 -0
- dbagent/llm/mock_provider.py +42 -0
- dbagent/llm/ollama_provider.py +145 -0
- dbagent/llm/openrouter_provider.py +100 -0
- dbagent/schema/formatter.py +123 -0
- dbagent/schema/models.py +72 -0
- dbagent/schema/selector.py +70 -0
- dbagent/ui/console.py +77 -0
- dbagent/ui/viewer.py +93 -0
- dbagent_cli-0.1.0.dist-info/METADATA +242 -0
- dbagent_cli-0.1.0.dist-info/RECORD +28 -0
- dbagent_cli-0.1.0.dist-info/WHEEL +5 -0
- dbagent_cli-0.1.0.dist-info/entry_points.txt +3 -0
- dbagent_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local Ollama LLM provider (100% Free, Offline, Zero-cost).
|
|
3
|
+
Auto-installs and configures Ollama on first use — fully independent.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import requests
|
|
8
|
+
from typing import Optional, Callable, List
|
|
9
|
+
from dbagent.llm.base import BaseLLMProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OllamaProvider(BaseLLMProvider):
|
|
13
|
+
"""Local offline provider using Ollama. Auto-sets up on first use."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, base_url: str = "http://localhost:11434", default_model: str = "qwen2.5-coder:1.5b"):
|
|
16
|
+
self.base_url = base_url.rstrip("/")
|
|
17
|
+
self.default_model = default_model
|
|
18
|
+
self._setup_done = False
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def name(self) -> str:
|
|
22
|
+
return "ollama"
|
|
23
|
+
|
|
24
|
+
def is_available(self) -> bool:
|
|
25
|
+
"""Check if local Ollama daemon is running."""
|
|
26
|
+
try:
|
|
27
|
+
r = requests.get(f"{self.base_url}/api/tags", timeout=0.5)
|
|
28
|
+
return r.status_code == 200
|
|
29
|
+
except Exception:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
def list_models(self) -> List[str]:
|
|
33
|
+
"""List all installed local models."""
|
|
34
|
+
try:
|
|
35
|
+
r = requests.get(f"{self.base_url}/api/tags", timeout=3)
|
|
36
|
+
if r.status_code == 200:
|
|
37
|
+
data = r.json()
|
|
38
|
+
return [m.get("name") for m in data.get("models", [])]
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
def _ensure_ready(self, print_fn=None) -> None:
|
|
44
|
+
"""Auto-install, start, and pull model if needed. Called once per session."""
|
|
45
|
+
if self._setup_done and self.is_available():
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
from dbagent.llm.auto_setup import ensure_ollama_ready, get_best_available_model
|
|
49
|
+
|
|
50
|
+
success, msg = ensure_ollama_ready(
|
|
51
|
+
model=self.default_model,
|
|
52
|
+
print_fn=print_fn,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if not success:
|
|
56
|
+
raise ConnectionError(msg)
|
|
57
|
+
|
|
58
|
+
# Use the best available model
|
|
59
|
+
best = get_best_available_model()
|
|
60
|
+
if best:
|
|
61
|
+
self.default_model = best
|
|
62
|
+
|
|
63
|
+
self._setup_done = True
|
|
64
|
+
|
|
65
|
+
def generate(
|
|
66
|
+
self,
|
|
67
|
+
prompt: str,
|
|
68
|
+
system_prompt: Optional[str] = None,
|
|
69
|
+
model: Optional[str] = None,
|
|
70
|
+
stream_callback: Optional[Callable[[str], None]] = None,
|
|
71
|
+
) -> str:
|
|
72
|
+
# Auto-setup on first generate call
|
|
73
|
+
if not self.is_available():
|
|
74
|
+
# Build a print function that uses Rich console if available
|
|
75
|
+
def _print_fn(msg, level="info"):
|
|
76
|
+
try:
|
|
77
|
+
from dbagent.ui.console import print_info, print_success, print_warning, print_error
|
|
78
|
+
if level == "success":
|
|
79
|
+
print_success(msg)
|
|
80
|
+
elif level == "warning":
|
|
81
|
+
print_warning(msg)
|
|
82
|
+
elif level == "error":
|
|
83
|
+
print_error(msg)
|
|
84
|
+
else:
|
|
85
|
+
print_info(msg)
|
|
86
|
+
except Exception:
|
|
87
|
+
print(f"[{level.upper()}] {msg}")
|
|
88
|
+
|
|
89
|
+
self._ensure_ready(print_fn=_print_fn)
|
|
90
|
+
|
|
91
|
+
target_model = model or self.default_model
|
|
92
|
+
|
|
93
|
+
# Check if requested model exists locally, pick best available
|
|
94
|
+
available = self.list_models()
|
|
95
|
+
if available and target_model not in available:
|
|
96
|
+
# Try partial match
|
|
97
|
+
for m in available:
|
|
98
|
+
if target_model.split(":")[0] in m:
|
|
99
|
+
target_model = m
|
|
100
|
+
break
|
|
101
|
+
else:
|
|
102
|
+
target_model = available[0]
|
|
103
|
+
|
|
104
|
+
payload = {
|
|
105
|
+
"model": target_model,
|
|
106
|
+
"prompt": prompt,
|
|
107
|
+
"system": system_prompt or "You are an expert database AI engineer.",
|
|
108
|
+
"stream": stream_callback is not None,
|
|
109
|
+
"options": {
|
|
110
|
+
"temperature": 0.1,
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
if stream_callback:
|
|
116
|
+
response = requests.post(
|
|
117
|
+
f"{self.base_url}/api/generate",
|
|
118
|
+
json=payload,
|
|
119
|
+
stream=True,
|
|
120
|
+
timeout=120,
|
|
121
|
+
)
|
|
122
|
+
full_text = []
|
|
123
|
+
for line in response.iter_lines():
|
|
124
|
+
if line:
|
|
125
|
+
chunk = json.loads(line)
|
|
126
|
+
text_part = chunk.get("response", "")
|
|
127
|
+
full_text.append(text_part)
|
|
128
|
+
stream_callback(text_part)
|
|
129
|
+
return "".join(full_text)
|
|
130
|
+
else:
|
|
131
|
+
response = requests.post(
|
|
132
|
+
f"{self.base_url}/api/generate",
|
|
133
|
+
json=payload,
|
|
134
|
+
timeout=120,
|
|
135
|
+
)
|
|
136
|
+
if response.status_code == 200:
|
|
137
|
+
return response.json().get("response", "")
|
|
138
|
+
else:
|
|
139
|
+
raise RuntimeError(f"Ollama error ({response.status_code}): {response.text}")
|
|
140
|
+
except requests.exceptions.ConnectionError:
|
|
141
|
+
raise ConnectionError(
|
|
142
|
+
"Could not connect to Ollama. Try running `ollama serve` manually."
|
|
143
|
+
)
|
|
144
|
+
except Exception as e:
|
|
145
|
+
raise RuntimeError(f"Ollama generation failed: {str(e)}")
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenRouter Provider for free tier models (e.g., Llama 3.3 70B, Qwen 2.5 Coder 32B Free).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import requests
|
|
8
|
+
from typing import Optional, Callable, List
|
|
9
|
+
from dbagent.llm.base import BaseLLMProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OpenRouterProvider(BaseLLMProvider):
|
|
13
|
+
"""OpenRouter Cloud Provider with access to free-tier open models."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, api_key: Optional[str] = None, default_model: str = "meta-llama/llama-3.3-70b-instruct:free"):
|
|
16
|
+
self.api_key = api_key or os.getenv("OPENROUTER_API_KEY")
|
|
17
|
+
self.default_model = default_model
|
|
18
|
+
self.endpoint = "https://openrouter.ai/api/v1/chat/completions"
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def name(self) -> str:
|
|
22
|
+
return "openrouter"
|
|
23
|
+
|
|
24
|
+
def is_available(self) -> bool:
|
|
25
|
+
return bool(self.api_key)
|
|
26
|
+
|
|
27
|
+
def list_models(self) -> List[str]:
|
|
28
|
+
return [
|
|
29
|
+
"meta-llama/llama-3.3-70b-instruct:free",
|
|
30
|
+
"qwen/qwen-2.5-coder-32b-instruct:free",
|
|
31
|
+
"mistralai/mistral-7b-instruct:free",
|
|
32
|
+
"google/gemini-2.0-flash-exp:free",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
def generate(
|
|
36
|
+
self,
|
|
37
|
+
prompt: str,
|
|
38
|
+
system_prompt: Optional[str] = None,
|
|
39
|
+
model: Optional[str] = None,
|
|
40
|
+
stream_callback: Optional[Callable[[str], None]] = None,
|
|
41
|
+
) -> str:
|
|
42
|
+
if not self.api_key:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
"OpenRouter API key is not configured. Set OPENROUTER_API_KEY environment variable or run `db-agent config`."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
target_model = model or self.default_model
|
|
48
|
+
|
|
49
|
+
headers = {
|
|
50
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
51
|
+
"HTTP-Referer": "https://github.com/db-agent/db-agent",
|
|
52
|
+
"X-Title": "DB-Agent",
|
|
53
|
+
"Content-Type": "application/json",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
messages = []
|
|
57
|
+
if system_prompt:
|
|
58
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
59
|
+
messages.append({"role": "user", "content": prompt})
|
|
60
|
+
|
|
61
|
+
payload = {
|
|
62
|
+
"model": target_model,
|
|
63
|
+
"messages": messages,
|
|
64
|
+
"temperature": 0.1,
|
|
65
|
+
"stream": stream_callback is not None,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
if stream_callback:
|
|
70
|
+
response = requests.post(self.endpoint, headers=headers, json=payload, stream=True, timeout=60)
|
|
71
|
+
if response.status_code != 200:
|
|
72
|
+
raise RuntimeError(f"OpenRouter API error ({response.status_code}): {response.text}")
|
|
73
|
+
|
|
74
|
+
full_text = []
|
|
75
|
+
for line in response.iter_lines():
|
|
76
|
+
if line:
|
|
77
|
+
decoded = line.decode("utf-8")
|
|
78
|
+
if decoded.startswith("data: "):
|
|
79
|
+
raw_json = decoded[6:].strip()
|
|
80
|
+
if raw_json == "[DONE]":
|
|
81
|
+
break
|
|
82
|
+
try:
|
|
83
|
+
chunk = json.loads(raw_json)
|
|
84
|
+
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
|
85
|
+
text_part = delta.get("content", "")
|
|
86
|
+
if text_part:
|
|
87
|
+
full_text.append(text_part)
|
|
88
|
+
stream_callback(text_part)
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
return "".join(full_text)
|
|
92
|
+
else:
|
|
93
|
+
response = requests.post(self.endpoint, headers=headers, json=payload, timeout=60)
|
|
94
|
+
if response.status_code == 200:
|
|
95
|
+
data = response.json()
|
|
96
|
+
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
|
97
|
+
else:
|
|
98
|
+
raise RuntimeError(f"OpenRouter API error ({response.status_code}): {response.text}")
|
|
99
|
+
except Exception as e:
|
|
100
|
+
raise RuntimeError(f"OpenRouter generation error: {str(e)}")
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Schema formatters for converting database metadata into rich LLM context.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
from dbagent.schema.models import DatabaseSchema, TableModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SchemaFormatter:
|
|
10
|
+
"""Formats DatabaseSchema into prompts for LLMs."""
|
|
11
|
+
|
|
12
|
+
@staticmethod
|
|
13
|
+
def to_markdown(
|
|
14
|
+
schema: DatabaseSchema,
|
|
15
|
+
selected_tables: Optional[List[str]] = None,
|
|
16
|
+
include_samples: bool = True,
|
|
17
|
+
max_sample_rows: int = 2,
|
|
18
|
+
) -> str:
|
|
19
|
+
"""Render schema into a comprehensive markdown description."""
|
|
20
|
+
lines = []
|
|
21
|
+
lines.append(f"# Database Catalog: {schema.database_name}")
|
|
22
|
+
lines.append(f"- **Dialect**: `{schema.dialect_name}`")
|
|
23
|
+
if schema.server_version:
|
|
24
|
+
lines.append(f"- **Server Version**: {schema.server_version}")
|
|
25
|
+
lines.append(f"- **Total Tables**: {schema.total_tables}")
|
|
26
|
+
lines.append(f"- **Total Views**: {schema.total_views}")
|
|
27
|
+
lines.append("")
|
|
28
|
+
|
|
29
|
+
tables_to_include = schema.tables
|
|
30
|
+
if selected_tables is not None:
|
|
31
|
+
lower_selected = [t.lower() for t in selected_tables]
|
|
32
|
+
tables_to_include = [t for t in schema.tables if t.name.lower() in lower_selected]
|
|
33
|
+
|
|
34
|
+
for table in tables_to_include:
|
|
35
|
+
table_type = "View" if table.is_view else "Table"
|
|
36
|
+
lines.append(f"## {table_type}: `{table.name}`")
|
|
37
|
+
if table.comment:
|
|
38
|
+
lines.append(f"*{table.comment}*")
|
|
39
|
+
if table.row_count is not None:
|
|
40
|
+
lines.append(f"- Approximate Row Count: {table.row_count:,}")
|
|
41
|
+
lines.append("")
|
|
42
|
+
|
|
43
|
+
# Columns table
|
|
44
|
+
lines.append("| Column | Type | Nullable | PK | Default | Comment |")
|
|
45
|
+
lines.append("|---|---|:---:|:---:|---|---|")
|
|
46
|
+
for col in table.columns:
|
|
47
|
+
pk_mark = "✅ PK" if col.is_primary_key else ""
|
|
48
|
+
null_mark = "NULL" if col.is_nullable else "NOT NULL"
|
|
49
|
+
default_val = f"`{col.default_value}`" if col.default_value else ""
|
|
50
|
+
comment = col.comment or ""
|
|
51
|
+
lines.append(
|
|
52
|
+
f"| `{col.name}` | `{col.data_type}` | {null_mark} | {pk_mark} | {default_val} | {comment} |"
|
|
53
|
+
)
|
|
54
|
+
lines.append("")
|
|
55
|
+
|
|
56
|
+
# Foreign Keys
|
|
57
|
+
if table.foreign_keys:
|
|
58
|
+
lines.append("### Foreign Key Relationships")
|
|
59
|
+
for fk in table.foreign_keys:
|
|
60
|
+
cols = ", ".join(f"`{c}`" for c in fk.constrained_columns)
|
|
61
|
+
ref_cols = ", ".join(f"`{c}`" for c in fk.referred_columns)
|
|
62
|
+
lines.append(f"- ({cols}) ➡️ `{fk.referred_table}`({ref_cols})")
|
|
63
|
+
lines.append("")
|
|
64
|
+
|
|
65
|
+
# Indexes
|
|
66
|
+
if table.indexes:
|
|
67
|
+
lines.append("### Indexes")
|
|
68
|
+
for idx in table.indexes:
|
|
69
|
+
idx_type = "UNIQUE " if idx.is_unique else ""
|
|
70
|
+
cols = ", ".join(f"`{c}`" for c in idx.columns)
|
|
71
|
+
lines.append(f"- {idx_type}INDEX `{idx.name}` ({cols})")
|
|
72
|
+
lines.append("")
|
|
73
|
+
|
|
74
|
+
# View definition
|
|
75
|
+
if table.is_view and table.view_definition:
|
|
76
|
+
lines.append("### View Definition")
|
|
77
|
+
lines.append("```sql")
|
|
78
|
+
lines.append(table.view_definition.strip())
|
|
79
|
+
lines.append("```")
|
|
80
|
+
lines.append("")
|
|
81
|
+
|
|
82
|
+
# Sample Rows
|
|
83
|
+
if include_samples and table.sample_rows:
|
|
84
|
+
lines.append("### Sample Data (Shape & Types)")
|
|
85
|
+
samples = table.sample_rows[:max_sample_rows]
|
|
86
|
+
if samples:
|
|
87
|
+
headers = list(samples[0].keys())
|
|
88
|
+
lines.append("| " + " | ".join(headers) + " |")
|
|
89
|
+
lines.append("| " + " | ".join(["---"] * len(headers)) + " |")
|
|
90
|
+
for row in samples:
|
|
91
|
+
row_vals = []
|
|
92
|
+
for h in headers:
|
|
93
|
+
val = row.get(h)
|
|
94
|
+
if val is None:
|
|
95
|
+
row_vals.append("`NULL`")
|
|
96
|
+
else:
|
|
97
|
+
val_str = str(val).replace("\n", " ")
|
|
98
|
+
if len(val_str) > 30:
|
|
99
|
+
val_str = val_str[:27] + "..."
|
|
100
|
+
row_vals.append(f"`{val_str}`")
|
|
101
|
+
lines.append("| " + " | ".join(row_vals) + " |")
|
|
102
|
+
lines.append("")
|
|
103
|
+
|
|
104
|
+
lines.append("---")
|
|
105
|
+
lines.append("")
|
|
106
|
+
|
|
107
|
+
return "\n".join(lines)
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def to_compact(schema: DatabaseSchema) -> str:
|
|
111
|
+
"""Render a compact single-line per table summary (for huge databases)."""
|
|
112
|
+
lines = [f"Database: {schema.database_name} ({schema.dialect_name})"]
|
|
113
|
+
for t in schema.tables:
|
|
114
|
+
cols = []
|
|
115
|
+
for c in t.columns:
|
|
116
|
+
pk = " [PK]" if c.is_primary_key else ""
|
|
117
|
+
cols.append(f"{c.name}: {c.data_type}{pk}")
|
|
118
|
+
fks = []
|
|
119
|
+
for fk in t.foreign_keys:
|
|
120
|
+
fks.append(f"{','.join(fk.constrained_columns)}->{fk.referred_table}({','.join(fk.referred_columns)})")
|
|
121
|
+
fk_str = f" | FK: {'; '.join(fks)}" if fks else ""
|
|
122
|
+
lines.append(f"- {t.name} ({', '.join(cols)}){fk_str}")
|
|
123
|
+
return "\n".join(lines)
|
dbagent/schema/models.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Schema catalog models for structured representation of databases.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import List, Dict, Any, Optional
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ColumnModel(BaseModel):
|
|
10
|
+
"""Represents a database table column."""
|
|
11
|
+
name: str
|
|
12
|
+
data_type: str
|
|
13
|
+
is_nullable: bool = True
|
|
14
|
+
is_primary_key: bool = False
|
|
15
|
+
default_value: Optional[str] = None
|
|
16
|
+
comment: Optional[str] = None
|
|
17
|
+
is_autoincrement: bool = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ForeignKeyModel(BaseModel):
|
|
21
|
+
"""Represents a foreign key constraint."""
|
|
22
|
+
name: Optional[str] = None
|
|
23
|
+
constrained_columns: List[str]
|
|
24
|
+
referred_table: str
|
|
25
|
+
referred_columns: List[str]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class IndexModel(BaseModel):
|
|
29
|
+
"""Represents a database index."""
|
|
30
|
+
name: str
|
|
31
|
+
columns: List[str]
|
|
32
|
+
is_unique: bool = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TableModel(BaseModel):
|
|
36
|
+
"""Represents a database table or view."""
|
|
37
|
+
name: str
|
|
38
|
+
schema_name: Optional[str] = None
|
|
39
|
+
is_view: bool = False
|
|
40
|
+
columns: List[ColumnModel] = Field(default_factory=list)
|
|
41
|
+
primary_key: List[str] = Field(default_factory=list)
|
|
42
|
+
foreign_keys: List[ForeignKeyModel] = Field(default_factory=list)
|
|
43
|
+
indexes: List[IndexModel] = Field(default_factory=list)
|
|
44
|
+
row_count: Optional[int] = None
|
|
45
|
+
sample_rows: List[Dict[str, Any]] = Field(default_factory=list)
|
|
46
|
+
view_definition: Optional[str] = None
|
|
47
|
+
comment: Optional[str] = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DatabaseSchema(BaseModel):
|
|
51
|
+
"""Complete catalog of an introspected database."""
|
|
52
|
+
dialect_name: str
|
|
53
|
+
database_name: str
|
|
54
|
+
server_version: Optional[str] = None
|
|
55
|
+
tables: List[TableModel] = Field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def total_tables(self) -> int:
|
|
59
|
+
return len([t for t in self.tables if not t.is_view])
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def total_views(self) -> int:
|
|
63
|
+
return len([t for t in self.tables if t.is_view])
|
|
64
|
+
|
|
65
|
+
def get_table(self, table_name: str) -> Optional[TableModel]:
|
|
66
|
+
for t in self.tables:
|
|
67
|
+
if t.name.lower() == table_name.lower():
|
|
68
|
+
return t
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
def get_table_names(self) -> List[str]:
|
|
72
|
+
return [t.name for t in self.tables]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Smart schema selector for filtering relevant tables in large databases.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import List, Set
|
|
7
|
+
from dbagent.schema.models import DatabaseSchema, TableModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SchemaSelector:
|
|
11
|
+
"""Selects subset of relevant tables from a large schema based on a user prompt."""
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def select_relevant_tables(
|
|
15
|
+
cls,
|
|
16
|
+
schema: DatabaseSchema,
|
|
17
|
+
user_prompt: str,
|
|
18
|
+
max_tables: int = 15,
|
|
19
|
+
) -> List[str]:
|
|
20
|
+
"""
|
|
21
|
+
Extract relevant table names from user query and include connected foreign key tables.
|
|
22
|
+
If total tables in database <= max_tables, returns all tables.
|
|
23
|
+
"""
|
|
24
|
+
all_table_names = [t.name for t in schema.tables]
|
|
25
|
+
if len(all_table_names) <= max_tables:
|
|
26
|
+
return all_table_names
|
|
27
|
+
|
|
28
|
+
prompt_lower = user_prompt.lower()
|
|
29
|
+
matched_tables: Set[str] = set()
|
|
30
|
+
|
|
31
|
+
# 1. Direct match or word boundary match
|
|
32
|
+
for table in schema.tables:
|
|
33
|
+
t_name = table.name.lower()
|
|
34
|
+
# Check exact word or substring
|
|
35
|
+
pattern = r"\b" + re.escape(t_name) + r"\b"
|
|
36
|
+
if re.search(pattern, prompt_lower) or t_name in prompt_lower:
|
|
37
|
+
matched_tables.add(table.name)
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
# Check if any column name is specifically mentioned
|
|
41
|
+
for col in table.columns:
|
|
42
|
+
c_name = col.name.lower()
|
|
43
|
+
if len(c_name) > 3 and re.search(r"\b" + re.escape(c_name) + r"\b", prompt_lower):
|
|
44
|
+
matched_tables.add(table.name)
|
|
45
|
+
break
|
|
46
|
+
|
|
47
|
+
# 2. Add foreign-key related tables for matched tables
|
|
48
|
+
related_tables: Set[str] = set()
|
|
49
|
+
for t_name in matched_tables:
|
|
50
|
+
t_model = schema.get_table(t_name)
|
|
51
|
+
if t_model:
|
|
52
|
+
for fk in t_model.foreign_keys:
|
|
53
|
+
if fk.referred_table in all_table_names:
|
|
54
|
+
related_tables.add(fk.referred_table)
|
|
55
|
+
|
|
56
|
+
# Also find any other table that references the matched table
|
|
57
|
+
for table in schema.tables:
|
|
58
|
+
for fk in table.foreign_keys:
|
|
59
|
+
if fk.referred_table in matched_tables:
|
|
60
|
+
related_tables.add(table.name)
|
|
61
|
+
|
|
62
|
+
combined = matched_tables.union(related_tables)
|
|
63
|
+
|
|
64
|
+
# If nothing matched (e.g. general prompt like "list everything"), return top tables
|
|
65
|
+
if not combined:
|
|
66
|
+
return all_table_names[:max_tables]
|
|
67
|
+
|
|
68
|
+
# Convert to list capped at max_tables
|
|
69
|
+
result = list(combined)[:max_tables]
|
|
70
|
+
return result
|
dbagent/ui/console.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rich terminal UI utilities and console formatting.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.syntax import Syntax
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from typing import Optional, List, Dict, Any
|
|
12
|
+
|
|
13
|
+
# Ensure UTF-8 on Windows terminal streams
|
|
14
|
+
if sys.platform == "win32":
|
|
15
|
+
try:
|
|
16
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
17
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
18
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
19
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
20
|
+
except Exception:
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def print_banner() -> None:
|
|
27
|
+
"""Print the DB-Agent terminal banner."""
|
|
28
|
+
banner_text = Text()
|
|
29
|
+
banner_text.append("[*] DB-AGENT ", style="bold cyan")
|
|
30
|
+
banner_text.append("- Universal Database Introspector & AI Script Generator\n", style="bold white")
|
|
31
|
+
banner_text.append("Supports PostgreSQL | MySQL | SQLite | MSSQL | Oracle | DuckDB | MongoDB", style="dim")
|
|
32
|
+
panel = Panel(banner_text, border_style="cyan", padding=(0, 2))
|
|
33
|
+
console.print(panel)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def print_success(message: str) -> None:
|
|
37
|
+
console.print(f"[bold green][OK][/bold green] {message}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def print_error(message: str) -> None:
|
|
41
|
+
console.print(f"[bold red][ERROR][/bold red] {message}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def print_warning(message: str) -> None:
|
|
45
|
+
console.print(f"[bold yellow][WARN][/bold yellow] {message}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def print_info(message: str) -> None:
|
|
49
|
+
console.print(f"[bold blue][INFO][/bold blue] {message}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def print_code(code: str, language: str = "sql", title: Optional[str] = None) -> None:
|
|
53
|
+
"""Print syntax-highlighted code block."""
|
|
54
|
+
syntax = Syntax(code, language, theme="monokai", line_numbers=True)
|
|
55
|
+
panel = Panel(syntax, title=f"[bold cyan]{title or language.upper()}[/bold cyan]", border_style="dim")
|
|
56
|
+
console.print(panel)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def print_results_table(columns: List[str], rows: List[Dict[str, Any]], title: str = "Query Results") -> None:
|
|
60
|
+
"""Print query results in a clean Rich table."""
|
|
61
|
+
if not columns:
|
|
62
|
+
console.print("[dim]Query returned no rows.[/dim]")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
table = Table(title=title, show_header=True, header_style="bold magenta", border_style="dim")
|
|
66
|
+
for col in columns:
|
|
67
|
+
table.add_column(str(col))
|
|
68
|
+
|
|
69
|
+
for row in rows:
|
|
70
|
+
row_vals = []
|
|
71
|
+
for col in columns:
|
|
72
|
+
v = row.get(col)
|
|
73
|
+
row_vals.append(str(v) if v is not None else "[dim]NULL[/dim]")
|
|
74
|
+
table.add_row(*row_vals)
|
|
75
|
+
|
|
76
|
+
console.print(table)
|
|
77
|
+
console.print(f"[dim]Total rows: {len(rows)}[/dim]\n")
|
dbagent/ui/viewer.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rich terminal viewer for database schema introspection.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
from rich.tree import Tree
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from typing import Optional
|
|
10
|
+
from dbagent.schema.models import DatabaseSchema, TableModel
|
|
11
|
+
from dbagent.ui.console import console
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SchemaViewer:
|
|
15
|
+
"""Renders database schema visual representations in the terminal."""
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def display_schema_summary(schema: DatabaseSchema) -> None:
|
|
19
|
+
"""Display an overview table of all discovered tables and views."""
|
|
20
|
+
table = Table(
|
|
21
|
+
title=f"[Catalog] Database: [bold cyan]{schema.database_name}[/bold cyan] ({schema.dialect_name})",
|
|
22
|
+
show_header=True,
|
|
23
|
+
header_style="bold cyan",
|
|
24
|
+
border_style="dim",
|
|
25
|
+
)
|
|
26
|
+
table.add_column("Type", justify="center", width=8)
|
|
27
|
+
table.add_column("Table Name", style="bold white")
|
|
28
|
+
table.add_column("Columns", justify="right")
|
|
29
|
+
table.add_column("Primary Key", style="yellow")
|
|
30
|
+
table.add_column("Foreign Keys", style="green")
|
|
31
|
+
table.add_column("Est. Rows", justify="right", style="cyan")
|
|
32
|
+
|
|
33
|
+
for t in schema.tables:
|
|
34
|
+
type_tag = "[cyan]View[/cyan]" if t.is_view else "[white]Table[/white]"
|
|
35
|
+
pk_str = ", ".join(t.primary_key) if t.primary_key else "[dim]None[/dim]"
|
|
36
|
+
fk_count = len(t.foreign_keys)
|
|
37
|
+
fk_str = f"{fk_count} relations" if fk_count > 0 else "[dim]None[/dim]"
|
|
38
|
+
rows_str = f"{t.row_count:,}" if t.row_count is not None else "[dim]N/A[/dim]"
|
|
39
|
+
|
|
40
|
+
table.add_row(
|
|
41
|
+
type_tag,
|
|
42
|
+
t.name,
|
|
43
|
+
str(len(t.columns)),
|
|
44
|
+
pk_str,
|
|
45
|
+
fk_str,
|
|
46
|
+
rows_str,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
console.print(table)
|
|
50
|
+
console.print(
|
|
51
|
+
f"[dim]Discovered [bold]{schema.total_tables}[/bold] tables and [bold]{schema.total_views}[/bold] views.[/dim]\n"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def display_table_detail(table: TableModel) -> None:
|
|
56
|
+
"""Display detailed column attributes for a single table."""
|
|
57
|
+
tbl = Table(
|
|
58
|
+
title=f"Table Structure: [bold cyan]{table.name}[/bold cyan]",
|
|
59
|
+
show_header=True,
|
|
60
|
+
header_style="bold magenta",
|
|
61
|
+
border_style="dim",
|
|
62
|
+
)
|
|
63
|
+
tbl.add_column("Column", style="bold white")
|
|
64
|
+
tbl.add_column("Type", style="cyan")
|
|
65
|
+
tbl.add_column("PK", justify="center", style="yellow")
|
|
66
|
+
tbl.add_column("Nullable", justify="center")
|
|
67
|
+
tbl.add_column("Default", style="dim")
|
|
68
|
+
tbl.add_column("Comment", style="italic dim")
|
|
69
|
+
|
|
70
|
+
for col in table.columns:
|
|
71
|
+
pk_str = "[PK]" if col.is_primary_key else ""
|
|
72
|
+
null_str = "[green]YES[/green]" if col.is_nullable else "[red]NO[/red]"
|
|
73
|
+
def_str = col.default_value or ""
|
|
74
|
+
comm_str = col.comment or ""
|
|
75
|
+
tbl.add_row(col.name, col.data_type, pk_str, null_str, def_str, comm_str)
|
|
76
|
+
|
|
77
|
+
console.print(tbl)
|
|
78
|
+
|
|
79
|
+
if table.foreign_keys:
|
|
80
|
+
console.print("[bold green]Foreign Keys:[/bold green]")
|
|
81
|
+
for fk in table.foreign_keys:
|
|
82
|
+
c = ", ".join(fk.constrained_columns)
|
|
83
|
+
r = ", ".join(fk.referred_columns)
|
|
84
|
+
console.print(f" * ({c}) -> [bold]{fk.referred_table}[/bold]({r})")
|
|
85
|
+
console.print("")
|
|
86
|
+
|
|
87
|
+
if table.indexes:
|
|
88
|
+
console.print("[bold blue]Indexes:[/bold blue]")
|
|
89
|
+
for idx in table.indexes:
|
|
90
|
+
u = "[yellow]UNIQUE [/yellow]" if idx.is_unique else ""
|
|
91
|
+
c = ", ".join(idx.columns)
|
|
92
|
+
console.print(f" * {u}{idx.name} on ({c})")
|
|
93
|
+
console.print("")
|