feat(session): 新增会话记录相关工具类
新增了session模块,实现了SessionRecorder会话记录器以及SessionOpResult结果类,提供会话的创建、更新、关闭、重开、触达以及查询等完整会话管理能力
This commit is contained in:
parent
3b487fae57
commit
5a01c894f2
6
backend/package/yuxi/channel/session/__init__.py
Normal file
6
backend/package/yuxi/channel/session/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
from yuxi.channel.session.recorder import SessionOpResult, SessionRecorder
|
||||
|
||||
__all__ = [
|
||||
"SessionOpResult",
|
||||
"SessionRecorder",
|
||||
]
|
||||
293
backend/package/yuxi/channel/session/recorder.py
Normal file
293
backend/package/yuxi/channel/session/recorder.py
Normal file
@ -0,0 +1,293 @@
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from yuxi.channel.message.models import DispatchResult, UnifiedMessage
|
||||
from yuxi.repositories.conversation_repository import ConversationRepository
|
||||
from yuxi.utils.datetime_utils import utc_isoformat
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CONVERSATION_TERMINAL_STATUSES = frozenset({"closed", "archived", "deleted"})
|
||||
|
||||
|
||||
def is_terminal_status(status: str) -> bool:
|
||||
return status in _CONVERSATION_TERMINAL_STATUSES
|
||||
|
||||
|
||||
_METADATA_SESSION_KEY = "session_key"
|
||||
_METADATA_SOURCE = "source"
|
||||
_METADATA_SOURCE_CHANNEL = "channel"
|
||||
_METADATA_CHANNEL_TYPE = "channel_type"
|
||||
_METADATA_ACCOUNT_ID = "account_id"
|
||||
_METADATA_PEER_ID = "peer_id"
|
||||
_METADATA_PEER_KIND = "peer_kind"
|
||||
_METADATA_LAST_MSG_ID = "last_msg_id"
|
||||
_METADATA_FIRST_INTERACTION_AT = "first_interaction_at"
|
||||
_METADATA_LAST_INTERACTION_AT = "last_interaction_at"
|
||||
_METADATA_RECORDED_AT = "recorded_at"
|
||||
_METADATA_CLOSED_AT = "closed_at"
|
||||
_METADATA_CLOSE_REASON = "close_reason"
|
||||
|
||||
|
||||
def _normalize_session_key(session_key: str) -> str:
|
||||
return session_key.strip().lower()
|
||||
|
||||
|
||||
def _build_base_metadata(msg: UnifiedMessage, session_key: str) -> dict:
|
||||
return {
|
||||
_METADATA_SOURCE: _METADATA_SOURCE_CHANNEL,
|
||||
_METADATA_CHANNEL_TYPE: msg.channel_type,
|
||||
_METADATA_ACCOUNT_ID: msg.account_id,
|
||||
_METADATA_PEER_ID: msg.sender.id,
|
||||
_METADATA_PEER_KIND: msg.sender.kind.value,
|
||||
_METADATA_SESSION_KEY: _normalize_session_key(session_key),
|
||||
}
|
||||
|
||||
|
||||
def _build_create_metadata(msg: UnifiedMessage, session_key: str) -> dict:
|
||||
now = utc_isoformat()
|
||||
meta = _build_base_metadata(msg, session_key)
|
||||
meta[_METADATA_LAST_MSG_ID] = msg.msg_id
|
||||
meta[_METADATA_FIRST_INTERACTION_AT] = now
|
||||
meta[_METADATA_LAST_INTERACTION_AT] = now
|
||||
meta[_METADATA_RECORDED_AT] = now
|
||||
return meta
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionOpResult:
|
||||
success: bool
|
||||
thread_id: str
|
||||
error: str | None = None
|
||||
found: bool = True
|
||||
|
||||
|
||||
class SessionRecorder:
|
||||
def __init__(self, conv_repo: ConversationRepository):
|
||||
self._conv_repo = conv_repo
|
||||
|
||||
async def record(
|
||||
self,
|
||||
msg: UnifiedMessage,
|
||||
dispatch_result: DispatchResult,
|
||||
session_key: str,
|
||||
user_id: str = "channel_user",
|
||||
title: str | None = None,
|
||||
on_error: Callable[[Exception], None] | None = None,
|
||||
track_task: Callable[[object], None] | None = None,
|
||||
) -> None:
|
||||
task = self._create_conversation_async(
|
||||
msg,
|
||||
dispatch_result,
|
||||
session_key,
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
on_error=on_error,
|
||||
)
|
||||
if track_task is not None:
|
||||
track_task(task)
|
||||
|
||||
async def record_or_update(
|
||||
self,
|
||||
msg: UnifiedMessage,
|
||||
dispatch_result: DispatchResult,
|
||||
session_key: str,
|
||||
user_id: str = "channel_user",
|
||||
title: str | None = None,
|
||||
on_error: Callable[[Exception], None] | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
conv = await self._conv_repo.get_conversation_by_thread_id(
|
||||
dispatch_result.thread_id
|
||||
)
|
||||
if conv is not None and is_terminal_status(conv.status):
|
||||
logger.debug(
|
||||
"Session record_or_update skipped: thread=%s status=%s",
|
||||
dispatch_result.thread_id,
|
||||
conv.status,
|
||||
)
|
||||
return False
|
||||
|
||||
base_meta = _build_create_metadata(msg, session_key)
|
||||
update_meta = {
|
||||
_METADATA_LAST_MSG_ID: msg.msg_id,
|
||||
_METADATA_LAST_INTERACTION_AT: utc_isoformat(),
|
||||
}
|
||||
|
||||
await self._conv_repo.upsert_conversation(
|
||||
thread_id=dispatch_result.thread_id,
|
||||
user_id=user_id,
|
||||
agent_id=str(dispatch_result.agent_config_id),
|
||||
title=title,
|
||||
metadata=base_meta,
|
||||
update_metadata=update_meta,
|
||||
)
|
||||
logger.info(
|
||||
"Session upserted: msg=%s channel=%s thread=%s",
|
||||
msg.msg_id,
|
||||
msg.channel_type,
|
||||
dispatch_result.thread_id,
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
if on_error is not None:
|
||||
on_error(exc)
|
||||
logger.exception(
|
||||
"Session record_or_update failed: msg=%s thread=%s",
|
||||
msg.msg_id,
|
||||
dispatch_result.thread_id,
|
||||
)
|
||||
return False
|
||||
|
||||
async def close(
|
||||
self,
|
||||
thread_id: str,
|
||||
reason: str | None = None,
|
||||
on_error: Callable[[Exception], None] | None = None,
|
||||
) -> SessionOpResult:
|
||||
try:
|
||||
meta = {_METADATA_CLOSED_AT: utc_isoformat()}
|
||||
if reason:
|
||||
meta[_METADATA_CLOSE_REASON] = reason
|
||||
|
||||
updated = await self._conv_repo.update_conversation(
|
||||
thread_id,
|
||||
status="closed",
|
||||
metadata=meta,
|
||||
)
|
||||
if updated is None:
|
||||
return SessionOpResult(
|
||||
success=False, thread_id=thread_id, error="not found", found=False
|
||||
)
|
||||
logger.info("Session closed: thread=%s", thread_id)
|
||||
return SessionOpResult(success=True, thread_id=thread_id)
|
||||
except Exception as exc:
|
||||
if on_error is not None:
|
||||
on_error(exc)
|
||||
logger.exception("Session close failed: thread=%s", thread_id)
|
||||
return SessionOpResult(success=False, thread_id=thread_id, error=str(exc))
|
||||
|
||||
async def reopen(
|
||||
self,
|
||||
thread_id: str,
|
||||
on_error: Callable[[Exception], None] | None = None,
|
||||
) -> SessionOpResult:
|
||||
try:
|
||||
updated = await self._conv_repo.update_conversation(
|
||||
thread_id,
|
||||
status="active",
|
||||
metadata={_METADATA_LAST_INTERACTION_AT: utc_isoformat()},
|
||||
)
|
||||
if updated is None:
|
||||
return SessionOpResult(
|
||||
success=False, thread_id=thread_id, error="not found", found=False
|
||||
)
|
||||
logger.info("Session reopened: thread=%s", thread_id)
|
||||
return SessionOpResult(success=True, thread_id=thread_id)
|
||||
except Exception as exc:
|
||||
if on_error is not None:
|
||||
on_error(exc)
|
||||
logger.exception("Session reopen failed: thread=%s", thread_id)
|
||||
return SessionOpResult(success=False, thread_id=thread_id, error=str(exc))
|
||||
|
||||
async def touch(
|
||||
self,
|
||||
msg: UnifiedMessage,
|
||||
thread_id: str,
|
||||
on_error: Callable[[Exception], None] | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
conv = await self._conv_repo.get_conversation_by_thread_id(thread_id)
|
||||
if conv is None:
|
||||
return False
|
||||
if is_terminal_status(conv.status):
|
||||
logger.debug(
|
||||
"Session touch skipped: thread=%s status=%s",
|
||||
thread_id,
|
||||
conv.status,
|
||||
)
|
||||
return False
|
||||
await self._conv_repo.update_conversation(
|
||||
thread_id,
|
||||
metadata={
|
||||
_METADATA_LAST_MSG_ID: msg.msg_id,
|
||||
_METADATA_LAST_INTERACTION_AT: utc_isoformat(),
|
||||
},
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
if on_error is not None:
|
||||
on_error(exc)
|
||||
logger.exception("Session touch failed: thread=%s", thread_id)
|
||||
return False
|
||||
|
||||
async def find_by_session_key(
|
||||
self,
|
||||
session_key: str,
|
||||
on_error: Callable[[Exception], None] | None = None,
|
||||
) -> list:
|
||||
try:
|
||||
normalized = _normalize_session_key(session_key)
|
||||
return await self._conv_repo.get_conversations_by_metadata_key(
|
||||
_METADATA_SESSION_KEY, normalized
|
||||
)
|
||||
except Exception as exc:
|
||||
if on_error is not None:
|
||||
on_error(exc)
|
||||
logger.exception("Session find_by_session_key failed: session_key=%s", session_key)
|
||||
return []
|
||||
|
||||
async def list_by_channel(
|
||||
self,
|
||||
channel_type: str,
|
||||
account_id: str | None = None,
|
||||
on_error: Callable[[Exception], None] | None = None,
|
||||
) -> list:
|
||||
try:
|
||||
return await self._conv_repo.get_conversations_by_channel(
|
||||
channel_type, account_id
|
||||
)
|
||||
except Exception as exc:
|
||||
if on_error is not None:
|
||||
on_error(exc)
|
||||
logger.exception(
|
||||
"Session list_by_channel failed: channel=%s account=%s",
|
||||
channel_type,
|
||||
account_id,
|
||||
)
|
||||
return []
|
||||
|
||||
async def _create_conversation_async(
|
||||
self,
|
||||
msg: UnifiedMessage,
|
||||
dispatch_result: DispatchResult,
|
||||
session_key: str,
|
||||
user_id: str,
|
||||
title: str | None,
|
||||
on_error: Callable[[Exception], None] | None,
|
||||
) -> None:
|
||||
try:
|
||||
metadata = _build_create_metadata(msg, session_key)
|
||||
|
||||
await self._conv_repo.upsert_conversation(
|
||||
thread_id=dispatch_result.thread_id,
|
||||
user_id=user_id,
|
||||
agent_id=str(dispatch_result.agent_config_id),
|
||||
title=title,
|
||||
metadata=metadata,
|
||||
)
|
||||
logger.info(
|
||||
"Session recorded: msg=%s channel=%s thread=%s",
|
||||
msg.msg_id,
|
||||
msg.channel_type,
|
||||
dispatch_result.thread_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
if on_error is not None:
|
||||
on_error(exc)
|
||||
logger.exception(
|
||||
"Failed to record session: msg=%s thread=%s",
|
||||
msg.msg_id,
|
||||
dispatch_result.thread_id,
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user