【历史参考】 本文保留 FinBayes 早期工程化讨论和旧实现上下文,便于追溯。当前战略、产品定义和工程化落地以
engineering/子目录为准。
FinBayes 输入收束(Narrowing)设计与实现说明
覆盖 Narrowing 层「为什么这么做、做了什么、代码长在哪」。 关联资料: GitHub Issue #1。
1. 它解决什么问题
FinBayes 的入口是一个对话输入框。用户可能随口扔进来:
- "BTC 怎么看?"
- "美联储这次讲话怎么看?"
- "最近有什么机会?"
- "帮我反驳一下我看多 BTC 的逻辑"
这些自然语言对下游的分析引擎来说是结构不完整的:任务类型不明、标的不明、时间尺度不明、事件原文缺失。直接喂给 Main Agent 会发生两件糟糕的事:
- Agent 自由发挥地猜用户在问什么,结果与用户预期对不齐;
- 越界问题被静默映射到支持的类型(例如把 Alpha Discovery 当 Asset Analysis 跑),用户无法察觉。
Narrowing 层就是插在用户输入与 Main Agent 之间的结构化收束器:把模糊问题转成强类型的 Task Spec,并在动用任何重分析之前,让用户确认这份解读。
对照 PRD §6.3「问题收束机制」:可推断则自动推断 → 关键缺口才追问 → 缺失项显式作为假设(Assumption)展示。Narrowing 是这套原则在 MVP 阶段的落地形态。
2. 设计原则(三条核心 ADR)
| ADR | 决策 | 关键理由 |
|---|---|---|
| 0001 | Narrowing 是独立的 ADK LlmAgent,挂在 Main Agent (root_agent) 前面,由 Runner 编排 | 分类 / 槽位填充可以单独评测、换模型、换 prompt;不与分析逻辑互相污染 |
| 0002 | MVP 只支持 3 种 Task Type:Asset Analysis / Pre-trade Decision Support / Event Analysis。Alpha Discovery 与 Risk Challenge 走显式 Unsupported 桶 | 这 3 类有可枚举的 Required Blocking 槽位,适合槽位填充;另外两类天然开放式,会破坏路由稳定性。显式拒绝优于硬塞 |
| 0003 | 槽位三分级:Required Blocking 才追问;Important Optional 永远不追问,用默认值并记入 Assumption;Nice-to-have 静默默认 | 多问一句换来的精度收益小,但路由 / UX 的确定性损失大。让用户在 Confirmation 卡片上用 Refinement 改默认值,体验更顺 |
简记:"分类清晰 + 槽位最少必要 + 缺失项透明可改"。
3. 状态机(Phase)
/api/chat 协议定义 4 个 Phase。同一时刻响应里只有一个 sibling payload 非空。
┌──────────────────────────────┐
│ (no session) idle │
└──────────────┬───────────────┘
│ 用户首次发送
▼
┌──────────────┐
┌──────────────►│ narrowing │
│ └───┬──────┬───┘
│ ask_slot/ │ │ ready & conf ≥ 0.7
│ 低置信兜底 │ │
│ ask_type │ ▼
│ │ ┌────────────────────────┐
│ │ │ awaiting_confirmation │
│ │ └─┬──────────────┬───────┘
│ │ │ /confirm │ 非 /confirm 文本
│ │ ▼ │ (= Refinement)
│ │ ┌──────────┐ │
│ │ │ completed │ │
│ │ └──────────┘ │
└───────────────────┼──────────────────┘
▼ action: rejected / 命中 guardrail
┌──────────┐
│ rejected │
└──────────┘
转移表:
| 当前 Phase | 用户动作 | 实际执行 | 下一个 Phase |
|---|---|---|---|
| (无 session) | 第一句话 | Narrowing Agent | narrowing / awaiting_confirmation / rejected |
| narrowing | 选项 / 自由文本回答 | Narrowing Agent (带 pending_narrowing) | narrowing / awaiting_confirmation |
| awaiting_confirmation | /confirm | Finalizer(只写日志,不调 LLM) | completed |
| awaiting_confirmation | 任何非 /confirm 文本 | Narrowing Agent (带 prior_spec,Refinement) | narrowing / awaiting_confirmation |
| completed / rejected | 新消息 | Narrowing Agent (起一份新 spec,同 session) | narrowing / ... |
MVP 中 Main Agent 没有挂上
/api/chat:/confirm后只走 Finalizer 写一行结构化日志,然后返回completion stub。/api/tasksSSE 与 legacy MarketIntelligence 流不受影响。
4. Task Spec 数据形状
Task Spec 是 Pydantic 判别联合(discriminator="task_type"),代码在 server/finclaw/task_spec.py。
TaskSpec = AssetAnalysisSpec | PreTradeSpec | EventAnalysisSpec
4.1 公共信封字段(_BaseSpec)
| 字段 | 含义 |
|---|---|
original_query | 用户原句,永远保留 |
task_type_confidence | 0–1,Narrowing 对分类的自评 |
assumptions | 自然语言假设列表(Important Optional 默认值的"显式化") |
slot_provenance | {slot 名: "user" | "inferred" | "default"},每个槽位的来源 |
confirmed_at | /confirm 后由 Finalizer 写入 |
4.2 三种 Task Type 的槽位
| Task Type | Required Blocking | Important Optional(永远不追问,有默认) | Nice-to-have(静默) |
|---|---|---|---|
| Asset Analysis | asset | time_horizon (intraday/short/mid/long, 默认 short)、analysis_angle (driver/strength_weakness/comprehensive, 默认 comprehensive) | comparison_basket、user_position |
| Pre-trade Decision Support | asset | direction (long/short,无默认,从不追问,空值=双向)、time_horizon (intraday/short/swing/position, 默认 short)、entry_intent (默认 fresh_entry) | current_price_reference、risk_aperture |
| Event Analysis | event_text(典型情况下等于原句) | event_type (默认 news)、affected_assets_hint (默认 [])、time_horizon (short/mid/long, 默认 short) | source_url |
设计要点:
time_horizon的枚举集在不同 Task Type 下故意不同(Pre-trade 有swing/position,Event 没有intraday)。这种"每个 spec 自带枚举"是判别联合的好处:把误用挡在类型层。
4.3 NarrowingDecision —— LLM 单轮的输出契约
Narrowing Agent 一次调用产出一个 NarrowingDecision:
action: "ready" | "ask_type" | "ask_slot" | "rejected"
task_spec: # 仅 action=ready 时存在
task_spec_partial: # 仅 action=ask_slot 时存在(已锁的 task_type + 已填槽位,RB 未补齐处填 null)
question / options / asked_slot / reasoning
task_spec_partial 是 S5 引入的关键:当 Agent 决定追问一个 RB 槽位时,必须同时把"半成品"的 spec 一起带回前端,让用户在等待补全时也能看到已锁定的 task_type 与已填的槽位卡片。
5. 后端实现地图
server/finclaw/
├── task_spec.py # 判别联合 + NarrowingDecision
├── narrowing_parser.py # LLM 文本 → NarrowingDecision(容错 + 通用兜底)
├── narrowing_agent.py # ADK LlmAgent + run_narrowing() 入口
├── prompts.py # NARROWING_INSTRUCTION + build_narrowing_input
├── finalizer.py # /confirm 落日志 + 返回 CompletionPayload
├── api/routes.py # /api/chat 协议 + phase 状态机
└── config.py # 三个 Narrowing 相关开关
5.1 关键模块职责
| 模块 | 职责 | 关键点 |
|---|---|---|
task_spec | 数据形状的唯一来源 | 用 Annotated[Union[...], Field(discriminator="task_type")] 让 Pydantic 自动按 task_type 分发;NarrowingDecision 有 model validator 保证 action="ready" 时 task_spec 不为空 |
narrowing_parser | LLM 自由文本 → NarrowingDecision | 先抓 fenced ```json,再回退到贪婪 {...};任何一步失败 → 返回 GENERIC_ASK_TYPE_FALLBACK(永远不抛、永远不 5xx) |
narrowing_agent | 包裹 ADK LlmAgent,提供 run_narrowing(...) | 懒构造(测试可 monkeypatch);每轮用一个全新的 in-memory ADK session(多轮上下文走 prior_spec / pending_narrowing 注入,而不是 ADK 会话);复用 Main Agent 的 _before_agent_callback 做交易禁词护栏 |
prompts.NARROWING_INSTRUCTION | Narrowing Agent 的 system prompt | 顶部"输出格式硬约束"段;Slot 三级规则;Refinement 规则(S6);10 个示例覆盖明确 / 歧义 / Unsupported / ask_slot+partial / Refinement |
prompts.build_narrowing_input | 拼用户消息上下文 | 三种形态:首问 / 带 pending_narrowing(narrowing 中接续) / 带 prior_spec(awaiting_confirmation 中 Refinement) |
finalizer.finalize | /confirm 后的纯函数 | 追加一行 JSON 到 server/data/task_specs.log,11 个字段(envelope 2 + PRD-tracked 9);返回 CompletionPayload |
api/routes.chat | Phase 状态机 + Decision → ChatResponse 的映射 | 见下节 |
5.2 /api/chat 处理顺序
server/finclaw/api/routes.py 中 chat() 处理的顺序就是文档应当如何阅读它的顺序:
- 输入护栏:
detect_forbidden(message)命中交易执行禁词 → 直接phase=rejected,不进 LLM。 /confirm短路:仅在phase=awaiting_confirmation时识别为确认指令,跑finalize(),落日志,返回phase=completed。unsupportedsentinel 短路:用户点了 ask_type 的"以上都不是"选项 → 直接phase=rejected,不进 LLM。- 调用
run_narrowing(...):把prior_spec(awaiting_confirmation 阶段用)或pending_narrowing(narrowing 阶段用)注入,得到一个NarrowingDecision。 - Decision → Phase 映射:
action=ready且confidence ≥ 0.7→awaiting_confirmation(并写task_spec_draft)action=ready且confidence < 0.7→ 折叠成ask_type(用GENERIC_ASK_TYPE_FALLBACK的 question/options)action=rejected→rejectedaction=ask_slot→narrowing,task_spec_draft用task_spec_partialaction=ask_type→narrowing,task_spec_draft = null
5.3 Session 状态(MVP 还在内存里)
_SESSIONS[sid] = {
"phase": ...,
"draft": TaskSpec | None, # awaiting_confirmation 锁定的 spec
"turns": int, # Narrowing 调用计数
"had_ask_type": bool, # 本轮是否问过 type
"had_ask_slot": bool, # 本轮是否问过 slot
"original_query": str | None, # narrowing 阶段保留原句,供 ask_type→ask_slot 接续
"last_asked_slot": str | None,
"partial_draft": dict | None, # 上轮 ask_slot 时带回的 task_spec_partial
}
这是 S3 的临时形态。迁到 ADK session state(
state["task_spec_draft"]/state["narrowing_phase"]/state["narrowing_turn_count"])是 S4+ 的事情。
5.4 配置开关(server/finclaw/config.py)
| 环境变量 / 字段 | 默认值 | 含义 |
|---|---|---|
FINCLAW_NARROWING_MODEL | None(沿用 main agent 模型) | 允许给 Narrowing 单独跑 cheap/fast 模型 |
narrowing_confidence_threshold | 0.7 | < 阈值的 ready 强制折叠成 ask_type |
narrowing_allow_reclassify_on_refine | true | awaiting_confirmation 阶段 Refinement 是否允许换 task_type |
6. Prompt 设计要点(精华)
完整 prompt 在 server/finclaw/prompts.py:NARROWING_INSTRUCTION。关键约束:
- 回复只能是一个 fenced JSON 代码块(顶部"输出格式硬约束"段),用来强约束 LLM 不要前言后语。
- 置信度自评:句子明确归一类 →
≥ 0.85;歧义/线索不足 →< 0.7(被后端折叠成 ask_type)。 - 不要硬塞:
< 0.7时不给ready,直接返回ask_type,options 必须是 4 个固定 value(asset_analysis/pre_trade_decision_support/event_analysis/unsupported)。 - ask_slot 必须配
task_spec_partial:RB 缺一槽位时,把已锁的 task_type + 已填的槽位带回前端。 - 回答 ask_slot 之后:LLM 收到含
已填槽位的 partial 与用户的新输入,最终返回ready,被追问的那个 RB 槽位slot_provenance必须翻成 "user"。 - Refinement(S6):当输入带「上轮 Narrowing 已得到的 Task Spec」前缀,LLM 应当
- 保留未被触及的槽位的值 + provenance;
- 用户明确触及的槽位 → 更新值,
slot_provenance翻成"user",并清掉对应的旧 Assumption; - 可以重新分类(由
narrowing_allow_reclassify_on_refine控制是否生效)。
- 10 个示例覆盖:明确归类、ask_type、Unsupported 拒绝、ask_type→ask_slot 串联(asset 缺失 vs event_text 直接取原句)、ask_slot 后用户补全、Refinement 改
time_horizon。
7. 前端实现地图
web/src/
├── foundation/api/chat.ts # 类型 + postChat(4xx → Rejected,5xx 抛错)
├── domains/chat/store/narrowing-session.ts # Zustand store:sessionId / phase / messages / status
├── domains/sovereign/components/
│ ├── NarrowingQuestionCard.tsx # ask_type / ask_slot 卡片(按钮 + 自由文本)
│ ├── CompletionStub.tsx # /confirm 后的占位
│ └── (原 AnalysisDashboard / AnalysisLoading / CollapsedCard 源码保留,待 Main Agent 接回 /api/chat 后复用)
└── pages/home/show.tsx # 入口:render phase-appropriate card
关键设计:
ChatResponse是判别联合(由phase区分),TS 类型上保证四个 sibling payload 同时只有一个非空。postChat把 4xx 折叠成phase=rejected(HTTP 协议错 → 业务上同样是"被拒"),5xx 抛错走顶层 toast。- Store 单一来源:
sessionId / phase / lastResponse / messages / status;confirm()内部就是sendMessage("/confirm"),/confirm是 wire 约定,不是用户可见字符串(前端用"开始分析"按钮包它)。
8. 现状 / 进度
| 阶段 | 状态 | 备注 |
|---|---|---|
| S1 — Pydantic 判别联合 + 单测 | ✅ | task_spec.py 三种 spec + NarrowingDecision 都有测试 |
S2 — narrowing_parser + Generic fallback | ✅ | LLM 输出 → 容错 → 兜底永不抛 |
S3 — narrowing_agent + /api/chat 真实联通 | ✅ | Phase 状态机 + 高置信度自动路由 |
| S4 — 置信度门槛折叠 + Unsupported sentinel 路由 | ✅ | < 0.7 折叠成 ask_type;前端"以上都不是"短路 reject |
S5 — ask_slot 带 task_spec_partial + user provenance | ✅ | 见 761d71b / 1e24b8d |
| S6 — Refinement 在 awaiting_confirmation 阶段重跑 Narrowing | ✅ | 见 bb7e198;受 narrowing_allow_reclassify_on_refine 控制 |
S7+ — Session state 迁到 ADK SessionService、Main Agent 接回 /api/chat、Dashboard 渲染 | ⏳ | 见 §9 |
测试位置:
- 后端:
server/tests/(pytest>=8.0,pytest-asyncio)。判别联合 round-trip、parser 兜底、finalizer 日志字段、phase 状态机。 - 前端:
web/src/foundation/api/chat.test.ts、web/src/domains/chat/store/narrowing-session.test.ts、web/src/domains/sovereign/components/NarrowingQuestionCard.test.tsx。
9. 当前 MVP 之外的事
明确不在这一期的范围(避免误解):
- Dashboard 生成:
/confirm后 Main Agent 还没接到/api/chat。当前completed阶段只是占位,真正的 widget dashboard 需要后续把 Main Agent 接回来,再走 PRD V2 §9 的多 widget 路径。 - Alpha Discovery / Risk Challenge:故意不支持(ADR 0002)。它们走
Unsupported拒绝桶,这本身是产品信号——日志里 Unsupported 的占比就是"什么时候该加进来"的需求量信号。 - Task Spec 持久化到数据库:目前是 append-only 日志 (
server/data/task_specs.log),足够给当前阶段做路由质量分析。 - 多 Dashboard 历史 UI / 导出 / 分享:都依赖真实 Dashboard 落地,留给后续阶段。
/api/chat的 SSE 流:Narrowing 是同步请求 / 响应。等 Main Agent 接回来时再加 SSE 端点。
10. 一句话总结
Narrowing 是一道纯结构化、可评测的"前置闸门":把任何模糊金融问题压成一个强类型
TaskSpec,只追问真正会卡住后续分析的 RB 槽位,所有 IO 默认值都显式作为 Assumption 暴露给用户,在用户敲下"开始分析"之前,我们与用户对"接下来要做什么"达成共识。