Skip to content

Commit 942c7b1

Browse files
committed
Add follow-up suggestion icon button after successful queries
Add a lightbulb icon to message footer that populates the chat input with an AI-generated follow-up suggestion on click. Uses the cheapest 'ui' LLM spec for efficient generation. - Add FollowUpSuggestion model in models.py - Add suggest_followup() to Coordinator with llm_spec='ui' - Add follow_up_suggestions.jinja2 extending Actor/main.jinja2 - Add lightbulb icon to footer_actions (fallback to footer_objects) - Add follow_up_suggestions settings toggle - 14 tests (10 UI + 4 coordinator) + docs Depends on panel-extensions/panel-material-ui#605 for footer_actions.
1 parent 461399f commit 942c7b1

8 files changed

Lines changed: 542 additions & 3 deletions

File tree

docs/configuration/ui.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,19 @@ ui = lmai.ExplorerUI(
7878

7979
1. Tuples of (Material icon name, button text)
8080

81+
### Follow-up suggestions
82+
83+
After each successful query, a lightbulb icon appears in the message footer. Clicking it populates the chat input with an AI-generated follow-up suggestion that references actual column names from the data.
84+
85+
``` py title="Disable follow-up suggestions"
86+
ui = lmai.ExplorerUI(
87+
data='penguins.csv',
88+
follow_up_suggestions=False
89+
)
90+
```
91+
92+
Users can also toggle this at runtime via **Settings > Follow-Up Suggestions**.
93+
8194
## Advanced parameters
8295

8396
### Enable chat logging
@@ -203,6 +216,7 @@ Quick reference:
203216
| `notebook_preamble` | str | Export header |
204217
| `provider_choices` | dict | LLM providers shown in Settings |
205218
| `source_controls` | list | Source control components for data |
219+
| `follow_up_suggestions` | bool | AI follow-up suggestion icon after queries (default: True) |
206220
| `suggestions` | list | Quick action buttons |
207221
| `title` | str | App title |
208222
| `tools` | list | Custom tools |

docs/getting_started/navigating_the_ui.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ Use **Settings** in the left sidebar to configure analysis behavior:
110110

111111
**Validation Step** — AI validates results for correctness (enabled by default)
112112

113+
**Follow-Up Suggestions** — After each successful query, a lightbulb icon appears in the message footer. Click it to populate the chat input with an AI-generated follow-up question. Toggle off in Settings if not needed.
114+
113115
**Code Execution** — If enabled by administrator, controls how visualizations are generated. See [Code Execution for Visualizations](using_lumen_ai.md#code-execution-for-visualizations) for security implications.
114116

115117
**LLM Configuration** — Choose and configure your language model

lumen/ai/coordinator/base.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from ..config import PROMPTS_DIR, MissingContextError
2626
from ..context import TContext
2727
from ..llm import LlamaCpp, Llm, Message
28-
from ..models import ThinkingYesNo
28+
from ..models import FollowUpSuggestion, ThinkingYesNo
2929
from ..report import ActorTask, Section, TaskGroup
3030
from ..tools import MetadataLookup, Tool, VectorLookupToolUser
3131
from ..utils import (
@@ -284,6 +284,11 @@ class Coordinator(Viewer, VectorLookupToolUser):
284284
"template": PROMPTS_DIR / "Coordinator" / "tool_relevance.jinja2",
285285
"response_model": ThinkingYesNo,
286286
},
287+
"follow_up_suggestions": {
288+
"template": PROMPTS_DIR / "Coordinator" / "follow_up_suggestions.jinja2",
289+
"response_model": FollowUpSuggestion,
290+
"llm_spec": "ui",
291+
},
287292
},
288293
)
289294

