diff --git a/.cursor/skills/codebase-to-course/references/design-system.md b/.cursor/skills/codebase-to-course/references/design-system.md index 5cb13ae..e900283 100644 --- a/.cursor/skills/codebase-to-course/references/design-system.md +++ b/.cursor/skills/codebase-to-course/references/design-system.md @@ -149,12 +149,15 @@ Complete CSS design tokens for the course. Copy this entire `:root` block into t padding: var(--space-16) var(--space-6); padding-top: calc(var(--nav-height) + var(--space-12)); } -.module-content { +.module-content, +.module-inner { max-width: var(--content-width); margin: 0 auto; } ``` +Use **`.module-inner`** in assembled module HTML (matches `build_all.py` output); **`.module-content`** remains an alias for older templates. + --- ## Shadows & Depth @@ -280,7 +283,7 @@ document.addEventListener('keydown', (e) => { **HTML template for each module:** ```html
-
+
0N

Module Title

diff --git a/.cursor/skills/codebase-to-course/references/interactive-elements.md b/.cursor/skills/codebase-to-course/references/interactive-elements.md index 62ef616..665a274 100644 --- a/.cursor/skills/codebase-to-course/references/interactive-elements.md +++ b/.cursor/skills/codebase-to-course/references/interactive-elements.md @@ -418,13 +418,15 @@ Full-system diagram where hovering/clicking a component shows a description tool Shows how different layers (e.g., HTML/CSS/JS, or data/logic/UI) build on each other. Three tabs switch between views. +**`showLayer(layerId, btn)`:** pass the clicked tab as second arg (`this`). If omitted, falls back to `window.event.currentTarget` (inline `onclick` only). Layer nodes use `id="layer-{layerId}"` inside `.layer-demo`; lookup is scoped to that demo. + **HTML:** ```html
- - - + + +
diff --git a/.cursor/skills/codebase-to-course/references/main.js b/.cursor/skills/codebase-to-course/references/main.js index 7150a69..410548e 100644 --- a/.cursor/skills/codebase-to-course/references/main.js +++ b/.cursor/skills/codebase-to-course/references/main.js @@ -486,13 +486,17 @@ /* ── LAYER TOGGLE ──────────────────────────────────────────── */ window.showLayer = function (layerId, btn) { - const demo = btn ? btn.closest('.layer-demo') : null; + const tab = btn || (window.event && window.event.currentTarget); + if (!tab || !tab.closest) return; + const demo = tab.closest('.layer-demo'); if (!demo) return; - $$('.layer', demo).forEach(l => l.style.display = 'none'); + $$('.layer', demo).forEach(l => { l.style.display = 'none'; }); $$('.layer-tab', demo).forEach(t => t.classList.remove('active')); - const layer = $('#' + layerId); + const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(layerId) : layerId.replace(/[^\w-]/g, ''); + let layer = demo.querySelector('#' + esc); + if (!layer) layer = demo.querySelector('#layer-' + esc); if (layer) layer.style.display = 'block'; - btn.classList.add('active'); + tab.classList.add('active'); }; })(); diff --git a/.cursor/skills/codebase-to-course/references/styles.css b/.cursor/skills/codebase-to-course/references/styles.css index 544669c..68d0e39 100644 --- a/.cursor/skills/codebase-to-course/references/styles.css +++ b/.cursor/skills/codebase-to-course/references/styles.css @@ -254,7 +254,8 @@ pre::-webkit-scrollbar { display: none; } padding-top: calc(var(--nav-height) + var(--space-12)); } -.module-content { +.module-content, +.module-inner { max-width: var(--content-width); margin: 0 auto; } diff --git a/AGENTS.md b/AGENTS.md index b5a1b5c..0426fa7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,6 @@ app/repository/core_ro.py # Core 只读 + check_suitability(R-02) scripts/core/reset.ps1 # 本地灌 Core 模拟库 ``` -**当前分支:** `merger` · **测试基线:** `python -m pytest` → 730 passed · **Redis:** Docker `6380` · **前端:** `cd web && npm run build/test` +**当前分支:** `merger` · **测试基线:** `python -m pytest` → 786 passed · **Redis:** Docker `6380` · **前端:** `cd web && npm run build/test` 技术选型硬阀门见 MEMORY 第 3、7 节。Cursor 以 `.cursor/rules/project-memory.mdc` 为准。 diff --git a/app/config/profile_slots.py b/app/config/profile_slots.py index 4a8eced..36435be 100644 --- a/app/config/profile_slots.py +++ b/app/config/profile_slots.py @@ -142,6 +142,16 @@ SLOTS: list[SlotSpec] = [ "description": "1-2 年内的具体生活/财务目标,短文本", "examples": ["明年准备买车", "下半年要装修", "近期想凑首付"], }, + { + "path": "investment.allocation_target", + "name": "资产配置目标", + "allowed_source": "chat", + "sensitivity": "medium", + "merge_mode": "latest", + "value_format": "text", + "description": "客户自述的目标股债/大类比例(C-08 规划偏好,非正式调仓指令)", + "examples": ["股债比例6:4", "希望债券占七成", "想多配一点稳健型"], + }, { "path": "threshold_pref_summary", "name": "阈值提醒偏好", diff --git a/app/config/settings.py b/app/config/settings.py index 17ea701..a211197 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -52,6 +52,9 @@ class Settings(BaseSettings): profile_confidence_high: float = 0.9 profile_confidence_default: float = 0.7 profile_window_max_msgs: int = 40 + profile_preference_top_k: int = 3 + profile_preference_inject_ttl_days: int = 90 + profile_preference_decay_half_life_days: int = 30 archive_idle_minutes: int = 120 archive_idle_scan_limit: int = 3 archive_summary_max_msgs: int = 30 diff --git a/app/repository/threshold_repository.py b/app/repository/threshold_repository.py new file mode 100644 index 0000000..941e9b5 --- /dev/null +++ b/app/repository/threshold_repository.py @@ -0,0 +1,89 @@ +"""客户亏损阈值配置与提醒留痕(jinrong_agent.customer_threshold_config / customer_notify_log)。""" + +from __future__ import annotations + +import json +from decimal import Decimal +from typing import Any + +from sqlalchemy import text + +from app.config.database import get_agent_engine + + +class ThresholdRepository: + def __init__(self, engine=None) -> None: + self._engine = engine or get_agent_engine() + + def list_enabled(self, customer_id: str) -> list[dict[str, Any]]: + sql = text( + """ + SELECT id, customer_id, scope_type, scope_ref, loss_threshold_pct, + notify_channel, is_enabled + FROM customer_threshold_config + WHERE customer_id = :cid AND is_enabled = 1 + """ + ) + with self._engine.connect() as conn: + return [dict(r) for r in conn.execute(sql, {"cid": customer_id}).mappings()] + + def upsert_portfolio(self, customer_id: str, loss_threshold_pct: Decimal) -> int: + """组合级阈值:同一客户仅保留一条 portfolio 配置(更新或插入)。""" + sel = text( + """ + SELECT id FROM customer_threshold_config + WHERE customer_id = :cid AND scope_type = 'portfolio' AND scope_ref IS NULL + LIMIT 1 + """ + ) + with self._engine.begin() as conn: + row = conn.execute(sel, {"cid": customer_id}).mappings().first() + if row: + upd = text( + """ + UPDATE customer_threshold_config + SET loss_threshold_pct = :pct, is_enabled = 1 + WHERE id = :id + """ + ) + conn.execute(upd, {"pct": loss_threshold_pct, "id": row["id"]}) + return int(row["id"]) + ins = text( + """ + INSERT INTO customer_threshold_config + (customer_id, scope_type, scope_ref, loss_threshold_pct, notify_channel, is_enabled) + VALUES (:cid, 'portfolio', NULL, :pct, 'app', 1) + """ + ) + result = conn.execute(ins, {"cid": customer_id, "pct": loss_threshold_pct}) + return int(result.lastrowid) + + def insert_notify_log( + self, + *, + customer_id: str, + trace_id: str, + threshold_config_id: int | None, + payload: dict, + channel: str = "app", + send_status: str = "sent", + ) -> None: + sql = text( + """ + INSERT INTO customer_notify_log + (customer_id, trace_id, notify_type, threshold_config_id, payload, channel, send_status) + VALUES (:cid, :tid, 'loss_threshold', :cfg_id, :payload, :channel, :status) + """ + ) + with self._engine.begin() as conn: + conn.execute( + sql, + { + "cid": customer_id, + "tid": trace_id or "threshold-check", + "cfg_id": threshold_config_id, + "payload": json.dumps(payload, ensure_ascii=False), + "channel": channel, + "status": send_status, + }, + ) diff --git a/app/service/customer_prompts.py b/app/service/customer_prompts.py index 95c9a76..8726ea8 100644 --- a/app/service/customer_prompts.py +++ b/app/service/customer_prompts.py @@ -19,6 +19,7 @@ INTENT_SYSTEM = """你是金融客服(已登录客户模式)意图分类器 - transaction_query:查询本人交易流水(交易记录/申购赎回记录/最近买卖/账单/流水) - risk_assessment_query:查询本人风险测评(我的风评/风险等级/测评结果/问卷得分/风险承受能力) - suitability_check:本人购买适当性匹配(我能买XX吗/R几产品适合我吗/买这个匹配吗/适当性) +- nav_query:查询具体产品最新净值(XX基金净值多少/单位净值/产品净值;系统只读库,非实时盘口) - product_consult:咨询具体产品知识(基金、理财、费用、风险等级含义、产品分类等非个人账户问题) - policy_interpret:咨询政策法规(投资者适当性、反洗钱、KYC、销售合规、投诉处理、冷静期、双录) - faq:常见问答(开户、身份认证、账户操作、App 使用等通用问题) @@ -34,10 +35,11 @@ INTENT_SYSTEM = """你是金融客服(已登录客户模式)意图分类器 4. 涉及"明天会涨/收益预测/能赚多少/走势"等走势预测一律 reject 5. 涉及"和其他平台比/哪个平台好"等竞品对比一律 reject 6. 涉及实时行情/实时价格查询一律 reject -7. "能买/可以买/适合买/匹配吗"是适当性查询(suitability_check),不是投资建议 -8. 查询本人账户/持仓/流水/风评是已登录客户的合法功能,不要输出 reject +7. "能买/可以买/适合买/匹配吗/我能买什么/哪些产品我能买"是适当性查询(suitability_check),不是投资建议 +8. 查询本人账户/持仓/流水/风评/产品净值是已登录客户的合法功能,不要输出 reject 9. 仅当用户明确要求"转人工/人工客服",或涉及投诉/纠纷/账户异常/被盗等安全问题时,才输出 transfer_human -10. 用户明确要求"你要记住/帮我记/记一下/别忘了/记着"等记忆指令时,输出 save_note""" +10. 用户明确要求"你要记住/帮我记/记一下/别忘了/记着"等记忆指令时,输出 save_note +11. "重新测评/重做风评/更新风评"→ risk_assessment_query(引导至 App/网点正式流程,Agent 不代填问卷)""" INTENT_USER_TEMPLATE = "用户输入:{message}" @@ -46,6 +48,7 @@ VALID_INTENTS = frozenset({ "transaction_query", "risk_assessment_query", "suitability_check", + "nav_query", "product_consult", "policy_interpret", "faq", @@ -62,6 +65,7 @@ DATA_QUERY_INTENTS = frozenset({ "transaction_query", "risk_assessment_query", "suitability_check", + "nav_query", }) @@ -88,7 +92,18 @@ _REJECT_COMPARE_KW = ( ) _REJECT_REALTIME_KW = ( "实时行情", "实时价格", "现在价格", "当前价格", "最新行情", - "今日净值", "实时净值", "最新净值", + "今日行情", +) + +# C-11:匹配说明类(走 suitability,不走 reject) +_ELIGIBLE_PRODUCTS_KW = ( + "我能买什么", "可以买什么", "哪些产品我能买", "适合买什么", "匹配的产品", + "有什么产品适合我", +) + +# C-05:产品净值(Core 只读库,非实时盘口) +_NAV_KW = ( + "净值", "单位净值", "最新净值", "基金净值", "产品净值", ) # 数据查询关键词(本人数据) @@ -105,6 +120,7 @@ _TRADE_KW = ( _RISK_KW = ( "我的风评", "风评", "风险测评", "风险评估", "风险等级", "风险承受能力", "测评结果", "测评问卷", "问卷得分", "我是稳健", "我是保守", "C1", "C2", "C3", "C4", "C5", + "重新测评", "重做风评", "更新风评", ) _SUITABILITY_KW = ( "能买", "可以买", "可买", "适合买", "适合我", "匹配吗", "能不能买", @@ -137,8 +153,10 @@ def keyword_route(message: str) -> tuple[str, str] | None: return ("reject", "compare") if any(k in msg for k in _REJECT_REALTIME_KW): return ("reject", "realtime") + if "实时净值" in msg: + return ("reject", "realtime") - # 数据查询类(本人数据) + # 数据查询类(本人数据,优先于 save_note) if any(k in msg for k in _TRADE_KW): return ("transaction_query", "") if any(k in msg for k in _RISK_KW): @@ -148,10 +166,15 @@ def keyword_route(message: str) -> tuple[str, str] | None: if any(k in msg for k in _HOLDING_KW): return ("holding_query", "") - # 备注(用户主动要求记忆,优先级低于数据查询) if any(k in msg for k in _SAVE_NOTE_KW): return ("save_note", "") + # C-11 / C-05:数据查询扩展 + if any(k in msg for k in _ELIGIBLE_PRODUCTS_KW): + return ("suitability_check", "") + if any(k in msg for k in _NAV_KW): + return ("nav_query", "") + return None @@ -171,7 +194,8 @@ PARAM_EXTRACT_SYSTEM = """你是金融客服的查询参数抽取器。从用户 2. months 范围 1~36;超出按边界裁剪;无法判断时间一律 null 3. product_keyword 去掉"产品/基金/理财"等无区分度尾词前后的多余称呼,保留产品名核心片段,最长 32 字 4. 用户说"我能买R3的吗"→risk_level="R3",product_keyword=null -5. 无法抽取任何参数时输出 {}""" +5. 用户问"005827净值多少"→product_keyword="005827" +6. 无法抽取任何参数时输出 {}""" PARAM_EXTRACT_USER_TEMPLATE = "用户输入:{message}" diff --git a/app/service/customer_service.py b/app/service/customer_service.py index e99058f..2f9f1f0 100644 --- a/app/service/customer_service.py +++ b/app/service/customer_service.py @@ -62,11 +62,13 @@ from app.service.profile_service import ( from app.service.rag_service import VisitorRagService from app.tool.core_ro_tool import ( query_holdings, + query_product_nav, query_risk_profile, query_suitability, query_trades, ) -from app.utils.compliance_guard import RISK_DISCLAIMER, sanitize_reply, should_add_disclaimer +from app.utils.compliance_guard import RISK_DISCLAIMER, should_add_disclaimer +from app.utils.sanitize_postprocess import finalize_sanitized_reply # --------------------------------------------------------------------------- # LLM 调用 @@ -125,8 +127,11 @@ _TOOL_BY_INTENT = { "transaction_query": query_trades, "risk_assessment_query": query_risk_profile, "suitability_check": query_suitability, + "nav_query": query_product_nav, } +_DATA_QUERY_INTENTS = frozenset(_TOOL_BY_INTENT.keys()) + _R_LEVEL_FULL_RE = re.compile(r"[Rr]\s*([1-5])") @@ -201,7 +206,7 @@ def rag_search(state: CustomerState) -> CustomerState: def param_extract(state: CustomerState) -> CustomerState: intent = state["intent"] - if intent not in ("transaction_query", "suitability_check"): + if intent not in ("transaction_query", "suitability_check", "nav_query"): return {"params": {}} try: @@ -250,6 +255,8 @@ def tool_call(state: CustomerState) -> CustomerState: product_keyword=params.get("product_keyword"), risk_level=params.get("risk_level"), ) + elif intent == "nav_query": + result = fn(cid, product_keyword=params.get("product_keyword")) else: result = fn(cid) except Exception: @@ -288,16 +295,13 @@ def interpret(state: CustomerState) -> CustomerState: except Exception: reply = fact_text # LLM 不可用时直接返回脱敏事实文本 - reply, need_transfer = sanitize_reply(reply) + reply, need_transfer = finalize_sanitized_reply( + reply, intent=state.get("intent"), fact_text=fact_text, + ) if need_transfer: return {"reply": reply, "transfer_to_human": True, "has_disclaimer": False} return {"reply": reply, "has_disclaimer": False} - -# --------------------------------------------------------------------------- -# 节点 7:RAG 生成(画像红线在 GENERATE_SYSTEM) -# --------------------------------------------------------------------------- - def generate(state: CustomerState) -> CustomerState: if not state.get("rag_context"): return {"reply": state.get("reply") or FALLBACK_TEXT, "has_disclaimer": False} @@ -319,7 +323,7 @@ def generate(state: CustomerState) -> CustomerState: except Exception: return {"reply": FALLBACK_TEXT, "intent": "fallback"} - reply, need_transfer = sanitize_reply(reply) + reply, need_transfer = finalize_sanitized_reply(reply, intent=state.get("intent")) if need_transfer: return {"reply": reply, "transfer_to_human": True, "has_disclaimer": False} @@ -351,7 +355,7 @@ def chitchat(state: CustomerState) -> CustomerState: except Exception: return {"reply": FALLBACK_TEXT, "intent": "fallback"} - reply, need_transfer = sanitize_reply(reply) + reply, need_transfer = finalize_sanitized_reply(reply, intent=state.get("intent")) if need_transfer: return {"reply": reply, "transfer_to_human": True, "has_disclaimer": False} return {"reply": reply, "has_disclaimer": False} diff --git a/app/service/profile_service.py b/app/service/profile_service.py index 1d8dba6..3e2b80d 100644 --- a/app/service/profile_service.py +++ b/app/service/profile_service.py @@ -13,6 +13,7 @@ from __future__ import annotations import json +import math import re import threading import time @@ -212,7 +213,87 @@ def _set_entry(tags: dict, path: str, entry: dict) -> None: tags[path] = entry -def render_profile_context(tags: dict) -> str: +_PREFERENCE_LIST_PATHS = frozenset({"investment.product_preferences", "investment.excluded_products"}) + + +def _touch_items_meta( + old_meta: dict | None, + additions: list[str], + confidence: float, + *, + now: datetime | None = None, +) -> dict: + now_iso = (now or datetime.now()).isoformat(timespec="seconds") + meta = dict(old_meta or {}) + for item in additions: + key = str(item) + prev = meta.get(key, {}) + meta[key] = { + "updated_at": now_iso, + "mention_count": int(prev.get("mention_count") or 0) + 1, + "confidence": round(confidence, 2), + } + return meta + + +def _days_between(iso_ts: str, now: datetime) -> float: + try: + parsed = datetime.fromisoformat(iso_ts) + except ValueError: + return 0.0 + return max(0.0, (now - parsed).total_seconds() / 86400.0) + + +def _preference_inject_score(meta: dict, *, now: datetime) -> float: + days = _days_between(str(meta.get("updated_at") or ""), now) + if days > settings.profile_preference_inject_ttl_days: + return -1.0 + conf = float(meta.get("confidence") or settings.profile_confidence_default) + mentions = int(meta.get("mention_count") or 1) + half = settings.profile_preference_decay_half_life_days + decay = 0.5 ** (days / half) if half > 0 else 1.0 + return conf * decay * (1.0 + math.log1p(max(0, mentions - 1))) + + +def _top_k_preference_values( + values: list, + items_meta: dict, + *, + now: datetime | None = None, +) -> list[str]: + now = now or datetime.now() + scored: list[tuple[float, str]] = [] + for v in values: + key = str(v) + meta = items_meta.get(key) + if not meta: + scored.append((1.0, key)) + continue + score = _preference_inject_score(meta, now=now) + if score < 0: + continue + scored.append((score, key)) + scored.sort(key=lambda x: (-x[0], x[1])) + return [v for _, v in scored[: settings.profile_preference_top_k]] + + +def _sync_threshold_config(customer_id: str, tags: dict) -> None: + """画像 threshold_pref_summary 更新时同步 customer_threshold_config(C-04)。""" + entry = _get_entry(tags, "threshold_pref_summary") + if not entry: + return + value = entry.get("value") + if not value: + return + try: + from app.service.threshold_service import sync_threshold_from_summary + + sync_threshold_from_summary(customer_id, str(value)) + except Exception: + pass + + +def render_profile_context(tags: dict, *, now: datetime | None = None) -> str: """带元数据结构 → 纯值文本(注入 generate/chitchat prompt 的客户画像参考)。""" if not tags: return "" @@ -222,7 +303,13 @@ def render_profile_context(tags: dict) -> str: if not entry: continue value = entry.get("value") - if isinstance(value, list): + if path in _PREFERENCE_LIST_PATHS and isinstance(value, list): + items_meta = entry.get("items_meta") or {} + picked = _top_k_preference_values(value, items_meta, now=now) + if not picked: + continue + value = "、".join(picked) + elif isinstance(value, list): value = "、".join(str(v) for v in value) if value in (None, ""): continue @@ -307,9 +394,18 @@ def merge_candidates(old_tags: dict, candidates: list[dict]) -> tuple[dict, list else: value = normalized + entry_payload: dict[str, Any] = { + "value": value, + "source": source, + "confidence": round(confidence, 2), + } + if slot["merge_mode"] == "set_union" and str(path) in _PREFERENCE_LIST_PATHS: + old_meta = (old_entry or {}).get("items_meta") + entry_payload["items_meta"] = _touch_items_meta(old_meta, additions, confidence) + _set_entry( new_tags, str(path), - {"value": value, "source": source, "confidence": round(confidence, 2)}, + entry_payload, ) diff.append( f"{path}: {'(空)' if old_display in (None, '', []) else old_display} → {value}" @@ -410,6 +506,7 @@ def extract_profile(customer_id: str, window_text: str, trace_id: str = "") -> l input_summary={"diff": diff, "version": version + 1}, decision="success", ) + _sync_threshold_config(customer_id, new_tags) return diff return [] diff --git a/app/service/threshold_service.py b/app/service/threshold_service.py new file mode 100644 index 0000000..265d000 --- /dev/null +++ b/app/service/threshold_service.py @@ -0,0 +1,108 @@ +"""客户亏损阈值提醒(C-04)。 + +从 L1 槽位 threshold_pref_summary 解析百分比并写入 customer_threshold_config; +持仓查询时按组合浮动盈亏与配置比对,命中则追加提醒并写 customer_notify_log。 +""" + +from __future__ import annotations + +import re +from decimal import Decimal +from typing import Any + +from app.repository.threshold_repository import ThresholdRepository + +_LOSS_PCT_RE = re.compile(r"(\d+(?:\.\d+)?)\s*%") + + +def parse_loss_threshold_pct(summary: str) -> Decimal | None: + """从「亏10%提醒我」类摘要解析正数阈值(百分点)。""" + m = _LOSS_PCT_RE.search(summary or "") + if not m: + return None + pct = Decimal(m.group(1)) + if pct <= 0 or pct > 100: + return None + return pct + + +def sync_threshold_from_summary(customer_id: str, summary: str) -> int | None: + """画像摘要 → 组合级 customer_threshold_config(upsert)。""" + pct = parse_loss_threshold_pct(summary) + if pct is None: + return None + return ThresholdRepository().upsert_portfolio(customer_id, pct) + + +def portfolio_pnl_pct(holdings: list[dict[str, Any]]) -> float | None: + """按市值加权计算组合浮动盈亏率(%)。""" + if not holdings: + return None + total_mv = sum(float(r.get("market_value") or 0) for r in holdings) + if total_mv <= 0: + return None + weighted = sum( + float(r.get("market_value") or 0) * float(r.get("pnl_pct") or 0) + for r in holdings + ) + return weighted / total_mv + + +def build_threshold_alert( + customer_id: str, + holdings: list[dict[str, Any]], + *, + trace_id: str = "", +) -> str | None: + """若组合亏损达到配置阈值,返回提醒文案并留痕;否则 None。""" + repo = ThresholdRepository() + configs = repo.list_enabled(customer_id) + portfolio_cfg = next((c for c in configs if c.get("scope_type") == "portfolio"), None) + if not portfolio_cfg: + return None + + pnl = portfolio_pnl_pct(holdings) + if pnl is None or pnl >= 0: + return None + + threshold = float(portfolio_cfg["loss_threshold_pct"]) + loss_pct = abs(pnl) + if loss_pct < threshold: + return None + + alert = ( + f"【阈值提醒】您的持仓组合浮动亏损约 {loss_pct:.2f}%," + f"已达到您设置的 {threshold:.0f}% 提醒线。" + "以上为系统只读计算,不构成投资建议;如需调整提醒条件,可告诉我新的亏损提醒比例。" + ) + repo.insert_notify_log( + customer_id=customer_id, + trace_id=trace_id, + threshold_config_id=int(portfolio_cfg["id"]), + payload={ + "portfolio_pnl_pct": round(pnl, 4), + "threshold_pct": threshold, + "holding_count": len(holdings), + }, + ) + return alert + + +def append_threshold_to_tool_result( + customer_id: str, + tool_result: dict[str, Any], + *, + trace_id: str = "", +) -> dict[str, Any]: + """持仓 tool 成功后追加阈值提醒到 fact_text。""" + if tool_result.get("tool") != "holding_query" or not tool_result.get("ok"): + return tool_result + facts = tool_result.get("facts") + if not isinstance(facts, list): + return tool_result + alert = build_threshold_alert(customer_id, facts, trace_id=trace_id) + if not alert: + return tool_result + out = dict(tool_result) + out["fact_text"] = f"{out.get('fact_text', '')}\n\n{alert}" + return out diff --git a/app/service/visitor_service.py b/app/service/visitor_service.py index 65fdda2..3909cd4 100644 --- a/app/service/visitor_service.py +++ b/app/service/visitor_service.py @@ -29,9 +29,9 @@ from app.utils.compliance_guard import ( REJECT_PREDICT, REJECT_REALTIME, RISK_DISCLAIMER, - sanitize_reply, should_add_disclaimer, ) +from app.utils.sanitize_postprocess import finalize_sanitized_reply # --------------------------------------------------------------------------- @@ -205,8 +205,8 @@ def generate(state: VisitorState) -> VisitorState: except Exception: return {"reply": FALLBACK_TEXT, "intent": "fallback"} - # 合规护栏 - reply, need_transfer = sanitize_reply(reply) + # 合规护栏(1B:命中不自动 transfer) + reply, need_transfer = finalize_sanitized_reply(reply, intent=state.get("intent")) if need_transfer: return {"reply": reply, "transfer_to_human": True, "has_disclaimer": False} @@ -236,7 +236,7 @@ def chitchat(state: VisitorState) -> VisitorState: return {"reply": FALLBACK_TEXT, "intent": "fallback"} # 合规护栏 - reply, need_transfer = sanitize_reply(reply) + reply, need_transfer = finalize_sanitized_reply(reply, intent=state.get("intent")) if need_transfer: return {"reply": reply, "transfer_to_human": True, "has_disclaimer": False} diff --git a/app/tool/core_ro_tool.py b/app/tool/core_ro_tool.py index 6c2c406..8f86eca 100644 --- a/app/tool/core_ro_tool.py +++ b/app/tool/core_ro_tool.py @@ -130,6 +130,13 @@ def query_holdings(customer_id: str, repo: CoreReadOnlyRepository | None = None) ) lines.append(f"持仓合计市值约 {_money(total_mv)} 元。") + from app.service.threshold_service import build_threshold_alert + + alert = build_threshold_alert(cid, rows) + if alert: + lines.append("") + lines.append(alert) + return ToolResult( tool="holding_query", ok=True, @@ -447,3 +454,64 @@ def query_suitability( facts=_masked(products), fact_text="\n".join(lines), ) + + +# --------------------------------------------------------------------------- +# 工具 5:产品净值查询(C-05 · Core 只读,非实时行情接口) +# --------------------------------------------------------------------------- + +def query_product_nav( + customer_id: str, + product_keyword: str | None = None, + repo: CoreReadOnlyRepository | None = None, +) -> ToolResult: + """查询在售产品最新净值(core_product_nav 种子/同步数据,非第三方实时盘口)。""" + _require_customer(customer_id) + ro = repo or CoreReadOnlyRepository() + keyword = (product_keyword or "").strip() + if not keyword: + return ToolResult( + tool="nav_query", + ok=True, + facts=[], + fact_text="请告诉我具体产品名称或代码片段,例如「005827 的净值是多少」。", + ) + + products = ro.find_products(keyword, limit=3) + if not products: + return ToolResult( + tool="nav_query", + ok=True, + facts=[], + fact_text=f"未找到名称包含「{keyword}」的在售产品,请核对产品名称后重试。", + ) + + lines = [f"以下为系统记录的最新净值(非实时交易盘口):"] + facts: list[dict[str, Any]] = [] + for p in products[:3]: + nav_row = ro.get_latest_nav(p["product_id"]) + name = p.get("product_name") or p.get("product_id") + if not nav_row: + lines.append(f"- {name}:暂未查询到净值记录。") + continue + facts.append( + { + "product_id": p.get("product_id"), + "product_name": name, + "nav": nav_row.get("nav"), + "nav_date": _date_str(nav_row.get("nav_date")), + "daily_chg_pct": nav_row.get("daily_chg_pct"), + } + ) + chg = nav_row.get("daily_chg_pct") + chg_text = f",日涨跌 {_pct(float(chg))}" if chg is not None else "" + lines.append( + f"- {name}:单位净值 {nav_row.get('nav')}(净值日期 {_date_str(nav_row.get('nav_date'))}{chg_text})" + ) + lines.append("以上净值来自代销平台只读库,仅供参考,不构成投资建议。") + return ToolResult( + tool="nav_query", + ok=True, + facts=_masked(facts), + fact_text="\n".join(lines), + ) diff --git a/app/utils/sanitize_postprocess.py b/app/utils/sanitize_postprocess.py new file mode 100644 index 0000000..46005ef --- /dev/null +++ b/app/utils/sanitize_postprocess.py @@ -0,0 +1,34 @@ +"""客服/游客编排层:违禁词扫描后的分治(1B 拍板)。 + +底层 compliance_guard.sanitize_reply 仍返回 need_transfer=True; +本模块在 customer/visitor 编排层决定是否真的转人工。 +""" + +from __future__ import annotations + +from app.utils.compliance_guard import COMPLIANCE_REJECT, sanitize_reply + +DATA_QUERY_INTENTS = frozenset({ + "holding_query", + "transaction_query", + "risk_assessment_query", + "suitability_check", + "nav_query", +}) + + +def finalize_sanitized_reply( + reply: str, + *, + intent: str | None = None, + fact_text: str | None = None, + data_query_intents: frozenset[str] | None = None, +) -> tuple[str, bool]: + """数据查询 intent 命中违禁词 → 回退 fact_text;其余 → COMPLIANCE_REJECT;均不自动 transfer。""" + allowed = data_query_intents or DATA_QUERY_INTENTS + safe, hit = sanitize_reply(reply) + if not hit: + return safe, False + if intent in allowed and fact_text: + return fact_text, False + return COMPLIANCE_REJECT, False diff --git a/docs/course/index.html b/docs/course/index.html index 9413c6a..b634b2d 100644 --- a/docs/course/index.html +++ b/docs/course/index.html @@ -129,7 +129,7 @@

JinRong 项目现状

四角色、JWT 双通道、数据从哪来、merger 分支做到哪——5 分钟建立仓库地图。

- 5 模块 · 入门首选 + 7 模块 · 入门首选
@@ -138,44 +138,44 @@

客户财富 Agent 深潜

-

客户 SSE 对话、14 节点 LangGraph、Core 只读 Tool、游客试聊与铁律边界。

- customer_service +

客户 SSE 对话、14 节点 LangGraph、口吻护栏、两套 RAG、合规 vs 一期拒答。

+ customer_service · 5 模块

代理人助手 Agent 深潜

-

理财师顾问线、名下客户查询、Neo4j/Cypher 与 chat 分流接缝。

- advisor · agent_service +

理财师顾问线、名下客户查询、G-01 归属;T-20 未做 · Neo4j 是同步脚本非 Tool。

+ advisor · agent_service · 4 模块

数据分析 Agent 深潜

-

问数 NL2SQL、dashboard 指标、平台鉴权下的 analyst 专属 REST。

- analyst · /api/analyst +

问数 NL2SQL、dashboard 指标、问数红线、口径种子排障。

+ analyst · /api/analyst · 4 模块

风控监测 Agent 深潜

-

预警台账、AML 只读 Tool、模拟交易与 risk_demo 权限口径。

- risk · X-Agent-Type: risk +

预警台账、AML 只读 Tool、模拟交易、cron/空台账演示边界。

+ risk · X-Agent-Type: risk · 4 模块

代销平台 API 深潜

-

v0.1 路由 customers/products/advisors/compliance、canonical 重复能力、脱敏开关。

- get_platform_auth_context +

v0.1 路由、适当性 canonical、空壳 API、行情 Phase B 草案。

+ get_platform_auth_context · 4 模块

前端 web 深潜

-

四角色 HashRouter、ChatPanel 鉴权差异、Vite proxy 8000 与 401 排障。

- web/ · React +

四角色 HashRouter、Chat 鉴权差异、未接线清单、会话 B 三端点。

+ web/ · React · 4 模块

共用底座与鉴权 深潜

-

JWT 双栈 deps/gateway、Redis 会话窗口、input_guard 与 audit_middleware。

- deps · memory_service +

JWT 双栈 deps/gateway、Redis 会话、审计只 INSERT、6380 fail-open。

+ deps · memory_service · 4 模块
diff --git a/docs/course/jinrong-module-advisor/_base.html b/docs/course/jinrong-module-advisor/_base.html index d9dba83..51c2d59 100644 --- a/docs/course/jinrong-module-advisor/_base.html +++ b/docs/course/jinrong-module-advisor/_base.html @@ -32,6 +32,7 @@ +
diff --git a/docs/course/jinrong-module-advisor/index.html b/docs/course/jinrong-module-advisor/index.html index 84d9520..46f1104 100644 --- a/docs/course/jinrong-module-advisor/index.html +++ b/docs/course/jinrong-module-advisor/index.html @@ -32,6 +32,7 @@ +
@@ -52,7 +53,7 @@

编排入口

-

走 agent_service.py 通用 LangGraph(tool → llm → guard),不是 customer_service 14 节点图。

+

走 顾问通用 tool→llm→guard 编排(agent_service.py)顾问通用 tool→llm→guard 编排,不是 登录客户 14 节点编排(customer_service.py)登录客户 14 节点编排。

客户范围

@@ -101,15 +102,15 @@
+ data-explanation-right="对。advisor 走 顾问通用 tool→llm→guard 编排(agent_service.py)的 tool→llm→guard;customer 才走 登录客户 14 节点编排(customer_service.py)14 节点。" + data-explanation-wrong="理财师线和客户线是 四 Agent 对话 HTTP 入口(chat.py)里两个 if 分支,编排文件不同。">

理财师打开「代理人助手」对话,编排跑在哪?

+
+
+
+

模块 4 · 未做与误区

+

T-20 还没做
Neo4j 也不是对话 Tool

+

+ 顾问线 chat 骨架已通,但草稿复核工作台、合规巡检、Neo4j 实时查询都还没进对话链路。 + 课程中心卡片写 Neo4j,容易让 AI 去改不存在的 Cypher 节点——这模块专门纠偏。 +

+ +
+

T-20 未实现清单(2026-09 快照)

+
+
+

A-03 话术草稿

+

无 advisor_draft 表流程 · 无复核 UI · prompt 里「草稿不外发」≠ 有工作台。

+
+
+

A-05 合规巡检

+

合规专员抽检全量顾问会话 — 无前端/API。

+
+
+

A-06 跟进日志

+

确认后回写 L2 — 对话线未入库跟进草稿。

+
+
+

已有

+

顾问通用 tool→llm→guard 编排(agent_service.py)tool→llm→guard · G-01 归属 · search_knowledge(kb_product_rules)。

+
+
+
+ prompt 禁外发 ≠ 系统禁外发: 代码里没有「发给客户」API,但也没有强制 review_status 门禁——上线前必须补 T-20。 +
+
+ +
+

Neo4j:同步脚本,不是 LangGraph 节点

+
+
+ 实际存在 · Neo4j 关系图离线同步(sync_neo4j.py)/ 顾问归属离线同步(sync_advisor_rel.py)离线同步脚本 +
python scripts/sync/sync_neo4j.py
+python scripts/sync/sync_advisor_rel.py
+# Neo4j 关系图离线同步(sync_neo4j.py)/ 顾问归属离线同步(sync_advisor_rel.py)离线同步脚本
+
+
+ 白话 +
+

顾问对话不在运行时发 Cypher;名下客户靠 G-01 + Core 只读 Tool(core_ro_tool.py)。

+

指挥 AI「加 Neo4j Tool 查客户关系」= 新需求,不是修现有 bug。

+

Neo4j 数据来自 Neo4j 关系图离线同步(sync_neo4j.py)/ 顾问归属离线同步(sync_advisor_rel.py)batch sync,和 Chat 会话无实时联动。

+
+
+
+
+ +
+

群聊:顾问想「一键发给客户」

+
+
+ + + +
+ +
+ + + + +
+
+ +
+
+

顾问 Chat 查「我名下有哪些客户」,代码走哪?

+
+ + + +
+
+
+ + +
+
+
diff --git a/docs/course/jinrong-module-advisor/main.js b/docs/course/jinrong-module-advisor/main.js index 7150a69..410548e 100644 --- a/docs/course/jinrong-module-advisor/main.js +++ b/docs/course/jinrong-module-advisor/main.js @@ -486,13 +486,17 @@ /* ── LAYER TOGGLE ──────────────────────────────────────────── */ window.showLayer = function (layerId, btn) { - const demo = btn ? btn.closest('.layer-demo') : null; + const tab = btn || (window.event && window.event.currentTarget); + if (!tab || !tab.closest) return; + const demo = tab.closest('.layer-demo'); if (!demo) return; - $$('.layer', demo).forEach(l => l.style.display = 'none'); + $$('.layer', demo).forEach(l => { l.style.display = 'none'; }); $$('.layer-tab', demo).forEach(t => t.classList.remove('active')); - const layer = $('#' + layerId); + const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(layerId) : layerId.replace(/[^\w-]/g, ''); + let layer = demo.querySelector('#' + esc); + if (!layer) layer = demo.querySelector('#layer-' + esc); if (layer) layer.style.display = 'block'; - btn.classList.add('active'); + tab.classList.add('active'); }; })(); diff --git a/docs/course/jinrong-module-advisor/modules/01-scenario.html b/docs/course/jinrong-module-advisor/modules/01-scenario.html index e3aee8c..e0bfdf3 100644 --- a/docs/course/jinrong-module-advisor/modules/01-scenario.html +++ b/docs/course/jinrong-module-advisor/modules/01-scenario.html @@ -13,7 +13,7 @@

编排入口

-

走 agent_service.py 通用 LangGraph(tool → llm → guard),不是 customer_service 14 节点图。

+

走 顾问通用 tool→llm→guard 编排(agent_service.py)顾问通用 tool→llm→guard 编排,不是 登录客户 14 节点编排(customer_service.py)登录客户 14 节点编排。

客户范围

@@ -62,15 +62,15 @@
+ data-explanation-right="对。advisor 走 顾问通用 tool→llm→guard 编排(agent_service.py)的 tool→llm→guard;customer 才走 登录客户 14 节点编排(customer_service.py)14 节点。" + data-explanation-wrong="理财师线和客户线是 四 Agent 对话 HTTP 入口(chat.py)里两个 if 分支,编排文件不同。">

理财师打开「代理人助手」对话,编排跑在哪?

+ + + +
+
+ +
+
+

顾问 Chat 查「我名下有哪些客户」,代码走哪?

+
+ + + +
+
+
+ + +
+
+
+ diff --git a/docs/course/jinrong-module-advisor/styles.css b/docs/course/jinrong-module-advisor/styles.css index 544669c..68d0e39 100644 --- a/docs/course/jinrong-module-advisor/styles.css +++ b/docs/course/jinrong-module-advisor/styles.css @@ -254,7 +254,8 @@ pre::-webkit-scrollbar { display: none; } padding-top: calc(var(--nav-height) + var(--space-12)); } -.module-content { +.module-content, +.module-inner { max-width: var(--content-width); margin: 0 auto; } diff --git a/docs/course/jinrong-module-analyst/_base.html b/docs/course/jinrong-module-analyst/_base.html index 7f672f8..92db22f 100644 --- a/docs/course/jinrong-module-analyst/_base.html +++ b/docs/course/jinrong-module-analyst/_base.html @@ -32,6 +32,7 @@ +
diff --git a/docs/course/jinrong-module-analyst/index.html b/docs/course/jinrong-module-analyst/index.html index bb36a72..99a71ec 100644 --- a/docs/course/jinrong-module-analyst/index.html +++ b/docs/course/jinrong-module-analyst/index.html @@ -32,6 +32,7 @@ + @@ -62,18 +63,18 @@
- 指挥 AI 改功能时: 要表格和 SQL 就改 analyst.py / analyst_agent.py; + 指挥 AI 改功能时: 要表格和 SQL 就改 问数 REST(analyst.py) / NL→SQL 编排(analyst_agent.py); 要聊天体验才碰 analytics/chat 占位页,别和问数混成一个接口。
customer self 域: 客户 token 进问数 → domain=self, - sql_guard 强制 SQL 带本人 customer_id;免责声明追加 CUSTOMER_AI_RISK_NOTE。 + SQL 安全校验(sql_guard.py)强制 SQL 带本人 customer_id;免责声明追加 CUSTOMER_AI_RISK_NOTE。

后端「四件套」路由

-

全部挂在 app/api/analyst.py,前缀 /api/analyst:

+

全部挂在 问数 REST(app/api/analyst.py),前缀 /api/analyst:

POST /chat
问数主入口 → AnalystAgent.run()
@@ -102,7 +103,7 @@ @@ -110,7 +111,7 @@
Agent
AnalystAgent -

消歧 → 生成 SQL → sql_guard → 执行 → 解读

+

消歧 → 生成 SQL → SQL 安全校验(sql_guard.py) → 执行 → 解读

+
diff --git a/docs/course/jinrong-module-analyst/main.js b/docs/course/jinrong-module-analyst/main.js index 7150a69..410548e 100644 --- a/docs/course/jinrong-module-analyst/main.js +++ b/docs/course/jinrong-module-analyst/main.js @@ -486,13 +486,17 @@ /* ── LAYER TOGGLE ──────────────────────────────────────────── */ window.showLayer = function (layerId, btn) { - const demo = btn ? btn.closest('.layer-demo') : null; + const tab = btn || (window.event && window.event.currentTarget); + if (!tab || !tab.closest) return; + const demo = tab.closest('.layer-demo'); if (!demo) return; - $$('.layer', demo).forEach(l => l.style.display = 'none'); + $$('.layer', demo).forEach(l => { l.style.display = 'none'; }); $$('.layer-tab', demo).forEach(t => t.classList.remove('active')); - const layer = $('#' + layerId); + const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(layerId) : layerId.replace(/[^\w-]/g, ''); + let layer = demo.querySelector('#' + esc); + if (!layer) layer = demo.querySelector('#layer-' + esc); if (layer) layer.style.display = 'block'; - btn.classList.add('active'); + tab.classList.add('active'); }; })(); diff --git a/docs/course/jinrong-module-analyst/modules/01-entry.html b/docs/course/jinrong-module-analyst/modules/01-entry.html index 0a06d54..c3a48ca 100644 --- a/docs/course/jinrong-module-analyst/modules/01-entry.html +++ b/docs/course/jinrong-module-analyst/modules/01-entry.html @@ -23,18 +23,18 @@
- 指挥 AI 改功能时: 要表格和 SQL 就改 analyst.py / analyst_agent.py; + 指挥 AI 改功能时: 要表格和 SQL 就改 问数 REST(analyst.py) / NL→SQL 编排(analyst_agent.py); 要聊天体验才碰 analytics/chat 占位页,别和问数混成一个接口。
customer self 域: 客户 token 进问数 → domain=self, - sql_guard 强制 SQL 带本人 customer_id;免责声明追加 CUSTOMER_AI_RISK_NOTE。 + SQL 安全校验(sql_guard.py)强制 SQL 带本人 customer_id;免责声明追加 CUSTOMER_AI_RISK_NOTE。

