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:
2026-09-12 13:14:57 +08:00
112 changed files with 11514 additions and 90 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
+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()))