From 4f15e261cd5b5d8556f39d96e7a1be790333d19e Mon Sep 17 00:00:00 2001 From: Jack Date: Fri, 7 Aug 2026 21:57:51 +0800 Subject: [PATCH] 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. --- PARSER_ALIGNMENT_HANDOFF.md | 243 ++++++++++++++++++++ deepdoc/parser/html_parser.py | 7 + internal/parser/parser/email_parser.go | 25 +- internal/parser/parser/email_parser_test.go | 173 ++++++++++++++ 4 files changed, 439 insertions(+), 9 deletions(-) create mode 100644 PARSER_ALIGNMENT_HANDOFF.md diff --git a/PARSER_ALIGNMENT_HANDOFF.md b/PARSER_ALIGNMENT_HANDOFF.md new file mode 100644 index 0000000000..0c943e63e0 --- /dev/null +++ b/PARSER_ALIGNMENT_HANDOFF.md @@ -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`); +- Python(flow):`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 默认 html(0 个 json item),Python `_spreadsheet` 默认也是 html(`ExcelParser().html`)→ 两端都是单字符串,item 维度均为 0,比对时按 HTML 字符串走。 + +--- + +## 2.2 Schema 模型(契约信封 / 数据结构) + +§2.1 描述的是 item(JSON 列表里的元素)。本节描述「装着 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)**。以「拼接后内容等价」作为一级闸门(见 §5),item 数 / 字段比对作为二级。验收重点是「两端 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. **json:Python 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 个大 item,Python 是 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 把 `` 原样抽成独立 item(`html_parser.py:75-76`)。→ 表示方式不同。 +- 两者都做 CSS 空白折叠与 `
` 硬换行(`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 数 N;Python = 数十~上百 token chunk。**必然不一致**(小文档每 spine <512 token 时可能巧合一致,但粒度能力已不同)。 +- 文本提取机制:Go 用正则 `stripHTMLTags`(`:245-277`)crude strip;Python 用 `RAGFlowHtmlParser` 结构化解析(表格/块级)。→ `text` 内容本身也会不同。 +- 降级方案:把 epub 并入会话 B(html)一并处理,根因都是"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 JSON(Go 独有) +- 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 并入 B;email 小修(Go 对齐 Python);json 接受 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.3):Go 的块边界应收敛到 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.2);Go 每 spine 1 item,Python 每 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`。 diff --git a/deepdoc/parser/html_parser.py b/deepdoc/parser/html_parser.py index 1f4b3a1a60..4381086b37 100644 --- a/deepdoc/parser/html_parser.py +++ b/deepdoc/parser/html_parser.py @@ -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 diff --git a/internal/parser/parser/email_parser.go b/internal/parser/parser/email_parser.go index 6952225710..3fe86f5cf7 100644 --- a/internal/parser/parser/email_parser.go +++ b/internal/parser/parser/email_parser.go @@ -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 diff --git a/internal/parser/parser/email_parser_test.go b/internal/parser/parser/email_parser_test.go index 610f9fc3ba..aeeb83095c 100644 --- a/internal/parser/parser/email_parser_test.go +++ b/internal/parser/parser/email_parser_test.go @@ -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: ", + "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 != "" { + 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) + } +}