后端「四件套」路由

-

全部挂在 app/api/analyst.py,前缀 /api/analyst:

+

全部挂在 问数 REST(app/api/analyst.py),前缀 /api/analyst:

POST /chat
问数主入口 → AnalystAgent.run()
@@ -63,7 +63,7 @@ @@ -71,7 +71,7 @@
Agent
AnalystAgent -

消歧 → 生成 SQL → sql_guard → 执行 → 解读

+

消歧 → 生成 SQL → SQL 安全校验(sql_guard.py) → 执行 → 解读

+
+ diff --git a/docs/course/jinrong-module-analyst/styles.css b/docs/course/jinrong-module-analyst/styles.css index 544669c..68d0e39 100644 --- a/docs/course/jinrong-module-analyst/styles.css +++ b/docs/course/jinrong-module-analyst/styles.css @@ -254,7 +254,8 @@ pre::-webkit-scrollbar { display: none; } padding-top: calc(var(--nav-height) + var(--space-12)); } -.module-content { +.module-content, +.module-inner { max-width: var(--content-width); margin: 0 auto; } diff --git a/docs/course/jinrong-module-customer/_base.html b/docs/course/jinrong-module-customer/_base.html index cccf722..6b5e2cd 100644 --- a/docs/course/jinrong-module-customer/_base.html +++ b/docs/course/jinrong-module-customer/_base.html @@ -32,6 +32,10 @@ + + + + diff --git a/docs/course/jinrong-module-customer/index.html b/docs/course/jinrong-module-customer/index.html index 540d0fd..3942a31 100644 --- a/docs/course/jinrong-module-customer/index.html +++ b/docs/course/jinrong-module-customer/index.html @@ -32,6 +32,10 @@ + + + + @@ -45,7 +49,7 @@ 登录客户(如 CUST-9527)打开「客户助手」后,可走同步或 SSE 流式 对话。未登录访客另有游客试聊(只 RAG、不查持仓)。 - 2026-09 merger 分支:客户线 SSE 已接通,测试基线 774 passed。 + 2026-09 merger 分支:客户线 SSE 已接通,测试基线 **786 passed**。

@@ -56,8 +60,8 @@

POST /api/chat/stream 或 POST /api/chat,请求头 X-Agent-Type: customer + Bearer JWT。

-

持仓 / 流水 / 风评

-

意图命中后走 core_ro_tool 查 Core 模拟库;customer_id 只来自 JWT,不信用户口述客户号。

+

持仓 / 流水 / 风评 / 净值

+

意图命中后走 Core 模拟库只读查询 Tool(core_ro_tool.py);含 C-05 最新净值(非实时);C-04 查持仓时可 inline 阈值提醒。

产品规则 RAG

@@ -65,28 +69,28 @@

游客试聊

-

POST /api/chat/visitor 免登录,visitor_service 9 节点图,不写客户会话表。

+

POST /api/chat/visitor 免登录,游客试聊 9 节点编排(visitor_service.py),不写客户会话表。

- 和理财师线的区别: 客户走独立 customer_service.py(14 节点 LangGraph),不走 agent_service 那条 tool→llm→guard 通用图。 + 和理财师线的区别: 客户线走登录客户 14 节点 LangGraph 编排(customer_service.py),不走 顾问通用 tool→llm→guard 编排(agent_service.py)。

数据流:浏览器问「我持仓多少」

-

客户发消息后,chat.py 按 X-Agent-Type 分流到客户编排,再查 Core。

+

客户发消息后,四 Agent 对话 HTTP 入口(chat.py)按 X-Agent-Type 分流到客户编排,再查 Core。

🖥浏览器
-
🚪chat.py
+
🚪对话 HTTP 入口(chat.py)
🤖customer_service
🗄core_ro_tool
@@ -101,7 +105,7 @@
- 入口 · chat.py + 入口 · 四 Agent 对话 HTTP 入口(chat.py)
if agent_type == "customer":
     host_ctx = host_auth_for_customer_service(auth, trace_id=trace_id)
     customer_prep = prepare_customer_stream(
@@ -149,8 +153,8 @@
     

模块 2 · 编排内核

14 节点 LangGraph:
从回忆到归档

- 客户线不走 agent_service 那张三张牌(tool→llm→guard),而是 - customer_service.py 里一张更大的 + 客户线不走顾问通用 tool→llm→guard 编排(agent_service.py),而是 + 登录客户 14 节点 LangGraph 编排(customer_service.py)里一张更大的 LangGraph。 入口永远是 recall_memory,出口经 save_memory 写记忆后收尾。

@@ -162,7 +166,7 @@
2intent_classify · LLM 分意图
3rag_search · fin_* Milvus
4param_extract · 抽查询参数
-
5tool_call · core_ro_tool
+
5tool_call · Core 只读 Tool(core_ro_tool.py)
6interpret · 解读 fact_text
7generate · RAG 拼回复
8chitchat · 闲聊
@@ -175,6 +179,7 @@
分支规则: intent_classify 之后走 RAG(产品/政策/FAQ)、走 Tool(持仓/流水/风评)、或走静态话术(拒绝/转人工/闲聊)。所有生成分支最后都汇入 save_memory。 + 口吻护栏与转人工决策见模块 4。
@@ -278,7 +283,7 @@
- 图构建 · customer_service.py + 图构建 · 登录客户 14 节点 LangGraph 编排(customer_service.py)
g.set_entry_point("recall_memory")
 g.add_edge("recall_memory", "intent_classify")
 g.add_conditional_edges("intent_classify", _route, {...})
@@ -336,15 +341,15 @@
       

铁律 1 · 数值不经过 LLM 编造

-

持仓、流水、风评等查询结果 100% 来自 core_ro_tool 的 fact_text。LLM 在 interpret / generate 里只做解读与组织语言,不能自己算数。

+

持仓、流水、风评等查询结果 100% 来自 Core 只读查询 Tool(core_ro_tool.py)的 fact_text。LLM 在 interpret / generate 里只做解读与组织语言,不能自己算数。

铁律 2 · customer_id 只来自 JWT

-

chat.py 在进图之前已从令牌解析客户号并做归属校验。图内 state["customer_id"] 不信用户消息里的「帮我查 CUST-xxx」。

+

四 Agent 对话 HTTP 入口(chat.py)在进图之前已从令牌解析客户号并做归属校验。图内 state["customer_id"] 不信用户消息里的「帮我查 CUST-xxx」。

- 和 advisor 线的差异: 理财师走 agent_service 的 tool→llm→guard;客户走独立 14 节点图。别混用 run_tool 关键词意图那套来改客户 RAG 分支。 + 和 advisor 线的差异: 理财师走顾问通用编排(agent_service.py)的 tool→llm→guard;客户走独立 14 节点图。别混用对话 Tool 编排(tool_service.py)关键词意图那套来改客户 RAG 分支。
@@ -372,7 +377,7 @@
白话
-

入参里的 customer_id 是 chat.py 验完 JWT 后塞进来的,函数内部不再解析用户文本。

+

入参里的 customer_id 是四 Agent 对话 HTTP 入口(chat.py)验完 JWT 后塞进来的,函数内部不再解析用户文本。

先拿到完整 reply(含 intent、是否转人工、是否附免责),再切片给 SSE 层逐块写。

改「真流式」要先动 LangGraph 执行模型,不是只改前端 ChatPanel。

@@ -384,14 +389,14 @@

相关文件(改流式 / 铁律时打开这些)

app/service/
-
customer_service.py — 14 节点图 + prepare_customer_stream
-
visitor_service.py — 游客 9 节点(无 Tool 查持仓)
-
rag_service.py — fin_faq / fin_product / fin_policy
+
登录客户 14 节点 LangGraph 编排(customer_service.py)— prepare_customer_stream
+
游客试聊 9 节点 LangGraph 编排(visitor_service.py)— 无 Tool 查持仓
+
RAG 检索层(rag_service.py)— fin_faq / fin_product / fin_policy
app/api/
-
chat.py — customer 分支分流 + SSE 写帧
-
auth_adapter.py — host_auth_for_customer_service
+
四 Agent 对话 HTTP 入口(chat.py)— customer 分支分流 + SSE 写帧
+
宿主 AuthContext → 模块 AuthContext 适配(auth_adapter.py)— host_auth_for_customer_service
app/tool/
-
core_ro_tool.py — query_holdings / query_trades 等
+
客户 Agent Core 只读查询工具(core_ro_tool.py)— query_holdings / query_trades 等
@@ -418,6 +423,533 @@
+ +
+
+

模块 4 · 口吻与转人工

+

专业口吻怎么「锁住」?
什么时候该转人工?

+

+ 机制课讲了 LangGraph 怎么走,但客服线还有两条你指挥 AI 时最常碰的线: + 回复别漂成投顾/推销员,以及什么时候打「建议转人工」。 + 代码里不是单靠「写个好 prompt」,而是三层滤网 + 一条主动转人工路径(sanitize 命中不再自动转人工,拍板 1B)。 +

+ +
+

三层滤网:防止口吻漂移

+

可以把客服回复想成「过三道闸」——前面两道尽量写对,最后一道不管 LLM 说什么都要拦违规词。

+
+
+ ① +

Prompt 层 · 客户线各分支 System Prompt 模板(customer_prompts.py)
+ 每个生成分支有独立 system 模板(INTERPRET_SYSTEM / GENERATE_SYSTEM / CHITCHAT_SYSTEM),开头就定身份:「金融客服助手(已登录客户模式)」——禁止投顾话术、禁止预测、画像只调措辞不能念出。

+
+
+ ② +

记忆层 · recall_memory
+ 咨询走 consult_memory、闲聊走 chitchat_memory,再注入 profile_context。让回复有连续语境,但 prompt 里写死:不得主动复述年龄/收入等画像字段。

+
+
+ ③ +

事后扫描 · finalize_sanitized_reply()(sanitize_postprocess.py)
+ interpret / generate / chitchat 产出后过违禁词正则。命中后:数据查询类(含 nav_query)回退 Core fact_text;RAG/闲聊替换 COMPLIANCE_REJECT——均不自动 transfer_to_human。游客线已对齐。

+
+
+
+ 入口还有第 0 道闸: 四 Agent 对话 HTTP 入口(chat.py)的 input_guard 在进图之前就拦注入/超长——这类请求根本到不了 LLM。 +
+
+ +
+
+
+ 事后扫描 · 共用分治(sanitize_postprocess.py) +
def finalize_sanitized_reply(reply, *, intent, fact_text):
+    safe, hit = sanitize_reply(reply)
+    if not hit: return safe, False
+    if intent in DATA_QUERY and fact_text:
+        return fact_text, False  # 1B:保真账,不转人工
+    return COMPLIANCE_REJECT, False
+
+
+ 白话 +
+

数据查询分支:LLM 嘴瓢时回退 Core fact_text,客户仍能看到「持有 3 只产品」等真数。

+

RAG/闲聊分支:替换为固定拒答,但不打转人工标志——除非用户原话已是投诉/转人工。

+

底层 sanitize_reply 仍负责扫违禁词;分治逻辑在 customer 编排层。

+
+
+
+
+ +
+

转人工 vs 直接拒绝:别混成一条线

+

模块 2 列出了 reject 和 transfer_human 两个节点,但触发条件完全不同。指挥 AI 改逻辑时最容易搞混这里。

+ +
+
+
💬intent_classify
+
👤transfer_human
+
🚫reject
+
🔍sanitize_reply
+
+

点击「下一步」看决策分叉

+
+ + +
+
+ +
+
+

路径 A · 主动转人工

+

关键词:转人工、投诉、纠纷、账户异常、被盗 等 → TRANSFER_TEXT + transfer_to_human=True。

+
+
+

路径 B · LLM 越界被拦(1B)

+

数据查询 interpret 违禁 → 回退 fact_text,transfer=false。RAG/闲聊违禁 → COMPLIANCE_REJECT,transfer=false。

+
+
+

路径 C · 边界外但不必转人工

+

问「推荐稳赚基金」「明天会涨吗」→ reject 静态话术(如 REJECT_ADVICE_TEXT)。不自动打转人工标志;话术中可引导用户自行说「转人工」。

+
+
+
+ 演示环境边界: 「建议转人工」只是 API 字段 + 前端橙色 Tag(ChatPanel),不会真的排队接入真人坐席——真转接要接呼叫中心/工单系统,那是产品层后续工作。 +
+
+ +
+

群聊动画:LLM 嘴瓢后被扫描层拦住

+

客户问持仓,Tool 返回正常 fact_text,但 interpret 的 LLM 多嘴加了「建议您加仓」——扫描层介入。

+ +
+
+ + + + + +
+ +
+ + + + +
+
+
+ +
+

RAG 咨询还要加风险提示

+

产品/政策/FAQ 类意图在 generate 通过后,若 intent 属于咨询类,会追加 RISK_DISCLAIMER(should_add_disclaimer)。这是「口吻」的另一面:该免责时必须免责,不是只有拦违规词。

+ +
+
+

客户说「推荐一个稳赚不赔的基金」,系统会怎么标记?

+
+ + + +
+
+
+ + +
+ +
+ 指挥 AI 改客服时的检查清单: + ① 改口吻 → 动客户线各分支 System Prompt 模板(customer_prompts.py)对应 SYSTEM,别只改前端; + ② 加新禁语 → 同时更新 FORBIDDEN_TERMS 和意图关键词路由; + ③ 加「必须转人工」场景 → 改 _TRANSFER_KEYWORDS 或 INTENT_SYSTEM 规则,别误放进 reject。 +
+ + +
+
+
+
+
+

模块 5 · 边界与接缝

+

两套知识库、合规口径
和一期代码差在哪?

+

+ 客服线「能答什么」不只看 LangGraph,还看知识库分家和合规文档 vs 一期实现。 + 指挥 AI 改 RAG 或「放开推荐」前,先对齐下面这张差分表。 +

+ +
+

两套 RAG:别灌错库

+
+
+

客服 · fin_* 三库

+

登录客户的对话编排(customer_service.py)和游客试聊编排(visitor_service.py)遇到产品/政策/FAQ 类问题时,都会交给 RAG 检索层(rag_service.py:把问题向量化后查 Milvus),从 fin_product · fin_policy · fin_faq 三个知识集合取片段。

+
+
+

顾问 · kb_product_rules

+

理财师/风控通用 tool→llm→guard 编排(agent_service.py)的 search_knowledge Tool → 知识库对话 Tool 注册与分发(kb_tools.py)· 集合名不同,不能把顾问文档灌进 fin_*。

+
+
+

风控 · 不开放 search_knowledge

+

知识库对话 Tool 注册与分发(kb_tools.py)注册表对 risk 关闭——风控对话走 RISK_TOOL_REGISTRY 四只读 Tool。

+
+
+
+ 指挥 AI 误区: 「统一成一个知识库」听起来省事,但会破坏各 Agent 合规过滤与检索策略——要改先对契约和 scripts/kb/ 脚本。 +
+
+ +
+

合规允许 vs 一期拒答(你拍板要讲的)

+

合规文档里,客户 Agent 在过 R-02 + 附免责 + 标注需持证审核时,可以做「匹配说明」——但一期代码对主动推荐/预测类问题走 reject 静态拒答,更保守。

+ +
+
+ 合规文档 · 允许方向 +
适当性匹配说明(C-11)
+→ 系统判定 + 规则解读 + 免责声明
+→ 禁止具体操作指令
+
+
+ 一期代码 · 实际行为 +
+

suitability_check Tool:「我能买 R3 吗」「有哪些匹配产品」——允许。

+

「推荐稳赚基金」「买什么好」→ reject + REJECT_ADVICE_TEXT——直接拒。

+

要「放开推荐」= 产品决策 + 改 intent 路由 + 合规评审,不是只改 prompt 一句。

+
+
+
+
+ +
+

群聊:同一客户,两种问法

+
+
+ + + + +
+ +
+ + + + +
+
+
+ +
+

游客与画像:客户看不见 L2/L3

+

游客走游客试聊 9 节点编排(visitor_service.py):无 JWT、无 Core 持仓查询、不写登录客户会话表。L1 画像由客服线后台抽槽写入;L2/L3 对客户不可见——合规上客户只能 enrich L1,不能替代正式风评。

+ +
+
+

AI 建议「把 kb 和 fin 合成一个 Milvus 集合简化维护」,你怎么回?

+
+ + + +
+
+
+ + +
+
+
+
+
+
+

模块 6 · L0 与 L1 画像

+

正式风评 vs 抽槽画像
Top-K 怎么注入 prompt?

+

+ 客户线除了查 Core 真账,还有后台画像抽槽(L1)调措辞。 + 拍板铁律:L0 永远优先——正式 C1~C5、持仓数字、适当性判定听 Core,L1 不能覆盖。 + 偏好类槽位注入 prompt 时走 Top-K + 时间衰减(R1),不是全量平铺。 +

+ +
+

L0 vs L1:谁说了算?

+
+
+

L0 · Core 正式数据

+

风评 C1~C5、持仓/流水数字、适当性矩阵判定——100% 来自 Core 只读查询 Tool(core_ro_tool.py)的 fact_text。LLM 只解读,不能改写。

+
+
+

L1 · 对话 enrich 画像

+

登录客户每 5 轮节流,后台线程跑画像抽槽与合并(profile_service.py)→ 写入 MySQL style_tags + Redis 热缓存。只影响 generate/chitchat 的措辞语境,不能替代正式风评。

+
+
+

撞车规则

+

抽槽若与 L0 字段冲突(例如用户口述「我是 R5」但 Core 是 R3)→ 永远听 L0。合并规则 D7:用户显式 user_declared 不被 inferred 覆盖。

+
+
+
+ +
+

13 槽位 + confidence 门槛

+

槽位表唯一来源:画像槽位定义(profile_slots.py)——13 个 path(含 C-08 investment.allocation_target),含 merge_mode 与 sensitivity。

+
+
+ ① +

归一 · normalize_value
+ 例如「我喜欢货币基金」→ 映射到 9 类 PRODUCT_TYPE_VOCAB(不是具体 SKU 代码)。

+
+
+ ② +

阈值 · confidence ≥0.7(默认)/ ≥0.9(高敏)才写入;高敏 inferred 一律丢弃。

+
+
+ ③ +

合并 · merge_candidates
+ 单值 latest 覆盖;偏好/排除类 set_union 并集,并写 items_meta(R1)。

+
+
+
+ 谁写 L1: 仅登录客户线(customer_service.profile_maybe_extract);游客试聊不写画像。
+ C-04 阈值槽: threshold_pref_summary → customer_threshold_config;达线提醒仅在查持仓时 inline。 +
+
+ +
+

R1 · 偏好 Top-K 注入

+
+
+ 注入前筛选 · 画像上下文渲染(profile_service.py) +
# settings 默认:top_k=3 · ttl=90d · half_life=30d
+score = confidence × decay × (1 + log(mention_count))
+# 超 TTL 的偏好不进 prompt;取得分 Top-K 条
+
+
+ 白话 +
+

客户说过很多偏好类型,但 prompt 里只带最近、反复提到的前几条,避免画像越积越长带偏 LLM。

+

很久没提过的偏好(超过 90 天)不再注入,但 MySQL 里仍保留,下次提到会刷新。

+

改 K 或 TTL → 动环境配置(settings.py 的 profile_preference_*),别在 prompt 里硬编码。

+
+
+
+ +
+
+

客户口述「我是激进型 R5」,但 Core 风评是 R3,持仓查询应信谁?

+
+ + + +
+
+
+ + +
+ + +
+
+
+
+
+

模块 7 · Wave3 数据查询扩展

+

净值、阈值、匹配说明
关键词怎么分流?

+

+ 2026-09-10 批次在持仓/流水/风评/适当性之外,又接了净值查询(C-05)、亏损阈值(C-04)和「我能买什么」(C-11)。 + 指挥 AI 改意图时,先看关键词路由顺序——数据查询类必须优先于「帮我记住」类 save_note。 +

+ +
+

新增 intent 一览

+
+
+

C-05 · nav_query

+

「最新净值 / 单位净值 / 基金净值」→ Core 只读 get_latest_nav(快照)。「实时净值」仍走 reject(禁止实时盘口)。

+
+
+

C-04 · 阈值提醒

+

用户说「亏 10% 提醒我」→ L1 槽 threshold_pref_summary + 写 customer_threshold_config;下次查持仓时组合加权盈亏达线 → 追加提醒 + customer_notify_log。

+
+
+

C-11 · 匹配说明

+

「我能买什么 / 匹配产品 / 有哪些我能买」→ suitability_check + Core 可购列表。「推荐稳赚」仍 reject。

+
+
+

C-07 · 风评重测

+

「重新测评 / 重做风评」扩进风评关键词 → 引导 App/网点;Agent 内不做问卷。

+
+
+
+ +
+

关键词优先级(易踩坑)

+

客户线各分支 System Prompt 模板(customer_prompts.py)里 keyword_route 顺序:

+
+
+ 1 +

转人工 / reject(实时净值、推荐稳赚等)

+
+
+ 2 +

数据查询:流水 · 风评 · 适当性 · 持仓(含「最近的流水」)

+
+
+ 3 +

save_note(「帮我记住…」——但不含上面已命中的数据词)

+
+
+ 4 +

匹配说明 · 净值查询

+
+
+
+ 回归用例: 「帮我记住最近的流水」必须走 transaction_query,不能误进 save_note——见 test_wave5_notes.py。 +
+
+ +
+
+
+ C-04 · 亏损阈值服务(threshold_service.py) +
# 画像抽槽 → sync_threshold_from_summary
+# query_holdings 末尾 → build_threshold_alert
+if loss_pct >= threshold:
+    append alert + insert_notify_log
+
+
+ 白话 +
+

阈值不是 push 通知——只有客户再来问持仓时才 inline 提醒。

+

组合盈亏按持仓市值加权算,不是单只基金。

+

改阈值:口述新比例 → 画像摘要更新 → config upsert。

+
+
+
+
+ +
+

1B 仍覆盖 nav_query

+

违禁词扫描后的分治(sanitize_postprocess.py)把 nav_query 算进数据查询 intent——interpret 嘴瓢时仍回退 Core fact_text。

+ +
+
+

客户问「这只基金实时净值多少」,系统怎么走?

+
+ + + +
+
+
+ + +
+ + +
+
diff --git a/docs/course/jinrong-module-customer/main.js b/docs/course/jinrong-module-customer/main.js index 7150a69..410548e 100644 --- a/docs/course/jinrong-module-customer/main.js +++ b/docs/course/jinrong-module-customer/main.js @@ -486,13 +486,17 @@ /* ── LAYER TOGGLE ──────────────────────────────────────────── */ window.showLayer = function (layerId, btn) { - const demo = btn ? btn.closest('.layer-demo') : null; + const tab = btn || (window.event && window.event.currentTarget); + if (!tab || !tab.closest) return; + const demo = tab.closest('.layer-demo'); if (!demo) return; - $$('.layer', demo).forEach(l => l.style.display = 'none'); + $$('.layer', demo).forEach(l => { l.style.display = 'none'; }); $$('.layer-tab', demo).forEach(t => t.classList.remove('active')); - const layer = $('#' + layerId); + const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(layerId) : layerId.replace(/[^\w-]/g, ''); + let layer = demo.querySelector('#' + esc); + if (!layer) layer = demo.querySelector('#layer-' + esc); if (layer) layer.style.display = 'block'; - btn.classList.add('active'); + tab.classList.add('active'); }; })(); diff --git a/docs/course/jinrong-module-customer/modules/01-capabilities.html b/docs/course/jinrong-module-customer/modules/01-capabilities.html index cdbbe06..d720947 100644 --- a/docs/course/jinrong-module-customer/modules/01-capabilities.html +++ b/docs/course/jinrong-module-customer/modules/01-capabilities.html @@ -6,7 +6,7 @@ 登录客户(如 CUST-9527)打开「客户助手」后,可走同步或 SSE 流式 对话。未登录访客另有游客试聊(只 RAG、不查持仓)。 - 2026-09 merger 分支:客户线 SSE 已接通,测试基线 774 passed。 + 2026-09 merger 分支:客户线 SSE 已接通,测试基线 **786 passed**。

