ForcePilot/backend/package/yuxi/channels/adapters/msteams/feedback.py
Kris 939f1ba82a refactor(msteams): 整理代码结构并新增多项功能
本次提交对Microsoft Teams适配器代码进行了多维度优化与新增:
1.  调整多处导入顺序,优化代码可读性
2.  新增media_tools工具模块,提供媒体相关辅助函数
3.  新增thread_history模块,实现对话历史拉取与缓存功能
4.  新增connection_modes模块,支持webhook/websocket/polling三种连接模式
5.  扩展security.py与tool_policy.py,新增通配符配置校验与三级策略解析
6.  新增feedback会话记录功能
7.  为sent_message_cache添加自动清理任务
8.  优化normalizer模块,新增引用、编辑消息解析与线程上下文注入
9.  重构file_upload的SSRF防护逻辑,复用公共校验工具
10. 修复多处导入顺序与代码排版问题
11. 为消息发送添加断路器保护与异步去重锁
2026-05-13 16:12:31 +08:00

235 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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})")
record_feedback_session(result)
return result
def record_feedback_session(feedback_result: dict[str, Any], storage_dir: str = "") -> None:
import os
session_dir = Path(
storage_dir or os.environ.get("MSTEAMS_FEEDBACK_DIR", "") or str(Path.home() / ".yuxi" / "msteams")
)
session_dir.mkdir(parents=True, exist_ok=True)
session_file = session_dir / "feedback-sessions.jsonl"
record = {
"timestamp": time.time(),
**feedback_result,
}
try:
with open(session_file, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
except OSError as e:
logger.warning(f"MSTeams feedback: failed to record session: {e}")