merge: integrate ZSY customer service and profile capabilities

This commit is contained in:
张胜宇
2026-09-11 22:31:51 +08:00
94 changed files with 7933 additions and 77 deletions
+9
View File
@@ -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
View File
@@ -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
+20
View File
@@ -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
+5 -1
View File
@@ -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, ...] = ()
+36
View File
@@ -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()))