fix(parser/email): align metadata/text_html emission with Python flow contract (#18005)

Aligns the Go EML parser (`internal/parser/parser/email_parser.go`) with the Python flow parser (`rag/flow/parser/parser.py:_email`) on two structural points so the Go path is a faithful drop-in for the Python path on `.eml` inputs.
This commit is contained in:
Jack
2026-08-07 21:57:51 +08:00
committed by GitHub
parent 4b4a6e72f0
commit 4f15e261cd
4 changed files with 439 additions and 9 deletions

243
PARSER_ALIGNMENT_HANDOFF.md Normal file
View File

@@ -0,0 +1,243 @@
# Parser 层 Go ↔ Python 一致性对齐 Handoff 文档
> 用途多会话并行处理「Go 与 Python 在 Parser 层是否对同一文件产出相同结构」的核对与收敛工作。
> 本文档自包含,新会话只需读本文即可独立开工,无需回看原对话。
> 范围:**排除 PDF 解析器**PDF 走独立原生后端 `internal/deepdoc/parser/` + `pdf_vision_dispatch.go`,不在此 scope
---
## 0. 目标与验收标准
**目标**:确认 DSL `File → Parser → Chunker → Extractor → Tokenizer` 中「Parser」这一独立阶段在 Go 与 Python 两端对相同输入文件是否产出**相同结构**。
**验收标准("相同结构"的定义)**:对同一份输入文件,
- 两端 Parser 阶段的输出**条目数量item 数)应一致**
- 每条目的**关键字段与取值应一致**(至少 `text` / `doc_type_kwd` 一致;若带 `ck_type` / `image` 等,约定以某一端为准对齐);
- 文本切分边界(段落 / 块 / 表格)应一致。
**重要**这里的「Parser 阶段输出」指
- Go`ParseResultProducer.ParseWithResult(...)` 返回的 `ParseResult`(字段 `JSON`/`Markdown`/`Text`/`HTML` + `File`
- Pythonflow`rag/flow/parser/parser.py``Parser._invoke``set_output` 产出的 `sections` 列表(每项形如 `{text, doc_type_kwd, image?, ck_type?}`)。
---
## 1. 关键定位纠正(务必先读)
**正确的 Python 对应物是 `rag/flow/parser/parser.py`,不是 `deepdoc/parser/*` + `rag/app/naive.py`。**
- 原因:你给的 DSL 是**分阶段**的Go 端 `internal/parser/parser/*` 的移植注释明确引用 `rag/flow/parser/parser.py:_invoke``set_output``internal/ingestion/component/parser.go:392` 注释。Go 的 `ParseResult` 契约移植自 `port-rag-flow-pipeline-to-go.md §4.2/§6.5`
- `deepdoc/parser/*` + `rag/app/naive.py`**naive 摄取路径**,它把 parser+chunker+tokenizer **融合**在一个 `chunk()` 里,**不是**独立 Parser 阶段的对应物。但注意:**flow 解析器内部又委托了 naive 解析器**`_markdown``naive.Markdown``_html``HtmlParser``_docx``naive.Docx`),所以 naive 解析器是底层实现,阶段边界仍以 `rag/flow/parser/parser.py` 为对齐面。
**架构根因(必须在任何收敛方案里承认)**
- Go 把 Parser 当作**干净的「未切块」阶段**`ParseResult` → 重塑成 `schema.Page``internal/ingestion/component/parser_dispatch.go``jsonItemsToPages`/`buildParserOutputs`)→ 交给下游 Chunker 才做 token 切块。
- Python flow 解析器复用了 naive 解析器,而 naive 解析器html/txt/epub 尤其)**在 parser 内部就做了 token 切块**。因此即便输出 key 名一致parser 阶段的粒度已经不同。
---
## 2. 类型 ↔ 实现映射与一致性总览
| 类型 | Go 实现 | Python(flow) 实现 | 默认输出格式 | 一致性 | 风险等级 |
|---|---|---|---|---|---|
| markdown | `internal/parser/parser/markdown_parser.go` | `rag/flow/parser/parser.py:_markdown``naive.Markdown` | json | ❌ 不一致 | 高 |
| html | `internal/parser/parser/html_parser.go` | `rag/flow/parser/parser.py:_html``HtmlParser` | json | ❌ 不一致 | 高 |
| text&code | `internal/parser/parser/text_parser.go` | `rag/flow/parser/parser.py:_code``TxtParser` | json | ❌ 不一致 | 高 |
| docx | `internal/parser/parser/docx_parser.go`office_oxide | `parser.py:_docx``naive.Docx`python-docx | json | ⚠️ 结构类似,引擎不同 | 中 |
| xlsx/xls/csv | `xlsx_parser.go` / `xls_parser.go` / `csv_parser.go` | `parser.py:_spreadsheet``ExcelParser` | html | ⚠️ 默认 html待细核 | 中 |
| pptx/ppt | `parser/pptx_parser.go`office_oxide | `parser.py:_slides``RAGFlowPptParser` | json | ⚠️ 引擎不同 | 中 |
| epub | `parser/epub_parser.go` | `parser.py:_epub``EpubParser` | json | ✅ 基本一致 | 低 |
| email | `parser/email_parser.go` | `parser.py:_email` | json/text | ✅ 基本一致 | 低 |
| doc | `parser/doc_parser.go`office_oxide | `parser.py:_doc`tika | json | ❌ 格式不同 | 中 |
| json | `parser/json_parser.go` | **flow 无对应分支** | json | — Go 独有 | — |
---
## 2.1 Go 端 Parser 的 item 模型(对拍基础)
对拍时反复提到的「item」在 Go 端有精确定义,先统一术语,避免与下游 `schema.Page` 混淆。
### item 是什么
- **item = `ParseResult.JSON` 里的一个 `map[string]any` 元素**`internal/parser/parser/parse_result.go:48`),代表从文件解析出的**一个逻辑单元**(块 / 段落 / 表格 / 图片 / 行 / 幻灯片…)。
- 关键字段:
- `text`:该单元文本(表格单元里是 HTML 字符串)
- `doc_type_kwd`:类型,取值 `text` / `image` / `table`
- 可选 `ck_type`更细分类heading/paragraph/list/code/quote/table/image
- 可选 `image`base64 图片;`page_number`:页码
- **仅当 `OutputFormat=="json"` 时才存在 item 列表**。markdown/text/html 输出格式的解析器产出的是**单个字符串**`ParseResult.Markdown`/`Text`/`HTML`reshape 后变成 **1 个 `schema.Page`****没有 JSON item**。
- 下游 dispatch`internal/ingestion/component/parser_dispatch.go``OutputFormat=="json"` → 每个 item 一个 page`jsonItemsToPages``markdown/text/html` → 整段作 1 个 page未知 → 按 `\f` 切页。所以「item 数」对比**只对 json 输出格式有意义**。
### item 数量(因格式与输入而异,无固定值)
| 格式 | OutputFormat | item 数量 |
|---|---|---|
| markdown | json | = 顶层块数;**整篇含表时塌缩成 1 个**`markdown_parser.go:99-107`);空输入补 1 个占位 |
| html | json | = 有文本的块级元素数(`walkHTMLBlocks` 每块 1 项) |
| text&code | json | = 非空段数(按 `\n\n` 切;超 8192B 就近再切) |
| docx | json | = section/element 数(段落/标题/图/表各自成项) |
| xlsx/xls/csv | **默认 html** | **0 个 JSON item**(单个 HTML 字符串);走 json 路径才按行成项 |
| pptx/ppt | json | = 幻灯片数(按 `\f` 分页) |
| epub | json | = spine 条目数 |
| email | json/text | json 时 **1 个**(单个字典项,含 from/to/subject/body… |
| json | json | = 逻辑记录数(数组每元素 / 对象 / JSONL 每行) |
### item 类型(按 `doc_type_kwd`,共 3 类;全 Go 解析器 JSON 输出一致)
1. **`text`** —— 绝大多数段落、标题、列表、代码块、单元格文本、行文本、幻灯片文本、email 正文等。docx 标题项额外带 `ck_type:"heading"`
2. **`image`** —— 图片,必带 `image`(base64)。来源markdown 的 `![alt](src)` 解析(`markdown_parser.go:287-292`,含 HTTP 抓取+SSRF 防护docx 内嵌图。
3. **`table`** —— 表格,`text` 字段是 HTML 表格字符串。来源docx 表格(`docx_ir.go`、markdown 内联表(未整篇塌缩时)。
- 更细一层的 `ck_type` 词汇(仅 markdown 等部分解析器输出):`heading` / `paragraph` / `list` / `code` / `quote` / `table` / `image`。**Python flow 的 `set_output` 不带 `ck_type`**,是字段契约差异点(见 §3.1)。
### 对照 Python可比对维度
- Python flow `set_output` 的 json 路径产出同形状 `[{text, doc_type_kwd, image?, ...}]`,因此「**item 数 + 每 item 的 `text`/`doc_type_kwd`**」是两端可直接对拍的维度(即 §5 逐项/计数比对层)。
- 例外xlsx/csv 在 Go 默认 html0 个 json itemPython `_spreadsheet` 默认也是 html`ExcelParser().html`)→ 两端都是单字符串item 维度均为 0比对时按 HTML 字符串走。
---
## 2.2 Schema 模型(契约信封 / 数据结构)
§2.1 描述的是 itemJSON 列表里的元素)。本节描述「装着 item 的信封」与组件间契约——即 Parser 输入输出、配置、以及 Parser→Chunker 的桥接结构。两端刻意镜像Go `schema/parser.go` 注释多处写明 "mirrors rag/flow/parser/schema.py")。
### Go 端
- **`ParseResult`**`internal/parser/parser/parse_result.go:48`):解析器统一返回契约。
- 字段:`OutputFormat`(json/markdown/text/html)、`File`(map)、`JSON`(`[]map[string]any`)、`Markdown`/`Text`/`HTML`(string)、`Err`
-`OutputFormat=="json"``JSON` 填充(即 §2.1 的 item 列表)。
- **`schema.Page`**`internal/ingestion/component/schema/parser.go:58``Page = map[string]any`Parser 把 `JSON` 重塑后交给下游 Chunker 的载体json→每 item 一 page。注释明确它故意保持 Python `dict` 形态,字段形如 `{text, doc_type_kwd, ck_type?, image?, page_number?, positions?}``parser.go:52-57`)。
- **`ParserFromUpstream`**`parser.go:29-41`Parser 组件输入name/file/abstract/author/created_time/elapsed_time。注释写明 mirrors Python `ParserFromUpstream`
- **`ParserOutputs`**`parser.go:110-136`Parser 组件输出契约。字段 `output_format` + 四选一(`json`/`markdown`/`text`/`html`+ `file` + `_ERROR`。**直接镜像 Python `set_output` 的写入面**。
- **`ParserSetup` / `ParserParam`**`parser.go:64-94`):每文件类型的配置块 + 静态配置(含 `AllowedOutputFormat` 白名单)。`Defaults()` 注释写明**逐字复制自 Python `ParserParam.__init__`**。
- **`ChunkDoc`**(下游,`internal/ingestion/component/schema/chunk_types.go`Chunker 产出结构text/content_with_weight/doc_type_kwd/ck_type/image/page_number/positions…。属 Parser 之后的阶段,仅作上下文,不在本 scope。
### Python 端flow
- **`ParserFromUpstream`**`rag/flow/parser/schema.py:18-26`Pydantic输入契约。字段与 Go 完全一致created_time/elapsed_time/name/file/abstract/author`populate_by_name``extra="forbid"`)。
- **Parser 输出面**`rag/flow/parser/parser.py``set_output`):无单一命名 model但写入键固定为 `output_format` + 四选一(`json`/`text`/`markdown`/`html`+ `file` + `_ERROR`**与 Go `ParserOutputs` 一一对应**。json 路径写 `set_output("json", [...])`(即 §2.1 的 item 列表)。
- **`TokenChunkerFromUpstream`**`rag/flow/chunker/schema.py:20-35`Pydantic**Parser→Chunker 的桥接契约**Chunker 消费它。
- 字段:`output_format`(json/markdown/text/html/chunks)、`json_result`(别名 `json`)、`markdown_result`/`text_result`/`html_result``chunks``name`/`file`/`created_time`/`elapsed_time`
- 即 Chunker 读 `from_upstream.output_format``from_upstream.json_result`(见 `token_chunker.py:313-345`)。这正是 Go `ParseResult.JSON`/`schema.Page` 在 Python 侧的对应物。
- Chunker 输出:`TokenChunker.set_output("chunks", [{"text", "doc_type_kwd", ...}])``token_chunker.py:440` `_finalize_json_chunks`)→ 对应 Go 的 `ChunkDoc`
### 跨端结构映射表
| 概念 | Go | Python(flow) |
|---|---|---|
| 解析器返回 | `ParseResult``parse_result.go:48` | (隐式)`set_output` 写入面 |
| 组件输入 | `ParserFromUpstream``schema/parser.go:29` | `ParserFromUpstream``parser/schema.py:18` |
| 组件输出 | `ParserOutputs``schema/parser.go:110` | `set_output``output_format`+`json/text/markdown/html`+`file`+`_ERROR` |
| item 载体 / 页 | `schema.Page` = `map[string]any``schema/parser.go:58` | `TokenChunkerFromUpstream.json_result``chunker/schema.py:30` |
| 配置块 | `ParserSetup` / `ParserParam``schema/parser.go:64-94` | `ParserParam.setups` / `allowed_output_format``parser.py:71-...` |
| Chunker 输入 | `schema.Page` 列表 | `TokenChunkerFromUpstream``chunker/schema.py:20` |
| Chunker 输出 | `ChunkDoc``chunk_types.go` | `chunks: [{"text","doc_type_kwd",...}]``token_chunker.py:440` |
**对齐要点**:结构信封两端已高度镜像(`ParserFromUpstream`/`ParserOutputs`/`ParserParam` 是逐字对应),所以「对拍」的重点不在信封,而在信封内的 **item 内容/粒度/字段**§2.1 + §3。唯一需留意的是 `TokenChunkerFromUpstream` 还有 `chunks` 字段——当上游已是 chunks 时 Chunker 直接透传,这是 Python 在 Parser 之后、Chunker 之前可能已被预切块的另一证据(关联 §3.2 的切块归属讨论)。
---
## 2.3 已确认的对齐决策全局2026-08-07
以下为已拍板、跨类型生效的决策,各并行会话直接遵循,无需再议:
1. **切块所有权:保留现状**。不做 pipeline 重构——Go Parser 仍不切块、Python flow Parser 仍内嵌 token 切块html/epub 等)。不在「把切块挪到 Chunker 统一」上做改动。
2. **对比方法采用拼接对比stitch-compare**。以「拼接后内容等价」作为一级闸门(见 §5item 数 / 字段比对作为二级。验收重点是「两端 Parser 提取出相同内容」,而非 item 边界一致。
3. **块抽取粒度基准:以 Python 为准**。逐块边界(段落 / 块 / 表 / 列表续行等)的对齐目标是让 **Go 收敛到 Python 的块边界**Python 现状视为事实基准。
4. **FIXME 保留不动**`deepdoc/parser/html_parser.py:236``rag_tokenizer.tokenize` 身份未确认,暂不调查。
5. **doc / docx / pptx / xlsx 引擎差异:保留(接受差异)**。Go 用 `office_oxide`、Python 用 python-docx / pptx / excelize / tika文本与分段发散属可接受分歧记录原因即可。
6. **jsonPython flow 不补分支**。Go `json_parser.go` 为 Go 独有Python 侧声明 flow 不支持 `.json`,接受此不对称。
---
## 3. 已核实的具体分歧(带 file:line供新会话直接验证
### 3.1 Markdown最严重
- **表格塌缩**Go `markdown_parser.go:99-107``renderMarkdownTablesInline` 只要文档出现**任意一张 GFM 表格**,就把**整篇**重写为单个 item `{"text": rendered, "doc_type_kwd":"text"}`(函数见 `:136-177``changed` 为真即整篇返回。Python `separate_tables=False``parser.py:1080`)只把表格内联成 HTML 块,但**仍按块切成多个 section**。→ 同输入Go 可能 1 个大 itemPython 是 N 个。
- **ck_type 字段**Go 输出带 `ck_type`heading/paragraph/list/code`markdown_parser.go:278-280`Python `_markdown``set_output` 输出**不含** `ck_type``parser.py:1089-1101`)。→ 字段形状不同。
- **delimiter 切分**Go markdown 解析器**不做** delimiter 切分Python 把 `conf["delimiter"]` 传入 naive 解析器做二次切分(`parser.py:1081`,底层 `deepdoc/parser/markdown_parser.py:167-174, 305-345`)。→ 配置 delimiter 时行为不同。
- 图片Go `resolveMarkdownImage``![alt](src)` 内联成 base64 + `doc_type_kwd:"image"``:287-292`,含 HTTP 抓取与 SSRF 防护 `:427-457`Python 走 `return_section_images=True` + vision 增强(`parser.py:1081-1088` 附近),机制不同但目标类似。
### 3.2 HTML粒度本质不同
- **切块时机**Go `html_parser.go``walkHTMLBlocks``:109-136`)每个块级元素产 1 个 item**不切块**。Python `_html``HtmlParser()(name, blob, int(conf.get("chunk_token_num", 512)))``parser.py:1154`,阈值来自 `self._param.setups["html"]["chunk_token_num"]`**配置驱动、默认 512**html 族默认 setup `parser.py:188-193` 未设该 key默认部署下即 512`HtmlParser.chunk_block``rag_tokenizer.tokenize` 的真实 token 数累加、超阈值即切(`deepdoc/parser/html_parser.py:232-237, 287-313`)。→ Go 是原始块Python 已切成 token 块。注意 `text&code` 的同类阈值默认是 128`parser.py:1135`),两族默认值不同。
- **表格表示**Go 把单元格折叠进文本并标 `ck_type:"table"``html_parser.go:169-174`Python 把 `<table>` 原样抽成独立 item`html_parser.py:75-76`)。→ 表示方式不同。
- 两者都做 CSS 空白折叠与 `<br>` 硬换行(`html_parser.go:179-300``html_parser.py:169-178`),这部分是对齐的。
**Python flow 管线后续仍走独立 Chunker`TokenChunker``rag/flow/chunker/token_chunker.py`),但默认不是二次切分**`_html` 产出的 `sections` 已是 512-token 块(`parser.py:1157-1159` 写入 `json_result`Chunker 的 json 分支(`token_chunker.py:344-442`)默认 `delimiter_pattern` 为空(只认反引号自定义分隔符,`:70-76``_build_json_chunks` 不把 section 切更小,仅 `_merge_text_chunks_by_token_size``:432-434`,阈值默认 512做相邻合并、绝不分裂 → 默认近似透传。两处隐患(**(a) 待核实 / FIXME**(a) **Parser 与 Chunker 的 token 计数口径是否一致 —— 待核实**`TokenChunker``num_tokens_from_string``common/token_utils.py:126-132`),底层是 `tiktoken.get_encoding("cl100k_base")``common/token_utils.py:45`OpenAI GPT BPE—— 此点已确认。`HtmlParser._token_count``rag_tokenizer.tokenize``html_parser.py:236`),但 `rag_tokenizer.tokenize` 实际解析到哪个分词器**尚未确认**:原先假设为 `infinity.rag_tokenizer.RagTokenizer` 已确认不正确(见 `html_parser.py` 内 FIXME 注释)。因此「两套分词器不一致」这一结论目前**不成立/待证**,需先确认 `rag_tokenizer.tokenize` 的真实实现,再判断 Parser/Chunker 是否同一 tokenizer、以及「512 token」前后含义是否一致。(b) 若 Chunker 配了自定义分隔符,`_build_json_chunks` 会把已切好的 section 内部再切(`:120-121`= 真正的二次切分。**收敛建议**:先确认 Parser 切块用的 `rag_tokenizer.tokenize` 真实身份FIXME再决定是否需要统一口径并让 `_html` 在 Parser 阶段停止预切块、统一交给 Chunker与 Go 对齐。**收敛建议**:让 Python `_html` 在 Parser 阶段不再 token 切块(只产出原始块,切块交给 Chunker 统一做),与 Go 对齐;注意 `HtmlParser` 被 naive 路径复用,应在 flow `_html` 调用处跳过 `chunk_block` 而非改 `HtmlParser` 本身。
### 3.3 Text / Code
- Go `text_parser.go:146-175` 仅按空行 `\n\n` 分段,超 8192 字节就近按 `\n` 再切,**无 delimiter、无 token 统计**。
- Python `_code``TxtParser(..., delimiter, keep_delimiters=True)``merge_paragraphs(OVER_CAP)` token 合并(`deepdoc/parser/txt_parser.py:46-63`)。→ 段落切分完全不同。
- 注意Go 文件头注释(`text_parser.go:17-31`**自承**比 Python 更简单,因为"无生产模板依赖更丰富结构"。
### 3.4 docx / pptx / xlsx / doc引擎差异
- 这四类 Go 用 `office_oxide` 原生库Python 用 `python-docx` / `python-pptx` / `excelize`(等价物)/ `tika`。**引擎不同 → section 数量与文本本身就会不同**,即便 JSON 包裹形状类似。这是"相同结构"的根本风险点。
- docx JSON 包裹形状两侧都是 `{text, image?, doc_type_kwd}` + 表格单独 `{text, doc_type_kwd:"table"}`,且 Go 额外带 `ck_type:"heading"``docx_ir.go:buildDOCXJSONSections` 附近);结构基本对齐,但文本来源不同。
- **doc 特例**Go `doc_parser.go` 输出 `OutputFormat:"text"``office_oxide.PlainText`单字符串Python `_doc` 配置 `output_format:"json"`,产出**行列表** `{"text": line, "doc_type_kwd":"text"}``parser.py` `_doc` 分支)。→ 输出格式都不同。
### 3.5 epub / email
**epub —— 不一致(中/高风险,与 HTML §3.2 同根因,非"基本一致"**
- 切块粒度Go `extractEPUBTextItems``internal/parser/parser/epub_parser.go:197-210`)每个 spine 文档产 1 个 item整篇纯文本**不切块**。Python `_epub``rag/flow/parser/parser.py:1374-1389`)→ `EpubParser`=`deepdoc/parser/epub_parser.py:RAGFlowEpubParser``deepdoc/parser/__init__.py:18` 别名)→ 对每个 spine 文档调用 `RAGFlowHtmlParser(item_path, binary=html_bytes, chunk_token_num=512)``epub_parser.py:66`**按 512 token 切块**。→ 同输入 item 数Go = spine 数 NPython = 数十~上百 token chunk。**必然不一致**(小文档每 spine <512 token 时可能巧合一致,但粒度能力已不同)。
- 文本提取机制Go 用正则 `stripHTMLTags``:245-277`crude stripPython 用 `RAGFlowHtmlParser` 结构化解析(表格/块级)。→ `text` 内容本身也会不同。
- 降级方案:把 epub 并入会话 Bhtml一并处理根因都是"Python 在 Parser 阶段内嵌 token 切块"。
**email —— 结构接近一致(单 item但有 4 处分歧**
- 1) `.msg` 支持Python 走 `extract_msg.Message``parser.py:1316-1349`Go 显式报错(`email_parser.go:79-83`)。→ 输入 `.msg` 时 Go 直接报错、Python 出内容。**能力缺口**。
- 2) metadata 收集逻辑Python 无条件建 `metadata` 字典(`:1253`),非基本 6 字段的 header 全部进 metadata且不受 `metadata` 是否在 fields 限制;基本 6 字段from/to/cc/bcc/date/subject若不在 target_fields 则被整体丢弃(`:1255-1261`。Go 仅在 `target["metadata"]` 为真时才挂 metadata且非基本 header 受其门控(`:164-180`)。→ 同 header、不同 fields 配置下 dict 形状不同。
- 3) 附件 payload 解码Python `payload.decode(part.get_content_charset())` 无 fallback 链(`:1306`charset 缺失/失败即抛异常Go 有完整 fallback 链 utf-8→gb2312→gbk→gb18030→latin1`:345-361`)。→ 多编码附件下 Go 更稳健,结果可能不同。
- 4) `text_html` 空值处理Python 总设 `text_html`(可空串,`:1296`Go 仅非空时设(`:192-194`)。→ 字段存在性差异JSON 形状)。
### 3.6 JSONGo 独有)
- Go 有 `json_parser.go`(数组/对象/JSONL 判别,`:85-200`Python flow `function_map` / `setups` **无 json 分支**flow 不处理 `.json`。→ 若需对称Python 侧要么补分支,要么明确"flow 不支持 json",二选一并记录决策。
---
## 4. 建议的并行拆分(每个会话认领一块)
按风险等级与依赖关系,建议如下拆分(各会话独立、互不冲突,最后统一回本文档的"对齐决策表"
| 会话 | 认领类型 | 任务 |
|---|---|---|
| A | markdown | 以 Python 为准对齐块边界(表格塌缩 / ck_type / delimiter切块保留现状用拼接对比验证内容等价写对拍测试 |
| B | html+epub | 以 Python 为准对齐块边界与表格表示;切块保留现状(不重构);拼接对比验证内容等价;写对拍测试 |
| C | text&code | 以 Python 为准对齐 delimiter / 合并策略的块边界;切块保留现状;写对拍测试 |
| D | docx/pptx/xlsx/doc | 引擎差异保留(接受);记录文本/分段发散原因;补基线测试 |
| E | epub/email/json | epub 并入 Bemail 小修Go 对齐 Pythonjson 接受 Go 独有Python 不补分支);补基线测试 |
---
## 5. 每个会话的标准开工步骤(复现法)
1. **取样本**:为该类型准备 2-3 份代表性样本(含边界:表格、长段落、嵌套列表、图片引用、多 sheet 等)。
2. **跑 Go**:构造调用 `parser.GetParser(fileType).ParseWithResult(ctx, name, data)`,打印 `ParseResult.JSON`/`Text`/`HTML` 的 item 数与内容。
3. **跑 Python**:调用 `rag/flow/parser/parser.py``Parser._invoke`(或对应 `_xxx` 方法 + `set_output`),打印 `sections` 的 item 数与内容。
4. **拼接对比(一级闸门,优先做)**:把两端 items 的 `text` 按 item 顺序拼成一段归一化统一换行、折叠空白、strip更稳可再转 token 多集合)后比较。相等即「内容等价」——这是核心验收(决策见 §2.3:切块保留现状,验收重点是内容一致而非 item 边界)。
- 图片/表格项无 `text`(或表示不同),需**单独**比 item 数与存在性,不计入文本拼接。
5. **逐项 / 计数比对(二级)**:在拼接等价基础上,再比 item 数、`text``doc_type_kwd``ck_type``image` 是否存在/取值,定位剩余边界差异。**粒度基准以 Python 为准**§2.3Go 的块边界应收敛到 Python。
6. **记录决策**:在本文档 §6「对齐决策记录表」对应行写明结论与关键决策以 Python 为准 / 接受差异+原因)。
7. **补测试**在对应包加对拍测试Go 端见 `internal/parser/parser/*_test.go` 现状Python 端可在 `rag/flow/parser/` 或 test 目录),测试应以拼接对比为断言主体。
注意 Go 构建依赖:`//go:build cgo` 的文件docx/xls/xlsx/ppt/doc需要 `office_oxide` 原生库,用 `bash build.sh --test ./internal/parser/parser/...` 跑,勿直接用裸 `go test`(会缺 CGO 标志)。详见 `AGENTS.md`「Go Test Tiers」。
---
## 6. 对齐决策记录表(各会话回填)
| 类型 | 结论(一致/以X为准/接受差异) | 关键决策 | 测试位置 | 负责人/会话 |
|---|---|---|---|---|
| markdown | 以 Python 为准(粒度) | 切块保留现状;块边界 Go 收敛到 Python拼接对比为一级闸门 | 待补对拍测试(会话 A | A |
| html | 以 Python 为准(粒度) | 切块保留现状;块边界/表格表示以 Python 为准;拼接对比验证内容等价 | 待补对拍测试(会话 B | B |
| text&code | 以 Python 为准(粒度) | 切块保留现状delimiter/合并策略对齐 Python | 待补对拍测试(会话 C | C |
| docx | 接受差异(引擎) | office_oxide vs python-docx文本/分段发散保留 | 待补对拍测试(会话 D | D |
| xlsx/xls/csv | 接受差异(引擎) | office_oxide/excelize vs ExcelParser保留 | 待补对拍测试(会话 D | D |
| pptx/ppt | 接受差异(引擎) | office_oxide vs python-pptx保留 | 待补对拍测试(会话 D | D |
| doc | 接受差异(引擎) | office_oxide vs tika且输出格式不同Go text / Py json保留 | 待补对拍测试(会话 D | D |
| epub | 不一致(接受差异/并入会话B | Python 在 Parser 内嵌 512-token 切块(同 HTML §3.2Go 每 spine 1 itemPython 每 spine 多 chunk文本提取机制也不同 | 待补对拍测试(与 html 合并,会话 B | E |
| email | 基本一致需小修Go 对齐 Python | .msg接受差异Go 无 MSG 后端metadata 门控逻辑 Go 需对齐 Python附件解码 Go 更稳健接受差异text_html 空值需对齐 | 待补对拍测试(会话 E | E |
| json | 接受不对称Go 独有) | Python flow 不补 json 分支;声明 flow 不支持 .json | 无需(仅 Go 侧测试) | E |
---
## 7. 关键文件索引(快速跳转)
**Go 端**
- 分派/调用:`internal/ingestion/component/parser.go``ParserComponent.Invoke`)、`internal/ingestion/component/parser_dispatch.go``dispatchParse` / `GetParser` 入口 `parser_type.go:31`
- 解析器:`internal/parser/parser/{markdown,html,text,docx,xlsx,xls,csv,doc,pptx,epub,email,json}_parser.go`
- 输出契约:`internal/parser/parser/parse_result.go``ParseResult`)、`internal/ingestion/component/schema/parser.go``Page`/`ParserSetup`/`ParserParam`
**Python 端flow正确对应物**
- `rag/flow/parser/parser.py``Parser` 类、`_invoke``set_output``_markdown/_html/_code/_docx/_spreadsheet/_slides/_epub/_email/_doc``function_map``setups`
- 底层实现(被 flow 委托):`deepdoc/parser/{markdown,html,txt,docx,excel,ppt,epub,json}_parser.py``rag/app/naive.py``Markdown`/`Docx`/`Html` 包装 + `naive_merge*`
**切勿混淆**`internal/deepdoc/parser/docx/` 是 Go 端另一套 DOCX 解析器,**未被 ingestion Parser 组件接入**ingestion 实际用的是 `internal/parser/parser/docx_parser.go`

View File

@@ -233,6 +233,13 @@ class RAGFlowHtmlParser:
def _token_count(cls, text):
if not text:
return 0
# FIXME: The identity of the tokenizer behind `rag_tokenizer.tokenize`
# (used here to size HTML chunks) is NOT confirmed. The earlier assumption
# that it resolves to `infinity.rag_tokenizer.RagTokenizer` is incorrect.
# Verify what `rag_tokenizer.tokenize` actually resolves to before claiming
# Parser and TokenChunker use different tokenizers. For reference,
# TokenChunker uses `num_tokens_from_string` (common/token_utils.py) which
# is tiktoken cl100k_base. See PARSER_ALIGNMENT_HANDOFF.md §3.2.
tks_str = rag_tokenizer.tokenize(text)
return len(tks_str.split(" ")) if tks_str else 0

View File

@@ -175,28 +175,35 @@ func parseEML(r io.Reader, fields []string) map[string]any {
meta[keyLower] = val
}
}
if target["metadata"] {
content["metadata"] = meta
}
// Always emit metadata to match the Python flow parser contract
// (rag/flow/parser/parser.py:_email), which unconditionally builds a
// metadata dict and collects every non-basic header into it regardless
// of whether "metadata" is present in the configured fields.
content["metadata"] = meta
// Body and attachments — readMailBody walks all multipart parts
// and collects text/html bodies alongside attachment parts whose
// Content-Disposition starts with "attachment".
needAttachments := target["attachments"]
if target["body"] {
// Attachments are extracted independently of "body" (mirrors the Python
// flow parser _email, whose attachment block is separate from the body
// block). Walk the body whenever either is requested, then gate each key
// by its own field so text/text_html stay absent when "body" is not in
// fields while attachments are still extracted when "attachments" is.
if target["body"] || needAttachments {
contentType := msg.Header.Get("Content-Type")
bodyText, bodyHTML, attachments := readMailBody(msg.Body, contentType, needAttachments)
if bodyText != "" {
// Always emit text/text_html when "body" is requested, to match the
// Python flow parser contract (rag/flow/parser/parser.py:_email),
// which sets both unconditionally (empty string for a missing part)
// rather than omitting the key.
if target["body"] {
content["text"] = bodyText
}
if bodyHTML != "" {
content["text_html"] = bodyHTML
}
if needAttachments {
content["attachments"] = attachments
}
} else if needAttachments {
content["attachments"] = []map[string]any{}
}
return content

View File

@@ -291,3 +291,176 @@ func TestEmailParser_Multipart(t *testing.T) {
t.Errorf("text_html: got %q", v)
}
}
// TestEmailParser_MetadataAlwaysPresent aligns Go with the Python flow parser
// contract: metadata is emitted unconditionally and every non-basic header is
// collected into it, even when "metadata" is NOT listed in fields.
func TestEmailParser_MetadataAlwaysPresent(t *testing.T) {
ctx := t.Context()
raw := strings.Join([]string{
"From: sender@example.com",
"To: recipient@example.com",
"Cc: cc@example.com",
"Date: Mon, 07 Jul 2025 10:00:00 +0000",
"Subject: Test Email",
"Message-ID: <abc@def.example>",
"X-Custom-Header: custom-value",
"Content-Type: text/plain; charset=utf-8",
"",
"This is the body of the test email.",
}, "\r\n")
p := NewEmailParser()
p.ConfigureFromSetup(map[string]any{
"output_format": "json",
// Note: "metadata" is intentionally NOT in fields.
"fields": []string{"from", "subject", "body"},
})
result := p.ParseWithResult(ctx, "test.eml", []byte(raw))
if result.Err != nil {
t.Fatalf("unexpected error: %v", result.Err)
}
item := result.JSON[0]
// metadata must exist even though it is not in fields (Python contract).
meta, ok := item["metadata"].(map[string]any)
if !ok {
t.Fatalf("metadata missing or wrong type (must always be present): %T", item["metadata"])
}
if v, ok := meta["x-custom-header"].(string); !ok || v != "custom-value" {
t.Errorf("metadata x-custom-header: got %q", v)
}
if v, ok := meta["message-id"].(string); !ok || v != "<abc@def.example>" {
t.Errorf("metadata message-id: got %q", v)
}
if v, ok := meta["content-type"].(string); !ok || !strings.Contains(v, "text/plain") {
t.Errorf("metadata content-type: got %q", v)
}
// Basic fields requested in fields appear at top level.
if v, ok := item["from"].(string); !ok || v != "sender@example.com" {
t.Errorf("from: got %q", v)
}
if v, ok := item["subject"].(string); !ok || v != "Test Email" {
t.Errorf("subject: got %q", v)
}
// Basic fields NOT in fields are dropped (neither top-level nor metadata).
for _, dropped := range []string{"to", "cc", "date"} {
if _, ok := item[dropped]; ok {
t.Errorf("%s should be dropped when not in fields, but was present", dropped)
}
if _, ok := meta[dropped]; ok {
t.Errorf("%s should not leak into metadata when not in fields", dropped)
}
}
}
// TestEmailParser_TextHTMLAlwaysPresent aligns Go with the Python flow parser
// contract: text and text_html are always emitted (empty string when the part
// is missing), not omitted when empty.
func TestEmailParser_TextHTMLAlwaysPresent(t *testing.T) {
ctx := t.Context()
raw := strings.Join([]string{
"From: sender@example.com",
"To: recipient@example.com",
"Subject: Plain only",
"Content-Type: text/plain; charset=utf-8",
"",
"Plain body, no html part.",
}, "\r\n")
p := NewEmailParser()
p.ConfigureFromSetup(map[string]any{
"output_format": "json",
"fields": []string{"from", "body"},
})
result := p.ParseWithResult(ctx, "test.eml", []byte(raw))
if result.Err != nil {
t.Fatalf("unexpected error: %v", result.Err)
}
item := result.JSON[0]
if v, ok := item["text"].(string); !ok || !strings.Contains(v, "Plain body") {
t.Errorf("text: got %q", v)
}
// text_html must be present (empty) for a plain-text email.
v, ok := item["text_html"].(string)
if !ok {
t.Fatalf("text_html should always be present (even empty), got %T", item["text_html"])
}
if v != "" {
t.Errorf("text_html: expected empty for plain-text email, got %q", v)
}
}
// TestEmailParser_AttachmentsWithoutBody aligns Go with the Python flow
// parser contract: attachments are extracted whenever "attachments" is in
// fields, independently of whether "body" is requested. The Python _email
// attachment block is separate from the body block, so a config that selects
// only attachments must still yield them. Previously Go silently returned an
// empty list because attachment extraction was coupled to the body branch
// (the "else if needAttachments" fallback set an empty slice instead of
// walking the message).
func TestEmailParser_AttachmentsWithoutBody(t *testing.T) {
ctx := t.Context()
attachmentContent := "SECRET attachment payload"
encoded := base64.StdEncoding.EncodeToString([]byte(attachmentContent))
boundary := "mixedbound"
raw := strings.Join([]string{
"From: sender@test.com",
"To: receiver@test.com",
"Subject: Attach Test",
"Content-Type: multipart/mixed; boundary=" + boundary,
"",
"--" + boundary,
"Content-Type: text/plain; charset=utf-8",
"",
"Hello body.",
"--" + boundary,
"Content-Type: application/octet-stream; name=\"a.txt\"",
"Content-Disposition: attachment; filename=\"a.txt\"",
"Content-Transfer-Encoding: base64",
"",
encoded,
"--" + boundary + "--",
}, "\r\n")
p := NewEmailParser()
p.ConfigureFromSetup(map[string]any{
"output_format": "json",
// NOTE: "body" is intentionally NOT in fields.
"fields": []string{"from", "attachments"},
})
result := p.ParseWithResult(ctx, "test.eml", []byte(raw))
if result.Err != nil {
t.Fatalf("unexpected error: %v", result.Err)
}
item := result.JSON[0]
// text/text_html must be absent (gated by "body"), matching Python.
if _, ok := item["text"]; ok {
t.Error("text should be absent when body not in fields")
}
if _, ok := item["text_html"]; ok {
t.Error("text_html should be absent when body not in fields")
}
// attachments must be extracted even without body.
atts, ok := item["attachments"].([]map[string]any)
if !ok {
t.Fatalf("attachments missing or wrong type: %T", item["attachments"])
}
if len(atts) != 1 {
t.Fatalf("expected 1 attachment without body, got %d (bug: attachments silently dropped)", len(atts))
}
if fn, _ := atts[0]["filename"].(string); fn != "a.txt" {
t.Errorf("filename = %q, want a.txt", fn)
}
if pl, _ := atts[0]["payload"].(string); pl != attachmentContent {
t.Errorf("payload = %q, want %q", pl, attachmentContent)
}
}