mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 19:43:15 +00:00
214 lines
6.2 KiB
Python
214 lines
6.2 KiB
Python
|
|
"""Slack reaction feedback handling."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import logging
|
||
|
|
import os
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from langgraph_sdk import get_client
|
||
|
|
from langgraph_sdk.client import LangGraphClient
|
||
|
|
|
||
|
|
from .langsmith import create_langsmith_feedback, delete_langsmith_feedback
|
||
|
|
from .slack import lookup_slack_run_mapping
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
LANGGRAPH_URL = os.environ.get("LANGGRAPH_URL") or os.environ.get(
|
||
|
|
"LANGGRAPH_URL_PROD", "http://localhost:2024"
|
||
|
|
)
|
||
|
|
|
||
|
|
FEEDBACK_REACTIONS: dict[str, float] = {
|
||
|
|
"+1": 1.0,
|
||
|
|
"thumbsup": 1.0,
|
||
|
|
"-1": 0.0,
|
||
|
|
"thumbsdown": 0.0,
|
||
|
|
}
|
||
|
|
|
||
|
|
_REACTION_STATE_NAMESPACE = "slack_reaction_state"
|
||
|
|
_REACTION_EVENT_NAMESPACE = "slack_reaction_events"
|
||
|
|
|
||
|
|
|
||
|
|
def _read_active_reactions(item: dict[str, Any] | None) -> set[str]:
|
||
|
|
if not item:
|
||
|
|
return set()
|
||
|
|
value = item.get("value")
|
||
|
|
if not isinstance(value, dict):
|
||
|
|
return set()
|
||
|
|
reactions = value.get("reactions")
|
||
|
|
if not isinstance(reactions, list):
|
||
|
|
return set()
|
||
|
|
return {reaction for reaction in reactions if isinstance(reaction, str)}
|
||
|
|
|
||
|
|
|
||
|
|
def _feedback_key(channel_id: str, user_id: str, message_ts: str) -> str:
|
||
|
|
return f"slack_reaction:{channel_id}:{user_id}:{message_ts}"
|
||
|
|
|
||
|
|
|
||
|
|
def _reaction_state_key(run_id: str, user_id: str, message_ts: str) -> str:
|
||
|
|
return f"{run_id}:{user_id}:{message_ts}"
|
||
|
|
|
||
|
|
|
||
|
|
async def _event_was_processed(
|
||
|
|
langgraph_client: LangGraphClient, channel_id: str, event_id: str
|
||
|
|
) -> bool:
|
||
|
|
if not event_id:
|
||
|
|
return False
|
||
|
|
item = await langgraph_client.store.get_item((_REACTION_EVENT_NAMESPACE, channel_id), event_id)
|
||
|
|
return bool(item)
|
||
|
|
|
||
|
|
|
||
|
|
async def _mark_event_processed(
|
||
|
|
langgraph_client: LangGraphClient, channel_id: str, event_id: str
|
||
|
|
) -> None:
|
||
|
|
if not event_id:
|
||
|
|
return
|
||
|
|
await langgraph_client.store.put_item(
|
||
|
|
(_REACTION_EVENT_NAMESPACE, channel_id), event_id, {"event_id": event_id}
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
async def _update_reaction_state(
|
||
|
|
langgraph_client: LangGraphClient,
|
||
|
|
*,
|
||
|
|
channel_id: str,
|
||
|
|
run_id: str,
|
||
|
|
user_id: str,
|
||
|
|
message_ts: str,
|
||
|
|
reaction: str,
|
||
|
|
added: bool,
|
||
|
|
) -> set[str]:
|
||
|
|
namespace = (_REACTION_STATE_NAMESPACE, channel_id)
|
||
|
|
key = _reaction_state_key(run_id, user_id, message_ts)
|
||
|
|
item = await langgraph_client.store.get_item(namespace, key)
|
||
|
|
active_reactions = _read_active_reactions(item)
|
||
|
|
|
||
|
|
if added:
|
||
|
|
active_reactions.add(reaction)
|
||
|
|
else:
|
||
|
|
active_reactions.discard(reaction)
|
||
|
|
|
||
|
|
await langgraph_client.store.put_item(
|
||
|
|
namespace,
|
||
|
|
key,
|
||
|
|
{
|
||
|
|
"run_id": run_id,
|
||
|
|
"user_id": user_id,
|
||
|
|
"message_ts": message_ts,
|
||
|
|
"reactions": sorted(active_reactions),
|
||
|
|
},
|
||
|
|
)
|
||
|
|
return active_reactions
|
||
|
|
|
||
|
|
|
||
|
|
def _score_reactions(reactions: set[str]) -> float | None:
|
||
|
|
scores = {
|
||
|
|
FEEDBACK_REACTIONS[reaction] for reaction in reactions if reaction in FEEDBACK_REACTIONS
|
||
|
|
}
|
||
|
|
if not scores:
|
||
|
|
return None
|
||
|
|
if len(scores) > 1:
|
||
|
|
# Conflicting positive + negative reactions from the same user — treat
|
||
|
|
# as ambiguous and clear feedback rather than recording a misleading
|
||
|
|
# average score.
|
||
|
|
return None
|
||
|
|
return next(iter(scores))
|
||
|
|
|
||
|
|
|
||
|
|
async def process_slack_reaction(
|
||
|
|
event: dict[str, Any],
|
||
|
|
*,
|
||
|
|
event_id: str = "",
|
||
|
|
added: bool,
|
||
|
|
) -> None:
|
||
|
|
reaction = event.get("reaction")
|
||
|
|
if not isinstance(reaction, str) or reaction not in FEEDBACK_REACTIONS:
|
||
|
|
return
|
||
|
|
|
||
|
|
item = event.get("item")
|
||
|
|
if not isinstance(item, dict) or item.get("type") != "message":
|
||
|
|
return
|
||
|
|
|
||
|
|
channel_id = item.get("channel")
|
||
|
|
message_ts = item.get("ts")
|
||
|
|
user_id = event.get("user")
|
||
|
|
if not (
|
||
|
|
isinstance(channel_id, str)
|
||
|
|
and channel_id
|
||
|
|
and isinstance(message_ts, str)
|
||
|
|
and message_ts
|
||
|
|
and isinstance(user_id, str)
|
||
|
|
and user_id
|
||
|
|
):
|
||
|
|
return
|
||
|
|
|
||
|
|
langgraph_client = get_client(url=LANGGRAPH_URL)
|
||
|
|
if await _event_was_processed(langgraph_client, channel_id, event_id):
|
||
|
|
return
|
||
|
|
|
||
|
|
mapping = await lookup_slack_run_mapping(langgraph_client, channel_id, message_ts)
|
||
|
|
if not mapping:
|
||
|
|
logger.debug(
|
||
|
|
"No run mapping for Slack reaction on channel=%s message=%s",
|
||
|
|
channel_id,
|
||
|
|
message_ts,
|
||
|
|
)
|
||
|
|
return
|
||
|
|
run_id_value = mapping.get("run_id")
|
||
|
|
if not isinstance(run_id_value, str) or not run_id_value:
|
||
|
|
return
|
||
|
|
run_id = run_id_value
|
||
|
|
|
||
|
|
triggering_user_id = mapping.get("triggering_user_id")
|
||
|
|
if isinstance(triggering_user_id, str) and triggering_user_id and triggering_user_id != user_id:
|
||
|
|
# Only the user who triggered the run may give feedback on it. Other
|
||
|
|
# reactors are ignored to keep eval signal clean in shared channels.
|
||
|
|
logger.debug(
|
||
|
|
"Ignoring Slack reaction from non-triggering user=%s on run=%s",
|
||
|
|
user_id,
|
||
|
|
run_id,
|
||
|
|
)
|
||
|
|
return
|
||
|
|
|
||
|
|
active_reactions = await _update_reaction_state(
|
||
|
|
langgraph_client,
|
||
|
|
channel_id=channel_id,
|
||
|
|
run_id=run_id,
|
||
|
|
user_id=user_id,
|
||
|
|
message_ts=message_ts,
|
||
|
|
reaction=reaction,
|
||
|
|
added=added,
|
||
|
|
)
|
||
|
|
|
||
|
|
key = _feedback_key(channel_id, user_id, message_ts)
|
||
|
|
source_info = {
|
||
|
|
"source": "slack_reaction",
|
||
|
|
"channel_id": channel_id,
|
||
|
|
"message_ts": message_ts,
|
||
|
|
"user_id": user_id,
|
||
|
|
}
|
||
|
|
score = _score_reactions(active_reactions)
|
||
|
|
if score is None:
|
||
|
|
success = await asyncio.to_thread(delete_langsmith_feedback, run_id, key)
|
||
|
|
else:
|
||
|
|
success = await asyncio.to_thread(
|
||
|
|
create_langsmith_feedback,
|
||
|
|
run_id,
|
||
|
|
key,
|
||
|
|
score=score,
|
||
|
|
comment=f"Slack reaction feedback from user {user_id}",
|
||
|
|
source_info={**source_info, "reactions": sorted(active_reactions)},
|
||
|
|
)
|
||
|
|
|
||
|
|
if success:
|
||
|
|
await _mark_event_processed(langgraph_client, channel_id, event_id)
|
||
|
|
|
||
|
|
|
||
|
|
async def process_slack_reaction_added(event: dict[str, Any], event_id: str = "") -> None:
|
||
|
|
await process_slack_reaction(event, event_id=event_id, added=True)
|
||
|
|
|
||
|
|
|
||
|
|
async def process_slack_reaction_removed(event: dict[str, Any], event_id: str = "") -> None:
|
||
|
|
await process_slack_reaction(event, event_id=event_id, added=False)
|