@@ -17,8 +17,8 @@

POST /api/chat/stream 或 POST /api/chat,请求头 X-Agent-Type: customer + Bearer JWT。

-

持仓 / 流水 / 风评

-

意图命中后走 core_ro_tool 查 Core 模拟库;customer_id 只来自 JWT,不信用户口述客户号。

+

持仓 / 流水 / 风评 / 净值

+

意图命中后走 Core 模拟库只读查询 Tool(core_ro_tool.py);含 C-05 最新净值(非实时);C-04 查持仓时可 inline 阈值提醒。

产品规则 RAG

@@ -26,28 +26,28 @@

游客试聊

-

POST /api/chat/visitor 免登录,visitor_service 9 节点图,不写客户会话表。

+

POST /api/chat/visitor 免登录,游客试聊 9 节点编排(visitor_service.py),不写客户会话表。

- 和理财师线的区别: 客户走独立 customer_service.py(14 节点 LangGraph),不走 agent_service 那条 tool→llm→guard 通用图。 + 和理财师线的区别: 客户线走登录客户 14 节点 LangGraph 编排(customer_service.py),不走 顾问通用 tool→llm→guard 编排(agent_service.py)。

数据流:浏览器问「我持仓多少」

-

客户发消息后,chat.py 按 X-Agent-Type 分流到客户编排,再查 Core。

+

客户发消息后,四 Agent 对话 HTTP 入口(chat.py)按 X-Agent-Type 分流到客户编排,再查 Core。

🖥浏览器
-
🚪chat.py
+
🚪对话 HTTP 入口(chat.py)
🤖customer_service
🗄core_ro_tool
@@ -62,7 +62,7 @@
- 入口 · chat.py + 入口 · 四 Agent 对话 HTTP 入口(chat.py)
if agent_type == "customer":
     host_ctx = host_auth_for_customer_service(auth, trace_id=trace_id)
     customer_prep = prepare_customer_stream(
diff --git a/docs/course/jinrong-module-customer/modules/02-langgraph.html b/docs/course/jinrong-module-customer/modules/02-langgraph.html
index 9a9b619..04dda82 100644
--- a/docs/course/jinrong-module-customer/modules/02-langgraph.html
+++ b/docs/course/jinrong-module-customer/modules/02-langgraph.html
@@ -3,8 +3,8 @@
     

模块 2 · 编排内核

14 节点 LangGraph:
从回忆到归档

- 客户线不走 agent_service 那张三张牌(tool→llm→guard),而是 - customer_service.py 里一张更大的 + 客户线不走顾问通用 tool→llm→guard 编排(agent_service.py),而是 + 登录客户 14 节点 LangGraph 编排(customer_service.py)里一张更大的 LangGraph。 入口永远是 recall_memory,出口经 save_memory 写记忆后收尾。

@@ -16,7 +16,7 @@
2intent_classify · LLM 分意图
3rag_search · fin_* Milvus
4param_extract · 抽查询参数
-
5tool_call · core_ro_tool
+
5tool_call · Core 只读 Tool(core_ro_tool.py)
6interpret · 解读 fact_text
7generate · RAG 拼回复
8chitchat · 闲聊
@@ -29,6 +29,7 @@
分支规则: intent_classify 之后走 RAG(产品/政策/FAQ)、走 Tool(持仓/流水/风评)、或走静态话术(拒绝/转人工/闲聊)。所有生成分支最后都汇入 save_memory。 + 口吻护栏与转人工决策见模块 4。
@@ -132,7 +133,7 @@
- 图构建 · customer_service.py + 图构建 · 登录客户 14 节点 LangGraph 编排(customer_service.py)
g.set_entry_point("recall_memory")
 g.add_edge("recall_memory", "intent_classify")
 g.add_conditional_edges("intent_classify", _route, {...})
diff --git a/docs/course/jinrong-module-customer/modules/03-stream-rules.html b/docs/course/jinrong-module-customer/modules/03-stream-rules.html
index 5e56885..6fc5a3f 100644
--- a/docs/course/jinrong-module-customer/modules/03-stream-rules.html
+++ b/docs/course/jinrong-module-customer/modules/03-stream-rules.html
@@ -13,15 +13,15 @@
       

铁律 1 · 数值不经过 LLM 编造

-

持仓、流水、风评等查询结果 100% 来自 core_ro_tool 的 fact_text。LLM 在 interpret / generate 里只做解读与组织语言,不能自己算数。

+

持仓、流水、风评等查询结果 100% 来自 Core 只读查询 Tool(core_ro_tool.py)的 fact_text。LLM 在 interpret / generate 里只做解读与组织语言,不能自己算数。

铁律 2 · customer_id 只来自 JWT

-

chat.py 在进图之前已从令牌解析客户号并做归属校验。图内 state["customer_id"] 不信用户消息里的「帮我查 CUST-xxx」。

+

四 Agent 对话 HTTP 入口(chat.py)在进图之前已从令牌解析客户号并做归属校验。图内 state["customer_id"] 不信用户消息里的「帮我查 CUST-xxx」。

- 和 advisor 线的差异: 理财师走 agent_service 的 tool→llm→guard;客户走独立 14 节点图。别混用 run_tool 关键词意图那套来改客户 RAG 分支。 + 和 advisor 线的差异: 理财师走顾问通用编排(agent_service.py)的 tool→llm→guard;客户走独立 14 节点图。别混用对话 Tool 编排(tool_service.py)关键词意图那套来改客户 RAG 分支。
@@ -49,7 +49,7 @@
白话
-

入参里的 customer_id 是 chat.py 验完 JWT 后塞进来的,函数内部不再解析用户文本。

+

入参里的 customer_id 是四 Agent 对话 HTTP 入口(chat.py)验完 JWT 后塞进来的,函数内部不再解析用户文本。

先拿到完整 reply(含 intent、是否转人工、是否附免责),再切片给 SSE 层逐块写。

改「真流式」要先动 LangGraph 执行模型,不是只改前端 ChatPanel。

@@ -61,14 +61,14 @@

相关文件(改流式 / 铁律时打开这些)

app/service/
-
customer_service.py — 14 节点图 + prepare_customer_stream
-
visitor_service.py — 游客 9 节点(无 Tool 查持仓)
-
rag_service.py — fin_faq / fin_product / fin_policy
+
登录客户 14 节点 LangGraph 编排(customer_service.py)— prepare_customer_stream
+
游客试聊 9 节点 LangGraph 编排(visitor_service.py)— 无 Tool 查持仓
+
RAG 检索层(rag_service.py)— fin_faq / fin_product / fin_policy
app/api/
-
chat.py — customer 分支分流 + SSE 写帧
-
auth_adapter.py — host_auth_for_customer_service
+
四 Agent 对话 HTTP 入口(chat.py)— customer 分支分流 + SSE 写帧
+
宿主 AuthContext → 模块 AuthContext 适配(auth_adapter.py)— host_auth_for_customer_service
app/tool/
-
core_ro_tool.py — query_holdings / query_trades 等
+
客户 Agent Core 只读查询工具(core_ro_tool.py)— query_holdings / query_trades 等
diff --git a/docs/course/jinrong-module-customer/modules/04-guardrails.html b/docs/course/jinrong-module-customer/modules/04-guardrails.html new file mode 100644 index 0000000..1299f44 --- /dev/null +++ b/docs/course/jinrong-module-customer/modules/04-guardrails.html @@ -0,0 +1,193 @@ +
+
+

模块 4 · 口吻与转人工

+

专业口吻怎么「锁住」?
什么时候该转人工?

+

+ 机制课讲了 LangGraph 怎么走,但客服线还有两条你指挥 AI 时最常碰的线: + 回复别漂成投顾/推销员,以及什么时候打「建议转人工」。 + 代码里不是单靠「写个好 prompt」,而是三层滤网 + 一条主动转人工路径(sanitize 命中不再自动转人工,拍板 1B)。 +

+ +
+

三层滤网:防止口吻漂移

+

可以把客服回复想成「过三道闸」——前面两道尽量写对,最后一道不管 LLM 说什么都要拦违规词。

+
+
+ ① +

Prompt 层 · 客户线各分支 System Prompt 模板(customer_prompts.py)
+ 每个生成分支有独立 system 模板(INTERPRET_SYSTEM / GENERATE_SYSTEM / CHITCHAT_SYSTEM),开头就定身份:「金融客服助手(已登录客户模式)」——禁止投顾话术、禁止预测、画像只调措辞不能念出。

+
+
+ ② +

记忆层 · recall_memory
+ 咨询走 consult_memory、闲聊走 chitchat_memory,再注入 profile_context。让回复有连续语境,但 prompt 里写死:不得主动复述年龄/收入等画像字段。

+
+
+ ③ +

事后扫描 · finalize_sanitized_reply()(sanitize_postprocess.py)
+ interpret / generate / chitchat 产出后过违禁词正则。命中后:数据查询类(含 nav_query)回退 Core fact_text;RAG/闲聊替换 COMPLIANCE_REJECT——均不自动 transfer_to_human。游客线已对齐。

+
+
+
+ 入口还有第 0 道闸: 四 Agent 对话 HTTP 入口(chat.py)的 input_guard 在进图之前就拦注入/超长——这类请求根本到不了 LLM。 +
+
+ +
+
+
+ 事后扫描 · 共用分治(sanitize_postprocess.py) +
def finalize_sanitized_reply(reply, *, intent, fact_text):
+    safe, hit = sanitize_reply(reply)
+    if not hit: return safe, False
+    if intent in DATA_QUERY and fact_text:
+        return fact_text, False  # 1B:保真账,不转人工
+    return COMPLIANCE_REJECT, False
+
+
+ 白话 +
+

数据查询分支:LLM 嘴瓢时回退 Core fact_text,客户仍能看到「持有 3 只产品」等真数。

+

RAG/闲聊分支:替换为固定拒答,但不打转人工标志——除非用户原话已是投诉/转人工。

+

底层 sanitize_reply 仍负责扫违禁词;分治逻辑在 customer 编排层。

+
+
+
+
+ +
+

转人工 vs 直接拒绝:别混成一条线

+

模块 2 列出了 reject 和 transfer_human 两个节点,但触发条件完全不同。指挥 AI 改逻辑时最容易搞混这里。

+ +
+
+
💬intent_classify
+
👤transfer_human
+
🚫reject
+
🔍sanitize_reply
+
+

点击「下一步」看决策分叉

+
+ + +
+
+ +
+
+

路径 A · 主动转人工

+

关键词:转人工、投诉、纠纷、账户异常、被盗 等 → TRANSFER_TEXT + transfer_to_human=True。

+
+
+

路径 B · LLM 越界被拦(1B)

+

数据查询 interpret 违禁 → 回退 fact_text,transfer=false。RAG/闲聊违禁 → COMPLIANCE_REJECT,transfer=false。

+
+
+

路径 C · 边界外但不必转人工

+

问「推荐稳赚基金」「明天会涨吗」→ reject 静态话术(如 REJECT_ADVICE_TEXT)。不自动打转人工标志;话术中可引导用户自行说「转人工」。

+
+
+
+ 演示环境边界: 「建议转人工」只是 API 字段 + 前端橙色 Tag(ChatPanel),不会真的排队接入真人坐席——真转接要接呼叫中心/工单系统,那是产品层后续工作。 +
+
+ +
+

群聊动画:LLM 嘴瓢后被扫描层拦住

+

客户问持仓,Tool 返回正常 fact_text,但 interpret 的 LLM 多嘴加了「建议您加仓」——扫描层介入。

+ +
+
+ + + + + +
+ +
+ + + + +
+
+
+ +
+

RAG 咨询还要加风险提示

+

产品/政策/FAQ 类意图在 generate 通过后,若 intent 属于咨询类,会追加 RISK_DISCLAIMER(should_add_disclaimer)。这是「口吻」的另一面:该免责时必须免责,不是只有拦违规词。

+ +
+
+

客户说「推荐一个稳赚不赔的基金」,系统会怎么标记?

+
+ + + +
+
+
+ + +
+ +
+ 指挥 AI 改客服时的检查清单: + ① 改口吻 → 动客户线各分支 System Prompt 模板(customer_prompts.py)对应 SYSTEM,别只改前端; + ② 加新禁语 → 同时更新 FORBIDDEN_TERMS 和意图关键词路由; + ③ 加「必须转人工」场景 → 改 _TRANSFER_KEYWORDS 或 INTENT_SYSTEM 规则,别误放进 reject。 +
+ + +
+
+
diff --git a/docs/course/jinrong-module-customer/modules/05-boundaries.html b/docs/course/jinrong-module-customer/modules/05-boundaries.html new file mode 100644 index 0000000..c7df909 --- /dev/null +++ b/docs/course/jinrong-module-customer/modules/05-boundaries.html @@ -0,0 +1,127 @@ +
+
+

模块 5 · 边界与接缝

+

两套知识库、合规口径
和一期代码差在哪?

+

+ 客服线「能答什么」不只看 LangGraph,还看知识库分家和合规文档 vs 一期实现。 + 指挥 AI 改 RAG 或「放开推荐」前,先对齐下面这张差分表。 +

+ +
+

两套 RAG:别灌错库

+
+
+

客服 · fin_* 三库

+

登录客户的对话编排(customer_service.py)和游客试聊编排(visitor_service.py)遇到产品/政策/FAQ 类问题时,都会交给 RAG 检索层(rag_service.py:把问题向量化后查 Milvus),从 fin_product · fin_policy · fin_faq 三个知识集合取片段。

+
+
+

顾问 · kb_product_rules

+

理财师/风控通用 tool→llm→guard 编排(agent_service.py)的 search_knowledge Tool → 知识库对话 Tool 注册与分发(kb_tools.py)· 集合名不同,不能把顾问文档灌进 fin_*。

+
+
+

风控 · 不开放 search_knowledge

+

知识库对话 Tool 注册与分发(kb_tools.py)注册表对 risk 关闭——风控对话走 RISK_TOOL_REGISTRY 四只读 Tool。

+
+
+
+ 指挥 AI 误区: 「统一成一个知识库」听起来省事,但会破坏各 Agent 合规过滤与检索策略——要改先对契约和 scripts/kb/ 脚本。 +
+
+ +
+

合规允许 vs 一期拒答(你拍板要讲的)

+

合规文档里,客户 Agent 在过 R-02 + 附免责 + 标注需持证审核时,可以做「匹配说明」——但一期代码对主动推荐/预测类问题走 reject 静态拒答,更保守。

+ +
+
+ 合规文档 · 允许方向 +
适当性匹配说明(C-11)
+→ 系统判定 + 规则解读 + 免责声明
+→ 禁止具体操作指令
+
+
+ 一期代码 · 实际行为 +
+

suitability_check Tool:「我能买 R3 吗」「有哪些匹配产品」——允许。

+

「推荐稳赚基金」「买什么好」→ reject + REJECT_ADVICE_TEXT——直接拒。

+

要「放开推荐」= 产品决策 + 改 intent 路由 + 合规评审,不是只改 prompt 一句。

+
+
+
+
+ +
+

群聊:同一客户,两种问法

+
+
+ + + + +
+ +
+ + + + +
+
+
+ +
+

游客与画像:客户看不见 L2/L3

+

游客走游客试聊 9 节点编排(visitor_service.py):无 JWT、无 Core 持仓查询、不写登录客户会话表。L1 画像由客服线后台抽槽写入;L2/L3 对客户不可见——合规上客户只能 enrich L1,不能替代正式风评。

+ +
+
+

AI 建议「把 kb 和 fin 合成一个 Milvus 集合简化维护」,你怎么回?

+
+ + + +
+
+
+ + +
+
+
+
diff --git a/docs/course/jinrong-module-customer/modules/06-profile-l0.html b/docs/course/jinrong-module-customer/modules/06-profile-l0.html new file mode 100644 index 0000000..1c36c8c --- /dev/null +++ b/docs/course/jinrong-module-customer/modules/06-profile-l0.html @@ -0,0 +1,99 @@ +
+
+

模块 6 · L0 与 L1 画像

+

正式风评 vs 抽槽画像
Top-K 怎么注入 prompt?

+

+ 客户线除了查 Core 真账,还有后台画像抽槽(L1)调措辞。 + 拍板铁律:L0 永远优先——正式 C1~C5、持仓数字、适当性判定听 Core,L1 不能覆盖。 + 偏好类槽位注入 prompt 时走 Top-K + 时间衰减(R1),不是全量平铺。 +

+ +
+

L0 vs L1:谁说了算?

+
+
+

L0 · Core 正式数据

+

风评 C1~C5、持仓/流水数字、适当性矩阵判定——100% 来自 Core 只读查询 Tool(core_ro_tool.py)的 fact_text。LLM 只解读,不能改写。

+
+
+

L1 · 对话 enrich 画像

+

登录客户每 5 轮节流,后台线程跑画像抽槽与合并(profile_service.py)→ 写入 MySQL style_tags + Redis 热缓存。只影响 generate/chitchat 的措辞语境,不能替代正式风评。

+
+
+

撞车规则

+

抽槽若与 L0 字段冲突(例如用户口述「我是 R5」但 Core 是 R3)→ 永远听 L0。合并规则 D7:用户显式 user_declared 不被 inferred 覆盖。

+
+
+
+ +
+

13 槽位 + confidence 门槛

+

槽位表唯一来源:画像槽位定义(profile_slots.py)——13 个 path(含 C-08 investment.allocation_target),含 merge_mode 与 sensitivity。

+
+
+ ① +

归一 · normalize_value
+ 例如「我喜欢货币基金」→ 映射到 9 类 PRODUCT_TYPE_VOCAB(不是具体 SKU 代码)。

+
+
+ ② +

阈值 · confidence ≥0.7(默认)/ ≥0.9(高敏)才写入;高敏 inferred 一律丢弃。

+
+
+ ③ +

合并 · merge_candidates
+ 单值 latest 覆盖;偏好/排除类 set_union 并集,并写 items_meta(R1)。

+
+
+
+ 谁写 L1: 仅登录客户线(customer_service.profile_maybe_extract);游客试聊不写画像。
+ C-04 阈值槽: threshold_pref_summary → customer_threshold_config;达线提醒仅在查持仓时 inline。 +
+
+ +
+

R1 · 偏好 Top-K 注入

+
+
+ 注入前筛选 · 画像上下文渲染(profile_service.py) +
# settings 默认:top_k=3 · ttl=90d · half_life=30d
+score = confidence × decay × (1 + log(mention_count))
+# 超 TTL 的偏好不进 prompt;取得分 Top-K 条
+
+
+ 白话 +
+

客户说过很多偏好类型,但 prompt 里只带最近、反复提到的前几条,避免画像越积越长带偏 LLM。

+

很久没提过的偏好(超过 90 天)不再注入,但 MySQL 里仍保留,下次提到会刷新。

+

改 K 或 TTL → 动环境配置(settings.py 的 profile_preference_*),别在 prompt 里硬编码。

+
+
+
+ +
+
+

客户口述「我是激进型 R5」,但 Core 风评是 R3,持仓查询应信谁?

+
+ + + +
+
+
+ + +
+ + +
+
+
diff --git a/docs/course/jinrong-module-customer/modules/07-wave3-queries.html b/docs/course/jinrong-module-customer/modules/07-wave3-queries.html new file mode 100644 index 0000000..ddbe5c5 --- /dev/null +++ b/docs/course/jinrong-module-customer/modules/07-wave3-queries.html @@ -0,0 +1,108 @@ +
+
+

模块 7 · Wave3 数据查询扩展

+

净值、阈值、匹配说明
关键词怎么分流?

+

+ 2026-09-10 批次在持仓/流水/风评/适当性之外,又接了净值查询(C-05)、亏损阈值(C-04)和「我能买什么」(C-11)。 + 指挥 AI 改意图时,先看关键词路由顺序——数据查询类必须优先于「帮我记住」类 save_note。 +

+ +
+

新增 intent 一览

+
+
+

C-05 · nav_query

+

「最新净值 / 单位净值 / 基金净值」→ Core 只读 get_latest_nav(快照)。「实时净值」仍走 reject(禁止实时盘口)。

+
+
+

C-04 · 阈值提醒

+

用户说「亏 10% 提醒我」→ L1 槽 threshold_pref_summary + 写 customer_threshold_config;下次查持仓时组合加权盈亏达线 → 追加提醒 + customer_notify_log。

+
+
+

C-11 · 匹配说明

+

「我能买什么 / 匹配产品 / 有哪些我能买」→ suitability_check + Core 可购列表。「推荐稳赚」仍 reject。

+
+
+

C-07 · 风评重测

+

「重新测评 / 重做风评」扩进风评关键词 → 引导 App/网点;Agent 内不做问卷。

+
+
+
+ +
+

关键词优先级(易踩坑)

+

客户线各分支 System Prompt 模板(customer_prompts.py)里 keyword_route 顺序:

+
+
+ 1 +

转人工 / reject(实时净值、推荐稳赚等)

+
+
+ 2 +

数据查询:流水 · 风评 · 适当性 · 持仓(含「最近的流水」)

+
+
+ 3 +

save_note(「帮我记住…」——但不含上面已命中的数据词)

+
+
+ 4 +

匹配说明 · 净值查询

+
+
+
+ 回归用例: 「帮我记住最近的流水」必须走 transaction_query,不能误进 save_note——见 test_wave5_notes.py。 +
+
+ +
+
+
+ C-04 · 亏损阈值服务(threshold_service.py) +
# 画像抽槽 → sync_threshold_from_summary
+# query_holdings 末尾 → build_threshold_alert
+if loss_pct >= threshold:
+    append alert + insert_notify_log
+
+
+ 白话 +
+

阈值不是 push 通知——只有客户再来问持仓时才 inline 提醒。

+

组合盈亏按持仓市值加权算,不是单只基金。

+

改阈值:口述新比例 → 画像摘要更新 → config upsert。

+
+
+
+
+ +
+

1B 仍覆盖 nav_query

+

违禁词扫描后的分治(sanitize_postprocess.py)把 nav_query 算进数据查询 intent——interpret 嘴瓢时仍回退 Core fact_text。

+ +
+
+

客户问「这只基金实时净值多少」,系统怎么走?

+
+ + + +
+
+
+ + +
+ + +
+
+
diff --git a/docs/course/jinrong-module-customer/styles.css b/docs/course/jinrong-module-customer/styles.css index 544669c..68d0e39 100644 --- a/docs/course/jinrong-module-customer/styles.css +++ b/docs/course/jinrong-module-customer/styles.css @@ -254,7 +254,8 @@ pre::-webkit-scrollbar { display: none; } padding-top: calc(var(--nav-height) + var(--space-12)); } -.module-content { +.module-content, +.module-inner { max-width: var(--content-width); margin: 0 auto; } diff --git a/docs/course/jinrong-module-frontend/_base.html b/docs/course/jinrong-module-frontend/_base.html index d03705c..5ed8588 100644 --- a/docs/course/jinrong-module-frontend/_base.html +++ b/docs/course/jinrong-module-frontend/_base.html @@ -32,6 +32,7 @@ +
diff --git a/docs/course/jinrong-module-frontend/index.html b/docs/course/jinrong-module-frontend/index.html index ecea412..5a3ac2c 100644 --- a/docs/course/jinrong-module-frontend/index.html +++ b/docs/course/jinrong-module-frontend/index.html @@ -32,6 +32,7 @@ +
@@ -165,14 +166,14 @@ {"highlight":"flow-actor-1","label":"ChatPanel(agentType=advisor, mode=stream)"}, {"highlight":"flow-actor-2","label":"useChatPanel → listChatSessions(token, advisor)","packet":true,"from":"1","to":"2"}, {"highlight":"flow-actor-3","label":"chat.ts:Bearer + X-Agent-Type: advisor","packet":true,"from":"2","to":"3"}, - {"highlight":"flow-actor-4","label":"chat.py:get_auth_context + 准入矩阵","packet":true,"from":"3","to":"4"}, + {"highlight":"flow-actor-4","label":"对话 HTTP 入口(chat.py):get_auth_context + 准入矩阵","packet":true,"from":"3","to":"4"}, {"highlight":"flow-actor-1","label":"SSE 流式 delta → UI 逐字显示","packet":true,"from":"4","to":"1"} ]'>
💬ChatPanel
🪝useChatPanel
📡api/chat.ts
-
🚪chat.py
+
🚪对话 HTTP 入口(chat.py)

