Skip to content

feat(research): 记录证据的原始 passage 位置,重载后仍可定位 - #251

Open
nanhanq1 wants to merge 2 commits into
helsome:mainfrom
nanhanq1:feat/13-evidence-passage-location
Open

nanhanq1 wants to merge 2 commits into
helsome:mainfrom
nanhanq1:feat/13-evidence-passage-location

Conversation

@nanhanq1

@nanhanq1 nanhanq1 commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

改动说明

为 Issue #13 补上证据的 passage/span 级定位——这是「claim → 具体原始 passage」跳转所缺的那层数据。

现状:EvidenceRef 只记录「哪个 capability run 产出了这条证据」(capabilityId + runId + fetchedAt)。而一次 research.news 的 run 会带回多条新闻,报告里却只有 run 级别的引用,因此从 claim 跳过去只能落到来源集合,落不到具体那一段话。

本 PR 做了什么:

  1. 新增 packages/core/src/evidence-passage.ts:SourcePassage / PassageLocation 契约。定位信息包含 source/document identity(documentId、canonicalUrl)、逐字 excerpt、以及 location(start/end 字符偏移 + 1-based line/column)——即 issue 里要求的「source/document identity、excerpt 和 location metadata」三件套。
  2. 新增 packages/shared/src/research/evidence-passage.ts:
    • locatePassage(sourceText, excerpt, { occurrence }):确定性定位。只容忍空白差异(换行/缩进不同仍能命中),其余字符必须逐字相同;偏移始终指向原始文本,所以 sourceText.slice(start, end) 就是那段证据本身。定位不到返回 undefined(fail-closed),绝不回退到相近偏移——那正是要防的「编造引用」。
    • relocatePassage(passage, sourceText):重载后按当前文本重新推导位置;来源已变则如实返回 undefined,不使用存下来的旧偏移(旧偏移恰好仍在范围内会高亮到无关文字)。
    • buildCapabilityPassages({ runId, capabilityId, result }):从能力结果构建 passage。只处理自带可检索文本的来源(当前是 research.news),结构化数据能力返回 []——CapabilityResult.summary 是 Folio 自己生成的散文,把它当 passage 会凭空制造出处。
    • passagesForEvidence(passages, { runId, capabilityId }):evidence → passage 方向;passage 自身带 runId/capabilityId,反向天然成立,无需额外索引。
    • passageIdOf:id 由 run + capability + 文档身份 + span 派生,与数组位置无关(重排带 id 的新闻不会改 id;只有来源无文档身份时才用位置兜底)。
  3. 接线:assembleReport 在组装报告时从 run outcome 派生 passage,写入 ResearchReport.evidencePassages。因为派生自真实 run outcome 而不是模型输出的正文,位置不可能被模型编出来。字段可选,且仅在该 run 确实产出 passage 时写入。

多对多:一条 evidence(一个 run)可以产出多条 passage;passage 上带 runId + capabilityId,所以「一个结论多条证据」「一条证据支撑多个结论」都天然成立,没有引入 1:1 schema。

关联 Issue

Related to #13

说明:按 CONTRIBUTING「大 Issue 可以拆成多个可独立合入的小 PR」,本 PR 只覆盖 issue 的一个独立子能力,因此用 Related to 而不是 Closes——写 Closes 会在合并时关掉整个 issue,而验收项里的 claim verifier 接线、报告 UI 双向跳转、真实模型 E2E 都不在本 PR 范围内。

测试报告(正式审核前必填)

