dbagent-cli 0.11.1__tar.gz → 0.11.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.
Files changed (98) hide show
  1. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/PKG-INFO +1 -1
  2. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/__init__.py +1 -1
  3. dbagent_cli-0.11.2/dbagent/agent/conversation.py +140 -0
  4. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/agent/generator.py +10 -0
  5. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/agent/pipeline.py +53 -15
  6. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent_cli.egg-info/PKG-INFO +1 -1
  7. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent_cli.egg-info/SOURCES.txt +1 -0
  8. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/pyproject.toml +1 -1
  9. dbagent_cli-0.11.2/tests/test_multi_turn_conversation.py +102 -0
  10. dbagent_cli-0.11.1/dbagent/agent/conversation.py +0 -93
  11. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/README.md +0 -0
  12. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/agent/confidence.py +0 -0
  13. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/agent/doctor.py +0 -0
  14. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/agent/error_classifier.py +0 -0
  15. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/agent/recommender.py +0 -0
  16. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/agent/validator.py +0 -0
  17. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/benchmark.py +0 -0
  18. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/cli.py +0 -0
  19. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/config.py +0 -0
  20. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/connectors/base.py +0 -0
  21. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/connectors/factory.py +0 -0
  22. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/connectors/mongo.py +0 -0
  23. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/connectors/relational.py +0 -0
  24. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/errors.py +0 -0
  25. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/execution/__init__.py +0 -0
  26. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/execution/cache.py +0 -0
  27. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/execution/changeset.py +0 -0
  28. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/execution/cost.py +0 -0
  29. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/execution/prompt_queue.py +0 -0
  30. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/execution/recovery.py +0 -0
  31. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/execution/sandbox.py +0 -0
  32. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/execution/streaming.py +0 -0
  33. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/llm/anthropic_provider.py +0 -0
  34. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/llm/auto_setup.py +0 -0
  35. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/llm/base.py +0 -0
  36. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/llm/deepseek_provider.py +0 -0
  37. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/llm/factory.py +0 -0
  38. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/llm/gemini_provider.py +0 -0
  39. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/llm/groq_provider.py +0 -0
  40. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/llm/mistral_provider.py +0 -0
  41. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/llm/mock_provider.py +0 -0
  42. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/llm/ollama_provider.py +0 -0
  43. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/llm/openai_provider.py +0 -0
  44. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/llm/openrouter_provider.py +0 -0
  45. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/plugins.py +0 -0
  46. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/sample_db.py +0 -0
  47. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/schema/cache.py +0 -0
  48. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/schema/formatter.py +0 -0
  49. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/schema/graph.py +0 -0
  50. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/schema/ground_truth.py +0 -0
  51. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/schema/models.py +0 -0
  52. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/schema/retriever.py +0 -0
  53. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/schema/selector.py +0 -0
  54. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/security/__init__.py +0 -0
  55. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/security/audit.py +0 -0
  56. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/security/environment.py +0 -0
  57. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/security/masking.py +0 -0
  58. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/security/modes.py +0 -0
  59. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/security/rbac.py +0 -0
  60. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/semantic/__init__.py +0 -0
  61. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/semantic/dictionary.py +0 -0
  62. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/semantic/knowledge.py +0 -0
  63. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/semantic/templates.py +0 -0
  64. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/sql/__init__.py +0 -0
  65. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/sql/optimizer.py +0 -0
  66. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/sql/validator.py +0 -0
  67. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/ui/chat_screen.py +0 -0
  68. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/ui/console.py +0 -0
  69. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/ui/grid_window.py +0 -0
  70. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/ui/live_streamer.py +0 -0
  71. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/ui/viewer.py +0 -0
  72. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent/ui/visualizer.py +0 -0
  73. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent_cli.egg-info/dependency_links.txt +0 -0
  74. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent_cli.egg-info/entry_points.txt +0 -0
  75. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent_cli.egg-info/requires.txt +0 -0
  76. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/dbagent_cli.egg-info/top_level.txt +0 -0
  77. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/setup.cfg +0 -0
  78. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/tests/test_benchmarks.py +0 -0
  79. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/tests/test_cache.py +0 -0
  80. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/tests/test_cli.py +0 -0
  81. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/tests/test_connectors.py +0 -0
  82. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/tests/test_demo_and_sample_db.py +0 -0
  83. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/tests/test_direct_sql.py +0 -0
  84. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/tests/test_doctor_and_viz.py +0 -0
  85. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/tests/test_formats_and_exit_codes.py +0 -0
  86. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/tests/test_generator.py +0 -0
  87. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/tests/test_graph.py +0 -0
  88. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/tests/test_knowledge.py +0 -0
  89. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/tests/test_plugins.py +0 -0
  90. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/tests/test_prompt_queue.py +0 -0
  91. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/tests/test_prompt_queue_and_sql.py +0 -0
  92. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/tests/test_providers.py +0 -0
  93. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/tests/test_recovery.py +0 -0
  94. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/tests/test_safety.py +0 -0
  95. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/tests/test_sandbox.py +0 -0
  96. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/tests/test_schema.py +0 -0
  97. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/tests/test_security.py +0 -0
  98. {dbagent_cli-0.11.1 → dbagent_cli-0.11.2}/tests/test_semantic.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbagent-cli
