ForcePilot/backend/package/yuxi/channels/adapters/msteams/feedback.py

212 lines
6.9 KiB
Python
Raw Normal View History

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