实现了 Teams 机器人所需的全功能组件,包括: - 基础命令解析与帮助卡片生成 - 租户验证与访问控制 - 自定义 UA 与媒体工具 - 消息分块、批注处理与会话管理 - 防抖、缓存与配置路由能力 - 投票、配对、审计与运行时状态管理 - TTS 语音合成与卡片构建工具 - 群组管理与权限控制逻辑
212 lines
6.9 KiB
Python
212 lines
6.9 KiB
Python
"""Microsoft Teams AI 反馈系统。
|
||
|
||
在 AI 回复消息中附加 Teams Native 拇指标反馈按钮,
|
||
处理 feedback invoke 交互,以及负面反馈反思流水线。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from yuxi.utils.logging_config import logger
|
||
|
||
FEEDBACK_INVOKE_NAME = "message/submitAction"
|
||
|
||
_REFLECTION_PROMPT_TEMPLATE = (
|
||
"You received a 👎 negative feedback on your last response. "
|
||
"Please think about what went wrong and write a brief, constructive self-reflection "
|
||
"to help you improve future responses. Focus on specific improvements.\n\n"
|
||
"The user's message was:\n{user_message}\n\nYour response was:\n{bot_response}"
|
||
)
|
||
|
||
_CODE_BLOCK_PATTERN = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
|
||
_DEFAULT_REFLECTION_COOLDOWN_MS = 300000
|
||
_LEARNINGS_STORE_FILENAME = "msteams-feedback-learnings.json"
|
||
|
||
|
||
def build_feedback_channel_data(
|
||
feedback_enabled: bool = True,
|
||
feedback_reflection: bool = False,
|
||
feedback_reflection_cooldown_ms: int = _DEFAULT_REFLECTION_COOLDOWN_MS,
|
||
) -> dict[str, Any] | None:
|
||
if not feedback_enabled:
|
||
return None
|
||
data: dict[str, Any] = {"feedbackLoopEnabled": True}
|
||
if feedback_reflection:
|
||
data["feedbackReflection"] = True
|
||
data["feedbackReflectionCooldownMs"] = feedback_reflection_cooldown_ms
|
||
return data
|
||
|
||
|
||
def build_feedback_activity(text: str, feedback_enabled: bool = True) -> dict[str, Any]:
|
||
activity: dict[str, Any] = {
|
||
"type": "message",
|
||
"text": text,
|
||
"textFormat": "markdown",
|
||
}
|
||
channel_data = build_feedback_channel_data(feedback_enabled=feedback_enabled)
|
||
if channel_data:
|
||
activity["channelData"] = channel_data
|
||
return activity
|
||
|
||
|
||
def is_feedback_invoke(activity: dict[str, Any]) -> bool:
|
||
return activity.get("name", "") == FEEDBACK_INVOKE_NAME
|
||
|
||
|
||
def parse_feedback_value(activity: dict[str, Any]) -> str:
|
||
value = activity.get("value", {}) or {}
|
||
return str(value.get("feedbackValue", ""))
|
||
|
||
|
||
def build_reflection_prompt(user_message: str, bot_response: str) -> str:
|
||
return _REFLECTION_PROMPT_TEMPLATE.format(
|
||
user_message=user_message[:2000],
|
||
bot_response=bot_response[:2000],
|
||
)
|
||
|
||
|
||
def parse_reflection_response(raw: str) -> str:
|
||
"""多路径解析反思响应:JSON.parse → 代码块提取 → 安全回退。"""
|
||
json_result = _try_parse_json_reflection(raw)
|
||
if json_result:
|
||
return json_result
|
||
|
||
block_result = _try_extract_code_block(raw)
|
||
if block_result:
|
||
return block_result
|
||
|
||
return raw.strip()[:1000]
|
||
|
||
|
||
def _try_parse_json_reflection(raw: str) -> str:
|
||
try:
|
||
data = json.loads(raw)
|
||
if isinstance(data, dict):
|
||
return str(data.get("reflection", "") or data.get("thought", "") or "")
|
||
if isinstance(data, list):
|
||
return " ".join(str(item) for item in data)[:1000]
|
||
return str(data)
|
||
except (json.JSONDecodeError, TypeError):
|
||
return ""
|
||
|
||
|
||
def _try_extract_code_block(raw: str) -> str:
|
||
m = _CODE_BLOCK_PATTERN.search(raw)
|
||
if m:
|
||
inner = m.group(1).strip()
|
||
json_result = _try_parse_json_reflection(inner)
|
||
return json_result or inner[:1000]
|
||
return ""
|
||
|
||
|
||
class FeedbackLearningsStore:
|
||
"""Session-level 学习存储:持久化用户过反馈的反思结果。"""
|
||
|
||
def __init__(self, storage_dir: str | None = None):
|
||
self._storage_dir = Path(storage_dir or str(Path.home() / ".yuxi" / "msteams"))
|
||
self._storage_dir.mkdir(parents=True, exist_ok=True)
|
||
self._learnings: dict[str, list[dict[str, Any]]] = {}
|
||
self._cooldowns: dict[str, float] = {}
|
||
self._load()
|
||
|
||
@property
|
||
def file_path(self) -> Path:
|
||
return self._storage_dir / _LEARNINGS_STORE_FILENAME
|
||
|
||
def _load(self) -> None:
|
||
if not self.file_path.exists():
|
||
return
|
||
try:
|
||
data = json.loads(self.file_path.read_text(encoding="utf-8"))
|
||
self._learnings = data.get("learnings", {})
|
||
self._cooldowns = data.get("cooldowns", {})
|
||
except (json.JSONDecodeError, OSError):
|
||
pass
|
||
|
||
def _save(self) -> None:
|
||
try:
|
||
self.file_path.write_text(
|
||
json.dumps(
|
||
{"learnings": self._learnings, "cooldowns": self._cooldowns},
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
except OSError as e:
|
||
logger.error(f"MSTeams learnings: failed to save: {e}")
|
||
|
||
def is_reflection_allowed(self, session_key: str, cooldown_ms: int = _DEFAULT_REFLECTION_COOLDOWN_MS) -> bool:
|
||
last = self._cooldowns.get(session_key, 0)
|
||
elapsed = (time.monotonic() - last) * 1000
|
||
return elapsed >= cooldown_ms
|
||
|
||
def store_session_learning(self, session_key: str, user_message: str, bot_response: str, reflection: str) -> None:
|
||
entry = {
|
||
"timestamp": time.time(),
|
||
"user_message": user_message[:500],
|
||
"bot_response": bot_response[:500],
|
||
"reflection": reflection[:1000],
|
||
}
|
||
if session_key not in self._learnings:
|
||
self._learnings[session_key] = []
|
||
self._learnings[session_key].append(entry)
|
||
if len(self._learnings[session_key]) > 50:
|
||
self._learnings[session_key] = self._learnings[session_key][-50:]
|
||
self._cooldowns[session_key] = time.monotonic()
|
||
self._save()
|
||
|
||
def load_session_learnings(self, session_key: str) -> list[dict[str, Any]]:
|
||
return self._learnings.get(session_key, [])[-10:]
|
||
|
||
def clear_reflection_cooldowns(self, session_key: str | None = None) -> None:
|
||
if session_key:
|
||
self._cooldowns.pop(session_key, None)
|
||
else:
|
||
self._cooldowns.clear()
|
||
self._save()
|
||
|
||
def clear(self) -> None:
|
||
self._learnings.clear()
|
||
self._cooldowns.clear()
|
||
self._save()
|
||
|
||
@property
|
||
def session_count(self) -> int:
|
||
return len(self._learnings)
|
||
|
||
|
||
def process_feedback(
|
||
activity: dict[str, Any],
|
||
on_positive: Any = None,
|
||
on_negative: Any = None,
|
||
) -> dict[str, Any]:
|
||
feedback_value = parse_feedback_value(activity)
|
||
from_info = activity.get("from", {}) or {}
|
||
user_id = from_info.get("aadObjectId", "") or from_info.get("id", "")
|
||
user_name = from_info.get("name", "")
|
||
|
||
is_positive = feedback_value == "positive"
|
||
is_negative = feedback_value == "negative"
|
||
|
||
result: dict[str, Any] = {
|
||
"feedback_value": feedback_value,
|
||
"is_positive": is_positive,
|
||
"is_negative": is_negative,
|
||
"user_id": user_id,
|
||
"user_name": user_name,
|
||
"reply_to_id": activity.get("replyToId", ""),
|
||
}
|
||
|
||
if is_negative:
|
||
logger.info(f"MSTeams negative feedback received from {user_name} ({user_id})")
|
||
elif is_positive:
|
||
logger.info(f"MSTeams positive feedback received from {user_name} ({user_id})")
|
||
|
||
return result
|