3
- Version: 0.11.1
3
+ Version: 0.11.2
4
4
  Summary: The Privacy-First AI Database Copilot, Schema Graph Introspector & Post-Commit Recovery Engine (CLI)
5
5
  Author: Santhosh Gupta
6
6
  License: MIT
@@ -2,5 +2,5 @@
2
2
  DB-Agent: Universal Database Introspection and Script Generation AI Agent (CLI).
3
3
  """
4
4
 
5
- __version__ = "0.11.1"
5
+ __version__ = "0.11.2"
6
6
  __author__ = "Santhosh Gupta"
@@ -0,0 +1,140 @@
1
+ """
2
+ Multi-Turn Conversational Context Tracker.
3
+ Maintains active tables, filter predicates, entities, and generated SQL across chat turns
4
+ so users can refine, modify, rollback, or question queries naturally (e.g. 'modify the above', 'give rollback query', 'only active users').
5
+ """
6
+
7
+ import re
8
+ from typing import Dict, Any, List, Optional, Set
9
+ from dataclasses import dataclass, field
10
+
11
+
12
+ @dataclass
13
+ class ConversationTurn:
14
+ """A single turn in the conversation."""
15
+ user_prompt: str
16
+ sql: str = ""
17
+ assistant_response: str = ""
18
+ tables: List[str] = field(default_factory=list)
19
+ filters: List[str] = field(default_factory=list)
20
+ entities: Dict[str, str] = field(default_factory=dict)
21
+ limit: Optional[int] = None
22
+ was_executed: bool = False
23
+ is_chat: bool = False
24
+ query_type: str = "read"
25
+
26
+
27
+ class ConversationContext:
28
+ """
29
+ Tracks state and multi-turn conversational history across interactive chat turns.
30
+ """
31
+
32
+ FOLLOWUP_INDICATORS = [
33
+ "above", "previous", "that", "this", "it", "rollback", "undo", "reverse",
34
+ "modify", "change it", "change that", "update that", "fix that", "correct this",
35
+ "instead", "also", "same query", "same table", "same as above", "again",
36
+ "for the above", "for that", "for this", "what about", "how about",
37
+ "now filter", "now show", "now sort", "add where", "add order", "add limit",
38
+ "remove limit", "remove where", "only active", "only inactive",
39
+ ]
40
+
41
+ def __init__(self, max_history: int = 15):
42
+ self.max_history = max_history
43
+ self.turns: List[ConversationTurn] = []
44
+ self.active_tables: Set[str] = set()
45
+ self.active_filters: List[str] = []
46
+
47
+ def add_turn(
48
+ self,
49
+ user_prompt: str,
50
+ sql: str = "",
51
+ assistant_response: str = "",
52
+ tables: Optional[List[str]] = None,
53
+ filters: Optional[List[str]] = None,
54
+ was_executed: bool = False,
55
+ is_chat: bool = False,
56
+ query_type: str = "read",
57
+ ) -> None:
58
+ """Records a completed or proposed turn and updates active context."""
59
+ cleaned_tables = [t.lower() for t in (tables or []) if t]
60
+
61
+ # If SQL is present, also extract table names from FROM/JOIN clauses
62
+ if sql:
63
+ for m in re.findall(r'\b(?:FROM|JOIN|UPDATE|INTO|TABLE)\s+([a-zA-Z0-9_]+)', sql, re.IGNORECASE):
64
+ t_lower = m.lower()
65
+ if t_lower not in ("select", "where", "set", "values", "as") and t_lower not in cleaned_tables:
66
+ cleaned_tables.append(t_lower)
67
+
68
+ turn = ConversationTurn(
69
+ user_prompt=user_prompt,
70
+ sql=sql,
71
+ assistant_response=assistant_response,
72
+ tables=cleaned_tables,
73
+ filters=filters or [],
74
+ was_executed=was_executed,
75
+ is_chat=is_chat,
76
+ query_type=query_type,
77
+ )
78
+ self.turns.append(turn)
79
+ if len(self.turns) > self.max_history:
80
+ self.turns.pop(0)
81
+
82
+ if cleaned_tables:
83
+ self.active_tables.update(cleaned_tables)
84
+
85
+ def get_active_tables(self) -> List[str]:
86
+ """Returns currently active database table names discussed in this session."""
87
+ if not self.active_tables and self.turns:
88
+ for t in reversed(self.turns):
89
+ if t.tables:
90
+ return list(t.tables)
91
+ return list(self.active_tables)
92
+
93
+ def get_last_turn(self) -> Optional[ConversationTurn]:
94
+ """Returns the most recent conversation turn."""
95
+ if self.turns:
96
+ return self.turns[-1]
97
+ return None
98
+
99
+ def get_last_sql(self) -> Optional[str]:
100
+ """Returns the most recent SQL statement generated in the session."""
101
+ for t in reversed(self.turns):
102
+ if t.sql and t.sql.strip():
103
+ return t.sql.strip()
104
+ return None
105
+
106
+ def is_followup_prompt(self, user_prompt: str) -> bool:
107
+ """Checks if a user prompt is modifying, rolling back, or referencing previous turns."""
108
+ if not self.turns:
109
+ return False
110
+ p_lower = user_prompt.lower()
111
+ return any(ind in p_lower for ind in self.FOLLOWUP_INDICATORS)
112
+
113
+ def format_context_for_prompt(self, max_turns: int = 6) -> str:
114
+ """Formats multi-turn conversation memory with full SQL for inclusion in LLM prompt."""
115
+ if not self.turns:
116
+ return ""
117
+
118
+ recent = self.turns[-max_turns:]
119
+ lines = ["=== Previous Chat Session Memory (Use for follow-ups, modifications, rollbacks & context) ==="]
120
+ for i, t in enumerate(recent, 1):
121
+ lines.append(f"Turn {i}:")
122
+ lines.append(f" User Request: \"{t.user_prompt}\"")
123
+ if t.sql:
124
+ status = "Executed" if t.was_executed else "Generated / Proposed"
125
+ lines.append(f" Previous SQL ({status}):\n{t.sql.strip()}")
126
+ if t.assistant_response:
127
+ clean_resp = t.assistant_response.strip().replace("\n", " ")
128
+ preview = (clean_resp[:250] + "...") if len(clean_resp) > 250 else clean_resp
129
+ lines.append(f" Assistant Output: \"{preview}\"")
130
+ if t.tables:
131
+ lines.append(f" Tables Referenced: {', '.join(t.tables)}")
132
+ lines.append("=== End Chat Session Memory ===")
133
+
134
+ return "\n".join(lines)
135
+
136
+ def clear(self) -> None:
137
+ """Resets the conversation history."""
138
+ self.turns.clear()
139
+ self.active_tables.clear()
140
+ self.active_filters.clear()
@@ -63,6 +63,16 @@ Generate ONLY the raw executable SQL query for the given database. No markdown f
63
63
  - For PostgreSQL: Use a single Common Table Expression (CTE) with data-modifying statements deleting from all child, mapping, activity, profile, and preference tables first, and finally deleting from the parent table (e.g. `WITH target AS (SELECT pk_user_id FROM ivis_user WHERE ...), del_act AS (DELETE FROM user_activity WHERE user_id IN (SELECT pk_user_id FROM target)), del_map AS (DELETE FROM ivis_user_access WHERE fk_user_id IN (SELECT pk_user_id FROM target)) DELETE FROM ivis_user WHERE pk_user_id IN (SELECT pk_user_id FROM target);`).
64
64
  - For MySQL / SQLite: Generate clean, ordered DELETE statements deleting referencing child records before the parent record.
65
65
  - Always delete from all referencing tables present in the schema to avoid foreign key violations.
66
+ 11. Multi-Turn Session Memory & Query Refinement:
67
+ - When the user asks to modify, refine, change, or fix a query (e.g. "modify the above", "change it to X", "add a filter", "order by Y", "show site names instead", "fix the error"):
68
+ - Locate the previous SQL statement in the Chat Session Memory, apply the requested changes, and output the complete, valid modified SQL query.
69
+ 12. Rollback & Reversal Queries:
70
+ - When the user asks for a "rollback query", "undo query", or "reversal script" for a previous query (e.g. "give the rollback query for the above one", "undo that update", "how to revert this"):
71
+ - Inspect the previous SQL statement in the Chat Session Memory.
72
+ - For an UPDATE: Generate the inverse UPDATE statement restoring the original/previous values or setting them back to the previous keys.
73
+ - For an INSERT: Generate the corresponding DELETE statement.
74
+ - For a DELETE: If the previous query deleted specific records or used a CTE, generate the corresponding recovery script or reverse CTE.
75
+ - NEVER output dummy placeholders like `SELECT 1` when a prior query exists to be modified or rolled back.
66
76
  """
