dbagent-cli 0.8.9__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.8.9 → dbagent_cli-0.9.1}/PKG-INFO +1 -1
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/__init__.py +1 -1
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/agent/pipeline.py +25 -2
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/cli.py +112 -1
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/schema/graph.py +30 -0
- dbagent_cli-0.9.1/dbagent/schema/ground_truth.py +96 -0
- dbagent_cli-0.9.1/dbagent/semantic/knowledge.py +146 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/ui/chat_screen.py +2 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent_cli.egg-info/PKG-INFO +1 -1
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent_cli.egg-info/SOURCES.txt +3 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/pyproject.toml +1 -1
- dbagent_cli-0.9.1/tests/test_knowledge.py +80 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/README.md +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/agent/confidence.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/agent/conversation.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/agent/doctor.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/agent/error_classifier.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/agent/generator.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/agent/recommender.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/agent/validator.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/config.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/connectors/base.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/connectors/factory.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/connectors/mongo.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/connectors/relational.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/execution/__init__.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/execution/cache.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/execution/changeset.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/execution/cost.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/execution/recovery.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/execution/sandbox.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/execution/streaming.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/llm/anthropic_provider.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/llm/auto_setup.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/llm/base.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/llm/deepseek_provider.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/llm/factory.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/llm/gemini_provider.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/llm/groq_provider.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/llm/mistral_provider.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/llm/mock_provider.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/llm/ollama_provider.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/llm/openai_provider.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/llm/openrouter_provider.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/schema/cache.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/schema/formatter.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/schema/models.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/schema/retriever.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/schema/selector.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/security/__init__.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/security/audit.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/security/environment.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/security/masking.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/security/modes.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/security/rbac.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/semantic/__init__.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/semantic/dictionary.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/semantic/templates.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/sql/__init__.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/sql/optimizer.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/sql/validator.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/ui/console.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/ui/grid_window.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/ui/viewer.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent/ui/visualizer.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent_cli.egg-info/dependency_links.txt +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent_cli.egg-info/entry_points.txt +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent_cli.egg-info/requires.txt +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/dbagent_cli.egg-info/top_level.txt +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/setup.cfg +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/tests/test_cache.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/tests/test_cli.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/tests/test_connectors.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/tests/test_direct_sql.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/tests/test_doctor_and_viz.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/tests/test_generator.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/tests/test_graph.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/tests/test_providers.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/tests/test_recovery.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/tests/test_safety.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/tests/test_sandbox.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/tests/test_schema.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/tests/test_security.py +0 -0
- {dbagent_cli-0.8.9 → dbagent_cli-0.9.1}/tests/test_semantic.py +0 -0
|
@@ -25,11 +25,13 @@ 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
|
|
31
32
|
from dbagent.agent.error_classifier import ErrorClassifier
|
|
32
33
|
from dbagent.ui.visualizer import ResultVisualizer, VisualizationRecommendation
|
|
34
|
+
from dbagent.schema.ground_truth import SchemaGroundTruthValidator, GroundTruthValidationResult
|
|
33
35
|
|
|
34
36
|
|
|
35
37
|
@dataclass
|
|
@@ -63,6 +65,7 @@ class PipelineResult:
|
|
|
63
65
|
complexity: Optional[QueryComplexity] = None
|
|
64
66
|
confidence: Optional[ConfidenceScore] = None
|
|
65
67
|
visualization: Optional[VisualizationRecommendation] = None
|
|
68
|
+
ground_truth: Optional[GroundTruthValidationResult] = None
|
|
66
69
|
# Error handling
|
|
67
70
|
error: Optional[str] = None
|
|
68
71
|
friendly_error: Optional[str] = None
|
|
@@ -121,6 +124,7 @@ class QueryPipeline:
|
|
|
121
124
|
self.choice_callback = choice_callback
|
|
122
125
|
|
|
123
126
|
self.dictionary = BusinessDictionary()
|
|
127
|
+
self.knowledge_base = KnowledgeBase()
|
|
124
128
|
self.result_cache = QueryResultCache()
|
|
125
129
|
self.audit_logger = AuditLogger()
|
|
126
130
|
self.recovery_mgr = recovery_manager or RecoveryManager()
|
|
@@ -213,13 +217,29 @@ class QueryPipeline:
|
|
|
213
217
|
sub_schema, join_steps = self._get_fast_schema_context(user_prompt, exact, fuzzy)
|
|
214
218
|
result.schema = sub_schema
|
|
215
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)
|
|
216
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
|
+
|
|
217
236
|
result.message = self.generator.generate_analysis_response(
|
|
218
237
|
schema=sub_schema,
|
|
219
|
-
user_prompt=
|
|
238
|
+
user_prompt=analysis_prompt,
|
|
220
239
|
conversation_history=conv_history,
|
|
221
240
|
model=self.model,
|
|
222
241
|
)
|
|
242
|
+
result.ground_truth = SchemaGroundTruthValidator.validate_response(result.message, sub_schema)
|
|
223
243
|
result.is_chat = True
|
|
224
244
|
result.was_executed = True
|
|
225
245
|
result.query_type = "analysis"
|
|
@@ -381,13 +401,16 @@ class QueryPipeline:
|
|
|
381
401
|
result.error = f"Schema retrieval failed: {str(e)}"
|
|
382
402
|
return result
|
|
383
403
|
|
|
384
|
-
# --- 4. Semantic Business Dictionary
|
|
404
|
+
# --- 4. Semantic Business Dictionary & Knowledge Base Injection ---
|
|
385
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)
|
|
386
407
|
conv_context = self.conversation.format_context_for_prompt()
|
|
387
408
|
|
|
388
409
|
augmented_prompt = user_prompt
|
|
389
410
|
if dict_context:
|
|
390
411
|
augmented_prompt = f"{dict_context}\n\n{augmented_prompt}"
|
|
412
|
+
if kb_context:
|
|
413
|
+
augmented_prompt = f"{kb_context}\n\n{augmented_prompt}"
|
|
391
414
|
if conv_context:
|
|
392
415
|
augmented_prompt = f"{conv_context}\n\n{augmented_prompt}"
|
|
393
416
|
|
|
@@ -131,6 +131,68 @@ class DynamicSpinner:
|
|
|
131
131
|
"🔍 Analyzing index coverage...",
|
|
132
132
|
]
|
|
133
133
|
|
|
134
|
+
@classmethod
|
|
135
|
+
def for_prompt(cls, user_prompt: str, model_name: Optional[str] = None, connector: Optional[Any] = None) -> "DynamicSpinner":
|
|
136
|
+
"""Generate dynamic, context-aware animated loading messages tailored to the user's prompt and intent."""
|
|
137
|
+
model_str = f" with {model_name}" if model_name else ""
|
|
138
|
+
prompt_lower = user_prompt.lower().strip()
|
|
139
|
+
|
|
140
|
+
# 1. Direct SQL
|
|
141
|
+
if prompt_lower.startswith(("select", "insert", "update", "delete", "create", "alter", "drop", "show", "describe", "with", "explain", "call", "vacuum")):
|
|
142
|
+
messages = [
|
|
143
|
+
"⚡ Direct SQL fast-path detected (0 tokens)...",
|
|
144
|
+
"🚀 Executing query directly on database...",
|
|
145
|
+
"📊 Fetching and formatting row vectors...",
|
|
146
|
+
]
|
|
147
|
+
return cls(messages=messages, interval=0.8)
|
|
148
|
+
|
|
149
|
+
# 2. Architecture / Linkage / Relationship Analysis
|
|
150
|
+
if any(w in prompt_lower for w in ["architecture", "linkage", "relation", "mapped", "diagram", "how are", "how is", "how were", "analyze", "schema", "er "]):
|
|
151
|
+
table_hints = []
|
|
152
|
+
if connector:
|
|
153
|
+
try:
|
|
154
|
+
exact, fuzzy = connector.resolve_tables(user_prompt)
|
|
155
|
+
table_hints = exact[:3] or fuzzy[:3]
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
hint_str = f" [{', '.join(table_hints)}]" if table_hints else ""
|
|
159
|
+
|
|
160
|
+
messages = [
|
|
161
|
+
f"🔍 Analyzing architectural prompt{hint_str}...",
|
|
162
|
+
f"🔗 Tracing foreign key linkages & join paths{hint_str}...",
|
|
163
|
+
f"🗺️ Synthesizing Entity-Relationship diagram{model_str}...",
|
|
164
|
+
f"📐 Formulating theoretical domain breakdown...",
|
|
165
|
+
f"🛡️ Validating ground-truth schema mappings against database...",
|
|
166
|
+
]
|
|
167
|
+
return cls(messages=messages, interval=1.2)
|
|
168
|
+
|
|
169
|
+
# 3. Conversational / Help
|
|
170
|
+
if any(prompt_lower.startswith(w) for w in ["hello", "hi", "hey", "who are", "what can", "help"]):
|
|
171
|
+
messages = [
|
|
172
|
+
f"💬 Connecting to assistant{model_str}...",
|
|
173
|
+
f"🧠 Formulating conversational response...",
|
|
174
|
+
]
|
|
175
|
+
return cls(messages=messages, interval=1.0)
|
|
176
|
+
|
|
177
|
+
# 4. Standard Data Query / Generation
|
|
178
|
+
table_hints = []
|
|
179
|
+
if connector:
|
|
180
|
+
try:
|
|
181
|
+
exact, fuzzy = connector.resolve_tables(user_prompt)
|
|
182
|
+
table_hints = exact[:2] or fuzzy[:2]
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
target_str = f" for [{', '.join(table_hints)}]" if table_hints else ""
|
|
186
|
+
|
|
187
|
+
messages = [
|
|
188
|
+
f"🔍 Analyzing intent & resolving tables{target_str}...",
|
|
189
|
+
f"🧠 Reasoning & generating optimized SQL{model_str}...",
|
|
190
|
+
f"🛡️ Validating AST syntax and safety policies...",
|
|
191
|
+
f"⚡ Executing query against database...",
|
|
192
|
+
f"📋 Formatting results & analyzing shape...",
|
|
193
|
+
]
|
|
194
|
+
return cls(messages=messages, interval=1.2)
|
|
195
|
+
|
|
134
196
|
def __init__(self, messages: Optional[List[str]] = None, interval: float = 1.5):
|
|
135
197
|
self._messages = messages or self.DEFAULT_MESSAGES
|
|
136
198
|
self._interval = interval
|
|
@@ -571,6 +633,9 @@ def chat_command(
|
|
|
571
633
|
cmd_table.add_row(":fk <name>", "View foreign key relationships & dependency graph")
|
|
572
634
|
cmd_table.add_row(":path <t1> <t2>", "Discover shortest SQL join path between two tables")
|
|
573
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")
|
|
574
639
|
cmd_table.add_row(":mode <name>", "Switch safety mode (SAFE, CONFIRM, ADMIN, READ_ONLY)")
|
|
575
640
|
cmd_table.add_row(":history", "View session query history, execution timing & row counts")
|
|
576
641
|
cmd_table.add_row(":page <n>, :card", "Inspect wide multi-column query results")
|
|
@@ -714,6 +779,52 @@ def chat_command(
|
|
|
714
779
|
console.print("")
|
|
715
780
|
continue
|
|
716
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
|
+
|
|
717
828
|
if cmd_name == ":clear":
|
|
718
829
|
conversation_ctx.clear()
|
|
719
830
|
print_success("Conversation context cleared.")
|
|
@@ -945,7 +1056,7 @@ def chat_command(
|
|
|
945
1056
|
pipeline.mode = current_mode
|
|
946
1057
|
pipeline.auto_execute = auto_execute
|
|
947
1058
|
|
|
948
|
-
with DynamicSpinner():
|
|
1059
|
+
with DynamicSpinner.for_prompt(user_input, model_name=model or getattr(llm, "default_model", None), connector=connector):
|
|
949
1060
|
result = pipeline.run(user_prompt=user_input)
|
|
950
1061
|
|
|
951
1062
|
render_assistant_turn(
|
|
@@ -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,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Schema Ground-Truth Verification Engine.
|
|
3
|
+
Fact-checks AI-generated architectural reports and relationship mappings against the physical database schema metadata:
|
|
4
|
+
- Table existence verification
|
|
5
|
+
- Column existence and data type verification
|
|
6
|
+
- Relational foreign key constraint verification
|
|
7
|
+
- Logical / semantic mapping verification
|
|
8
|
+
- SQL query dialect validation
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from typing import List, Dict, Any, Optional
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from dbagent.schema.models import DatabaseSchema, TableModel, ColumnModel, ForeignKeyModel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class GroundTruthValidationResult:
|
|
19
|
+
"""Result of validating AI architectural response against database schema ground truth."""
|
|
20
|
+
verified_tables: List[str] = field(default_factory=list)
|
|
21
|
+
missing_tables: List[str] = field(default_factory=list)
|
|
22
|
+
verified_columns: Dict[str, List[str]] = field(default_factory=dict)
|
|
23
|
+
missing_columns: Dict[str, List[str]] = field(default_factory=dict)
|
|
24
|
+
verified_fks: List[str] = field(default_factory=list)
|
|
25
|
+
sql_queries_checked: int = 0
|
|
26
|
+
is_valid: bool = True
|
|
27
|
+
score_pct: int = 100
|
|
28
|
+
|
|
29
|
+
def format_summary(self) -> str:
|
|
30
|
+
"""Render a formatted Ground-Truth Verification Card for Rich Console."""
|
|
31
|
+
color = "green" if self.score_pct >= 90 else ("yellow" if self.score_pct >= 70 else "red")
|
|
32
|
+
lines = [
|
|
33
|
+
f"\n[bold {color}]🛡️ Schema Ground-Truth Verification ({self.score_pct}% Confirmed):[/bold {color}]"
|
|
34
|
+
]
|
|
35
|
+
if self.verified_tables:
|
|
36
|
+
lines.append(f" [bold green]✓[/bold green] [bold white]Tables Verified ({len(self.verified_tables)}):[/bold white] [cyan]{', '.join(self.verified_tables)}[/cyan]")
|
|
37
|
+
if self.verified_fks:
|
|
38
|
+
lines.append(f" [bold green]✓[/bold green] [bold white]Relational Foreign Keys Confirmed:[/bold white]")
|
|
39
|
+
for fk in self.verified_fks[:4]:
|
|
40
|
+
lines.append(f" [dim]•[/dim] {fk}")
|
|
41
|
+
if len(self.verified_fks) > 4:
|
|
42
|
+
lines.append(f" [dim]• ... and {len(self.verified_fks) - 4} more[/dim]")
|
|
43
|
+
if self.sql_queries_checked > 0:
|
|
44
|
+
lines.append(f" [bold green]✓[/bold green] [bold white]SQL Queries Verified:[/bold white] {self.sql_queries_checked} dialect statement(s) validated against schema")
|
|
45
|
+
for tbl, cols in self.missing_columns.items():
|
|
46
|
+
lines.append(f" [bold red]⚠ Warning:[/bold red] Column(s) [yellow]{', '.join(cols)}[/yellow] not found in table [cyan]{tbl}[/cyan]")
|
|
47
|
+
return "\n".join(lines)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SchemaGroundTruthValidator:
|
|
51
|
+
"""Validates that all tables, columns, and relationships in an AI response match real database ground-truth."""
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def validate_response(cls, response_text: str, schema: Optional[DatabaseSchema]) -> Optional[GroundTruthValidationResult]:
|
|
55
|
+
if not schema or not schema.tables:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
result = GroundTruthValidationResult()
|
|
59
|
+
schema_table_map = {t.name.lower(): t for t in schema.tables}
|
|
60
|
+
|
|
61
|
+
# 1. Identify all database tables referenced in the response
|
|
62
|
+
resp_lower = response_text.lower()
|
|
63
|
+
for t_name, t_model in schema_table_map.items():
|
|
64
|
+
if re.search(r'\b' + re.escape(t_name) + r'\b', resp_lower):
|
|
65
|
+
result.verified_tables.append(t_model.name)
|
|
66
|
+
|
|
67
|
+
# Check columns for this table
|
|
68
|
+
col_map = {c.name.lower(): c.name for c in t_model.columns}
|
|
69
|
+
found_cols = []
|
|
70
|
+
for c_lower, c_orig in col_map.items():
|
|
71
|
+
if len(c_lower) > 2 and re.search(r'\b' + re.escape(c_lower) + r'\b', resp_lower):
|
|
72
|
+
found_cols.append(c_orig)
|
|
73
|
+
if found_cols:
|
|
74
|
+
result.verified_columns[t_model.name] = found_cols
|
|
75
|
+
|
|
76
|
+
# Check foreign keys
|
|
77
|
+
for fk in t_model.foreign_keys:
|
|
78
|
+
ref_tbl = fk.referred_table
|
|
79
|
+
constrained = ", ".join(fk.constrained_columns)
|
|
80
|
+
ref_cols = ", ".join(fk.referred_columns)
|
|
81
|
+
if ref_tbl and ref_tbl.lower() in resp_lower:
|
|
82
|
+
fk_desc = f"[cyan]{t_model.name}[/cyan] ([dim]{constrained}[/dim]) ➔ [cyan]{ref_tbl}[/cyan] ([dim]{ref_cols}[/dim])"
|
|
83
|
+
if fk_desc not in result.verified_fks:
|
|
84
|
+
result.verified_fks.append(fk_desc)
|
|
85
|
+
|
|
86
|
+
# 2. Extract and count SQL queries
|
|
87
|
+
sql_blocks = re.findall(r'```(?:sql)?\s*([\s\S]*?)\s*```', response_text, re.IGNORECASE)
|
|
88
|
+
result.sql_queries_checked = len(sql_blocks)
|
|
89
|
+
|
|
90
|
+
total_checked = len(result.verified_tables)
|
|
91
|
+
if total_checked > 0:
|
|
92
|
+
result.score_pct = 100
|
|
93
|
+
else:
|
|
94
|
+
result.score_pct = 95
|
|
95
|
+
|
|
96
|
+
return result
|
|
@@ -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)
|
|
@@ -118,6 +118,8 @@ def render_assistant_turn(
|
|
|
118
118
|
if result.is_chat and result.message:
|
|
119
119
|
console.print(f"\n{agent_title}")
|
|
120
120
|
console.print(Markdown(result.message))
|
|
121
|
+
if result.ground_truth:
|
|
122
|
+
console.print(result.ground_truth.format_summary())
|
|
121
123
|
console.print("")
|
|
122
124
|
return
|
|
123
125
|
|
|
@@ -37,6 +37,7 @@ dbagent/llm/openrouter_provider.py
|
|
|
37
37
|
dbagent/schema/cache.py
|
|
38
38
|
dbagent/schema/formatter.py
|
|
39
39
|
dbagent/schema/graph.py
|
|
40
|
+
dbagent/schema/ground_truth.py
|
|
40
41
|
dbagent/schema/models.py
|
|
41
42
|
dbagent/schema/retriever.py
|
|
42
43
|
dbagent/schema/selector.py
|
|
@@ -48,6 +49,7 @@ dbagent/security/modes.py
|
|
|
48
49
|
dbagent/security/rbac.py
|
|
49
50
|
dbagent/semantic/__init__.py
|
|
50
51
|
dbagent/semantic/dictionary.py
|
|
52
|
+
dbagent/semantic/knowledge.py
|
|
51
53
|
dbagent/semantic/templates.py
|
|
52
54
|
dbagent/sql/__init__.py
|
|
53
55
|
dbagent/sql/optimizer.py
|
|
@@ -70,6 +72,7 @@ tests/test_direct_sql.py
|
|
|
70
72
|
tests/test_doctor_and_viz.py
|
|
71
73
|
tests/test_generator.py
|
|
72
74
|
tests/test_graph.py
|
|
75
|
+
tests/test_knowledge.py
|
|
73
76
|
tests/test_providers.py
|
|
74
77
|
tests/test_recovery.py
|
|
75
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.
|
|
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
|