对比:问数走 analyst.ts,无 X-Agent-Type

@@ -249,7 +250,7 @@
1 -
后端:uvicorn app.main:app --reload --port 8000(Core 模拟库 + Redis 6380 按需)
+
后端 · 应用入口(main.py):uvicorn app.main:app --reload --port 8000(Core 模拟库 + Redis 6380 按需)
2 @@ -347,6 +348,83 @@
+ +
+
+

模块 4 · 未接线

+

菜单有 ≠ API 已接
Chat 还有 B 方案三端点

+

+ 前端 P0 主链路已真接,但和后端课对照,仍有页面占位、风控/平台按钮缺失、SSE 行为等缝。 + 改 URL 进别人工作台,前端藏菜单拦不住,后端才是闸。 +

+ +
+

前端未全接清单(2026-09)

+
+
+

风控

+

AML scan · 适当性校验按钮 · 台账高级筛选 — 部分 API 有,UI 简版或未接。

+
+
+

问数

+

AnalystChatShell 占位 · 真问数在 AnalystQueryPage + dashboard/assets。

+
+
+

游客

+

VisitorChatWidget 首页试聊 · 与登录 ChatPanel 不同 API。

+
+
+
+ +
+

Chat 方案 B:三端点 + SSE 注意点

+
+
+ 会话 API +
GET  /api/chat/sessions
+GET  /api/chat/sessions/{id}/messages
+POST /api/chat/sessions/{id}/close
+POST /api/chat/stream  → customer SSE
+
+
+ 白话 +
+

列表/历史/关闭与发消息共用同一套鉴权(Bearer + X-Agent-Type)。

+

SSE 无心跳:断连可能留下半空回合——排障时查 session 表 + 是否 uvicorn 重启。

+

HashRouter:URL 带 #,生产 Nginx 要配 SPA fallback。

+
+
+
+
+ +
+

改 URL 进别人工作台

+

RequireAuth 只验「有没有 token」,不验「这个角色能不能进 /app/risk」。菜单隐藏 ≠ 安全——后端 AGENT_ACCESS_MATRIX 和平台 RBAC 才拒 403。

+ +
+
+

客户账号手动打开 /app/risk/alerts,只靠前端能拦住吗?

+
+ + + +
+
+
+ + +
+
+
diff --git a/docs/course/jinrong-module-frontend/main.js b/docs/course/jinrong-module-frontend/main.js index 7150a69..410548e 100644 --- a/docs/course/jinrong-module-frontend/main.js +++ b/docs/course/jinrong-module-frontend/main.js @@ -486,13 +486,17 @@ /* ── LAYER TOGGLE ──────────────────────────────────────────── */ window.showLayer = function (layerId, btn) { - const demo = btn ? btn.closest('.layer-demo') : null; + const tab = btn || (window.event && window.event.currentTarget); + if (!tab || !tab.closest) return; + const demo = tab.closest('.layer-demo'); if (!demo) return; - $$('.layer', demo).forEach(l => l.style.display = 'none'); + $$('.layer', demo).forEach(l => { l.style.display = 'none'; }); $$('.layer-tab', demo).forEach(t => t.classList.remove('active')); - const layer = $('#' + layerId); + const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(layerId) : layerId.replace(/[^\w-]/g, ''); + let layer = demo.querySelector('#' + esc); + if (!layer) layer = demo.querySelector('#layer-' + esc); if (layer) layer.style.display = 'block'; - btn.classList.add('active'); + tab.classList.add('active'); }; })(); diff --git a/docs/course/jinrong-module-frontend/modules/02-chat-auth.html b/docs/course/jinrong-module-frontend/modules/02-chat-auth.html index 7c519d4..ad33fe4 100644 --- a/docs/course/jinrong-module-frontend/modules/02-chat-auth.html +++ b/docs/course/jinrong-module-frontend/modules/02-chat-auth.html @@ -30,14 +30,14 @@ {"highlight":"flow-actor-1","label":"ChatPanel(agentType=advisor, mode=stream)"}, {"highlight":"flow-actor-2","label":"useChatPanel → listChatSessions(token, advisor)","packet":true,"from":"1","to":"2"}, {"highlight":"flow-actor-3","label":"chat.ts:Bearer + X-Agent-Type: advisor","packet":true,"from":"2","to":"3"}, - {"highlight":"flow-actor-4","label":"chat.py:get_auth_context + 准入矩阵","packet":true,"from":"3","to":"4"}, + {"highlight":"flow-actor-4","label":"对话 HTTP 入口(chat.py):get_auth_context + 准入矩阵","packet":true,"from":"3","to":"4"}, {"highlight":"flow-actor-1","label":"SSE 流式 delta → UI 逐字显示","packet":true,"from":"4","to":"1"} ]'>
💬ChatPanel
🪝useChatPanel
📡api/chat.ts
-
🚪chat.py
+
🚪对话 HTTP 入口(chat.py)

对比:问数走 analyst.ts,无 X-Agent-Type

diff --git a/docs/course/jinrong-module-frontend/modules/03-dev-debug.html b/docs/course/jinrong-module-frontend/modules/03-dev-debug.html index e9334aa..ac36d89 100644 --- a/docs/course/jinrong-module-frontend/modules/03-dev-debug.html +++ b/docs/course/jinrong-module-frontend/modules/03-dev-debug.html @@ -13,7 +13,7 @@
1 -
后端:uvicorn app.main:app --reload --port 8000(Core 模拟库 + Redis 6380 按需)
+
后端 · 应用入口(main.py):uvicorn app.main:app --reload --port 8000(Core 模拟库 + Redis 6380 按需)
2 diff --git a/docs/course/jinrong-module-frontend/modules/04-unwired.html b/docs/course/jinrong-module-frontend/modules/04-unwired.html new file mode 100644 index 0000000..4f8f2d8 --- /dev/null +++ b/docs/course/jinrong-module-frontend/modules/04-unwired.html @@ -0,0 +1,77 @@ +
+
+

模块 4 · 未接线

+

菜单有 ≠ API 已接
Chat 还有 B 方案三端点

+

+ 前端 P0 主链路已真接,但和后端课对照,仍有页面占位、风控/平台按钮缺失、SSE 行为等缝。 + 改 URL 进别人工作台,前端藏菜单拦不住,后端才是闸。 +

+ +
+

前端未全接清单(2026-09)

+
+
+

风控

+

AML scan · 适当性校验按钮 · 台账高级筛选 — 部分 API 有,UI 简版或未接。

+
+
+

问数

+

AnalystChatShell 占位 · 真问数在 AnalystQueryPage + dashboard/assets。

+
+
+

游客

+

VisitorChatWidget 首页试聊 · 与登录 ChatPanel 不同 API。

+
+
+
+ +
+

Chat 方案 B:三端点 + SSE 注意点

+
+
+ 会话 API +
GET  /api/chat/sessions
+GET  /api/chat/sessions/{id}/messages
+POST /api/chat/sessions/{id}/close
+POST /api/chat/stream  → customer SSE
+
+
+ 白话 +
+

列表/历史/关闭与发消息共用同一套鉴权(Bearer + X-Agent-Type)。

+

SSE 无心跳:断连可能留下半空回合——排障时查 session 表 + 是否 uvicorn 重启。

+

HashRouter:URL 带 #,生产 Nginx 要配 SPA fallback。

+
+
+
+
+ +
+

改 URL 进别人工作台

+

RequireAuth 只验「有没有 token」,不验「这个角色能不能进 /app/risk」。菜单隐藏 ≠ 安全——后端 AGENT_ACCESS_MATRIX 和平台 RBAC 才拒 403。

+ +
+
+

客户账号手动打开 /app/risk/alerts,只靠前端能拦住吗?

+
+ + + +
+
+
+ + +
+
+
+
diff --git a/docs/course/jinrong-module-frontend/styles.css b/docs/course/jinrong-module-frontend/styles.css index 544669c..68d0e39 100644 --- a/docs/course/jinrong-module-frontend/styles.css +++ b/docs/course/jinrong-module-frontend/styles.css @@ -254,7 +254,8 @@ pre::-webkit-scrollbar { display: none; } padding-top: calc(var(--nav-height) + var(--space-12)); } -.module-content { +.module-content, +.module-inner { max-width: var(--content-width); margin: 0 auto; } diff --git a/docs/course/jinrong-module-platform/_base.html b/docs/course/jinrong-module-platform/_base.html index 6553b8a..eeab6f3 100644 --- a/docs/course/jinrong-module-platform/_base.html +++ b/docs/course/jinrong-module-platform/_base.html @@ -32,6 +32,7 @@ +
diff --git a/docs/course/jinrong-module-platform/index.html b/docs/course/jinrong-module-platform/index.html index f6f3343..78c0405 100644 --- a/docs/course/jinrong-module-platform/index.html +++ b/docs/course/jinrong-module-platform/index.html @@ -32,6 +32,7 @@ +
@@ -53,7 +54,7 @@

/api/customers/*

-

客户 L0 档案、持仓、流水。customers.py → platform/customer_service → core_ro。

+

客户 L0 档案、持仓、流水。平台客户 REST 薄路由(customers.py) → platform/customer_service → core_ro。

/api/products/*

@@ -76,7 +77,7 @@

三层分工(别让 AI 在路由里写 SQL)

检查答案 + +
+
+
diff --git a/docs/course/jinrong-module-platform/main.js b/docs/course/jinrong-module-platform/main.js index 7150a69..410548e 100644 --- a/docs/course/jinrong-module-platform/main.js +++ b/docs/course/jinrong-module-platform/main.js @@ -486,13 +486,17 @@ /* ── LAYER TOGGLE ──────────────────────────────────────────── */ window.showLayer = function (layerId, btn) { - const demo = btn ? btn.closest('.layer-demo') : null; + const tab = btn || (window.event && window.event.currentTarget); + if (!tab || !tab.closest) return; + const demo = tab.closest('.layer-demo'); if (!demo) return; - $$('.layer', demo).forEach(l => l.style.display = 'none'); + $$('.layer', demo).forEach(l => { l.style.display = 'none'; }); $$('.layer-tab', demo).forEach(t => t.classList.remove('active')); - const layer = $('#' + layerId); + const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(layerId) : layerId.replace(/[^\w-]/g, ''); + let layer = demo.querySelector('#' + esc); + if (!layer) layer = demo.querySelector('#layer-' + esc); if (layer) layer.style.display = 'block'; - btn.classList.add('active'); + tab.classList.add('active'); }; })(); diff --git a/docs/course/jinrong-module-platform/modules/01-routes.html b/docs/course/jinrong-module-platform/modules/01-routes.html index 8ceab97..30c9063 100644 --- a/docs/course/jinrong-module-platform/modules/01-routes.html +++ b/docs/course/jinrong-module-platform/modules/01-routes.html @@ -14,7 +14,7 @@

/api/customers/*

-

客户 L0 档案、持仓、流水。customers.py → platform/customer_service → core_ro。

+

客户 L0 档案、持仓、流水。平台客户 REST 薄路由(customers.py) → platform/customer_service → core_ro。

/api/products/*

@@ -37,7 +37,7 @@

三层分工(别让 AI 在路由里写 SQL)

检查答案 + +
+
+
+ diff --git a/docs/course/jinrong-module-platform/styles.css b/docs/course/jinrong-module-platform/styles.css index 544669c..68d0e39 100644 --- a/docs/course/jinrong-module-platform/styles.css +++ b/docs/course/jinrong-module-platform/styles.css @@ -254,7 +254,8 @@ pre::-webkit-scrollbar { display: none; } padding-top: calc(var(--nav-height) + var(--space-12)); } -.module-content { +.module-content, +.module-inner { max-width: var(--content-width); margin: 0 auto; } diff --git a/docs/course/jinrong-module-risk/_base.html b/docs/course/jinrong-module-risk/_base.html index bd1751e..054730d 100644 --- a/docs/course/jinrong-module-risk/_base.html +++ b/docs/course/jinrong-module-risk/_base.html @@ -32,6 +32,7 @@ +
diff --git a/docs/course/jinrong-module-risk/index.html b/docs/course/jinrong-module-risk/index.html index 2f1ed08..302faa0 100644 --- a/docs/course/jinrong-module-risk/index.html +++ b/docs/course/jinrong-module-risk/index.html @@ -32,6 +32,7 @@ +
@@ -64,7 +65,7 @@

模拟交易 FR-1

-

POST /api/simulate/trade:risk_demo 或客户本人可提交;走 trade_gateway → 适当性 → 规则引擎。

+

POST /api/simulate/trade:risk_demo 或客户本人可提交;走 模拟写 Core 网关(trade_gateway.py) → 适当性 → 规则引擎(risk_engine.py)。

@@ -123,7 +124,7 @@
- CODE · simulate.py 鉴权 + CODE · 模拟交易入口(simulate.py)鉴权
if not (auth.has_role("risk_demo")
         or (auth.is_customer()
             and auth.customer_id == req.customer_id)):
@@ -170,8 +171,8 @@
     

模块 2 · 模拟交易链路

一笔演示交易
怎么长出预警单?

- RiskSimulatePage 提交表单 → simulate.py 验权 → trade_gateway 写库 → - risk_engine 扫规则 → 聚合进 risk_alert。 + RiskSimulatePage 提交表单 → 模拟交易入口(simulate.py)验权 → 模拟写 Core 网关(trade_gateway.py)写库 → + 规则引擎(risk_engine.py)扫规则 → 聚合进 risk_alert。 像演示用的「假收银台」:钱是假的,但风控流程是真的。

@@ -179,16 +180,16 @@

四段链路(动画)

🧪模拟交易 UI
-
🚪simulate.py
-
⚙trade_gateway
-
🛡risk_engine
+
🚪模拟交易入口
+
⚙模拟写 Core 网关
+
🛡规则引擎

Preset A-3:CUST-3001 · 50 万 → 大额预警 · Preset A-1:适当性阻断

@@ -201,12 +202,12 @@

代码地图

-
app/api/simulate.py
+
模拟交易入口(app/api/simulate.py)
薄路由:鉴权 + 调 submit_trade
-
app/gateway/trade_gateway.py
+
模拟写 Core 网关(app/gateway/trade_gateway.py)
适当性 → 写 core_trade → 调 risk 引擎
app/service/risk/
-
risk_engine.py · alert_service.py · 规则聚合
+
规则引擎(risk_engine.py) · alert_service.py · 规则聚合
web/src/pages/risk/RiskSimulatePage.tsx
预设 A-1 / A-3 一键填表
@@ -215,7 +216,7 @@
- CODE · simulate.py + CODE · 模拟交易入口(simulate.py)
if not (auth.has_role("risk_demo")
         or (auth.is_customer() and
             auth.customer_id == req.customer_id)):
@@ -234,18 +235,18 @@
       

Demo 里谁可以把新成交写进 core_trade?

@@ -263,7 +264,7 @@

风控REST 走 get_auth_context,前端必须带 X-Agent-Type: risk。 - 风控对话则通过 chat_tools 注册五只只读 Tool + query_agent_behavior,由 tool_service 统一分发。 + 风控对话则通过风控对话 Tool 注册表(chat_tools.py)注册五只只读 Tool + query_agent_behavior,由对话 Tool 编排(tool_service.py)统一分发。

@@ -285,9 +286,9 @@
-

chat_tools 注册表(C1 + 扩展)

+

风控对话 Tool(chat_tools.py)注册表(C1 + 扩展)

-
app/service/risk/chat_tools.py
+
风控对话 Tool(app/service/risk/chat_tools.py)
alert_query — 客户或全量待审预警
customer_context — L0 + L3 + 待审预警
suitability_check — 只读校验 + 审计落库
@@ -390,6 +391,86 @@

+ +
+
+

模块 4 · 演示与缺口

+

后端有、前端没有
cron 和空台账怎么理解

+

+ 风控引擎和 REST 大多已实现,但演示运维、cron 升级、部分前端按钮仍开放。 + reset 后台账为空是正常现象——不是「风控坏了」。 +

+ +
+

后端有 · 前端未全接(2026-09)

+
+
+

AML scan

+

POST /api/risk/aml/scan — API 有,web/ 无专用操作页。

+
+
+

适当性双 URL

+

平台 canonical /api/compliance/suitability-check vs 风控 /api/risk/suitability/check — 别再加第三条。

+
+
+

台账筛选

+

类型/客户/日期高级筛选 — 后端能力部分有,UI 简版。

+
+
+
+ +
+

FR-9/10:引擎在,cron 无 UI

+
+
+
💬风控 Chat
+
⏰cron 脚本
+
📊MySQL 台账
+
+

点击「下一步」

+
+ + +
+
+
+ reset 后台账空: reset.ps1 只重建 jinrong_core;risk_alert 要跑 prepare_risk_demo.sql + AML 种子才有演示数据。 +
+
+ +
+

合规硬边界(再强调)

+

预警默认 pending_review · 仅风控专员 handle 改状态 · 不自动冻户 · 不自动上报监管 · 不改正式 C1~C5。

+ +
+
+

风控专员说「在 Chat 里说一句就自动跑超期升级」,能实现吗?

+
+ + + +
+
+
+ + +
+
+
diff --git a/docs/course/jinrong-module-risk/main.js b/docs/course/jinrong-module-risk/main.js index 7150a69..410548e 100644 --- a/docs/course/jinrong-module-risk/main.js +++ b/docs/course/jinrong-module-risk/main.js @@ -486,13 +486,17 @@ /* ── LAYER TOGGLE ──────────────────────────────────────────── */ window.showLayer = function (layerId, btn) { - const demo = btn ? btn.closest('.layer-demo') : null; + const tab = btn || (window.event && window.event.currentTarget); + if (!tab || !tab.closest) return; + const demo = tab.closest('.layer-demo'); if (!demo) return; - $$('.layer', demo).forEach(l => l.style.display = 'none'); + $$('.layer', demo).forEach(l => { l.style.display = 'none'; }); $$('.layer-tab', demo).forEach(t => t.classList.remove('active')); - const layer = $('#' + layerId); + const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(layerId) : layerId.replace(/[^\w-]/g, ''); + let layer = demo.querySelector('#' + esc); + if (!layer) layer = demo.querySelector('#layer-' + esc); if (layer) layer.style.display = 'block'; - btn.classList.add('active'); + tab.classList.add('active'); }; })(); diff --git a/docs/course/jinrong-module-risk/modules/01-capabilities.html b/docs/course/jinrong-module-risk/modules/01-capabilities.html index 3619fd5..903e49e 100644 --- a/docs/course/jinrong-module-risk/modules/01-capabilities.html +++ b/docs/course/jinrong-module-risk/modules/01-capabilities.html @@ -25,7 +25,7 @@

模拟交易 FR-1

-

POST /api/simulate/trade:risk_demo 或客户本人可提交;走 trade_gateway → 适当性 → 规则引擎。

+

POST /api/simulate/trade:risk_demo 或客户本人可提交;走 模拟写 Core 网关(trade_gateway.py) → 适当性 → 规则引擎(risk_engine.py)。

@@ -84,7 +84,7 @@
- CODE · simulate.py 鉴权 + CODE · 模拟交易入口(simulate.py)鉴权
if not (auth.has_role("risk_demo")
         or (auth.is_customer()
             and auth.customer_id == req.customer_id)):
diff --git a/docs/course/jinrong-module-risk/modules/02-engine.html b/docs/course/jinrong-module-risk/modules/02-engine.html
index 4d4d539..6bca6d8 100644
--- a/docs/course/jinrong-module-risk/modules/02-engine.html
+++ b/docs/course/jinrong-module-risk/modules/02-engine.html
@@ -3,8 +3,8 @@
     

模块 2 · 模拟交易链路