67
77
 
68
78
 
@@ -160,7 +160,10 @@ class QueryPipeline:
160
160
 
161
161
  # 2. Targeted on-demand table introspection with automatic junction expansion
162
162
  target_names = list(set(exact + fuzzy))
163
- is_delete_or_mapping = any(w in user_prompt.lower() for w in ["delete", "remove", "drop", "cascade", "mapping", "map", "link", "clean", "without any mappings"])
163
+ is_delete_or_mapping = any(w in user_prompt.lower() for w in [
164
+ "delete", "remove", "drop", "cascade", "mapping", "map", "link", "clean",
165
+ "without any mappings", "change", "update", "rollback", "undo", "revert"
166
+ ])
164
167
  max_tables_limit = 25 if is_delete_or_mapping else 12
165
168
 
166
169
  if target_names:
@@ -442,7 +445,17 @@ class QueryPipeline:
442
445
  except AttributeError:
443
446
  exact, fuzzy = [], []
444
447
 
445
- if not exact and len(fuzzy) > 1:
448
+ # If user prompt is a follow-up or has no table mentions, inherit active tables from previous conversation turns
449
+ is_followup = self.conversation.is_followup_prompt(user_prompt)
450
+ if (not exact and not fuzzy) or (not exact and is_followup):
451
+ active_from_ctx = self.conversation.get_active_tables()
452
+ if active_from_ctx:
453
+ exact = active_from_ctx[:10]
454
+ result.exact_tables = exact
455
+ # Also prevent false ambiguity prompt if we have strong session context
456
+ result.ambiguous = False
457
+
458
+ if not exact and len(fuzzy) > 1 and not is_followup:
446
459
  result.ambiguous = True
