Commit Graph
119 Commits
Author SHA1 Message Date
lzf_0626 8d79bd9767 补齐三个成功响应缺失的 meta 信封;信封实现抽为公共(docs/05 §3.3)
"缺 meta"很容易被误判成"有":X-Trace-ID 是**响应头**(中间件加),和 body 里的
meta.trace_id 是两件事;错误响应一直有 meta(异常处理器统一加),漏的只有成功路径。

核到三个端点在把 service 的内部结构直接当响应体返回:

- GET /conversations/{session_id}/messages → 裸 {"data": [...]},meta 整个缺失,
  游标也没地方放(§3.3 要求列表的 data 为纯数组、next_cursor/has_more 进 meta)
- POST /conversation-messages/{id}/feedback → 同样没有 meta
- GET /knowledge-references/{token} → 直接返回资源对象

改动:

- 新增 app/api/views/envelope.py,把 envelope / list_envelope 抽成一份公共实现,
  风控链路改为复用它 —— 同一份契约写两遍的结果就是其中一处漏了 meta。
- ConversationService.messages 改为返回内部结构 {items, next_cursor, has_more},
  用 limit + 1 判断 has_more:只看"取满没取满"会把恰好等于 limit 的最后一页说成
  还有下一页。next_cursor 取本页最后一条的 message_id —— 游标语义是"取更旧的一页",
  天然可续,集成测试本来就是这么翻页的。
- Controller 统一套信封,data 仍是数组、字段名不变,前端不需要改。

测试:新增 tests/unit/api/test_response_envelope.py,断言 set(body) == {"data","meta"}
(多或少一个顶层字段都会红),并覆盖 has_more / next_cursor / trace_id;
另更新两处既有断言(limit 20→21、feedback 返回裸对象)。

门禁:ruff 干净 / mypy 138 文件 / 696 unit+contract / 33 integration。
2026-09-11 15:21:36 +08:00
Windows 13bb4f257d feat: add allocation backtest evidence 2026-09-11 15:08:37 +08:00
qyqy 57c4add7d8 merge: 客服Agent+RAG+画像 与 架构师最新 qyqy_develop 合并
- customer_service.py 以架构师实现为骨架(三档置信/适当性/会话记忆/话题矩阵),嫁接本人画像出口
- 知识检索契约合并两条链路:架构师 search_knowledge(KnowledgeSearchInput) + 本线
  query_knowledge 链路所需常量(ALLOWED_CONSTANTS/VECTOR_DIM/intent_for_qa_id)
- bootstrap 保留架构师 6 工具/3 Agent,补回 query_customer_profile 与 get_milvus_knowledge_writer
- model_gateway 能力映射修正 intent_classification→chat,保留空集回退兜底
- governance 免责声明限定面向客户 Agent(agent_type 由定义透传),风控结构化输出不再被追加
- 修 JWT 密钥路径(config/jwt/dev)、文档 21 号撞号→25
- 测试基线 934 passed / 1 failed(既有空集缺陷)
2026-09-11 15:06:44 +08:00
lzf_0626 575b4c2baa 客服适当性改按第十二条匹配矩阵(更正我上一轮的复核结论)
你说得对:C ≥ R 是风控的口径,客服要走政策原文的矩阵。

上一轮我复核后说"两边一致、无需改动"是错的 —— 我只对比了政策文本与
SuitabilityService,漏了第三个东西:客服自己的知识库。POL-AST-012 就是第十二条矩阵
原文,而客服为 C1-C5 补的「能买什么产品」问答答案直接取自该矩阵(docs/24 第二节)。
于是同一个客服 Agent 对同一个问题给出相反答案:

  问"C1 能买什么产品"   → 知识检索答 R1、R2 可买(矩阵口径)
  问"C1 能买这只 R2 吗" → 适当性出口答不能购买(严格 C ≥ R)

政策冲突的精确位置也不是"矩阵 vs 硬匹配",而是第十四条**第 1 款与它自己的第 2、3 款**
矛盾:第 2 款说"低于一个等级以上"才拒绝(C1→R2 只低 1 级,够不上"以上"),第 3 款禁止
C1 买 R3+、C2 买 R4+(R2 不在禁止列表)。矩阵与第 2、3 款三方一致,孤立的是第 1 款。

改动:

- suitability_service.py 新增 MATRIX_ALLOWED / MATRIX_NEEDS_DISCLOSURE,_decide 由
  "C < R 即拒绝"改为按矩阵:C1→R2、C2→R3 直接可买;C3→R4、C4→R5 走第十五条豁免档
  (reason_code=SUITABLE_WITH_DISCLOSURE,强制揭示 + 确认 + 录音);低两级及以上仍拒绝。
- 客服话术分档:越级档不再说"在您的风险承受能力范围内" —— 那句只对 C ≥ R 成立,
  用在 C1 买 R2 上会让客户以为自己的测评本来就覆盖这只产品。
- 风控侧不动,保留 C ≥ R:它要发现的是"越级成交且留痕不全",这个差异是有意保留的。

测试:新增逐格对照政策原文的 25 格矩阵用例、豁免档用例、话术分档用例(+29)。
docs/25 第七节 #1 与 docs/24 第七节同步更正,包括写明我上一轮那个结论错在哪。