一笔演示交易
怎么长出预警单?

- RiskSimulatePage 提交表单 → simulate.py 验权 → trade_gateway 写库 → - risk_engine 扫规则 → 聚合进 risk_alert。 + RiskSimulatePage 提交表单 → 模拟交易入口(simulate.py)验权 → 模拟写 Core 网关(trade_gateway.py)写库 → + 规则引擎(risk_engine.py)扫规则 → 聚合进 risk_alert。 像演示用的「假收银台」:钱是假的,但风控流程是真的。

@@ -12,16 +12,16 @@

四段链路(动画)

🧪模拟交易 UI
-
🚪simulate.py
-
⚙trade_gateway
-
🛡risk_engine
+
🚪模拟交易入口
+
⚙模拟写 Core 网关
+
🛡规则引擎

Preset A-3:CUST-3001 · 50 万 → 大额预警 · Preset A-1:适当性阻断

@@ -34,12 +34,12 @@

代码地图

-
app/api/simulate.py
+
模拟交易入口(app/api/simulate.py)
薄路由:鉴权 + 调 submit_trade
-
app/gateway/trade_gateway.py
+
模拟写 Core 网关(app/gateway/trade_gateway.py)
适当性 → 写 core_trade → 调 risk 引擎
app/service/risk/
-
risk_engine.py · alert_service.py · 规则聚合
+
规则引擎(risk_engine.py) · alert_service.py · 规则聚合
web/src/pages/risk/RiskSimulatePage.tsx
预设 A-1 / A-3 一键填表
@@ -48,7 +48,7 @@
- CODE · simulate.py + CODE · 模拟交易入口(simulate.py)
if not (auth.has_role("risk_demo")
         or (auth.is_customer() and
             auth.customer_id == req.customer_id)):
@@ -67,18 +67,18 @@
       

Demo 里谁可以把新成交写进 core_trade?

diff --git a/docs/course/jinrong-module-risk/modules/03-tools-rest.html b/docs/course/jinrong-module-risk/modules/03-tools-rest.html index 974ec80..8bfa841 100644 --- a/docs/course/jinrong-module-risk/modules/03-tools-rest.html +++ b/docs/course/jinrong-module-risk/modules/03-tools-rest.html @@ -5,7 +5,7 @@

风控REST 走 get_auth_context,前端必须带 X-Agent-Type: risk。 - 风控对话则通过 chat_tools 注册五只只读 Tool + query_agent_behavior,由 tool_service 统一分发。 + 风控对话则通过风控对话 Tool 注册表(chat_tools.py)注册五只只读 Tool + query_agent_behavior,由对话 Tool 编排(tool_service.py)统一分发。

@@ -27,9 +27,9 @@
-

chat_tools 注册表(C1 + 扩展)

+

风控对话 Tool(chat_tools.py)注册表(C1 + 扩展)

-
app/service/risk/chat_tools.py
+
风控对话 Tool(app/service/risk/chat_tools.py)
alert_query — 客户或全量待审预警
customer_context — L0 + L3 + 待审预警
suitability_check — 只读校验 + 审计落库
diff --git a/docs/course/jinrong-module-risk/modules/04-gaps.html b/docs/course/jinrong-module-risk/modules/04-gaps.html new file mode 100644 index 0000000..5486b19 --- /dev/null +++ b/docs/course/jinrong-module-risk/modules/04-gaps.html @@ -0,0 +1,80 @@ +
+
+

模块 4 · 演示与缺口

+

后端有、前端没有
cron 和空台账怎么理解

+

+ 风控引擎和 REST 大多已实现,但演示运维、cron 升级、部分前端按钮仍开放。 + reset 后台账为空是正常现象——不是「风控坏了」。 +

+ +
+

后端有 · 前端未全接(2026-09)

+
+
+

AML scan

+

POST /api/risk/aml/scan — API 有,web/ 无专用操作页。

+
+
+

适当性双 URL

+

平台 canonical /api/compliance/suitability-check vs 风控 /api/risk/suitability/check — 别再加第三条。

+
+
+

台账筛选

+

类型/客户/日期高级筛选 — 后端能力部分有,UI 简版。

+
+
+
+ +
+

FR-9/10:引擎在,cron 无 UI

+
+
+
💬风控 Chat
+
⏰cron 脚本
+
📊MySQL 台账
+
+

点击「下一步」

+
+ + +
+
+
+ reset 后台账空: reset.ps1 只重建 jinrong_core;risk_alert 要跑 prepare_risk_demo.sql + AML 种子才有演示数据。 +
+
+ +
+

合规硬边界(再强调)

+

预警默认 pending_review · 仅风控专员 handle 改状态 · 不自动冻户 · 不自动上报监管 · 不改正式 C1~C5。

+ +
+
+

风控专员说「在 Chat 里说一句就自动跑超期升级」,能实现吗?

+
+ + + +
+
+
+ + +
+
+
+
diff --git a/docs/course/jinrong-module-risk/styles.css b/docs/course/jinrong-module-risk/styles.css index 544669c..68d0e39 100644 --- a/docs/course/jinrong-module-risk/styles.css +++ b/docs/course/jinrong-module-risk/styles.css @@ -254,7 +254,8 @@ pre::-webkit-scrollbar { display: none; } padding-top: calc(var(--nav-height) + var(--space-12)); } -.module-content { +.module-content, +.module-inner { max-width: var(--content-width); margin: 0 auto; } diff --git a/docs/course/jinrong-module-shared/_base.html b/docs/course/jinrong-module-shared/_base.html index 5169a95..2a68e76 100644 --- a/docs/course/jinrong-module-shared/_base.html +++ b/docs/course/jinrong-module-shared/_base.html @@ -32,6 +32,7 @@ +
diff --git a/docs/course/jinrong-module-shared/index.html b/docs/course/jinrong-module-shared/index.html index a6dbf98..9ca12a6 100644 --- a/docs/course/jinrong-module-shared/index.html +++ b/docs/course/jinrong-module-shared/index.html @@ -32,6 +32,7 @@ +
@@ -40,10 +41,10 @@

模块 1 · 双栈鉴权

-

JWT 双栈:
deps 与 gateway 各管一线

+

JWT 双栈:
模块鉴权(deps.py)与宿主网关鉴权(gateway/auth_deps.py)各管一线

- 模块 API 走 app/api/deps.py(get_auth_context / get_platform_auth_context); - 宿主 Wave 0 网关走 app/gateway/auth_deps.py。 + 模块 API 走模块鉴权(app/api/deps.py)(get_auth_context / get_platform_auth_context); + 宿主 Wave 0 网关走宿主网关鉴权(app/gateway/auth_deps.py)。 模块禁止 import gateway——指挥 AI 改鉴权时,先确认改的是哪条栈。

@@ -54,7 +55,7 @@ Agent / 对话get_auth_contextJWT 通道必填 + 准入矩阵/api/chat, /api/risk/*, simulate 平台只读get_platform_auth_context不要/api/customers/*, /api/analyst/* - 宿主网关gateway/auth_deps.get_auth_context宿主口径Wave 0 四件套(模块不 import) + 宿主网关宿主网关鉴权(gateway/auth_deps.py)· get_auth_context宿主口径Wave 0 四件套(模块不 import)
@@ -65,7 +66,7 @@
- deps.py · 对话线 + 模块鉴权(deps.py)· 对话线
agent_type = request.headers.get("X-Agent-Type")
 if not agent_type:
     raise ApiError(401, "AUTH_401_MISSING_AGENT_TYPE")
@@ -81,7 +82,7 @@
             

对话线:验完 JWT 还要读 Agent 头,并对照 AGENT_ACCESS_MATRIX。

例如客户 token 不能带 X-Agent-Type: risk,否则 403。

平台线:验 JWT 就放行到归属断言,不问你走哪条 Agent。

-

gateway 栈是宿主合并用,和 deps 双栈并存,别在模块里混 import。

+

宿主网关鉴权(gateway/auth_deps.py)栈是宿主合并用,和模块鉴权(deps.py)双栈并存,别在模块里混 import。

@@ -99,8 +100,8 @@
+ data-explanation-right="平台读 API 用 get_platform_auth_context,文件在模块鉴权(deps.py),与宿主网关鉴权(gateway/auth_deps.py)无关。" + data-explanation-wrong="宿主网关鉴权(gateway/auth_deps.py)是宿主 Wave 0;模块平台路由只 Depends 模块鉴权(deps.py)里的函数。">

customers.py 的 Depends 应该从哪 import?

@@ -124,7 +125,7 @@

模块 2 · 会话记忆

-

session_repository + memory_service:
Redis 窗口,MySQL 权威

+

session_repository + Redis 会话窗口(memory_service.py):
Redis 窗口,MySQL 权威

对话每轮消息同步写 MySQL(权威),同时用 Redis @@ -135,17 +136,17 @@

数据流:用户发第二条消息

-
🚪chat.py
+
🚪对话 HTTP 入口(chat.py)
🗄session_repository
-
⚡memory_service · Redis
+
⚡Redis 会话窗口(memory_service.py)

Key 格式:sess:{agent}:{session_id}:msgs

@@ -161,7 +162,7 @@
- memory_service.py + Redis 会话窗口(memory_service.py)
def window_key(agent_type, session_id):
     return f"sess:{agent_type}:{session_id}:msgs"
 
@@ -212,12 +213,12 @@
 

模块 3 · 防护与审计

-

input_guard 限流注入、
audit_middleware 留痕

+

input_guard 限流注入、
审计中间件(audit_middleware.py)留痕

用户消息进 LLM 前要经过 input_guard; 每个 HTTP 请求经过 - audit_middleware + 审计中间件(audit_middleware.py) 写访问审计。鉴权 403 还会双写 audit_log + input_guard_log(平台线部分跳过 ENUM 限制)。

@@ -267,7 +268,7 @@ @@ -290,7 +291,7 @@
- chat.py + audit_middleware + 对话 HTTP 入口(chat.py)+ 审计中间件(audit_middleware.py)
if not input_guard.check_rate_limit(agent_type, auth.actor_id):
     insert_input_guard_log(..., guard_type=GUARD_RATE_LIMIT)
     raise ApiError(...)
@@ -299,7 +300,7 @@
 if verdict.reject:
     insert_input_guard_log(..., guard_type=verdict.guard_type)
 
-# main.py 挂载
+# 应用入口(main.py)挂载
 async def audit_middleware(request, call_next):
     # INSERT audit_log 单请求访问记录
@@ -308,8 +309,8 @@

先发消息前查频率:同 actor 刷太快就拒,并记限流日志。

再扫内容:注入/超长命中就不调 LLM,直接拒答+留痕。

-

audit_middleware 包在更外层:每个 HTTP 进来都记一条访问审计。

-

deps.deny 鉴权失败也会双写——平台 agent_type 时 input_guard_log 可能跳过 ENUM 限制。

+

审计中间件(audit_middleware.py)包在更外层:每个 HTTP 进来都记一条访问审计。

+

模块鉴权(deps.py).deny 鉴权失败也会双写——平台 agent_type 时 input_guard_log 可能跳过 ENUM 限制。

@@ -335,12 +336,12 @@
-

audit_middleware 和 input_guard 的关系?

+ data-explanation-right="审计中间件(audit_middleware.py)记录每个请求的访问轨迹;input_guard_log 专记输入防护命中。" + data-explanation-wrong="两者不同表、不同触发点;审计中间件(audit_middleware.py)不替 guard 做注入检测。"> +

审计中间件(audit_middleware.py)和 input_guard 的关系?

+
+
+
+

模块 4 · 生产认知

+

审计只 INSERT
Redis 6380 与 fail-open

+

+ 共用底座课讲了双栈鉴权与 input_guard,这模块补生产环境行为和画像/cache 接缝—— + 指挥 AI 改「吊销 fail-closed」或「审计可改」前必看。 +

+ +
+

G-02:审计表只 INSERT

+

audit_log · input_guard_log · risk_suitability_log 等合规留痕表禁止 UPDATE/DELETE。任何「改历史审计」需求都应被拒绝,改为追加更正记录。

+
+ input_guard 命中: fail-fast,不建会话——和 LangGraph 内 fallback 不同,没有 reply 回合入库。 +
+
+ +
+

fail-open 与 debug 双闸门

+
+
+ ① +

Redis 会话窗口 fail-open — Redis 挂了对话仍能回,只是少短期记忆(客服线同样策略)。

+
+
+ ② +

jti 吊销 fail-open — 吊销服务异常时 token 仍可能通过;生产要监控 Redis。

+
+
+ ③ +

debug 头 — 仅 APP_ENV=development 且无 RS256 公钥;生产有公钥时 debug 头失效。

+
+
+ ④ +

Redis 6380 + RESP2 — Docker 映射 6380;Windows 本机 6379 旧 Redis 3 不兼容。

+
+
+
+ +
+

L1/L2 热缓存:谁接了谁没接

+
+
+ 接缝快照 +
客服 profile_maybe_extract → L1 抽槽 ✓
+ProfileHotCache → 部分读 ✓
+L2 Repository / 全量 Redis L1 → 开放项
+宿主 POST /api/chat 非 customer → 未全接 L1 热缓存
+
+
+ 白话 +
+

指挥 AI「全 Agent 统一画像 Redis」前,先对 FRAMEWORK 实现状态表。

+

Bearer 全环境优先——pytest 仍用 dev token,但生产必须 RS256 + 禁 debug 头冒充。

+
+
+
+ +
+
+

运维说「把 audit_log 里错的那条改成正确 decision」,你怎么回?

+
+ + + +
+
+
+ + +
+
+
diff --git a/docs/course/jinrong-module-shared/main.js b/docs/course/jinrong-module-shared/main.js index 7150a69..410548e 100644 --- a/docs/course/jinrong-module-shared/main.js +++ b/docs/course/jinrong-module-shared/main.js @@ -486,13 +486,17 @@ /* ── LAYER TOGGLE ──────────────────────────────────────────── */ window.showLayer = function (layerId, btn) { - const demo = btn ? btn.closest('.layer-demo') : null; + const tab = btn || (window.event && window.event.currentTarget); + if (!tab || !tab.closest) return; + const demo = tab.closest('.layer-demo'); if (!demo) return; - $$('.layer', demo).forEach(l => l.style.display = 'none'); + $$('.layer', demo).forEach(l => { l.style.display = 'none'; }); $$('.layer-tab', demo).forEach(t => t.classList.remove('active')); - const layer = $('#' + layerId); + const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(layerId) : layerId.replace(/[^\w-]/g, ''); + let layer = demo.querySelector('#' + esc); + if (!layer) layer = demo.querySelector('#layer-' + esc); if (layer) layer.style.display = 'block'; - btn.classList.add('active'); + tab.classList.add('active'); }; })(); diff --git a/docs/course/jinrong-module-shared/modules/01-dual-auth.html b/docs/course/jinrong-module-shared/modules/01-dual-auth.html index 5df7f70..6928252 100644 --- a/docs/course/jinrong-module-shared/modules/01-dual-auth.html +++ b/docs/course/jinrong-module-shared/modules/01-dual-auth.html @@ -1,10 +1,10 @@

模块 1 · 双栈鉴权

-

JWT 双栈:
deps 与 gateway 各管一线

+

JWT 双栈:
模块鉴权(deps.py)与宿主网关鉴权(gateway/auth_deps.py)各管一线

- 模块 API 走 app/api/deps.py(get_auth_context / get_platform_auth_context); - 宿主 Wave 0 网关走 app/gateway/auth_deps.py。 + 模块 API 走模块鉴权(app/api/deps.py)(get_auth_context / get_platform_auth_context); + 宿主 Wave 0 网关走宿主网关鉴权(app/gateway/auth_deps.py)。 模块禁止 import gateway——指挥 AI 改鉴权时,先确认改的是哪条栈。

@@ -15,7 +15,7 @@ Agent / 对话get_auth_contextJWT 通道必填 + 准入矩阵/api/chat, /api/risk/*, simulate 平台只读get_platform_auth_context不要/api/customers/*, /api/analyst/* - 宿主网关gateway/auth_deps.get_auth_context宿主口径Wave 0 四件套(模块不 import) + 宿主网关宿主网关鉴权(gateway/auth_deps.py)· get_auth_context宿主口径Wave 0 四件套(模块不 import)
@@ -26,7 +26,7 @@
- deps.py · 对话线 + 模块鉴权(deps.py)· 对话线
agent_type = request.headers.get("X-Agent-Type")
 if not agent_type:
     raise ApiError(401, "AUTH_401_MISSING_AGENT_TYPE")
@@ -42,7 +42,7 @@
             

对话线:验完 JWT 还要读 Agent 头,并对照 AGENT_ACCESS_MATRIX。

例如客户 token 不能带 X-Agent-Type: risk,否则 403。

平台线:验 JWT 就放行到归属断言,不问你走哪条 Agent。

-

gateway 栈是宿主合并用,和 deps 双栈并存,别在模块里混 import。

+

宿主网关鉴权(gateway/auth_deps.py)栈是宿主合并用,和模块鉴权(deps.py)双栈并存,别在模块里混 import。

@@ -60,8 +60,8 @@
+ data-explanation-right="平台读 API 用 get_platform_auth_context,文件在模块鉴权(deps.py),与宿主网关鉴权(gateway/auth_deps.py)无关。" + data-explanation-wrong="宿主网关鉴权(gateway/auth_deps.py)是宿主 Wave 0;模块平台路由只 Depends 模块鉴权(deps.py)里的函数。">

customers.py 的 Depends 应该从哪 import?

diff --git a/docs/course/jinrong-module-shared/modules/02-session-redis.html b/docs/course/jinrong-module-shared/modules/02-session-redis.html index 6eb3d23..d1e3156 100644 --- a/docs/course/jinrong-module-shared/modules/02-session-redis.html +++ b/docs/course/jinrong-module-shared/modules/02-session-redis.html @@ -1,7 +1,7 @@

模块 2 · 会话记忆

-

session_repository + memory_service:
Redis 窗口,MySQL 权威

+

session_repository + Redis 会话窗口(memory_service.py):
Redis 窗口,MySQL 权威

对话每轮消息同步写 MySQL(权威),同时用 Redis @@ -12,17 +12,17 @@

数据流:用户发第二条消息

-
🚪chat.py
+
🚪对话 HTTP 入口(chat.py)
🗄session_repository
-
⚡memory_service · Redis
+
⚡Redis 会话窗口(memory_service.py)

Key 格式:sess:{agent}:{session_id}:msgs

@@ -38,7 +38,7 @@
- memory_service.py + Redis 会话窗口(memory_service.py)
def window_key(agent_type, session_id):
     return f"sess:{agent_type}:{session_id}:msgs"
 
diff --git a/docs/course/jinrong-module-shared/modules/03-guard-audit.html b/docs/course/jinrong-module-shared/modules/03-guard-audit.html
index 7f2d87d..b5000a8 100644
--- a/docs/course/jinrong-module-shared/modules/03-guard-audit.html
+++ b/docs/course/jinrong-module-shared/modules/03-guard-audit.html
@@ -1,12 +1,12 @@
 

模块 3 · 防护与审计

-

input_guard 限流注入、
audit_middleware 留痕

+

input_guard 限流注入、
审计中间件(audit_middleware.py)留痕

用户消息进 LLM 前要经过 input_guard; 每个 HTTP 请求经过 - audit_middleware + 审计中间件(audit_middleware.py) 写访问审计。鉴权 403 还会双写 audit_log + input_guard_log(平台线部分跳过 ENUM 限制)。

@@ -56,7 +56,7 @@ @@ -79,7 +79,7 @@
- chat.py + audit_middleware + 对话 HTTP 入口(chat.py)+ 审计中间件(audit_middleware.py)
if not input_guard.check_rate_limit(agent_type, auth.actor_id):
     insert_input_guard_log(..., guard_type=GUARD_RATE_LIMIT)
     raise ApiError(...)
@@ -88,7 +88,7 @@
 if verdict.reject:
     insert_input_guard_log(..., guard_type=verdict.guard_type)
 
-# main.py 挂载
+# 应用入口(main.py)挂载
 async def audit_middleware(request, call_next):
     # INSERT audit_log 单请求访问记录
@@ -97,8 +97,8 @@

先发消息前查频率:同 actor 刷太快就拒,并记限流日志。

再扫内容:注入/超长命中就不调 LLM,直接拒答+留痕。

-

audit_middleware 包在更外层:每个 HTTP 进来都记一条访问审计。

-

deps.deny 鉴权失败也会双写——平台 agent_type 时 input_guard_log 可能跳过 ENUM 限制。

+

审计中间件(audit_middleware.py)包在更外层:每个 HTTP 进来都记一条访问审计。

+

模块鉴权(deps.py).deny 鉴权失败也会双写——平台 agent_type 时 input_guard_log 可能跳过 ENUM 限制。

@@ -124,12 +124,12 @@
-

audit_middleware 和 input_guard 的关系?

+ data-explanation-right="审计中间件(audit_middleware.py)记录每个请求的访问轨迹;input_guard_log 专记输入防护命中。" + data-explanation-wrong="两者不同表、不同触发点;审计中间件(audit_middleware.py)不替 guard 做注入检测。"> +

审计中间件(audit_middleware.py)和 input_guard 的关系?

+ + +
+
+
+ + +
+
+
+
diff --git a/docs/course/jinrong-module-shared/styles.css b/docs/course/jinrong-module-shared/styles.css index 544669c..68d0e39 100644 --- a/docs/course/jinrong-module-shared/styles.css +++ b/docs/course/jinrong-module-shared/styles.css @@ -254,7 +254,8 @@ pre::-webkit-scrollbar { display: none; } padding-top: calc(var(--nav-height) + var(--space-12)); } -.module-content { +.module-content, +.module-inner { max-width: var(--content-width); margin: 0 auto; } diff --git a/docs/course/jinrong-overview/_base.html b/docs/course/jinrong-overview/_base.html index 8656112..5bed6c0 100644 --- a/docs/course/jinrong-overview/_base.html +++ b/docs/course/jinrong-overview/_base.html @@ -34,6 +34,8 @@ + +
diff --git a/docs/course/jinrong-overview/index.html b/docs/course/jinrong-overview/index.html index 3b47008..4dc4faf 100644 --- a/docs/course/jinrong-overview/index.html +++ b/docs/course/jinrong-overview/index.html @@ -34,6 +34,8 @@ + +
@@ -68,14 +70,14 @@
🖥浏览器 web/
-
🚪FastAPI chat.py
-
🤖customer_service
+
🚪四 Agent 对话 HTTP 入口(chat.py)四 Agent 对话 HTTP 入口
+
🤖登录客户 14 节点编排(customer_service.py)登录客户 14 节点编排
🗄Core + Milvus

点击「下一步」看数据怎么流

@@ -98,7 +100,7 @@ 白话

前端每次调「对话 API」都要带两样东西:登录令牌 + 你正在用哪条 Agent 线。

-

后端据此决定走客服 LangGraph 还是通用 agent_service,以及能不能过门禁。

+

后端据此决定走客服 LangGraph 还是通用 顾问通用 tool→llm→guard 编排(agent_service.py),以及能不能过门禁。

@@ -142,11 +144,11 @@

客户财富 Agent

-

服务登录客户本人:查持仓/流水、产品规则 RAG、合规话术。代码:customer_service.py

+

服务登录客户本人:查持仓/流水、产品规则 RAG、合规话术。代码:登录客户 14 节点编排(customer_service.py)登录客户 14 节点编排

代理人助手

-

服务理财师:聊名下客户、公开知识库。代码:agent_service + advisor 分支

+

服务理财师:聊名下客户、公开知识库。代码:顾问通用 tool→llm→guard 编排(agent_service.py)顾问通用 tool→llm→guard 编排 + advisor 分支

数据分析 Agent

@@ -175,14 +177,14 @@ @@ -211,12 +213,12 @@

代码地图(改功能前先找门)

app/
-
main.py — 挂载所有路由
-
api/chat.py — 四 Agent 对话入口
-
api/analyst.py — 问数四件套
-
api/risk.py + simulate.py — 风控 REST
-
service/customer_service.py — 客户 14 节点图
-
service/agent_service.py — 顾问/风控/分析对话图
+
FastAPI 应用入口(main.py)FastAPI 应用入口 — 挂载所有路由
+
四 Agent 对话 HTTP 入口(chat.py)四 Agent 对话 HTTP 入口 — app/api/chat.py
+
问数 REST 路由(analyst.py)问数 REST 路由 — app/api/analyst.py
+
风控 REST(risk.py)风控 REST — app/api/risk.py · 模拟交易 HTTP 入口(simulate.py)模拟交易 HTTP 入口 — app/api/simulate.py
+
登录客户 14 节点编排(customer_service.py)登录客户 14 节点编排 — app/service/customer_service.py
+
顾问通用 tool→llm→guard 编排(agent_service.py)顾问通用 tool→llm→guard 编排 — app/service/agent_service.py
web/
src/pages/* — 四角色页面
src/api/*.ts — 调后端(注意鉴权头差异)
@@ -225,18 +227,18 @@
+ data-explanation-right="客户线已独立:同步 POST /api/chat 和 SSE stream 都走 登录客户 14 节点编排(customer_service.py),不是 顾问通用 tool→llm→guard 编排(agent_service.py)那条通用图。" + data-explanation-wrong="customer 在 四 Agent 对话 HTTP 入口(chat.py)里有专门 if 分支,不会和 advisor 共用同一张 LangGraph。">

客户打开「客户助手」时,编排跑在哪?

@@ -275,7 +277,7 @@
- CODE · deps.py + CODE · 模块鉴权工厂(deps.py)模块鉴权工厂
agent_type = request.headers.get("X-Agent-Type")
 if not agent_type:
     raise ApiError(401, "AUTH_401_MISSING_AGENT_TYPE")
@@ -300,7 +302,7 @@
         
STAFF-20001分析员
STAFF-30001风控 + risk_demo
-

改 jwt_service.py 里角色后,必须重新登录 才会拿到新 JWT。

+

改 宿主 JWT 签发/验签(jwt_service.py)宿主 JWT 签发/验签 里角色后,必须重新登录 才会拿到新 JWT。

课程生成于 2026-09-09 · 对照仓库 docs/memory/MEMORY.md · 打开 index.html 即可离线浏览

+
+
+
+

模块 6 · 硬阀门

+

改代码前先念五条
踩了就是合规事故

+

+ 四 Agent 可以各自演进,但有几条是全项目硬阀门——不是「最好遵守」,是写进合规文档、测试也会卡住的规则。 + 指挥 AI 加功能前,先用这张表判断「能不能做」。 +

+ +
+

五条硬阀门(2026-09 快照)

+
+
+

① Core 正式 C1~C5 不可被画像覆盖

+

L0 风评在 jinrong_core 是权威;L1 画像 enrich 只能辅助,不能改正式等级。

+
+
+

② 审计表只 INSERT

+

audit_log / input_guard_log 等只追加、不 UPDATE/DELETE——留痕不可篡改。

+
+
+

③ 仅 R-02 可阻断交易

+

适当性不匹配可在网关拦单;R-01/R-03 只生成预警,不自动冻户。

+
+
+

④ 代理人草稿不外发

+

顾问 Agent 产出是草稿;没有「一键发给客户」API,须人工复核后才能外用。

+
+
+

⑤ 四 Agent 不互调 LLM

+

跨角色协作走 L1/L2/L3 画像表与 risk_alert,禁止 A 调 B 的 LangGraph。

+
+
+
+ 还有技术硬阀门: Core 模拟库除模拟交易网关外只读;Streamlit 禁止引入(MEMORY 第 3 节)。 +
+
+ +
+

数据流:谁有权改什么

+
+
+
📖Core 只读
+
💳模拟网关
+
⚠预警台账
+
📋审计总账
+
+

点击「下一步」看写入边界

+
+ + +
+
+
+ +
+
+
+

AI 建议「大额预警命中就自动冻结账户」,你怎么回复?

+
+ + + +
+
+
+ + +
+ +
+
+
+
+
+

模块 7 · 未实现对照

+

文档写了 ≠ 代码有
2026-09 快照表

+

+ 指挥 AI 时最容易把「需求规格里的 P0」当成「仓库已经做完」。 + 下表是课程专用快照(不写入项目记忆文件),对照 merger 分支实际代码。 +

+ +
+

已实现(别重复造轮子)

+
+
✓平台 API v0.1 · 四 Agent 对话 + customer SSE
+
✓问数 S3 + dashboard/assets · 风控台账 + 模拟交易
+
✓客服 14 节点 + 游客 9 节点 · R-02 网关阻断
+
+
+ +
+

未实现 / 演示停在某层(指挥 AI 前先查)

+
+
+

空壳 API

+

知识库空壳 API(knowledge.py)· 管理空壳 API(admin.py)空壳 API — T-21 拍板一期只做脚本入库,无上传/重建端点。

+
+
+

T-20 顾问工作台

+

A-03 话术草稿复核 · A-05 合规巡检台 · A-06 跟进草稿入库 — prompt 有约束,无前端/表流程。

+
+
+

前端未接线

+

AML scan UI · 平台适当性按钮 · 风控台账高级筛选 — 后端部分已有,web/ 未全接。

+
+
+

运维 cron 无 UI

+

FR-9 超期升级 cron(escalation_scan.py)FR-9 cron · FR-10 行为扫描 cron(agent_behavior_scan.py)FR-10 cron — 引擎在,须手动跑脚本。

+
+
+

演示边界

+

转人工 = API 字段 + 橙色 Tag · debug 头仅 dev · 脱敏默认关 · 无真呼叫中心排队。

+
+
+

画像热缓存

+

L1 Redis 全量热缓存 · L2 Repository — 客服线部分已接,宿主/顾问未全接。

+
+
+
+ 注意: REQUIREMENTS.md 部分 Wave 条目仍标「未做」,与代码不同步——以本表 + 各模块深潜课为准。 +
+
+ +
+

鉴权三线速查(跨课总表)

+
+
+ 三条入口 +
对话 Chat    → get_auth_context + X-Agent-Type
+问数/平台    → get_platform_auth_context(无 Agent 头)
+风控 REST    → get_auth_context + X-Agent-Type: risk
+
+
+ 白话 +
+

401 MISSING_AGENT_TYPE:Chat 忘了带头,不是 JWT 坏了。

+

问数页加 X-Agent-Type: analyst 是错方向——会走错鉴权栈。

+

持仓页用平台鉴权,不要 Agent 头——和 Chat 不是一套。

+
+
+
+ +
+
+

AI 说「补全 knowledge 上传 API 就能用 RAG」,你怎么回?

+
+ + + +
+
+
+ + +
+ + +
+
diff --git a/docs/course/jinrong-overview/main.js b/docs/course/jinrong-overview/main.js index 7150a69..410548e 100644 --- a/docs/course/jinrong-overview/main.js +++ b/docs/course/jinrong-overview/main.js @@ -486,13 +486,17 @@ /* ── LAYER TOGGLE ──────────────────────────────────────────── */ window.showLayer = function (layerId, btn) { - const demo = btn ? btn.closest('.layer-demo') : null; + const tab = btn || (window.event && window.event.currentTarget); + if (!tab || !tab.closest) return; + const demo = tab.closest('.layer-demo'); if (!demo) return; - $$('.layer', demo).forEach(l => l.style.display = 'none'); + $$('.layer', demo).forEach(l => { l.style.display = 'none'; }); $$('.layer-tab', demo).forEach(t => t.classList.remove('active')); - const layer = $('#' + layerId); + const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(layerId) : layerId.replace(/[^\w-]/g, ''); + let layer = demo.querySelector('#' + esc); + if (!layer) layer = demo.querySelector('#layer-' + esc); if (layer) layer.style.display = 'block'; - btn.classList.add('active'); + tab.classList.add('active'); }; })(); diff --git a/docs/course/jinrong-overview/modules/01-intro.html b/docs/course/jinrong-overview/modules/01-intro.html index 266d3d8..49168fa 100644 --- a/docs/course/jinrong-overview/modules/01-intro.html +++ b/docs/course/jinrong-overview/modules/01-intro.html @@ -27,14 +27,14 @@
🖥浏览器 web/
-
🚪FastAPI chat.py
-
🤖customer_service
+
🚪四 Agent 对话 HTTP 入口(chat.py)四 Agent 对话 HTTP 入口
+
🤖登录客户 14 节点编排(customer_service.py)登录客户 14 节点编排
🗄Core + Milvus