447
460
  result.ambiguous_choices = fuzzy
448
461
  if self.choice_callback:
@@ -469,6 +482,14 @@ class QueryPipeline:
469
482
  conv_context = self.conversation.format_context_for_prompt()
470
483
 
471
484
  augmented_prompt = user_prompt
485
+ if is_followup and self.conversation.get_last_sql():
486
+ last_sql = self.conversation.get_last_sql()
487
+ followup_hint = (
488
+ f"[Follow-Up Note: The user's query refers directly to the previous query in the session.]\n"
489
+ f"[Target Previous SQL:\n{last_sql}\n]"
490
+ )
491
+ augmented_prompt = f"{followup_hint}\n\n{augmented_prompt}"
492
+
472
493
  if dict_context:
473
494
  augmented_prompt = f"{dict_context}\n\n{augmented_prompt}"
474
495
  if kb_context:
@@ -547,8 +568,28 @@ class QueryPipeline:
547
568
  if self.confirm_callback(result.sql, violations):
548
569
  should_execute = True
549
570
  else:
571
+ turn_tables = list(set((exact or []) + (fuzzy or []) + [t.name.lower() for t in (sub_schema.tables if sub_schema else [])]))
572
+ self.conversation.add_turn(
573
+ user_prompt=user_prompt,
574
+ sql=result.sql,
575
+ tables=turn_tables,
576
+ was_executed=False,
577
+ is_chat=False,
578
+ query_type=result.query_type,
579
+ )
580
+ result.execution_time_ms = (time.time() - t_start) * 1000
550
581
  return result