@@ -509,6 +514,40 @@ async def _check_tool_relevance(self, tool: Tool, tool_output: str, actor: Actor
509514

510515
return result.yes
511516

517+
async def suggest_followup(self, plan: Plan) -> str | None:
518+
"""Generate a single follow-up suggestion from a completed plan.
519+
520+
Returns a suggestion query string, or None on failure.
521+
"""
522+
pipeline = plan.out_context.get("pipeline")
523+
if pipeline is None:
524+
return None
525+
526+
data_summary = plan.out_context.get("data", "")
527+
if not data_summary:
528+
return None
529+
530+
sql = plan.out_context.get("sql", "")
531+
previous_queries = [
532+
m["content"] for m in plan.history
533+
if m.get("role") == "user"
534+
]
535+
output_type = "chart" if plan.out_context.get("view") else "table"
536+
537+
try:
538+
result = await self._invoke_prompt(
539+
"follow_up_suggestions",
540+
messages=[{"role": "user", "content": "Generate a follow-up suggestion."}],
541+
context=plan.out_context,
542+
data_summary=data_summary,
543+
sql=sql,
544+
output_type=output_type,
545+
previous_queries=previous_queries,
546+
)
547+
return result.query
548+
except Exception:
549+
return None
550+
512551
def __panel__(self):
513552
return self.interface
514553

lumen/ai/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ def model_post_init(self, __context):
2929
raise MissingContextError(self.insufficient_context_reason)
3030

3131

32+
class FollowUpSuggestion(BaseModel):
33+
query: str = Field(
34+
description="A single dataset-specific follow-up question referencing actual column names",
35+
max_length=100
36+
)
37+
38+
3239
class ErrorDescription(BaseModel):
3340
"""
3441
Represents a user-facing error explanation.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{% extends 'Actor/main.jinja2' %}
2+
3+
{% block instructions %}
4+
Suggest ONE concise follow-up question based on the user's query and the resulting data.
5+
6+
Rules:
7+
- MUST reference specific column names from the data summary
8+
- Keep it short and actionable (under 100 characters)
9+
- Do NOT repeat any previous queries
10+
- Do NOT suggest analyses the data cannot support
11+
{% endblock %}
12+
13+
{% block examples %}
14+
Examples:
15+
- "Show revenue trend over date by product"
16+
- "Compare revenue across region"
17+
- "Filter to top 5 products by revenue"
18+
{% endblock %}
19+
20+
{% block context %}
21+
Data Summary:
22+
{{ data_summary }}
23+
{% if sql %}
24+
SQL Executed: {{ sql }}
25+
{% endif %}
26+
Output Type: {{ output_type }}
27+
{% if previous_queries %}
28+
Previous Queries (do NOT repeat):
29+
{% for q in previous_queries %}- {{ q }}
30+
{% endfor %}{% endif %}
31+
{% endblock %}

lumen/ai/ui.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,12 @@ class UI(Viewer):
410410
export_functions = param.Dict(default={}, doc="""
411411
Dictionary mapping from name of exporter to export function.""")
412412

413+
follow_up_suggestions = param.Boolean(default=True, doc="""
414+
Whether to generate AI-powered follow-up suggestions after each
415+
successful query. When enabled, a lightbulb icon button appears
416+
in the message footer that populates the input with a follow-up
417+
suggestion on click. Requires an additional LLM call per query.""")
418+
413419
interface = param.ClassSelector(class_=ChatFeed, doc="""
414420
The interface for the Coordinator to interact with.""")
415421

@@ -1042,6 +1048,7 @@ def _configure_coordinator(self):
10421048
if self.analyses:
10431049
agents.append(AnalysisAgent(analyses=self.analyses))
10441050

1051+
self._background_tasks = set()
10451052
self._coordinator = self.coordinator(
10461053
agents=agents,
10471054
context=self.context,
@@ -1596,6 +1603,67 @@ async def _add_analysis_suggestions(self, plan: Plan):
15961603
num_objects=len(self.interface.objects),
15971604
)
15981605

1606+
def _add_followup_icon_to_footer(self, suggestion: str, num_objects: int):
1607+
"""Add a lightbulb icon button to the message footer actions.
1608+
1609+
On click, populates the ChatAreaInput with the suggestion
1610+
without auto-submitting, so the user can review or edit before sending.
1611+
"""
1612+
1613+
def on_click(event):
1614+
with edit_readonly(self._chat_input):
1615+
self._chat_input.value_input = suggestion
1616+
followup_button.visible = False
1617+
1618+
def hide_followup(_=None):
1619+
if len(self.interface.objects) > num_objects:
1620+
followup_button.visible = False
1621+
1622+
followup_button = IconButton(
1623+
icon="lightbulb",
1624+
description=f"Suggest: {suggestion}",
1625+
size="small",
1626+
icon_size="0.9em",
1627+
margin=(5, 0),
1628+
on_click=on_click,
1629+
name="FollowUp",
1630+
disabled=self.interface.param.loading,
1631+
color="default",
1632+
sx={"color": "#FFD700", "padding": "0 0.1em"},
1633+
)
1634+
1635+
if len(self.interface):
1636+
message = self.interface.objects[-1]
1637+
try:
1638+
existing = message.footer_actions or []
1639+
except AttributeError:
1640+
existing = message.footer_objects or []
1641+
existing = [
1642+
obj for obj in existing
1643+
if getattr(obj, 'name', '') != "FollowUp"
1644+
]
1645+
try:
1646+
message.footer_actions = existing + [followup_button]
1647+
except AttributeError:
1648+
message.footer_objects = existing + [followup_button]
1649+
self.interface.param.watch(hide_followup, "objects")
1650+
1651+
async def _generate_followup_suggestions(self, plan: Plan):
1652+
"""Render follow-up suggestion from the coordinator."""
1653+
if not self.follow_up_suggestions:
1654+
return
1655+
1656+
num_objects = len(self.interface.objects)
1657+
suggestion = await self._coordinator.suggest_followup(plan)
1658+
if not suggestion:
1659+
return
1660+
1661+
# Guard against stale message if user sent a new query while we were generating
1662+
if len(self.interface.objects) != num_objects:
1663+
return
1664+
1665+
self._add_followup_icon_to_footer(suggestion, num_objects)
1666+
15991667
def __panel__(self):
16001668
return self._main
16011669

@@ -1799,6 +1867,9 @@ def _render_sidebar(self) -> list[Viewable]:
17991867
validation = Switch(label='Validation Step', description='Check if the response fully answered your question', value=True)
18001868
self._coordinator.validation_enabled = validation
18011869
switches.append(validation)
1870+
followup = Switch(label='Follow-Up Suggestions', description='Show AI-generated follow-up questions after each query', value=self.follow_up_suggestions)
1871+
followup.link(self, value='follow_up_suggestions', bidirectional=True)
1872+
switches.append(followup)
18021873

18031874
# Add code execution selector if not hidden
18041875
code_agents = False
@@ -2506,6 +2577,9 @@ async def _postprocess_exploration(
25062577
await self._sync_sources(SimpleNamespace(new=plan.out_context), global_context=plan.out_context)
25072578
if "pipeline" in plan.out_context:
25082579
await self._add_analysis_suggestions(plan)
2580+
task = asyncio.create_task(self._generate_followup_suggestions(plan))
2581+
self._background_tasks.add(task)
2582+
task.add_done_callback(self._background_tasks.discard)
25092583

25102584
if is_new:
25112585
plan.param.watch(partial(self._update_views, exploration), "views")

lumen/tests/ai/test_coordinator.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
from lumen.ai.coordinator import Coordinator, Plan, Planner
2020
from lumen.ai.coordinator.planner import Reasoning, make_plan_model
2121
from lumen.ai.editors import SQLEditor
22-
from lumen.ai.models import ReplaceLine, RetrySpec
22+
from lumen.ai.models import (
23+
FollowUpSuggestion, ReplaceLine, RetrySpec,
24+
)
2325
from lumen.ai.report import ActorTask
2426
from lumen.ai.schemas import get_metaset
2527
from lumen.ai.tools import FunctionTool, define_tool
@@ -587,3 +589,81 @@ async def test_process_files_duplicate_table_name():
587589
df = sources[0].get("data")
588590
# Should have the latest data
589591
assert df.iloc[0, 0] == 99
592+
593+
594+
# --- Follow-up suggestion coordinator tests ---
595+
596+
async def test_suggest_followup_returns_suggestion(llm):
597+
"""Coordinator.suggest_followup should return a query string."""
598+
suggestion_response = FollowUpSuggestion(query="Show value by category")
599+
llm.set_responses([suggestion_response])
600+
601+
coordinator = Coordinator(llm=llm)
602+
plan = Plan(
603+
ActorTask(ChatAgent(llm=llm)),
604+
history=[{"content": "Sum value column", "role": "user"}],
605+
title="test",
606+
context={},
607+
)
608+
plan.out_context = {
609+
"pipeline": object(),
610+
"data": "columns: category, value",
611+
"sql": "SELECT * FROM t",
612+
}
613+
614+
result = await coordinator.suggest_followup(plan)
615+
assert result == "Show value by category"
616+
617+
618+
async def test_suggest_followup_no_pipeline(llm):
619+
"""Should return None when plan has no pipeline."""
620+
coordinator = Coordinator(llm=llm)
621+
plan = Plan(
622+
ActorTask(ChatAgent(llm=llm)),
623+
history=[],
624+
title="test",
625+
context={},
626+
)
627+
plan.out_context = {}
628+
629+
result = await coordinator.suggest_followup(plan)
630+
assert result is None
631+
632+
633+
async def test_suggest_followup_no_data(llm):
634+
"""Should return None when plan has no data summary."""
635+
coordinator = Coordinator(llm=llm)
636+
plan = Plan(
637+
ActorTask(ChatAgent(llm=llm)),
638+
history=[],
639+
title="test",
640+
context={},
641+
)
642+
plan.out_context = {"pipeline": object(), "data": ""}
643+
644+
result = await coordinator.suggest_followup(plan)
645+
assert result is None
646+
647+
648+
async def test_suggest_followup_llm_failure(llm):
649+
"""Should return None when LLM call fails."""
650+
def raise_error():
651+
raise RuntimeError("LLM unavailable")
652+
653+
llm.set_responses([raise_error])
654+
655+
coordinator = Coordinator(llm=llm)
656+
plan = Plan(
657+
ActorTask(ChatAgent(llm=llm)),
658+
history=[{"content": "test", "role": "user"}],
659+
title="test",
660+
context={},
661+
)
662+
plan.out_context = {
663+
"pipeline": object(),
664+
"data": "columns: a, b",
665+
"sql": "SELECT * FROM t",
666+
}
667+
668+
result = await coordinator.suggest_followup(plan)
669+
assert result is None

0 commit comments

Comments
 (0)