chore: update gitignore; feat: 新增customer_agent业务模块与api路由
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""客服 Agent 领域层:会话编排、意图处理和响应策略。"""
|
||||
|
||||
package_name = "customer_agent"
|
||||
|
||||
__all__ = ["package_name"]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Redis short-term conversation context for the customer-service Agent."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from inspect import isawaitable
|
||||
|
||||
|
||||
async def _config(config_getter, key: str, default):
|
||||
value = config_getter(key, str(default))
|
||||
if isawaitable(value):
|
||||
value = await value
|
||||
return type(default)(value)
|
||||
|
||||
|
||||
class RedisConversationContext:
|
||||
def __init__(self, redis, *, config_getter, token_counter=None):
|
||||
self.redis = redis
|
||||
self.config_getter = config_getter
|
||||
self.token_counter = token_counter or (lambda text: max(1, len(text) // 4))
|
||||
|
||||
async def append(self, session_id: str, role: str, content: str) -> None:
|
||||
key = f"session:{session_id}:messages"
|
||||
payload = json.dumps(
|
||||
{"role": role, "content": content}, ensure_ascii=False
|
||||
)
|
||||
await self.redis.rpush(key, payload)
|
||||
ttl = await _config(self.config_getter, "agent.customer.session.ttl", 1800)
|
||||
await self.redis.expire(key, ttl)
|
||||
await self._trim(key)
|
||||
|
||||
async def get(self, session_id: str) -> list[dict]:
|
||||
raw_messages = await self.redis.lrange(
|
||||
f"session:{session_id}:messages", 0, -1
|
||||
)
|
||||
return [json.loads(raw) for raw in raw_messages if raw]
|
||||
|
||||
async def _trim(self, key: str) -> None:
|
||||
limit = await _config(
|
||||
self.config_getter, "agent.customer.session_max_token", 4096
|
||||
)
|
||||
raw_messages = [raw for raw in await self.redis.lrange(key, 0, -1) if raw]
|
||||
total = 0
|
||||
keep_from = len(raw_messages)
|
||||
for index in range(len(raw_messages) - 1, -1, -1):
|
||||
message = json.loads(raw_messages[index])
|
||||
total += self.token_counter(message["content"])
|
||||
if total > limit:
|
||||
keep_from = index + 1
|
||||
break
|
||||
keep_from = index
|
||||
await self.redis.ltrim(key, keep_from, -1)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Redis-backed anonymous customer-service session primitives."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from inspect import isawaitable
|
||||
|
||||
|
||||
class SessionOwnershipError(Exception):
|
||||
def __init__(self, code: int, message: str):
|
||||
self.code = code
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
async def _config(config_getter, key: str, default):
|
||||
value = config_getter(key, str(default))
|
||||
if isawaitable(value):
|
||||
value = await value
|
||||
return type(default)(value)
|
||||
|
||||
|
||||
class AnonymousSessionService:
|
||||
def __init__(self, redis, *, config_getter, clock=time.time):
|
||||
self.redis = redis
|
||||
self.config_getter = config_getter
|
||||
self.clock = clock
|
||||
|
||||
async def create_session(self) -> str:
|
||||
session_id = uuid.uuid4().hex
|
||||
ttl = await _config(self.config_getter, "agent.customer.session.ttl", 1800)
|
||||
await self.redis.set(f"session:{session_id}", "anonymous", ex=ttl)
|
||||
# Redis lists cannot be created empty; the marker is ignored by readers.
|
||||
await self.redis.rpush(f"session:{session_id}:messages", "")
|
||||
await self.redis.expire(f"session:{session_id}:messages", ttl)
|
||||
return session_id
|
||||
|
||||
async def verify_session_ownership(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
customer_id: str | None = None,
|
||||
session_customer_id: str | None = None,
|
||||
) -> None:
|
||||
if not await self.redis.exists(f"session:{session_id}"):
|
||||
raise SessionOwnershipError(404, "会话不存在或已过期")
|
||||
if customer_id is not None and customer_id != session_customer_id:
|
||||
raise SessionOwnershipError(403, "无权访问该会话")
|
||||
|
||||
async def consume_chat_quota(self, session_id: str) -> int | None:
|
||||
window = await _config(
|
||||
self.config_getter, "agent.customer.rate_limit.window_sec", 60
|
||||
)
|
||||
maximum = await _config(
|
||||
self.config_getter, "agent.customer.rate_limit.max_requests", 20
|
||||
)
|
||||
key = f"rate:limit:anon:{session_id}:chat"
|
||||
now = self.clock()
|
||||
await self.redis.zremrangebyscore(key, 0, (now - window) * 1000)
|
||||
count = await self.redis.zcard(key)
|
||||
if count >= maximum:
|
||||
entries = self.redis.sorted_sets.get(key, [])
|
||||
oldest = min((score for score, _ in entries), default=now * 1000)
|
||||
return max(1, int((oldest / 1000 + window) - now))
|
||||
await self.redis.zadd(key, {uuid.uuid4().hex: now * 1000})
|
||||
await self.redis.expire(key, window)
|
||||
return None
|
||||
Reference in New Issue
Block a user