门禁:ruff 干净 / mypy 137 文件 / 693 unit+contract / 33 integration。
2026-09-11 14:47:08 +08:00
wangjianlong_0626 e4c4099aaa wip: 客服Agent + RAG + 画像收尾(基于 6516ccb) 2026-09-11 14:46:40 +08:00
Windows ff71a1a724 feat: add dynamic advisor asset allocation 2026-09-11 14:44:29 +08:00
lzf_0626 ff681df143 落地第十五条豁免额度校验;登记三处业务裁定
一、第十五条豁免规则(docs/25 第七节 #2,业务裁定:实现)

政策原文:C3→R4 签署风险揭示书后**可买**,但单只 R4 持仓不超过总资产 20%;
C4→R5 同理,上限 10%。越级购买本身不是违规,超出额度才是 —— 原先扫描侧只看
"留痕是否齐全",于是"签了字但买超额度"这种明确违规没有预警;研判侧也把豁免的
前提条件(留痕齐全)当成了结论,直接判"疑似误报"。

- 扫描侧:新增 EXEMPTION_LIMITS 与 RiskRuleEngine._exemption_state,核算
  "单只持仓 / 总资产"并写进证据快照;触发条件改为
  gap > 0 and (missing_trace or 超出额度)。
- 研判侧:_assess_rw007 先判额度再判留痕。超限 → 证据支持风险;留痕齐全且在额度
  内 → 疑似误报;留痕齐全但快照缺总资产/持仓 → 继续复核。

数据前提(tools/probe_exemption_data.py,证据见 docs/evidence/exemption-data-probe.json):
库内 fin_customer_profile 仅 1 行且 total_asset = 0.00、fin_holding 0 行、无任何申购
交易 —— 这条规则当前不会被触发,与 behavior_score 同源(画像与持仓由本项目之外的
流程写入)。因此刻意不把"算不出来"当成"超限":拿 0 去算会让每一笔 C3→R4 都变成违规,
豁免规则反倒成了误报源。上游把数据写入后无需再改代码即可生效。

二、三处业务裁定(此前挂在"待裁定")

- 模型网关 chat + tools 入口:本轮不补,按基座能力缺口记录。它要贯穿
  ModelGateway → … → BaseAgent 整条链路,属公共契约变更,演示联调期影响面大于收益。
- exclude(关闭误报)是否必须先"调查中":保持现状,不加门禁。
- 政策冲突:以第十四条 C ≥ R 为准;客服侧 check_suitability 复核后确认本来就按
  C ≥ R 实现,无需改动。

三、其他

- 新增 tests/unit/service/test_risk_judgement_rw007.py(6 例)与扫描侧 4 例。
- 风控文档 03/05 同步 RW-007 的豁免额度条件与研判口径。
2026-09-11 14:24:45 +08:00
lzf_0626 661b17c244 风控全量查询封顶并在响应里标注截断(docs/25 P3 #20)
预警详情里的资金流水/持仓/登录记录、以及日报的四组预警都是"一次性取全量"。某个客户
的历史数据一旦异常膨胀,单次响应就能把内存和连接拖垮;登录记录此前更是连时间窗都
没有。

处理原则是"封顶但**不静默**":详情证据每类最多 200 条、日报每组最多 5000 条,用
`limit + 1` 多取一行判断是否真被截断(只看"取满没取满"会把恰好等于上限的正常数据
误报成截断),被截断时:

- 详情新增 `evidence_truncated: string[]`,列出被截断的证据类型;
- 日报新增 `data_truncated: bool` —— 日报的 daily_alert_count 等数字直接来自行数,
  静默截断等于给出一份看起来正常、实际少统计的日报,那比报错更难发现。

`scalars(...)` 统一用 `list(...)` 而不是 `.all()`,实现不再依赖 ScalarResult 的专有方法。

新增 tests/unit/repository/test_risk_repository_limits.py(3 例,用 literal_binds
编译 SQL,确保断言看到的是 201 而不只是"有 LIMIT"),并补日报与详情的标记用例;
风控文档 06 同步登记两个新字段与写接口的幂等要求。
2026-09-11 14:16:07 +08:00
Windows 8ffd08b4ce feat: add portfolio graph enrichment 2026-09-11 14:14:50 +08:00
lzf_0626 15866d4564 风控写接口接入平台幂等(docs/25 P3 #22)
docs/05 §5.1 把"业务写接口"列为必须携带 Idempotency-Key 的接口,风控 6 个 POST
(手工扫描、确认接收、进入调查、关闭误报、完成结案、升级处理)此前一个都没带,
重复提交会二次驱动状态机。

复用平台的 api_request_receipt 与 ApiTransactionService,但新增 execute_in:原来的
execute 自己开 SessionFactory() 和 session.begin(),而 RiskActionService._finish
会在内部 commit,套进去就成了"内层提交外层事务"。execute_in 改为在调用方传入的
session 上读写幂等记录,幂等记录因此与业务写入同处一个事务(§5.2)。

scope 用实际路径(含 alert_no)而不是路由模板:§5.1 的幂等范围是
user_id + method + normalized_path + idempotency_key,把路径参数折成模板会让同一个键
在不同预警之间互相回放 —— 那是把两次不同资源的操作当成一次。

测试:单测加幂等透传替身与"缺键即 422"用例;新增
tests/integration/test_risk_idempotency_mysql.py,覆盖同键回放不重复执行、同键不同
正文 409、缺键/非 ASCII 拒绝,以及端到端"重复 POST 只调用一次处置逻辑"。
2026-09-11 14:12:56 +08:00
lzf_0626 790518114b 风控 SSE 内容协商与鉴权时序;docs/05 补齐 413 与风控入口(docs/25 P3 #23 #24 #25)
#23:413 是上传超限的标准语义,前端文档(风控业务演示文档 17)也已按 413 做提示
映射,所以不把代码降成 422,而是在 docs/05 §3.5 状态码表补登 413 —— 契约以"补齐"
而不是"改动"的方式对齐。

#24:/api/v1/risk/daily-report/stream 此前既不校验 Accept,又把鉴权留在 async
generator 内部。后者更隐蔽:StreamingResponse 已经返回、响应头已经发出,403 只能
变成"200 + 半截流"。现在 controller 先 await service.authorize(context) 再判定
Accept,顺序与 §6.4 一致(鉴权先行,不用状态码差异做探测)。SSE 协商逻辑抽到
app/api/dependencies/negotiation.py,与 /agent-runs/{run_id}/events 共用同一口径,
避免同一种客户端在一个端点上 200、另一个端点上 406。

#25:复核后确认前半段不成立 —— §19 末尾写明业务域接口由各自业务文档登记,风控 15 条
端点已在 06-模块接口与字段映射.md 逐条登记。真问题是 §12 表里写的
/api/v1/risk-scans/**、/api/v1/risk-alerts/** 与实际实现 /api/v1/risk/** 不符,
按实际实现更新 §12 并加说明;顺带把风控文档里 /daily-report/mail 的权限从
"按主项目邮件策略执行"改为实际的 risk:report:mail。

新增 tests/unit/api/test_risk_stream_negotiation.py(7 例)。
2026-09-11 14:08:55 +08:00
lzf_0626 a572c09a5c 风控列表游标绑定用户与查询条件(docs/25 遗留 P3)
docs/05 §3.8 要求游标绑定用户、查询条件、排序字段和方向,此前实现只把 offset
用 base64 包了一层:任何登录用户拿到别人的游标都能继续翻,换个筛选条件也能继续翻
(偏移量对不上就静默返回错页)。现在游标里携带 SHA-256 指纹:

- 指纹口径 = user_id + data_scope/customer_ids + 查询条件(排除 limit/cursor)
- 刻意排除 limit:它是分页参数、不是查询条件,算进去只会让翻页时改页大小失效
- /evidence/{source} 的 source 是路径参数,单独并入指纹,否则 customers 的
  游标能直接拿去翻 products
- 指纹不符一律 InvalidCursorError -> 400 INVALID_CURSOR(docs/05 §3.6)

新增 2 个单测:换用户/换筛选/换 data_scope 失效、改 limit 仍有效、
不同证据类型游标不互通。
2026-09-11 14:05:37 +08:00
lzf_0626 b241059c2a fix(risk): 合并预警的嵌套证据在研判入口摊平,RW-003 不再一律降级
docs/25 P2。核实后比报告描述的更麻烦一点:合并后的 evidence_snapshot 只留
product_id 与 merged_alerts,**各条规则原有的证据键被塞进了嵌套结构**
(risk_scan_service._merge_same_transaction_alerts:398-404)。而各研判函数读的是
**顶层键**(snapshot.get("ratio") 之类),于是合并过的预警一律读不到证据、降级成
"缺证据无法复核" —— 合并本来是为了少几条噪音,结果把这些预警的研判全废了。

修法:在**研判入口统一摊平**,而不是让每条规则各自去认嵌套结构。

- 新增 _flatten_merged_evidence(detail):顶层已有的键优先(来自 priority_score 最高的
  主预警),再按顺序补入各子条目 evidence 里的键;merged_alerts 本身保留,
  可追溯性不受影响;非合并结构原样返回。
- 列表级(_assess_list_rule)与详情级(_assess_detail_rule)两个入口都调用它,
  所有规则(RW-003/007/012/015/018)一并受益。

新增 tests/unit/service/test_risk_judgement_merged_evidence.py(5 条):非合并结构原样返回、
嵌套键被抬到顶层、冲突时顶层优先、多子条目全部抬平,以及**报告症状的回归** ——
对比"没有 ratio"与"ratio 藏在 merged_alerts 里"两种输入,摊平后 RW-003 的研判结果
不再相同(原先两者都会降级成同一句话)。

ruff / mypy(136 文件) / 637 unit+contract 全绿。
2026-09-11 13:49:57 +08:00
lzf_0626 c1f043684c fix(risk): RW-012 研判补上"≥3 倍历史均值"复核,顺带修一处除零
docs/25 P2。核实后发现**根因不在研判侧偷懒,而在快照缺数据**:

- 扫描侧生成 RW-012 要求 verage > 0 且 amount >= average * 3(risk_scan_service.py:224);
- 研判侧 _assess_rw012 只看了年龄、金额、非常用设备,**完全没核均值** —— 于是达不到
  3 倍的交易也会被判"证据支持风险"。它自己的建议文本写着"继续核实一年期历史交易均值":
  作者知道该看,只是当时确实没有数据可看。
- 原因:扫描侧写进快照的只有 product_id / age / device_id,**没有均值**
  (对比 RW-003 在 :124 就写了 ratio)。

**两侧一起改**:

1. 扫描侧补写 verage_amount 与 
atio(用 ratio 与 RW-003 的快照风格保持一致)。
2. 研判侧据此复核:
atio 缺失 → CONTINUE_REVIEW("无法复核 3 倍门槛",
   不再顺着"非常用设备"定案);
atio < 3 → SUSPECTED_FALSE_POSITIVE;否则走原有逻辑。
3. **顺带修一处除零**:verage == 0 时 mount < 0*3 恒为 False,会一路走到
   mount / average 直接崩。改为同时挡住 verage_amount <= 0。

新增 tests/unit/service/test_risk_judgement_rw012.py(3 条):ratio 缺失时不落到风险成立、
ratio < 3 判误报并给出倍数、ratio >= 3 继续往下走。三个用例都停在登录记录判断之前,
不必构造复杂的登录证据。

ruff / mypy(136 文件) / 632 unit+contract / 29 integration 全绿。
2026-09-11 13:48:08 +08:00
lzf_0626 85a1b33eff fix(risk): RW-018 研判的两个口径缺陷——列表级不再无条件放行,详情级与扫描侧对齐
docs/25 P2。核实后发现这两条其实是**同一处的两个表现**,所以一起修。

**① 列表级无条件放行**
_assess_list_rule 收到 item 参数**却完全不看它**,对 RW-018 一律返回"可考虑放行",
连理由文本都是硬编码的"现有摘要显示交易来自有效定投工单"。于是渠道不匹配、或证据里
根本没有工单信息的预警,在列表层就被标成可放行 —— 而列表正是风控专员最先看到的一屏
(详情层另有判断,但那要等人点进去)。

现在按 item["evidence_snapshot"]["channel"] 判断。快照里确实有渠道:扫描侧写入了它
(risk_scan_service.py:308),列表行也带出了整个 snapshot(risk_repository.py:955),
所以这个校验是可行的,此前只是没做。

**② 详情级与扫描侧口径不一致**
扫描按 work_order.channel in {"定投", "自动定投"} 生成预警(risk_scan_service.py:294),
详情级却只认 "定投"。于是"自动定投"的预警会出现**"扫描认为有效、详情认为未确认"**的
自相矛盾。

抽出 DIRECT_INVESTMENT_CHANNELS = frozenset({"定投", "自动定投"}) 作为唯一口径,
列表级与详情级都改用它;改这份常量即同时影响两侧。

新增 tests/unit/service/test_risk_judgement_rw018.py(6 条):常量覆盖扫描侧全部渠道、
列表级在无渠道 / 渠道不符时**不得**放行、两个合法渠道在列表级与详情级都放行、
详情级对无关渠道不放行。

ruff / mypy(136 文件) / 629 unit+contract 全绿。
2026-09-11 13:45:51 +08:00
lzf_0626 0d3447bde4 fix(risk): 日报邮件端点补上授权校验——它此前是风控里唯一没有校验的端点
**问题**:POST /api/v1/risk/daily-report/mail 原先只取 context 做 401 判定,
**没有任何授权校验**;RiskDailyReportMailService.send 既拿不到 context、也不调用
AuthorizationService。收件人、标题、正文**全部由客户端决定** —— 一旦运维开启 SMTP
(RISK_DAILY_REPORT_MAIL_ENABLED),它就是一个未授权的邮件发送器。
默认关闭(ENABLED 默认 false + DRY_RUN 默认 true)让它至今没出事,但那不是可依赖的保护。

**改动**:
- send 改为 async 并接收 context,入口处 wait AuthorizationService.require(
  context, "risk:report:mail")。校验放在 **service 层**而不是 controller —— 本项目风控
  端点的授权一律落在 service(risk_query / risk_action / risk_scan 等都是这样),
  controller 只负责取 context;这个端点是唯一的例外,现在补齐。
- controller 相应改为 wait ...send(..., context=context)。
- 权限 
isk:report:mail 已在上一轮随另外三个一起创建并授予 risk_operator 与 admin。

**实测**:
- 9002(risk_operator) → **200** + {"status":"disabled","recipient_count":1}(默认关闭)
- 9001(customer)      → **403** AGENT_PERMISSION_DENIED「缺少操作权限」

**测试**:3 个既有用例改为 async 并传入带权限的 context;**新增**
	est_mail_service_requires_the_permission,断言无权限身份必须被拒 —— 钉住本次修复。

ruff / mypy(136 文件) / 623 unit+contract / 29 integration 全绿。
2026-09-11 13:39:50 +08:00
lzf_0626 24de6c34f7 fix(risk): 扫描健壮性——脏数据不再中断整批,调度器重启后仍会执行
docs/25 P0 的后两项,都属于"风控看起来在工作、其实没在跑"那一类。

**② 一条脏数据中断整批**
_level_value 原先直接 int(level.replace(prefix, "")):等级字段只要有一条不是
R1-R5 / C1-C5(例如写了「中风险」),就抛 ValueError 并冒到 scan() 的兜底 → **整批
rollback**,本次扫描前面已经生成的预警全部作废。数据脏属于运维问题,不该升级成
"整个风控停摆"。

改为返回 int | None;调用点跳过该条并记 warning(带上 transaction_id 与两个原始值,
便于运维直接定位)。顺带把 
eplace 换成 
emoveprefix:原先 "R2R" 会被错当成 2,
现在只去掉开头那一个前缀字符。

**③ 调度器重启后永不执行**
last_run_at 只存在内存里,重启后为 None,而 _is_due 此时返回
config.run_immediately(默认 False)⇒ 重启后 _is_due 恒为假,**调度器形同虚设,
而且没有任何告警**。

改为"从未跑过即视为 due":多跑一次的最坏后果是重复扫描,而扫描每条规则都先 _exists
查重、外层还有 MySQL 级锁;反过来"不跑"的后果可能是永远不跑。
config.run_immediately 不再承担"首次是否执行"的语义(它原本想表达"启动后别马上跑",
但那与"永远不跑"在实现上无法区分),字段保留以免破坏既有配置。

新增 tests/unit/service/test_risk_scan_robustness.py(6 条):脏数据返回 None 而不抛异常、
R2R 不被过度剥离、首次必 due(哪怕 run_immediately=False)、以及间隔前后的判定。

ruff / mypy(136 文件) / 622 unit+contract / 29 integration 全绿。
2026-09-11 13:35:58 +08:00
lzf_0626 d331c817ac fix(risk): 通知创建失败必须能被区分出来,不再静默返回 0
docs/25 P2「通知失败被吞」。核实后要把定性说得更准一点:它**有** logger.exception,
不是完全静默;问题在于 _create_notifications 失败时 
eturn 0,而"无需通知"
(没有高风险预警、或通知功能关闭)也返回 0 —— 调用方只拿到一个 notification_count,
**分不清"本来就不用通知"和"高风险通知创建失败"**。

风控里这个区别很要紧:通知没发出去等于处置链路的第一环断了,而扫描依旧报"完成"。
这与同一批缺陷里的"定时扫描重启后不再执行"是同一性质 —— 看起来在工作、其实有一环没跑。

改动:
- _create_notifications 返回 (条数, 失败原因),失败原因非空即代表出了问题。
- scan() 在有失败时于返回体里带上
otification_failure,并把 message 改成
  "规则扫描完成,但高风险通知创建失败",让上游与运维都能直接看到。
- **失败仍然不回滚预警**:预警已经生成、比通知重要,不该因为通知写失败就丢掉。

新增 tests/unit/service/test_risk_scan_notification.py(4 条),其中一条专门断言
"两种 0 必须可区分" —— 那是本次修复的全部意义。

ruff / mypy(136 文件) / 616 unit+contract 全绿。
2026-09-11 13:33:41 +08:00
lzf_0626 e017bcc9bb fix(platform): 配置告警覆盖全部受 release 约束的表,并提供生效快照能力
**问题**:上一轮加的"配置丢失告警"只比对 platform_config_item,而受 config_release
整版本替换影响的表有**三张**(按 information_schema 核对):platform_config_item /
prompt_template_version / model_routing_rule。这个盲区造成过真实后果 —— 客服闲聊提示词
挂在 release 174,active 变成 181 后 load_active_prompt 读不到,而 Agent 侧有逐字段
兜底、回落到代码默认值,于是功能看着正常、没人发现、**一行告警都没有**。

**改动**(均在 app/service/config_release_service.py):

1. 新增 RELEASE_SCOPED_TABLES:三张表 + 各自的**逻辑键**。逻辑键不含 release_id、
   不含自增 id、也**不含 version** —— 同名提示词在不同版本里可以用不同 version,
   那仍是同一份配置。清单是穷举的,并注明漏掉任何一张的后果都是静默失效。

2. 新增 effective_snapshot():读当前生效版本在**全部三张表**里的内容,每行已剥掉
   id / 
elease_id(见 NOT_PORTABLE_COLUMNS),可直接作为新版本的写入载荷。
   发布脚本应先取它、再追加本次变更,这样"漏继承"就从"每次靠人记得"变成结构上不容易漏。

3. _warn_dropped_items 改为逐张表比对,告警里带上表名。

model_routing_rule 没有 ORM 映射,用原生 SQL 处理;它当前 0 行,但纳进来才不会将来
配了又漏。

测试:新增一条专门锁住"提示词被丢掉时也要点名"(那正是这次的盲区),并把 fake session
改成按表 + release 返回行 —— 第一版 fake 不分表,查提示词表时会拿到配置项的行、
报 KeyError,虽然真实代码是按表查的,但 fake 不真实就盖不住问题。

ruff / mypy(136 文件) / 612 unit+contract 全绿。
2026-09-11 13:25:00 +08:00
Windows 281e76e4b1 feat: add advisor portfolio analysis 2026-09-11 13:20:09 +08:00
lzf_0626 4e2e42c896 test(base): 验证最后一种工具拒绝(角色不符),四种分支全部实测通过
分支 4 是最难构造的一种,两个前提缺一不可:

1. **工具的角色集合必须比 Agent 的更窄**。Agent 层的 validate_access(base.py:101)会先按
   AgentDefinition.allowed_roles 拦截,两者一致时永远进不到工具层的角色校验。所以把
   probe_alt 收窄为 ("risk_operator",),而 Agent 仍允许 admin。
2. **调用者必须有工具要求的权限**,否则会先命中权限分支。所以脚本临时给 admin 授
   probe:read,验证后撤销。

过程中又修掉一处自己写错的地方:探针的 handle 原先硬编码调用 PROBE_TOOL,导致分支 4
(需要调 probe_alt)与分支 2(需要调白名单之外的那一个)互相干扰——第一次跑出来的结果
是"工具不在当前意图白名单"。改为按消息里的 "alt" 选择要调的工具。

四种分支的实测结果,message 各自独立、指向不同处置动作:
- 白名单为空        → 该意图未配置工具白名单
- 工具不在白名单    → 工具不在当前意图白名单
- 缺少工具权限      → 缺少工具权限
- 角色不符          → 角色不能使用工具

目标的另一半也验证了:审计里是完整细节(reason = "角色 ['admin'] 与工具允许的角色
['risk_operator'] 无交集",并带 tool_name / intent / trace_id),而异常 message 只有
"角色不能使用工具"、不含角色集合。**内部配置只进审计,不进客户可见响应。**

环境复原:生效配置 9 条(与起点一致);sys_permission / sys_role_permission 中
probe:read 的行数为 0。
2026-09-11 13:12:56 +08:00
Windows 60c4a49b9b feat: migrate advisor investment goals 2026-09-11 13:11:58 +08:00
lzf_0626 1eb8946552 test(base): 端到端验证工具拒绝的分支 2 与 3,探针加第二个工具
接上一轮(分支 1 已验证)。本轮用只读探针触发另外两种拒绝:

- 分支 2「工具不在当前意图白名单」:发布白名单 ["probe_alt"] 但 Agent 调 probe_echo。
  这一步能做,正是因为给探针加了第二个工具 —— governance.resolve 取的是
  「发布白名单 ∩ 代码声明的 allowed_tools」,配置**只能缩小不能放大**,所以单个工具的
  Agent 永远构造不出"有白名单但不含该工具"的场景。这是做端到端时才撞到的结构性约束,
  platform_probe.py 里已注明。

- 分支 3「缺少工具权限」:发布白名单 ["probe_echo"],工具可用了,但 admin 角色并没有
  probe:read 这条权限,天然命中权限分支,不需要动 RBAC。

实测:两种拒绝的 stderr 分别为「工具不在当前意图白名单」与「缺少工具权限」,
运行状态均为 failed / AGENT_PERMISSION_DENIED;生效配置已恢复(终点 9 条,与起点一致)。

顺带发现分支 4 的结构性障碍(下一步处理):Agent 层的 validate_access 会先按
AgentDefinition.allowed_roles 拦截,所以要在**工具层**触发"角色不能使用工具",
必须让工具的角色集合比 Agent 的更窄 —— 探针目前两者的角色集合相同,触发不到。
2026-09-11 13:11:30 +08:00
lzf_0626 edc0c43245 test(base): 造只读探针端到端验证工具拒绝,并修掉它暴露的一个死分支
**为什么造探针**:ToolExecutor 的四种拒绝在真实链路上很难安全触发——要么改客服、风控的
生效配置,要么动 RBAC,两条路都会影响正在工作的 Agent。platform_probe 是个只读、无副作用
的探针:它只声明 probe 一个意图(所以意图分类只可能返回它)、没有发布工具白名单
(天然处于"未配置"状态)、工具只回显参数不碰业务数据。

**它立刻查出一个死分支**:探针报的是「工具不在当前意图白名单」,而不是我新加的
「该意图未配置工具白名单」。原因是 governance.resolve 会为每个 supported_intents
**预填条目**(governance.py:55-61),未配置时得到的是**空元组**——所以
intent not in configured_tools 在运行期**永远不成立**,那个分支是死代码。

单元测试没能发现它,因为我在测试里手工构造了 configured={},而真实链路不产生这个形状。
**这正是端到端测试的价值**:单元测试验证的是我设想的形状,端到端验证的是真实形状。

修法:改判"白名单为空"而非"缺键",文案改为"该意图的工具白名单为空",并注明经过 governance
装配后"完全没配"与"配了空列表"无法区分、也不假装能区分(两者运维动作相同)。新增一条按
**真实形状**({"faq": ()})构造的用例把它锁住。

实测:探针调用 → failed / AGENT_PERMISSION_DENIED,stderr 为
ForbiddenAgentError: 该意图未配置工具白名单(tool_executor.py:108)。

ruff / mypy(136 文件) / 611 unit+contract 全绿。
2026-09-11 13:10:04 +08:00
lzf_0626 38a9bc8285 fix(platform): 配置发布激活时点名"将被丢掉的配置项"
config_release 是**整版本替换**语义:激活新版本后,旧版本的配置项全部不再生效。
所以新版本只要漏了某项,它就是**无声消失**的——agent_tools 里的工具白名单一少,
相关 Agent 的工具就被 fail-closed 拒掉,而现场表现只是"客服/风控什么都答不了",
没人会想到是发布配置少了一条。

本项目已经两次靠"发布前手工继承"规避(客服与风控的发布脚本里各写了一遍继承逻辑),
说明这个风险真实且反复出现。

改为在 activate 时先比对"被取代版本的配置项"与"新版本的配置项",把将被丢掉的逐条
写进 warning 日志。**不阻断激活**——有时确实是要主动撤下某项配置,拒绝会让正常运维
做不了事;这里要的是"事后能查到是谁把它弄没的"。

新增 tests/unit/service/test_config_release_dropped_items.py(3 条):丢项时点名、
完整继承时无噪音(否则运维会习惯性忽略这条日志)、首个版本不报丢项。

ruff / mypy(135 文件) / 610 unit+contract 全绿。
2026-09-11 13:01:58 +08:00
lzf_0626 a7ac1b6a1c fix(platform): 让"工具用不了"的三种原因可区分,并消掉选端点的隐式顺序依赖
基座层面的两处缺陷,都属于"静默失败"——排查成本高,且本项目已经各踩过一次。

1. tool_executor.py 的拒绝原因原先无法区分:
   - "意图压根没发布白名单"与"白名单里没这个工具"共用一句「工具不在当前意图白名单」,
     运维不知道该去补发布配置、还是改白名单内容(客服与风控的意图码都要求三处对齐,
     两次都因此多花排查时间);
   - 权限与角色两处只说「缺少工具权限」,不说是哪一个。
   现在四种情况各有独立 message,各自指向不同的处置动作。

   同时把**审计与异常分离**:白名单内容、权限码、角色集属于内部配置,只写进审计;
   异常 message 会随 API 响应返回给调用方,保持通用、不泄漏配置。

2. model_gateway.py 的 TASK_CAPABILITY 补齐风控的几处 task_type
   (risk_agent_chat / risk_analysis / risk_script / risk_summary / daily_report_suggestion)。
   它们要的都是文本生成端点;不登记就会落到"未映射 → 返回全部 active 端点"的分支,
   而能否选对端点取决于 model_endpoint_config 的**行顺序**——实测风控能跑通,仅仅因为
   deepseek-flash(id=3) 恰好排在 qwen-embedding(id=5) 前面。这个隐式依赖现在消掉了。
   未登记的 task_type 仍退回全部端点(保持原有保守策略:让故障表现为调用失败而不是
   解析为空),但会记 warning,不再静默。

新增 tests/unit/service/test_tool_executor_denials.py(4 条),锁住"四种拒绝可区分"
与"内部细节只进审计、不进 message"。

ruff / mypy(135 文件) / 607 unit+contract 全绿。
2026-09-11 13:00:23 +08:00
Windows acb9175e31 feat: migrate advisor risk questionnaire 2026-09-11 12:57:07 +08:00
lzf_0626 8ac0b794ff fix(risk): 收敛剩余的时区口径(REST 时间参数、年龄、日报日期字段)
接 b3da1b6。上一条只改了凌晨规则与日报日界,剩下几处一并收掉:

1. risk_query_service.py:77-78:REST 的 start_time/end_time 是**裸 datetime**,
   原先原样透传去比库内 UTC 列,而 Agent 路径本来就带时区
   (risk_natural_language.py:117)——同一条筛选条件在界面与对话里会查出不同结果。
   timeutil 新增 from_local:裸值按**北京时间**解释(面向中国客户的业务系统,
   填表人的预期就是本地时间),带时区的按其自身时区处理。它与 to_utc_naive 的区别
   正在裸值上:取库里的值用后者,接客户端输入用这个。
2. risk_scan_service.py 与 risk_judgement_service.py 的 _age:一处用 UTC 日期、
   一处用服务器 date.today(),生日边界上同一客户会差一岁、65 岁阈值可能翻面。
   统一走 local_date(北京时间)。
3. risk_daily_report_service.py:122 的 report_date 与 :244 的 created_today:
   原先取 UTC 日期,北京 08:00 之前会把"今天新增的预警"算成昨天。

ruff / mypy(135 文件) / 603 unit+contract 全绿。
2026-09-11 12:55:00 +08:00
lzf_0626 b3da1b65ed fix(risk): 修正风控的时区缺陷——凌晨规则与日报日界
docs/25 P1 #6。根因是"库内存 UTC naive"这个约定在**业务判断层**没被遵守,
而展示层其实已经是对的(risk_daily_report_service.py:418 按配置时区转换)。

1. 新增 app/core/timeutil.py 作为统一换算入口:约定库内 UTC naive,提供
   local_hour / local_date / local_day_bounds(后两者用于查库时必须返回 UTC naive,
   否则区间与库内值整体错开 8 小时),带时区的入参按其自身时区解释。
2. risk_scan_service.py:248:0 <= confirmed_at.hour < 6 → local_hour(...)。
   原先 [0,6) UTC 被当成"凌晨",实际是北京时间 08:00-14:00,整条
   「凌晨时段小额操作」规则判的是上午。
3. risk_judgement_service.py:238/243:判断**与展示**都换算。展示不改的话,
   风控专员看到的时刻与直觉差 8 小时,无法与客户核对。
4. risk_daily_report_service.py:89:日界改用 local_day_bounds(按北京时间自然日,
   再折回 UTC naive)。原先按 UTC 日期切日,北京 08:00 前生成的日报统计窗口跨零点。

测试:
- 新增 tests/unit/core/test_timeutil.py(8 条),含"UTC 凌晨 0-6 点不是北京凌晨"
  这一缺陷复现,以及"日界必须返回 UTC naive"。
- 改写 test_risk_scan_service.py::test_night_small_trade_boundaries:它原本就拿
  UTC 小时构造数据(写 0 点/6 点),等于在测北京 08:00/14:00;语义一并修正为
  北京 00:00(含)与 06:00(不含)两个边界,三个边界场景保持不变。

ruff / mypy(135 文件) / 603 unit+contract 全绿。
2026-09-11 12:50:52 +08:00
Windows 291cb7b58f feat: add advisor product evidence intake 2026-09-11 12:47:01 +08:00
张胜宇 0059701509 fix: persist visitor handover tickets anonymously 2026-09-11 11:57:48 +08:00
Windows 5e8eecc288 feat: adapt advisor agent to qyqy base 2026-09-11 11:49:11 +08:00
zhangshy 870fd0d44e Merge branch 'RM2_develop' into qyqy_develop 2026-09-11 11:44:51 +08:00
zhangshy 6911dd98a7 fix: 修复证据上传审计并补充前端操作提示要求 2026-09-11 11:42:53 +08:00
zhangshy 1673816c4f Merge branch 'RM2_develop' into qyqy_develop 2026-09-11 11:12:43 +08:00
zhangshy c7c6736c54 fix: 修复日报模型装配并统一本地时间格式 2026-09-11 11:10:33 +08:00
张胜宇 9220b7b47c feat: integrate customer service agent into ZSY develop
# Conflicts:
#	app/core/config.py
#	app/main.py
#	app/service/agent/bootstrap.py
2026-09-11 10:46:56 +08:00
lzf_0626 478b64e4d7 Merge remote-tracking branch 'origin/qyqy_develop' into qyqy_develop_1
# Conflicts:
#	app/service/agent/bootstrap.py
2026-09-11 10:43:08 +08:00
张胜宇 c76b763101 feat: configure local customer service knowledge runtime 2026-09-11 10:13:48 +08:00
zhangshy c2178a985d feat: 增加风控定时扫描 Worker 与环境配置 2026-09-11 09:49:21 +08:00
lzf_0626 c369a919c6 test(customer-service): 用格式矩阵把 _topic_of 的第四、五口咬痕也堵上
方案 A:把每种出口的**真实产出**都过一遍 _topic_of。谁再改回答格式或加出口,测试立刻红,
而不是等客户实测先撞上——它已经咬了三次,三次都是客户先发现的。

写这个矩阵当场又抓出两个真 bug:

1. 整节块的主语带着章节号("2.1 南方季季盈90天")。下游 _product_risk_level 要求产品名
   是该块标题或正文的**子串**,带编号就永远匹配不上——适当性查询会静默失败、退化成转人工。
   这是真故障,不是洁癖。
2. 政策条款的主语是"第十二条 投资者与产品匹配矩阵";整节块去掉标题行后剩下的表格行
   ("| C1保守型 | ✅ 可购买 |")也会被当成主语。

修法:剥掉开头的章节号;排除以"|"开头的表格行;排除"第X条"开头的条款标题。

矩阵覆盖全部四个出口:知识直返(行级块/整节块/FAQ/政策四种形状)、适当性裁决(四种话术,
真调 _suitability_text 而非手写样例)、引导人工(兜底话术取不出主语是对的)、
闲聊(无产品实体,取不出也是对的)。
2026-09-10 22:53:29 +08:00
lzf_0626 8b1ed70916 fix(customer-service): 自述等级的说明行不能挡住产品名
实测连问「季季盈90天起投多少」→「C3 客户能买它吗」→「那我能买它吗」,第三问转人工。
原因是上一步刚加的客户自述等级说明("您提到自己是 C3。…以您在公司留存的测评结果为准")
占了回答首行,而 _topic_of 只看首行,产品名被挤到第二行,追问于是丢掉了指代对象。

这是 _topic_of 第三次因为"只认固定格式"而咬人(前两次:FAQ 的"问"字、适当性回答没有
冒号)。改成遍历回答前四行、逐行尝试解析,并把单行解析抽成 _topic_in。

同时补上自述等级时的依据说明:客户说"我是 C3"而系统答"您没有测评结果",在他眼里是
矛盾的,必须点明判断以档案为准、不以自述为准。自述等级只用于这一句说明,**绝不**参与
裁决。拒绝后的指引也从"联系客户经理或拨打热线"改成"请先完成风险测评"。

新增三条单测:自述等级识别(含小写)、自述说明行占首行时仍能取到主语、
"为 R2"开头说明前面没有产品名时返回空。
2026-09-10 22:48:56 +08:00
lzf_0626 c564494f84 fix(customer-service): 追问适当性结论时不能丢掉产品名
实测三连问:「季季盈90天起投多少」→「C1 客户能买它吗」→「那我能买它吗」,
第三问转人工。原因是 _topic_of 不认识适当性回答的格式——它那一行是
"南方季季盈90天为 R2(中低风险),与您当前的风险测评结果不匹配…",
既没有冒号也不是标题行,于是被判成"取不出主语",第三问就失去了指代对象。

补上这种格式:产品名在"为 R{1-5}"之前,且必须在冒号判断**之前**匹配(那一行没有冒号)。
以"为 R2"开头的行说明前面没有产品名,返回空串,而不是把等级本身当成主语。

修好后第三问给出与第二问一致的结论,这是对的:裁决是确定性的,同一个客户问同一只
产品,结论就该一样。新增两条单测钉住这两种情形。
2026-09-10 22:46:31 +08:00
lzf_0626 33fbb0eb01 feat(customer-service): 接入适当性裁决,回答"我这个等级能不能买它"
客户实测反馈:「c1客户能买它吗」答的是 C1 的通用规则,没回答"能不能买季季盈90天"。
根因是这个问题需要**组合两个事实**——产品的风险等级(R2)与客户档案等级能不能匹配——
而检索只能给出"最像的那段原文",给不出结论。基座早就有了 check_suitability 裁决工具,
接口留好了但客服没接线(这个项目里第三次遇到同一类情况)。

改动三处:
1. 新增 suitability_check 意图,意图码三处对齐(AgentDefinition.supported_intents、
   agent_intent_config 的 active 行、发布版 agent_tools 白名单)。
2. 新出口 _answer_suitability:产品风险等级**从知识库查出来**(不猜、也不采信问句里
   出现的"R2"字样),产品名只取上一轮回答里的主语(来自知识块字段,可信),客户等级
   交给 check_suitability 按档案解析——**不采信客户自称**。任何一步拿不到确定值就转人工:
   这个出口会给出"能不能买"的结论,宁可答不了也不能答错。
3. 发布脚本加意图注册与工具白名单,并做成幂等(重跑不会因为"已经审过了"而 409)。

实测:意图正确路由到新出口,裁决链路走通。9001 因为在 fin_risk_assessment 里没有测评
记录,系统给出"暂时无法购买 + 您目前没有在有效期内的风险测评结果"——这正是适当性管理
要求的行为,不是故障:不能卖给一个没有有效测评结果的客户。措辞也据此改过,不写
"您的等级为未记录"这种客户看不懂的句子。

顺带修了前端一处误导标记:它用"是否含客服热线"判断"已引导人工",而正常的适当性回答
里也会建议拨打客服热线,于是"已经给出结论"被误报成"已引导人工"。
2026-09-10 22:42:17 +08:00
lzf_0626 ee9de1b520 fix(customer-service): FAQ 型回答不能把"问"字当成追问的主语
给 C1-C5 补 FAQ 之后暴露的连带问题:FAQ 型回答的首行是"问:C1 客户能买什么产品?",
_topic_of 取冒号前的内容会拿到一个孤零零的"问"字,客户追问时检索词被污染成
"问 那它能买基金吗"。

改为"问"/"答"这类纯标签直接判为取不出主语,退化成只查当前这一句。

另附一条实测结论(**没有改代码**):在 FAQ 型回答之后追问「那它能买基金吗」仍会转人工,
这是安全且合理的——上一轮回答里没有可指代的产品实体,而"C1 能不能买基金"本身取决于
那只基金的风险等级,知识库没有、也不该有这种一概而论的答案。按金融场景的原则,
"答不了"好过"答错"。
2026-09-10 22:35:17 +08:00
lzf_0626 4c2b147793 feat(knowledge): 产品知识拆到表格行级,并按问句选粒度
问题(客户实测反馈):同一会话里问「季季盈90天起投多少」和「那它风险高吗」,两次回答
**一模一样**——都是整个产品小节的表格。客户问的是风险,收到的是整张说明书,看起来像
客服没听懂问题。

根因是切分粒度:原来"一个叶子标题 = 一块",产品手册里就是整个产品小节(表格 + 说明)
成一块。这既让两个不同的问题命中同一块,也让整节几百字的向量成了"整节的混合语义",
与"起投多少"这种具体小问题相似度天然偏低(实测该问句向量 top1 仅 0.6291,够不到 0.75
硬门槛,只能靠与次优的差值勉强通过)。

改动三处:
1. 切分:Markdown 表格的每一行额外生成一个**自解释**的小块("南方季季盈90天:起投金额
   1万元"),挂在父块 doc_id 下(PROD-007-04),父块照旧保留。知识块 160 → 631。
   效果:该问句的命中分从 0.6291 升到 0.869,命中的正是"起投金额"那一行。
2. 检索:命中行级子块时把它的整节父块一并带回(分数按 0.9 折算),供调用方按问句选粒度。
   整节块保底占最后一个名额,且不参与 top1/top2 判定——实测它挤到第 2 位会把 gap 从
   0.090 压到 0.076,几乎跌破 0.07 的转人工门槛。
3. 客服:命中的是行级子块时,看问句与子块标签是否真的对得上——「起投多少」对「起投金额」
   对得上,用那一行;「介绍一下」对不上,换成整节。

过程中两次判据写错并已修正(都固化进了测试):用"含连字符"认子块时,整节块自己的编号
PROD-901 被误判成子块;用"不含两位数字后缀"认整节块时,FAQ 块全被误判成整节块排到后面,
把正确答案挤出 top1、害得「基金赎回几天到账」转人工。

验证:起投/管理费等字段问法给出聚焦的单行答案;"介绍一下"给出整节;FAQ 与政策问法不受
影响(换话题、指代追问等此前修好的场景复测通过);
ruff / mypy(113 文件) / 468 unit+contract / 29 integration 全绿。
2026-09-10 22:29:23 +08:00
lzf_0626 a6c09fa3c4 fix(customer-service): 检索问句只在客户这一句说不清楚时才带上文
实测的答非所问:同一会话先问「季季盈90天的起投金额是多少」,再问「基金赎回几天到账」,
第二问答出的是季季盈的产品介绍。原因是 _search_query 无条件把上一轮客户问题拼进检索词,
客户换话题时旧话题的检索结果被带了回来。在金融场景里这比"引导转人工"糟得多:客户问 A
得到 B 的答案会直接失去对客服的信任,而"答不了"至少是诚实的。

改为只在两种真正需要上文的情况下拼接:句子里有明确指代词("这个产品""该基金"),
或短到不构成完整意图("那它风险高吗"只有 6 个字)。指代词刻意不收单字"它/他"——中文里
"其他产品"会被误判,而这类短句已经由长度规则覆盖。

验证:换话题场景第二问回到 FAQ-0016 的到账时间,指代追问仍命中季季盈产品块;
ruff / mypy(113 文件) / 457 unit+contract 全绿。
2026-09-10 22:17:34 +08:00
lzf_0626 570493e71c feat(knowledge): 知识检索增加产品名的字面兜底召回
问题:客户问「季季盈90天的起投金额是多少」会被引导到人工客服,而知识库里明明有答案。
实测根因不是阈值拍错了,而是专有名词在 embedding 空间里不占优势——该问句的向量 top1
只有 0.6291,够不到 0.75 硬门槛,只能靠与次优的差值勉强通过;而同一次查询用
title like "%季季盈%" 是唯一命中 PROD-007。既然客户已经说出了产品名,就不该再赌相似度。

做法(三条边界都是实测逼出来的,不是设想):
1. 只对产品集合做字面匹配。客户问「季季盈90天的起投金额是多少」与通用 FAQ 标题
   「基金起投金额是多少?」有 7 个字连续重合;把 FAQ 纳入字面匹配会让它和真正的产品块
   一起拿到满分、差距归零,反而又退化成"转人工"。
2. 字面命中只在向量结果不够确定时采用。客户问「基金赎回几天到账」时向量已给出正确答案
   (FAQ-0016 得 0.8060),但手册章节标题「5.2 基金赎回流程」与问句也有 4 个字连续重合,
   无条件采纳会把"操作步骤"顶掉客户真正问的"到账时间"。
3. 重叠门槛取 6 字而不是 4 字:"基金赎回"这类业务动作词正好 4 字,会骗过 4 字门槛;
   产品名("南方季季盈90天")更长,6 字能同时保住产品名、挡住动作词。

未改动任何转人工判定阈值;VECTOR_CONFIDENT_SCORE 与 Agent 的 HIGH_SCORE 由单测锁定一致,
避免两处各自漂移出"谁都答不出来"的死角。

验证:季季盈类问法由"转人工"变为正确答出,基金赎回问法仍答 FAQ-0016;
ruff / mypy(113 文件) / 453 unit+contract / 29 integration 全绿。
2026-09-10 22:15:50 +08:00
张胜宇 bc2f521c2e feat: add guarded knowledge publication tool 2026-09-10 22:08:15 +08:00
lzf_0626 dbe7285c1c feat: 短期会话记忆(多轮指代可解析)
一、此前的缺口
方案 §2.2 要求会话短期记忆,但底座**没有任何加载历史消息的代码**:conversation_message
存了全部消息、svc_conversation_session 只在计数,而 run 执行时只拿到当前这一条消息。
后果是客户问"那它风险高吗"时"它"无从对应,向量检索落到无关内容、整条回答走兜底——
多轮对话事实上不可用。

二、实现
1. 契约:AgentRequest 新增 `history: tuple[ConversationTurn, ...] = ()`(默认空元组,
   既有构造点无需改动)。ConversationTurn 只保留 role 与正文,不把意图/置信度等内部字段
   喂给模型——既减少噪声,也收窄"模型看到不该看的东西"的面。
2. 加载:WorkerRuntime._execute_claimed 构造 AgentRequest 时加载本会话此前的对话
   (上限 10 轮,按 id 正序)。`before_message_id` 排除本轮请求消息本身,否则模型会在
   上下文里看到自己的问题被重复一遍。
3. 使用:客服 Agent 构造检索查询时,把最近两轮**客户**消息与当前问题拼接。只取客户的
   话、不取 Agent 自己的回答——把后者拼进来会让检索偏向自己上一轮的说法,而客户的真实
   意图可能已经在下一句里被修正。

三、两个刻意的取舍
· **不引入 Redis 双写**:方案 §2.2 设想用 Redis 列表,但消息在受理时已落库,再同步一份
  只会带来不一致与 TTL 管理成本,换来的仅是一次索引查询的节省。这里取等价语义
  (同样"最近若干轮、超出即截断")而不复制存储。
· **按条数截断而非 token**:没有与模型一致的分词器,按 token 截断只能估算、边界会随实现
  漂移;按条数是确定性的,宁可少给几轮,也不给一个不稳定的边界。

四、实测(同一会话两轮)
· 第 1 轮"南方季季盈90天的起投金额是多少" → 正确返回该产品表格(R2、起投 1 万元等);
· 第 2 轮只说"那它风险高吗"(不含任何产品名)→ 仍正确检索到同一产品并答出风险等级 R2、
  业绩比较基准与投资范围;此前这类提问必然走兜底;
· ruff 通过、mypy 113 文件无错、unit+contract 447 passed。
2026-09-10 22:01:43 +08:00