点击「下一步」看数据怎么流

@@ -57,7 +57,7 @@ 白话

前端每次调「对话 API」都要带两样东西:登录令牌 + 你正在用哪条 Agent 线。

-

后端据此决定走客服 LangGraph 还是通用 agent_service,以及能不能过门禁。

+

后端据此决定走客服 LangGraph 还是通用 顾问通用 tool→llm→guard 编排(agent_service.py),以及能不能过门禁。

diff --git a/docs/course/jinrong-overview/modules/02-actors.html b/docs/course/jinrong-overview/modules/02-actors.html index 0b9cafe..6c5d4a2 100644 --- a/docs/course/jinrong-overview/modules/02-actors.html +++ b/docs/course/jinrong-overview/modules/02-actors.html @@ -12,11 +12,11 @@

客户财富 Agent

-

服务登录客户本人:查持仓/流水、产品规则 RAG、合规话术。代码:customer_service.py

+

服务登录客户本人:查持仓/流水、产品规则 RAG、合规话术。代码:登录客户 14 节点编排(customer_service.py)登录客户 14 节点编排

代理人助手

-

服务理财师:聊名下客户、公开知识库。代码:agent_service + advisor 分支

+

服务理财师:聊名下客户、公开知识库。代码:顾问通用 tool→llm→guard 编排(agent_service.py)顾问通用 tool→llm→guard 编排 + advisor 分支

数据分析 Agent

@@ -45,14 +45,14 @@ @@ -81,12 +81,12 @@

代码地图(改功能前先找门)