环境

  • Bun:1.4.2(bun install --frozen-lockfile)
  • OS:Microsoft Windows 11 家庭中文版 10.0.26200(AMD64)
  • 基线:upstream/main @ ba5dcdf(fix(portfolio-risk): 打通「临近财报」信号与 research.events 的真实契约 (#178))

实际执行命令与结果

bun test packages/shared/src/research/evidence-passage.test.ts
→ 21 passed / 0 failed / 40 expect() calls

bun test packages/shared/src/research/runner.test.ts packages/shared/src/research/evidence-passage.test.ts
→ 28 passed / 0 failed / 91 expect() calls

bun test packages/shared/src/research/
→ 96 passed / 0 failed / 323 expect() calls

bun test packages/core --isolate
→ 53 passed / 0 failed / 121 expect() calls

bun test packages/shared --isolate
→ 1116 passed / 22 failed / 1138 tests across 100 files(22 项见下)

bun run typecheck
→ core / i18n / shared / ui / electron 全部 exit 0

git diff --check
→ exit 0

# secret scan(照搬 .github/workflows/pr.yml 的 grep 规则)
→ No secret patterns found.

# 补验收后的聚焦复跑
bun test --isolate packages/shared/src/research packages/core --timeout 20000
→ 185 passed / 0 failed / 540 expect() calls across 21 files

已知失败 / Baseline

bun test packages/shared --isolate 的 22 项失败全部与本 PR 无关,均可复现于当前 main:

  • 21 项在 packages/shared/src/providers/longbridge/longbridge.test.ts(LongbridgeFinancialDataProvider / LongbridgeBrokerAccountProvider):本机没有 Longbridge CLI 与凭据。
  • 1 项 langfuse backend > does not throw when Langfuse is down:依赖外部 Langfuse 端点。

A/B 对照(同一命令,回退本 PR 的生产代码后再跑一遍):

bun test packages/shared/src/providers/longbridge packages/shared/src/evaluation/langfuse
带改动:  30 pass / 22 fail
基线(无改动):30 pass / 22 fail   ← 完全一致
  • 已提供实际测试命令与 pass/fail 结果
  • 已说明测试环境
  • 如果存在已知 baseline / 环境失败,已提供 main 对照或说明
  • 核心改动已有对应 focused test / smoke / integration 验证

与改动对应的验证(要点)

  • fail-closed:定位不到、excerpt 为空、excerpt 超过 400 词、occurrence 越界,一律返回 undefined;改写一个词的「近似句」不会被匹配上。
  • 偏移可信:sourceText.slice(start, end) 回切即得到该 passage;多行来源的 line/column 有独立断言。
  • 重载后仍可定位(集成):runner.test.ts 里 keeps a passage jump valid after the report is persisted and reloaded 走真实链路 —— ResearchRunner → assembleReport → ResearchReportRepository → JsonFileStore 落盘 → 新的 store 实例重新读盘 → passagesForEvidence 解出 passage → relocatePassage 用原始来源文本复算位置并与落盘位置一致。对应验收项「claim → evidence 的跳转在报告重新加载后仍然有效,并定位到具体原始 passage」。
  • 不伪造出处:结构化数据能力(如 market.quote)不产出 passage,报告里 evidencePassages 保持缺席。

真实 Deep Research E2E(issue #13 验收项 7/8)

验收项 7 要求「至少完成 1 次真实 Deep Research E2E,走真实模型 + 实际 retrieval/search 路径;只跑 mock / fixture 不算完成」。本 PR 已补:

  • 新增 apps/electron/e2e/evidence-passage-live-acceptance.ts:只走生产路径 —— 真实模型(HTTP 流式)+ 真实 AgentKernel + 生产 createAgentSynthesizer + 生产 research.news 能力(只注入 getNews fetcher)+ 生产 ResearchService/ResearchRunner/assembleReport + 真实 ResearchReportRepository。没有凭据或没有可达检索源时直接失败,不留 fixture 退路。
  • 新增 docs/acceptance/evidence-passage/:本次真实运行的产物。
  • 新增 docs/evidence-passage-test-report.md:验收报告。

实测结果(2026-09-28,commit 963d0ff):

证据 结果
模型 deepseek-v4-pro,真实流式 API(2068 prompt + 2635 completion tokens)
检索 49 条实时新闻(真实 HTTPS 抓取)
记录 passage 49 条,一篇对应一条
偏移可重定位 全部通过:relocatePassage 回到同一位置,且 sourceText.slice(start, end) 等于记录的 excerpt
evidence → passage 每条 EvidenceRef 都解析到至少一条 passage
重载后仍有效 换全新 repository 实例读回,passage 逐字节相同且全部仍可重定位

如实披露两点:

  1. 检索源不是 Longbridge。验收环境没有 Longbridge CLI 与凭据,因此只替换了生产能力的 getNews fetcher,改用实时公开 RSS 源;生产 Longbridge fetcher 仍是该能力的默认值,并由 feat: 为 Deep Research 增加持久化检查点与崩溃恢复 #80 的 research-recovery-kernel.ts 端到端覆盖。另外 createResearchNewsCapability 把 provenance.provider 写死为 'longbridge',所以这次运行的 checkpoint 里该字段仍显示 longbridge —— 这是既有行为,不是本 PR 引入的。
  2. 验收项 7/8 的其余部分不在本 PR:claim 身份与双向映射(feat: 为研究报告引入 claim 身份与 claim↔evidence 双向映射 #67)、verifier 接线(feat(research): 增加 claim verifier 核心 #90 只合了库、无调用方)、报告 UI 双向跳转与录屏(无人认领)目前都还没有代码,因此本次 E2E 只覆盖本 PR 的 passage 定位切片,报告里已明确标注 scope。

UI 截图(仅可见 UI 变化时必填)

  • 本 PR 无可见 UI 变化(无需截图)

Scope / 后续

本 PR 包含:

  • passage/span 级定位的数据契约(source/document identity + 逐字 excerpt + location metadata)
  • 确定性、fail-closed 的定位器,以及重载后的复算
  • 报告组装时接线并随报告持久化
  • evidence → passage(一对多);passage → evidence(passage 自带 runId/capabilityId)

本 PR 不包含(留给后续 PR):

  • claim 身份与 claim ↔ evidence 双向映射:PR feat: 为研究报告引入 claim 身份与 claim↔evidence 双向映射 #67 正在处理,本 PR 未复制其类型、也未与其重叠
  • claim 级 verifier 接线:core 已由 feat(research): 增加 claim verifier 核心 #90 合入,聚合接线见 feat(research): 基于统一证据 bundle 聚合研报 Claim 核验 #155
  • 报告 UI 的 claim → passage 跳转与高亮、passage → claim 反查:本 PR 只提供后端数据(issue 的 Notes 明确要求「不要只做 citation UI;后端必须存在稳定、可持久化、可供评测消费的 claim/evidence 数据关系」)
  • 非文本来源(filing / 研究文档)的 passage 抽取:buildCapabilityPassages 目前只认 research.news,其余返回 [],不会为结构化数据硬造一个位置
  • 真实模型 + 实际 retrieval 的 Deep Research E2E 的其余部分:本 PR 已补上 passage 定位切片的真实 E2E(见上一节),但 claim 身份、verifier 输出、双向映射与报告 UI 跳转仍无代码,所以 issue 验收项 7/8 对本 PR 而言只完成了 passage 定位那一段,其余如实标注为未完成。

与在途 PR 的关系(如实披露):本 PR 修改了 packages/core/src/research.ts 与 packages/shared/src/research/runner.ts,这两个文件 PR #67、#164 也在改。本 PR 对它们只做追加(新增一个可选字段 + 一处派生逻辑),没有改动任何既有字段、既有行为或既有导出。如果维护者希望避免同文件冲突,我可以把该字段改为独立的持久化产物,或等前置 PR 合并后 rebase。

Issue helsome#13 要求 evidence 定位到原始 source 的具体 passage/span,而不是只保存
run/URL 级引用。此前 EvidenceRef 只记录产出该证据的 capability run,而一条
research.news 的 run 会带回多条新闻,从 claim 跳过去只能落到来源集合。

- 新增 SourcePassage / PassageLocation 契约:source/document identity、
  逐字 excerpt、以及 start/end 字符偏移与 1-based line/column
- 新增确定性定位器 locatePassage:只容忍空白差异,其余逐字匹配;定位不到
  返回 undefined(fail-closed),绝不回退到相近偏移
- relocatePassage 在重载后按当前文本复算位置;来源变了如实返回 undefined
- buildCapabilityPassages 只处理自带可检索文本的来源(当前 research.news),
  结构化数据能力返回空,不把 Folio 自己生成的 summary 当 passage
- assembleReport 接线,写入可选的 ResearchReport.evidencePassages
issue helsome#13 的验收项 7/8 要求真实 Deep Research E2E,只跑 fixture 不算完成。
本 PR 的切片是 passage 级证据定位,因此补一个只走生产路径的验收:

- apps/electron/e2e/evidence-passage-live-acceptance.ts
  真实模型(HTTP 流式)+ 真实 AgentKernel + 生产 createAgentSynthesizer
  + 生产 research.news 能力(仅注入 getNews fetcher)+ 生产 ResearchService。
  断言四组:真实 retrieval 产出 evidencePassages;每条 passage 的 offset
  能 relocate 回同一位置且 slice 等于 excerpt;每条 EvidenceRef 能经
  passagesForEvidence 解析到 passage;换全新 repository 实例重载后仍成立。
  没有凭据/没有可达检索源时直接失败,不留 fixture 退路。

- docs/acceptance/evidence-passage/
  真实运行产物:deepseek-v4-pro + 49 条实时新闻 → 49 条 passage 全部可重定位。

- docs/evidence-passage-test-report.md
  验收报告。明确写出本切片**不覆盖** claim 身份、verifier 输出、双向
  claim↔evidence 映射与报告 UI 跳转(分别依赖 helsome#67 / helsome#90 接线 / 未认领),
  并如实记录注入 fetcher 时 provenance.provider 仍为 'longbridge' 的既有行为。

Related to helsome#13
@nanhanq1

Copy link
Copy Markdown
Contributor Author

请求批准合并

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant