merge: 合并主干 qyqy_develop(PR #7 之后)并对齐两套投影实现
共同祖先 bbf623a;主干 54 个提交、118 个文件;本线 25 个文件;9 个冲突文件。 主干这次把 **ZSY 的整条投影实现合进来了(PR #7)**,而本线此前的提交正是 移植并修正同一套代码 —— 因此冲突的本质是"同一功能两份实现并存",取舍错了会把 已修好的缺陷又带回来。逐项取舍与理由见 `docs/39-主干合并对策记录.md`。 ## 取舍(9 个冲突) 取本线: - `app/infrastructure/milvus_profile_projection.py` —— 主干是 ZSY 原版,含两处必炸点: ① `customer_id` 要求 int 而本仓所有生产者都写 `str` ⇒ 每个事件必然失败; ② 不可投影的 `memory_key` 直接 raise ⇒ 一条 `constraint:` 记忆毒死整客户整批。 本线版已放宽为「接受纯数字字符串」与「跳过并留痕」。 - `memory_sync_outbox_worker.py` / `conversation_privacy.py` / `risk_questionnaire.py` —— 代码逐行一致,仅注释与说明文字详略不同(`risk_questionnaire.py` 两边**独立做了 完全相同的修复**,都改成 re-export `app.model.profile`)。 - 两个投影测试文件 —— 本线是他那份的**超集**(4→10、4→5 例,包含他全部用例)。 两边合并: - `app/worker/runtime.py`:`__init__` 两边各加一个参数,都要。 - `app/service/agent/implementations/customer_service.py`:import 取并集; `COMPANY` 取主干的「奶龙基金责任有限公司」("奶龙"是本项目实际品牌名,主干多处出现), `HOTLINE`/`SERVICE_HOURS` **取本线的修复**(主干仍是占位符 `400-XXX-XXXX`, 本线已改为引用 `customer_service_rules` 的唯一来源 —— 这是 A1 缺陷修复, 否则同一客服给客户两个不同号码)。 - `AGENTS.md`:表数/Agent 清单取主干(90/89、7 个 Agent),本线的 `-X utf8` 与两条 outbox 易错点保留,测试基线按合并后实测重算。 ## 消费端只保留一套(本次最重要的一处) 合并后曾出现**两套消费者读同一个 `memory_sync_outbox`**:`__main__.py`(PR #7) 与 `runtime.consume_profile_projections()`(本线),而**两者的 neo4j handler 不同** —— 前者用 ZSY 的 `Neo4jProfileProjection`(按客户各建私有节点), 后者用主干 `ProfileGraphProjectionService`(共享 tag 节点、只投影已确认事实)。 同一事件被谁领到结果不定,等于"同一事实在图里有两种说法",正是**方案 A 要避免的状态**。 现只保留 runtime 那一套(带 `memory_sources` 兜底、neo4j 复用主干服务), 删除 `__main__.py` 的重复接线;装配入口职责仍在该文件(注入 `relationships` / `projection_cleaner`),Milvus 客户端由 `bootstrap` 工厂惰性构造、缺配置时显式降级。 副作用:`app/infrastructure/neo4j_profile_projection.py` 不再被生产代码引用,成为 **死代码**(本线未删,属架构师线,其单测仍在)—— 待架构师决定删或明确分工。 ## 顺带修掉的 3 个继承缺陷(主干同样存在,PR #7 后未整套复跑故未发现) 1. `tools/seed_test_rbac.py` **少建 `review_t`(9004)账号** —— 两个集成测试都依赖它 ("账号存在但无权限应返回 200 空集而非 404"、`PLACEHOLDER_ACCOUNTS`)。 同时把用户↔角色绑定从 `zip(..., strict=True)` 改为**显式配对表**:原写法隐含 "USERS 与 ROLES 一一对应",一加不绑角色的账号就 ValueError、整个种子跑不完 (commit 在最后,外部表现是"什么都没发生")。 2. `CustomerProfileCandidateService._write_profile_snapshot` **漏写 `current_customer_id`** —— 该列不是生成列而是普通可空列 + 唯一键 `uk_profile_snapshot_current`, 不写则唯一键形同虚设(多个 NULL 不冲突),且旧当前版本也没清该列、补写就会撞键。 现旧值置 None、新值显式写入(与 `ProfileGenerationService._clear_current` 一致)。 3. 集成测试前置未记录 —— 13 个登录/RBAC 用例因 401 而红,实为"测试账号不存在", 跑 `seed_test_rbac.py` + `set_user_password.py` 后转绿;已在 `AGENTS.md` 记明, 避免被误判成代码缺陷。 ## 文档 - 新增 `docs/39-主干合并对策记录.md`(逐文件取舍 + 理由 + 遗留) - `docs/37` 订正一处过时说法:曾写 `current_customer_id` 无人使用且故意不映射, 实际 `app/model/profile.py` 已映射且有人使用(详见该文档 §6.2 的订正块) - 文档编号:主干已占 29–36,本线两份文档让号至 `docs/37`、`docs/38` ## 验证(合并后实测) - `pytest tests`(全量)→ `2 failed, 1396 passed, 2 skipped` - `pytest tests/integration` → `102 passed, 1 skipped`(修上述 1、2 后从 15 failed 归零) - `mypy app` → `Success: no issues found in 245 source files` - `tools/audit_schema.py` → 89 张业务表无缺失/意外(未改动任何表结构) - `tools/check_authoritative_docs.py` → 52 份文档无编号冲突 - `tools/check_rbac_seed_consistency.py` → 通过 那 2 个失败是既有环境项(`test_offsite_document_recognition_adapter.py` 断言请求体 中文原文而 httpx 序列化成 `\uXXXX`),与本次合并无关。
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
|
||||
|
||||
|
||||
@@ -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