跳到主要内容

【历史参考】 本文保留 FinBayes 早期工程化讨论和旧实现上下文,便于追溯。当前战略、产品定义和工程化落地以 engineering/ 子目录为准。

FinBayes 输入收束(Narrowing)设计与实现说明

覆盖 Narrowing 层「为什么这么做、做了什么、代码长在哪」。 关联资料: GitHub Issue #1。


1. 它解决什么问题

FinBayes 的入口是一个对话输入框。用户可能随口扔进来:

  • "BTC 怎么看?"
  • "美联储这次讲话怎么看?"
  • "最近有什么机会?"
  • "帮我反驳一下我看多 BTC 的逻辑"

这些自然语言对下游的分析引擎来说是结构不完整的:任务类型不明、标的不明、时间尺度不明、事件原文缺失。直接喂给 Main Agent 会发生两件糟糕的事:

  1. Agent 自由发挥地猜用户在问什么,结果与用户预期对不齐;
  2. 越界问题被静默映射到支持的类型(例如把 Alpha Discovery 当 Asset Analysis 跑),用户无法察觉。

Narrowing 层就是插在用户输入与 Main Agent 之间的结构化收束器:把模糊问题转成强类型的 Task Spec,并在动用任何重分析之前,让用户确认这份解读。

对照 PRD §6.3「问题收束机制」:可推断则自动推断 → 关键缺口才追问 → 缺失项显式作为假设(Assumption)展示。Narrowing 是这套原则在 MVP 阶段的落地形态。


2. 设计原则(三条核心 ADR)

