"""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