feat(monitoring): 关联反馈记录与消息ID,新增反馈导出 (#2120)

* feat(monitoring): link feedback to LangBot message ID and add feedback export

- Add pipeline→adapter notification hook so monitoring message ID is
  passed back to WecomBotAdapter after creation
- Store stream_id→monitoring_message_id mapping with 10-min TTL cleanup
- Replace feedback record stream_id with LangBot monitoring message ID
  so feedback can be linked to actual message records
- Rename streamId label to "Related Query ID" in all 7 i18n locales
- Remove non-functional message ID jump button from FeedbackList
- Add feedback export option to ExportDropdown (backend already implemented)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(monitoring): add combined refresh handler for monitoring and feedback data

* fix(wecombot): improve stream ID mapping and error logging in WecomBotAdapter

* feat(lark): add monitoring message ID mapping for feedback correlation

* feat(lark): rename monitoring message ID mappings for clarity and consistency
feat(feedback): add button to view conversation for feedback items

* feat(bot-session-monitor): add feedback handling for bot messages with visual indicators

* feat(bot-session-monitor): enhance feedback display with hover content for like/dislike indicators

* fix(dingtalk): use voice recognition text instead of raw audio binary

When DingTalk sends a voice message to the bot, the callback JSON contains
a 'recognition' field with the speech-to-text result (powered by Qwen).

Previously, LangBot only extracted the 'downloadCode' to download the raw
audio binary and passed it as 'file_base64' to LLM APIs, which caused
400 errors since most models don't support this content type.

This patch:
- Extracts the 'recognition' field from DingTalk audio message content
- Uses it as plain text input to the LLM instead of raw audio
- Falls back to audio binary only when no recognition text is available
- Fixes duplicate text issue for audio messages with recognition

Fixes voice messages returning 'Request failed' on all LLM models.

* fix: add filereader for dingtalk,lark (#2122)

* fix: add filereader for dingtalk

* feat: add lark

* feat: update uv.lock

* chore: update version to 4.9.6 in pyproject.toml, __init__.py, and uv.lock

* fix: update langbot-plugin version to 0.3.8

* fix: update langbot-plugin version to 0.3.8

* fix(wecombot): extend StreamSession TTL for feedback sessions to prevent context data loss

StreamSessionManager.cleanup() removes sessions after 60s TTL, but feedback
events (like → cancel → dislike) can arrive later. When the session expires
before the dislike event, all context fields (session_id, user_id, message_id,
stream_id) are lost because get_session_by_feedback_id() returns None.

Fix: Sessions with registered feedback_ids now use a 10-minute TTL, aligned
with the adapter's _stream_to_monitoring_msg TTL in wecombot.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: 6mvp6 <13727783693@163.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: fdc310 <2213070223@qq.com>
Co-authored-by: haiyangbg <zhouhaiyangaa@gmail.com>
Co-authored-by: Guanchao Wang <wangcham233@gmail.com>
Co-authored-by: Rock Chin <1010553892@qq.com>
This commit is contained in:
6mvp6
2026-04-18 12:56:41 +08:00
committed by GitHub
parent 917edb3413
commit f8010a20eb
15 changed files with 241 additions and 47 deletions

View File

@@ -71,6 +71,11 @@ class StreamSession:
class StreamSessionManager:
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
# Sessions with registered feedback_ids use a longer TTL to survive the
# full like → cancel → dislike feedback flow. Must align with the adapter's
# _stream_to_monitoring_msg TTL (wecombot.py).
_FEEDBACK_SESSION_TTL = 600 # 10 minutes
def __init__(self, logger: EventLogger, ttl: int = 60) -> None:
self.logger = logger
@@ -214,11 +219,17 @@ class StreamSessionManager:
session.last_access = time.time()
def cleanup(self) -> None:
"""定期清理过期会话,防止队列与映射无上限累积。"""
"""定期清理过期会话,防止队列与映射无上限累积。
已注册 feedback_id 的会话使用更长的 TTL确保用户在点赞/取消/点踩流程中
不会因为 session 被提前清除而丢失上下文信息。
"""
now = time.time()
expired: list[str] = []
for stream_id, session in self._sessions.items():
if now - session.last_access > self.ttl:
# Sessions with registered feedback_ids use a longer TTL
effective_ttl = self._FEEDBACK_SESSION_TTL if session.feedback_id else self.ttl
if now - session.last_access > effective_ttl:
expired.append(stream_id)
for stream_id in expired:

View File

@@ -297,6 +297,9 @@ class RuntimePipeline:
)
# Store message_id in query variables for LLM call monitoring
query.variables['_monitoring_message_id'] = message_id
# Notify adapter so it can map platform-specific IDs to monitoring message ID
if hasattr(query.adapter, 'on_monitoring_message_created'):
await query.adapter.on_monitoring_message_created(query, message_id)
except Exception as e:
self.ap.logger.error(f'Failed to record query start: {e}')

View File

@@ -787,6 +787,13 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
card_id_dict: dict[str, str] # 消息id到卡片id的映射便于创建卡片后的发送消息到指定卡片
# Monitoring message ID mapping for feedback correlation
# Temp: user Lark message ID → monitoring_message_id (populated by on_monitoring_message_created, consumed by create_message_card)
pending_monitoring_msg: dict[str, str]
# Final: reply Lark message ID → (monitoring_message_id, timestamp) (used by feedback callbacks)
reply_to_monitoring_msg: dict[str, tuple[str, float]]
_MONITORING_MAPPING_TTL = 600 # 10 minutes
seq: int # 用于在发送卡片消息中识别消息顺序直接以seq作为标识
bot_uuid: str = None # 机器人UUID
app_ticket: str = None # 商店应用用到
@@ -833,6 +840,11 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else:
session_id = None
# Resolve monitoring message ID from reply message mapping
monitoring_msg_id = None
if open_message_id and open_message_id in self.reply_to_monitoring_msg:
monitoring_msg_id = self.reply_to_monitoring_msg[open_message_id][0]
feedback_event = platform_events.FeedbackEvent(
feedback_id=getattr(event.header, 'event_id', str(uuid.uuid4())),
feedback_type=feedback_type,
@@ -840,6 +852,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
user_id=user_id,
session_id=session_id,
message_id=open_message_id,
stream_id=monitoring_msg_id,
source_platform_object=event,
)
@@ -878,6 +891,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
logger=logger,
lark_tenant_key=config.get('lark_tenant_key', ''),
card_id_dict={},
pending_monitoring_msg={},
reply_to_monitoring_msg={},
seq=1,
listeners={},
quart_app=quart_app,
@@ -1018,6 +1033,22 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
is_stream = True
return is_stream
async def on_monitoring_message_created(self, query, monitoring_message_id: str):
"""Called by pipeline after monitoring message is created, to map user message ID to monitoring message ID."""
try:
user_msg_id = query.message_event.message_chain.message_id
if user_msg_id:
self.pending_monitoring_msg[user_msg_id] = monitoring_message_id
except Exception as e:
await self.logger.debug(f'Failed to map message to monitoring message: {e}')
def _cleanup_monitoring_mapping(self):
"""Remove entries older than TTL from the reply-to-monitoring mapping."""
now = time.time()
expired = [k for k, (_, ts) in self.reply_to_monitoring_msg.items() if now - ts > self._MONITORING_MAPPING_TTL]
for k in expired:
del self.reply_to_monitoring_msg[k]
async def create_card_id(self, message_id):
try:
# self.logger.debug('飞书支持stream输出,创建卡片......')
@@ -1257,6 +1288,18 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
raise Exception(
f'client.im.v1.message.reply failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
)
# Transfer monitoring message mapping: user msg ID → reply msg ID
try:
user_msg_id = event.message_chain.message_id
reply_msg_id = getattr(response.data, 'message_id', None)
monitoring_msg_id = self.pending_monitoring_msg.pop(user_msg_id, None)
if reply_msg_id and monitoring_msg_id:
self.reply_to_monitoring_msg[reply_msg_id] = (monitoring_msg_id, time.time())
self._cleanup_monitoring_mapping()
except Exception as e:
asyncio.create_task(self.logger.debug(f'Failed to transfer monitoring mapping in create_message_card: {e}'))
return True
async def reply_message(
@@ -1567,6 +1610,11 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else:
session_id = None
# Resolve monitoring message ID from reply message mapping
monitoring_msg_id = None
if open_message_id and open_message_id in self.reply_to_monitoring_msg:
monitoring_msg_id = self.reply_to_monitoring_msg[open_message_id][0]
feedback_event = platform_events.FeedbackEvent(
feedback_id=data.get('header', {}).get('event_id', str(uuid.uuid4())),
feedback_type=feedback_type,
@@ -1574,6 +1622,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
user_id=user_id,
session_id=session_id,
message_id=open_message_id,
stream_id=monitoring_msg_id,
source_platform_object=data,
)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import typing
import asyncio
import time
import traceback
import datetime
@@ -293,6 +294,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
_ws_mode: bool = False
bot_name: str = ''
listeners: dict = {}
_stream_to_monitoring_msg: dict = {} # Maps stream_id to (monitoring_message_id, timestamp)
_STREAM_MAPPING_TTL = 600 # 10 minutes
def __init__(self, config: dict, logger: EventLogger):
enable_webhook = config.get('enable-webhook', False)
@@ -329,8 +332,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot_account_id=bot_account_id,
bot_name=bot_name,
event_converter=event_converter,
listeners={},
_stream_to_monitoring_msg={},
)
self.listeners = {}
async def reply_message(
self,
@@ -422,6 +426,23 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""设置 bot UUID用于生成 webhook URL"""
self.bot_uuid = bot_uuid
async def on_monitoring_message_created(self, query, monitoring_message_id: str):
"""Called by pipeline after monitoring message is created, to map stream_id to monitoring message ID."""
try:
stream_id = query.message_event.source_platform_object.stream_id
if stream_id:
self._stream_to_monitoring_msg[stream_id] = (monitoring_message_id, time.time())
self._cleanup_stream_mapping()
except Exception as e:
await self.logger.debug(f'Failed to map stream_id to monitoring message: {e}')
def _cleanup_stream_mapping(self):
"""Remove entries older than TTL from the stream_id to monitoring message mapping."""
now = time.time()
expired = [k for k, (_, ts) in self._stream_to_monitoring_msg.items() if now - ts > self._STREAM_MAPPING_TTL]
for k in expired:
del self._stream_to_monitoring_msg[k]
async def _on_feedback(self, **kwargs):
"""Handle feedback event from WeChat Work AI Bot SDK and dispatch as FeedbackEvent."""
try:
@@ -447,6 +468,11 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
message_id = session.msg_id
stream_id = session.stream_id
# Resolve stream_id to LangBot monitoring message ID if available
monitoring_msg_id = None
if stream_id and stream_id in self._stream_to_monitoring_msg:
monitoring_msg_id = self._stream_to_monitoring_msg[stream_id][0]
await self.logger.info(
f'Feedback event: feedback_id={feedback_id}, type={feedback_type}, '
f'session_id={session_id}, user_id={user_id}, message_id={message_id}'
@@ -460,7 +486,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
user_id=user_id,
session_id=session_id,
message_id=message_id,
stream_id=stream_id,
stream_id=monitoring_msg_id or stream_id,
source_platform_object=session,
)