merge: integrate ZSY customer service and profile capabilities
This commit is contained in:
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
|
||||
jwt_private_key_path: str = "config/jwt/dev/jwt-private.pem"
|
||||
jwt_public_key_path: str = "config/jwt/dev/jwt-public.pem"
|
||||
jwt_clock_skew_seconds: int = Field(default=30, ge=0)
|
||||
visitor_token_ttl_seconds: int = Field(default=900, ge=60, le=3600)
|
||||
mysql_dsn: str
|
||||
mysql_pool_size: int = Field(default=5, ge=1)
|
||||
mysql_max_overflow: int = Field(default=10, ge=0)
|
||||
@@ -47,6 +48,7 @@ class Settings(BaseSettings):
|
||||
message_broker_outbox_topic: str = "agent.outbox"
|
||||
message_broker_dlq_topic: str = "agent.dlq"
|
||||
milvus_uri: str
|
||||
milvus_local_uri: str = ""
|
||||
milvus_token: str = ""
|
||||
milvus_collection: str = "jr_memory"
|
||||
neo4j_uri: str
|
||||
@@ -56,6 +58,8 @@ class Settings(BaseSettings):
|
||||
model_router_config_ref: str = "local"
|
||||
model_default_endpoint: str = ""
|
||||
model_fallback_endpoint: str = ""
|
||||
knowledge_embedding_endpoint_code: str = ""
|
||||
knowledge_embedding_timeout_ms: int = Field(default=15000, gt=0)
|
||||
sse_heartbeat_seconds: int = Field(default=15, gt=0)
|
||||
sse_chunk_characters: int = Field(default=256, ge=1, le=4096)
|
||||
sse_max_connection_seconds: int = Field(default=300, gt=0)
|
||||
@@ -118,6 +122,11 @@ class Settings(BaseSettings):
|
||||
advisor_rollout_enabled: bool = False
|
||||
advisor_rollout_customer_ids: str = ""
|
||||
|
||||
@property
|
||||
def resolved_milvus_uri(self) -> str:
|
||||
"""本地开发优先使用 Lite 文件;部署环境使用标准 Milvus URI。"""
|
||||
return self.milvus_local_uri or self.milvus_uri
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,12 @@ class AgentRequestMetadata(BaseModel):
|
||||
locale: str | None = None
|
||||
client_version: str | None = None
|
||||
ui_entry: str | None = None
|
||||
# 仅由受理服务写入 Outbox,客户端 API 不接收该字段。
|
||||
chitchat_streak: int = Field(default=0, ge=0, le=5)
|
||||
# 客服澄清轮次只来自服务端会话行,客户端不得提交或覆盖。
|
||||
clarification_round: int = Field(default=0, ge=0, le=2)
|
||||
# 仅供客服在当前短期会话内消解指代的已脱敏上下文,不是长期记忆或客户画像。
|
||||
session_context: tuple[str, ...] = Field(default_factory=tuple, max_length=6)
|
||||
|
||||
|
||||
class ConversationTurn(BaseModel):
|
||||
@@ -77,6 +83,9 @@ class AgentDefinition(BaseModel):
|
||||
allowed_roles: tuple[str, ...] = ()
|
||||
allowed_portals: tuple[str, ...] = ()
|
||||
supported_intents: tuple[str, ...] = ("general",)
|
||||
requires_model_intent_classification: bool = True
|
||||
# 长期/画像记忆属于客户数据能力;默认保留既有 Agent 行为,客服需显式关闭。
|
||||
recalls_customer_memory: bool = True
|
||||
|
||||
|
||||
class ResolvedAgentConfig(BaseModel):
|
||||
@@ -132,6 +141,8 @@ class CoreResult(BaseModel):
|
||||
intent: IntentResult | None = None
|
||||
source_references: tuple[SourceReference, ...] = ()
|
||||
tool_calls: tuple[ToolCallRecord, ...] = ()
|
||||
# 请求澄清时由持久化层安全递增会话轮次;达到上限后必须改为人工转接。
|
||||
clarification_required: bool = False
|
||||
transfer_required: bool = False
|
||||
transfer_reason: str | None = None
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""客服会话落库前的敏感凭据最小化处理。"""
|
||||
|
||||
import re
|
||||
|
||||
# 替换顺序从带业务语义的凭据开始,避免通用数字规则先破坏上下文。
|
||||
_SENSITIVE_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
(re.compile(r"(?i)((?:登录|交易)?密码)\s*(?:[::=]|是)\s*[^\s,。;,;]{1,64}"), r"\1[已隐藏]"),
|
||||
(re.compile(r"(?i)((?:登录|交易)?密码)\s*\d{4,32}"), r"\1[已隐藏]"),
|
||||
(re.compile(r"(?i)(验证码|短信码|校验码)\s*(?:[::=]|是)?\s*\d{4,8}"), r"\1[已隐藏]"),
|
||||
(re.compile(r"(?<!\d)\d{17}[\dXx](?!\d)"), "[证件号已隐藏]"),
|
||||
(re.compile(r"(?<!\d)(?:\d[ -]?){15,18}\d(?!\d)"), "[银行卡号已隐藏]"),
|
||||
(re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)"), "[手机号已隐藏]"),
|
||||
)
|
||||
|
||||
def sanitize_customer_service_message(message: str) -> str:
|
||||
"""保留风险关键词,移除不应进入会话、Outbox 或后续 Redis 的凭据值。"""
|
||||
sanitized = message
|
||||
for pattern, replacement in _SENSITIVE_PATTERNS:
|
||||
sanitized = pattern.sub(replacement, sanitized)
|
||||
return sanitized
|
||||
@@ -20,6 +20,8 @@ ALLOWED_COLLECTIONS = frozenset({
|
||||
"fin_product_collection",
|
||||
"fin_policy_collection",
|
||||
})
|
||||
# 兼容知识生命周期服务的历史命名;两者必须始终指向同一白名单。
|
||||
ALLOWED_KNOWLEDGE_COLLECTIONS = ALLOWED_COLLECTIONS
|
||||
|
||||
#: text-embedding-v3 输出维度。维度不符必须失败关闭。
|
||||
VECTOR_DIM = 1024
|
||||
@@ -80,6 +82,8 @@ class KnowledgeHit(BaseModel):
|
||||
collection: str
|
||||
title: str | None = None
|
||||
snippet: str
|
||||
# MySQL 权威过滤后附加的完整答案;Milvus 原始命中可不携带该字段。
|
||||
answer: str | None = None
|
||||
score: float | None = Field(default=None, ge=0, le=1)
|
||||
tags: tuple[str, ...] = ()
|
||||
version: str | None = None
|
||||
@@ -99,7 +103,7 @@ class KnowledgeHit(BaseModel):
|
||||
class KnowledgeSearchResult(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
hits: tuple[KnowledgeHit, ...]
|
||||
hits: tuple[KnowledgeHit, ...] = ()
|
||||
degraded: bool = False
|
||||
degradation_reason: str | None = None
|
||||
searched_collections: tuple[str, ...] = ()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
from uuid import uuid4
|
||||
@@ -20,6 +21,35 @@ class EmptyRevocationStore:
|
||||
return False
|
||||
|
||||
|
||||
class VisitorTokenIssuer:
|
||||
"""Issues short-lived anonymous tokens for public customer-service access."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
self._private_key = self._load_private_key()
|
||||
|
||||
def _load_private_key(self) -> str:
|
||||
path = Path(self._settings.jwt_private_key_path)
|
||||
if not path.is_absolute():
|
||||
path = Path.cwd() / path
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"JWT private key cannot be read: {path}") from exc
|
||||
|
||||
def issue(self) -> tuple[str, datetime]:
|
||||
now = datetime.now(UTC)
|
||||
expires_at = now + timedelta(seconds=self._settings.visitor_token_ttl_seconds)
|
||||
subject = str(uuid4().int % 9_000_000_000_000_000_000 + 1)
|
||||
token = jwt.encode(
|
||||
{"sub": subject, "iss": self._settings.jwt_issuer,
|
||||
"aud": self._settings.jwt_audience, "iat": now, "nbf": now,
|
||||
"exp": expires_at, "jti": str(uuid4()), "visitor": True},
|
||||
self._private_key, algorithm=self._settings.jwt_algorithm,
|
||||
)
|
||||
return token, expires_at
|
||||
|
||||
|
||||
class JwtAuthenticator:
|
||||
def __init__(self, settings: Settings, revocation_store: RevocationStore | None = None) -> None:
|
||||
self._settings = settings
|
||||
@@ -59,4 +89,10 @@ class JwtAuthenticator:
|
||||
or not subject.isdecimal() or len(subject) > 20
|
||||
or not 0 < int(subject) <= 18446744073709551615):
|
||||
raise UnauthorizedAgentError("invalid subject")
|
||||
if claims.get("visitor") is True:
|
||||
return RequestContext(
|
||||
user_id=str(subject), trace_id=str(uuid4()), roles=("visitor",),
|
||||
# 访客仅可运行 Agent 与读取已发布的公共知识,绝不含个人数据权限。
|
||||
permissions=("agent:run", "knowledge:query"), data_scope="public",
|
||||
)
|
||||
return RequestContext(user_id=str(claims["sub"]), trace_id=str(uuid4()))
|
||||
|
||||
Reference in New Issue
Block a user