dbagent-cli 0.9.0__tar.gz → 0.9.1__tar.gz
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_cli-0.9.0 → dbagent_cli-0.9.1}/PKG-INFO +1 -1
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/__init__.py +1 -1
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/agent/pipeline.py +22 -2
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/cli.py +49 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/schema/graph.py +30 -0
- dbagent_cli-0.9.1/dbagent/semantic/knowledge.py +146 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent_cli.egg-info/PKG-INFO +1 -1
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent_cli.egg-info/SOURCES.txt +2 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/pyproject.toml +1 -1
- dbagent_cli-0.9.1/tests/test_knowledge.py +80 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/README.md +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/agent/confidence.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/agent/conversation.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/agent/doctor.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/agent/error_classifier.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/agent/generator.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/agent/recommender.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/agent/validator.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/config.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/connectors/base.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/connectors/factory.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/connectors/mongo.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/connectors/relational.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/execution/__init__.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/execution/cache.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/execution/changeset.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/execution/cost.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/execution/recovery.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/execution/sandbox.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/execution/streaming.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/llm/anthropic_provider.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/llm/auto_setup.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/llm/base.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/llm/deepseek_provider.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/llm/factory.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/llm/gemini_provider.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/llm/groq_provider.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/llm/mistral_provider.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/llm/mock_provider.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/llm/ollama_provider.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/llm/openai_provider.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/llm/openrouter_provider.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/schema/cache.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/schema/formatter.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/schema/ground_truth.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/schema/models.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/schema/retriever.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/schema/selector.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/security/__init__.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/security/audit.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/security/environment.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/security/masking.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/security/modes.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/security/rbac.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/semantic/__init__.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/semantic/dictionary.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/semantic/templates.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/sql/__init__.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/sql/optimizer.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/sql/validator.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/ui/chat_screen.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/ui/console.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/ui/grid_window.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/ui/viewer.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent/ui/visualizer.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent_cli.egg-info/dependency_links.txt +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent_cli.egg-info/entry_points.txt +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent_cli.egg-info/requires.txt +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/dbagent_cli.egg-info/top_level.txt +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/setup.cfg +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/tests/test_cache.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/tests/test_cli.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/tests/test_connectors.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/tests/test_direct_sql.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/tests/test_doctor_and_viz.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/tests/test_generator.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/tests/test_graph.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/tests/test_providers.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/tests/test_recovery.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/tests/test_safety.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/tests/test_sandbox.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/tests/test_schema.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/tests/test_security.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.1}/tests/test_semantic.py +0 -0
|
@@ -25,6 +25,7 @@ from dbagent.sql.validator import SQLValidator
|
|
|
25
25
|
from dbagent.execution.cost import QueryCostEstimator, QueryComplexity
|
|
26
26
|
from dbagent.execution.cache import QueryResultCache
|
|
27
27
|
from dbagent.semantic.dictionary import BusinessDictionary
|
|
28
|
+
from dbagent.semantic.knowledge import KnowledgeBase, LearnedRule
|
|
28
29
|
from dbagent.execution.recovery import RecoveryManager, RowSnapshotRecoveryStrategy, RecoveryStatus
|
|
29
30
|
from dbagent.agent.confidence import ConfidenceEvaluator, ConfidenceScore
|
|
30
31
|
from dbagent.agent.conversation import ConversationContext
|
|
@@ -123,6 +124,7 @@ class QueryPipeline:
|
|
|
123
124
|
self.choice_callback = choice_callback
|
|
124
125
|
|
|
125
126
|
self.dictionary = BusinessDictionary()
|
|
127
|
+
self.knowledge_base = KnowledgeBase()
|
|
126
128
|
self.result_cache = QueryResultCache()
|
|
127
129
|
self.audit_logger = AuditLogger()
|
|
128
130
|
self.recovery_mgr = recovery_manager or RecoveryManager()
|
|
@@ -215,10 +217,25 @@ class QueryPipeline:
|
|
|
215
217
|
sub_schema, join_steps = self._get_fast_schema_context(user_prompt, exact, fuzzy)
|
|
216
218
|
result.schema = sub_schema
|
|
217
219
|
result.join_steps = join_steps
|
|
220
|
+
|
|
221
|
+
# Auto-Learn from user guidance/corrections
|
|
222
|
+
prompt_l = user_prompt.lower()
|
|
223
|
+
if any(w in prompt_l for w in ["mapped with", "mapped to", "observe the database", "the rule is", "remember that", "notice that"]):
|
|
224
|
+
self.knowledge_base.learn(
|
|
225
|
+
db_name=self.db_name,
|
|
226
|
+
topic=f"Mapping for {', '.join(exact[:3]) if exact else 'Entities'}",
|
|
227
|
+
rule_description=user_prompt,
|
|
228
|
+
relevant_tables=exact + fuzzy[:4],
|
|
229
|
+
source="user_guidance",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
kb_context = self.knowledge_base.format_prompt_context(self.db_name, user_prompt, exact + fuzzy)
|
|
218
233
|
conv_history = self.conversation.format_context_for_prompt()
|
|
234
|
+
analysis_prompt = f"{kb_context}\n\n{user_prompt}" if kb_context else user_prompt
|
|
235
|
+
|
|
219
236
|
result.message = self.generator.generate_analysis_response(
|
|
220
237
|
schema=sub_schema,
|
|
221
|
-
user_prompt=
|
|
238
|
+
user_prompt=analysis_prompt,
|
|
222
239
|
conversation_history=conv_history,
|
|
223
240
|
model=self.model,
|
|
224
241
|
)
|
|
@@ -384,13 +401,16 @@ class QueryPipeline:
|
|
|
384
401
|
result.error = f"Schema retrieval failed: {str(e)}"
|
|
385
402
|
return result
|
|
386
403
|
|
|
387
|
-
# --- 4. Semantic Business Dictionary
|
|
404
|
+
# --- 4. Semantic Business Dictionary & Knowledge Base Injection ---
|
|
388
405
|
dict_context = self.dictionary.format_prompt_context(user_prompt)
|
|
406
|
+
kb_context = self.knowledge_base.format_prompt_context(self.db_name, user_prompt, exact + fuzzy)
|
|
389
407
|
conv_context = self.conversation.format_context_for_prompt()
|
|
390
408
|
|
|
391
409
|
augmented_prompt = user_prompt
|
|
392
410
|
if dict_context:
|
|
393
411
|
augmented_prompt = f"{dict_context}\n\n{augmented_prompt}"
|
|
412
|
+
if kb_context:
|
|
413
|
+
augmented_prompt = f"{kb_context}\n\n{augmented_prompt}"
|
|
394
414
|
if conv_context:
|
|
395
415
|
augmented_prompt = f"{conv_context}\n\n{augmented_prompt}"
|
|
396
416
|
|
|
@@ -633,6 +633,9 @@ def chat_command(
|
|
|
633
633
|
cmd_table.add_row(":fk <name>", "View foreign key relationships & dependency graph")
|
|
634
634
|
cmd_table.add_row(":path <t1> <t2>", "Discover shortest SQL join path between two tables")
|
|
635
635
|
cmd_table.add_row(":doctor", "Run database health, index, and query performance diagnostics")
|
|
636
|
+
cmd_table.add_row(":learn <rule>", "Teach DB-Agent custom domain rules & mappings (:learn topic = rule)")
|
|
637
|
+
cmd_table.add_row(":memory, :learned", "View all learned domain rules & relationship memory")
|
|
638
|
+
cmd_table.add_row(":forget <id|topic>", "Remove a learned rule from persistent memory")
|
|
636
639
|
cmd_table.add_row(":mode <name>", "Switch safety mode (SAFE, CONFIRM, ADMIN, READ_ONLY)")
|
|
637
640
|
cmd_table.add_row(":history", "View session query history, execution timing & row counts")
|
|
638
641
|
cmd_table.add_row(":page <n>, :card", "Inspect wide multi-column query results")
|
|
@@ -776,6 +779,52 @@ def chat_command(
|
|
|
776
779
|
console.print("")
|
|
777
780
|
continue
|
|
778
781
|
|
|
782
|
+
if cmd_name in [":learn", ":remember"]:
|
|
783
|
+
if not cmd_arg or "=" not in cmd_arg:
|
|
784
|
+
print_warning("Usage: :learn <topic> = <description> (e.g. :learn site_user_map = ivis_site maps to ivis_user via site_group -> group)")
|
|
785
|
+
continue
|
|
786
|
+
parts = cmd_arg.split("=", 1)
|
|
787
|
+
topic = parts[0].strip()
|
|
788
|
+
desc = parts[1].strip()
|
|
789
|
+
try:
|
|
790
|
+
exact, fuzzy = connector.resolve_tables(desc)
|
|
791
|
+
except Exception:
|
|
792
|
+
exact, fuzzy = [], []
|
|
793
|
+
learned_rule = pipeline.knowledge_base.learn(
|
|
794
|
+
db_name=db_name,
|
|
795
|
+
topic=topic,
|
|
796
|
+
rule_description=desc,
|
|
797
|
+
relevant_tables=exact + fuzzy[:4],
|
|
798
|
+
source="user_explicit",
|
|
799
|
+
)
|
|
800
|
+
print_success(f"Learned & Persisted Rule: [bold cyan]{learned_rule.topic}[/bold cyan]")
|
|
801
|
+
console.print(f" [dim]• Relevant tables: {', '.join(learned_rule.relevant_tables) or 'general'}[/dim]")
|
|
802
|
+
console.print(f" [dim]• DB-Agent will automatically apply this rule in all future queries.[/dim]\n")
|
|
803
|
+
continue
|
|
804
|
+
|
|
805
|
+
if cmd_name in [":memory", ":learned", ":knowledge"]:
|
|
806
|
+
rules = pipeline.knowledge_base.list_all(db_name)
|
|
807
|
+
if not rules:
|
|
808
|
+
console.print(f"[dim]No learned rules stored for '{db_name}' yet. Use [bold]:learn <topic> = <desc>[/bold] to teach DB-Agent.[/dim]\n")
|
|
809
|
+
else:
|
|
810
|
+
console.print(f"\n[bold cyan]🧠 DB-Agent Learned Knowledge Base for '{db_name}':[/bold cyan]")
|
|
811
|
+
for r in rules:
|
|
812
|
+
tbl_str = f" [cyan]({', '.join(r.relevant_tables)})[/cyan]" if r.relevant_tables else ""
|
|
813
|
+
console.print(f" * [bold white]{r.topic}[/bold white]{tbl_str}:")
|
|
814
|
+
console.print(f" [dim]{r.rule_description}[/dim] [dim italic]({r.rule_id})[/dim italic]")
|
|
815
|
+
console.print("\n[dim]To remove a rule: [bold]:forget <rule_id|topic>[/bold][/dim]\n")
|
|
816
|
+
continue
|
|
817
|
+
|
|
818
|
+
if cmd_name in [":forget", ":unlearn"]:
|
|
819
|
+
if not cmd_arg:
|
|
820
|
+
print_warning("Usage: :forget <rule_id or topic>")
|
|
821
|
+
continue
|
|
822
|
+
if pipeline.knowledge_base.delete(db_name, cmd_arg):
|
|
823
|
+
print_success(f"Removed rule '{cmd_arg}' from knowledge memory.")
|
|
824
|
+
else:
|
|
825
|
+
print_warning(f"No matching rule found for '{cmd_arg}'.")
|
|
826
|
+
continue
|
|
827
|
+
|
|
779
828
|
if cmd_name == ":clear":
|
|
780
829
|
conversation_ctx.clear()
|
|
781
830
|
print_success("Conversation context cleared.")
|
|
@@ -176,6 +176,36 @@ class RelationshipGraph:
|
|
|
176
176
|
|
|
177
177
|
return None
|
|
178
178
|
|
|
179
|
+
def find_all_paths(self, start_table: str, end_table: str, max_hops: int = 4) -> List[JoinPath]:
|
|
180
|
+
"""
|
|
181
|
+
Finds all valid multi-hop join paths between start_table and end_table up to max_hops.
|
|
182
|
+
Discovers direct paths, junction tables, and indirect hierarchical relationships.
|
|
183
|
+
"""
|
|
184
|
+
start = start_table.lower()
|
|
185
|
+
end = end_table.lower()
|
|
186
|
+
|
|
187
|
+
if start not in self.adj or end not in self.adj:
|
|
188
|
+
return []
|
|
189
|
+
|
|
190
|
+
all_paths: List[JoinPath] = []
|
|
191
|
+
queue: deque = deque([(start, [], {start})]) # (curr, list_of_steps, visited_set)
|
|
192
|
+
|
|
193
|
+
while queue:
|
|
194
|
+
curr, steps, visited = queue.popleft()
|
|
195
|
+
|
|
196
|
+
if curr == end and steps:
|
|
197
|
+
all_paths.append(JoinPath(start_table, end_table, steps))
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
if len(steps) >= max_hops:
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
for neighbor, step in self.adj.get(curr, []):
|
|
204
|
+
if neighbor not in visited:
|
|
205
|
+
queue.append((neighbor, steps + [step], visited | {neighbor}))
|
|
206
|
+
|
|
207
|
+
return all_paths
|
|
208
|
+
|
|
179
209
|
def find_connecting_path_for_tables(self, table_names: List[str]) -> List[JoinStep]:
|
|
180
210
|
"""
|
|
181
211
|
Given a set of seed tables (e.g. ['users', 'events']), finds the minimal join tree / path
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Persistent Knowledge Base & Self-Learning Memory Engine.
|
|
3
|
+
Stores domain rules, verified multi-hop relationship paths, and custom business logic per database in ~/.dbagent/knowledge/<db_name>.json.
|
|
4
|
+
Automatically injects learned domain knowledge into LLM generation context.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, Any, List, Optional
|
|
11
|
+
from dataclasses import dataclass, field, asdict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class LearnedRule:
|
|
16
|
+
"""A verified domain rule or relationship path learned from user interaction."""
|
|
17
|
+
rule_id: str
|
|
18
|
+
topic: str
|
|
19
|
+
rule_description: str
|
|
20
|
+
relevant_tables: List[str] = field(default_factory=list)
|
|
21
|
+
created_at: str = ""
|
|
22
|
+
source: str = "user_feedback"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class KnowledgeBase:
|
|
26
|
+
"""
|
|
27
|
+
Persistent Knowledge Base & Self-Learning Memory for DB-Agent.
|
|
28
|
+
Stores and retrieves domain-specific relationship insights per database in ~/.dbagent/knowledge/<db_name>.json.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, storage_dir: Optional[Path] = None):
|
|
32
|
+
self.storage_dir = storage_dir or Path.home() / ".dbagent" / "knowledge"
|
|
33
|
+
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
|
|
35
|
+
def _get_db_file(self, db_name: str) -> Path:
|
|
36
|
+
safe_name = "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in db_name.lower())
|
|
37
|
+
return self.storage_dir / f"{safe_name}.json"
|
|
38
|
+
|
|
39
|
+
def learn(
|
|
40
|
+
self,
|
|
41
|
+
db_name: str,
|
|
42
|
+
topic: str,
|
|
43
|
+
rule_description: str,
|
|
44
|
+
relevant_tables: Optional[List[str]] = None,
|
|
45
|
+
source: str = "user_feedback",
|
|
46
|
+
) -> LearnedRule:
|
|
47
|
+
"""Persist a new learned domain rule or mapping path."""
|
|
48
|
+
db_file = self._get_db_file(db_name)
|
|
49
|
+
rules = self.list_all(db_name)
|
|
50
|
+
|
|
51
|
+
# Check if identical topic exists and update, else create new
|
|
52
|
+
existing_idx = None
|
|
53
|
+
for i, r in enumerate(rules):
|
|
54
|
+
if r.topic.lower().strip() == topic.lower().strip():
|
|
55
|
+
existing_idx = i
|
|
56
|
+
break
|
|
57
|
+
|
|
58
|
+
rule_id = f"rule_{len(rules) + 1}_{int(time.time())}"
|
|
59
|
+
new_rule = LearnedRule(
|
|
60
|
+
rule_id=rule_id,
|
|
61
|
+
topic=topic.strip(),
|
|
62
|
+
rule_description=rule_description.strip(),
|
|
63
|
+
relevant_tables=[t.lower() for t in (relevant_tables or [])],
|
|
64
|
+
created_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
65
|
+
source=source,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if existing_idx is not None:
|
|
69
|
+
rules[existing_idx] = new_rule
|
|
70
|
+
else:
|
|
71
|
+
rules.append(new_rule)
|
|
72
|
+
|
|
73
|
+
with open(db_file, "w", encoding="utf-8") as f:
|
|
74
|
+
json.dump([asdict(r) for r in rules], f, indent=2)
|
|
75
|
+
return new_rule
|
|
76
|
+
|
|
77
|
+
def list_all(self, db_name: str) -> List[LearnedRule]:
|
|
78
|
+
"""List all learned rules for a database."""
|
|
79
|
+
db_file = self._get_db_file(db_name)
|
|
80
|
+
if not db_file.exists():
|
|
81
|
+
return []
|
|
82
|
+
try:
|
|
83
|
+
with open(db_file, "r", encoding="utf-8") as f:
|
|
84
|
+
data = json.load(f)
|
|
85
|
+
return [LearnedRule(**item) for item in data]
|
|
86
|
+
except Exception:
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
def delete(self, db_name: str, target: str) -> bool:
|
|
90
|
+
"""Delete a rule by ID or topic."""
|
|
91
|
+
rules = self.list_all(db_name)
|
|
92
|
+
target_lower = target.lower().strip()
|
|
93
|
+
filtered = [r for r in rules if r.rule_id != target and r.topic.lower() != target_lower]
|
|
94
|
+
if len(filtered) < len(rules):
|
|
95
|
+
db_file = self._get_db_file(db_name)
|
|
96
|
+
with open(db_file, "w", encoding="utf-8") as f:
|
|
97
|
+
json.dump([asdict(r) for r in filtered], f, indent=2)
|
|
98
|
+
return True
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
def clear(self, db_name: str) -> None:
|
|
102
|
+
"""Clear all learned rules for a database."""
|
|
103
|
+
db_file = self._get_db_file(db_name)
|
|
104
|
+
if db_file.exists():
|
|
105
|
+
db_file.unlink()
|
|
106
|
+
|
|
107
|
+
def find_relevant(self, db_name: str, user_prompt: str, table_names: Optional[List[str]] = None) -> List[LearnedRule]:
|
|
108
|
+
"""Finds learned rules relevant to the user prompt or active tables."""
|
|
109
|
+
all_rules = self.list_all(db_name)
|
|
110
|
+
if not all_rules:
|
|
111
|
+
return []
|
|
112
|
+
|
|
113
|
+
prompt_lower = user_prompt.lower()
|
|
114
|
+
active_tables = {t.lower() for t in (table_names or [])}
|
|
115
|
+
matched = []
|
|
116
|
+
|
|
117
|
+
for r in all_rules:
|
|
118
|
+
# 1. Match by topic in prompt
|
|
119
|
+
if r.topic.lower() in prompt_lower:
|
|
120
|
+
matched.append(r)
|
|
121
|
+
continue
|
|
122
|
+
# 2. Match by relevant tables
|
|
123
|
+
if any(t in active_tables or t in prompt_lower for t in r.relevant_tables):
|
|
124
|
+
matched.append(r)
|
|
125
|
+
continue
|
|
126
|
+
# 3. Keyword overlap
|
|
127
|
+
keywords = [w for w in r.topic.lower().split() if len(w) > 3]
|
|
128
|
+
if any(kw in prompt_lower for kw in keywords):
|
|
129
|
+
matched.append(r)
|
|
130
|
+
|
|
131
|
+
return matched
|
|
132
|
+
|
|
133
|
+
def format_prompt_context(self, db_name: str, user_prompt: str, table_names: Optional[List[str]] = None) -> str:
|
|
134
|
+
"""Format learned domain knowledge for LLM prompt context injection."""
|
|
135
|
+
matched = self.find_relevant(db_name, user_prompt, table_names)
|
|
136
|
+
if not matched:
|
|
137
|
+
return ""
|
|
138
|
+
|
|
139
|
+
lines = [
|
|
140
|
+
"### 🧠 Persistent Database Domain Knowledge & Verified Mappings (Learned from User Feedback):"
|
|
141
|
+
]
|
|
142
|
+
for m in matched:
|
|
143
|
+
tbl_str = f" [Tables: {', '.join(m.relevant_tables)}]" if m.relevant_tables else ""
|
|
144
|
+
lines.append(f"- **{m.topic}**{tbl_str}: {m.rule_description}")
|
|
145
|
+
lines.append("Always apply these verified mapping paths and domain rules when generating SQL or architectural explanations.\n")
|
|
146
|
+
return "\n".join(lines)
|
|
@@ -49,6 +49,7 @@ dbagent/security/modes.py
|
|
|
49
49
|
dbagent/security/rbac.py
|
|
50
50
|
dbagent/semantic/__init__.py
|
|
51
51
|
dbagent/semantic/dictionary.py
|
|
52
|
+
dbagent/semantic/knowledge.py
|
|
52
53
|
dbagent/semantic/templates.py
|
|
53
54
|
dbagent/sql/__init__.py
|
|
54
55
|
dbagent/sql/optimizer.py
|
|
@@ -71,6 +72,7 @@ tests/test_direct_sql.py
|
|
|
71
72
|
tests/test_doctor_and_viz.py
|
|
72
73
|
tests/test_generator.py
|
|
73
74
|
tests/test_graph.py
|
|
75
|
+
tests/test_knowledge.py
|
|
74
76
|
tests/test_providers.py
|
|
75
77
|
tests/test_recovery.py
|
|
76
78
|
tests/test_safety.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dbagent-cli"
|
|
7
|
-
version = "0.9.
|
|
7
|
+
version = "0.9.1"
|
|
8
8
|
description = "Enterprise AI Database Copilot, Schema Graph Introspector & Autonomous Recovery Agent (CLI)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for Persistent KnowledgeBase & Multi-Hop find_all_paths.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from dbagent.semantic.knowledge import KnowledgeBase, LearnedRule
|
|
8
|
+
from dbagent.schema.graph import RelationshipGraph
|
|
9
|
+
from dbagent.schema.models import DatabaseSchema, TableModel, ColumnModel, ForeignKeyModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def temp_kb(tmp_path):
|
|
14
|
+
return KnowledgeBase(storage_dir=tmp_path)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_knowledge_base_learn_and_find(temp_kb):
|
|
18
|
+
rule = temp_kb.learn(
|
|
19
|
+
db_name="test_db",
|
|
20
|
+
topic="Site to User Group Mapping",
|
|
21
|
+
rule_description="ivis_site maps to ivis_user through ivis_site_group -> ivis_group -> ivis_user_group_map",
|
|
22
|
+
relevant_tables=["ivis_site", "ivis_site_group", "ivis_group", "ivis_user_group_map", "ivis_user"],
|
|
23
|
+
source="user_correction",
|
|
24
|
+
)
|
|
25
|
+
assert rule.rule_id.startswith("rule_")
|
|
26
|
+
assert rule.topic == "Site to User Group Mapping"
|
|
27
|
+
|
|
28
|
+
# List all
|
|
29
|
+
all_rules = temp_kb.list_all("test_db")
|
|
30
|
+
assert len(all_rules) == 1
|
|
31
|
+
|
|
32
|
+
# Find relevant by prompt
|
|
33
|
+
matched = temp_kb.find_relevant("test_db", "how to map sites to user")
|
|
34
|
+
assert len(matched) == 1
|
|
35
|
+
assert matched[0].topic == "Site to User Group Mapping"
|
|
36
|
+
|
|
37
|
+
# Context formatting
|
|
38
|
+
ctx = temp_kb.format_prompt_context("test_db", "how to map sites to user")
|
|
39
|
+
assert "Persistent Database Domain Knowledge" in ctx
|
|
40
|
+
assert "Site to User Group Mapping" in ctx
|
|
41
|
+
|
|
42
|
+
# Delete
|
|
43
|
+
deleted = temp_kb.delete("test_db", rule.rule_id)
|
|
44
|
+
assert deleted is True
|
|
45
|
+
assert len(temp_kb.list_all("test_db")) == 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_relationship_graph_find_all_paths():
|
|
49
|
+
schema = DatabaseSchema(
|
|
50
|
+
dialect_name="postgresql",
|
|
51
|
+
database_name="test_db",
|
|
52
|
+
tables=[
|
|
53
|
+
TableModel(
|
|
54
|
+
name="users",
|
|
55
|
+
columns=[ColumnModel(name="id", data_type="INT", is_primary_key=True)],
|
|
56
|
+
primary_key=["id"],
|
|
57
|
+
),
|
|
58
|
+
TableModel(
|
|
59
|
+
name="user_groups",
|
|
60
|
+
columns=[
|
|
61
|
+
ColumnModel(name="user_id", data_type="INT"),
|
|
62
|
+
ColumnModel(name="group_id", data_type="INT"),
|
|
63
|
+
],
|
|
64
|
+
foreign_keys=[
|
|
65
|
+
ForeignKeyModel(name="fk_u", constrained_columns=["user_id"], referred_table="users", referred_columns=["id"]),
|
|
66
|
+
ForeignKeyModel(name="fk_g", constrained_columns=["group_id"], referred_table="groups", referred_columns=["id"]),
|
|
67
|
+
],
|
|
68
|
+
),
|
|
69
|
+
TableModel(
|
|
70
|
+
name="groups",
|
|
71
|
+
columns=[ColumnModel(name="id", data_type="INT", is_primary_key=True)],
|
|
72
|
+
primary_key=["id"],
|
|
73
|
+
),
|
|
74
|
+
],
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
graph = RelationshipGraph(schema)
|
|
78
|
+
paths = graph.find_all_paths("users", "groups", max_hops=3)
|
|
79
|
+
assert len(paths) >= 1
|
|
80
|
+
assert paths[0].tables == ["users", "user_groups", "groups"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|