Files
group_fqcd_jr/app/core/config.py
T
lzf_0626 0c642133d4 修复长期记忆向量链路:投影入队 + 集合名三侧同源 + 召回按客户过滤
语义召回"恒空"的根因分四层,本提交修掉投递层与读取层(另两层——重试计数
门禁、episode 不投 rebuild 事件——已在前两个提交修复)。

R2 投递层:dispatch_profile_rebuild 只做图投影,没有任何 Milvus 投递,
  长期记忆的向量从未被写入过(实测客户 9001 在 memory_sync_outbox 里 0 行)。
  新增 _enqueue_memory_vector_projection(),在画像重建后投
  MemorySyncOutbox(target_store="milvus")。走事件而不是同步写,是为了拿
  Outbox 的重试/退避/死信,且不把 embedding 的网络等待拖进事务。
  ⚠️ payload 必须带 version(正整数):适配器 _coerce_profile_version 缺它
  直接抛 ValueError(实测踩到,事件立刻 failed)。

R4 读取层:写的集合与读的集合不是同一个 ——
  写(MilvusProfileProjection)用 "user_long_term_memory_v1",
  读(bootstrap.get_vector_memory_adapter)与删(projection_cleanup_service)
  却用 settings.milvus_collection = "jr_memory",而该集合从未被创建。
  ⇒ 召回:MilvusClient 构造不校验集合存在,适配器"构造成功"但每次 search
     抛异常被 VectorMemoryAdapter 吞成 degraded → 召回恒
     degraded_reasons=('milvus_unavailable',)、向量命中恒 0 条;
  ⇒ 清理:jr_memory 不在集合列表 → 走 vector_collection_absent 分支 →
     报清理成功但一个向量都没删,陈旧向量永久留存。
  修法:PROFILE_COLLECTION 成为唯一常量,读/删两侧直接引用它;
  并删除 Settings.milvus_collection 配置项、清掉 .env.example 的
  MILVUS_COLLECTION —— 写侧从来没读过它,一个只在契约一侧生效的配置项
  比没有配置项更危险(Settings 的 extra="ignore" 会让其他环境残留的该
  变量被安全忽略)。

顺带:
- 语义检索把客户过滤下推到 Milvus(filter="customer_id == N")。此前不带
  过滤,别家客户的命中会白占 limit 名额,稀释本客户的召回条数。
- upsert 在 sources 为空时先返回,不再无条件 load_collection ——
  "本来就没有可写内容"不该被记成投递失败(10001/10002 那两条事件即如此
  重试 5 次进死信)。

回归守卫:tests/unit/infrastructure/test_memory_vector_collection_consistency.py
  断言读侧与删侧用的都是 PROFILE_COLLECTION,且被删掉的配置项不得回归。
  这个缺陷能活下来,正是因为两侧单测全绿而接缝无人守。

验证(走生产装配、进程内调用,未重启你正在跑的 API 窗口):
  读侧集合打印 user_long_term_memory_v1(修前为 jr_memory);
  召回 degraded=False / reasons=() / sources 含 milvus,且排序随 query 语义
  变化(投资期限→horizon 0.288 > risk_level 0.201;风险偏好→risk_level 0.287);
  query="进取型" 双通道合并且 vector_score=0.9987;query=None 走 mysql 全量。
  pytest tests/unit tests/contract → 1432 passed, 2 skipped, 1 failed
  (唯一失败是同事正在改的投顾页面,与记忆链路无关)。

文档:docs/演示用/记忆召回恒空-根因与修复-2026-09-14.md(新增,四层根因+证据)、
  docs/37-记忆投影链路实现说明.md(补集合名三侧契约)。
2026-09-14 21:26:03 +08:00