551
582
  else:
583
+ turn_tables = list(set((exact or []) + (fuzzy or []) + [t.name.lower() for t in (sub_schema.tables if sub_schema else [])]))
584
+ self.conversation.add_turn(
585
+ user_prompt=user_prompt,
586
+ sql=result.sql,
587
+ tables=turn_tables,
588
+ was_executed=False,
589
+ is_chat=False,
590
+ query_type=result.query_type,
591
+ )
592
+ result.execution_time_ms = (time.time() - t_start) * 1000
552
593
  return result
553
594
  elif self.mode == OperationMode.ADMIN:
554
595
  should_execute = True
@@ -620,19 +661,16 @@ class QueryPipeline:
620
661
  error=result.error,
621
662
  )
622
663
 
623
- if result.success:
624
- sql_tables = set()
625
- if result.sql:
626
- import re as _re
627
- for m in _re.findall(r'\b(?:FROM|JOIN)\s+([a-zA-Z0-9_]+)', result.sql, _re.IGNORECASE):
628
- sql_tables.add(m.lower())
629
- turn_tables = list(set((exact or []) + (fuzzy or []) + list(sql_tables)))
630
- self.conversation.add_turn(
631
- user_prompt=user_prompt,
632
- sql=result.sql,
633
- tables=turn_tables,
634
- is_chat=False,
635
- )
664
+ turn_tables = list(set((exact or []) + (fuzzy or []) + [t.name.lower() for t in (sub_schema.tables if sub_schema else [])]))
665
+ self.conversation.add_turn(
666
+ user_prompt=user_prompt,
667
+ sql=result.sql,
668
+ assistant_response=result.error or result.friendly_error or "",
669
+ tables=turn_tables,
670
+ was_executed=result.was_executed,
671
+ is_chat=False,
672
+ query_type=result.query_type,
673
+ )
636
674
 
637
675
  return result
638
676
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbagent-cli
3
- Version: 0.11.1
3
+ Version: 0.11.2
4
4
  Summary: The Privacy-First AI Database Copilot, Schema Graph Introspector & Post-Commit Recovery Engine (CLI)
5
5
  Author: Santhosh Gupta
6
6
  License: MIT
@@ -82,6 +82,7 @@ tests/test_formats_and_exit_codes.py
82
82
  tests/test_generator.py
83
83
  tests/test_graph.py
84
84
  tests/test_knowledge.py
85
+ tests/test_multi_turn_conversation.py
85
86
  tests/test_plugins.py
86
87
  tests/test_prompt_queue.py
87
88
  tests/test_prompt_queue_and_sql.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dbagent-cli"
7
- version = "0.11.1"
7
+ version = "0.11.2"
8
8
  description = "The Privacy-First AI Database Copilot, Schema Graph Introspector & Post-Commit Recovery Engine (CLI)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,102 @@