app/
-
main.py — 挂载所有路由
-
api/chat.py — 四 Agent 对话入口
-
api/analyst.py — 问数四件套
-
api/risk.py + simulate.py — 风控 REST
-
service/customer_service.py — 客户 14 节点图
-
service/agent_service.py — 顾问/风控/分析对话图
+
FastAPI 应用入口(main.py)FastAPI 应用入口 — 挂载所有路由
+
四 Agent 对话 HTTP 入口(chat.py)四 Agent 对话 HTTP 入口 — app/api/chat.py
+
问数 REST 路由(analyst.py)问数 REST 路由 — app/api/analyst.py
+
风控 REST(risk.py)风控 REST — app/api/risk.py · 模拟交易 HTTP 入口(simulate.py)模拟交易 HTTP 入口 — app/api/simulate.py
+
登录客户 14 节点编排(customer_service.py)登录客户 14 节点编排 — app/service/customer_service.py
+
顾问通用 tool→llm→guard 编排(agent_service.py)顾问通用 tool→llm→guard 编排 — app/service/agent_service.py
web/
src/pages/* — 四角色页面
src/api/*.ts — 调后端(注意鉴权头差异)
@@ -95,18 +95,18 @@
+ data-explanation-right="客户线已独立:同步 POST /api/chat 和 SSE stream 都走 登录客户 14 节点编排(customer_service.py),不是 顾问通用 tool→llm→guard 编排(agent_service.py)那条通用图。" + data-explanation-wrong="customer 在 四 Agent 对话 HTTP 入口(chat.py)里有专门 if 分支,不会和 advisor 共用同一张 LangGraph。">

客户打开「客户助手」时,编排跑在哪?

diff --git a/docs/course/jinrong-overview/modules/03-auth.html b/docs/course/jinrong-overview/modules/03-auth.html index 33a15d1..4a9c727 100644 --- a/docs/course/jinrong-overview/modules/03-auth.html +++ b/docs/course/jinrong-overview/modules/03-auth.html @@ -26,7 +26,7 @@
- CODE · deps.py + CODE · 模块鉴权工厂(deps.py)模块鉴权工厂
agent_type = request.headers.get("X-Agent-Type")
 if not agent_type:
     raise ApiError(401, "AUTH_401_MISSING_AGENT_TYPE")
@@ -51,7 +51,7 @@
         
STAFF-20001分析员
STAFF-30001风控 + risk_demo
-

改 jwt_service.py 里角色后,必须重新登录 才会拿到新 JWT。

+

改 宿主 JWT 签发/验签(jwt_service.py)宿主 JWT 签发/验签 里角色后,必须重新登录 才会拿到新 JWT。

+
+

模块 6 · 硬阀门

+

改代码前先念五条
踩了就是合规事故

+

+ 四 Agent 可以各自演进,但有几条是全项目硬阀门——不是「最好遵守」,是写进合规文档、测试也会卡住的规则。 + 指挥 AI 加功能前,先用这张表判断「能不能做」。 +

+ +
+

五条硬阀门(2026-09 快照)

+
+
+

① Core 正式 C1~C5 不可被画像覆盖

+

L0 风评在 jinrong_core 是权威;L1 画像 enrich 只能辅助,不能改正式等级。

+
+
+

② 审计表只 INSERT

+

audit_log / input_guard_log 等只追加、不 UPDATE/DELETE——留痕不可篡改。

+
+
+

③ 仅 R-02 可阻断交易

+

适当性不匹配可在网关拦单;R-01/R-03 只生成预警,不自动冻户。

+
+
+

④ 代理人草稿不外发

+

顾问 Agent 产出是草稿;没有「一键发给客户」API,须人工复核后才能外用。

+
+
+

⑤ 四 Agent 不互调 LLM

+

跨角色协作走 L1/L2/L3 画像表与 risk_alert,禁止 A 调 B 的 LangGraph。

+
+
+
+ 还有技术硬阀门: Core 模拟库除模拟交易网关外只读;Streamlit 禁止引入(MEMORY 第 3 节)。 +
+
+ +
+

数据流:谁有权改什么

+
+
+
📖Core 只读
+
💳模拟网关
+
⚠预警台账
+
📋审计总账
+
+

点击「下一步」看写入边界

+
+ + +
+
+
+ +
+
+
+

AI 建议「大额预警命中就自动冻结账户」,你怎么回复?

+
+ + + +
+
+
+ + +
+ +
+
+ diff --git a/docs/course/jinrong-overview/modules/07-not-implemented.html b/docs/course/jinrong-overview/modules/07-not-implemented.html new file mode 100644 index 0000000..587e6cd --- /dev/null +++ b/docs/course/jinrong-overview/modules/07-not-implemented.html @@ -0,0 +1,97 @@ +
+
+

模块 7 · 未实现对照

+

文档写了 ≠ 代码有
2026-09 快照表

+

+ 指挥 AI 时最容易把「需求规格里的 P0」当成「仓库已经做完」。 + 下表是课程专用快照(不写入项目记忆文件),对照 merger 分支实际代码。 +

+ +
+

已实现(别重复造轮子)

+
+
✓平台 API v0.1 · 四 Agent 对话 + customer SSE
+
✓问数 S3 + dashboard/assets · 风控台账 + 模拟交易
+
✓客服 14 节点 + 游客 9 节点 · R-02 网关阻断
+
+
+ +
+

未实现 / 演示停在某层(指挥 AI 前先查)

+
+
+

空壳 API

+

知识库空壳 API(knowledge.py)· 管理空壳 API(admin.py)空壳 API — T-21 拍板一期只做脚本入库,无上传/重建端点。

+
+
+

T-20 顾问工作台

+

A-03 话术草稿复核 · A-05 合规巡检台 · A-06 跟进草稿入库 — prompt 有约束,无前端/表流程。

+
+
+

前端未接线

+

AML scan UI · 平台适当性按钮 · 风控台账高级筛选 — 后端部分已有,web/ 未全接。

+
+
+

运维 cron 无 UI

+

FR-9 超期升级 cron(escalation_scan.py)FR-9 cron · FR-10 行为扫描 cron(agent_behavior_scan.py)FR-10 cron — 引擎在,须手动跑脚本。

+
+
+

演示边界

+

转人工 = API 字段 + 橙色 Tag · debug 头仅 dev · 脱敏默认关 · 无真呼叫中心排队。

+
+
+

画像热缓存

+

L1 Redis 全量热缓存 · L2 Repository — 客服线部分已接,宿主/顾问未全接。

+
+
+
+ 注意: REQUIREMENTS.md 部分 Wave 条目仍标「未做」,与代码不同步——以本表 + 各模块深潜课为准。 +
+
+ +
+

鉴权三线速查(跨课总表)

+
+
+ 三条入口 +
对话 Chat    → get_auth_context + X-Agent-Type
+问数/平台    → get_platform_auth_context(无 Agent 头)
+风控 REST    → get_auth_context + X-Agent-Type: risk
+
+
+ 白话 +
+

401 MISSING_AGENT_TYPE:Chat 忘了带头,不是 JWT 坏了。

+

问数页加 X-Agent-Type: analyst 是错方向——会走错鉴权栈。

+

持仓页用平台鉴权,不要 Agent 头——和 Chat 不是一套。

+
+
+
+ +
+
+

AI 说「补全 knowledge 上传 API 就能用 RAG」,你怎么回?

+
+ + + +
+
+
+ + +
+ + +
+
+
diff --git a/docs/course/jinrong-overview/styles.css b/docs/course/jinrong-overview/styles.css index 544669c..68d0e39 100644 --- a/docs/course/jinrong-overview/styles.css +++ b/docs/course/jinrong-overview/styles.css @@ -254,7 +254,8 @@ pre::-webkit-scrollbar { display: none; } padding-top: calc(var(--nav-height) + var(--space-12)); } -.module-content { +.module-content, +.module-inner { max-width: var(--content-width); margin: 0 auto; } diff --git a/docs/memory/FLOW.md b/docs/memory/FLOW.md index e0aaa40..fe3171d 100644 --- a/docs/memory/FLOW.md +++ b/docs/memory/FLOW.md @@ -39,7 +39,7 @@ uvicorn app.main:app --reload GET http://127.0.0.1:8000/health → {"status":"ok"} cd web && npm run dev → 5173 代理 8000 - python -m pytest → 730 passed 0 skipped + python -m pytest → 786 passed 1 skipped RBAC 联调账号:scripts/dev/rbac-seed-reference.md ``` @@ -54,11 +54,11 @@ RBAC 联调账号:scripts/dev/rbac-seed-reference.md Client → Gateway(JWT/RBAC) → api/chat → agent_service(LangGraph) → Tools → 存储 → 响应 + audit_log ``` -当前:**风控全链路 + Wave 0 共用底座 + 代销平台 v0.1 + AL-09 + 客服 S2 + 前端 P0 主链路(2026-09-09,`merger`)**。**测试基线:`python -m pytest` → 730 passed** · **`web/` 19 Vitest 绿**。 +当前:**风控全链路 + Wave 0 共用底座 + 代销平台 v0.1 + AL-09 + 客服 S2 Wave3 部分 + 前端 P0(2026-09-10,`merger`)**。**测试基线:`python -m pytest` → 786 passed 1 skipped** · **`web/` 19 Vitest 绿**。 -**下一步(统筹 P1):** analytics 问数页 · 接口契约发群 · customer Chat SSE(后端 stream 未接 customer_service)。 +**下一步(统筹 P1):** 接口契约发群 · 风控演示运维脚本 · 画像 Redis L1/L2 热读扩展 · customer 阈值 push(未做)。 -**本机已就位状态(2026-09-09):** `.env` 含 `REDIS_URL=redis://127.0.0.1:6380/0`(Docker Redis 7);pytest **730 绿**;前端 Chat/平台页已真接。**演示/走查后按《演示SOP-风控模块.md》§2 重灌**。 +**本机已就位状态(2026-09-10):** `.env` 含 `REDIS_URL=redis://127.0.0.1:6380/0`;pytest **786 绿**;客户 Chat SSE 已接 `prepare_customer_stream`。 **本机已知坑:** `mysql.exe` 不在 PATH;`reset.ps1` 自动化用 `MYSQL_PWD`;**6379 常被 Windows Redis 3.x 占用** → 项目 Docker 映射 **6380**;Milvus 中文路径需英文 `MILVUS_URI`;重装环境后 `python -c "import jose, redis"` 自检。 @@ -90,6 +90,18 @@ Tool(tool_service.run_tool:白名单 → 归属校验 → 执行 → agent_t 落库:agent_message、agent_tool_call(T-04 已接:success/blocked/error 全留痕,message_id 一期 NULL)、audit_log(http_access 已接,同 trace_id) ``` +**客户线并行分支(`X-Agent-Type: customer` · 2026-09-10):** + +```text +api/chat 或 /stream → auth_adapter → customer_service.run_customer_chat(14 节点 LangGraph) + → intent_classify(关键词优先:数据查询 > save_note > nav/匹配) + → core_ro_tool(持仓/流水/风评/适当性/净值)或 fin_* RAG 或 reject/transfer + → interpret/generate/chitchat → sanitize_postprocess.finalize_sanitized_reply(1B) + → profile_maybe_extract → threshold_service.sync(C-04 摘要写 config) + → query_holdings 末尾 build_threshold_alert(达线追加提醒 + customer_notify_log) +SSE:prepare_customer_stream 整图跑完再 _chunk_reply_text 推块 +``` + ------ ## 3. 主链路 · 知识库入库 diff --git a/docs/memory/FRAMEWORK.md b/docs/memory/FRAMEWORK.md index 0ffe0a3..68a1c2d 100644 --- a/docs/memory/FRAMEWORK.md +++ b/docs/memory/FRAMEWORK.md @@ -35,7 +35,7 @@ | 模块 | 职责 | 依赖 | 代码状态 | | --- | --- | --- | --- | | Agent Gateway / Auth SDK | JWT、RBAC、归属校验 | Redis、MySQL customer_advisor_rel | **已实现(T-01 + AL-09)**:模块 `service/auth_service.py` + `api/deps.py`;宿主 `gateway/` 四件套并存;`/api/auth/login` 统一走 `issue_dev_token`;S2 接缝 `auth_adapter.module_auth_from_host` | -| 客户财富 Agent | L1 画像、事实查询、阈值提醒 | Core RO、Milvus 产品库 | **S2 已接线** · **Chat 同步+SSE**(stream → `prepare_customer_stream`) +| 客户财富 Agent | L1 画像、事实查询、阈值提醒 | Core RO、Milvus 产品库 | **S2 + Wave3 部分(2026-09-10)**:Chat SSE · 1B/R1 · **C-04 阈值** · **C-05 nav** · **C-11 匹配** · 13 槽 L1 · **786 pytest** | 代理人助手 Agent | L2 画像、RAG、草稿 | L1 只读、Milvus | 空壳 service(chat 骨架已通) | | 数据分析 Agent | NL→SQL→解读 | Core RO、画像只读 | **S3+P2(2026-09-09)**:问数 · dashboard metrics · 资产沉淀 API/UI · 口径种子 SQL | | 风控监测 Agent | 预警、L3、R-02 适当性 | 交易事件、AML 名单 | **已实现 B1~B9b + C1~C6(FR-1~10)**:事件线 + 对话线 + 集中度/时效升级/代理人行为链;**AL-09 已并入 `merger` 分支** | diff --git a/docs/memory/ITERATION.md b/docs/memory/ITERATION.md index d942ff0..f6fcadb 100644 --- a/docs/memory/ITERATION.md +++ b/docs/memory/ITERATION.md @@ -21,4 +21,5 @@ | 2026-09-08 | P1 环境收口(续):补建 `risk_aml_list` + AML 种子 · `fix_utf8_seed.py` 修复 Windows 中文乱码 · 集成测试 11 例恢复 · **530 passed 0 skipped** | 真库集成 skip/失败 | MEMORY / ENVIRONMENT / FLOW / bootstrap 脚本 | | 2026-09-09 | **四角色收益/洞察 Dashboard 需求 v2**(锚点修正·四角色 Demo 数·风控 Empty·Hook/联调) | 用户再完善需求 | wealth-dashboard spec v2 / frontend-p0 §2.5 / TODO | | 2026-09-09 | **客服 Agent 代码合并**(`customer-service-agent` 只增不盖) | 用户要求合并上传内容 | `客服Agent-合并说明.md` · Wave1~5 新增文件 · CS-C-11 迁移 SQL · wave 测试暂 ignore | -| 2026-09-09 | **L3 Redis cache-aside 读路径补齐** + 风控/演示测试数据 TODO 拆项 | 画像 Redis 只做写侧 DEL · 测未接接口缺种子 | profile_l3 / redis_gateway / TODO | +| 2026-09-10 | **客服 Wave3 批次**:1B 抽到 `sanitize_postprocess`(visitor 对齐)· C-04 `threshold_service` · C-05 `nav_query` · C-11/C-07/C-08 意图/槽位 · keyword 优先级修复 · **786 pytest** | 用户「全做」批次收尾 | MEMORY / REQUIREMENTS / FLOW / TODO / 课程模块 7 / TEST-LOG v1.1 | +| 2026-09-10 | **客服合规拍板落地**:1B sanitize 分治 · R1 L1 Top-K · MEMORY/课程/测试包 | 误转人工 + 画像膨胀 | MEMORY §3 · TEST-2026-09-10-CS-001 · 776 pytest | diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md index 30e699b..f70f81e 100644 --- a/docs/memory/MEMORY.md +++ b/docs/memory/MEMORY.md @@ -9,7 +9,7 @@ **项目是什么:** 金融四 Agent(客户财富 / 代理人 / 数据分析 / 风控)共用数据层与合规底座;**不**互调 LLM,跨 Agent 走 L1/L2/L3 画像与预警表。 -**当前进度:** 需求与表设计已定 · **风控 + 平台 API + 客服 S2 + 数据分析 S3/P2** · **customer Chat SSE**(stream 分流 customer_service)· **STAFF-30001 含 risk_demo**(模拟交易演示)· **774+ pytest** · **19 Vitest** · **Redis @ 6380** +**当前进度:** 需求与表设计已定 · **风控 + 平台 API + 客服 S2 Wave3 部分落地 + 数据分析 S3/P2** · **customer Chat SSE** · **C-04/C-05/C-11 等** · **786 pytest** · **19 Vitest** · **Redis @ 6380** **工作分支:** 团队开发在 **`merger`**;历史 `risk-control-agent` 交付冻结。 @@ -41,11 +41,13 @@ | `app/utils/` | **基本就绪** | trace(trace_id+request_id 双 contextvar)/ desensitize / db(引擎工厂)/ response(统一错误体+4xx/500 handler)/ exceptions(含 ApiError)已实现;logger 占位 | | `app/config/database.py` | **已实现** | MySQL 双引擎 + **Redis 单例**(`REDIS_URL` · **RESP2 `protocol=2`** 兼容 Docker Redis 7 / 旧 Windows Redis 3) | | `app/config/settings.py` | **已实现** | 双库 + risk_* 阈值 + JWT + **customer/visitor/profile** 字段 + `kb_root_dir` | -| `app/service/customer_service.py` `visitor_service.py` | **已实现(S2)** | 客服 14 节点 · 游客 9 节点 LangGraph;RAG 走 **fin_* 三库**;Redis 记忆 **fail-open** | +| `app/service/customer_service.py` `visitor_service.py` | **已实现(S2 + Wave3 部分)** | 客服 14 节点 · **C-04 阈值** · **C-05 nav_query** · **C-11 匹配说明** · 游客 9 节点;共用 `sanitize_postprocess.finalize_sanitized_reply`(1B);RAG **fin_* 三库** | +| `app/service/threshold_service.py` | **已实现(C-04)** | L1 `threshold_pref_summary` → `customer_threshold_config`;持仓查询加权盈亏 vs 阈值 → `customer_notify_log` | +| `app/utils/sanitize_postprocess.py` | **已实现(1B 共用)** | 数据查询 intent(含 nav_query)违禁 → 回退 fact_text;RAG/闲聊 → COMPLIANCE_REJECT;均不自动 transfer | | `scripts/core/*.sql` + `reset.ps1` | **已实现** | Core 模拟库 DDL + 种子 | | `scripts/agent/` `scripts/demo/` `scripts/dev/` | **已实现** | AML 种子 + 演示数据 + `run_sql_file.py` + **`start-redis.ps1`**(Docker Redis 优先)+ issue_dev_token | | `scripts/sync/*.py` | **已实现** | 归属同步 + Neo4j 全图 | -| `tests/` | **已实现** | **774 用例** 0 skipped(含 Wave6 analyst + customer stream 测试) +| `tests/` | **已实现** | **786 用例** 1 skipped(Wave3 customer + threshold + sanitize_postprocess + 1B/R1) | `docs/course/` | **交互课程集(2026-09-09)** | 打开 `docs/course/index.html` 导览中心 · 总览 + 7 模块深潜课 | | `docs/PRD/PRD-风控监测Agent.md` | **已冻结(v1.1)** | 风控 PRD v1.0 + v1.1 追加 FR-8/9/10(§4A)+ 规则表附录 | | `docs/项目框架设计/实现方案-风控追加需求v1.1-C4C6.md` | **已定稿** | C4~C6 编码依据(经独立 AI 评审修订闭环);分支/进度速览另见项目根 `交接文档.md` | @@ -70,7 +72,7 @@ (风控演示:scripts/demo/prepare_risk_demo.sql,reset 后重跑) 6. python scripts/sync/sync_advisor_rel.py && python scripts/sync/sync_neo4j.py 7. `docker compose up -d redis`(或 `.\scripts\dev\start-redis.ps1`)→ **REDIS_URL=redis://127.0.0.1:6380/0**(Docker Redis 7;避开本机 Windows Redis 占 6379) -8. uvicorn … · python -m pytest(**774 绿**);问数冒烟 · 可选 `mysql … < scripts/agent/seed-analyst-metric-dict.sql` +8. uvicorn … · python -m pytest(**786 绿**);问数冒烟 · 可选 `mysql … < scripts/agent/seed-analyst-metric-dict.sql` ``` **AL-09 合并后架构(一句话):** 宿主 `gateway/` + 模块 `deps.py` **双栈并存**;对外登录/token **统一**;chat/risk 均走模块鉴权;接缝 S2 用 `auth_adapter`。 @@ -153,6 +155,19 @@ audit_log 等审计表(只 INSERT) 画像不得覆盖 L0 正式测评 ``` +**客服合规拍板(2026-09-10 · 已落地):** + +| ID | 决策 | 代码落点 | +| --- | --- | --- | +| **1B** | 数据查询 intent(持仓/流水/风评/适当性/**净值**)经 `sanitize_reply` 命中违禁词 → **回退 `fact_text`,不转人工**;RAG/闲聊命中 → `COMPLIANCE_REJECT`,**也不自动 transfer**;**customer + visitor 共用** | `app/utils/sanitize_postprocess.py` · `finalize_sanitized_reply` | +| **R1** | L1 `product_preferences` / `excluded_products` 合并时写 `items_meta`;注入 prompt **Top-K + 时间衰减 + TTL**(K=3 · TTL=90d · 半衰 30d) | `profile_service` · `settings.profile_preference_*` | +| **C-04** | 用户口述「亏 X% 提醒我」→ L1 摘要 + `customer_threshold_config`;**查持仓时**组合加权盈亏达线 → 追加提醒 + `customer_notify_log`(非 push/cron) | `threshold_service` · `core_ro_tool.query_holdings` | +| **C-05** | 「最新净值/单位净值」→ Core `get_latest_nav`(快照,非实时盘口);**「实时净值」仍 reject** | `core_ro_tool.query_product_nav` · intent `nav_query` | +| **C-11** | 「我能买什么/匹配产品」→ `suitability_check` + Core 可购列表;**「推荐稳赚/买什么好」仍 reject** | `customer_prompts._ELIGIBLE_PRODUCTS_KW` | +| **C-07** | 风评查询走 Core;**「重新测评/重做风评」** → 引导 App/网点(Agent 内不做问卷) | `customer_prompts._RISK_KW` | +| **C-08** | 仅 L1 槽位 `investment.allocation_target`(13 槽);**无自动偏离检测/调仓** | `profile_slots.py` | +| **L0 优先** | 抽槽与 L0 撞车**永远听 L0**;L1 只 enrich 措辞 | `profile_slots` D7 | + ------ ## 4. 入口与运行 @@ -165,7 +180,7 @@ Core 模拟:scripts/core/reset.ps1 · 文档 docs/项目框架设计/Core模 依赖:requirements.txt(LangGraph + langchain-core/openai + FastAPI + SQLAlchemy) 启动:uvicorn app.main:app --reload → GET /health Redis:`docker compose up -d redis` · `REDIS_URL=redis://127.0.0.1:6380/0` · `scripts/dev/start-redis.ps1` -测试:python -m pytest(**730 绿**;集成需本机 MySQL + `risk_aml_list` + `prepare_risk_demo.sql`) +测试:python -m pytest(**786 绿**;集成需本机 MySQL + `risk_aml_list` + `prepare_risk_demo.sql`) 前端:cd web && npm run dev · npm run build/test/lint(**19** Vitest) 运维/演示脚本:scripts/demo/subscribe_alerts.py(订阅推送演示)· rebuild_alerts.py TRD-xxx(引擎异常补偿重放) JWT 联调:python scripts/dev/issue_dev_token.py --sub STAFF-30001 --roles risk_officer(+ Authorization: Bearer + X-Agent-Type) @@ -214,6 +229,6 @@ RBAC 联调账号:scripts/dev/rbac-seed-reference.md 2. 改动属于 api / service / tool / repository 哪一层? 3. 是否需 customer_id 归属与 JWT RBAC? 4. Core 是模拟库只读还是 agent 库读写? -5. 如何验证?(`python -m pytest` **730 绿** · `docker compose up -d redis` + `.env` **6380** · uvicorn + `/health` · `web/` build/test · 平台 `/api/customers/*` · 登录页游客试聊 · 客户助手 Chat) +5. 如何验证?(`python -m pytest` **786 绿** · Redis **6380** · uvicorn + `/health` · 客户助手 Chat SSE · 测试包 `docs/memory/tests/2026-09-10-customer-1b-r1/`) 大任务:FRAMEWORK/FLOW 与实现状态不符时先更新 memory 再编码(用户确认跳过除外)。 diff --git a/docs/memory/REQUIREMENTS.md b/docs/memory/REQUIREMENTS.md index b351c81..215890a 100644 --- a/docs/memory/REQUIREMENTS.md +++ b/docs/memory/REQUIREMENTS.md @@ -56,6 +56,13 @@ | ID | 需求 | 验收对照 | 状态 | TODO | | --- | --- | --- | --- | --- | -| C-01~C-05 | 持仓/产品/规则/阈值/净值 | 无买卖指导 | 未做 | T-40 | +| C-01 | 持仓查询 | Core 只读 fact_text · 无买卖指导 | **已实现** · `holding_query` | ~~T-40~~ | +| C-02 | 产品规则咨询 | fin_product RAG + 免责 | **已实现** · Wave1~2 | — | +| C-03 | 政策/FAQ | fin_policy / fin_faq RAG | **已实现** · Wave1~2 | — | +| C-04 | 亏损阈值提醒 | `customer_threshold_config` + 持仓查询追加提醒 | **已实现(2026-09-10)** · `threshold_service` | ~~T-40~~ | +| C-05 | 产品净值 | Core 最新净值快照;禁止实时盘口 | **已实现(2026-09-10)** · `nav_query`;「实时净值」reject | ~~T-40~~ | +| C-07 | 风评查询 / 重测引导 | Core L0;重测引导 App/网点 | **部分已实现** · 无 Agent 内问卷 | T-41 | +| C-08 | 配置目标 | L1 槽 `allocation_target` | **部分已实现** · 无偏离检测/调仓 | T-41 | +| C-11 | 适当性匹配说明 | 非营销推荐;联动 R-02 | **部分已实现** · 「我能买什么」→ suitability;「推荐稳赚」仍 reject | T-42 | -P1+ 见 `docs/需求拆解/业务场景优先级清单.md` §3。 +**Wave 3 未做:** 主动 push/cron 阈值通知 · Phase B 行情 sync · GraphRAG 式推荐 · 自动调仓。 diff --git a/docs/memory/TODO.md b/docs/memory/TODO.md index ab904e4..5c6bebc 100644 --- a/docs/memory/TODO.md +++ b/docs/memory/TODO.md @@ -36,6 +36,9 @@ - [x] **Wave 3 测试解禁**:`test_wave3_customer_service.py` 19 例绿 · 基线 **554 passed** - [x] **Wave 1/2 解禁**:`test_wave1_*` · `test_wave2_*` 已移出 `collect_ignore` 并绿(132+ 例) - [x] **Wave 3 profile / 4 / 5 解禁**:`test_wave3_profile_service` · `test_wave4_e2e` · `test_wave5_notes` · 基线 **730 passed** +- [x] **1B/R1 拍板落地**(2026-09-10):`sanitize_postprocess` · Top-K · TEST-LOG · 776 pytest +- [x] **Wave3 能力批次**(2026-09-10):C-04 threshold · C-05 nav · C-11/C-07/C-08 · visitor 1B 对齐 · keyword 优先级 · **786 pytest** +- [x] **企业级测试包** `docs/memory/tests/2026-09-10-customer-1b-r1/`(模块负责人 zhangyong · 修改人 Andrew) - [x] **客服 KB 第二套(fin_*)· Ollama 灌库**(2026-09-09 本机): 1. `ollama pull bge-m3` ✓ 2. Ollama 服务 `http://127.0.0.1:11434` ✓ @@ -98,7 +101,12 @@ - [ ] **可选 dev 静态预警 seed**:2~3 条 `pending_review` 样例(免每次 simulate;与集成测试当日窗冲突须文档说明) - [ ] **链式 reset 脚本**:AML + prepare 并入 `reset.ps1` 或新增 `scripts/demo/prepare_all.ps1` -### 画像 Redis · cache-aside(2026-09-09) +### 客服 Agent · Wave3 开放项(2026-09-10 后) + +- [ ] **C-04 push/cron**:阈值命中仅持仓查询内联提醒;无 Redis pub / 定时扫仓 +- [ ] **C-05 Phase B**:`nav-snapshot` sync · 前端行情 Phase B(见 v0.2 草案) +- [ ] **C-08 偏离检测**:仅 L1 槽位,无自动调仓建议 +- [ ] **Vitest**:客户 Chat 新 intent 无前端单测(后端已覆盖) - [x] **L3 热读**:`get_profile_l3` cache-aside(读 Redis miss → MySQL → SET EX 5m;写 upsert DEL)· `customer_context` Tool 已走此路径 - [ ] **L1 热读/写**:`ProfileHotCache` 仅在客服线 `customer_service.py` · 宿主 `POST /api/chat` 未接 diff --git a/docs/memory/tests/2026-09-10-customer-1b-r1/MANUAL-CHECKLIST.md b/docs/memory/tests/2026-09-10-customer-1b-r1/MANUAL-CHECKLIST.md new file mode 100644 index 0000000..835ece1 --- /dev/null +++ b/docs/memory/tests/2026-09-10-customer-1b-r1/MANUAL-CHECKLIST.md @@ -0,0 +1,32 @@ +# 手工验收清单 · TEST-2026-09-10-CS-001 + +## 前置 + +- [ ] `merger` 分支最新代码 +- [ ] Core:`.\scripts\core\reset.ps1`(CUST-9527 持仓 3 只) +- [ ] Agent 表已建(`01-mysql-共用底座.sql`) +- [ ] Redis Docker 6380 · `.env` `REDIS_URL=redis://127.0.0.1:6380/0` +- [ ] R1 演示:`python scripts/dev/run_sql_file.py scripts/agent/seed-customer-profile-l1-r1-demo.sql` + `DEL profile:l1:CUST-9527` + +## 1B · 数据查询 sanitize 不误转人工 + +| # | 步骤 | 期望 | 结果 | +| --- | --- | --- | --- | +| 1 | 登录 CUST-9527,问「我的持仓怎么样」 | 回复含 Core 真数(如 3 只产品) | ☐ | +| 2 | 同上,观察 `transfer_to_human` | **false**(无橙色转人工 Tag) | ☐ | +| 3 | 问「推荐稳赚基金」 | reject 静态拒答,transfer **false** | ☐ | +| 4 | 输入「转人工」 | transfer **true** | ☐ | + +## R1 · 偏好 Top-K 注入 + +| # | 步骤 | 期望 | 结果 | +| --- | --- | --- | --- | +| 5 | 灌 R1 种子后闲聊/RAG 一轮 | prompt 侧画像含「货币基金、债券基金」等 Top-3 | ☐ | +| 6 | 确认不含「指数基金」 | 2020 条目超 TTL=90d 被过滤 | ☐ | + +## 自动化(CI 等价) + +```powershell +python -m pytest tests/test_wave3_customer_service.py tests/test_wave3_profile_service.py -q +python -m pytest -q +``` diff --git a/docs/memory/tests/2026-09-10-customer-1b-r1/README.md b/docs/memory/tests/2026-09-10-customer-1b-r1/README.md new file mode 100644 index 0000000..d4acb39 --- /dev/null +++ b/docs/memory/tests/2026-09-10-customer-1b-r1/README.md @@ -0,0 +1,28 @@ +# 测试包 · 客服 1B / 画像 R1(2026-09-10) + +| 项 | 值 | +| --- | --- | +| 测试记录编号 | `TEST-2026-09-10-CS-001` | +| 模块 | 客户财富 Agent(CS Wave 3) | +| 模块负责人 | zhangyong | +| 修改人 | Andrew | +| 关联拍板 | 1B · R1 · L0 优先 · C-04~C-11(部分) | + +## 文件 + +| 文件 | 说明 | +| --- | --- | +| [TEST-LOG-2026-09-10-CS-001.md](./TEST-LOG-2026-09-10-CS-001.md) | 企业级测试日志(缺陷发现 → 修复 → 回归) | +| [MANUAL-CHECKLIST.md](./MANUAL-CHECKLIST.md) | 手工验收步骤(含数据灌入) | + +## 数据灌入(可选 · R1 手工验 Top-K) + +```powershell +# Agent 库 L1 演示数据(CUST-9527 · 含 items_meta) +python scripts/dev/run_sql_file.py scripts/agent/seed-customer-profile-l1-r1-demo.sql + +# 清 Redis 热缓存(6380) +redis-cli -p 6380 DEL profile:l1:CUST-9527 +``` + +**1B 持仓 sanitize** 不依赖 L1 种子,仅需 Core 库 CUST-9527 持仓(`scripts/core/reset.ps1` 已有)。 diff --git a/docs/memory/tests/2026-09-10-customer-1b-r1/TEST-LOG-2026-09-10-CS-001.md b/docs/memory/tests/2026-09-10-customer-1b-r1/TEST-LOG-2026-09-10-CS-001.md new file mode 100644 index 0000000..86914e5 --- /dev/null +++ b/docs/memory/tests/2026-09-10-customer-1b-r1/TEST-LOG-2026-09-10-CS-001.md @@ -0,0 +1,170 @@ +# 企业级测试日志 · TEST-2026-09-10-CS-001 + +> 客户财富 Agent · sanitize 分治(1B)+ L1 偏好 Top-K(R1)+ Wave3 能力(C-04/C-05/C-11 等) + +--- + +## 1. 文档元数据 + +| 字段 | 值 | +| --- | --- | +| **测试记录编号** | TEST-2026-09-10-CS-001 | +| **缺陷/变更标题** | 1B/R1 合规拍板 + Wave3 数据查询扩展 + keyword 优先级 | +| **文档版本** | v1.1 | +| **创建日期** | 2026-09-10 | +| **最后更新** | 2026-09-10 | +| **关联分支** | `merger` | +| **关联拍板 ID** | 1B · R1 · L0 优先 · C-04 · C-05 · C-11 · C-07 · C-08 | +| **关联 TODO / 需求** | MEMORY §3 · REQUIREMENTS Wave3 · TODO 客服 Wave3 批次 | +| **风险等级** | MEDIUM(合规体验 + 意图路由回归) | +| **缺陷类型** | 逻辑缺陷 ×3(sanitize 分治 · 画像注入 · keyword 优先级)+ 能力增量 ×5 | +| **发现阶段** | 代码走读 + 课程缺口 + pytest 回归 | +| **修复阶段** | TDD 单测 + 全量 pytest 786 绿 | + +--- + +## 2. 组织与责任 + +| 字段 | 值 | +| --- | --- | +| **所属系统** | JinRong 金融四 Agent 智能管家 | +| **所属模块** | 客户财富 Agent(CS Wave 3) | +| **子模块 / 服务** | `customer_service` · `visitor_service` · `profile_service` · `threshold_service` · `sanitize_postprocess` | +| **模块负责人** | zhangyong | +| **发现人** | Andrew | +| **修改人** | Andrew | +| **测试执行人** | Andrew | +| **评审人** | (待模块负责人确认) | +| **发布建议** | 可合并 `merger`;C-04 无 push/cron 为已知缺口 | + +--- + +## 3. 环境与基线 + +| 字段 | 值 | +| --- | --- | +| **测试环境** | development · 本机 Windows | +| **Python** | 3.13 · pytest 8.4.2 | +| **数据库** | MySQL `jinrong_agent` + `jinrong_core`(模拟库) | +| **Redis** | Docker 6380 · RESP2(R1 手工项可 skip) | +| **修复前测试基线** | 774 passed | +| **中间基线(1B/R1)** | 776 passed, 1 skipped | +| **修复后测试基线** | **786 passed**, 1 skipped | +| **前端** | 未在本轮执行 Vitest | + +--- + +## 4. 缺陷与变更描述 + +### 4.1 缺陷 A · 1B — 数据查询 sanitize 误转人工 + +| 字段 | 内容 | +| --- | --- | +| **缺陷编号** | DEF-CS-2026-09-10-001 | +| **严重等级** | P2 | +| **修复方案** | 抽到 `app/utils/sanitize_postprocess.py` · `finalize_sanitized_reply`;customer + visitor 共用 | +| **变更文件** | `sanitize_postprocess.py` · `customer_service.py` · `visitor_service.py` · `test_sanitize_postprocess.py` | + +### 4.2 缺陷 B · R1 — L1 偏好全量平铺注入 + +| 字段 | 内容 | +| --- | --- | +| **缺陷编号** | DEF-CS-2026-09-10-002 | +| **严重等级** | P3 | +| **修复方案** | `items_meta` + Top-K/TTL · `settings.profile_preference_*` | +| **变更文件** | `profile_service.py` · `settings.py` · `test_wave3_profile_service.py` | + +### 4.3 缺陷 C · keyword_route — save_note 抢数据查询 + +| 字段 | 内容 | +| --- | --- | +| **缺陷编号** | DEF-CS-2026-09-10-003 | +| **严重等级** | P2 | +| **复现** | 「帮我记住最近的流水」误命中 `save_note` 而非 `transaction_query` | +| **根因** | save_note 关键词块排在数据查询之前 | +| **修复** | 顺序:数据查询(trade/risk/suitability/holding)→ save_note → eligible/nav | +| **变更文件** | `customer_prompts.py` · `test_wave5_notes.py` | + +### 4.4 能力增量 · Wave3(非缺陷) + +| ID | 能力 | 落点 | 单测 | +| --- | --- | --- | --- | +| C-04 | 阈值摘要 → config;持仓达线 inline 提醒 | `threshold_service.py` · `core_ro_tool.query_holdings` | `test_threshold_service.py` | +| C-05 | nav_query · Core 最新净值;实时净值 reject | `core_ro_tool.query_product_nav` | `test_wave2_prompts.py` · `test_wave3_customer_service.py` | +| C-11 | 「我能买什么」→ suitability_check | `customer_prompts._ELIGIBLE_PRODUCTS_KW` | `test_wave2_prompts.py` | +| C-07 | 重新测评关键词 → 引导网点 | `customer_prompts._RISK_KW` | `test_wave2_prompts.py` | +| C-08 | L1 槽 `investment.allocation_target`(13 槽) | `profile_slots.py` | `test_wave2_profile_slots.py` | + +--- + +## 5. 变更清单 + +| 类型 | 路径 | 说明 | +| --- | --- | --- | +| 代码 | `app/utils/sanitize_postprocess.py` | 1B 共用分治 | +| 代码 | `app/service/threshold_service.py` | C-04 新建 | +| 代码 | `app/service/visitor_service.py` | 对齐 1B | +| 代码 | `app/tool/core_ro_tool.py` | nav + 阈值 alert | +| 代码 | `app/service/customer_prompts.py` | Wave3 意图 + keyword 顺序 | +| 代码 | `app/config/profile_slots.py` | C-08 第 13 槽 | +| 单测 | `tests/test_threshold_service.py` 等 | +10 例量级 | +| 记忆 | MEMORY / REQUIREMENTS / FLOW / TODO / FRAMEWORK / ITERATION | 786 基线 · Wave3 状态 | +| 课程 | `modules/04~07` · `build_all.py` | 1B 路径 · Wave3 模块 7 | +| 种子 | `scripts/agent/seed-customer-profile-l1-r1-demo.sql` | R1 手工验 | + +--- + +## 6. 测试执行记录 + +| 序号 | 类型 | 用例 / 命令 | 执行时间 | 执行人 | 结果 | 证据 | +| --- | --- | --- | --- | --- | --- | --- | +| 1 | 单元 | 1B/R1 原 5 项 | 2026-09-10 | Andrew | **PASS** | 见 v1.0 | +| 2 | 单元 | `test_threshold_service.py` | 2026-09-10 | Andrew | **PASS** | C-04 解析/alert | +| 3 | 单元 | `test_sanitize_postprocess.py` | 2026-09-10 | Andrew | **PASS** | nav_query 在 DATA_QUERY | +| 4 | 单元 | `test_keyword_route_save_note_priority` | 2026-09-10 | Andrew | **PASS** | 修顺序后绿 | +| 5 | 模块 | `test_wave2_prompts` · `test_wave3_customer_service` | 2026-09-10 | Andrew | **PASS** | nav/eligible/13 intent | +| 6 | 全量回归 | `python -m pytest -q` | 2026-09-10 | Andrew | **PASS** | **786 passed, 1 skipped** | +| 7 | 手工 · R1 | 种子 + render_profile_context | 2026-09-10 | Andrew | **PASS(脚本)** | Top-3 正确 | +| 8 | Redis DEL | `profile:l1:CUST-9527` | 2026-09-10 | Andrew | **跳过** | 6380 未起时可后补 | + +--- + +## 7. 数据准备与重灌结论 + +| 数据集 | 是否需重灌 | 说明 | +| --- | --- | --- | +| Core | 否 | CUST-9527 持仓/风评/净值在 reset.ps1 | +| Agent L1 种子 | 建议(R1 手工) | `seed-customer-profile-l1-r1-demo.sql` | +| `customer_threshold_config` | 可选 | 口述「亏10%提醒我」后自动 upsert | +| Redis 热缓存 | 灌 SQL 后清 | `DEL profile:l1:CUST-9527` | + +--- + +## 8. 结论与剩余风险 + +| 字段 | 结论 | +| --- | --- | +| **需求是否覆盖** | C-01~05 已实现;C-07/08/11 部分实现(见 REQUIREMENTS) | +| **旧功能是否破坏** | 否 · 786 pytest 全绿 | +| **剩余风险** | ① C-04 无 push/cron;② C-05 非实时、Phase B 未做;③ C-08 无偏离检测;④ 手工 uvicorn 走查待补 | +| **建议人工再验** | CUST-9527:持仓+阈值 · nav_query · 「我能买什么」· 「实时净值」reject · save_note 与流水优先级 | +| **是否可发布** | 后端可合并 | + +--- + +## 9. 签核(可选) + +| 角色 | 姓名 | 日期 | 意见 | +| --- | --- | --- | --- | +| 模块负责人 | zhangyong | | ☐ 通过 ☐ 待改 | +| 修改人 | Andrew | 2026-09-10 | 自测通过 | +| 测试 | Andrew | 2026-09-10 | 786 自动化 PASS | + +--- + +## 10. 修订历史 + +| 版本 | 日期 | 作者 | 说明 | +| --- | --- | --- | --- | +| v1.0 | 2026-09-10 | Andrew | 1B/R1 初稿 | +| v1.1 | 2026-09-10 | Andrew | Wave3 批次 · keyword 缺陷 · 786 基线 · 课程模块 7 | diff --git a/docs/memory/tests/_TEMPLATE.md b/docs/memory/tests/_TEMPLATE.md index aeee8d9..468b25a 100644 --- a/docs/memory/tests/_TEMPLATE.md +++ b/docs/memory/tests/_TEMPLATE.md @@ -1,34 +1,51 @@ # 测试日志模板 -复制为 `YYYYMMDD-短任务名.md` 后填写。 +复制为 `YYYYMMDD-短任务名/` 目录,内含 `TEST-LOG-*.md`(企业级字段见 `2026-09-10-customer-1b-r1/TEST-LOG-2026-09-10-CS-001.md` 范例)。 ```markdown # 测试日志:<任务名> -- 日期: -- 关联 TODO: -- 风险等级:LOW / MEDIUM / HIGH +## 1. 文档元数据 +| 测试记录编号 | | +| 缺陷/变更标题 | | +| 文档版本 | v1.0 | +| 创建日期 | | +| 关联分支 | merger | +| 关联拍板/TODO | | +| 风险等级 | LOW / MEDIUM / HIGH | +| 缺陷类型 | | +| 发现阶段 | | -## 改了什么 +## 2. 组织与责任 +| 所属模块 | | +| 模块负责人 | | +| 发现人 | | +| 修改人 | | +| 测试执行人 | | +| 评审人 | | -- 文件: -- 行为变化: +## 3. 缺陷描述(可多条) +| 缺陷编号 | | +| 严重等级 | P1/P2/P3 | +| 复现步骤 | | +| 期望 / 实际 | | +| 根因 | | +| 修复方案 | | -## 已执行 +## 4. 变更清单 +| 类型 | 路径 | 说明 | -| 类型 | 命令或步骤 | 结果 | -| --- | --- | --- | -| 手工 / 单测 / 集成 | | PASS / FAIL / 未执行 | +## 5. 测试执行记录 +| 序号 | 类型 | 用例/命令 | 执行人 | 结果 | 证据 | -## 未执行 +## 6. 数据准备 +| 数据集 | 是否重灌 | 操作 | -- 项: -- 原因: +## 7. 结论 +| 需求覆盖 | | +| 回归 | | +| 剩余风险 | | +| 建议人工再验 | | -## 结论 - -- 需求是否覆盖: -- 旧功能是否破坏: -- 剩余风险: -- 建议人工再验: +## 8. 签核 / 修订历史 ``` diff --git a/docs/项目框架设计/技术选型和版本/01-技术栈与版本.md b/docs/项目框架设计/技术选型和版本/01-技术栈与版本.md index 539899d..c08024d 100644 --- a/docs/项目框架设计/技术选型和版本/01-技术栈与版本.md +++ b/docs/项目框架设计/技术选型和版本/01-技术栈与版本.md @@ -1,19 +1,138 @@ # 技术栈与版本(已定) -> 更新日期:2026-09-05 +> 更新日期:2026-09-10 > 环境:Windows 本机开发 · 内存 15.4 GB(可用约 3.7 GB) -> 原则:**日常开发走 Windows 原生安装**;Docker 仅作答辩/生产备选,不用于日常联调。 +> 原则:**日常开发走 Windows 原生安装**;Docker **仅 Redis 单容器 + 答辩/生产备选**,不用于日常全栈容器化。 --- -## 1. 组件一览 +## 0. 选型背景:我们在解决什么问题 + +本项目是 **四个 Agent 共用一套合规底座** 的金融代销演示系统:客户 / 代理人 / 数据分析 / 风控,各自对话与 Tool,但共享 MySQL 画像与审计、Core 只读账、向量知识库与 Redis 会话。 + +选型时要同时满足: + +| 约束 | 对技术选型的影响 | +| --- | --- | +| **合规与审计** | 业务真相在 MySQL + Core;Redis 只是加速副本;审计表只 INSERT | +| **四 Agent 不互调 LLM** | 需要统一 FastAPI 入口 + LangGraph 编排,而不是四个独立微服务各搞一套 | +| **无真实 Core** | 用 MySQL 模拟 `jinrong_core`,Agent 经 Repository 只读,便于本地灌库演示 | +| **本机内存紧(~3.7 GB 可用)** | 不能日常跑 Docker 全家桶 + Milvus 三容器;向量用 Milvus Lite | +| **团队 Windows 原生开发** | MySQL / Neo4j / Ollama 本机安装;减少「先起 5 个容器才能写代码」 | +| **敏感文档不出内网** | Embedding 本地 Ollama;生成推理可走 DeepSeek API(可脱敏) | +| **答辩要可演示** | 保留 Docker Compose 切换路径(Milvus Standalone、完整 Redis),与开发 API 一致 | + +**决策原则(拍板后不变):** 能本机原生则原生;必须容器化的只选 **内存占用小、切换成本低** 的组件;前后端分离、API 契约先行;P0 不引入 MinIO / 实时行情 / 多租户。 + +--- + +## 1. 各组件为什么选它 + +> 下面按 **「这层干什么 → 为什么用这个」** 说明;版本号见 §2 组件一览。 + +### 1.1 后端:Python 3.13 + FastAPI + +| 为什么 | 说明 | +| --- | --- | +| **Agent 与 API 同进程** | 四个 Agent 的 LangGraph、Tool、Repository 与 REST 路由在同一 FastAPI 进程,调试、断点、pytest 一条线,不必维护 Java + Python 两套 | +| **异步 I/O 够用** | 对话、SSE、MySQL/Redis 并发连接;风控事件线以同步 SQL 为主,FastAPI 足够 | +| **类型与生态** | Pydantic 校验请求体、settings 管理双库与阈值;与 LangChain / pymilvus 生态衔接成熟 | +| **未选 Node/Java 做 Agent 主栈** | 团队 Python 统一;数据分析 Agent 的 SQL 守卫、风控规则引擎已在 Python 落地 | + +### 1.2 Agent 编排:LangGraph + langchain-openai(DeepSeek) + +| 为什么 | 说明 | +| --- | --- | +| **状态图 + Tool 节点** | 对话 = `tool → llm → guard` 固定链路;风控/客服可插分支而不重写 HTTP | +| **DeepSeek 通过 OpenAI 兼容 API** | `langchain-openai` 改 base_url 即可;无 key 时降级明确标注,不 silent 假回复 | +| **未选裸 HTTP 调模型** | Tool 注册表、免责声明、会话注入需要与 LangGraph 节点绑定;手写状态机维护成本高 | +| **未选四 Agent 各一个独立服务** | 项目规则禁止 Agent 互调 LLM;共用 `chat.py` + `X-Agent-Type` 分流更省内存 | + +### 1.3 关系库:MySQL 8.0 双库(`jinrong_agent` + `jinrong_core`) + +| 为什么 | 说明 | +| --- | --- | +| **双库隔离 L0 与 Agent enrich** | `jinrong_core` = 模拟正式账(客户、持仓、流水、产品);`jinrong_agent` = 会话、审计、预警、L1/L2/L3、AML | +| **Agent 只读 Core** | 符合「Core 是权威账,Agent 不能改 C1~C5」;真实接入时只换 Core 连接,Agent 库 schema 基本不动 | +| **未选 PostgreSQL** | 团队与本机已标准化 MySQL 8;表设计、种子脚本、Windows 安装路径已打通 | +| **未选 SQLite 做生产底座** | 四 Agent 并发写审计/会话、集成测试要贴近真库;SQLite 仅单测用 | + +### 1.4 缓存:Redis 8.x(开发推荐 Docker `6380`) + +| 为什么 | 说明 | +| --- | --- | +| **会话热窗口** | `sess:{agent}:{session_id}:msgs`:最近 N 轮对话,TTL 2h;miss 回源 MySQL | +| **画像热读** | `profile:l3:{customer_id}` 等 cache-aside;MySQL 为权威 | +| **风控辅助** | 预警 Pub/Sub、去重键、限流计数、JWT jti 吊销 | +| **为什么开发改用 Docker Redis(6380)** | 本机 Windows 自带 Redis 常占 **6379** 且版本偏旧(RESP3 兼容问题);Docker `redis:7-alpine` 映射 **6380**,与 `database.py` RESP2 一致,**只启一个容器 ~50MB**,不拉垮 3.7GB 内存 | +| **未选 Memcached** | 需要 List、Pub/Sub、TTL 组合;Redis 一条栈覆盖会话+缓存+限流 | + +### 1.5 图库:Neo4j 5.x(Desktop) + +| 为什么 | 说明 | +| --- | --- | +| **关系穿透演示** | 客户—产品—行业—市场多跳查询;为 GraphRAG / 投顾场景预留 | +| **非画像主库** | 完整画像在 MySQL L1/L2/L3;Neo4j 存实体关系,由 `sync_neo4j.py` 从 Core 同步 | +| **Desktop 降低运维** | 开发期不需自建集群;答辩可导出/换 Server | +| **P0 对话未强依赖** | 选型先占位;不阻塞四 Agent P0 主链路 | + +### 1.6 向量库:Milvus Lite + pymilvus(1024 维 bge-m3) + +| 为什么 | 说明 | +| --- | --- | +| **RAG 必需** | 产品规则、客服 fin_* 库、代理人知识检索;需要向量检索 + 标量过滤(effective_date 等) | +| **Milvus Lite 单文件** | 免 etcd/minio/milvus 三容器(~3.5GB);本机 `.milvus/` 或 `data/milvus.db` 即可 | +| **与 Standalone 同 API** | 答辩/生产改 `MILVUS_URI` 切 Standalone,Collection 维度和字段不变 | +| **未选 pgvector / 纯 FAISS 文件** | 多 Collection、标量过滤、与 pymilvus 文档对齐;FAISS 中文路径等坑已在 T-21 踩过 | +| **1024 维 bge-m3** | 中文金融语料表现好;Ollama 本地 embed,**文档向量不出内网** | + +### 1.7 前端:React 19 + Vite 7 + Ant Design 5 + HashRouter + +| 为什么 | 说明 | +| --- | --- | +| **四角色工作台** | 登录、侧栏、Dashboard、Chat、表格台账;需要组件库而非脚本式页面 | +| **HashRouter** | 静态部署 / 本地 file 预览友好;后端只提供 API,不要求服务端路由 | +| **Vite 开发体验** | HMR 快;与 TS strict、Vitest 一套工具链 | +| **未选 Streamlit** | 需求文档或课程示例或出现 Streamlit;**本项目统一 React**,四 Agent 共用 Layout/API 客户端,桌面端信息密度更高 | +| **未选 Vue/Angular** | 团队既定 React;Ant Design 5 金融后台组件成熟 | + +### 1.8 LLM 分工:DeepSeek API + Ollama bge-m3 + +| 能力 | 选型 | 为什么 | +| --- | --- | --- | +| **对话 / 推理 / Tool** | DeepSeek API | 质量与成本平衡;OpenAI 兼容接入 LangGraph | +| **Embedding** | Ollama bge-m3 本地 | 产品手册、政策 chunk **不出内网**;维度 1024 与 Milvus 对齐 | +| **未选全本地 LLM** | — | 本机内存不够跑 7B+ 推理 + Neo4j + MySQL 同时满载;生成走 API 更稳 | +| **未选全云端 Embedding** | — | 合规与演示要求「知识库向量化在内网完成」 | + +### 1.9 数据分析:sqlglot(SQL AST 白名单) + +| 为什么 | 说明 | +| --- | --- | +| **NL→SQL 必须防写** | 分析 Agent 只允许 SELECT;AST 层拦截 DROP/UPDATE/多语句 | +| **与只读 DB 账号双保险** | 即使账号配错,应用层仍拒绝非 SELECT | +| **未选正则黑名单** | 金融 SQL 嵌套多,正则易漏;sqlglot 解析更可靠 | + +### 1.10 刻意不选 + +| 不选 | 为什么 | +| --- | --- | +| **MinIO / OSS(P0)** | 文档量小,本地 `data/kb/` + MySQL 元数据够用;减依赖 | +| **Docker 日常全栈** | 内存不够;MySQL/Neo4j/Ollama 本机已安装 | +| **Streamlit 前端** | 与 React 工作台重复;不作为交付前端 | +| **Agent 互调 / 多 LLM 编排** | 项目 MEMORY 禁止;跨 Agent 走画像表与预警 API | +| **Milvus Docker 日常** | 三容器 + Docker Desktop VM 易 OOM;Lite 足够开发 | + +--- + +## 2. 组件一览 | 组件 | 版本 / 状态 | 备注 | | --- | --- | --- | | **后端** | Python 3.13.14 + FastAPI | 系统 Python;Agent 编排 **LangGraph** 1.2.x + `langchain-core` / `langchain-openai`(DeepSeek)/ `pymilvus` 3.0.1 | | **SQL 安全** | `sqlglot`(AST 解析白名单) | 数据分析 Agent 只读 SQL 校验;与只读账号双保险(已确认新增) | | **关系库** | MySQL 8.0.46 | 原生安装,端口 **3306** ✓ | -| **缓存** | Redis 8.10.1 | 原生安装,路径 `F:\Redis\...`,端口 **6379** ✓ | +| **缓存** | Redis 8.x / Docker `redis:7-alpine` | 开发推荐 **6380**(`REDIS_URL`);本机 Windows Redis 若占 6379 则与之并存 · 见 §3 | | **图库** | Neo4j 5.26.19(Enterprise) | Neo4j Desktop 2,已建库 ✓ | | **向量库** | Milvus Lite(本地文件模式) | 见 §3 踩坑说明;Collection 设计见 [03-milvus-collections.md](../项目框架设计/表设计/03-milvus-collections.md) | | **前端** | React 19 + Vite 7 + TS strict + Ant Design 5 + HashRouter + Vitest | 沿用既有前端栈;**不用**需求文档中的 Streamlit | @@ -24,42 +143,70 @@ --- -## 2. 部署方式:Windows 原生(已定) +## 3. 部署方式:为什么这样部署(已定) -### 2.1 为何不用 Docker 做日常开发 +### 3.1 总体策略:「重服务本机原生 + 轻量容器补缺」 + +```text +┌─────────────────────────────────────────────────────────┐ +│ Windows 本机(日常开发) │ +│ MySQL :3306 · Neo4j Desktop · Ollama · FastAPI · Vite │ +│ Milvus Lite 单文件 · 文档目录 data/kb/ │ +├─────────────────────────────────────────────────────────┤ +│ 唯一日常容器:Docker Redis → 127.0.0.1:6380 │ +│ (避开本机 Redis 6379 · 统一 RESP2 · 内存 ~50MB) │ +├─────────────────────────────────────────────────────────┤ +│ 答辩 / 生产备选:Docker Compose │ +│ Milvus Standalone(etcd+minio+milvus)· 可换 OSS │ +└─────────────────────────────────────────────────────────┘ +``` + +| 部署选择 | 原因 | +| --- | --- | +| **MySQL 本机安装** | 双库 + 种子脚本 + pytest 集成测依赖真库;容器化 MySQL 在 3.7GB 可用内存下性价比低 | +| **Neo4j Desktop** | 图形化管理、一键启停;开发不需要 K8s 级 HA | +| **Ollama 本机** | Embedding 低延迟、无外网;与 Python 同机调试 | +| **Milvus Lite 文件** | 不启 etcd/minio;向量库与代码同仓库路径可备份 | +| **Redis 用 Docker 单容器** | 本机 Windows Redis 版本/端口冲突;单容器内存小、与 compose 一致,**不算「全栈 Docker 开发」** | +| **FastAPI + Vite 进程直跑** | `uvicorn --reload` + `npm run dev`;改代码即生效 | +| **答辩才考虑 Compose 全家桶** | 演示机内存够时 Milvus Standalone 与 Lite **API 相同**,切换 URI 即可 | + +### 3.2 为何不用 Docker 做日常全栈开发 | 项 | 说明 | | --- | --- | | **本机内存** | 总计 15.4 GB,可用约 **3.7 GB**(使用率 ~75%) | -| **Docker 开销** | Docker Desktop WSL2 VM ~**2 GB** + Milvus 三容器(etcd / minio / milvus)~**3.5 GB** ≈ **5.5 GB** | -| **原生套件占用** | MySQL + Redis + Neo4j + Milvus Lite ≈ **1.9 GB** | +| **Docker 全栈开销** | Docker Desktop WSL2 VM ~**2 GB** + Milvus 三容器 ~**3.5 GB** ≈ **5.5 GB** | +| **原生 + Lite 占用** | MySQL + Redis 容器 + Neo4j + Milvus Lite + Ollama ≈ **2 GB 量级** | -在现有内存下,**必须采用 Windows 原生部署**,否则 Milvus / Docker 与 IDE、浏览器、Neo4j 同时运行易 OOM。 +在现有内存下,**日常必须避免 Milvus/数据库全容器化**,否则与 IDE、浏览器、Neo4j 同时运行易 OOM。 -### 2.2 日常开发启动顺序(建议) +### 3.3 日常开发启动顺序(建议) ```text -1. MySQL 8.0.46 → :3306 -2. Redis 8.10.1 → :6379 -3. Neo4j Desktop → 默认 bolt :7687 -4. Ollama + bge-m3 → 本地 embedding -5. FastAPI 后端 → 连接上述服务 + Milvus Lite 文件 -6. Vite 前端 → dev server +1. MySQL 8.0.46 → :3306 +2. Docker Redis → .\scripts\dev\start-redis.ps1 → :6380 +3. Neo4j Desktop → bolt :7687 +4. Ollama + bge-m3 → 本地 embedding +5. FastAPI 后端 → 连接上述 + Milvus Lite 文件 +6. Vite 前端 → npm run dev ``` -### 2.3 生产 / 答辩备选 +### 3.4 生产 / 答辩备选 -- 可使用 Docker Compose 拉起完整 Milvus Standalone(etcd + minio + milvus),**与开发环境 Milvus Lite 通过同一 pymilvus API 切换 URI**。 -- 对象存储生产环境可再评估 MinIO / OSS;当前阶段本地目录 + MySQL 元数据即可。 +- **Milvus Standalone**(Docker Compose):etcd + minio + milvus,与 Lite 通过 **同一 pymilvus API** 切换 `MILVUS_URI`。 +- **Redis**:可换云 Redis / 集群;应用只认 `REDIS_URL`。 +- **对象存储**:P0 本地目录;上云再换 OSS 适配层,**不改 Agent 业务接口**。 +- **DeepSeek**:生产可换私有化模型 endpoint,LangGraph 侧仍 OpenAI 兼容协议。 --- -## 3. 连接与配置约定 +## 4. 连接与配置约定 | 服务 | 开发默认 | 环境变量示例 | | --- | --- | --- | | MySQL | `127.0.0.1:3306` | `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_DATABASE=jinrong_agent` | -| Redis | `127.0.0.1:6379` | `REDIS_URL=redis://127.0.0.1:6379/0` | +| Redis | `127.0.0.1:6380`(Docker 推荐) | `REDIS_URL=redis://127.0.0.1:6380/0` | | Neo4j | Desktop 本地 | `NEO4J_URI=bolt://localhost:7687` | | Milvus Lite | 项目内 `.milvus/` 或配置路径 | `MILVUS_URI=./data/milvus.db`(Lite 文件 URI 以 pymilvus 文档为准) | | Ollama | `http://127.0.0.1:11434` | `OLLAMA_BASE_URL`, `EMBED_MODEL=bge-m3` | @@ -69,7 +216,7 @@ Agent 业务库脚本:[01-mysql-共用底座.sql](../项目框架设计/表设 --- -## 4. 向量与 RAG 对齐 +## 5. 向量与 RAG 对齐 | 项 | 约定 | | --- | --- | @@ -81,9 +228,9 @@ Agent 业务库脚本:[01-mysql-共用底座.sql](../项目框架设计/表设 --- -## 5. 踩坑说明 +## 6. 踩坑说明 -### 5.1 Milvus:Lite vs Standalone +### 6.1 Milvus:Lite vs Standalone | 模式 | 适用 | 注意 | | --- | --- | --- | @@ -92,27 +239,35 @@ Agent 业务库脚本:[01-mysql-共用底座.sql](../项目框架设计/表设 代码层通过 `MILVUS_URI` / 连接模式开关切换,**Collection 字段与 1024 维不变**。 -### 5.2 不用 MinIO +### 6.2 不用 MinIO - 原始 PDF/Word 存 **本地目录**(如 `data/kb/`)。 - Milvus 存 chunk 文本或本地文件相对路径;`source_doc_id` / `source_version` 进 MySQL 或 Milvus 标量字段(见 Collection 设计)。 - 后续若上云,再替换为 OSS 适配层,不改 Agent 业务接口。 -### 5.3 前端栈与需求文档差异 +### 6.3 前端栈与需求文档差异 - 需求/用户故事中可能出现的 Streamlit **不作为本项目前端**。 - 统一:**React 19 + Vite 7 + Ant Design 5**,四 Agent 可共用组件库与 HashRouter 多入口。 -### 5.4 LLM 分工 +### 6.4 LLM 分工 | 能力 | 选型 | 数据出境 | | --- | --- | --- | | 对话 / 推理 / Tool 编排 | DeepSeek API + **LangGraph** StateGraph | 按 API 协议;敏感字段需脱敏 | | 文档 Embedding | Ollama bge-m3 本地 | **不出内网** | +### 6.5 Redis 端口与 RESP 版本 + +| 现象 | 处理 | +| --- | --- | +| 本机 Windows Redis 占 **6379** | 项目 `.env` 用 **6380**(Docker 映射),两者可并存 | +| 客户端 RESP3 与旧 Redis 不兼容 | `database.py` 固定 **protocol=2**(RESP2) | +| 启动 | `.\scripts\dev\start-redis.ps1` 或 `docker compose up -d redis` | + --- -## 6. 与项目其他文档的关系 +## 7. 与项目其他文档的关系 | 文档 | 关系 | | --- | --- | @@ -123,9 +278,10 @@ Agent 业务库脚本:[01-mysql-共用底座.sql](../项目框架设计/表设 --- -## 7. 版本变更记录 +## 8. 版本变更记录 | 日期 | 变更 | | --- | --- | | 2026-09-05 | 初版:Windows 原生 + Milvus Lite + 不用 MinIO + React 前端栈 | | 2026-09 | 新增 `sqlglot`(数据分析 Agent SQL AST 白名单校验,已确认) | +| 2026-09-10 | 新增 **§0 选型背景**、**§1 各组件为什么选它**、**§3 为什么这样部署**;Redis 开发口径改为 Docker **6380** | diff --git a/scripts/agent/seed-customer-profile-l1-r1-demo.sql b/scripts/agent/seed-customer-profile-l1-r1-demo.sql new file mode 100644 index 0000000..b33309f --- /dev/null +++ b/scripts/agent/seed-customer-profile-l1-r1-demo.sql @@ -0,0 +1,39 @@ +-- CS Wave 3 · R1 手工验收种子(CUST-9527) +-- 用途:验证 render_profile_context Top-K + TTL 注入(默认 K=3 · TTL=90d) +-- 执行:python scripts/dev/run_sql_file.py scripts/agent/seed-customer-profile-l1-r1-demo.sql +-- 前置:jinrong_agent 已建表(01-mysql-共用底座.sql);Core CUST-9527 已由 scripts/core/reset.ps1 灌入 +-- 灌后:DEL profile:l1:CUST-9527(Redis)或等 10min TTL,避免读到旧热缓存 + +USE jinrong_agent; + +INSERT INTO customer_profile_l1 (customer_id, style_tags, version, updated_by) +VALUES ( + 'CUST-9527', + JSON_OBJECT( + 'basic', JSON_OBJECT( + 'city', JSON_OBJECT('value', '上海', 'source', 'user_declared', 'confidence', 0.9) + ), + 'investment', JSON_OBJECT( + 'product_preferences', JSON_OBJECT( + 'value', JSON_ARRAY('货币基金', '债券基金', '指数基金', '混合基金', 'QDII基金'), + 'source', 'user_declared', + 'confidence', 0.92, + 'items_meta', JSON_OBJECT( + '货币基金', JSON_OBJECT('updated_at', '2026-09-10T08:00:00', 'mention_count', 5, 'confidence', 0.95), + '债券基金', JSON_OBJECT('updated_at', '2026-09-09T10:00:00', 'mention_count', 3, 'confidence', 0.90), + '指数基金', JSON_OBJECT('updated_at', '2020-01-01T10:00:00', 'mention_count', 8, 'confidence', 0.99), + '混合基金', JSON_OBJECT('updated_at', '2026-08-01T10:00:00', 'mention_count', 1, 'confidence', 0.70), + 'QDII基金', JSON_OBJECT('updated_at', '2026-09-08T12:00:00', 'mention_count', 2, 'confidence', 0.85) + ) + ), + 'horizon', JSON_OBJECT('value', '1-3年', 'source', 'user_declared', 'confidence', 0.88) + ), + 'threshold_pref_summary', JSON_OBJECT('value', '亏损10%提醒', 'source', 'user_declared', 'confidence', 0.97) + ), + 1, + 'customer_agent' +) +ON DUPLICATE KEY UPDATE + style_tags = VALUES(style_tags), + version = version + 1, + updated_by = 'customer_agent'; diff --git a/tests/test_module_boundary.py b/tests/test_module_boundary.py index e747e76..3e76ada 100644 --- a/tests/test_module_boundary.py +++ b/tests/test_module_boundary.py @@ -120,15 +120,18 @@ CUSTOMER_AGENT_SEAM_SKIP = ( "app/api/visitor.py", "app/repository/note_repository.py", "app/repository/profile_repository.py", + "app/repository/threshold_repository.py", "app/service/customer_service.py", "app/service/customer_prompts.py", "app/service/note_service.py", "app/service/profile_service.py", + "app/service/threshold_service.py", "app/service/visitor_prompts.py", "app/service/visitor_service.py", "app/tool/core_ro_tool.py", "app/utils/compliance_guard.py", "app/utils/data_masker.py", + "app/utils/sanitize_postprocess.py", "app/config/profile_slots.py", ) diff --git a/tests/test_sanitize_postprocess.py b/tests/test_sanitize_postprocess.py new file mode 100644 index 0000000..884df10 --- /dev/null +++ b/tests/test_sanitize_postprocess.py @@ -0,0 +1,23 @@ +"""sanitize_postprocess 分治(1B)单测。""" + +from app.utils.compliance_guard import COMPLIANCE_REJECT +from app.utils.sanitize_postprocess import finalize_sanitized_reply + + +def test_data_query_falls_back_to_fact_text(): + reply, transfer = finalize_sanitized_reply( + "建议您买入", + intent="holding_query", + fact_text="您当前持有 3 只产品。", + ) + assert transfer is False + assert "3 只产品" in reply + + +def test_rag_hit_returns_compliance_without_transfer(): + reply, transfer = finalize_sanitized_reply( + "建议您买入更多", + intent="product_consult", + ) + assert transfer is False + assert reply == COMPLIANCE_REJECT diff --git a/tests/test_threshold_service.py b/tests/test_threshold_service.py new file mode 100644 index 0000000..b4d4adc --- /dev/null +++ b/tests/test_threshold_service.py @@ -0,0 +1,59 @@ +"""C-04 亏损阈值提醒服务单测。""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from app.service import threshold_service as ts + + +def test_parse_loss_threshold_pct(): + assert ts.parse_loss_threshold_pct("亏10%就提醒我") == Decimal("10") + assert ts.parse_loss_threshold_pct("亏损 12.5% 通知我") == Decimal("12.5") + assert ts.parse_loss_threshold_pct("没有数字") is None + + +def test_portfolio_pnl_pct_weighted(): + rows = [ + {"market_value": 60000, "pnl_pct": -12.0}, + {"market_value": 40000, "pnl_pct": -8.0}, + ] + pnl = ts.portfolio_pnl_pct(rows) + assert pnl is not None + assert round(pnl, 2) == -10.4 + + +def test_build_threshold_alert_when_breached(monkeypatch): + logs: list[dict] = [] + + class FakeRepo: + def list_enabled(self, cid): + return [{ + "id": 7, + "scope_type": "portfolio", + "loss_threshold_pct": Decimal("10"), + }] + + def insert_notify_log(self, **kwargs): + logs.append(kwargs) + + monkeypatch.setattr(ts, "ThresholdRepository", FakeRepo) + holdings = [{"market_value": 100000, "pnl_pct": -15.0}] + alert = ts.build_threshold_alert("CUST-1", holdings, trace_id="t1") + assert alert and "阈值提醒" in alert + assert logs and logs[0]["customer_id"] == "CUST-1" + + +def test_build_threshold_alert_skips_when_within_threshold(monkeypatch): + class FakeRepo: + def list_enabled(self, cid): + return [{"id": 1, "scope_type": "portfolio", "loss_threshold_pct": Decimal("20")}] + + def insert_notify_log(self, **kwargs): + raise AssertionError("should not notify") + + monkeypatch.setattr(ts, "ThresholdRepository", FakeRepo) + holdings = [{"market_value": 100000, "pnl_pct": -5.0}] + assert ts.build_threshold_alert("CUST-1", holdings) is None diff --git a/tests/test_wave2_profile_slots.py b/tests/test_wave2_profile_slots.py index 8a2f2ba..948854d 100644 --- a/tests/test_wave2_profile_slots.py +++ b/tests/test_wave2_profile_slots.py @@ -15,9 +15,9 @@ from app.config.profile_slots import ( def test_slots_integrity(): - """12 槽位、path 唯一、字段合法、与 style_tags 路径对齐。""" - assert len(SLOTS) == 12 - assert len(SLOT_PATHS) == len(set(SLOT_PATHS)) == 12 + """13 槽位、path 唯一、字段合法、与 style_tags 路径对齐。""" + assert len(SLOTS) == 13 + assert len(SLOT_PATHS) == len(set(SLOT_PATHS)) == 13 valid_merge = {"latest", "set_union"} valid_fmt = {"band", "enum", "list", "text"} diff --git a/tests/test_wave2_prompts.py b/tests/test_wave2_prompts.py index f9c07fd..89720ff 100644 --- a/tests/test_wave2_prompts.py +++ b/tests/test_wave2_prompts.py @@ -15,11 +15,11 @@ from app.service.customer_prompts import ( def test_intent_constants(): - assert len(VALID_INTENTS) == 12 - assert len(DATA_QUERY_INTENTS) == 4 - # 4 个数据查询意图均在合法集内 + assert len(VALID_INTENTS) == 13 + assert len(DATA_QUERY_INTENTS) == 5 + # 数据查询意图均在合法集内 assert DATA_QUERY_INTENTS <= VALID_INTENTS - # 意图分类 prompt 覆盖 12 类标签 + # 意图分类 prompt 覆盖标签 for intent in VALID_INTENTS: assert intent in INTENT_SYSTEM @@ -69,6 +69,8 @@ def test_keyword_route_data_queries(message, expected_intent): ("这个基金明天会涨吗", ("reject", "predict")), ("和其他平台比哪个好", ("reject", "compare")), ("现在实时净值多少", ("reject", "realtime")), + ("我能买什么产品", ("suitability_check", "")), + ("005827的净值是多少", ("nav_query", "")), ], ) def test_keyword_route_transfer_and_reject(message, expected): diff --git a/tests/test_wave3_customer_service.py b/tests/test_wave3_customer_service.py index 662ebf3..8c55ed3 100644 --- a/tests/test_wave3_customer_service.py +++ b/tests/test_wave3_customer_service.py @@ -195,8 +195,9 @@ def test_interpret_sanitized_on_forbidden_reply(env, monkeypatch): env["llm"].responses = ["我建议您买入更多高风险产品。"] reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "我的持仓怎么样", "s1", "CUST-9527") - assert transfer is True - assert "无法提供投资建议" in reply + assert transfer is False + assert "3 只产品" in reply + assert "无法提供投资建议" not in reply def test_interpret_falls_back_to_fact_text_when_llm_down(env, monkeypatch): @@ -253,6 +254,46 @@ def test_chitchat_with_profile_context(env): assert "所在城市:上海" in user_prompt +# --------------------------------------------------------------------------- +# C-05 / C-11 数据查询扩展 +# --------------------------------------------------------------------------- + +def test_nav_query_keyword(env, monkeypatch): + monkeypatch.setitem( + cs._TOOL_BY_INTENT, "nav_query", + lambda cid, product_keyword=None, repo=None: { + "tool": "nav_query", "ok": True, "facts": [], + "fact_text": "单位净值 1.2345(净值日期 2026-09-09)", + }, + ) + env["llm"].responses = ["nav_query", "单位净值 1.2345(净值日期 2026-09-09)"] + reply, disc, intent, transfer = cs.run_customer_chat( + _ctx(), "005827的净值是多少", "s1", "CUST-9527", + ) + assert intent == "nav_query" + assert transfer is False + assert "1.2345" in reply + + +def test_eligible_products_routes_suitability_not_reject(env, monkeypatch): + monkeypatch.setitem( + cs._TOOL_BY_INTENT, "suitability_check", + lambda cid, product_keyword=None, risk_level=None, repo=None: { + "tool": "suitability_check", "ok": True, "facts": [], + "fact_text": "按您当前风评,在售产品适当性匹配汇总:", + }, + ) + env["llm"].responses = [ + "suitability_check", + "按您当前风评,在售产品适当性匹配汇总:", + ] + reply, disc, intent, transfer = cs.run_customer_chat( + _ctx(), "我能买什么产品", "s1", "CUST-9527", + ) + assert intent == "suitability_check" + assert transfer is False + + # --------------------------------------------------------------------------- # 记忆保存 / 画像抽槽节流 / 归档 # --------------------------------------------------------------------------- diff --git a/tests/test_wave3_profile_service.py b/tests/test_wave3_profile_service.py index c3c140c..fafd7a6 100644 --- a/tests/test_wave3_profile_service.py +++ b/tests/test_wave3_profile_service.py @@ -202,6 +202,53 @@ def test_render_profile_context_empty(): assert ps.render_profile_context({"basic": {"age_band": {"source": "user_declared"}}}) == "" +def test_merge_preferences_records_items_meta(): + new, diff = ps.merge_candidates( + {}, + [{"path": "investment.product_preferences", "value": "我喜欢货币基金", "source": "user_declared", "confidence": 0.85}], + ) + assert diff + entry = new["investment"]["product_preferences"] + assert entry["value"] == ["货币基金"] + meta = entry["items_meta"]["货币基金"] + assert meta["mention_count"] == 1 + assert meta["confidence"] == 0.85 + assert meta["updated_at"] + + newer, _ = ps.merge_candidates( + new, + [{"path": "investment.product_preferences", "value": "货币基金和债券基金", "source": "user_declared", "confidence": 0.9}], + ) + assert set(newer["investment"]["product_preferences"]["value"]) == {"货币基金", "债券基金"} + assert newer["investment"]["product_preferences"]["items_meta"]["货币基金"]["mention_count"] == 2 + assert newer["investment"]["product_preferences"]["items_meta"]["债券基金"]["mention_count"] == 1 + + +def test_render_profile_context_top_k_and_stale_excluded(monkeypatch): + monkeypatch.setattr(ps.settings, "profile_preference_top_k", 2) + monkeypatch.setattr(ps.settings, "profile_preference_inject_ttl_days", 90) + tags = { + "investment": { + "product_preferences": { + "value": ["货币基金", "债券基金", "指数基金", "混合基金"], + "source": "user_declared", + "confidence": 0.9, + "items_meta": { + "货币基金": {"updated_at": "2026-09-10T10:00:00", "mention_count": 3, "confidence": 0.95}, + "债券基金": {"updated_at": "2026-09-09T10:00:00", "mention_count": 2, "confidence": 0.9}, + "指数基金": {"updated_at": "2020-01-01T10:00:00", "mention_count": 5, "confidence": 0.99}, + "混合基金": {"updated_at": "2026-09-08T10:00:00", "mention_count": 1, "confidence": 0.7}, + }, + } + } + } + text = ps.render_profile_context(tags, now=ps.datetime.fromisoformat("2026-09-10T12:00:00")) + assert "指数基金" not in text + assert "货币基金" in text + assert "债券基金" in text + assert "混合基金" not in text + + # --------------------------------------------------------------------------- # merge_candidates 规则合并 # ---------------------------------------------------------------------------