ADR决策关键理由
0001Narrowing 是独立的 ADK LlmAgent,挂在 Main Agent (root_agent) 前面,由 Runner 编排分类 / 槽位填充可以单独评测、换模型、换 prompt;不与分析逻辑互相污染
0002MVP 只支持 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 Agentnarrowing / awaiting_confirmation / rejected
narrowing选项 / 自由文本回答Narrowing Agent (带 pending_narrowing)narrowing / awaiting_confirmation
awaiting_confirmation/confirmFinalizer(只写日志,不调 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/tasks SSE 与 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_confidence0–1,Narrowing 对分类的自评
assumptions自然语言假设列表(Important Optional 默认值的"显式化")
slot_provenance{slot 名: "user" | "inferred" | "default"},每个槽位的来源
confirmed_at/confirm 后由 Finalizer 写入

4.2 三种 Task Type 的槽位

Task TypeRequired BlockingImportant Optional(永远不追问,有默认)Nice-to-have(静默)
Asset Analysisassettime_horizon (intraday/short/mid/long, 默认 short)、analysis_angle (driver/strength_weakness/comprehensive, 默认 comprehensive)comparison_basketuser_position
Pre-trade Decision Supportassetdirection (long/short,无默认,从不追问,空值=双向)、time_horizon (intraday/short/swing/position, 默认 short)、entry_intent (默认 fresh_entry)current_price_referencerisk_aperture
Event Analysisevent_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_parserLLM 自由文本 → 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_INSTRUCTIONNarrowing 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.chatPhase 状态机 + Decision → ChatResponse 的映射见下节

5.2 /api/chat 处理顺序

server/finclaw/api/routes.pychat() 处理的顺序就是文档应当如何阅读它的顺序:

  1. 输入护栏:detect_forbidden(message) 命中交易执行禁词 → 直接 phase=rejected,不进 LLM。
  2. /confirm 短路:仅在 phase=awaiting_confirmation 时识别为确认指令,跑 finalize(),落日志,返回 phase=completed
  3. unsupported sentinel 短路:用户点了 ask_type 的"以上都不是"选项 → 直接 phase=rejected,不进 LLM。
  4. 调用 run_narrowing(...):把 prior_spec(awaiting_confirmation 阶段用)或 pending_narrowing(narrowing 阶段用)注入,得到一个 NarrowingDecision
  5. Decision → Phase 映射:
    • action=readyconfidence ≥ 0.7awaiting_confirmation(并写 task_spec_draft)
    • action=readyconfidence < 0.7 → 折叠成 ask_type(用 GENERIC_ASK_TYPE_FALLBACK 的 question/options)
    • action=rejectedrejected
    • action=ask_slotnarrowing,task_spec_drafttask_spec_partial
    • action=ask_typenarrowing,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_MODELNone(沿用 main agent 模型)允许给 Narrowing 单独跑 cheap/fast 模型
narrowing_confidence_threshold0.7< 阈值的 ready 强制折叠成 ask_type
narrowing_allow_reclassify_on_refinetrueawaiting_confirmation 阶段 Refinement 是否允许换 task_type

6. Prompt 设计要点(精华)

完整 prompt 在 server/finclaw/prompts.py:NARROWING_INSTRUCTION。关键约束:

  1. 回复只能是一个 fenced JSON 代码块(顶部"输出格式硬约束"段),用来强约束 LLM 不要前言后语。
  2. 置信度自评:句子明确归一类 → ≥ 0.85;歧义/线索不足 → < 0.7(被后端折叠成 ask_type)。
  3. 不要硬塞:< 0.7 时不给 ready,直接返回 ask_type,options 必须是 4 个固定 value(asset_analysis / pre_trade_decision_support / event_analysis / unsupported)。
  4. ask_slot 必须配 task_spec_partial:RB 缺一槽位时,把已锁的 task_type + 已填的槽位带回前端。
  5. 回答 ask_slot 之后:LLM 收到含 已填槽位 的 partial 与用户的新输入,最终返回 ready,被追问的那个 RB 槽位 slot_provenance 必须翻成 "user"
  6. Refinement(S6):当输入带「上轮 Narrowing 已得到的 Task Spec」前缀,LLM 应当
    • 保留未被触及的槽位的值 + provenance;
    • 用户明确触及的槽位 → 更新值,slot_provenance 翻成 "user",并清掉对应的旧 Assumption;
    • 可以重新分类(由 narrowing_allow_reclassify_on_refine 控制是否生效)。
  7. 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 fallbackLLM 输出 → 容错 → 兜底永不抛
S3 — narrowing_agent + /api/chat 真实联通Phase 状态机 + 高置信度自动路由
S4 — 置信度门槛折叠 + Unsupported sentinel 路由< 0.7 折叠成 ask_type;前端"以上都不是"短路 reject
S5 — ask_slottask_spec_partial + user provenance761d71b / 1e24b8d
S6 — Refinement 在 awaiting_confirmation 阶段重跑 Narrowingbb7e198;受 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.tsweb/src/domains/chat/store/narrowing-session.test.tsweb/src/domains/sovereign/components/NarrowingQuestionCard.test.tsx

9. 当前 MVP 之外的事

明确不在这一期的范围(避免误解):

  1. Dashboard 生成:/confirm 后 Main Agent 还没接到 /api/chat。当前 completed 阶段只是占位,真正的 widget dashboard 需要后续把 Main Agent 接回来,再走 PRD V2 §9 的多 widget 路径。
  2. Alpha Discovery / Risk Challenge:故意不支持(ADR 0002)。它们走 Unsupported 拒绝桶,这本身是产品信号——日志里 Unsupported 的占比就是"什么时候该加进来"的需求量信号。
  3. Task Spec 持久化到数据库:目前是 append-only 日志 (server/data/task_specs.log),足够给当前阶段做路由质量分析。
  4. 多 Dashboard 历史 UI / 导出 / 分享:都依赖真实 Dashboard 落地,留给后续阶段。
  5. /api/chat 的 SSE 流:Narrowing 是同步请求 / 响应。等 Main Agent 接回来时再加 SSE 端点。

10. 一句话总结

Narrowing 是一道纯结构化、可评测的"前置闸门":把任何模糊金融问题压成一个强类型 TaskSpec,只追问真正会卡住后续分析的 RB 槽位,所有 IO 默认值都显式作为 Assumption 暴露给用户,在用户敲下"开始分析"之前,我们与用户对"接下来要做什么"达成共识。