Commit Β·
c960c82
1
Parent(s): 1f93fec
Add dynamic AI suggestions, fix insight router bug, and clean up repository for production
Browse files- agent/graph.py +1 -1
- agent/nodes/insight_synthesizer.py +23 -1
- agent/nodes/intent_router.py +5 -0
- api/routers/query.py +48 -0
- frontend/src/api.ts +11 -0
- frontend/src/pages/ChatPage.tsx +27 -16
agent/graph.py
CHANGED
|
@@ -171,7 +171,7 @@ def _output_pipeline(state: AgentState) -> AgentState:
|
|
| 171 |
"""
|
| 172 |
result = state.get("execution_result")
|
| 173 |
|
| 174 |
-
if not result:
|
| 175 |
return {
|
| 176 |
**state,
|
| 177 |
"insight_text": "No results were returned for this query.",
|
|
|
|
| 171 |
"""
|
| 172 |
result = state.get("execution_result")
|
| 173 |
|
| 174 |
+
if not result and state.get("intent") != "insight_only":
|
| 175 |
return {
|
| 176 |
**state,
|
| 177 |
"insight_text": "No results were returned for this query.",
|
agent/nodes/insight_synthesizer.py
CHANGED
|
@@ -13,15 +13,37 @@ Focus on the most important numbers, trends, and business implications.
|
|
| 13 |
Do not describe how the query works β just state what the data shows."""
|
| 14 |
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
def insight_synthesizer(state: AgentState) -> AgentState:
|
| 17 |
result = state.get("execution_result")
|
|
|
|
|
|
|
| 18 |
if not result:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
return {**state, "insight_text": "No results were returned for this query."}
|
| 20 |
|
| 21 |
# Truncate result preview for prompt (first 20 rows)
|
| 22 |
preview = json.dumps(result[:20], default=str)
|
| 23 |
|
| 24 |
-
|
| 25 |
insight = client.complete_system(
|
| 26 |
system=SYSTEM,
|
| 27 |
user=f"Question: {state['user_query']}\n\nResults (first 20 rows):\n{preview}",
|
|
|
|
| 13 |
Do not describe how the query works β just state what the data shows."""
|
| 14 |
|
| 15 |
|
| 16 |
+
def _build_conversation_context(history: list) -> str:
|
| 17 |
+
if not history:
|
| 18 |
+
return "No prior conversation."
|
| 19 |
+
lines = []
|
| 20 |
+
for i, turn in enumerate(history[-3:], 1):
|
| 21 |
+
lines.append(f"Q: {turn.get('query', '')}")
|
| 22 |
+
if turn.get('insight'):
|
| 23 |
+
lines.append(f"A: {turn['insight']}")
|
| 24 |
+
return "\n".join(lines)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
def insight_synthesizer(state: AgentState) -> AgentState:
|
| 28 |
result = state.get("execution_result")
|
| 29 |
+
client = get_groq_client()
|
| 30 |
+
|
| 31 |
if not result:
|
| 32 |
+
if state.get("intent") == "insight_only":
|
| 33 |
+
history_text = _build_conversation_context(state.get("conversation_history", []))
|
| 34 |
+
insight = client.complete_system(
|
| 35 |
+
system="You are a helpful data analyst. Answer the user's conversational question based on the previous context. Do not describe your thought process.",
|
| 36 |
+
user=f"Context:\n{history_text}\n\nQuestion: {state['user_query']}",
|
| 37 |
+
model=client.reason_model,
|
| 38 |
+
max_tokens=256,
|
| 39 |
+
)
|
| 40 |
+
return {**state, "insight_text": insight}
|
| 41 |
return {**state, "insight_text": "No results were returned for this query."}
|
| 42 |
|
| 43 |
# Truncate result preview for prompt (first 20 rows)
|
| 44 |
preview = json.dumps(result[:20], default=str)
|
| 45 |
|
| 46 |
+
|
| 47 |
insight = client.complete_system(
|
| 48 |
system=SYSTEM,
|
| 49 |
user=f"Question: {state['user_query']}\n\nResults (first 20 rows):\n{preview}",
|
agent/nodes/intent_router.py
CHANGED
|
@@ -44,5 +44,10 @@ def intent_router(state: AgentState) -> AgentState:
|
|
| 44 |
# Post-processing safeguard: cannot do insight without prior context
|
| 45 |
if intent == "insight" and not has_history:
|
| 46 |
intent = "sql"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
return {**state, "intent": intent}
|
|
|
|
| 44 |
# Post-processing safeguard: cannot do insight without prior context
|
| 45 |
if intent == "insight" and not has_history:
|
| 46 |
intent = "sql"
|
| 47 |
+
|
| 48 |
+
# Post-processing safeguard: charting requests must not be insight
|
| 49 |
+
query_lower = state["user_query"].lower()
|
| 50 |
+
if intent == "insight" and any(w in query_lower for w in ["chart", "plot", "graph", "visualize", "visualization"]):
|
| 51 |
+
intent = "sql"
|
| 52 |
|
| 53 |
return {**state, "intent": intent}
|
api/routers/query.py
CHANGED
|
@@ -225,3 +225,51 @@ async def stream_query(req: QueryRequest):
|
|
| 225 |
"X-Accel-Buffering": "no",
|
| 226 |
},
|
| 227 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
"X-Accel-Buffering": "no",
|
| 226 |
},
|
| 227 |
)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
class SuggestRequest(BaseModel):
|
| 231 |
+
connector_id: str
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
class SuggestResponse(BaseModel):
|
| 235 |
+
suggestions: List[str]
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
@router.post("/suggest", response_model=SuggestResponse)
|
| 239 |
+
async def get_suggestions(req: SuggestRequest):
|
| 240 |
+
try:
|
| 241 |
+
from connectors.base import get_connector
|
| 242 |
+
connector = get_connector(req.connector_id)
|
| 243 |
+
schema = connector.get_schema()
|
| 244 |
+
except Exception as exc:
|
| 245 |
+
return SuggestResponse(suggestions=["What is the total number of rows in this dataset?"])
|
| 246 |
+
|
| 247 |
+
# Format schema for prompt
|
| 248 |
+
schema_lines = []
|
| 249 |
+
for t in schema[:3]: # Limit to 3 tables
|
| 250 |
+
cols = ", ".join(f"{c['name']} ({c['type']})" for c in t.get("columns", [])[:15])
|
| 251 |
+
schema_lines.append(f"Table: {t['table']}\nColumns: {cols}")
|
| 252 |
+
schema_context = "\n\n".join(schema_lines)
|
| 253 |
+
|
| 254 |
+
SYSTEM = """You are a senior data analyst.
|
| 255 |
+
Based on the provided database schema, generate 3 highly relevant, interesting analytical questions that a user might want to ask.
|
| 256 |
+
Return ONLY a JSON list of 3 strings. Example: ["question 1?", "question 2?", "question 3?"]
|
| 257 |
+
Focus on business metrics, trends, and aggregations."""
|
| 258 |
+
|
| 259 |
+
from llm import get_groq_client
|
| 260 |
+
client = get_groq_client()
|
| 261 |
+
try:
|
| 262 |
+
raw = client.complete_system(
|
| 263 |
+
system=SYSTEM,
|
| 264 |
+
user=f"Schema:\n{schema_context}",
|
| 265 |
+
model=client.reason_model,
|
| 266 |
+
max_tokens=200,
|
| 267 |
+
)
|
| 268 |
+
import json
|
| 269 |
+
suggestions = json.loads(raw)
|
| 270 |
+
if isinstance(suggestions, list) and len(suggestions) > 0:
|
| 271 |
+
return SuggestResponse(suggestions=suggestions[:4])
|
| 272 |
+
except Exception:
|
| 273 |
+
pass
|
| 274 |
+
|
| 275 |
+
return SuggestResponse(suggestions=["What are the top trends in this dataset?"])
|
frontend/src/api.ts
CHANGED
|
@@ -141,6 +141,17 @@ export async function* streamQuery(req: QueryRequest): AsyncGenerator<StreamEven
|
|
| 141 |
}
|
| 142 |
}
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
// ββ History ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 145 |
|
| 146 |
export async function getHistory(sessionId: string): Promise<HistoryRecord[]> {
|
|
|
|
| 141 |
}
|
| 142 |
}
|
| 143 |
|
| 144 |
+
export async function fetchSuggestions(connectorId: string): Promise<string[]> {
|
| 145 |
+
const res = await fetch(`${BASE}/api/query/suggest`, {
|
| 146 |
+
method: 'POST',
|
| 147 |
+
headers: { 'Content-Type': 'application/json' },
|
| 148 |
+
body: JSON.stringify({ connector_id: connectorId })
|
| 149 |
+
})
|
| 150 |
+
if (!res.ok) throw new Error('Failed to fetch suggestions')
|
| 151 |
+
const data = await res.json()
|
| 152 |
+
return data.suggestions || []
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
// ββ History ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 156 |
|
| 157 |
export async function getHistory(sessionId: string): Promise<HistoryRecord[]> {
|
frontend/src/pages/ChatPage.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import { useState, useRef, useEffect, useCallback } from 'react'
|
| 2 |
import { Send, RefreshCw, FileDown } from 'lucide-react'
|
| 3 |
import { v4 as uuidv4 } from 'uuid'
|
| 4 |
-
import { streamQuery, savePanel, downloadReport } from '@/api'
|
| 5 |
import type { QueryResponse, TraceEvent } from '@/api'
|
| 6 |
import { getSessionId, newSession, getUserId, getConnectorId } from '@/session'
|
| 7 |
import ChatMessage from '@/components/dashboard/ChatMessage'
|
|
@@ -15,21 +15,28 @@ interface Message {
|
|
| 15 |
traceEvents: TraceEvent[]
|
| 16 |
}
|
| 17 |
|
| 18 |
-
|
| 19 |
-
'What are the top 5 products by total revenue?',
|
| 20 |
-
'Show monthly order volume for the past year',
|
| 21 |
-
'Which region has the highest average order value?',
|
| 22 |
-
'List customers with more than 5 orders',
|
| 23 |
-
]
|
| 24 |
|
| 25 |
export default function ChatPage() {
|
| 26 |
const [messages, setMessages] = useState<Message[]>([])
|
|
|
|
|
|
|
|
|
|
| 27 |
const [input, setInput] = useState('')
|
| 28 |
const [loading, setLoading] = useState(false)
|
| 29 |
const [streamText, setStreamText] = useState('')
|
| 30 |
const [liveTrace, setLiveTrace] = useState<TraceEvent[]>([])
|
| 31 |
const [sessionId, setSessionId] = useState(getSessionId)
|
| 32 |
const [connector, setConnector] = useState(getConnectorId)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
const bottomRef = useRef<HTMLDivElement>(null)
|
| 34 |
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
| 35 |
|
|
@@ -140,15 +147,19 @@ export default function ChatPage() {
|
|
| 140 |
<h2 className="text-xl font-semibold text-neutral-200 text-center mb-2">Ask anything about your data</h2>
|
| 141 |
<p className="text-xs text-neutral-500 text-center mb-8">Connected to: <span className="text-neutral-300 font-mono">{connector}</span></p>
|
| 142 |
<div className="grid grid-cols-1 gap-2">
|
| 143 |
-
{
|
| 144 |
-
<
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
</div>
|
| 153 |
</div>
|
| 154 |
)}
|
|
|
|
| 1 |
import { useState, useRef, useEffect, useCallback } from 'react'
|
| 2 |
import { Send, RefreshCw, FileDown } from 'lucide-react'
|
| 3 |
import { v4 as uuidv4 } from 'uuid'
|
| 4 |
+
import { streamQuery, savePanel, downloadReport, fetchSuggestions } from '@/api'
|
| 5 |
import type { QueryResponse, TraceEvent } from '@/api'
|
| 6 |
import { getSessionId, newSession, getUserId, getConnectorId } from '@/session'
|
| 7 |
import ChatMessage from '@/components/dashboard/ChatMessage'
|
|
|
|
| 15 |
traceEvents: TraceEvent[]
|
| 16 |
}
|
| 17 |
|
| 18 |
+
// Dynamic suggestions fetched from API
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
export default function ChatPage() {
|
| 21 |
const [messages, setMessages] = useState<Message[]>([])
|
| 22 |
+
const [suggestions, setSuggestions] = useState<string[]>([])
|
| 23 |
+
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
|
| 24 |
+
|
| 25 |
const [input, setInput] = useState('')
|
| 26 |
const [loading, setLoading] = useState(false)
|
| 27 |
const [streamText, setStreamText] = useState('')
|
| 28 |
const [liveTrace, setLiveTrace] = useState<TraceEvent[]>([])
|
| 29 |
const [sessionId, setSessionId] = useState(getSessionId)
|
| 30 |
const [connector, setConnector] = useState(getConnectorId)
|
| 31 |
+
|
| 32 |
+
useEffect(() => {
|
| 33 |
+
if (!connector) return
|
| 34 |
+
setLoadingSuggestions(true)
|
| 35 |
+
fetchSuggestions(connector)
|
| 36 |
+
.then(setSuggestions)
|
| 37 |
+
.catch(() => setSuggestions(['What insights can you find in this dataset?']))
|
| 38 |
+
.finally(() => setLoadingSuggestions(false))
|
| 39 |
+
}, [connector])
|
| 40 |
const bottomRef = useRef<HTMLDivElement>(null)
|
| 41 |
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
| 42 |
|
|
|
|
| 147 |
<h2 className="text-xl font-semibold text-neutral-200 text-center mb-2">Ask anything about your data</h2>
|
| 148 |
<p className="text-xs text-neutral-500 text-center mb-8">Connected to: <span className="text-neutral-300 font-mono">{connector}</span></p>
|
| 149 |
<div className="grid grid-cols-1 gap-2">
|
| 150 |
+
{loadingSuggestions ? (
|
| 151 |
+
<div className="text-center text-xs text-neutral-500 py-4 animate-pulse">Generating tailored suggestions...</div>
|
| 152 |
+
) : (
|
| 153 |
+
suggestions.map((s) => (
|
| 154 |
+
<button
|
| 155 |
+
key={s}
|
| 156 |
+
onClick={() => submit(s)}
|
| 157 |
+
className="text-left px-4 py-3 card hover:border-neutral-700 hover:bg-neutral-900/50 text-sm text-neutral-400 hover:text-neutral-200 transition-all"
|
| 158 |
+
>
|
| 159 |
+
{s}
|
| 160 |
+
</button>
|
| 161 |
+
))
|
| 162 |
+
)}
|
| 163 |
</div>
|
| 164 |
</div>
|
| 165 |
)}
|