Skip to content

Commit 2d837c9

Browse files
authored
feat: add in-product survey system (#2008)
* feat: add in-product survey system - SurveyManager: event-based trigger, Space API communication - Trigger on first successful non-WebSocket response - Backend API: /api/v1/survey/{pending,respond,dismiss} - Frontend: floating survey widget with progressive questions - Flat radio/checkbox style (not dropdown Select) * fix: persist triggered survey events to disk across restarts Store triggered events in data/survey_triggered_events.json so that restarting the process doesn't re-query Space for already-triggered events. * fix: use metadata table for survey event persistence instead of file Store triggered events in the existing metadata KV table (key='survey_triggered_events') instead of a standalone JSON file. * fix: ruff format and prettier fixes
1 parent 2ded774 commit 2d837c9

File tree

9 files changed

+650
-0
lines changed

9 files changed

+650
-0
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import quart
2+
3+
from .. import group
4+
5+
6+
@group.group_class('survey', '/api/v1/survey')
7+
class SurveyRouterGroup(group.RouterGroup):
8+
async def initialize(self) -> None:
9+
@self.route('/pending', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
10+
async def _get_pending() -> str:
11+
"""Get pending survey for the frontend to display."""
12+
survey = self.ap.survey.get_pending_survey() if self.ap.survey else None
13+
return self.success(data={'survey': survey})
14+
15+
@self.route('/respond', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
16+
async def _respond() -> str:
17+
"""Submit survey response."""
18+
json_data = await quart.request.json
19+
survey_id = json_data.get('survey_id')
20+
answers = json_data.get('answers', {})
21+
completed = json_data.get('completed', True)
22+
23+
if not survey_id:
24+
return self.fail(1, 'survey_id required')
25+
26+
if self.ap.survey:
27+
ok = await self.ap.survey.submit_response(survey_id, answers, completed)
28+
if ok:
29+
return self.success()
30+
return self.fail(2, 'Failed to submit response')
31+
return self.fail(3, 'Survey not available')
32+
33+
@self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
34+
async def _dismiss() -> str:
35+
"""Dismiss survey."""
36+
json_data = await quart.request.json
37+
survey_id = json_data.get('survey_id')
38+
39+
if not survey_id:
40+
return self.fail(1, 'survey_id required')
41+
42+
if self.ap.survey:
43+
ok = await self.ap.survey.dismiss_survey(survey_id)
44+
if ok:
45+
return self.success()
46+
return self.fail(2, 'Failed to dismiss')
47+
return self.fail(3, 'Survey not available')

src/langbot/pkg/core/app.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from ..rag.knowledge import kbmgr as rag_mgr
4040
from ..vector import mgr as vectordb_mgr
4141
from ..telemetry import telemetry as telemetry_module
42+
from ..survey import manager as survey_module
4243

4344

4445
class Application:
@@ -147,6 +148,8 @@ class Application:
147148

148149
telemetry: telemetry_module.TelemetryManager = None
149150

151+
survey: survey_module.SurveyManager = None
152+
150153
monitoring_service: monitoring_service.MonitoringService = None
151154

152155
def __init__(self):

src/langbot/pkg/core/stages/build_app.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from ...vector import mgr as vectordb_mgr
3535
from .. import taskmgr
3636
from ...telemetry import telemetry as telemetry_module
37+
from ...survey import manager as survey_module
3738

3839

3940
@stage.stage_class('BuildAppStage')
@@ -110,6 +111,11 @@ async def run(self, ap: app.Application):
110111
await telemetry_inst.initialize()
111112
ap.telemetry = telemetry_inst
112113

114+
# Survey manager
115+
survey_inst = survey_module.SurveyManager(ap)
116+
await survey_inst.initialize()
117+
ap.survey = survey_inst
118+
113119
cmd_mgr_inst = cmdmgr.CommandManager(ap)
114120
await cmd_mgr_inst.initialize()
115121
ap.cmd_mgr = cmd_mgr_inst

src/langbot/pkg/pipeline/process/handlers/chat.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,11 @@ async def handle(
200200

201201
# Send telemetry asynchronously and do not block pipeline via app's telemetry manager
202202
await self.ap.telemetry.start_send_task(payload)
203+
204+
# Trigger survey event on first successful non-WebSocket response
205+
if not locals().get('error_info') and adapter_name and 'WebSocket' not in adapter_name:
206+
if self.ap.survey:
207+
await self.ap.survey.trigger_event('first_bot_response_success')
203208
except Exception as ex:
204209
# Ensure telemetry issues do not affect normal flow
205210
self.ap.logger.warning(f'Failed to send telemetry: {ex}')

src/langbot/pkg/survey/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Survey module for in-product surveys triggered by events."""

src/langbot/pkg/survey/manager.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""Survey manager: tracks events, communicates with Space to fetch/submit surveys."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import json
7+
import typing
8+
import httpx
9+
import sqlalchemy
10+
11+
from ..core import app as core_app
12+
from ..entity.persistence.metadata import Metadata
13+
from ..utils import constants
14+
15+
SURVEY_TRIGGERED_KEY = 'survey_triggered_events'
16+
17+
18+
class SurveyManager:
19+
"""Manages survey lifecycle: event tracking, pending survey fetch, submission."""
20+
21+
def __init__(self, ap: core_app.Application):
22+
self.ap = ap
23+
self._triggered_events: set[str] = set()
24+
self._pending_survey: typing.Optional[dict] = None
25+
self._space_url: str = ''
26+
27+
async def initialize(self):
28+
space_config = self.ap.instance_config.data.get('space', {})
29+
self._space_url = space_config.get('url', '').rstrip('/')
30+
await self._load_triggered_events()
31+
32+
async def _load_triggered_events(self):
33+
"""Load previously triggered events from metadata table."""
34+
try:
35+
result = await self.ap.persistence_mgr.execute_async(
36+
sqlalchemy.select(Metadata).where(Metadata.key == SURVEY_TRIGGERED_KEY)
37+
)
38+
row = result.first()
39+
if row:
40+
self._triggered_events = set(json.loads(row[0].value))
41+
except Exception:
42+
self._triggered_events = set()
43+
44+
async def _save_triggered_events(self):
45+
"""Persist triggered events to metadata table."""
46+
try:
47+
value = json.dumps(list(self._triggered_events))
48+
result = await self.ap.persistence_mgr.execute_async(
49+
sqlalchemy.select(Metadata).where(Metadata.key == SURVEY_TRIGGERED_KEY)
50+
)
51+
if result.first():
52+
await self.ap.persistence_mgr.execute_async(
53+
sqlalchemy.update(Metadata).where(Metadata.key == SURVEY_TRIGGERED_KEY).values(value=value)
54+
)
55+
else:
56+
await self.ap.persistence_mgr.execute_async(
57+
sqlalchemy.insert(Metadata).values(key=SURVEY_TRIGGERED_KEY, value=value)
58+
)
59+
except Exception as e:
60+
self.ap.logger.debug(f'Failed to save survey triggered events: {e}')
61+
62+
def _is_space_configured(self) -> bool:
63+
space_config = self.ap.instance_config.data.get('space', {})
64+
if space_config.get('disable_telemetry', False):
65+
return False
66+
return bool(self._space_url)
67+
68+
async def trigger_event(self, event: str):
69+
"""Called when an event occurs. Checks Space for a pending survey."""
70+
if event in self._triggered_events:
71+
return
72+
if not self._is_space_configured():
73+
return
74+
75+
self._triggered_events.add(event)
76+
await self._save_triggered_events()
77+
78+
# Check for pending survey asynchronously
79+
asyncio.create_task(self._fetch_pending_survey(event))
80+
81+
async def _fetch_pending_survey(self, event: str):
82+
"""Fetch pending survey from Space for this event."""
83+
try:
84+
url = f'{self._space_url}/api/v1/survey/pending'
85+
payload = {
86+
'instance_id': constants.instance_id,
87+
'event': event,
88+
}
89+
async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:
90+
resp = await client.post(url, json=payload)
91+
if resp.status_code == 200:
92+
data = resp.json()
93+
if data.get('code') == 0 and data.get('data', {}).get('survey'):
94+
self._pending_survey = data['data']['survey']
95+
self.ap.logger.info(f'Survey pending: {self._pending_survey.get("survey_id")}')
96+
except Exception as e:
97+
self.ap.logger.debug(f'Failed to fetch pending survey: {e}')
98+
99+
def get_pending_survey(self) -> typing.Optional[dict]:
100+
"""Return the current pending survey (if any) for the frontend to display."""
101+
return self._pending_survey
102+
103+
def clear_pending_survey(self):
104+
"""Clear the pending survey (after user responds or dismisses)."""
105+
self._pending_survey = None
106+
107+
async def submit_response(self, survey_id: str, answers: dict, completed: bool = True) -> bool:
108+
"""Submit a survey response to Space."""
109+
if not self._is_space_configured():
110+
return False
111+
try:
112+
url = f'{self._space_url}/api/v1/survey/respond'
113+
payload = {
114+
'survey_id': survey_id,
115+
'instance_id': constants.instance_id,
116+
'answers': answers,
117+
'metadata': {
118+
'version': constants.semantic_version,
119+
},
120+
'completed': completed,
121+
}
122+
async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:
123+
resp = await client.post(url, json=payload)
124+
if resp.status_code == 200:
125+
self.clear_pending_survey()
126+
return True
127+
except Exception as e:
128+
self.ap.logger.warning(f'Failed to submit survey response: {e}')
129+
return False
130+
131+
async def dismiss_survey(self, survey_id: str) -> bool:
132+
"""Dismiss a survey."""
133+
if not self._is_space_configured():
134+
return False
135+
try:
136+
url = f'{self._space_url}/api/v1/survey/dismiss'
137+
payload = {
138+
'survey_id': survey_id,
139+
'instance_id': constants.instance_id,
140+
}
141+
async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:
142+
resp = await client.post(url, json=payload)
143+
if resp.status_code == 200:
144+
self.clear_pending_survey()
145+
return True
146+
except Exception as e:
147+
self.ap.logger.warning(f'Failed to dismiss survey: {e}')
148+
return False

0 commit comments

Comments
 (0)