1
+ """
2
+ Tests for Multi-Turn Conversational Memory, Query Refinements, and Rollback Generation.
3
+ """
4
+
5
+ from typing import List, Optional, Callable
6
+ from dbagent.agent.conversation import ConversationContext
7
+ from dbagent.agent.pipeline import QueryPipeline
8
+ from dbagent.connectors.relational import RelationalConnector
9
+ from dbagent.llm.base import BaseLLMProvider
10
+ from dbagent.sample_db import init_sample_database
11
+ from dbagent.security.modes import OperationMode
12
+
13
+
14
+ class MockWriteProvider(BaseLLMProvider):
15
+ """Custom provider returning specific SQL for multi-turn test."""
16
+ def __init__(self):
17
+ self.call_count = 0
18
+
19
+ @property
20
+ def name(self) -> str:
21
+ return "mock-write"
22
+
23
+ def is_available(self) -> bool:
24
+ return True
25
+
26
+ def list_models(self) -> List[str]:
27
+ return ["mock-v1"]
28
+
29
+ def generate(
30
+ self,
31
+ prompt: str,
32
+ system_prompt: Optional[str] = None,
33
+ model: Optional[str] = None,
34
+ stream_callback: Optional[Callable[[str], None]] = None,
35
+ ) -> str:
36
+ self.call_count += 1
37
+ if self.call_count == 1:
38
+ return "UPDATE customers SET first_name = 'Alice' WHERE customer_id = 1;"
39
+ else:
40
+ return "UPDATE customers SET first_name = 'Original' WHERE customer_id = 1;"
41
+
42
+
43
+ def test_conversation_context_tracking_and_followup():
44
+ """Verify that ConversationContext detects follow-ups and records unexecuted proposals."""
45
+ ctx = ConversationContext()
46
+ assert ctx.is_followup_prompt("give the rollback query for the above one") is False # Empty history
47
+
48
+ # Add a proposed write query that was not executed
49
+ ctx.add_turn(
50
+ user_prompt="Update customer 1 to inactive",
51
+ sql="UPDATE customers SET status = 'INACTIVE' WHERE customer_id = 1;",
52
+ tables=["customers"],
53
+ was_executed=False,
54
+ query_type="write",
55
+ )
56
+
57
+ assert len(ctx.turns) == 1
58
+ assert "customers" in ctx.get_active_tables()
59
+ assert ctx.get_last_sql() == "UPDATE customers SET status = 'INACTIVE' WHERE customer_id = 1;"
60
+ assert ctx.is_followup_prompt("okay also give the rollback query for the above one") is True
61
+ assert ctx.is_followup_prompt("modify that to customer_id = 2") is True
62
+
63
+ prompt_context = ctx.format_context_for_prompt()
64
+ assert "UPDATE customers SET status = 'INACTIVE'" in prompt_context
65
+ assert "Generated / Proposed" in prompt_context
66
+
67
+
68
+ def test_pipeline_multi_turn_followup_table_inheritance():
69
+ """Verify that QueryPipeline automatically inherits active tables and targets for follow-up prompts."""
70
+ _, db_url = init_sample_database(force_recreate=False)
71
+ connector = RelationalConnector(db_url)
72
+ llm = MockWriteProvider()
73
+ ctx = ConversationContext()
74
+
75
+ # Reject confirmation to simulate user answering [n]
76
+ def _reject_confirmation(sql, violations):
77
+ return False
78
+
79
+ pipeline = QueryPipeline(
80
+ connector=connector,
81
+ llm=llm,
82
+ mode=OperationMode.CONFIRM,
83
+ confirm_callback=_reject_confirmation,
84
+ conversation_context=ctx,
85
+ )
86
+
87
+ # Turn 1: Propose update on customers (user says [n] so it is not executed)
88
+ res1 = pipeline.run("change customer 1 name to Alice")
89
+ assert res1.needs_confirmation is True
90
+ assert res1.was_executed is False
91
+ assert len(ctx.turns) == 1
92
+ assert "customers" in ctx.get_active_tables()
93
+ assert ctx.get_last_sql() == "UPDATE customers SET first_name = 'Alice' WHERE customer_id = 1;"
94
+
95
+ # Turn 2: Follow-up prompt with no table names in the text
96
+ res2 = pipeline.run("give the rollback query for the above one")
97
+ # Exact tables and schema context should be inherited from previous turn
98
+ assert "customers" in res2.exact_tables
99
+ assert res2.schema is not None
100
+ assert "customers" in [t.name.lower() for t in res2.schema.tables]
101
+
102
+ connector.close()
@@ -1,93 +0,0 @@
1
- """
2
- Multi-Turn Conversational Context Tracker.
3
- Maintains active tables, filter predicates, entities, and sort orders across chat turns
4
- so users can refine queries naturally (e.g. 'Which sites?', 'Only Hyderabad', 'Top 10?').
5
- """
6
-
7
- from typing import Dict, Any, List, Optional, Set
8
- from dataclasses import dataclass, field
9
-
10
-
11
- @dataclass
12
- class ConversationTurn:
13
- """A single turn in the conversation."""
14
- user_prompt: str
15
- sql: str = ""
16
- assistant_response: str = ""
17
- tables: List[str] = field(default_factory=list)
18
- filters: List[str] = field(default_factory=list)
19
- entities: Dict[str, str] = field(default_factory=dict)
20
- limit: Optional[int] = None
21
- is_chat: bool = False
22
-
23
-
24
- class ConversationContext:
25
- """
26
- Tracks state and multi-turn conversational history across interactive chat turns.
27
- """
28
-
29
- def __init__(self, max_history: int = 15):
30
- self.max_history = max_history
31
- self.turns: List[ConversationTurn] = []
32
- self.active_tables: Set[str] = set()
33
- self.active_filters: List[str] = []
34
-
35
- def add_turn(
36
- self,
37
- user_prompt: str,
38
- sql: str = "",
39
- assistant_response: str = "",
40
- tables: Optional[List[str]] = None,
41
- filters: Optional[List[str]] = None,
42
- is_chat: bool = False,
43
- ) -> None:
44
- """Records a completed turn and updates active context."""
45
- turn = ConversationTurn(
46
- user_prompt=user_prompt,
47
- sql=sql,
48
- assistant_response=assistant_response,
49
- tables=tables or [],
50
- filters=filters or [],
51
- is_chat=is_chat,
52
- )
53
- self.turns.append(turn)
54
- if len(self.turns) > self.max_history:
55
- self.turns.pop(0)
56
-
57
- if tables:
58
- self.active_tables.update(t.lower() for t in tables)
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
-
64
- def get_last_turn(self) -> Optional[ConversationTurn]:
65
- if self.turns:
66
- return self.turns[-1]
67
- return None
68
-
69
- def format_context_for_prompt(self, max_turns: int = 5) -> str:
70
- """Formats multi-turn conversation memory for inclusion in LLM prompt."""
71
- if not self.turns:
72
- return ""
73
-
74
- recent = self.turns[-max_turns:]
75
- lines = ["=== Previous Chat Session Memory (Use for follow-ups & context) ==="]
76
- for i, t in enumerate(recent, 1):
77
- lines.append(f"Turn {i}:")
78
- lines.append(f" User: \"{t.user_prompt}\"")
79
- if t.sql:
80
- lines.append(f" Assistant (SQL): {t.sql.strip()}")
81
- elif t.assistant_response:
82
- clean_resp = t.assistant_response.strip().replace("\n", " ")
83
- preview = (clean_resp[:120] + "...") if len(clean_resp) > 120 else clean_resp
84
- lines.append(f" Assistant: \"{preview}\"")
85
- lines.append("=== End Chat Session Memory ===")
86
-
87
- return "\n".join(lines)
88
-
89
- def clear(self) -> None:
90
- """Resets the conversation history."""
91
- self.turns.clear()
92
- self.active_tables.clear()
93
- self.active_filters.clear()
File without changes
File without changes