dbagent-cli 0.9.0__tar.gz → 0.9.2__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.2}/PKG-INFO +1 -1
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/__init__.py +1 -1
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/agent/conversation.py +5 -1
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/agent/generator.py +41 -25
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/agent/pipeline.py +35 -3
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/cli.py +49 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/connectors/base.py +32 -4
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/schema/graph.py +30 -0
- dbagent_cli-0.9.2/dbagent/semantic/knowledge.py +146 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent_cli.egg-info/PKG-INFO +1 -1
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent_cli.egg-info/SOURCES.txt +2 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/pyproject.toml +1 -1
- dbagent_cli-0.9.2/tests/test_knowledge.py +80 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/README.md +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/agent/confidence.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/agent/doctor.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/agent/error_classifier.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/agent/recommender.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/agent/validator.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/config.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/connectors/factory.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/connectors/mongo.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/connectors/relational.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/execution/__init__.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/execution/cache.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/execution/changeset.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/execution/cost.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/execution/recovery.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/execution/sandbox.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/execution/streaming.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/llm/anthropic_provider.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/llm/auto_setup.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/llm/base.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/llm/deepseek_provider.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/llm/factory.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/llm/gemini_provider.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/llm/groq_provider.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/llm/mistral_provider.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/llm/mock_provider.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/llm/ollama_provider.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/llm/openai_provider.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/llm/openrouter_provider.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/schema/cache.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/schema/formatter.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/schema/ground_truth.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/schema/models.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/schema/retriever.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/schema/selector.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/security/__init__.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/security/audit.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/security/environment.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/security/masking.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/security/modes.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/security/rbac.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/semantic/__init__.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/semantic/dictionary.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/semantic/templates.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/sql/__init__.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/sql/optimizer.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/sql/validator.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/ui/chat_screen.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/ui/console.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/ui/grid_window.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/ui/viewer.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent/ui/visualizer.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent_cli.egg-info/dependency_links.txt +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent_cli.egg-info/entry_points.txt +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent_cli.egg-info/requires.txt +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/dbagent_cli.egg-info/top_level.txt +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/setup.cfg +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/tests/test_cache.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/tests/test_cli.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/tests/test_connectors.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/tests/test_direct_sql.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/tests/test_doctor_and_viz.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/tests/test_generator.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/tests/test_graph.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/tests/test_providers.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/tests/test_recovery.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/tests/test_safety.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/tests/test_sandbox.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/tests/test_schema.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/tests/test_security.py +0 -0
- {dbagent_cli-0.9.0 → dbagent_cli-0.9.2}/tests/test_semantic.py +0 -0
|
@@ -54,9 +54,13 @@ class ConversationContext:
|
|
|
54
54
|
if len(self.turns) > self.max_history:
|
|
55
55
|
self.turns.pop(0)
|
|
56
56
|
|
|
57
|
-
if tables
|
|
57
|
+
if tables:
|
|
58
58
|
self.active_tables.update(t.lower() for t in tables)
|
|
59
59
|
|
|
60
|
+
def get_active_tables(self) -> List[str]:
|
|
61
|
+
"""Returns currently active database table names discussed in this session."""
|
|
62
|
+
return list(self.active_tables)
|
|
63
|
+
|
|
60
64
|
def get_last_turn(self) -> Optional[ConversationTurn]:
|
|
61
65
|
if self.turns:
|
|
62
66
|
return self.turns[-1]
|
|
@@ -106,6 +106,11 @@ Provide ready-to-run, dialect-accurate SQL JOIN queries answering the user's spe
|
|
|
106
106
|
|
|
107
107
|
### 4. 💡 Architectural Insights & Best Practices
|
|
108
108
|
Mention indexing recommendations, cascade rules, or performance considerations for these join paths.
|
|
109
|
+
|
|
110
|
+
### 🛑 Critical Operational Directives:
|
|
111
|
+
1. NEVER ask passive questions like "Would you like me to inspect tables?" or "Should I run a query?". Always proactively inspect and deliver the full architectural blueprint immediately.
|
|
112
|
+
2. NEVER give generic textbook definitions. Always ground every explanation in the real database schema provided above.
|
|
113
|
+
3. Always include ALL 4 sections (Diagram, Theoretical Breakdown, SQL Queries, and Insights).
|
|
109
114
|
"""
|
|
110
115
|
|
|
111
116
|
|
|
@@ -142,37 +147,52 @@ class ScriptGenerator:
|
|
|
142
147
|
prompt_lower = user_prompt.lower().strip()
|
|
143
148
|
clean_prompt = prompt_lower.rstrip(".!?,:;~ ")
|
|
144
149
|
|
|
145
|
-
#
|
|
146
|
-
chat_triggers = [
|
|
147
|
-
"hello", "hi", "hey", "howdy", "greetings", "good morning", "good evening", "good afternoon",
|
|
148
|
-
"who are you", "what can you do", "what are you", "help me", "how to use",
|
|
149
|
-
"what is this", "tell me about this database", "how does this work",
|
|
150
|
-
"can you help", "thanks", "thank you", "thx", "bye", "goodbye", "see ya",
|
|
151
|
-
"good", "great", "nice", "cool", "awesome", "perfect", "ok", "okay",
|
|
152
|
-
"yes", "no", "sure", "got it", "understood", "nice work", "well done",
|
|
153
|
-
"sounds good", "looks good", "alright", "super", "neat", "excellent",
|
|
154
|
-
"amazing", "wonderful", "cool thanks", "great thanks", "thanks a lot",
|
|
155
|
-
]
|
|
156
|
-
for trig in chat_triggers:
|
|
157
|
-
if clean_prompt == trig or prompt_lower.startswith(f"{trig} ") or prompt_lower.startswith(f"{trig},") or prompt_lower.startswith(f"{trig}!"):
|
|
158
|
-
return "chat"
|
|
159
|
-
|
|
160
|
-
# Architecture / Relationship / Linkage / Analysis indicators
|
|
150
|
+
# 1. Architecture / Relationship / Linkage / Analysis indicators (Highest Priority)
|
|
161
151
|
analysis_keywords = [
|
|
162
152
|
"linkage architecture", "linkage", "architecture", "relationship between",
|
|
163
153
|
"how are", "how is", "how were", "how they were related", "how were they related",
|
|
164
|
-
"how they were mapped", "how to map", "mapped
|
|
154
|
+
"how they were mapped", "how to map", "where they mapped", "where is it mapped",
|
|
155
|
+
"how they were assigned", "how are they assigned", "how will be linked", "how is it linked",
|
|
156
|
+
"mapped to", "mapped with", "er diagram", "diagram of",
|
|
165
157
|
"data model", "explain relationship", "explain schema", "schema analysis",
|
|
166
158
|
"analyze the database", "analyze database", "table relationship",
|
|
167
159
|
"foreign key relationship", "how are tables related", "which sites in",
|
|
168
160
|
"how to know that which", "how to know which", "mapping architecture",
|
|
169
161
|
"entity relationship", "theoritical information", "theoretical information",
|
|
162
|
+
"check with my previous context", "check with previous context", "previous context",
|
|
163
|
+
"complete architecture", "scope architecture", "tenant level to user level",
|
|
164
|
+
"tenant level", "user level",
|
|
170
165
|
]
|
|
171
166
|
for kw in analysis_keywords:
|
|
172
167
|
if kw in prompt_lower:
|
|
173
168
|
return "analysis"
|
|
174
169
|
|
|
175
|
-
#
|
|
170
|
+
# Contextual clarification for analysis (e.g. "i mean ivis_scope", "i mean table_x")
|
|
171
|
+
if re.search(r'\bi mean\b', prompt_lower) and not re.search(r'\b(insert|update|delete|drop)\b', prompt_lower):
|
|
172
|
+
return "analysis"
|
|
173
|
+
|
|
174
|
+
# 2. Pure Greeting / Feedback / Help triggers (Only when not asking a technical question)
|
|
175
|
+
pure_chat_greetings = ["hello", "hi", "hey", "howdy", "greetings", "good morning", "good evening", "good afternoon"]
|
|
176
|
+
pure_chat_feedback = [
|
|
177
|
+
"thanks", "thank you", "thx", "bye", "goodbye", "see ya",
|
|
178
|
+
"good", "great", "nice", "cool", "awesome", "perfect", "ok", "okay",
|
|
179
|
+
"yes", "no", "sure", "got it", "understood", "nice work", "well done",
|
|
180
|
+
"sounds good", "looks good", "alright", "super", "neat", "excellent",
|
|
181
|
+
"amazing", "wonderful", "cool thanks", "great thanks", "thanks a lot",
|
|
182
|
+
]
|
|
183
|
+
pure_chat_help = [
|
|
184
|
+
"who are you", "what can you do", "what are you", "help me", "how to use",
|
|
185
|
+
"what is this", "tell me about this database", "how does this work", "can you help",
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
if clean_prompt in pure_chat_greetings or clean_prompt in pure_chat_feedback or clean_prompt in pure_chat_help:
|
|
189
|
+
return "chat"
|
|
190
|
+
|
|
191
|
+
for trig in pure_chat_greetings + pure_chat_help:
|
|
192
|
+
if prompt_lower.startswith(f"{trig} ") or prompt_lower.startswith(f"{trig},") or prompt_lower.startswith(f"{trig}!"):
|
|
193
|
+
return "chat"
|
|
194
|
+
|
|
195
|
+
# 3. DDL indicators
|
|
176
196
|
ddl_keywords = [
|
|
177
197
|
"create table", "alter table", "drop table", "add column",
|
|
178
198
|
"remove column", "rename column", "rename table", "modify column",
|
|
@@ -182,7 +202,7 @@ class ScriptGenerator:
|
|
|
182
202
|
if kw in prompt_lower:
|
|
183
203
|
return "ddl"
|
|
184
204
|
|
|
185
|
-
# Write indicators (word-boundary matching to avoid false positives like "show dataset")
|
|
205
|
+
# 4. Write indicators (word-boundary matching to avoid false positives like "show dataset")
|
|
186
206
|
write_phrases = [
|
|
187
207
|
"insert", "add record", "add row", "add new",
|
|
188
208
|
"update", "delete", "drop record",
|
|
@@ -191,14 +211,10 @@ class ScriptGenerator:
|
|
|
191
211
|
for kw in write_phrases:
|
|
192
212
|
if kw in prompt_lower:
|
|
193
213
|
return "write"
|
|
194
|
-
# Single-word write verbs need word boundary to avoid false positives
|
|
195
|
-
# e.g. "set" should not match "dataset", "change" should not match "show changes"
|
|
196
|
-
import re as _re
|
|
197
214
|
write_verbs = ["set", "change", "modify", "remove"]
|
|
198
215
|
for verb in write_verbs:
|
|
199
|
-
if
|
|
200
|
-
|
|
201
|
-
read_context = _re.search(r'\b(?:show|list|display|view|get|count)\b.*\b' + verb, prompt_lower)
|
|
216
|
+
if re.search(r'\b' + verb + r'\b', prompt_lower):
|
|
217
|
+
read_context = re.search(r'\b(?:show|list|display|view|get|count)\b.*\b' + verb, prompt_lower)
|
|
202
218
|
if not read_context:
|
|
203
219
|
return "write"
|
|
204
220
|
|
|
@@ -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()
|
|
@@ -212,13 +214,34 @@ class QueryPipeline:
|
|
|
212
214
|
except Exception:
|
|
213
215
|
exact, fuzzy = [], []
|
|
214
216
|
|
|
217
|
+
# Inherit active tables from conversation context if follow-up prompt doesn't name tables explicitly
|
|
218
|
+
if not exact and not fuzzy:
|
|
219
|
+
inherited = self.conversation.get_active_tables()
|
|
220
|
+
if inherited:
|
|
221
|
+
exact = inherited[:4]
|
|
222
|
+
|
|
215
223
|
sub_schema, join_steps = self._get_fast_schema_context(user_prompt, exact, fuzzy)
|
|
216
224
|
result.schema = sub_schema
|
|
217
225
|
result.join_steps = join_steps
|
|
226
|
+
|
|
227
|
+
# Auto-Learn from user guidance/corrections
|
|
228
|
+
prompt_l = user_prompt.lower()
|
|
229
|
+
if any(w in prompt_l for w in ["mapped with", "mapped to", "observe the database", "the rule is", "remember that", "notice that"]):
|
|
230
|
+
self.knowledge_base.learn(
|
|
231
|
+
db_name=self.db_name,
|
|
232
|
+
topic=f"Mapping for {', '.join(exact[:3]) if exact else 'Entities'}",
|
|
233
|
+
rule_description=user_prompt,
|
|
234
|
+
relevant_tables=exact + fuzzy[:4],
|
|
235
|
+
source="user_guidance",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
kb_context = self.knowledge_base.format_prompt_context(self.db_name, user_prompt, exact + fuzzy)
|
|
218
239
|
conv_history = self.conversation.format_context_for_prompt()
|
|
240
|
+
analysis_prompt = f"{kb_context}\n\n{user_prompt}" if kb_context else user_prompt
|
|
241
|
+
|
|
219
242
|
result.message = self.generator.generate_analysis_response(
|
|
220
243
|
schema=sub_schema,
|
|
221
|
-
user_prompt=
|
|
244
|
+
user_prompt=analysis_prompt,
|
|
222
245
|
conversation_history=conv_history,
|
|
223
246
|
model=self.model,
|
|
224
247
|
)
|
|
@@ -227,7 +250,13 @@ class QueryPipeline:
|
|
|
227
250
|
result.was_executed = True
|
|
228
251
|
result.query_type = "analysis"
|
|
229
252
|
result.execution_time_ms = (time.time() - t_start) * 1000
|
|
230
|
-
self.conversation.add_turn(
|
|
253
|
+
self.conversation.add_turn(
|
|
254
|
+
user_prompt=user_prompt,
|
|
255
|
+
sql="",
|
|
256
|
+
assistant_response=result.message,
|
|
257
|
+
tables=exact + fuzzy,
|
|
258
|
+
is_chat=True,
|
|
259
|
+
)
|
|
231
260
|
return result
|
|
232
261
|
except Exception as e:
|
|
233
262
|
result.error = f"Architecture analysis failed: {str(e)}"
|
|
@@ -384,13 +413,16 @@ class QueryPipeline:
|
|
|
384
413
|
result.error = f"Schema retrieval failed: {str(e)}"
|
|
385
414
|
return result
|
|
386
415
|
|
|
387
|
-
# --- 4. Semantic Business Dictionary
|
|
416
|
+
# --- 4. Semantic Business Dictionary & Knowledge Base Injection ---
|
|
388
417
|
dict_context = self.dictionary.format_prompt_context(user_prompt)
|
|
418
|
+
kb_context = self.knowledge_base.format_prompt_context(self.db_name, user_prompt, exact + fuzzy)
|
|
389
419
|
conv_context = self.conversation.format_context_for_prompt()
|
|
390
420
|
|
|
391
421
|
augmented_prompt = user_prompt
|
|
392
422
|
if dict_context:
|
|
393
423
|
augmented_prompt = f"{dict_context}\n\n{augmented_prompt}"
|
|
424
|
+
if kb_context:
|
|
425
|
+
augmented_prompt = f"{kb_context}\n\n{augmented_prompt}"
|
|
394
426
|
if conv_context:
|
|
395
427
|
augmented_prompt = f"{conv_context}\n\n{augmented_prompt}"
|
|
396
428
|
|
|
@@ -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.")
|
|
@@ -44,14 +44,42 @@ class BaseConnector(ABC):
|
|
|
44
44
|
|
|
45
45
|
def resolve_tables(self, user_prompt: str) -> Tuple[List[str], List[str]]:
|
|
46
46
|
"""
|
|
47
|
-
Resolve which tables the user is referring to.
|
|
47
|
+
Resolve which tables the user is referring to with prefix stripping, pluralization handling, and fuzzy matching.
|
|
48
48
|
Returns (exact_matches, fuzzy_matches).
|
|
49
|
-
Default implementation uses get_table_names with simple substring matching.
|
|
50
49
|
"""
|
|
50
|
+
import re
|
|
51
51
|
all_tables = self.get_table_names()
|
|
52
52
|
prompt_lower = user_prompt.lower()
|
|
53
|
-
exact = [
|
|
54
|
-
|
|
53
|
+
exact = []
|
|
54
|
+
fuzzy = []
|
|
55
|
+
|
|
56
|
+
tokens = set(re.findall(r'\b[a-zA-Z0-9_]+\b', prompt_lower))
|
|
57
|
+
|
|
58
|
+
for t in all_tables:
|
|
59
|
+
t_lower = t.lower()
|
|
60
|
+
# 1. Exact full table name in prompt
|
|
61
|
+
if re.search(r'\b' + re.escape(t_lower) + r'\b', prompt_lower):
|
|
62
|
+
exact.append(t)
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
# 2. Match without common prefixes (e.g. "ivis_scope" -> "scope", "scopes")
|
|
66
|
+
unprefixed = re.sub(r'^(?:ivis_|tbl_|table_|app_)', '', t_lower)
|
|
67
|
+
if unprefixed and len(unprefixed) >= 3:
|
|
68
|
+
if (unprefixed in tokens or
|
|
69
|
+
f"{unprefixed}s" in tokens or
|
|
70
|
+
f"{unprefixed}es" in tokens or
|
|
71
|
+
re.search(r'\b' + re.escape(unprefixed) + r'(?:s|es)?\b', prompt_lower)):
|
|
72
|
+
if t not in exact:
|
|
73
|
+
exact.append(t)
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
# 3. Fuzzy partial match
|
|
77
|
+
for tok in tokens:
|
|
78
|
+
if len(tok) >= 4 and (tok in t_lower or t_lower in tok):
|
|
79
|
+
if t not in exact and t not in fuzzy:
|
|
80
|
+
fuzzy.append(t)
|
|
81
|
+
|
|
82
|
+
return exact, fuzzy
|
|
55
83
|
|
|
56
84
|
def refresh_cache(self) -> None:
|
|
57
85
|
"""Invalidate any cached metadata. Override in subclasses."""
|
|
@@ -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.2"
|
|
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
|