149 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from functools import lru_cache
from dotenv import load_dotenv
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
# 把 `.env` 显式注入进程环境变量。pydantic-settings 只把 `.env` 读进 Settings 对象,
# **不会**写 `os.environ`;而模型密钥解析(`EnvironmentSecretResolver` 要求 secret_ref
# 形如 `env:VAR`)读的正是 `os.getenv`。两者不打通就会出现"按文档在 .env 里配好密钥、
# 运行时却读不到",且症状伪装成"没有可用的模型端点",排查方向被带偏。
# `override=False`:真实进程环境变量优先,`.env` 只作补充(生产用容器注入时不会被覆盖)。
load_dotenv(override=False)
class Settings(BaseSettings):
app_env: str = "local"
app_name: str = "jr-agent-platform"
log_level: str = "INFO"
timezone: str = "Asia/Shanghai"
cors_allowed_origins: str = "http://127.0.0.1:5173,http://localhost:5173"
jwt_issuer: str
jwt_audience: str
jwt_algorithm: str = "RS256"
# 默认指向**开发专用**密钥(tools/generate_jwt_keys.py 生成,config/jwt/ 不进版本库)。
# 生产环境必须用环境变量覆盖为生产机上单独生成的那一套,不要复用开发密钥。
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)
mysql_pool_pre_ping: bool = False
mysql_tx_isolation: str = "READ COMMITTED"
redis_url: str
redis_connect_timeout_seconds: float = Field(default=2, gt=0)
# 限流(文档 §3.5 的 429 / §3.6 的 RATE_LIMITED)。默认值刻意宽松:
# 每用户每接口每分钟 600 次(10 QPS)。取值依据是"本地验收与集成测试的量级"——
# 两个验收脚本与 tests/integration 里同一身份对同一接口最多几十次调用,
# 600 留了一个数量级余量,绝不可能误伤;同时 10 QPS 已足以拦住单个客户端
# 把底座打爆(正常业务交互远低于 1 QPS)。生产按真实容量收紧即可,无需改代码。
rate_limit_enabled: bool = True
rate_limit_window_seconds: int = Field(default=60, gt=0)
rate_limit_max_requests: int = Field(default=600, ge=1)
rate_limit_key_prefix: str = "jr:rate_limit"
message_broker_url: str = ""
message_broker_run_topic: str = "agent.run"
message_broker_outbox_topic: str = "agent.outbox"
message_broker_dlq_topic: str = "agent.dlq"
milvus_uri: str
milvus_local_uri: str = ""
milvus_token: str = ""
# 长期记忆向量集合名**刻意不做成配置项**:它是写(投影)、读(召回)、删(清理)
# 三侧共用的代码级契约,集合 schema 与向量维度(1024)也由代码定义
# (`app/infrastructure/milvus_profile_projection.py` 的 `PROFILE_COLLECTION`)。
# 这里原本有个 `milvus_collection`(`jr_memory`),写路径却从没读过它 ——
# 只有读/删两侧读,于是"写进 A、从 B 查、从 B 删",语义召回恒空、清理恒报成功却不删。
# 一个只在契约一侧生效的配置项比没有配置项更危险,故直接删除。
neo4j_uri: str
neo4j_database: str = "neo4j"
neo4j_username: str = "neo4j"
neo4j_password: str = ""
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)
worker_lease_seconds: int = Field(default=60, gt=0)
worker_retry_limit: int = Field(default=3, ge=0)
worker_poll_seconds: float = Field(default=1, gt=0)
offsite_mailbox: str = "yuan80818843@163.com"
offsite_allowed_senders: tuple[str, ...] = ("15273589815@163.com",)
offsite_risk_receiver_id: str = ""
offsite_settlement_receiver_id: str = ""
offsite_mail_return_receiver: str = "yuan80818843@163.com"
offsite_max_retry_count: int = Field(default=3, ge=0)
offsite_imap_enabled: bool = False
offsite_imap_host: str = ""
offsite_imap_port: int = Field(default=993, gt=0)
offsite_imap_username: str = ""
offsite_imap_password: str = ""
offsite_imap_use_ssl: bool = True
offsite_imap_idle_enabled: bool = False
offsite_imap_idle_timeout_seconds: float = Field(default=120, gt=0)
offsite_mail_worker_enabled: bool = False
offsite_mail_worker_batch_size: int = Field(default=20, ge=1, le=100)
offsite_worker_user_id: str = ""
offsite_notification_sending_timeout_seconds: int = Field(default=900, gt=0)
offsite_mail_storage_dir: str = "data/offsite_mail"
offsite_ocr_enabled: bool = False
offsite_ocr_timeout_seconds: float = Field(default=30, gt=0)
offsite_aliyun_ocr_endpoint: str = "https://docmind-api.cn-hangzhou.aliyuncs.com"
offsite_aliyun_region_id: str = "cn-hangzhou"
offsite_aliyun_poll_interval_seconds: float = Field(default=1, gt=0)
offsite_aliyun_access_key_id: str = ""
offsite_aliyun_access_key_secret: str = ""
offsite_deepseek_enabled: bool = False
offsite_deepseek_base_url: str = "https://api.deepseek.com"
offsite_deepseek_api_key: str = ""
offsite_deepseek_model: str = "deepseek-v4-flash"
offsite_deepseek_timeout_seconds: float = Field(default=30, gt=0)
offsite_smtp_enabled: bool = False
offsite_smtp_dry_run: bool = True
offsite_smtp_host: str = ""
offsite_smtp_port: int = Field(default=465, gt=0)
offsite_smtp_username: str = ""
offsite_smtp_password: str = ""
offsite_smtp_sender: str = ""
offsite_smtp_use_ssl: bool = True
offsite_smtp_timeout_seconds: float = Field(default=30, gt=0)
promotion_material_storage_dir: str = "data/promotion_materials"
promotion_pdf_enabled: bool = False
promotion_pdf_converter_path: str = ""
promotion_max_photo_size_bytes: int = Field(default=10 * 1024 * 1024, gt=0)
promotion_max_performance_file_size_bytes: int = Field(default=20 * 1024 * 1024, gt=0)
promotion_image_enabled: bool = False
promotion_image_base_url: str = "https://dashscope.aliyuncs.com"
promotion_image_model: str = "wan2.2-t2i-flash"
promotion_image_timeout_seconds: float = Field(default=90, gt=0)
promotion_image_poll_interval_seconds: float = Field(default=2, gt=0)
risk_scan_schedule_enabled: bool = False
risk_scan_interval_minutes: int = Field(default=5, ge=1, le=1440)
risk_scan_run_immediately: bool = False
risk_scan_retry_limit: int = Field(default=2, ge=0, le=5)
risk_scan_poll_seconds: float = Field(default=30, gt=0)
risk_alert_mail_enabled: bool = False
risk_alert_mail_dry_run: bool = True
risk_alert_mail_recipients: str = ""
# 投顾灰度默认关闭,只有显式开启并配置客户白名单后才限制客户流量。
# 管理员角色始终可进入,便于审核、发布和故障处置。
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")
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings() # type: ignore[call-arg]